import CocoaLumberjackSwift
import Combine
import CoreData
import PromiseKit
import ThreemaFramework
import ThreemaMacros
protocol ChatViewDataSourceDelegate: AnyObject {
/// Called before a new snapshot is applied
///
/// This is always called on the main queue. This might be called multiple times before the corresponding
/// `didApplySnapshot()` is called.
///
/// - Parameter currentDoesIncludeNewestMessage: Should the current snapshot include the newest message? (`false` if
/// this is the first snapshot)
/// - Returns: A block which should be executed together with `apply` in a single UIView.animate block.
func willApplySnapshot(currentDoesIncludeNewestMessage: Bool) -> (() -> Void)?
/// Called after a new snapshot was applied
///
/// This is always called on the main queue.
func didApplySnapshot(delegateScrollCompletion: @escaping (() -> Void))
func lastMessageChanged(messageIdentifier: NSManagedObjectID)
func didDeleteMessages()
func checkToShowReactionsTip()
var willDisappear: Bool { get }
}
/// Manage the table view data for a chat view
///
/// The data sources uses a `MessageProvider` to load messages and `ChatViewCellProvider` for cell registration, loading
/// and configuration.
final class ChatViewDataSource: UITableViewDiffableDataSource<String, ChatViewDataSource.CellType> {
typealias Config = ChatViewConfiguration.DataSource
struct MessageNeighbors {
let previousMessage: BaseMessageEntity?
let nextMessage: BaseMessageEntity?
static let noNeighbors = MessageNeighbors(previousMessage: nil, nextMessage: nil)
}
enum CellType: Hashable {
case message(objectID: NSManagedObjectID)
case unreadLine(state: UnreadMessagesStateManager.UnreadMessagesState)
case typingIndicator
}
enum DataSourceError: Error {
/// Used to cancel message load requests
case cancelled
}
/// Last index path of the data
var bottomIndexPath: IndexPath? {
let snapshot = snapshot()
let sectionIndex = snapshot.numberOfSections - 1
// This should fail if we have an empty table view
guard sectionIndex >= 0 else {
return nil
}
let lastSectionIdentifier = snapshot.sectionIdentifiers[sectionIndex]
let lastItemInSectionIndex = snapshot.numberOfItems(inSection: lastSectionIdentifier) - 1
guard lastItemInSectionIndex >= 0 else {
return nil
}
return IndexPath(item: lastItemInSectionIndex, section: sectionIndex)
}
var topIndexPath: IndexPath? {
let snapshot = snapshot()
let sectionIndex = snapshot.numberOfSections - 1
// This should fail if we have an empty table view
guard sectionIndex >= 0 else {
return nil
}
return IndexPath(row: 0, section: 0)
}
private var snapshotApplyTiming = CFAbsoluteTimeGetCurrent()
/// Is the initial setup completed (e.g. scroll position restored)
///
/// Set to true after the initial setup of messages loading is completed.
var initialSetupCompleted = false {
didSet {
if initialSetupCompleted {
DDLogVerbose("Initial setup completed!")
delegate?.checkToShowReactionsTip()
}
}
}
/// Keep track if the previous snapshot contained the newest messages
/// This is needed by the chat view to decide if we scrolled all the way to the bottom
var previouslyNewestMessagesLoaded = false
/// Is the data source in the process of loading (more) messages?
///
/// Only set after `initialSetupCompleted` is `true`
private(set) var isLoadingNewMessages = false
/// Used for ensuring mutual exclusion between animated scrolling and applying new snapshots
///
/// `apply(_:animatingDifferences:)` will cancel in progress animations such as animated scrolling to the newest
/// cell.
/// We therefore do not want to apply new snapshots while we're scrolling to the newest cell in `didApplySnapshot`.
/// This may be used on other instances as well but be wary of deadlocks.
let snapshotApplyLock = NSLock()
// MARK: - Private properties
private let conversation: ConversationEntity
private weak var delegate: ChatViewDataSourceDelegate?
private weak var chatViewTableViewCellDelegate: ChatViewTableViewCellDelegateProtocol?
private weak var chatViewTableViewVoiceMessageCellDelegate: ChatViewTableViewVoiceMessageCellDelegateProtocol?
private let entityManager: EntityManager
private let messageProvider: MessageProvider
private let unreadMessagesSnapshot: UnreadMessagesStateManager
private lazy var snapshotProvider = ChatViewSnapshotProvider(
conversation: conversation,
entityManager: entityManager,
messageProvider: messageProvider,
unreadMessagesSnapshot: unreadMessagesSnapshot,
delegate: self,
userSettings: UserSettings.shared()
)
private let dataSourceApplyQueue = DispatchQueue(
label: "ch.threema.chatView.dataSource.applyQueue",
qos: .userInteractive,
attributes: [],
autoreleaseFrequency: .inherit,
target: nil
)
private let preDebounceDataSourceApplyQueue = DispatchQueue(
label: "ch.threema.chatView.dataSource.preDebounceApplyQueue",
qos: .userInteractive,
attributes: [],
autoreleaseFrequency: .inherit,
target: nil
)
/// `lastMessage` contains the last message in the requested snapshot
private var fetchRequestSnapshotApplyStore: (lastMessage: NSManagedObjectID?, seal: Resolver<Void>)?
private var bottomIdentifier: NSManagedObjectID? {
guard let bottomIndexPath else {
return nil
}
guard case let .message(objectID) = itemIdentifier(for: bottomIndexPath) else {
return nil
}
return objectID
}
private lazy var chatViewTypingIndicatorInformationProvider: ChatViewTypingIndicatorInformationProvider? =
ChatViewTypingIndicatorInformationProvider(
conversation: conversation,
entityManager: entityManager
)
private var afterFirstSnapshotApply: (() -> Void)?
private var observers = [NSManagedObjectID: NSKeyValueObservation]()
private var cancellables = Set<AnyCancellable>()
private lazy var selectedObjectIDs = Set<NSManagedObjectID>()
/// Object IDs of all deleted messages using multiselect since the creation of the data source (i.e. opening a chat)
///
/// This is also needed for `ChatViewSnapshotProviderDelegate` conformance
lazy var deletedMessagesObjectIDs = Set<NSManagedObjectID>()
// MARK: - Lifecycle
/// Crate a new chat view data source
/// - Parameters:
/// - conversation: Messages of this conversation will be provided by this data source
/// - tableView: Table view that will display the messages
/// - delegate: `ChatViewDataSourceDelegate` that is informed about certain changes
/// - chatViewTableViewCellDelegate: `ChatViewTableViewCellDelegateProtocol` that is informed about cell delegates
/// - chatViewTableViewVoiceMessageCellDelegate: `ChatViewTableViewVoiceMessageCellDelegate` that is informed
/// about voice message changes
/// - entityManager: Entity manager used to fetch messages from the object store
/// - date: Date to load messages around. If `nil` newest messages are loaded.
/// - afterFirstSnapshotApply: Closure called only on first apply to data source snapshot (this will be called on
/// the main queue)
init(
for conversation: ConversationEntity,
in tableView: UITableView,
delegate: ChatViewDataSourceDelegate,
chatViewTableViewCellDelegate: ChatViewTableViewCellDelegateProtocol,
chatViewTableViewVoiceMessageCellDelegate: ChatViewTableViewVoiceMessageCellDelegateProtocol,
entityManager: EntityManager,
loadAround date: () -> Date?,
afterFirstSnapshotApply: @escaping () -> Void,
unreadMessagesSnapshot: UnreadMessagesStateManager
) {
self.conversation = conversation
self.delegate = delegate
self.entityManager = entityManager
self.chatViewTableViewCellDelegate = chatViewTableViewCellDelegate
self.chatViewTableViewVoiceMessageCellDelegate = chatViewTableViewVoiceMessageCellDelegate
self.afterFirstSnapshotApply = afterFirstSnapshotApply
self.unreadMessagesSnapshot = unreadMessagesSnapshot
// Workaround to fix circular `self` dependency during initialization
// For the super class's initializer we need a `CellProvider` that needs to reference the message provider.
// As `self` is not available before the super call we need to workaround this circular dependency.
// Solved by steps 1 - 3 below:
// 1. Create message provider stored in a local constant (needed) that can be referenced in the `CellProvider`
let messageProvider = MessageProvider(
for: conversation,
around: date(),
entityManager: entityManager
)
self.messageProvider = messageProvider
// Ensure that cells will be available in our cell provider
ChatViewCellProvider.registerCells(in: tableView)
let chatViewCellProvider = ChatViewCellProvider(
chatViewTableViewCellDelegate: chatViewTableViewCellDelegate,
chatViewTableViewVoiceMessageCellDelegate: chatViewTableViewVoiceMessageCellDelegate
)
// 2. Setup class and use message provider in this call
super.init(tableView: tableView) { tableView, indexPath, cellType in
// TODO: (IOS-2014) Is it a problem that we load the message by object id and not
// by index path? We don't take advantage of batch fetching here, but it might never happen anyway.
switch cellType {
case let .message(objectID: messageObjectID):
guard let message = messageProvider.message(for: messageObjectID) else {
// This might happen if a delayed* snapshot apply still contains a message that was deleted in the
// meantime (e.g. when a group is dissolved and deleted at the "same" time).
// * the delay might be from us or from the apply API itself
DDLogError("Unable to load requested message. Show close to zero cell instead")
return chatViewCellProvider.closeToZeroHeightCell(in: tableView, at: indexPath)
}
let neighbors = ChatViewDataSource.neighbors(of: messageObjectID, in: tableView, with: entityManager)
let cell = chatViewCellProvider.cell(
for: message,
with: neighbors,
in: tableView,
at: indexPath
)
return cell
case .typingIndicator:
return chatViewCellProvider.typingIndicator(in: tableView, at: indexPath)
case let .unreadLine(state: state):
let cell = chatViewCellProvider.unreadMessageLine(
with: state.numberOfUnreadMessages,
in: tableView,
at: indexPath
)
return cell
}
}
// Do our configuration
configureDataSource()
// 3. Load initial messages and observe changes
loadInitialMessagesAndObserveChanges()
}
deinit {
DDLogVerbose("\(#function)")
self.fetchRequestSnapshotApplyStore?.seal.reject(DataSourceError.cancelled)
// `observers` holds KVO observations on `NSManagedObject`s. Invalidating (and deallocating)
// an observation touches its observed managed object, which is only allowed on the object's
// context queue (main). `deinit` – and the implicit ivar destruction that follows it – can
// run on a background queue (e.g. `snapshotProviderQueue`) when the data source is released
// while converting a snapshot during deletion, which trips CoreData's multithreaded-access
// assertion.
//
// Move the observations into a local and invalidate them on the main queue. Capturing the
// local (not `self`, which can't be retained from `deinit`) keeps the backing storage alive
// past the ivar destructor, so the observations are only released on the main queue.
let observersToInvalidate = observers
let invalidateObservers = {
for observer in observersToInvalidate.values {
observer.invalidate()
}
}
if Thread.isMainThread {
invalidateObservers()
}
else {
DispatchQueue.main.async(execute: invalidateObservers)
}
}
@available(*, unavailable)
override init(
tableView: UITableView,
cellProvider: @escaping UITableViewDiffableDataSource<String, CellType>.CellProvider
) {
fatalError("Not supported")
}
// MARK: - Configure
private func configureDataSource() {
// TODO: (IOS-2014) Maybe add an empty snapshot to not crash if there was no snapshot so far
}
private func loadInitialMessagesAndObserveChanges() {
// The actual subscription is handled by the `Subscriber` extension below
snapshotProvider.$snapshotInfo
.receive(on: preDebounceDataSourceApplyQueue)
.compactMap(\.self)
.debounceSnapshots(scheduler: dataSourceApplyQueue)
.receive(on: dataSourceApplyQueue)
.flatMap(maxPublishers: .max(1)) { [weak self] value -> Future<Void, Never> in
Future<Void, Never> { [weak self] promise in
guard let strongSelf = self else {
return
}
// Ensure that we don't try to apply a snapshot with deleted messages in it
let deletesMessagesObjectIDsSet = Set(strongSelf.deletedMessagesObjectIDs.map {
CellType.message(objectID: $0)
})
let snapshotItemIdentifiersSet = Set(value.snapshot.itemIdentifiers)
guard deletesMessagesObjectIDsSet.intersection(snapshotItemIdentifiersSet).isEmpty else {
DDLogWarn("Tried to apply a snapshot with deleted messages. Refetch now...")
strongSelf.messageProvider.refetch()
promise(.success(()))
return
}
strongSelf.apply(snapshotInfo: value)
// Delay next run after the first snapshot apply
let delay = Config.currentMessageSnapshotDelay
strongSelf.dataSourceApplyQueue.asyncAfter(deadline: .now() + .milliseconds(delay)) {
promise(.success(()))
}
}
}.sink { $0 }.store(in: &cancellables)
}
/// Must always be called on `dataSourceApplyQueue`
/// - Parameter snapshotInfo: Snapshot info to be applied
private func apply(snapshotInfo: ChatViewSnapshotProvider.SnapshotInfo) {
DDLogVerbose("apply async")
guard !conversation.willBeDeleted else {
DDLogWarn("Conversation is being deleted, skipping snapshot apply")
return
}
DDLogVerbose("\(#function) dataSource.snapshotApplyLock.lock()")
if !snapshotApplyLock.lock(before: Date().addingTimeInterval(5)) {
let msg = "Could not take lock. This is a fatal error in strict mode. Continue in non-strict mode."
guard let delegate, !delegate.willDisappear else {
DDLogVerbose("Nevermind we have actually already disappeared")
return
}
if ChatViewConfiguration.strictMode {
fatalError(msg)
}
else {
DDLogError("\(msg)")
assertionFailure()
}
}
DDLogVerbose("apply start")
let prevSnapshotApplyTiming = snapshotApplyTiming
let startTime = CFAbsoluteTimeGetCurrent()
snapshotApplyTiming = startTime
let snapshotWillApplyDoneDispatchGroup = DispatchGroup()
snapshotWillApplyDoneDispatchGroup.enter()
// Block which will be executed together with `apply` in a single animation
var snapshotApplyAnimateBlockBlock: (() -> Void)?
DispatchQueue.main.async {
DDLogVerbose("willApplySnapshot")
snapshotApplyAnimateBlockBlock = self.delegate?.willApplySnapshot(
currentDoesIncludeNewestMessage: snapshotInfo.previouslyNewestMessagesLoaded
)
if let snapshotChanged = snapshotInfo.snapshotChanged {
self.delegate?.lastMessageChanged(messageIdentifier: snapshotChanged)
}
snapshotWillApplyDoneDispatchGroup.leave()
}
if snapshotWillApplyDoneDispatchGroup.wait(timeout: .now() + .seconds(15)) == .timedOut {
guard let delegate, !delegate.willDisappear else {
DDLogVerbose("Nevermind we have actually already disappeared")
return
}
let msg =
"Could not call willApplySnapshot in a timely manner. This is a fatal error in strict mode. Continue in non-strict mode."
if ChatViewConfiguration.strictMode {
fatalError(msg)
}
else {
DDLogError("\(msg)")
#if DEBUG
raise(SIGINT)
#endif
}
}
let snapshotApplyDoneDispatchGroup: DispatchGroup? =
if snapshotInfo.mustWaitForApply {
DispatchGroup()
}
else {
nil
}
snapshotApplyDoneDispatchGroup?.enter()
isLoadingNewMessages = false
// Update the default Row animation based on snapshot info
defaultRowAnimation = snapshotInfo.rowAnimation
// A fun observation on shouldAnimate. If we set `shouldAnimate` to false and add lots of messages at the top,
// then the UITableView will not detect the new contentOffset, which should increase by a lot because there are
// now many cells on top, and keeps it approximately at the current value.
// This causes our load at top detection to trigger again which in turn then makes the chat view jump to an
// unrelated scroll position further up.
let shouldAnimate = snapshotInfo.rowAnimation != .none
// Block calling the apply function of the data source
// This must only be used directly below and must be called exactly *once*.
let applyBlock = {
// Apply should always be called on the main thread (IOS-4490)
assert(Thread.isMainThread)
self.apply(snapshotInfo.snapshot, animatingDifferences: shouldAnimate) {
DDLogVerbose("Did apply new snapshot")
assert(Thread.isMainThread)
self.previouslyNewestMessagesLoaded = snapshotInfo.previouslyNewestMessagesLoaded
self.snapshotApplyLock.unlock()
self.delegate?.didApplySnapshot {
DDLogVerbose("Leaving DispatchGroup")
snapshotApplyDoneDispatchGroup?.leave()
}
}
}
// Choreograph snapshot apply and subsequent scroll to bottom
// Resolves points four and five in IOS-3613
//
// When sending two messages in quick succession we would observe the scroll
// position to be incorrectly set when the first message would hide its date and state view.
// Instead of keeping at the very bottom we would scroll up by about the height of the date and state view.
// It is unclear why this would happen, but we believe that this is coming from UITableView trying to keep a
// "good" scroll position but failing in this case since we want to be at the very bottom.
//
// Simply adding an additional scroll to bottom call in didApplySnapshot of ChatViewController did result in a
// visible scroll up and then down animation. To avoid this we move the previous implementation of snapshot
// apply and a call to scrollToBottom (`snapshotApplyAnimateBlockBlock`) into a single UIView.animate animation
// block.
DispatchQueue.main.async {
let previousSnapshot = self.snapshot()
if let snapshotApplyAnimateBlockBlock,
previousSnapshot.numberOfItems == snapshotInfo.snapshot.numberOfItems ||
ChatViewDataSource.nextRemovesOrAddsUnreadMessageLine(
current: previousSnapshot,
next: snapshotInfo.snapshot
) || ChatViewDataSource
.nextRemovesTypingIndicator(current: previousSnapshot, next: snapshotInfo.snapshot) {
UIView.animate(withDuration: Config.animationDuration, animations: {
applyBlock()
snapshotApplyAnimateBlockBlock()
})
}
else {
applyBlock()
}
}
/// The scrolling animation may be cancelled by layout changes.
/// If the scrolling animation is cancelled the completion handler of `didApplySnapshot` is never called
/// and we run into the timeout here. This causes noticeable delays if the timeout is large.
/// We should avoid any layout passes as much as possible in the chat view controller.
let wait = snapshotApplyDoneDispatchGroup?.wait(timeout: .now() + .seconds(15))
if wait == .timedOut {
guard let delegate, !delegate.willDisappear else {
DDLogVerbose("Nevermind we have actually already disappeared")
return
}
/// This might fail if slow animations are enabled
let msg = "Scroll completion not called within n seconds. Assume that something is wrong."
if ChatViewConfiguration.strictMode {
fatalError(msg)
}
else {
DDLogError("\(msg)")
#if DEBUG
raise(SIGINT)
#endif
}
return
}
/// In order to signal the application of calls to `loadMessages` (see below) we check if the currently applied
/// snapshot has the same identifier as the last (not cancelled) call to one of the load messages methods.
///
/// If we cannot signal completion but have a fetch request waiting for application we cancel it.
let lastMessage = snapshotInfo.snapshot.itemIdentifiers.last
if case let .message(objectID) = lastMessage,
let fetchRequestSnapshotApplyStore {
if fetchRequestSnapshotApplyStore.lastMessage == objectID {
fetchRequestSnapshotApplyStore.seal.fulfill_()
self.fetchRequestSnapshotApplyStore = nil
}
else {
DDLogWarn(
"Cancel previous pending load request because it doesn't match what we have loaded."
)
fetchRequestSnapshotApplyStore.seal.reject(DataSourceError.cancelled)
self.fetchRequestSnapshotApplyStore = nil
}
}
else {
DDLogWarn("Cancel previous pending load request because it doesn't match what we have loaded.")
fetchRequestSnapshotApplyStore?.seal.reject(DataSourceError.cancelled)
fetchRequestSnapshotApplyStore = nil
}
/// On the first publish we want to signal completion of the first snapshotApply
DispatchQueue.main.async {
if self.afterFirstSnapshotApply != nil {
self.afterFirstSnapshotApply?()
self.afterFirstSnapshotApply = nil
}
}
let endTime = CFAbsoluteTimeGetCurrent()
DDLogVerbose("Duration of this snapshot apply \(endTime - startTime) s")
DDLogVerbose("Time between last snapshot apply and this one \(endTime - prevSnapshotApplyTiming) s")
}
// MARK: - Helper Functions
static func neighbors(
of messageObjectID: NSManagedObjectID,
in tableView: UITableView,
with entityManager: EntityManager = BusinessInjector.ui.entityManager
) -> ChatViewDataSource.MessageNeighbors {
guard let dataSource = tableView.dataSource as? UITableViewDiffableDataSource<String, CellType> else {
return .noNeighbors
}
let snapshot = dataSource.snapshot()
guard let index = snapshot.indexOfItem(.message(objectID: messageObjectID)) else {
return .noNeighbors
}
var previousMessage: BaseMessageEntity?
var nextMessage: BaseMessageEntity?
if index - 1 >= 0, case let .message(objectID: prevObjectID) = snapshot.itemIdentifiers[index - 1] {
previousMessage = entityManager.entityFetcher.existingObject(with: prevObjectID) as? BaseMessageEntity
}
if index + 1 < snapshot.itemIdentifiers.count,
case let .message(objectID: nextObjectID) = snapshot.itemIdentifiers[index + 1] {
nextMessage = entityManager.entityFetcher.existingObject(with: nextObjectID) as? BaseMessageEntity
}
return ChatViewDataSource.MessageNeighbors(previousMessage: previousMessage, nextMessage: nextMessage)
}
static func nextRemovesOrAddsUnreadMessageLine(
current: NSDiffableDataSourceSnapshot<String, ChatViewDataSource.CellType>,
next: NSDiffableDataSourceSnapshot<String, ChatViewDataSource.CellType>
) -> Bool {
let currentHasUnreadMessageLine = current.itemIdentifiers.reversed().contains(where: { cellType in
if case .unreadLine(state: _) = cellType {
true
}
else {
false
}
})
let nextHasUnreadMessageLine = next.itemIdentifiers.reversed().contains(where: { cellType in
if case .unreadLine(state: _) = cellType {
true
}
else {
false
}
})
return currentHasUnreadMessageLine && !nextHasUnreadMessageLine || !currentHasUnreadMessageLine &&
nextHasUnreadMessageLine
}
static func nextRemovesTypingIndicator(
current: NSDiffableDataSourceSnapshot<String, ChatViewDataSource.CellType>,
next: NSDiffableDataSourceSnapshot<String, ChatViewDataSource.CellType>
) -> Bool {
let currentHasUnreadMessageLine = current.itemIdentifiers.reversed().contains(where: { cellType in
if cellType == .typingIndicator {
true
}
else {
false
}
})
let nextHasUnreadMessageLine = next.itemIdentifiers.reversed().contains(where: { cellType in
if cellType == .typingIndicator {
true
}
else {
false
}
})
return currentHasUnreadMessageLine && !nextHasUnreadMessageLine &&
abs(current.numberOfItems - next.numberOfItems) == 1
}
public func startObservingMessage(with id: NSManagedObjectID) {
guard let message = message(for: id) else {
return
}
observeMessageIfNeeded(message: message, keyPath: \.messageMarkers?.star) { [weak self] in
guard !message.willBeDeleted, message.deletedAt == nil else {
return
}
self?.snapshotProvider.applyAdditionalSnapshotForMessage(with: message.objectID)
}
}
public func stopObservingMessage(with id: NSManagedObjectID) {
guard let messageObserver = observers[id] else {
return
}
messageObserver.invalidate()
observers.removeValue(forKey: id)
}
/// Helper to add observers to the `message` property
///
/// All observers are stored in the `observers` property.
///
/// - Parameters:
/// - keyPath: Key path in `BaseMessage` to observe
/// - changeHandler: Handler called on each observed change.
/// Don't forget to capture `self` weakly! Dispatched on the main queue.
private func observeMessageIfNeeded(
message: BaseMessageEntity,
keyPath: KeyPath<BaseMessageEntity, some Any>,
changeHandler: @escaping () -> Void
) {
guard !observers.keys.contains(message.objectID) else {
return
}
let observer = message.observe(keyPath) { _, _ in
// Because `changeHandler` updates UI elements we need to ensure that it runs on the main queue
DispatchQueue.main.async(execute: changeHandler)
}
observers[message.objectID] = observer
}
// MARK: - Multi-Select
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
// For performance, we only check if table view is actually in edit mode
guard tableView.isEditing else {
return false
}
guard let cellType = itemIdentifier(for: indexPath),
case let CellType.message(objectID: objectID) = cellType else {
return false
}
var canEdit = true
entityManager.performAndWait {
guard let message = self.entityManager.entityFetcher.existingObject(with: objectID) else {
canEdit = false
return
}
if let message = message as? SystemMessageEntity,
case SystemMessageEntity.SystemMessageType.workConsumerInfo = message.systemMessageType {
canEdit = false
}
}
return canEdit
}
/// Adds object ID of cell at index path to selected cells
/// - Parameter indexPath: Index path of cell to be selected
public func didSelectRow(at indexPath: IndexPath) {
guard let cellType = itemIdentifier(for: indexPath) else {
return
}
if case let CellType.message(objectID: objectID) = cellType {
selectedObjectIDs.insert(objectID)
}
}
/// Removes object ID of cell at index path from selected cells
/// - Parameter indexPath: Index path of cell to be deselected
public func didDeselectRow(at indexPath: IndexPath) {
guard let cellType = itemIdentifier(for: indexPath) else {
return
}
if case let CellType.message(objectID: objectID) = cellType {
selectedObjectIDs.remove(objectID)
}
}
/// Checks if objectID belongs to a selected message
/// - Parameter objectID: NSManagedObjectID of message
/// - Returns: Bool if is selected
public func isSelected(objectID: NSManagedObjectID) -> Bool {
selectedObjectIDs.contains(objectID)
}
/// Returns whether there are currently selected object IDs
/// - Returns: Bool
public func hasSelectedObjectIDs() -> Bool {
!selectedObjectIDs.isEmpty
}
/// Returns the count of the currently selected object IDs
/// - Returns: Count as Int
public func selectedObjectIDsCount() -> Int {
selectedObjectIDs.count
}
/// Deletes the currently selected messages
public func deleteSelectedMessages() {
guard !selectedObjectIDs.isEmpty else {
return
}
// Keep track of all deleted messages IDs
deletedMessagesObjectIDs = deletedMessagesObjectIDs.union(selectedObjectIDs)
entityManager.performAndWaitSave {
var deletedCount = 0
for objectID in self.selectedObjectIDs {
guard let message = self.entityManager.entityFetcher
.existingObject(with: objectID) as? BaseMessageEntity
else {
continue
}
self.entityManager.entityDestroyer.delete(baseMessage: message)
deletedCount += 1
}
self.showDeletedNotification(count: deletedCount)
}
deselectAllMessages()
conversation.updateLastDisplayMessage(with: entityManager)
delegate?.didDeleteMessages()
}
/// Call before deleting all message in this chat if you do the deletion yourself and don't call
/// `deleteAllMessages()`
public func willDeleteAllMessages() {
let messageObjectIDsInCurrentSnapshot: [NSManagedObjectID] = snapshot().itemIdentifiers.compactMap { cellType in
guard case let CellType.message(objectID: messageObjectID) = cellType else {
return nil
}
return messageObjectID
}
// In rare cases we might miss a message here if the deleted message is already queues up and not published at
// this point. Then there might be two following updates where in the second update the cell might be
// reconfigured to another cell leading to a crash.
deletedMessagesObjectIDs = deletedMessagesObjectIDs.union(messageObjectIDsInCurrentSnapshot)
}
/// Deletes all messages in conversation
public func deleteAllMessages() {
guard selectedObjectIDs.isEmpty else {
DDLogError("[ChatViewDataSource] SelectedObjectIDs was not empty when deleteAllMessages() was called.")
return
}
willDeleteAllMessages()
entityManager.performAndWaitSave {
let count = self.entityManager.entityDestroyer.deleteMessages(of: self.conversation)
self.showDeletedNotification(count: count)
}
deselectAllMessages()
conversation.updateLastDisplayMessage(with: entityManager)
delegate?.didDeleteMessages()
}
/// Deselects all currently selected messages
public func deselectAllMessages() {
selectedObjectIDs.removeAll()
}
private func showDeletedNotification(count: Int) {
let type = NotificationPresenterType(notificationText: String.localizedStringWithFormat(
#localize("notification_deleted_messages_count"),
count
), notificationStyle: .success)
NotificationPresenterWrapper.shared.present(type: type)
}
// MARK: - Message Load Requests
/// For all message load requests the promise resolves as soon as a snapshot containing the same last message as the
/// fetchRequest has been applied.
/// It is either fulfilled or cancelled if the last message is different.
/// If a newer fetchRequest has been added it either cancels earlier fetchRequests or cancels itself.
///
/// Additionally, the promise may be cancelled if the next applied fetch request does not contain the same last
/// message. The message may however be loaded nevertheless. Cancellation does *not* mean that the message is not
/// present in the last, currently or next applied snapshot.
/// Load message
/// - Parameter objectID: Object ID of message to load
/// - Returns: Loaded message if there was any for this object ID
func message(for objectID: NSManagedObjectID) -> BaseMessageEntity? {
messageProvider.message(for: objectID)
}
/// Load messages at the top. This happens asynchronously.
///
/// Not every call leads to new messages as we don't start a new request if one is currently running.
///
/// - Returns: See comment at the top of the section about when exactly this promise is resolved. Approximate tl;dr
/// fulfilled if this exact fetchRequest had its snapshot applied; cancelled otherwise
@discardableResult func loadMessagesAtTop() -> Promise<Void> {
DDLogVerbose("Trying to load more messages at top...")
return loadMessagesIfAllowed(with: messageProvider.loadMessagesAtTop)
}
/// Load messages at the bottom. This happens asynchronously.
///
/// Not every call leads to new messages as we don't start a new request for a while after a previous call.
///
/// - Returns: See comment at the top of the section about when exactly this promise is resolved. Approximate tl;dr
/// fulfilled if this exact fetchRequest had its snapshot applied; cancelled otherwise
@discardableResult func loadMessagesAtBottom() -> Promise<Void> {
DDLogVerbose("Trying to load more messages at bottom...")
return loadMessagesIfAllowed(with: messageProvider.loadMessagesAtBottom)
}
/// Load messages around `date`
///
/// This might replace already loaded messages.
///
/// - Parameter date: Date to load messages around
/// - Returns: See comment at the top of the section about when exactly this promise is resolved. Approximate tl;dr
/// fulfilled if this exact fetchRequest had its snapshot applied; cancelled otherwise
func loadMessages(around date: Date) -> Promise<Void> {
DDLogVerbose("Trying to load more messages around \(date)...")
return loadMessagesIfAllowed {
self.messageProvider.loadMessages(around: date)
}
}
/// Load newest messages (at the bottom)
///
/// This also succeeds during setup. This might replace all already loaded messages. Use this to scroll all the way
/// to the bottom.
///
/// - Returns: See comment at the top of the section about when exactly this promise is resolved. Approximate tl;dr
/// fulfilled if this exact fetchRequest had its snapshot applied; cancelled otherwise
func loadNewestMessages() -> Promise<Void> {
DDLogVerbose("Load newest messages...")
return loadMessages(with: messageProvider.loadNewestMessages, cancellable: false)
}
/// Load oldest messages (at the top)
///
/// - Returns: See comment at the top of the section about when exactly this promise is resolved. Approximate tl;dr
/// fulfilled if this exact fetchRequest had its snapshot applied; cancelled otherwise
func loadOldestMessages() -> Promise<Void> {
DDLogVerbose("Load oldest messages...")
return loadMessagesIfAllowed(with: messageProvider.loadOldestMessages, cancellable: false)
}
// MARK: - Private load helper
/// Execute `loadRequest` if no-one is running at this point
///
/// - Parameter loadRequest: Load request that might be executed
/// - Returns: See comment at the top of the section about when exactly this promise is resolved. Approximate tl;dr
/// fulfilled if this exact fetchRequest had its snapshot applied; cancelled otherwise
private func loadMessagesIfAllowed(
with loadRequest: @escaping () -> Guarantee<NSManagedObjectID?>,
cancellable: Bool = true
)
-> Promise<Void> {
guard initialSetupCompleted, !isLoadingNewMessages || fetchRequestSnapshotApplyStore == nil else {
DDLogVerbose("Skip loading...")
return Promise { $0.reject(DataSourceError.cancelled) }
}
DDLogVerbose("Load more messages...")
return loadMessages(with: loadRequest, cancellable: cancellable)
}
/// Execute load request
///
/// - Parameter loadRequest: Load request to execute
/// - Returns: See comment at the top of the section about when exactly this promise is resolved. Approximate tl;dr
/// fulfilled if this exact fetchRequest had its snapshot applied; cancelled otherwise
private func loadMessages(
with loadRequest: @escaping () -> Guarantee<NSManagedObjectID?>,
cancellable: Bool
) -> Promise<Void> {
firstly {
self.isLoadingNewMessages = true
return loadRequest()
}.then { (objectID: NSManagedObjectID?) -> Promise<Void> in
defer { self.isLoadingNewMessages = false }
guard let objectID, self.initialSetupCompleted else {
DDLogVerbose("No new messages loaded, fulfill immediately")
return Promise { $0.fulfill_() }
}
if let seal = self.fetchRequestSnapshotApplyStore?.seal {
if cancellable {
DDLogVerbose("Cancelled loadMessages request bc. another request is still pending")
return Promise { $0.reject(DataSourceError.cancelled) }
}
else {
DDLogVerbose("Cancelling running loadMessages request bc. it has been overwritten by a newer one.")
seal.reject(DataSourceError.cancelled)
}
}
return Promise { seal in
self.fetchRequestSnapshotApplyStore = (objectID, seal)
}
}
}
func removeUnreadMessageLine() {
snapshotProvider.removeUnreadMessageLine()
}
/// Reconfigures all existing cells
func reconfigure() {
dataSourceApplyQueue.async {
var current = self.snapshot()
current.reconfigureItems(current.itemIdentifiers)
// Apply should always be called on the main thread (IOS-4490)
DispatchQueue.main.async {
assert(Thread.isMainThread)
self.apply(current)
}
}
}
/// Reconfigures the cells for all items in `items` if they exist
/// - Parameter items:
func reconfigure(_ items: [NSManagedObjectID]) {
dataSourceApplyQueue.async {
var current = self.snapshot()
var cells = [CellType]()
for item in items {
if current.itemIdentifiers.contains(where: { $0 == .message(objectID: item) }) {
cells.append(.message(objectID: item))
}
}
current.reconfigureItems(cells)
// Apply should always be called on the main thread (IOS-4490)
DispatchQueue.main.async {
assert(Thread.isMainThread)
self.apply(current)
}
}
}
}
// MARK: - ChatViewSnapshotProviderDelegate
extension ChatViewDataSource: ChatViewSnapshotProviderDelegate { }
¤ Dauer der Verarbeitung: 0.16 Sekunden
¤
*© Formatika GbR, Deutschland
|
|