mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			2100 lines
		
	
	
		
			97 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			2100 lines
		
	
	
		
			97 KiB
		
	
	
	
		
			Swift
		
	
| // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
 | |
| 
 | |
| import UIKit
 | |
| import AVKit
 | |
| import GRDB
 | |
| import DifferenceKit
 | |
| import SessionUIKit
 | |
| import SessionMessagingKit
 | |
| import SessionUtilitiesKit
 | |
| import SignalUtilitiesKit
 | |
| 
 | |
| final class ConversationVC: BaseVC, LibSessionRespondingViewController, ConversationSearchControllerDelegate, UITableViewDataSource, UITableViewDelegate {
 | |
|     private static let loadingHeaderHeight: CGFloat = 40
 | |
|     
 | |
|     internal let viewModel: ConversationViewModel
 | |
|     private var dataChangeObservable: DatabaseCancellable? {
 | |
|         didSet { oldValue?.cancel() }   // Cancel the old observable if there was one
 | |
|     }
 | |
|     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 focusedInteractionInfo: Interaction.TimestampInfo?
 | |
|     var focusBehaviour: ConversationViewModel.FocusBehaviour = .none
 | |
|     
 | |
|     // 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
 | |
|     
 | |
|     /// These flags are true between `viewDid/Will Appear/Disappear` and is used to prevent keyboard changes
 | |
|     /// from trying to animate (as the animations can cause buggy transitions)
 | |
|     var viewIsDisappearing = false
 | |
|     var viewIsAppearing = false
 | |
|     var lastPresentedViewController: UIViewController?
 | |
|     
 | |
|     // Reaction
 | |
|     var currentReactionListSheet: ReactionListSheet?
 | |
|     var reactionExpandedMessageIds: Set<String> = []
 | |
| 
 | |
|     /// 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? {
 | |
|         return (
 | |
|             (viewModel.threadData.canWrite && 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 = { ((try? Identity.mnemonic()) ?? "") }()
 | |
| 
 | |
|     // FIXME: Would be good to create a Swift-based cache and replace this
 | |
|     lazy var mediaCache: NSCache<NSString, AnyObject> = {
 | |
|         let result = NSCache<NSString, AnyObject>()
 | |
|         result.countLimit = 40
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     lazy var recordVoiceMessageActivity = AudioActivity(audioDescription: "Voice message", behavior: .playAndRecord) // stringlint:disable
 | |
| 
 | |
|     lazy var searchController: ConversationSearchController = {
 | |
|         let result: ConversationSearchController = ConversationSearchController(
 | |
|             threadId: self.viewModel.threadData.threadId
 | |
|         )
 | |
|         result.uiSearchController.obscuresBackgroundDuringPresentation = false
 | |
|         result.delegate = self
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     // MARK: - UI
 | |
|     
 | |
|     var lastKnownKeyboardFrame: CGRect?
 | |
|     
 | |
|     var scrollButtonBottomConstraint: NSLayoutConstraint?
 | |
|     var scrollButtonMessageRequestsBottomConstraint: NSLayoutConstraint?
 | |
|     var messageRequestsViewBotomConstraint: NSLayoutConstraint?
 | |
|     var emptyStateLabelTopConstraint: NSLayoutConstraint?
 | |
|     
 | |
|     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.keyboardDismissMode = .interactive
 | |
|         result.contentInset = UIEdgeInsets(
 | |
|             top: 0,
 | |
|             leading: 0,
 | |
|             bottom: (viewModel.threadData.canWrite ?
 | |
|                 Values.mediumSpacing :
 | |
|                 (Values.mediumSpacing + (UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0))
 | |
|             ),
 | |
|             trailing: 0
 | |
|         )
 | |
|         result.registerHeaderFooterView(view: UITableViewHeaderFooterView.self)
 | |
|         result.register(view: DateHeaderCell.self)
 | |
|         result.register(view: UnreadMarkerCell.self)
 | |
|         result.register(view: VisibleMessageCell.self)
 | |
|         result.register(view: InfoMessageCell.self)
 | |
|         result.register(view: TypingIndicatorCell.self)
 | |
|         result.register(view: CallMessageCell.self)
 | |
|         result.estimatedSectionHeaderHeight = ConversationVC.loadingHeaderHeight
 | |
|         result.sectionFooterHeight = 0
 | |
|         result.dataSource = self
 | |
|         result.delegate = self
 | |
|         result.contentInsetAdjustmentBehavior = .never  // We custom handle it to prevent bugs
 | |
| 
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     lazy var snInputView: InputView = InputView(
 | |
|         threadVariant: self.viewModel.initialThreadVariant,
 | |
|         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)
 | |
|         result.isHidden = true
 | |
|         result.alpha = 0
 | |
|         
 | |
|         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 stateStackView: UIStackView = {
 | |
|         let result: UIStackView = UIStackView(arrangedSubviews: [ outdatedClientBanner, emptyStateLabelContainer ])
 | |
|         result.axis = .vertical
 | |
|         result.spacing = Values.smallSpacing
 | |
|         result.alignment = .fill
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
|     
 | |
|     lazy var outdatedClientBanner: InfoBanner = {
 | |
|         let info: InfoBanner.Info = InfoBanner.Info(
 | |
|             message: "disappearingMessagesLegacy"
 | |
|                 .put(key: "name", value: self.viewModel.threadData.displayName)
 | |
|                 .localized(),
 | |
|             backgroundColor: .primary,
 | |
|             messageFont: .systemFont(ofSize: Values.verySmallFontSize),
 | |
|             messageTintColor: .messageBubble_outgoingText,
 | |
|             messageLabelAccessibilityLabel: "Outdated client banner text",
 | |
|             height: 40
 | |
|         )
 | |
|         let result: InfoBanner = InfoBanner(info: info, dismiss: { [weak self] in
 | |
|             self?.removeOutdatedClientBanner()
 | |
|         })
 | |
|         result.accessibilityLabel = "Outdated client banner"
 | |
|         result.isAccessibilityElement = true
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
| 
 | |
|     lazy var blockedBanner: InfoBanner = {
 | |
|         let info: InfoBanner.Info = InfoBanner.Info(
 | |
|             message: self.viewModel.blockedBannerMessage,
 | |
|             backgroundColor: .danger,
 | |
|             messageFont: .boldSystemFont(ofSize: Values.smallFontSize),
 | |
|             messageTintColor: .textPrimary,
 | |
|             messageLabelAccessibilityLabel: "Blocked banner text",
 | |
|             height: 54
 | |
|         )
 | |
|         let result: InfoBanner = InfoBanner(info: info)
 | |
|         result.accessibilityLabel = "Blocked banner"
 | |
|         result.isAccessibilityElement = true
 | |
|         let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(unblock))
 | |
|         result.addGestureRecognizer(tapGestureRecognizer)
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
|     
 | |
|     private lazy var emptyStateLabelContainer: UIView = {
 | |
|         let result: UIView = UIView()
 | |
|         result.addSubview(emptyStateLabel)
 | |
|         emptyStateLabel.pin(.leading, to: .leading, of: result, withInset: Values.largeSpacing)
 | |
|         emptyStateLabel.pin(.trailing, to: .trailing, of: result, withInset: -Values.largeSpacing)
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
|     
 | |
|     private lazy var emptyStateLabel: UILabel = {
 | |
|         let text: String = emptyStateText(for: viewModel.threadData)
 | |
|         let result: UILabel = UILabel()
 | |
|         result.isAccessibilityElement = true
 | |
|         result.accessibilityIdentifier = "Empty state label"
 | |
|         result.accessibilityLabel = "Empty state label"
 | |
|         result.translatesAutoresizingMaskIntoConstraints = false
 | |
|         result.font = .systemFont(ofSize: Values.verySmallFontSize)
 | |
|         result.attributedText = NSAttributedString(string: text)
 | |
|             .adding(
 | |
|                 attributes: [.font: UIFont.boldSystemFont(ofSize: Values.verySmallFontSize)],
 | |
|                 range: text.range(of: self.viewModel.threadData.displayName)
 | |
|                     .map { NSRange($0, in: text) }
 | |
|                     .defaulting(to: NSRange(location: 0, length: 0))
 | |
|             )
 | |
|         result.themeTextColor = .textSecondary
 | |
|         result.textAlignment = .center
 | |
|         result.lineBreakMode = .byWordWrapping
 | |
|         result.numberOfLines = 0
 | |
| 
 | |
|         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: RoundIconButton = {
 | |
|         let result: RoundIconButton = RoundIconButton(
 | |
|             image: UIImage(named: "ic_chevron_down")?
 | |
|                 .withRenderingMode(.alwaysTemplate)
 | |
|         ) { [weak self] in
 | |
|             // The table view's content size is calculated by the estimated height of cells,
 | |
|             // so the result may be inaccurate before all the cells are loaded. Use this
 | |
|             // to scroll to the last row instead.
 | |
|             self?.scrollToBottom(isAnimated: true)
 | |
|         }
 | |
|         result.alpha = 0
 | |
|         result.accessibilityIdentifier = "Scroll button"
 | |
|         result.isAccessibilityElement = true
 | |
|         
 | |
|         return result
 | |
|     }()
 | |
|     
 | |
|     lazy var messageRequestFooterView: MessageRequestFooterView = MessageRequestFooterView(
 | |
|         threadVariant: self.viewModel.threadData.threadVariant,
 | |
|         canWrite: self.viewModel.threadData.canWrite,
 | |
|         threadIsMessageRequest: (self.viewModel.threadData.threadIsMessageRequest == true),
 | |
|         threadRequiresApproval: (self.viewModel.threadData.threadRequiresApproval == true),
 | |
|         onBlock: { [weak self] in self?.blockMessageRequest() },
 | |
|         onAccept: { [weak self] in self?.acceptMessageRequest() },
 | |
|         onDecline: { [weak self] in self?.declineMessageRequest() }
 | |
|     )
 | |
| 
 | |
|     // 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,
 | |
|         focusedInteractionInfo: Interaction.TimestampInfo? = nil,
 | |
|         using dependencies: Dependencies
 | |
|     ) {
 | |
|         self.viewModel = ConversationViewModel(threadId: threadId, threadVariant: threadVariant, focusedInteractionInfo: focusedInteractionInfo, using: dependencies)
 | |
|         
 | |
|         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,
 | |
|             initialIsNoteToSelf: self.viewModel.threadData.threadIsNoteToSelf,
 | |
|             initialIsBlocked: (self.viewModel.threadData.threadIsBlocked == true)
 | |
|         )
 | |
|         titleView.initialSetup(
 | |
|             with: self.viewModel.initialThreadVariant,
 | |
|             isNoteToSelf: self.viewModel.threadData.threadIsNoteToSelf
 | |
|         )
 | |
|         
 | |
|         // Constraints
 | |
|         view.addSubview(tableView)
 | |
|         tableView.pin(to: view)
 | |
| 
 | |
|         // Message requests view & scroll to bottom
 | |
|         view.addSubview(scrollButton)
 | |
|         view.addSubview(stateStackView)
 | |
|         view.addSubview(messageRequestFooterView)
 | |
|         
 | |
|         stateStackView.pin(.top, to: .top, of: view, withInset: 0)
 | |
|         stateStackView.pin(.leading, to: .leading, of: view, withInset: 0)
 | |
|         stateStackView.pin(.trailing, to: .trailing, of: view, withInset: 0)
 | |
|         self.emptyStateLabelTopConstraint = emptyStateLabel.pin(.top, to: .top, of: emptyStateLabelContainer, withInset: Values.largeSpacing)
 | |
|         
 | |
|         scrollButton.pin(.trailing, to: .trailing, of: view, withInset: -20)
 | |
|         messageRequestFooterView.pin(.leading, to: .leading, of: view, withInset: 16)
 | |
|         messageRequestFooterView.pin(.trailing, to: .trailing, of: view, withInset: -16)
 | |
|         self.messageRequestsViewBotomConstraint = messageRequestFooterView.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: messageRequestFooterView, withInset: -4)
 | |
| 
 | |
|         // 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(sendScreenshotNotification),
 | |
|             name: UIApplication.userDidTakeScreenshotNotification,
 | |
|             object: nil
 | |
|         )
 | |
|        
 | |
|         // Observe keyboard notifications
 | |
|         let keyboardNotifications: [Notification.Name] = [
 | |
|             UIResponder.keyboardWillShowNotification,
 | |
|             UIResponder.keyboardDidShowNotification,
 | |
|             UIResponder.keyboardWillChangeFrameNotification,
 | |
|             UIResponder.keyboardDidChangeFrameNotification,
 | |
|             UIResponder.keyboardWillHideNotification,
 | |
|             UIResponder.keyboardDidHideNotification
 | |
|         ]
 | |
|         keyboardNotifications.forEach { notification in
 | |
|             NotificationCenter.default.addObserver(
 | |
|                 self,
 | |
|                 selector: #selector(handleKeyboardNotification(_:)),
 | |
|                 name: notification,
 | |
|                 object: nil
 | |
|             )
 | |
|         }
 | |
| 
 | |
|         self.viewModel.navigatableState.setupBindings(viewController: self, disposables: &self.viewModel.disposables)
 | |
|         
 | |
|         // The first time the view loads we should mark the thread as read (in case it was manually
 | |
|         // marked as unread) - doing this here means if we add a "mark as unread" action within the
 | |
|         // conversation settings then we don't need to worry about the conversation getting marked as
 | |
|         // when when the user returns back through this view controller
 | |
|         self.viewModel.markAsRead(target: .thread, timestampMs: nil)
 | |
|     }
 | |
|     
 | |
|     override func viewWillAppear(_ animated: Bool) {
 | |
|         super.viewWillAppear(animated)
 | |
|         
 | |
|         startObservingChanges()
 | |
|         
 | |
|         /// If the view is removed and readded to the view hierarchy then `viewWillDisappear` will be called but `viewDidDisappear`
 | |
|         /// **won't**, as a result `viewIsDisappearing` would never get set to `false` - do so here to handle this case
 | |
|         viewIsDisappearing = false
 | |
|         viewIsAppearing = true
 | |
|     }
 | |
| 
 | |
|     override func viewDidAppear(_ animated: Bool) {
 | |
|         super.viewDidAppear(animated)
 | |
|         
 | |
|         /// When the `ConversationVC` is on the screen we want to store it so we can avoid sending notification without accessing the
 | |
|         /// main thread (we don't currently care if it's still in the nav stack though - so if a user is on a conversation settings screen this should
 | |
|         /// get cleared within `viewWillDisappear`)
 | |
|         ///
 | |
|         /// **Note:** We do this on an async queue because `Atomic<T>` can block if something else is mutating it and we want to avoid
 | |
|         /// the risk of blocking the conversation transition
 | |
|         DispatchQueue.global(qos: .userInitiated).async { [weak self] in
 | |
|             SessionApp.currentlyOpenConversationViewController.mutate { $0 = self }
 | |
|         }
 | |
|         
 | |
|         if delayFirstResponder || isShowingSearchUI {
 | |
|             delayFirstResponder = false
 | |
|             
 | |
|             DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in
 | |
|                 (self?.isShowingSearchUI == false ?
 | |
|                     self :
 | |
|                     self?.searchController.uiSearchController.searchBar
 | |
|                 )?.becomeFirstResponder()
 | |
|             }
 | |
|         }
 | |
|         else if !self.isFirstResponder && hasLoadedInitialThreadData && lastPresentedViewController == nil {
 | |
|             // After we have loaded the initial data if the user starts and cancels the interactive pop
 | |
|             // gesture the input view will disappear (but if we are returning from a presented view controller
 | |
|             // the keyboard will automatically reappear and calling this will break the first responder state
 | |
|             // so don't do it in that case)
 | |
|             self.becomeFirstResponder()
 | |
|         }
 | |
|         
 | |
|         recoverInputView { [weak self] in
 | |
|             // Flag that the initial layout has been completed (the flag blocks and unblocks a number
 | |
|             // of different behaviours)
 | |
|             self?.didFinishInitialLayout = true
 | |
|             self?.viewIsAppearing = false
 | |
|             self?.lastPresentedViewController = nil
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     override func viewWillDisappear(_ animated: Bool) {
 | |
|         super.viewWillDisappear(animated)
 | |
|         
 | |
|         /// When the `ConversationVC` is on the screen we want to store it so we can avoid sending notification without accessing the
 | |
|         /// main thread (we don't currently care if it's still in the nav stack though - so if a user leaves a conversation settings screen we clear
 | |
|         /// it, and if a user moves to a different `ConversationVC` this will get updated to that one within `viewDidAppear`)
 | |
|         ///
 | |
|         /// **Note:** We do this on an async queue because `Atomic<T>` can block if something else is mutating it and we want to avoid
 | |
|         /// the risk of blocking the conversation transition
 | |
|         DispatchQueue.global(qos: .userInitiated).async {
 | |
|             SessionApp.currentlyOpenConversationViewController.mutate { $0 = nil }
 | |
|         }
 | |
|         
 | |
|         viewIsDisappearing = true
 | |
|         lastPresentedViewController = self.presentedViewController
 | |
|         
 | |
|         // 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
 | |
|         viewIsDisappearing = false
 | |
|         
 | |
|         // If the user just created this thread but didn't send a message then we want to delete the
 | |
|         // "shadow" thread since it's not actually in use (this is to prevent it from taking up database
 | |
|         // space or unintentionally getting synced via libSession in the future)
 | |
|         let threadId: String = viewModel.threadData.threadId
 | |
|         
 | |
|         if
 | |
|             (
 | |
|                 self.navigationController == nil ||
 | |
|                 self.navigationController?.viewControllers.contains(self) == false
 | |
|             ) &&
 | |
|             viewModel.threadData.threadIsNoteToSelf == false &&
 | |
|             viewModel.threadData.threadShouldBeVisible == false &&
 | |
|             !LibSession.conversationInConfig(
 | |
|                 threadId: threadId,
 | |
|                 threadVariant: viewModel.threadData.threadVariant,
 | |
|                 visibleOnly: false,
 | |
|                 using: viewModel.dependencies
 | |
|             )
 | |
|         {
 | |
|             Storage.shared.writeAsync { db in
 | |
|                 _ = try SessionThread   // Intentionally use `deleteAll` here instead of `deleteOrLeave`
 | |
|                     .filter(id: threadId)
 | |
|                     .deleteAll(db)
 | |
|             }
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     @objc func applicationDidBecomeActive(_ notification: Notification) {
 | |
|         /// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query
 | |
|         DispatchQueue.main.async { [weak self] in
 | |
|             self?.startObservingChanges(didReturnFromBackground: true)
 | |
|         }
 | |
|         
 | |
|         recoverInputView()
 | |
|         
 | |
|         if !isShowingSearchUI && self.presentedViewController == nil {
 | |
|             if !self.isFirstResponder {
 | |
|                 self.becomeFirstResponder()
 | |
|             }
 | |
|             else {
 | |
|                 self.reloadInputViews()
 | |
|             }
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     @objc func applicationDidResignActive(_ notification: Notification) {
 | |
|         stopObservingChanges()
 | |
|     }
 | |
|     
 | |
|     // MARK: - Updating
 | |
|     
 | |
|     private func startObservingChanges(didReturnFromBackground: Bool = false) {
 | |
|         guard dataChangeObservable == nil else { return }
 | |
|         
 | |
|         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,
 | |
|                         (
 | |
|                             (try? SessionId.Prefix(from: sessionId)) == .blinded15 ||
 | |
|                             (try? SessionId.Prefix(from: sessionId)) == .blinded25
 | |
|                         ),
 | |
|                         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
 | |
|                         // nearest conversation list
 | |
|                         let maybeTargetViewController: UIViewController? = self?.navigationController?
 | |
|                             .viewControllers
 | |
|                             .last(where: { ($0 as? LibSessionRespondingViewController)?.isConversationList == true })
 | |
|                         
 | |
|                         if let targetViewController: UIViewController = maybeTargetViewController {
 | |
|                             self?.navigationController?.popToViewController(targetViewController, animated: true)
 | |
|                         }
 | |
|                         else {
 | |
|                             self?.navigationController?.popToRootViewController(animated: true)
 | |
|                         }
 | |
|                         return
 | |
|                     }
 | |
|                     
 | |
|                     // Stop observing changes
 | |
|                     self?.stopObservingChanges()
 | |
|                     Storage.shared.removeObserver(self?.viewModel.pagedDataObserver)
 | |
|                     
 | |
|                     // Swap the observing to the updated thread
 | |
|                     let newestVisibleMessageId: Int64? = self?.fullyVisibleCellViewModels()?.last?.id
 | |
|                     self?.viewModel.swapToThread(updatedThreadId: unblindedId, focussedMessageId: newestVisibleMessageId)
 | |
|                     
 | |
|                     // 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, changeset in
 | |
|                         self?.handleInteractionUpdates(updatedInteractionData, changeset: changeset)
 | |
|                     }
 | |
|                     
 | |
|                     // 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 {
 | |
|                         DispatchQueue.global(qos: .background).async {
 | |
|                             self?.viewModel.pagedDataObserver?.reload()
 | |
|                         }
 | |
|                     }
 | |
|                 }
 | |
|             }
 | |
|         )
 | |
|     }
 | |
|     
 | |
|     func stopObservingChanges() {
 | |
|         self.dataChangeObservable = nil
 | |
|         self.viewModel.onInteractionChange = nil
 | |
|     }
 | |
|     
 | |
|     private func emptyStateText(for threadData: SessionThreadViewModel) -> String {
 | |
|         switch (threadData.threadIsNoteToSelf, threadData.canWrite) {
 | |
|             case (true, _):
 | |
|                 return "noteToSelfEmpty".localized()
 | |
|             case (_, false):
 | |
|                 if threadData.profile?.blocksCommunityMessageRequests == true {
 | |
|                     return "messageRequestsTurnedOff"
 | |
|                         .put(key: "name", value: threadData.displayName)
 | |
|                         .localized()
 | |
|                 } else {
 | |
|                     return "conversationsEmpty"
 | |
|                         .put(key: "conversation_name", value: threadData.displayName)
 | |
|                         .localized()
 | |
|                 }
 | |
|             default:
 | |
|                 return "groupNoMessages"
 | |
|                     .put(key: "group_name", value: threadData.displayName)
 | |
|                     .localized()
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     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 {
 | |
|             // Need to correctly determine if it's the initial load otherwise we would be needlesly updating
 | |
|             // extra UI elements
 | |
|             let isInitialLoad: Bool = (
 | |
|                 !hasLoadedInitialThreadData &&
 | |
|                 hasReloadedThreadDataAfterDisappearance
 | |
|             )
 | |
|             hasLoadedInitialThreadData = true
 | |
|             hasReloadedThreadDataAfterDisappearance = true
 | |
|             
 | |
|             UIView.performWithoutAnimation {
 | |
|                 handleThreadUpdates(updatedThreadData, initialLoad: isInitialLoad)
 | |
|             }
 | |
|             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 ||
 | |
|             viewModel.threadData.disappearingMessagesConfiguration != updatedThreadData.disappearingMessagesConfiguration
 | |
|         {
 | |
|             titleView.update(
 | |
|                 with: updatedThreadData.displayName,
 | |
|                 isNoteToSelf: updatedThreadData.threadIsNoteToSelf,
 | |
|                 threadVariant: updatedThreadData.threadVariant,
 | |
|                 mutedUntilTimestamp: updatedThreadData.threadMutedUntilTimestamp,
 | |
|                 onlyNotifyForMentions: (updatedThreadData.threadOnlyNotifyForMentions == true),
 | |
|                 userCount: updatedThreadData.userCount,
 | |
|                 disappearingMessagesConfig: updatedThreadData.disappearingMessagesConfiguration
 | |
|             )
 | |
|             
 | |
|             // Update the empty state
 | |
|             let text: String = emptyStateText(for: updatedThreadData)
 | |
|             emptyStateLabel.attributedText = text.formatted(in: emptyStateLabel)
 | |
|         }
 | |
|         
 | |
|         if
 | |
|             initialLoad ||
 | |
|             viewModel.threadData.threadVariant != updatedThreadData.threadVariant ||
 | |
|             viewModel.threadData.threadIsNoteToSelf != updatedThreadData.threadIsNoteToSelf ||
 | |
|             viewModel.threadData.threadIsBlocked != updatedThreadData.threadIsBlocked ||
 | |
|             viewModel.threadData.threadIsMessageRequest != updatedThreadData.threadIsMessageRequest ||
 | |
|             viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval ||
 | |
|             viewModel.threadData.profile != updatedThreadData.profile
 | |
|         {
 | |
|             updateNavBarButtons(
 | |
|                 threadData: updatedThreadData,
 | |
|                 initialVariant: viewModel.initialThreadVariant,
 | |
|                 initialIsNoteToSelf: viewModel.threadData.threadIsNoteToSelf,
 | |
|                 initialIsBlocked: (viewModel.threadData.threadIsBlocked == true)
 | |
|             )
 | |
|         }
 | |
|         
 | |
|         if
 | |
|             initialLoad ||
 | |
|             viewModel.threadData.canWrite != updatedThreadData.canWrite ||
 | |
|             viewModel.threadData.threadVariant != updatedThreadData.threadVariant ||
 | |
|             viewModel.threadData.threadIsMessageRequest != updatedThreadData.threadIsMessageRequest ||
 | |
|             viewModel.threadData.threadRequiresApproval != updatedThreadData.threadRequiresApproval
 | |
|         {
 | |
|             if updatedThreadData.canWrite {
 | |
|                 self.showInputAccessoryView()
 | |
|             } else {
 | |
|                 self.hideInputAccessoryView()
 | |
|             }
 | |
|            
 | |
|             let messageRequestsViewWasVisible: Bool = (self.messageRequestFooterView.isHidden == false)
 | |
|             
 | |
|             UIView.animate(withDuration: 0.3) { [weak self] in
 | |
|                 self?.messageRequestFooterView.update(
 | |
|                     threadVariant: updatedThreadData.threadVariant,
 | |
|                     canWrite: updatedThreadData.canWrite,
 | |
|                     threadIsMessageRequest: (updatedThreadData.threadIsMessageRequest == true),
 | |
|                     threadRequiresApproval: (updatedThreadData.threadRequiresApproval == true)
 | |
|                 )
 | |
|                 self?.scrollButtonMessageRequestsBottomConstraint?.isActive = (
 | |
|                     self?.messageRequestFooterView.isHidden == false
 | |
|                 )
 | |
|                 self?.scrollButtonBottomConstraint?.isActive = (
 | |
|                     self?.scrollButtonMessageRequestsBottomConstraint?.isActive == false
 | |
|                 )
 | |
|                 
 | |
|                 // Update the table content inset and offset to account for
 | |
|                 // the dissapearance of the messageRequestsView
 | |
|                 if messageRequestsViewWasVisible != (self?.messageRequestFooterView.isHidden == false) {
 | |
|                     let messageRequestsOffset: CGFloat = (self?.messageRequestFooterView.bounds.height ?? 0)
 | |
|                     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.outdatedMemberId != updatedThreadData.outdatedMemberId ||
 | |
|             viewModel.threadData.disappearingMessagesConfiguration != updatedThreadData.disappearingMessagesConfiguration
 | |
|         {
 | |
|             addOrRemoveOutdatedClientBanner(
 | |
|                 outdatedMemberId: updatedThreadData.outdatedMemberId,
 | |
|                 disappearingMessagesConfiguration: updatedThreadData.disappearingMessagesConfiguration
 | |
|             )
 | |
|         }
 | |
|         
 | |
|         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
 | |
|         self.viewModel.updateThreadData(updatedThreadData)
 | |
|         
 | |
|         /// **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],
 | |
|         changeset: StagedChangeset<[ConversationViewModel.SectionModel]>,
 | |
|         initialLoad: Bool = false
 | |
|     ) {
 | |
|         // Determine if we have any messages for the empty state
 | |
|         let hasMessages: Bool = (updatedData
 | |
|             .filter { $0.model == .messages }
 | |
|             .first?
 | |
|             .elements
 | |
|             .isEmpty == 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 {
 | |
|             // Need to dispatch async to prevent this from causing glitches in the push animation
 | |
|             DispatchQueue.main.async {
 | |
|                 self.viewModel.updateInteractionData(updatedData)
 | |
|                 
 | |
|                 // Update the empty state
 | |
|                 self.emptyStateLabel.isHidden = hasMessages
 | |
|                 
 | |
|                 UIView.performWithoutAnimation {
 | |
|                     self.tableView.reloadData()
 | |
|                     self.hasLoadedInitialInteractionData = true
 | |
|                     self.performInitialScrollIfNeeded()
 | |
|                 }
 | |
|             }
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         // Update the empty state
 | |
|         self.emptyStateLabel.isHidden = hasMessages
 | |
|         
 | |
|         // 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
 | |
|         let onlyReplacedOptimisticUpdate: Bool = {
 | |
|             // Replacing an optimistic update means making a delete and an insert, which will be done
 | |
|             // as separate changes at the same positions
 | |
|             guard
 | |
|                 changeset.count > 1 &&
 | |
|                 changeset[changeset.count - 2].elementDeleted == changeset[changeset.count - 1].elementInserted
 | |
|             else { return false }
 | |
|             
 | |
|             let deletedModels: [MessageViewModel] = changeset[changeset.count - 2]
 | |
|                 .elementDeleted
 | |
|                 .map { self.viewModel.interactionData[$0.section].elements[$0.element] }
 | |
|             let insertedModels: [MessageViewModel] = changeset[changeset.count - 1]
 | |
|                 .elementInserted
 | |
|                 .map { updatedData[$0.section].elements[$0.element] }
 | |
|             
 | |
|             // Make sure all the deleted models were optimistic updates, the inserted models were not
 | |
|             // optimistic updates and they have the same timestamps
 | |
|             return (
 | |
|                 deletedModels.map { $0.id }.asSet() == [MessageViewModel.optimisticUpdateId] &&
 | |
|                 insertedModels.map { $0.id }.asSet() != [MessageViewModel.optimisticUpdateId] &&
 | |
|                 deletedModels.map { $0.timestampMs }.asSet() == insertedModels.map { $0.timestampMs }.asSet()
 | |
|             )
 | |
|         }()
 | |
|         let wasOnlyUpdates: Bool = (
 | |
|             onlyReplacedOptimisticUpdate || (
 | |
|                 changeset.count == 1 &&
 | |
|                 changeset[0].elementUpdated.count == changeset[0].changeCount
 | |
|             )
 | |
|         )
 | |
|         self.viewModel.sentMessageBeforeUpdate = false
 | |
|         
 | |
|         // When sending a message, or if there were only cell updates (ie. read status changes) we want to
 | |
|         // reload the UI instantly (with any form of animation the message sending feels somewhat unresponsive
 | |
|         // but an instant update feels snappy and without the instant update there is some overlap of the read
 | |
|         // status text change even though there shouldn't be any animations)
 | |
|         guard !didSendMessageBeforeUpdate && !wasOnlyUpdates else {
 | |
|             self.viewModel.updateInteractionData(updatedData)
 | |
|             self.tableView.reloadData()
 | |
|             
 | |
|             // If we just sent a message then we want to jump to the bottom of the conversation instantly
 | |
|             if didSendMessageBeforeUpdate {
 | |
|                 // We need to dispatch to the next run loop after a slight delay because it seems trying to scroll
 | |
|                 // immediately after triggering a 'reloadData' doesn't work and it's possible (eg. when uploading)
 | |
|                 // for two updates to come through in rapid succession which will result in two updates, the second
 | |
|                 // which stops the scroll from working
 | |
|                 DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(50)) { [weak self] in
 | |
|                     self?.tableView.layoutIfNeeded()
 | |
|                     self?.scrollToBottom(isAnimated: false)
 | |
|                     
 | |
|                     // 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?.scrollButton.alpha = 0
 | |
|                     self?.unreadCountView.alpha = 0
 | |
|                 }
 | |
|             }
 | |
|             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? = nil,
 | |
|                 oldVisibleIndexPath: IndexPath? = nil
 | |
|             ) {
 | |
|                 self.isInsertAtTop = isInsertAtTop
 | |
|                 self.firstIndexIsVisible = firstIndexIsVisible
 | |
|                 self.visibleIndexPath = visibleIndexPath
 | |
|                 self.oldVisibleIndexPath = oldVisibleIndexPath
 | |
|             }
 | |
|         }
 | |
|         
 | |
|         let numItemsInserted: Int = changeset.map { $0.elementInserted.count }.reduce(0, +)
 | |
|         let isInsert: Bool = (numItemsInserted > 0)
 | |
|         let wasLoadingMore: Bool = self.isLoadingMore
 | |
|         let wasOffsetCloseToBottom: Bool = self.isCloseToBottom
 | |
|         let numItemsInUpdatedData: [Int] = updatedData.map { $0.elements.count }
 | |
|         let didSwapAllContent: Bool = {
 | |
|             // The dynamic headers use negative id values so by using `compactMap` and returning
 | |
|             // null in those cases allows us to exclude them without another iteration via `filter`
 | |
|             let currentIds: Set<Int64> = (self.viewModel.interactionData
 | |
|                 .first { $0.model == .messages }?
 | |
|                 .elements
 | |
|                 .compactMap { $0.id > 0 ? $0.id : nil }
 | |
|                 .asSet())
 | |
|                 .defaulting(to: [])
 | |
|             let updatedIds: Set<Int64> = (updatedData
 | |
|                 .first { $0.model == .messages }?
 | |
|                 .elements
 | |
|                 .compactMap { $0.id > 0 ? $0.id : nil }
 | |
|                 .asSet())
 | |
|                 .defaulting(to: [])
 | |
|             
 | |
|             return updatedIds.isDisjoint(with: currentIds)
 | |
|         }()
 | |
|         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 firstVisibleIndexPath: IndexPath = self.tableView.indexPathsForVisibleRows?
 | |
|                     .filter({
 | |
|                         $0.section == oldSectionIndex &&
 | |
|                         self.viewModel.interactionData[$0.section].elements[$0.row].cellType != .dateHeader
 | |
|                     })
 | |
|                     .sorted()
 | |
|                     .first
 | |
|             else { return ItemChangeInfo() }
 | |
|             
 | |
|             guard
 | |
|                 let newFirstItemIndex: Int = updatedData[newSectionIndex].elements
 | |
|                     .firstIndex(where: { item -> Bool in
 | |
|                         // Since the first item is probably a `DateHeaderCell` (which would likely
 | |
|                         // be removed when inserting items above it) we check if the id matches
 | |
|                         let messages: [MessageViewModel] = self.viewModel
 | |
|                             .interactionData[oldSectionIndex]
 | |
|                             .elements
 | |
|                         
 | |
|                         return (
 | |
|                             item.id == messages[safe: 0]?.id ||
 | |
|                             item.id == messages[safe: 1]?.id
 | |
|                         )
 | |
|                     }),
 | |
|                 let newVisibleIndex: Int = updatedData[newSectionIndex].elements
 | |
|                     .firstIndex(where: { item in
 | |
|                         item.id == self.viewModel.interactionData[oldSectionIndex]
 | |
|                             .elements[firstVisibleIndexPath.row]
 | |
|                             .id
 | |
|                     })
 | |
|             else {
 | |
|                 let oldTimestamps: [Int64] = self.viewModel.interactionData[oldSectionIndex]
 | |
|                     .elements
 | |
|                     .filter { $0.cellType != .dateHeader }
 | |
|                     .map { $0.timestampMs }
 | |
|                 let newTimestamps: [Int64] = updatedData[newSectionIndex]
 | |
|                     .elements
 | |
|                     .filter { $0.cellType != .dateHeader }
 | |
|                     .map { $0.timestampMs }
 | |
|                 
 | |
|                 return ItemChangeInfo(
 | |
|                     isInsertAtTop: ((newTimestamps.max() ?? Int64.max) < (oldTimestamps.min() ?? Int64.min)),
 | |
|                     firstIndexIsVisible: (firstVisibleIndexPath.row == 0),
 | |
|                     oldVisibleIndexPath: firstVisibleIndexPath
 | |
|                 )
 | |
|             }
 | |
|             
 | |
|             return ItemChangeInfo(
 | |
|                 isInsertAtTop: (
 | |
|                     newSectionIndex > oldSectionIndex ||
 | |
|                     // Note: Using `1` here instead of `0` as the first item will generally
 | |
|                     // be a `DateHeaderCell` instead of a message
 | |
|                     newFirstItemIndex > 1
 | |
|                 ),
 | |
|                 firstIndexIsVisible: (firstVisibleIndexPath.row == 0),
 | |
|                 visibleIndexPath: IndexPath(row: newVisibleIndex, section: newSectionIndex),
 | |
|                 oldVisibleIndexPath: firstVisibleIndexPath
 | |
|             )
 | |
|         }()
 | |
|         
 | |
|         guard !isInsert || (!didSwapAllContent && itemChangeInfo.isInsertAtTop) else {
 | |
|             self.viewModel.updateInteractionData(updatedData)
 | |
|             self.tableView.reloadData()
 | |
|             
 | |
|             // If we had a focusedInteractionInfo then scroll to it (and hide the search
 | |
|             // result bar loading indicator)
 | |
|             if let focusedInteractionInfo: Interaction.TimestampInfo = self.focusedInteractionInfo {
 | |
|                 self.tableView.afterNextLayoutSubviews(when: { _, _, _ in true }, then: { [weak self] in
 | |
|                     self?.searchController.resultsBar.stopLoading()
 | |
|                     self?.scrollToInteractionIfNeeded(
 | |
|                         with: focusedInteractionInfo,
 | |
|                         focusBehaviour: (self?.focusBehaviour ?? .none),
 | |
|                         contentSwapLocation: {
 | |
|                             switch (didSwapAllContent, itemChangeInfo.isInsertAtTop) {
 | |
|                                 case (true, true): return .earlier
 | |
|                                 case (true, false): return .later
 | |
|                                 default: return .none
 | |
|                             }
 | |
|                         }(),
 | |
|                         isAnimated: true
 | |
|                     )
 | |
|                     
 | |
|                     if wasLoadingMore {
 | |
|                         // Complete page loading
 | |
|                         self?.isLoadingMore = false
 | |
|                         self?.autoLoadNextPageIfNeeded()
 | |
|                     }
 | |
|                 })
 | |
|             }
 | |
|             else if wasOffsetCloseToBottom && !wasLoadingMore && numItemsInserted < 5 {
 | |
|                 /// 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)
 | |
|                 ///
 | |
|                 /// **Note:** We won't automatically scroll to the bottom if 5 or more messages were inserted (to avoid endlessly
 | |
|                 /// auto-scrolling to the bottom when fetching new pages of data within open groups
 | |
|                 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()
 | |
|             }
 | |
|             else {
 | |
|                 // Need to update the scroll button alpha in case new messages were added but we didn't scroll
 | |
|                 self.updateScrollToBottom()
 | |
|             }
 | |
|             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 itemChangeInfo.isInsertAtTop, let visibleIndexPath: IndexPath = itemChangeInfo.visibleIndexPath, let oldVisibleIndexPath: IndexPath = itemChangeInfo.oldVisibleIndexPath {
 | |
|             let oldCellRect: CGRect = self.tableView.rectForRow(at: oldVisibleIndexPath)
 | |
|             let oldCellTopOffset: CGFloat = (self.tableView.frame.minY - self.tableView.convert(oldCellRect, to: self.tableView.superview).minY)
 | |
|             
 | |
|             // 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)
 | |
|                     UIView.performWithoutAnimation {
 | |
|                         self?.tableView.scrollToRow(at: visibleIndexPath, at: .top, animated: false)
 | |
|                         self?.tableView.contentOffset.y += oldCellTopOffset
 | |
|                     }
 | |
|                     
 | |
|                     if let focusedInteractionInfo: Interaction.TimestampInfo = self?.focusedInteractionInfo {
 | |
|                         DispatchQueue.main.async { [weak self] in
 | |
|                             // If we had a focusedInteractionInfo then scroll to it (and hide the search
 | |
|                             // result bar loading indicator)
 | |
|                             self?.searchController.resultsBar.stopLoading()
 | |
|                             self?.scrollToInteractionIfNeeded(
 | |
|                                 with: focusedInteractionInfo,
 | |
|                                 focusBehaviour: (self?.focusBehaviour ?? .none),
 | |
|                                 isAnimated: true
 | |
|                             )
 | |
|                         }
 | |
|                     }
 | |
|                     
 | |
|                     // Complete page loading
 | |
|                     self?.isLoadingMore = false
 | |
|                     self?.autoLoadNextPageIfNeeded()
 | |
|                 }
 | |
|             )
 | |
|         }
 | |
|         else if wasLoadingMore {
 | |
|             if let focusedInteractionInfo: Interaction.TimestampInfo = self.focusedInteractionInfo {
 | |
|                 DispatchQueue.main.async { [weak self] in
 | |
|                     // If we had a focusedInteractionInfo then scroll to it (and hide the search
 | |
|                     // result bar loading indicator)
 | |
|                     self?.searchController.resultsBar.stopLoading()
 | |
|                     self?.scrollToInteractionIfNeeded(
 | |
|                         with: focusedInteractionInfo,
 | |
|                         focusBehaviour: (self?.focusBehaviour ?? .none),
 | |
|                         isAnimated: true
 | |
|                     )
 | |
|                     
 | |
|                     // Complete page loading
 | |
|                     self?.isLoadingMore = false
 | |
|                     self?.autoLoadNextPageIfNeeded()
 | |
|                 }
 | |
|             }
 | |
|             else {
 | |
|                 // Complete page loading
 | |
|                 self.isLoadingMore = false
 | |
|                 self.autoLoadNextPageIfNeeded()
 | |
|             }
 | |
|         }
 | |
|         
 | |
|         // Update the messages
 | |
|         self.tableView.reload(
 | |
|             using: changeset,
 | |
|             deleteSectionsAnimation: .none,
 | |
|             insertSectionsAnimation: .none,
 | |
|             reloadSectionsAnimation: .none,
 | |
|             deleteRowsAnimation: .fade,
 | |
|             insertRowsAnimation: .none,
 | |
|             reloadRowsAnimation: .none,
 | |
|             interrupt: { itemChangeInfo.isInsertAtTop || $0.changeCount > 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 focusedInteractionInfo: Interaction.TimestampInfo = self.viewModel.focusedInteractionInfo {
 | |
|             self.scrollToInteractionIfNeeded(
 | |
|                 with: focusedInteractionInfo,
 | |
|                 focusBehaviour: self.viewModel.focusBehaviour,
 | |
|                 isAnimated: false
 | |
|             )
 | |
|         }
 | |
|         else {
 | |
|             self.scrollToBottom(isAnimated: false)
 | |
|         }
 | |
|         self.updateScrollToBottom()
 | |
|         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.hasLoadedInitialInteractionData &&
 | |
|             !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: .userInitiated).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,
 | |
|         initialIsNoteToSelf: Bool,
 | |
|         initialIsBlocked: Bool
 | |
|     ) {
 | |
|         navigationItem.hidesBackButton = isShowingSearchUI
 | |
| 
 | |
|         if isShowingSearchUI {
 | |
|             navigationItem.leftBarButtonItem = nil
 | |
|             navigationItem.rightBarButtonItems = []
 | |
|         }
 | |
|         else {
 | |
|             let shouldHaveCallButton: Bool = (
 | |
|                 SessionCall.isEnabled &&
 | |
|                 (threadData?.threadVariant ?? initialVariant) == .contact &&
 | |
|                 (threadData?.threadIsNoteToSelf ?? initialIsNoteToSelf) == false &&
 | |
|                 (threadData?.threadIsBlocked ?? initialIsBlocked) == false
 | |
|             )
 | |
|             
 | |
|             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
 | |
|                             )
 | |
|                         )
 | |
|                     ),
 | |
|                     (shouldHaveCallButton ?
 | |
|                         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(size: .navigation)
 | |
|                     profilePictureView.update(
 | |
|                         publicKey: threadData.threadId,  // Contact thread uses the contactId
 | |
|                         threadVariant: threadData.threadVariant,
 | |
|                         customImageData: nil,
 | |
|                         profile: threadData.profile,
 | |
|                         additionalProfile: nil
 | |
|                     )
 | |
|                     profilePictureView.customWidth = (44 - 16)   // Width of the standard back button
 | |
| 
 | |
|                     let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openSettings))
 | |
|                     profilePictureView.addGestureRecognizer(tapGestureRecognizer)
 | |
| 
 | |
|                     let settingsButtonItem: UIBarButtonItem = UIBarButtonItem(customView: profilePictureView)
 | |
|                     settingsButtonItem.accessibilityLabel = "More options"
 | |
|                     settingsButtonItem.isAccessibilityElement = true
 | |
|                     
 | |
|                     if shouldHaveCallButton {
 | |
|                         let callButton = UIBarButtonItem(
 | |
|                             image: UIImage(named: "Phone"),
 | |
|                             style: .plain,
 | |
|                             target: self,
 | |
|                             action: #selector(startCall)
 | |
|                         )
 | |
|                         callButton.accessibilityLabel = "Call"
 | |
|                         callButton.isAccessibilityElement = true
 | |
|                         
 | |
|                         navigationItem.rightBarButtonItems = [settingsButtonItem, callButton]
 | |
|                     }
 | |
|                     else {
 | |
|                         navigationItem.rightBarButtonItems = [settingsButtonItem]
 | |
|                     }
 | |
|                     
 | |
|                 default:
 | |
|                     let rightBarButtonItem: UIBarButtonItem = UIBarButtonItem(image: UIImage(named: "Gear"), style: .plain, target: self, action: #selector(openSettings))
 | |
|                     rightBarButtonItem.accessibilityLabel = "More options"
 | |
|                     rightBarButtonItem.isAccessibilityElement = true
 | |
| 
 | |
|                     navigationItem.rightBarButtonItems = [rightBarButtonItem]
 | |
|             }
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     // MARK: - Keyboard Avoidance
 | |
| 
 | |
|     @objc func handleKeyboardNotification(_ notification: Notification) {
 | |
|         guard !viewIsDisappearing else { return }
 | |
|         guard
 | |
|             !viewIsDisappearing,
 | |
|             let userInfo: [AnyHashable: Any] = notification.userInfo,
 | |
|             var keyboardEndFrame: CGRect = userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
 | |
|         else { return }
 | |
|         
 | |
|         // If reduce motion+crossfade transitions is on, in iOS 14 UIKit vends out a keyboard end frame
 | |
|         // of CGRect zero. This breaks the math below.
 | |
|         //
 | |
|         // If our keyboard end frame is CGRectZero, build a fake rect that's translated off the bottom edge.
 | |
|         if keyboardEndFrame == .zero {
 | |
|             keyboardEndFrame = CGRect(
 | |
|                 x: UIScreen.main.bounds.minX,
 | |
|                 y: UIScreen.main.bounds.maxY,
 | |
|                 width: UIScreen.main.bounds.width,
 | |
|                 height: 0
 | |
|             )
 | |
|         }
 | |
|         
 | |
|         // No nothing if there was no change
 | |
|         let keyboardEndFrameConverted: CGRect = self.view.convert(keyboardEndFrame, from: nil)
 | |
|         guard keyboardEndFrameConverted != lastKnownKeyboardFrame else { return }
 | |
|         
 | |
|         self.lastKnownKeyboardFrame = keyboardEndFrameConverted
 | |
|         
 | |
|         // 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 curveValue: Int = ((userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int) ?? Int(UIView.AnimationOptions.curveEaseInOut.rawValue))
 | |
|         let options: UIView.AnimationOptions = UIView.AnimationOptions(rawValue: UInt(curveValue << 16))
 | |
|         let duration = ((userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval) ?? 0)
 | |
|         
 | |
|         guard didFinishInitialLayout && !viewIsAppearing, duration > 0, !UIAccessibility.isReduceMotionEnabled else {
 | |
|             // UIKit by default (sometimes? never?) animates all changes in response to keyboard events.
 | |
|             // We want to suppress those animations if the view isn't visible,
 | |
|             // otherwise presentation animations don't work properly.
 | |
|             UIView.performWithoutAnimation {
 | |
|                 self.updateKeyboardAvoidance()
 | |
|             }
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         UIView.animate(
 | |
|             withDuration: duration,
 | |
|             delay: 0,
 | |
|             options: options,
 | |
|             animations: { [weak self] in
 | |
|                 self?.updateKeyboardAvoidance()
 | |
|                 self?.view.layoutIfNeeded()
 | |
|             },
 | |
|             completion: nil
 | |
|         )
 | |
|     }
 | |
|     
 | |
|     private func updateKeyboardAvoidance() {
 | |
|         guard let lastKnownKeyboardFrame: CGRect = self.lastKnownKeyboardFrame else { return }
 | |
|         
 | |
|         let messageRequestsOffset: CGFloat = (messageRequestFooterView.isHidden ? 0 :
 | |
|             messageRequestFooterView.bounds.height)
 | |
|         let viewIntersection = view.bounds.intersection(lastKnownKeyboardFrame)
 | |
|         let bottomOffset: CGFloat = (viewIntersection.isEmpty ? 0 : view.bounds.maxY - viewIntersection.minY)
 | |
|         let contentInsets = UIEdgeInsets(
 | |
|             top: 0,
 | |
|             left: 0,
 | |
|             bottom: bottomOffset + Values.mediumSpacing + messageRequestsOffset,
 | |
|             right: 0
 | |
|         )
 | |
|         let insetDifference: CGFloat = (contentInsets.bottom - tableView.contentInset.bottom)
 | |
|         scrollButtonBottomConstraint?.constant = -(bottomOffset + 12)
 | |
|         messageRequestsViewBotomConstraint?.constant = -bottomOffset
 | |
|         tableView.contentInset = contentInsets
 | |
|         tableView.scrollIndicatorInsets = contentInsets
 | |
|         
 | |
|         // Only modify the contentOffset if we aren't at the bottom of the tableView, with a little
 | |
|         // buffer (if we are at the bottom then it'll automatically scroll for us and modifying the
 | |
|         // value will break things)
 | |
|         let tableViewBottom: CGFloat = (tableView.contentSize.height - tableView.bounds.height + tableView.contentInset.bottom)
 | |
|         
 | |
|         if tableView.contentOffset.y < (tableViewBottom - 5) {
 | |
|             tableView.contentOffset.y += insetDifference
 | |
|         }
 | |
|         
 | |
|         updateScrollToBottom()
 | |
|     }
 | |
| 
 | |
|     // MARK: - General
 | |
|     
 | |
|     func addOrRemoveOutdatedClientBanner(
 | |
|         outdatedMemberId: String?,
 | |
|         disappearingMessagesConfiguration: DisappearingMessagesConfiguration?
 | |
|     ) {
 | |
|         let currentDisappearingMessagesConfiguration: DisappearingMessagesConfiguration? = disappearingMessagesConfiguration ?? self.viewModel.threadData.disappearingMessagesConfiguration
 | |
|         // Do not show the banner until the new disappearing messages is enabled
 | |
|         guard currentDisappearingMessagesConfiguration?.isEnabled == true else {
 | |
|             self.outdatedClientBanner.isHidden = true
 | |
|             self.emptyStateLabelTopConstraint?.constant = Values.largeSpacing
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         guard let outdatedMemberId: String = outdatedMemberId else {
 | |
|             UIView.animate(
 | |
|                 withDuration: 0.25,
 | |
|                 animations: { [weak self] in
 | |
|                     self?.outdatedClientBanner.alpha = 0
 | |
|                 },
 | |
|                 completion: { [weak self] _ in
 | |
|                     self?.outdatedClientBanner.isHidden = true
 | |
|                     self?.outdatedClientBanner.alpha = 1
 | |
|                     self?.emptyStateLabelTopConstraint?.constant = Values.largeSpacing
 | |
|                 }
 | |
|             )
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         self.outdatedClientBanner.update(
 | |
|             message: "disappearingMessagesLegacy"
 | |
|                 .put(key: "name", value: Profile.displayName(id: outdatedMemberId, threadVariant: self.viewModel.threadData.threadVariant))
 | |
|                 .localized(),
 | |
|             dismiss: self.removeOutdatedClientBanner
 | |
|         )
 | |
| 
 | |
|         self.outdatedClientBanner.isHidden = false
 | |
|         self.emptyStateLabelTopConstraint?.constant = 0
 | |
|     }
 | |
|     
 | |
|     private func removeOutdatedClientBanner() {
 | |
|         guard let outdatedMemberId: String = self.viewModel.threadData.outdatedMemberId else { return }
 | |
|         Storage.shared.writeAsync { db in
 | |
|             try Contact
 | |
|                 .filter(id: outdatedMemberId)
 | |
|                 .updateAll(db, Contact.Columns.lastKnownClientVersion.set(to: nil))
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     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(completion: (() -> ())? = nil) {
 | |
|         // 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
 | |
|             completion?()
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     // 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) { [weak self] updatedInfo, error in
 | |
|                         DispatchQueue.main.async {
 | |
|                             guard error == nil else {
 | |
|                                 let modal: ConfirmationModal = ConfirmationModal(
 | |
|                                     targetView: self?.view,
 | |
|                                     info: ConfirmationModal.Info(
 | |
|                                         title: "theError".localized(),
 | |
|                                         body: .text("audioUnableToPlay".localized()),
 | |
|                                         cancelTitle: "okay".localized(),
 | |
|                                         cancelStyle: .alert_text
 | |
|                                     )
 | |
|                                 )
 | |
|                                 self?.present(modal, animated: true)
 | |
|                                 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, 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: .userInitiated).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 lastInteractionInfo: Interaction.TimestampInfo = {
 | |
|                 guard
 | |
|                     let interactionId: Int64 = self.viewModel.threadData.interactionId,
 | |
|                     let timestampMs: Int64 = self.viewModel.threadData.interactionTimestampMs
 | |
|                 else {
 | |
|                     return Interaction.TimestampInfo(
 | |
|                         id: messages[messages.count - 1].id,
 | |
|                         timestampMs: messages[messages.count - 1].timestampMs
 | |
|                     )
 | |
|                 }
 | |
|                 
 | |
|                 return Interaction.TimestampInfo(id: interactionId, timestampMs: timestampMs)
 | |
|             }()
 | |
|             
 | |
|             self.scrollToInteractionIfNeeded(
 | |
|                 with: lastInteractionInfo,
 | |
|                 position: .bottom,
 | |
|                 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.viewModel.markAsRead(
 | |
|             target: .threadAndInteractions(interactionsBeforeInclusive: nil),
 | |
|             timestampMs: nil
 | |
|         )
 | |
|     }
 | |
| 
 | |
|     func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
 | |
|         isUserScrolling = true
 | |
|     }
 | |
| 
 | |
|     func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
 | |
|         isUserScrolling = false
 | |
|     }
 | |
| 
 | |
|     func scrollViewDidScroll(_ scrollView: UIScrollView) {
 | |
|         self.updateScrollToBottom()
 | |
|         
 | |
|         // The initial scroll can trigger this logic but we already mark the initially focused message
 | |
|         // as read so don't run the below until the user actually scrolls after the initial layout
 | |
|         guard self.didFinishInitialLayout else { return }
 | |
|         
 | |
|         self.markFullyVisibleAndOlderCellsAsRead(interactionInfo: nil)
 | |
|     }
 | |
|     
 | |
|     func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
 | |
|         guard let focusedInteractionInfo: Interaction.TimestampInfo = self.focusedInteractionInfo else {
 | |
|             self.focusedInteractionInfo = nil
 | |
|             self.focusBehaviour = .none
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         let behaviour: ConversationViewModel.FocusBehaviour = self.focusBehaviour
 | |
|         self.focusedInteractionInfo = nil
 | |
|         self.focusBehaviour = .none
 | |
|         
 | |
|         DispatchQueue.main.async { [weak self] in
 | |
|             self?.markFullyVisibleAndOlderCellsAsRead(interactionInfo: focusedInteractionInfo)
 | |
|             self?.highlightCellIfNeeded(interactionId: focusedInteractionInfo.id, behaviour: behaviour)
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     func updateUnreadCountView(unreadCount: UInt?) {
 | |
|         let unreadCount: Int = Int(unreadCount ?? 0)
 | |
|         let fontSize: CGFloat = (unreadCount < 10000 ? Values.verySmallFontSize : 8)
 | |
|         unreadCountLabel.text = (unreadCount < 10000 ? "\(unreadCount)" : "9999+") // stringlint:disable
 | |
|         unreadCountLabel.font = .boldSystemFont(ofSize: fontSize)
 | |
|         unreadCountView.isHidden = (unreadCount == 0)
 | |
|     }
 | |
|     
 | |
|     public func updateScrollToBottom(force: Bool = false) {
 | |
|         // Don't update the scroll button until we have actually setup the initial scroll position to avoid
 | |
|         // any odd flickering or incorrect appearance
 | |
|         guard self.didFinishInitialLayout || force else { return }
 | |
|         
 | |
|         // If we have a 'loadNewer' item in the interaction data then there are subsequent pages and the
 | |
|         // 'scrollToBottom' actions should always be visible to allow the user to jump to the bottom (without
 | |
|         // this the button will fade out as the user gets close to the bottom of the current page)
 | |
|         guard !self.viewModel.interactionData.contains(where: { $0.model == .loadNewer }) else {
 | |
|             self.scrollButton.alpha = 1
 | |
|             self.unreadCountView.alpha = 1
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         // Calculate the target opacity for the scroll button
 | |
|         let contentOffsetY: CGFloat = tableView.contentOffset.y
 | |
|         let x = (lastPageTop - ConversationVC.bottomInset - contentOffsetY).clamp(0, .greatestFiniteMagnitude)
 | |
|         let a = 1 / (ConversationVC.scrollButtonFullVisibilityThreshold - ConversationVC.scrollButtonNoVisibilityThreshold)
 | |
|         let targetOpacity: CGFloat = max(0, min(1, a * x))
 | |
|         
 | |
|         self.scrollButton.alpha = targetOpacity
 | |
|         self.unreadCountView.alpha = targetOpacity
 | |
|     }
 | |
| 
 | |
|     // 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.center(.vertical, in: searchBarContainer)
 | |
|             searchBar.pin(.top, to: .top, of: searchBar)
 | |
|             searchBar.pin(.leading, to: .leading, of: searchBar)
 | |
|             searchBar.pin(.trailing, to: .leading, of: ipadCancelButton, withInset: -Values.smallSpacing)
 | |
|             searchBar.pin(.bottom, to: .bottom, of: searchBar)
 | |
|         }
 | |
|         else {
 | |
|             searchBar.pin(toMarginsOf: searchBarContainer)
 | |
|         }
 | |
|         
 | |
|         // Nav bar buttons
 | |
|         updateNavBarButtons(
 | |
|             threadData: viewModel.threadData,
 | |
|             initialVariant: viewModel.initialThreadVariant,
 | |
|             initialIsNoteToSelf: viewModel.threadData.threadIsNoteToSelf,
 | |
|             initialIsBlocked: (viewModel.threadData.threadIsBlocked == true)
 | |
|         )
 | |
|         
 | |
|         // 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.
 | |
|         searchController.uiSearchController.stubbableSearchBar.stubbedNextResponder = self
 | |
|     }
 | |
| 
 | |
|     @objc func hideSearchUI() {
 | |
|         isShowingSearchUI = false
 | |
|         navigationItem.titleView = titleView
 | |
|         updateNavBarButtons(
 | |
|             threadData: viewModel.threadData,
 | |
|             initialVariant: viewModel.initialThreadVariant,
 | |
|             initialIsNoteToSelf: viewModel.threadData.threadIsNoteToSelf,
 | |
|             initialIsBlocked: (viewModel.threadData.threadIsBlocked == true)
 | |
|         )
 | |
|         
 | |
|         searchController.uiSearchController.stubbableSearchBar.stubbedNextResponder = nil
 | |
|         becomeFirstResponder()
 | |
|         reloadInputViews()
 | |
|     }
 | |
| 
 | |
|     func didDismissSearchController(_ searchController: UISearchController) {
 | |
|         hideSearchUI()
 | |
|     }
 | |
|     
 | |
|     func currentVisibleIds() -> [Int64] { return (fullyVisibleCellViewModels() ?? []).map { $0.id } }
 | |
|     
 | |
|     func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Interaction.TimestampInfo]?, searchText: String?) {
 | |
|         viewModel.lastSearchedText = searchText
 | |
|         tableView.reloadRows(at: tableView.indexPathsForVisibleRows ?? [], with: UITableView.RowAnimation.none)
 | |
|     }
 | |
| 
 | |
|     func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionInfo interactionInfo: Interaction.TimestampInfo) {
 | |
|         scrollToInteractionIfNeeded(with: interactionInfo, focusBehaviour: .highlight)
 | |
|     }
 | |
| 
 | |
|     func scrollToInteractionIfNeeded(
 | |
|         with interactionInfo: Interaction.TimestampInfo,
 | |
|         focusBehaviour: ConversationViewModel.FocusBehaviour = .none,
 | |
|         position: UITableView.ScrollPosition = .middle,
 | |
|         contentSwapLocation: ConversationViewModel.ContentSwapLocation = .none,
 | |
|         originalIndexPath: IndexPath? = nil,
 | |
|         isAnimated: Bool = true
 | |
|     ) {
 | |
|         // Store the info incase we need to load more data (call will be re-triggered)
 | |
|         self.focusBehaviour = focusBehaviour
 | |
|         self.focusedInteractionInfo = interactionInfo
 | |
|         
 | |
|         // 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 == interactionInfo.id })
 | |
|         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: .userInitiated).async { [weak self] in
 | |
|                 self?.viewModel.pagedDataObserver?.load(.jumpTo(
 | |
|                     id: interactionInfo.id,
 | |
|                     paddingForInclusive: 5
 | |
|                 ))
 | |
|             }
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         // If it's before the initial layout and the index before the target is an 'UnreadMarker' then
 | |
|         // we should scroll to that instead (will be better UX)
 | |
|         let targetIndexPath: IndexPath = {
 | |
|             guard
 | |
|                 !self.didFinishInitialLayout &&
 | |
|                 targetMessageIndex > 0 &&
 | |
|                 self.viewModel.interactionData[messageSectionIndex]
 | |
|                     .elements[targetMessageIndex - 1]
 | |
|                     .cellType == .unreadMarker
 | |
|             else {
 | |
|                 return IndexPath(
 | |
|                     row: targetMessageIndex,
 | |
|                     section: messageSectionIndex
 | |
|                 )
 | |
|             }
 | |
|             
 | |
|             return IndexPath(
 | |
|                 row: (targetMessageIndex - 1),
 | |
|                 section: messageSectionIndex
 | |
|             )
 | |
|         }()
 | |
|         let targetPosition: UITableView.ScrollPosition = {
 | |
|             guard position == .middle else { return position }
 | |
|             
 | |
|             // Make sure the target cell isn't too large for the screen (if it is then we want to scroll
 | |
|             // it to the top rather than the middle
 | |
|             let cellSize: CGSize = self.tableView(
 | |
|                 tableView,
 | |
|                 cellForRowAt: targetIndexPath
 | |
|             ).systemLayoutSizeFitting(view.bounds.size)
 | |
|             
 | |
|             guard cellSize.height > tableView.frame.size.height else { return position }
 | |
|             
 | |
|             return .top
 | |
|         }()
 | |
|         
 | |
|         // If we aren't animating or aren't highlighting then everything can be run immediately
 | |
|         guard isAnimated else {
 | |
|             self.tableView.scrollToRow(
 | |
|                 at: targetIndexPath,
 | |
|                 at: targetPosition,
 | |
|                 animated: (self.didFinishInitialLayout && isAnimated)
 | |
|             )
 | |
|             
 | |
|             // If we haven't finished the initial layout then we want to delay the highlight/markRead slightly
 | |
|             // so it doesn't look buggy with the push transition and we know for sure the correct visible cells
 | |
|             // have been loaded
 | |
|             DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.didFinishInitialLayout ? 0 : 150)) { [weak self] in
 | |
|                 self?.markFullyVisibleAndOlderCellsAsRead(interactionInfo: interactionInfo)
 | |
|                 self?.highlightCellIfNeeded(interactionId: interactionInfo.id, behaviour: focusBehaviour)
 | |
|                 self?.updateScrollToBottom(force: true)
 | |
|             }
 | |
|             
 | |
|             self.focusedInteractionInfo = nil
 | |
|             self.focusBehaviour = .none
 | |
|             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.markFullyVisibleAndOlderCellsAsRead(interactionInfo: interactionInfo)
 | |
|             self.highlightCellIfNeeded(interactionId: interactionInfo.id, behaviour: focusBehaviour)
 | |
|             self.focusedInteractionInfo = nil
 | |
|             self.focusBehaviour = .none
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         // As an optimisation if the target cell is too far away we just reload the entire table instead of loading
 | |
|         // all intermediate messages, as a result the scroll animation can be buggy (as the contentOffset could
 | |
|         // actually end up on the wrong side of the destination before the scroll animation starts)
 | |
|         //
 | |
|         // To get around this we immediately jump to a position 10 cells above/below the destination and then scroll
 | |
|         // which appears as though the screen has properly scrolled between the messages
 | |
|         switch contentSwapLocation {
 | |
|             case .none:
 | |
|                 if let originalIndexPath: IndexPath = originalIndexPath {
 | |
|                     // Since we use `estimatedRowHeight` instead of an explicit height there is an annoying issue
 | |
|                     // where the cells won't have their heights calculated correctly so jumping between cells can
 | |
|                     // result in a scroll animation going the wrong direction - by jumping to the destination and
 | |
|                     // back to the current cell all of the relevant cells will have their frames calculated correctly
 | |
|                     // and the animation will look correct
 | |
|                     self.tableView.scrollToRow(at: targetIndexPath, at: targetPosition, animated: false)
 | |
|                     self.tableView.scrollToRow(at: originalIndexPath, at: targetPosition, animated: false)
 | |
|                 }
 | |
|                 
 | |
|             case .earlier:
 | |
|                 let targetRow: Int = min(targetIndexPath.row + 10, self.viewModel.interactionData[messageSectionIndex].elements.count - 1)
 | |
|                 
 | |
|                 self.tableView.contentOffset = CGPoint(x: 0, y: self.tableView.rectForRow(at: IndexPath(row: targetRow, section: targetIndexPath.section)).midY)
 | |
|                 
 | |
|             case .later:
 | |
|                 let targetRow: Int = min(targetIndexPath.row - 10, 0)
 | |
|                 
 | |
|                 self.tableView.contentOffset = CGPoint(x: 0, y: self.tableView.rectForRow(at: IndexPath(row: targetRow, section: targetIndexPath.section)).midY)
 | |
|         }
 | |
|         
 | |
|         self.tableView.scrollToRow(at: targetIndexPath, at: targetPosition, animated: true)
 | |
|     }
 | |
|     
 | |
|     func fullyVisibleCellViewModels() -> [MessageViewModel]? {
 | |
|         // We remove the 'Values.mediumSpacing' as that is the distance the table content appears above the input view
 | |
|         let tableVisualTop: CGFloat = tableView.frame.minY
 | |
|         let tableVisualBottom: CGFloat = (tableView.frame.maxY - (tableView.contentInset.bottom - Values.mediumSpacing))
 | |
|         
 | |
|         guard
 | |
|             let visibleIndexPaths: [IndexPath] = self.tableView.indexPathsForVisibleRows,
 | |
|             let messagesSection: Int = visibleIndexPaths
 | |
|                 .first(where: { self.viewModel.interactionData[$0.section].model == .messages })?
 | |
|                 .section
 | |
|         else { return nil }
 | |
|         
 | |
|         return visibleIndexPaths
 | |
|             .sorted()
 | |
|             .filter({ $0.section == messagesSection })
 | |
|             .compactMap({ indexPath -> (frame: CGRect, cellViewModel: MessageViewModel)? in
 | |
|                 guard let cell: UITableViewCell = tableView.cellForRow(at: indexPath) else { return nil }
 | |
|                 
 | |
|                 switch cell {
 | |
|                     case is VisibleMessageCell, is CallMessageCell, is InfoMessageCell:
 | |
|                         return (
 | |
|                             view.convert(cell.frame, from: tableView),
 | |
|                             self.viewModel.interactionData[indexPath.section].elements[indexPath.row]
 | |
|                         )
 | |
|                         
 | |
|                     case is TypingIndicatorCell, is DateHeaderCell, is UnreadMarkerCell:
 | |
|                         return nil
 | |
|                     
 | |
|                     default:
 | |
|                         SNLog("[ConversationVC] Warning: Processing unhandled cell type when marking as read, this could result in intermittent failures")
 | |
|                         return nil
 | |
|                 }
 | |
|             })
 | |
|             // Exclude messages that are partially off the the screen
 | |
|             .filter({ $0.frame.minY >= tableVisualTop && $0.frame.maxY <= tableVisualBottom })
 | |
|             .map { $0.cellViewModel }
 | |
|     }
 | |
|     
 | |
|     func markFullyVisibleAndOlderCellsAsRead(interactionInfo: Interaction.TimestampInfo?) {
 | |
|         // Only retrieve the `fullyVisibleCellViewModels` if the viewModel things we should mark something as read
 | |
|         guard self.viewModel.shouldTryMarkAsRead() else { return }
 | |
|         
 | |
|         // We want to mark messages as read on load and while we scroll, so grab the newest message and mark
 | |
|         // everything older as read
 | |
|         guard let newestCellViewModel: MessageViewModel = fullyVisibleCellViewModels()?.last else {
 | |
|             // If we weren't able to get any visible cells for some reason then we should fall back to
 | |
|             // marking the provided interactionInfo as read just in case
 | |
|             if let interactionInfo: Interaction.TimestampInfo = interactionInfo {
 | |
|                 self.viewModel.markAsRead(
 | |
|                     target: .threadAndInteractions(interactionsBeforeInclusive: interactionInfo.id),
 | |
|                     timestampMs: interactionInfo.timestampMs
 | |
|                 )
 | |
|             }
 | |
|             return
 | |
|         }
 | |
|         
 | |
|         // Mark all interactions before the newest entirely-visible one as read
 | |
|         self.viewModel.markAsRead(
 | |
|             target: .threadAndInteractions(interactionsBeforeInclusive: newestCellViewModel.id),
 | |
|             timestampMs: newestCellViewModel.timestampMs
 | |
|         )
 | |
|     }
 | |
|     
 | |
|     func highlightCellIfNeeded(interactionId: Int64, behaviour: ConversationViewModel.FocusBehaviour) {
 | |
|         self.focusedInteractionInfo = nil
 | |
|         self.focusBehaviour = .none
 | |
|         
 | |
|         // Only trigger the highlight if that's the desired behaviour
 | |
|         guard behaviour == .highlight else { return }
 | |
|         
 | |
|         // 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()
 | |
|         }
 | |
|     }
 | |
|     
 | |
|     // MARK: - LibSessionRespondingViewController
 | |
|     
 | |
|     func isConversation(in threadIds: [String]) -> Bool {
 | |
|         return threadIds.contains(self.viewModel.threadData.threadId)
 | |
|     }
 | |
| }
 |