// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit import GRDB import DifferenceKit import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit import SignalUtilitiesKit final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate { private static let loadingHeaderHeight: CGFloat = 40 internal let viewModel: ConversationViewModel private var dataChangeObservable: DatabaseCancellable? private var hasLoadedInitialThreadData: Bool = false private var hasLoadedInitialInteractionData: Bool = false private var currentTargetOffset: CGPoint? private var isAutoLoadingNextPage: Bool = false private var isLoadingMore: Bool = false var isReplacingThread: Bool = false /// This flag indicates whether the thread data has been reloaded after a disappearance (it defaults to true as it will /// never have disappeared before - this is only needed for value observers since they run asynchronously) private var hasReloadedThreadDataAfterDisappearance: Bool = true var focusedInteractionId: Int64? var shouldHighlightNextScrollToInteraction: Bool = false var scrollButtonBottomConstraint: NSLayoutConstraint? var scrollButtonMessageRequestsBottomConstraint: NSLayoutConstraint? var messageRequestsViewBotomConstraint: NSLayoutConstraint? // Search var isShowingSearchUI = false // Audio playback & recording var audioPlayer: OWSAudioPlayer? var audioRecorder: AVAudioRecorder? var audioTimer: Timer? // Context menu var contextMenuWindow: ContextMenuWindow? var contextMenuVC: ContextMenuVC? // Mentions var currentMentionStartIndex: String.Index? var mentions: [MentionInfo] = [] // Scrolling & paging var isUserScrolling = false var hasPerformedInitialScroll = false var didFinishInitialLayout = false var scrollDistanceToBottomBeforeUpdate: CGFloat? var baselineKeyboardHeight: CGFloat = 0 // Reaction var currentReactionListSheet: ReactionListSheet? var reactionExpandedMessageIds: Set = [] /// This flag is used to temporarily prevent the ConversationVC from becoming the first responder (primarily used with /// custom transitions from preventing them from being buggy var delayFirstResponder: Bool = false override var canBecomeFirstResponder: Bool { !delayFirstResponder && // Need to return false during the swap between threads to prevent keyboard dismissal !isReplacingThread } override var inputAccessoryView: UIView? { guard viewModel.threadData.threadVariant != .closedGroup || viewModel.threadData.currentUserIsClosedGroupMember == true else { return nil } return (isShowingSearchUI ? searchController.resultsBar : snInputView) } /// The height of the visible part of the table view, i.e. the distance from the navigation bar (where the table view's origin is) /// to the top of the input view (`tableView.adjustedContentInset.bottom`). var tableViewUnobscuredHeight: CGFloat { let bottomInset = tableView.adjustedContentInset.bottom return tableView.bounds.height - bottomInset } /// The offset at which the table view is exactly scrolled to the bottom. var lastPageTop: CGFloat { return tableView.contentSize.height - tableViewUnobscuredHeight } var isCloseToBottom: Bool { let margin = (self.lastPageTop - self.tableView.contentOffset.y) return margin <= ConversationVC.scrollToBottomMargin } lazy var mnemonic: String = { if let hexEncodedSeed: String = Identity.fetchHexEncodedSeed() { return Mnemonic.encode(hexEncodedString: hexEncodedSeed) } // Legacy account return Mnemonic.encode(hexEncodedString: Identity.fetchUserPrivateKey()!.toHexString()) }() // FIXME: Would be good to create a Swift-based cache and replace this lazy var mediaCache: NSCache = { let result = NSCache() result.countLimit = 40 return result }() lazy var recordVoiceMessageActivity = AudioActivity(audioDescription: "Voice message", behavior: .playAndRecord) lazy var searchController: ConversationSearchController = { let result: ConversationSearchController = ConversationSearchController( threadId: self.viewModel.threadData.threadId ) result.uiSearchController.obscuresBackgroundDuringPresentation = false result.delegate = self return result }() // MARK: - UI lazy var titleView: ConversationTitleView = { let result: ConversationTitleView = ConversationTitleView() let tapGestureRecognizer = UITapGestureRecognizer( target: self, action: #selector(handleTitleViewTapped) ) result.addGestureRecognizer(tapGestureRecognizer) return result }() lazy var tableView: InsetLockableTableView = { let result: InsetLockableTableView = InsetLockableTableView() result.separatorStyle = .none result.themeBackgroundColor = .clear result.showsVerticalScrollIndicator = false result.contentInsetAdjustmentBehavior = .never result.keyboardDismissMode = .interactive result.contentInset = UIEdgeInsets( top: 0, leading: 0, bottom: Values.mediumSpacing, trailing: 0 ) result.registerHeaderFooterView(view: UITableViewHeaderFooterView.self) result.register(view: VisibleMessageCell.self) result.register(view: InfoMessageCell.self) result.register(view: TypingIndicatorCell.self) result.register(view: CallMessageCell.self) result.dataSource = self result.delegate = self return result }() lazy var snInputView: InputView = InputView( threadVariant: self.viewModel.threadData.threadVariant, delegate: self ) lazy var unreadCountView: UIView = { let result: UIView = UIView() result.themeBackgroundColor = .backgroundSecondary result.layer.masksToBounds = true result.layer.cornerRadius = (ConversationVC.unreadCountViewSize / 2) result.set(.width, greaterThanOrEqualTo: ConversationVC.unreadCountViewSize) result.set(.height, to: ConversationVC.unreadCountViewSize) return result }() lazy var unreadCountLabel: UILabel = { let result: UILabel = UILabel() result.font = .boldSystemFont(ofSize: Values.verySmallFontSize) result.themeTextColor = .textPrimary result.textAlignment = .center return result }() lazy var blockedBanner: InfoBanner = { let result: InfoBanner = InfoBanner( message: self.viewModel.blockedBannerMessage, backgroundColor: .danger ) let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(unblock)) result.addGestureRecognizer(tapGestureRecognizer) return result }() lazy var footerControlsStackView: UIStackView = { let result: UIStackView = UIStackView() result.translatesAutoresizingMaskIntoConstraints = false result.axis = .vertical result.alignment = .trailing result.distribution = .equalSpacing result.spacing = 10 result.layoutMargins = UIEdgeInsets(top: 0, left: 20, bottom: 0, right: 20) result.isLayoutMarginsRelativeArrangement = true return result }() lazy var scrollButton: ScrollToBottomButton = ScrollToBottomButton(delegate: self) lazy var messageRequestView: UIView = { let result: UIView = UIView() result.translatesAutoresizingMaskIntoConstraints = false result.themeBackgroundColor = .backgroundPrimary result.isHidden = ( self.viewModel.threadData.threadIsMessageRequest == false || self.viewModel.threadData.threadRequiresApproval == true ) return result }() private let messageRequestDescriptionLabel: UILabel = { let result: UILabel = UILabel() result.translatesAutoresizingMaskIntoConstraints = false result.font = UIFont.systemFont(ofSize: 12) result.text = "MESSAGE_REQUESTS_INFO".localized() result.themeTextColor = .textSecondary result.textAlignment = .center result.numberOfLines = 2 return result }() private lazy var messageRequestAcceptButton: UIButton = { let result: OutlineButton = OutlineButton(style: .regular, size: .medium) result.translatesAutoresizingMaskIntoConstraints = false result.setTitle("TXT_DELETE_ACCEPT".localized(), for: .normal) result.addTarget(self, action: #selector(acceptMessageRequest), for: .touchUpInside) return result }() private lazy var messageRequestDeleteButton: UIButton = { let result: OutlineButton = OutlineButton(style: .destructive, size: .medium) result.translatesAutoresizingMaskIntoConstraints = false result.setTitle("TXT_DECLINE_TITLE".localized(), for: .normal) result.addTarget(self, action: #selector(deleteMessageRequest), for: .touchUpInside) return result }() private lazy var messageRequestBlockButton: UIButton = { let result: UIButton = UIButton() result.translatesAutoresizingMaskIntoConstraints = false result.clipsToBounds = true result.titleLabel?.font = UIFont.boldSystemFont(ofSize: 16) result.setTitle("TXT_BLOCK_USER_TITLE".localized(), for: .normal) result.setThemeTitleColor(.danger, for: .normal) result.addTarget(self, action: #selector(block), for: .touchUpInside) return result }() // MARK: - Settings static let unreadCountViewSize: CGFloat = 20 /// The table view's bottom inset (content will have this distance to the bottom if the table view is fully scrolled down). static let bottomInset = Values.mediumSpacing /// The table view will start loading more content when the content offset becomes less than this. static let loadMoreThreshold: CGFloat = 120 /// The button will be fully visible once the user has scrolled this amount from the bottom of the table view. static let scrollButtonFullVisibilityThreshold: CGFloat = 80 /// The button will be invisible until the user has scrolled at least this amount from the bottom of the table view. static let scrollButtonNoVisibilityThreshold: CGFloat = 20 /// Automatically scroll to the bottom of the conversation when sending a message if the scroll distance from the bottom is less than this number. static let scrollToBottomMargin: CGFloat = 60 // MARK: - Initialization init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64? = nil) { self.viewModel = ConversationViewModel(threadId: threadId, threadVariant: threadVariant, focusedInteractionId: focusedInteractionId) Storage.shared.addObserver(viewModel.pagedDataObserver) super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { preconditionFailure("Use init(thread:) instead.") } deinit { NotificationCenter.default.removeObserver(self) } // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() navigationItem.titleView = titleView // Note: We need to update the nav bar buttons here (with invalid data) because if we don't the // nav will be offset incorrectly during the push animation (unfortunately the profile icon still // doesn't appear until after the animation, I assume it's taking a snapshot or something, but // there isn't much we can do about that unfortunately) updateNavBarButtons(threadData: nil, initialVariant: self.viewModel.initialThreadVariant) titleView.initialSetup(with: self.viewModel.initialThreadVariant) // Constraints view.addSubview(tableView) tableView.pin(to: view) // Message requests view & scroll to bottom view.addSubview(scrollButton) view.addSubview(messageRequestView) messageRequestView.addSubview(messageRequestBlockButton) messageRequestView.addSubview(messageRequestDescriptionLabel) messageRequestView.addSubview(messageRequestAcceptButton) messageRequestView.addSubview(messageRequestDeleteButton) scrollButton.pin(.right, to: .right, of: view, withInset: -20) messageRequestView.pin(.left, to: .left, of: view) messageRequestView.pin(.right, to: .right, of: view) self.messageRequestsViewBotomConstraint = messageRequestView.pin(.bottom, to: .bottom, of: view, withInset: -16) self.scrollButtonBottomConstraint = scrollButton.pin(.bottom, to: .bottom, of: view, withInset: -16) self.scrollButtonBottomConstraint?.isActive = false // Note: Need to disable this to avoid a conflict with the other bottom constraint self.scrollButtonMessageRequestsBottomConstraint = scrollButton.pin(.bottom, to: .top, of: messageRequestView, withInset: -16) messageRequestBlockButton.pin(.top, to: .top, of: messageRequestView, withInset: 10) messageRequestBlockButton.center(.horizontal, in: messageRequestView) messageRequestDescriptionLabel.pin(.top, to: .bottom, of: messageRequestBlockButton, withInset: 5) messageRequestDescriptionLabel.pin(.left, to: .left, of: messageRequestView, withInset: 40) messageRequestDescriptionLabel.pin(.right, to: .right, of: messageRequestView, withInset: -40) messageRequestAcceptButton.pin(.top, to: .bottom, of: messageRequestDescriptionLabel, withInset: 20) messageRequestAcceptButton.pin(.left, to: .left, of: messageRequestView, withInset: 20) messageRequestAcceptButton.pin(.bottom, to: .bottom, of: messageRequestView) messageRequestDeleteButton.pin(.top, to: .bottom, of: messageRequestDescriptionLabel, withInset: 20) messageRequestDeleteButton.pin(.left, to: .right, of: messageRequestAcceptButton, withInset: UIDevice.current.isIPad ? Values.iPadButtonSpacing : 20) messageRequestDeleteButton.pin(.right, to: .right, of: messageRequestView, withInset: -20) messageRequestDeleteButton.pin(.bottom, to: .bottom, of: messageRequestView) messageRequestDeleteButton.set(.width, to: .width, of: messageRequestAcceptButton) // Unread count view view.addSubview(unreadCountView) unreadCountView.addSubview(unreadCountLabel) unreadCountLabel.pin(.top, to: .top, of: unreadCountView) unreadCountLabel.pin(.bottom, to: .bottom, of: unreadCountView) unreadCountView.pin(.leading, to: .leading, of: unreadCountLabel, withInset: -4) unreadCountView.pin(.trailing, to: .trailing, of: unreadCountLabel, withInset: 4) unreadCountView.centerYAnchor.constraint(equalTo: scrollButton.topAnchor).isActive = true unreadCountView.center(.horizontal, in: scrollButton) // Notifications NotificationCenter.default.addObserver( self, selector: #selector(applicationDidBecomeActive(_:)), name: UIApplication.didBecomeActiveNotification, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(applicationDidResignActive(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(handleKeyboardWillChangeFrameNotification(_:)), name: UIResponder.keyboardWillChangeFrameNotification, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(handleKeyboardWillHideNotification(_:)), name: UIResponder.keyboardWillHideNotification, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(sendScreenshotNotification), name: UIApplication.userDidTakeScreenshotNotification, object: nil ) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) startObservingChanges() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) // Flag that the initial layout has been completed (the flag blocks and unblocks a number // of different behaviours) didFinishInitialLayout = true if delayFirstResponder || isShowingSearchUI { delayFirstResponder = false DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in (self?.isShowingSearchUI == false ? self : self?.searchController.uiSearchController.searchBar )?.becomeFirstResponder() } } recoverInputView() } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) // Don't set the draft or resign the first responder if we are replacing the thread (want the keyboard // to appear to remain focussed) guard !isReplacingThread else { return } stopObservingChanges() viewModel.updateDraft(to: snInputView.text) inputAccessoryView?.resignFirstResponder() } override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) mediaCache.removeAllObjects() hasReloadedThreadDataAfterDisappearance = false } @objc func applicationDidBecomeActive(_ notification: Notification) { startObservingChanges(didReturnFromBackground: true) recoverInputView() if !isShowingSearchUI { if !self.isFirstResponder { self.becomeFirstResponder() } else { self.reloadInputViews() } } } @objc func applicationDidResignActive(_ notification: Notification) { stopObservingChanges() } // MARK: - Updating private func startObservingChanges(didReturnFromBackground: Bool = false) { // Start observing for data changes dataChangeObservable = Storage.shared.start( viewModel.observableThreadData, onError: { _ in }, onChange: { [weak self] maybeThreadData in guard let threadData: SessionThreadViewModel = maybeThreadData else { // If the thread data is null and the id was blinded then we just unblinded the thread // and need to swap over to the new one guard let sessionId: String = self?.viewModel.threadData.threadId, SessionId.Prefix(from: sessionId) == .blinded, let blindedLookup: BlindedIdLookup = Storage.shared.read({ db in try BlindedIdLookup .filter(id: sessionId) .fetchOne(db) }), let unblindedId: String = blindedLookup.sessionId else { // If we don't have an unblinded id then something has gone very wrong so pop to the HomeVC self?.navigationController?.popToRootViewController(animated: true) return } // Stop observing changes self?.stopObservingChanges() Storage.shared.removeObserver(self?.viewModel.pagedDataObserver) // Swap the observing to the updated thread self?.viewModel.swapToThread(updatedThreadId: unblindedId) // Start observing changes again Storage.shared.addObserver(self?.viewModel.pagedDataObserver) self?.startObservingChanges() return } // The default scheduler emits changes on the main thread self?.handleThreadUpdates(threadData) // Note: We want to load the interaction data into the UI after the initial thread data // has loaded to prevent an issue where the conversation loads with the wrong offset if self?.viewModel.onInteractionChange == nil { self?.viewModel.onInteractionChange = { [weak self] updatedInteractionData in self?.handleInteractionUpdates(updatedInteractionData) } // Note: When returning from the background we could have received notifications but the // PagedDatabaseObserver won't have them so we need to force a re-fetch of the current // data to ensure everything is up to date if didReturnFromBackground { self?.viewModel.pagedDataObserver?.reload() } } } ) } private func stopObservingChanges() { // Stop observing database changes dataChangeObservable?.cancel() self.viewModel.onInteractionChange = nil } private func handleThreadUpdates(_ updatedThreadData: SessionThreadViewModel, initialLoad: Bool = false) { // Ensure the first load or a load when returning from a child screen runs without animations (if // we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition) guard hasLoadedInitialThreadData && hasReloadedThreadDataAfterDisappearance else { hasLoadedInitialThreadData = true hasReloadedThreadDataAfterDisappearance = true UIView.performWithoutAnimation { handleThreadUpdates(updatedThreadData, initialLoad: true) } return } // Update general conversation UI if initialLoad || viewModel.threadData.displayName != updatedThreadData.displayName || viewModel.threadData.threadVariant != updatedThreadData.threadVariant || viewModel.threadData.threadIsNoteToSelf != updatedThreadData.threadIsNoteToSelf || viewModel.threadData.threadMutedUntilTimestamp != updatedThreadData.threadMutedUntilTimestamp || viewModel.threadData.threadOnlyNotifyForMentions != updatedThreadData.threadOnlyNotifyForMentions || viewModel.threadData.userCount != updatedThreadData.userCount { titleView.update( with: updatedThreadData.displayName, isNoteToSelf: updatedThreadData.threadIsNoteToSelf, threadVariant: updatedThreadData.threadVariant, mutedUntilTimestamp: updatedThreadData.threadMutedUntilTimestamp, onlyNotifyForMentions: (updatedThreadData.threadOnlyNotifyForMentions == true), userCount: updatedThreadData.userCount ) } if initialLoad || viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval || viewModel.threadData.threadIsMessageRequest != updatedThreadData.threadIsMessageRequest || viewModel.threadData.profile != updatedThreadData.profile { updateNavBarButtons(threadData: updatedThreadData, initialVariant: viewModel.initialThreadVariant) let messageRequestsViewWasVisible: Bool = (messageRequestView.isHidden == false) UIView.animate(withDuration: 0.3) { [weak self] in self?.messageRequestView.isHidden = ( updatedThreadData.threadIsMessageRequest == false || updatedThreadData.threadRequiresApproval == true ) self?.scrollButtonMessageRequestsBottomConstraint?.isActive = ( updatedThreadData.threadIsMessageRequest == true ) self?.scrollButtonBottomConstraint?.isActive = (updatedThreadData.threadIsMessageRequest == false) // Update the table content inset and offset to account for // the dissapearance of the messageRequestsView if messageRequestsViewWasVisible { let messageRequestsOffset: CGFloat = ((self?.messageRequestView.bounds.height ?? 0) + 16) let oldContentInset: UIEdgeInsets = (self?.tableView.contentInset ?? UIEdgeInsets.zero) self?.tableView.contentInset = UIEdgeInsets( top: 0, leading: 0, bottom: max(oldContentInset.bottom - messageRequestsOffset, 0), trailing: 0 ) } } } if initialLoad || viewModel.threadData.threadIsBlocked != updatedThreadData.threadIsBlocked { addOrRemoveBlockedBanner(threadIsBlocked: (updatedThreadData.threadIsBlocked == true)) } if initialLoad || viewModel.threadData.threadUnreadCount != updatedThreadData.threadUnreadCount { updateUnreadCountView(unreadCount: updatedThreadData.threadUnreadCount) } if initialLoad || viewModel.threadData.enabledMessageTypes != updatedThreadData.enabledMessageTypes { snInputView.setEnabledMessageTypes( updatedThreadData.enabledMessageTypes, message: nil ) } // Only set the draft content on the initial load if initialLoad, let draft: String = updatedThreadData.threadMessageDraft, !draft.isEmpty { snInputView.text = draft } // Now we have done all the needed diffs, update the viewModel with the latest data and mark // all messages as read (we do it in here as the 'threadData' actually contains the last // 'interactionId' for the thread) self.viewModel.updateThreadData(updatedThreadData) self.viewModel.markAllAsRead() /// **Note:** This needs to happen **after** we have update the viewModel's thread data if initialLoad || viewModel.threadData.currentUserIsClosedGroupMember != updatedThreadData.currentUserIsClosedGroupMember { if !self.isFirstResponder { self.becomeFirstResponder() } else { self.reloadInputViews() } } } private func handleInteractionUpdates(_ updatedData: [ConversationViewModel.SectionModel], initialLoad: Bool = false) { // Ensure the first load or a load when returning from a child screen runs without animations (if // we don't do this the cells will animate in from a frame of CGRect.zero or have a buggy transition) guard self.hasLoadedInitialInteractionData else { self.hasLoadedInitialInteractionData = true self.viewModel.updateInteractionData(updatedData) UIView.performWithoutAnimation { self.tableView.reloadData() self.performInitialScrollIfNeeded() } return } // Update the ReactionListSheet (if one exists) if let messageUpdates: [MessageViewModel] = updatedData.first(where: { $0.model == .messages })?.elements { self.currentReactionListSheet?.handleInteractionUpdates(messageUpdates) } // Store the 'sentMessageBeforeUpdate' state locally let didSendMessageBeforeUpdate: Bool = self.viewModel.sentMessageBeforeUpdate self.viewModel.sentMessageBeforeUpdate = false // When sending a message we want to reload the UI instantly (with any form of animation the message // sending feels somewhat unresponsive but an instant update feels snappy) guard !didSendMessageBeforeUpdate else { self.viewModel.updateInteractionData(updatedData) self.tableView.reloadData() // Note: The scroll button alpha won't get set correctly in this case so we forcibly set it to // have an alpha of 0 to stop it appearing buggy self.scrollToBottom(isAnimated: false) self.scrollButton.alpha = 0 self.unreadCountView.alpha = scrollButton.alpha return } // Reload the table content animating changes if they'll look good struct ItemChangeInfo { let isInsertAtTop: Bool let firstIndexIsVisible: Bool let visibleIndexPath: IndexPath let oldVisibleIndexPath: IndexPath init( isInsertAtTop: Bool = false, firstIndexIsVisible: Bool = false, visibleIndexPath: IndexPath = IndexPath(row: 0, section: 0), oldVisibleIndexPath: IndexPath = IndexPath(row: 0, section: 0) ) { self.isInsertAtTop = isInsertAtTop self.firstIndexIsVisible = firstIndexIsVisible self.visibleIndexPath = visibleIndexPath self.oldVisibleIndexPath = oldVisibleIndexPath } } let changeset: StagedChangeset<[ConversationViewModel.SectionModel]> = StagedChangeset( source: viewModel.interactionData, target: updatedData ) let isInsert: Bool = (changeset.map({ $0.elementInserted.count }).reduce(0, +) > 0) let wasLoadingMore: Bool = self.isLoadingMore let wasOffsetCloseToBottom: Bool = self.isCloseToBottom let numItemsInUpdatedData: [Int] = updatedData.map { $0.elements.count } let didSwapAllContent: Bool = (updatedData .first(where: { $0.model == .messages })? .elements .contains(where: { $0.id == self.viewModel.interactionData .first(where: { $0.model == .messages })? .elements .first? .id })) .defaulting(to: false) let itemChangeInfo: ItemChangeInfo? = { guard isInsert, let oldSectionIndex: Int = self.viewModel.interactionData.firstIndex(where: { $0.model == .messages }), let newSectionIndex: Int = updatedData.firstIndex(where: { $0.model == .messages }), let newFirstItemIndex: Int = updatedData[newSectionIndex].elements .firstIndex(where: { item -> Bool in item.id == self.viewModel.interactionData[oldSectionIndex].elements.first?.id }), let firstVisibleIndexPath: IndexPath = self.tableView.indexPathsForVisibleRows? .filter({ $0.section == oldSectionIndex }) .sorted() .first, let newVisibleIndex: Int = updatedData[newSectionIndex].elements .firstIndex(where: { item in item.id == self.viewModel.interactionData[oldSectionIndex] .elements[firstVisibleIndexPath.row] .id }) else { return nil } return ItemChangeInfo( isInsertAtTop: ( newSectionIndex > oldSectionIndex || newFirstItemIndex > 0 ), firstIndexIsVisible: (firstVisibleIndexPath.row == 0), visibleIndexPath: IndexPath(row: newVisibleIndex, section: newSectionIndex), oldVisibleIndexPath: firstVisibleIndexPath ) }() guard !isInsert || itemChangeInfo?.isInsertAtTop == true else { self.viewModel.updateInteractionData(updatedData) self.tableView.reloadData() // Animate to the target interaction (or the bottom) after a slightly delay to prevent buggy // animation conflicts if let focusedInteractionId: Int64 = self.focusedInteractionId { // If we had a focusedInteractionId then scroll to it (and hide the search // result bar loading indicator) let delay: DispatchTime = (didSwapAllContent ? .now() : (.now() + .milliseconds(100)) ) DispatchQueue.main.asyncAfter(deadline: delay) { [weak self] in self?.searchController.resultsBar.stopLoading() self?.scrollToInteractionIfNeeded( with: focusedInteractionId, isAnimated: true, highlight: (self?.shouldHighlightNextScrollToInteraction == true) ) if wasLoadingMore { // Complete page loading self?.isLoadingMore = false self?.autoLoadNextPageIfNeeded() } } } else if wasOffsetCloseToBottom && !wasLoadingMore { // Scroll to the bottom if an interaction was just inserted and we either // just sent a message or are close enough to the bottom (wait a tiny fraction // to avoid buggy animation behaviour) DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(100)) { [weak self] in self?.scrollToBottom(isAnimated: true) } } else if wasLoadingMore { // Complete page loading self.isLoadingMore = false self.autoLoadNextPageIfNeeded() } return } /// UITableView doesn't really support bottom-aligned content very well and as such jumps around a lot when inserting content but /// we want to maintain the current offset from before the data was inserted (except when adding at the bottom while the user is at /// the bottom, in which case we want to scroll down) /// /// Unfortunately the UITableView also does some weird things when updating (where it won't have updated it's internal data until /// after it performs the next layout); the below code checks a condition on layout and if it passes it calls a closure if let itemChangeInfo: ItemChangeInfo = itemChangeInfo, itemChangeInfo.isInsertAtTop { let oldCellHeight: CGFloat = self.tableView.rectForRow(at: itemChangeInfo.oldVisibleIndexPath).height // The the user triggered the 'scrollToTop' animation (by tapping in the nav bar) then we // need to stop the animation before attempting to lock the offset (otherwise things break) if itemChangeInfo.firstIndexIsVisible { self.tableView.setContentOffset(self.tableView.contentOffset, animated: false) } // Wait until the tableView has completed a layout and reported the correct number of // sections/rows and then update the contentOffset self.tableView.afterNextLayoutSubviews( when: { numSections, numRowsInSections, _ -> Bool in numSections == updatedData.count && numRowsInSections == numItemsInUpdatedData }, then: { [weak self] in // Only recalculate the contentOffset when loading new data if the amount of data // loaded was smaller than 2 pages (this will prevent calculating the frames of // a large number of cells when getting search results which are very far away // only to instantly start scrolling making the calculation redundant) if (abs(itemChangeInfo.visibleIndexPath.row - itemChangeInfo.oldVisibleIndexPath.row) <= (ConversationViewModel.pageSize * 2)) { UIView.performWithoutAnimation { let calculatedRowHeights: CGFloat = (0.. ConversationViewModel.pageSize } ) { [weak self] updatedData in self?.viewModel.updateInteractionData(updatedData) } } // MARK: Updating private func performInitialScrollIfNeeded() { guard !hasPerformedInitialScroll && hasLoadedInitialThreadData && hasLoadedInitialInteractionData else { return } // Scroll to the last unread message if possible; otherwise scroll to the bottom. // When the unread message count is more than the number of view items of a page, // the screen will scroll to the bottom instead of the first unread message if let focusedInteractionId: Int64 = self.viewModel.focusedInteractionId { self.scrollToInteractionIfNeeded(with: focusedInteractionId, isAnimated: false, highlight: true) } else { self.scrollToBottom(isAnimated: false) } self.scrollButton.alpha = self.getScrollButtonOpacity() self.unreadCountView.alpha = self.scrollButton.alpha self.hasPerformedInitialScroll = true // Now that the data has loaded we need to check if either of the "load more" sections are // visible and trigger them if so // // Note: We do it this way as we want to trigger the load behaviour for the first section // if it has one before trying to trigger the load behaviour for the last section self.autoLoadNextPageIfNeeded() } private func autoLoadNextPageIfNeeded() { guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return } self.isAutoLoadingNextPage = true DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in self?.isAutoLoadingNextPage = false // Note: We sort the headers as we want to prioritise loading newer pages over older ones let sections: [(ConversationViewModel.Section, CGRect)] = (self?.viewModel.interactionData .enumerated() .map { index, section in (section.model, (self?.tableView.rectForHeader(inSection: index) ?? .zero)) }) .defaulting(to: []) let shouldLoadOlder: Bool = sections .contains { section, headerRect in section == .loadOlder && headerRect != .zero && (self?.tableView.bounds.contains(headerRect) == true) } let shouldLoadNewer: Bool = sections .contains { section, headerRect in section == .loadNewer && headerRect != .zero && (self?.tableView.bounds.contains(headerRect) == true) } guard shouldLoadOlder || shouldLoadNewer else { return } self?.isLoadingMore = true DispatchQueue.global(qos: .default).async { [weak self] in // Attachments are loaded in descending order so 'loadOlder' actually corresponds with // 'pageAfter' in this case self?.viewModel.pagedDataObserver?.load(shouldLoadOlder ? .pageAfter : .pageBefore ) } } } func updateNavBarButtons(threadData: SessionThreadViewModel?, initialVariant: SessionThread.Variant) { navigationItem.hidesBackButton = isShowingSearchUI if isShowingSearchUI { navigationItem.leftBarButtonItem = nil navigationItem.rightBarButtonItems = [] } else { guard let threadData: SessionThreadViewModel = threadData, ( threadData.threadRequiresApproval == false && threadData.threadIsMessageRequest == false ) else { // Note: Adding empty buttons because without it the title alignment is busted (Note: The size was // taken from the layout inspector for the back button in Xcode navigationItem.rightBarButtonItems = [ UIBarButtonItem( customView: UIView( frame: CGRect( x: 0, y: 0, // Width of the standard back button minus an arbitrary amount to make the // animation look good width: (44 - 10), height: 44 ) ) ), (initialVariant == .contact ? UIBarButtonItem(customView: UIView(frame: CGRect(x: 0, y: 0, width: 44, height: 44))) : nil ) ].compactMap { $0 } return } switch threadData.threadVariant { case .contact: let profilePictureView = ProfilePictureView() profilePictureView.size = Values.verySmallProfilePictureSize profilePictureView.update( publicKey: threadData.threadId, // Contact thread uses the contactId profile: threadData.profile, threadVariant: threadData.threadVariant ) profilePictureView.set(.width, to: (44 - 16)) // Width of the standard back button profilePictureView.set(.height, to: Values.verySmallProfilePictureSize) let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openSettings)) profilePictureView.addGestureRecognizer(tapGestureRecognizer) let settingsButtonItem: UIBarButtonItem = UIBarButtonItem(customView: profilePictureView) settingsButtonItem.accessibilityLabel = "Settings button" settingsButtonItem.isAccessibilityElement = true if SessionCall.isEnabled && !threadData.threadIsNoteToSelf { let callButton = UIBarButtonItem( image: UIImage(named: "Phone"), style: .plain, target: self, action: #selector(startCall) ) navigationItem.rightBarButtonItems = [settingsButtonItem, callButton] } else { navigationItem.rightBarButtonItem = settingsButtonItem } default: let rightBarButtonItem: UIBarButtonItem = UIBarButtonItem(image: UIImage(named: "Gear"), style: .plain, target: self, action: #selector(openSettings)) rightBarButtonItem.accessibilityLabel = "Settings button" rightBarButtonItem.isAccessibilityElement = true navigationItem.rightBarButtonItems = [rightBarButtonItem] } } } // MARK: - Notifications @objc func handleKeyboardWillChangeFrameNotification(_ notification: Notification) { // Please refer to https://github.com/mapbox/mapbox-navigation-ios/issues/1600 // and https://stackoverflow.com/a/25260930 to better understand what we are // doing with the UIViewAnimationOptions let userInfo: [AnyHashable: Any] = (notification.userInfo ?? [:]) let duration = ((userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval) ?? 0) let curveValue: Int = ((userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int) ?? Int(UIView.AnimationOptions.curveEaseInOut.rawValue)) let options: UIView.AnimationOptions = UIView.AnimationOptions(rawValue: UInt(curveValue << 16)) let keyboardRect: CGRect = ((userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect) ?? CGRect.zero) // Calculate new positions (Need the ensure the 'messageRequestView' has been layed out as it's // needed for proper calculations, so force an initial layout if it doesn't have a size) var hasDoneLayout: Bool = true if messageRequestView.bounds.height <= CGFloat.leastNonzeroMagnitude { hasDoneLayout = false UIView.performWithoutAnimation { self.view.layoutIfNeeded() } } let keyboardTop = (UIScreen.main.bounds.height - keyboardRect.minY) let messageRequestsOffset: CGFloat = (messageRequestView.isHidden ? 0 : messageRequestView.bounds.height + 16) let oldContentInset: UIEdgeInsets = tableView.contentInset let newContentInset: UIEdgeInsets = UIEdgeInsets( top: 0, leading: 0, bottom: (Values.mediumSpacing + keyboardTop + messageRequestsOffset), trailing: 0 ) let newContentOffsetY: CGFloat = (tableView.contentOffset.y + (newContentInset.bottom - oldContentInset.bottom)) let changes = { [weak self] in self?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 16) self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 16) self?.tableView.contentInset = newContentInset self?.tableView.contentOffset.y = newContentOffsetY let scrollButtonOpacity: CGFloat = (self?.getScrollButtonOpacity() ?? 0) self?.scrollButton.alpha = scrollButtonOpacity self?.unreadCountView.alpha = scrollButtonOpacity self?.view.setNeedsLayout() self?.view.layoutIfNeeded() } // Perform the changes (don't animate if the initial layout hasn't been completed) guard hasDoneLayout else { UIView.performWithoutAnimation { changes() } return } UIView.animate( withDuration: duration, delay: 0, options: options, animations: changes, completion: nil ) } @objc func handleKeyboardWillHideNotification(_ notification: Notification) { // Please refer to https://github.com/mapbox/mapbox-navigation-ios/issues/1600 // and https://stackoverflow.com/a/25260930 to better understand what we are // doing with the UIViewAnimationOptions let userInfo: [AnyHashable: Any] = (notification.userInfo ?? [:]) let duration = ((userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval) ?? 0) let curveValue: Int = ((userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int) ?? Int(UIView.AnimationOptions.curveEaseInOut.rawValue)) let options: UIView.AnimationOptions = UIView.AnimationOptions(rawValue: UInt(curveValue << 16)) let keyboardRect: CGRect = ((userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect) ?? CGRect.zero) let keyboardTop = (UIScreen.main.bounds.height - keyboardRect.minY) UIView.animate( withDuration: duration, delay: 0, options: options, animations: { [weak self] in self?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 16) self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 16) let scrollButtonOpacity: CGFloat = (self?.getScrollButtonOpacity() ?? 0) self?.scrollButton.alpha = scrollButtonOpacity self?.unreadCountView.alpha = scrollButtonOpacity self?.view.setNeedsLayout() self?.view.layoutIfNeeded() }, completion: nil ) } // MARK: - General func addOrRemoveBlockedBanner(threadIsBlocked: Bool) { guard threadIsBlocked else { UIView.animate( withDuration: 0.25, animations: { [weak self] in self?.blockedBanner.alpha = 0 }, completion: { [weak self] _ in self?.blockedBanner.alpha = 1 self?.blockedBanner.removeFromSuperview() } ) return } self.view.addSubview(self.blockedBanner) self.blockedBanner.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: self.view) } func recoverInputView() { // This is a workaround for an issue where the textview is not scrollable // after the app goes into background and goes back in foreground. DispatchQueue.main.async { self.snInputView.text = self.snInputView.text } } // MARK: - UITableViewDataSource func numberOfSections(in tableView: UITableView) -> Int { return viewModel.interactionData.count } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { let section: ConversationViewModel.SectionModel = viewModel.interactionData[section] return section.elements.count } func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let section: ConversationViewModel.SectionModel = viewModel.interactionData[indexPath.section] switch section.model { case .messages: let cellViewModel: MessageViewModel = section.elements[indexPath.row] let cell: MessageCell = tableView.dequeue(type: MessageCell.cellType(for: cellViewModel), for: indexPath) cell.update( with: cellViewModel, mediaCache: mediaCache, playbackInfo: viewModel.playbackInfo(for: cellViewModel) { updatedInfo, error in DispatchQueue.main.async { guard error == nil else { OWSAlerts.showErrorAlert(message: "INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE".localized()) return } cell.dynamicUpdate(with: cellViewModel, playbackInfo: updatedInfo) } }, showExpandedReactions: viewModel.reactionExpandedInteractionIds .contains(cellViewModel.id), lastSearchText: viewModel.lastSearchedText ) cell.delegate = self return cell default: preconditionFailure("Other sections should have no content") } } func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { let section: ConversationViewModel.SectionModel = viewModel.interactionData[section] switch section.model { case .loadOlder, .loadNewer: let loadingIndicator: UIActivityIndicatorView = UIActivityIndicatorView(style: .medium) loadingIndicator.themeTintColor = .textPrimary loadingIndicator.alpha = 0.5 loadingIndicator.startAnimating() let view: UIView = UIView() view.addSubview(loadingIndicator) loadingIndicator.center(in: view) return view case .messages: return nil } } // MARK: - UITableViewDelegate func tableView(_ tableView: UITableView, estimatedHeightForRowAt indexPath: IndexPath) -> CGFloat { return UITableView.automaticDimension } func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { return UITableView.automaticDimension } func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { let section: ConversationViewModel.SectionModel = viewModel.interactionData[section] switch section.model { case .loadOlder, .loadNewer: return ConversationVC.loadingHeaderHeight case .messages: return 0 } } func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) { guard self.hasPerformedInitialScroll && !self.isLoadingMore else { return } let section: ConversationViewModel.SectionModel = self.viewModel.interactionData[section] switch section.model { case .loadOlder, .loadNewer: self.isLoadingMore = true DispatchQueue.global(qos: .default).async { [weak self] in // Messages are loaded in descending order so 'loadOlder' actually corresponds with // 'pageAfter' in this case self?.viewModel.pagedDataObserver?.load(section.model == .loadOlder ? .pageAfter : .pageBefore ) } case .messages: break } } func scrollToBottom(isAnimated: Bool) { guard !self.isUserScrolling, let messagesSectionIndex: Int = self.viewModel.interactionData .firstIndex(where: { $0.model == .messages }), !self.viewModel.interactionData[messagesSectionIndex] .elements .isEmpty else { return } // If the last interaction isn't loaded then scroll to the final interactionId on // the thread data let hasNewerItems: Bool = self.viewModel.interactionData.contains(where: { $0.model == .loadNewer }) guard !self.didFinishInitialLayout || !hasNewerItems else { let messages: [MessageViewModel] = self.viewModel.interactionData[messagesSectionIndex].elements let lastInteractionId: Int64 = self.viewModel.threadData.interactionId .defaulting(to: messages[messages.count - 1].id) self.scrollToInteractionIfNeeded( with: lastInteractionId, position: .bottom, isJumpingToLastInteraction: true, isAnimated: true ) return } let targetIndexPath: IndexPath = IndexPath( row: (self.viewModel.interactionData[messagesSectionIndex].elements.count - 1), section: messagesSectionIndex ) self.tableView.scrollToRow( at: targetIndexPath, at: .bottom, animated: isAnimated ) self.handleInitialOffsetBounceBug(targetIndexPath: targetIndexPath, at: .bottom) } func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { isUserScrolling = true } func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { isUserScrolling = false } func scrollViewDidScroll(_ scrollView: UIScrollView) { scrollButton.alpha = getScrollButtonOpacity() unreadCountView.alpha = scrollButton.alpha } func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { guard let focusedInteractionId: Int64 = self.focusedInteractionId, self.shouldHighlightNextScrollToInteraction else { self.focusedInteractionId = nil self.shouldHighlightNextScrollToInteraction = false return } DispatchQueue.main.async { [weak self] in self?.highlightCellIfNeeded(interactionId: focusedInteractionId) } } func updateUnreadCountView(unreadCount: UInt?) { let unreadCount: Int = Int(unreadCount ?? 0) let fontSize: CGFloat = (unreadCount < 10000 ? Values.verySmallFontSize : 8) unreadCountLabel.text = (unreadCount < 10000 ? "\(unreadCount)" : "9999+") unreadCountLabel.font = .boldSystemFont(ofSize: fontSize) unreadCountView.isHidden = (unreadCount == 0) } func getScrollButtonOpacity() -> CGFloat { let contentOffsetY = tableView.contentOffset.y let x = (lastPageTop - ConversationVC.bottomInset - contentOffsetY).clamp(0, .greatestFiniteMagnitude) let a = 1 / (ConversationVC.scrollButtonFullVisibilityThreshold - ConversationVC.scrollButtonNoVisibilityThreshold) return max(0, min(1, a * x)) } // MARK: - Search func popAllConversationSettingsViews(completion completionBlock: (() -> Void)? = nil) { if presentedViewController != nil { dismiss(animated: true) { [weak self] in guard let strongSelf: UIViewController = self else { return } self?.navigationController?.popToViewController(strongSelf, animated: true, completion: completionBlock) } } else { navigationController?.popToViewController(self, animated: true, completion: completionBlock) } } func showSearchUI() { isShowingSearchUI = true // Search bar let searchBar = searchController.uiSearchController.searchBar searchBar.setUpSessionStyle() let searchBarContainer = UIView() searchBarContainer.layoutMargins = UIEdgeInsets.zero searchBar.sizeToFit() searchBar.layoutMargins = UIEdgeInsets.zero searchBarContainer.set(.height, to: 44) searchBarContainer.set(.width, to: UIScreen.main.bounds.width - 32) searchBarContainer.addSubview(searchBar) navigationItem.titleView = searchBarContainer // On iPad, the cancel button won't show // See more https://developer.apple.com/documentation/uikit/uisearchbar/1624283-showscancelbutton?language=objc if UIDevice.current.isIPad { let ipadCancelButton = UIButton() ipadCancelButton.setTitle("cancel".localized(), for: .normal) ipadCancelButton.addTarget(self, action: #selector(hideSearchUI), for: .touchUpInside) ipadCancelButton.setThemeTitleColor(.textPrimary, for: .normal) searchBarContainer.addSubview(ipadCancelButton) ipadCancelButton.pin(.trailing, to: .trailing, of: searchBarContainer) ipadCancelButton.autoVCenterInSuperview() searchBar.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets.zero, excludingEdge: .trailing) searchBar.pin(.trailing, to: .leading, of: ipadCancelButton, withInset: -Values.smallSpacing) } else { searchBar.autoPinEdgesToSuperviewMargins() } // Nav bar buttons updateNavBarButtons(threadData: self.viewModel.threadData, initialVariant: viewModel.initialThreadVariant) // Hack so that the ResultsBar stays on the screen when dismissing the search field // keyboard. // // Details: // // When the search UI is activated, both the SearchField and the ConversationVC // have the resultsBar as their inputAccessoryView. // // So when the SearchField is first responder, the ResultsBar is shown on top of the keyboard. // When the ConversationVC is first responder, the ResultsBar is shown at the bottom of the // screen. // // When the user swipes to dismiss the keyboard, trying to see more of the content while // searching, we want the ResultsBar to stay at the bottom of the screen - that is, we // want the ConversationVC to becomeFirstResponder. // // If the SearchField were a subview of ConversationVC.view, this would all be automatic, // as first responder status is percolated up the responder chain via `nextResponder`, which // basically travereses each superView, until you're at a rootView, at which point the next // responder is the ViewController which controls that View. // // However, because SearchField lives in the Navbar, it's "controlled" by the // NavigationController, not the ConversationVC. // // So here we stub the next responder on the navBar so that when the searchBar resigns // first responder, the ConversationVC will be in it's responder chain - keeeping the // ResultsBar on the bottom of the screen after dismissing the keyboard. let navBar = navigationController!.navigationBar as! OWSNavigationBar navBar.stubbedNextResponder = self } @objc func hideSearchUI() { isShowingSearchUI = false navigationItem.titleView = titleView updateNavBarButtons(threadData: self.viewModel.threadData, initialVariant: viewModel.initialThreadVariant) let navBar: OWSNavigationBar? = navigationController?.navigationBar as? OWSNavigationBar navBar?.stubbedNextResponder = nil becomeFirstResponder() reloadInputViews() } func didDismissSearchController(_ searchController: UISearchController) { hideSearchUI() } func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Int64]?, searchText: String?) { viewModel.lastSearchedText = searchText tableView.reloadRows(at: tableView.indexPathsForVisibleRows ?? [], with: UITableView.RowAnimation.none) } func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionId interactionId: Int64) { scrollToInteractionIfNeeded(with: interactionId, highlight: true) } func scrollToInteractionIfNeeded( with interactionId: Int64, position: UITableView.ScrollPosition = .middle, isJumpingToLastInteraction: Bool = false, isAnimated: Bool = true, highlight: Bool = false ) { // Store the info incase we need to load more data (call will be re-triggered) self.focusedInteractionId = interactionId self.shouldHighlightNextScrollToInteraction = highlight // Ensure the target interaction has been loaded guard let messageSectionIndex: Int = self.viewModel.interactionData .firstIndex(where: { $0.model == .messages }), let targetMessageIndex = self.viewModel.interactionData[messageSectionIndex] .elements .firstIndex(where: { $0.id == interactionId }) else { // If not the make sure we have finished the initial layout before trying to // load the up until the specified interaction guard self.didFinishInitialLayout else { return } self.isLoadingMore = true self.searchController.resultsBar.startLoading() DispatchQueue.global(qos: .default).async { [weak self] in if isJumpingToLastInteraction { self?.viewModel.pagedDataObserver?.load(.jumpTo( id: interactionId, paddingForInclusive: 5 )) } else { self?.viewModel.pagedDataObserver?.load(.untilInclusive( id: interactionId, padding: 5 )) } } return } let targetIndexPath: IndexPath = IndexPath( row: targetMessageIndex, section: messageSectionIndex ) // If we aren't animating or aren't highlighting then everything can be run immediately guard isAnimated && highlight else { self.tableView.scrollToRow( at: targetIndexPath, at: position, animated: (self.didFinishInitialLayout && isAnimated) ) self.handleInitialOffsetBounceBug(targetIndexPath: targetIndexPath, at: position) // If we haven't finished the initial layout then we want to delay the highlight slightly // so it doesn't look buggy with the push transition if highlight { DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.didFinishInitialLayout ? 0 : 150)) { [weak self] in self?.highlightCellIfNeeded(interactionId: interactionId) } } self.shouldHighlightNextScrollToInteraction = false self.focusedInteractionId = nil return } // If we are animating and highlighting then determine if we want to scroll to the target // cell (if we try to trigger the `scrollToRow` call and the animation doesn't occur then // the highlight will not be triggered so if a cell is entirely on the screen then just // don't bother scrolling) let targetRect: CGRect = self.tableView.rectForRow(at: targetIndexPath) guard !self.tableView.bounds.contains(targetRect) else { self.highlightCellIfNeeded(interactionId: interactionId) return } self.tableView.scrollToRow(at: targetIndexPath, at: position, animated: true) self.handleInitialOffsetBounceBug(targetIndexPath: targetIndexPath, at: position) } func highlightCellIfNeeded(interactionId: Int64) { self.shouldHighlightNextScrollToInteraction = false self.focusedInteractionId = nil // Trigger on the next run loop incase we are still finishing some other animation DispatchQueue.main.async { self.tableView .visibleCells .first(where: { ($0 as? VisibleMessageCell)?.viewModel?.id == interactionId }) .asType(VisibleMessageCell.self)? .highlight() } } private func handleInitialOffsetBounceBug(targetIndexPath: IndexPath, at position: UITableView.ScrollPosition) { /// Note: This code is a hack to prevent a weird 'bounce' behaviour that occurs when triggering the initial scroll due /// to the UITableView properly calculating it's cell sizes (it seems to layout ~3 times each with slightly different sizes) if !self.hasPerformedInitialScroll { let initialUpdateTime: CFTimeInterval = CACurrentMediaTime() var lastSize: CGSize = .zero self.tableView.afterNextLayoutSubviews( when: { [weak self] numSections, numRowInSections, updatedContentSize in // If too much time has passed or the section/row count doesn't match then // just stop the callback guard (CACurrentMediaTime() - initialUpdateTime) < 2 && lastSize != updatedContentSize && numSections > targetIndexPath.section && numRowInSections[targetIndexPath.section] > targetIndexPath.row else { return true } lastSize = updatedContentSize self?.tableView.scrollToRow( at: targetIndexPath, at: position, animated: false ) return false }, then: {} ) } } }