Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/ConversationView/ConversationViewController.swift
1 views
//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

public import SignalServiceKit
public import SignalUI

public enum ConversationUIMode: UInt {
    case normal
    case search
    case selection

    // These two modes are used to select interactions.
    public var hasSelectionUI: Bool {
        switch self {
        case .normal, .search:
            return false
        case .selection:
            return true
        }
    }
}

public enum ConversationViewAction {
    case none
    case compose
    case voiceCall
    case videoCall
    case groupCallLobby
    case newGroupActionSheet
    case updateDraft
}

// MARK: -

public final class ConversationViewController: OWSViewController {

    let context: ViewControllerContext

    public let appReadiness: AppReadinessSetter
    public let viewState: CVViewState
    public let loadCoordinator: CVLoadCoordinator
    public let layout: ConversationViewLayout
    public let collectionView: ConversationCollectionView
    public let searchController: ConversationSearchController
    public var pinnedMessageIndex: Int
    var memberLabelCoordinator: MemberLabelCoordinator?

    weak var threadActionProviderDelegate: ThreadContextualActionProvider?

    var selectionToolbar: MessageActionsToolbar?

    var otherUsersProfileDidChangeEvent: DebouncedEvent?

    /// See `ConversationViewController+OWS.updateContentInsetsDebounced`
    lazy var updateContentInsetsEvent = DebouncedEvents.build(
        mode: .lastOnly,
        maxFrequencySeconds: 0.01,
        onQueue: .main,
        notifyBlock: { [weak self] in
            self?.updateContentInsets()
        },
    )

    // MARK: -

    public static func load(
        appReadiness: AppReadinessSetter,
        threadViewModel: ThreadViewModel,
        action: ConversationViewAction,
        focusMessageId: String?,
        tx: DBReadTransaction,
    ) -> ConversationViewController {
        let thread = threadViewModel.threadRecord

        // We always need to find where the unread divider should be placed, even
        // if we opened the chat by tapping on a search result.
        let interactionFinder = InteractionFinder(threadUniqueId: thread.uniqueId)
        let oldestUnreadMessage = try? interactionFinder.oldestUnreadInteraction(transaction: tx)

        let loadAroundMessageId: String?
        let scrollToMessageId: String?

        if let focusMessageId {
            loadAroundMessageId = focusMessageId
            scrollToMessageId = focusMessageId
        } else if let oldestUnreadMessage {
            loadAroundMessageId = oldestUnreadMessage.uniqueId
            // Set this to `nil` so that we scroll to the unread divider.
            scrollToMessageId = nil
        } else {
            // If we're not scrolling to a specific message AND we don't have any
            // unread messages, try to focus on the last visible interaction.
            let lastVisibleMessageId = Self.lastVisibleInteractionId(for: threadViewModel.threadRecord, tx: tx)
            loadAroundMessageId = lastVisibleMessageId
            scrollToMessageId = lastVisibleMessageId
        }

        let chatColor = Self.loadChatColor(for: thread, tx: tx)
        let wallpaperViewBuilder = Self.loadWallpaperViewBuilder(for: thread, tx: tx)

        let conversationStyle = Self.buildInitialConversationStyle(
            for: thread,
            chatColor: chatColor,
            wallpaperViewBuilder: wallpaperViewBuilder,
        )
        let conversationViewModel = ConversationViewModel.load(for: thread, tx: tx)

        let cvc = ConversationViewController(
            appReadiness: appReadiness,
            threadViewModel: threadViewModel,
            conversationViewModel: conversationViewModel,
            action: action,
            conversationStyle: conversationStyle,
            loadAroundMessageId: loadAroundMessageId,
            scrollToMessageId: scrollToMessageId,
            oldestUnreadMessage: oldestUnreadMessage,
            chatColor: chatColor,
            wallpaperViewBuilder: wallpaperViewBuilder,
        )

        return cvc
    }

    static func loadChatColor(for thread: TSThread, tx: DBReadTransaction) -> ColorOrGradientSetting {
        return DependenciesBridge.shared.chatColorSettingStore.resolvedChatColor(
            for: thread,
            tx: tx,
        )
    }

    static func loadWallpaperViewBuilder(for thread: TSThread, tx: DBReadTransaction) -> WallpaperViewBuilder? {
        return Wallpaper.viewBuilder(for: thread, tx: tx)
    }

    private init(
        appReadiness: AppReadinessSetter,
        threadViewModel: ThreadViewModel,
        conversationViewModel: ConversationViewModel,
        action: ConversationViewAction,
        conversationStyle: ConversationStyle,
        loadAroundMessageId: String?,
        scrollToMessageId: String?,
        oldestUnreadMessage: TSInteraction?,
        chatColor: ColorOrGradientSetting,
        wallpaperViewBuilder: WallpaperViewBuilder?,
    ) {
        AssertIsOnMainThread()

        self.appReadiness = appReadiness
        self.context = ViewControllerContext.shared

        self.viewState = CVViewState(
            threadUniqueId: threadViewModel.threadRecord.uniqueId,
            conversationStyle: conversationStyle,
            chatColor: chatColor,
            wallpaperViewBuilder: wallpaperViewBuilder,
        )
        self.loadCoordinator = CVLoadCoordinator(
            viewState: viewState,
            threadViewModel: threadViewModel,
            conversationViewModel: conversationViewModel,
            oldestUnreadMessageSortId: oldestUnreadMessage?.sortId,
        )
        self.layout = ConversationViewLayout(conversationStyle: conversationStyle)
        self.collectionView = ConversationCollectionView(frame: .zero, collectionViewLayout: self.layout)
        self.searchController = ConversationSearchController(thread: threadViewModel.threadRecord)

        self.pinnedMessageIndex = 0

        super.init()

        self.viewState.delegate = self
        self.viewState.selectionState.delegate = self
        self.hidesBottomBarWhenPushed = true

        SUIEnvironment.shared.contactsViewHelperRef.addObserver(self)

        self.actionOnOpen = action

        self.recordInitialScrollState(scrollToMessageId)

        loadCoordinator.configure(
            delegate: self,
            componentDelegate: self,
            focusMessageIdOnOpen: loadAroundMessageId,
        )

        searchController.delegate = self

        self.otherUsersProfileDidChangeEvent = DebouncedEvents.build(
            mode: .firstLast,
            maxFrequencySeconds: 1.0,
            onQueue: .main,
        ) { [weak self] in
            // Reload all cells if this is a group conversation,
            // since we may need to update the sender names on the messages.
            self?.loadCoordinator.enqueueReload(canReuseInteractionModels: true, canReuseComponentStates: false)
        }

        let tsAccountManager = DependenciesBridge.shared.tsAccountManager
        if
            let groupModelV2 = currentGroupModel as? TSGroupModelV2,
            let localIdentifiers = tsAccountManager.localIdentifiersWithMaybeSneakyTransaction
        {
            let groupNameColors = GroupNameColors.forThread(threadViewModel.threadRecord)
            self.memberLabelCoordinator = MemberLabelCoordinator(
                groupModel: groupModelV2,
                groupNameColors: groupNameColors,
                localIdentifiers: localIdentifiers,
            )
        }
    }

    deinit {
        reloadTimer?.invalidate()
        scrollUpdateTimer?.invalidate()
    }

    // MARK: - View Lifecycle

    override public func viewDidLoad() {
        AssertIsOnMainThread()

        // We won't have a navigation controller if we're presented in a preview
        owsAssertDebug(self.navigationController != nil || self.isInPreviewPlatter)

        super.viewDidLoad()

        createContents()
        createConversationScrollButtons()
        createHeaderViews()
        addNotificationListeners()
        loadCoordinator.viewDidLoad()

        self.startReloadTimer()
    }

    private func createContents() {
        AssertIsOnMainThread()

        self.layout.delegate = self.loadCoordinator

        // We use the root view bounds as the initial frame for the collection
        // view so that its contents can be laid out immediately.
        //
        // TODO: To avoid relayout, it'd be better to take into account safeAreaInsets,
        //       but they're not yet set when this method is called.
        self.collectionView.frame = view.bounds
        self.collectionView.layoutDelegate = self
        self.collectionView.delegate = self.loadCoordinator
        self.collectionView.dataSource = self.loadCoordinator
        self.collectionView.showsVerticalScrollIndicator = true
        self.collectionView.showsHorizontalScrollIndicator = false
        self.collectionView.keyboardDismissMode = .interactive
        self.collectionView.allowsMultipleSelection = true
        self.collectionView.backgroundColor = .clear

        // To minimize time to initial apearance, we initially disable prefetching, but then
        // re-enable it once the view has appeared.
        self.collectionView.isPrefetchingEnabled = false

        self.view.addSubview(self.collectionView)
        self.collectionView.autoPinEdge(toSuperviewEdge: .top)
        self.collectionView.autoPinEdge(toSuperviewEdge: .bottom)
        self.collectionView.autoPinEdge(toSuperviewSafeArea: .leading)
        self.collectionView.autoPinEdge(toSuperviewSafeArea: .trailing)

        self.collectionView.accessibilityIdentifier = "collectionView"

        self.registerReuseIdentifiers()

        // The view controller will only automatically adjust content insets for a
        // scrollView at index 0, so we need the collection view to remain subview index 0.
        // But the background views should appear visually behind the collection view.
        let backgroundContainer = self.backgroundContainer
        backgroundContainer.delegate = self
        self.view.addSubview(backgroundContainer)
        backgroundContainer.autoPinEdgesToSuperviewEdges()
        setUpWallpaper()

        self.view.addSubview(bottomBarContainer)
        bottomBarContainer.autoPinWidthToSuperview()
        bottomBarContainer.autoPinEdge(toSuperviewEdge: .bottom)

        selectionToolbar = self.buildSelectionToolbar()

        // Obscures content underneath bottom bar to improve legibility.
        if #available(iOS 26, *) {
            let scrollInteraction = UIScrollEdgeElementContainerInteraction()
            scrollInteraction.scrollView = collectionView
            scrollInteraction.edge = .bottom
            if let selectionToolbar {
                selectionToolbar.addInteraction(scrollInteraction)
            }
            searchController.resultsBar.addInteraction(scrollInteraction)
        }

        // This should kick off the first load.
        owsAssertDebug(!self.hasRenderState)
        self.updateConversationStyle()
    }

    override public var canBecomeFirstResponder: Bool {
        return true
    }

    override public func becomeFirstResponder() -> Bool {
        let result = super.becomeFirstResponder()

        guard hasViewWillAppearEverBegun else {
            return result
        }
        guard let inputToolbar else {
            return result
        }

        // If we become the first responder, it means that the
        // input toolbar is not the first responder. As such,
        // we should clear out the desired keyboard since an
        // interactive dismissal may have just occurred and we
        // need to update the UI to reflect that fact. We don't
        // actually ever want to be the first responder, so resign
        // immediately. We just want to know when the responder
        // state of our children changed and that information is
        // conveniently bubbled up the responder chain.
        if result {
            self.resignFirstResponder()
            inputToolbar.clearDesiredKeyboard()
        }

        return result
    }

    override public var textInputContextIdentifier: String? {
        thread.uniqueId
    }

    public func dismissPresentedViewControllerIfNecessary() {
        guard let presentedViewController = self.presentedViewController else {
            return
        }

        if presentedViewController is ActionSheetController || presentedViewController is UIAlertController {
            dismiss(animated: false, completion: nil)
            return
        }
    }

    override public func viewWillAppear(_ animated: Bool) {
        self.viewWillAppearDidBegin()

        super.viewWillAppear(animated)

        configureGestureRecognizersIfNeeded()

        ensureBannerState()

        self.isViewVisible = true
        self.viewWillAppearForLoad()

        // We should have already requested contact access at this point, so this should be a no-op
        // unless it ever becomes possible to load this VC without going via the ChatListViewController.
        SSKEnvironment.shared.contactManagerImplRef.requestSystemContactsOnce()

        self.updateBarButtonItems()
        self.updateNavigationTitle()

        self.ensureBottomViewType()
        inputToolbar?.scrollToBottom()

        self.refreshCallState()

        self.showMessageRequestDialogIfRequired()
        self.viewWillAppearDidComplete()
    }

    private var groupAndProfileRefresherTask: Task<Void, any Error>?

    override public func viewDidAppear(_ animated: Bool) {
        self.viewDidAppearDidBegin()

        super.viewDidAppear(animated)

        // We don't present incoming message notifications for the presented
        // conversation. But there's a narrow window *while* the conversationVC
        // is being presented where a message notification for the not-quite-yet
        // presented conversation can be shown. If that happens, dismiss it as soon
        // as we enter the conversation.
        SSKEnvironment.shared.notificationPresenterRef.cancelNotifications(threadId: thread.uniqueId)

        // recover status bar when returning from PhotoPicker, which is dark (uses light status bar)
        self.setNeedsStatusBarAppearanceUpdate()

        self.markVisibleMessagesAsRead()
        self.startReadTimer()
        self.updateNavigationBarSubtitleLabel()
        self.autoLoadMoreIfNecessary()

        let serviceIds = thread.recipientAddressesWithSneakyTransaction.compactMap(\.serviceId)

        self.groupAndProfileRefresherTask?.cancel()
        self.groupAndProfileRefresherTask = Task {
            await self.updateV2GroupIfNecessary()

            // Fetch profiles AFTER refreshing the group to ensure GSEs are available.
            let profileFetcher = SSKEnvironment.shared.profileFetcherRef
            for serviceId in Set(serviceIds).shuffled() {
                try Task.checkCancellation()
                let thread = self.thread as? TSGroupThread
                let context = ProfileFetchContext(groupId: try? thread?.groupIdentifier, isOpportunistic: true)
                _ = try? await profileFetcher.fetchProfile(for: serviceId, context: context)
            }
        }

        if !self.viewHasEverAppeared {
            // To minimize time to initial apearance, we initially disable prefetching, but then
            // re-enable it once the view has appeared.
            self.collectionView.isPrefetchingEnabled = true
        }

        self.isViewCompletelyAppeared = true
        self.shouldAnimateKeyboardChanges = true

        switch self.actionOnOpen {
        case .none:
            break
        case .compose:
            // Don't pop the keyboard if we have a pending message request, since
            // the user can't currently send a message until acting on this
            if nil == requestView {
                self.popKeyBoard()
            }
        case .voiceCall:
            self.startIndividualAudioCall()
        case .videoCall:
            self.startIndividualVideoCall()
        case .groupCallLobby:
            self.showGroupLobbyOrActiveCall()
        case .newGroupActionSheet:
            DispatchQueue.main.async { [weak self] in
                self?.showGroupLinkPromotionActionSheet()
            }
        case .updateDraft:
            // Do nothing input toolbar was just created with the latest draft.
            break
        }

        scrollToInitialPosition(animated: false)
        if viewState.hasAppliedFirstLoad {
            self.clearInitialScrollState()
        }

        // Clear the "on open" state after the view has been presented.
        self.actionOnOpen = .none

        self.configureScrollDownButtons()
        inputToolbar?.viewDidAppear()

        self.focusInitialVoiceoverElement()

        self.viewDidAppearDidComplete()
    }

    // `viewWillDisappear` is called whenever the view *starts* to disappear,
    // but, as is the case with the "pan left for message details view" gesture,
    // this can be canceled. As such, we shouldn't tear down anything expensive
    // until `viewDidDisappear`.
    override public func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)

        self.isViewCompletelyAppeared = false

        dismissMessageContextMenu(animated: false)

        self.dismissReactionsDetailSheet(animated: false)
        self.saveLastVisibleSortIdAndOnScreenPercentage(async: true)

        self.groupAndProfileRefresherTask?.cancel()
        self.groupAndProfileRefresherTask = nil
    }

    override public func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)

        self.userHasScrolled = false
        self.isViewVisible = false
        self.shouldAnimateKeyboardChanges = false

        AppEnvironment.shared.cvAudioPlayerRef.stopAll()

        self.cancelReadTimer()
        self.saveDraft()
        self.markVisibleMessagesAsRead()
        self.finishRecordingVoiceMessage(sendImmediately: false)
        self.mediaCache.removeAllObjects()
        inputToolbar?.clearDesiredKeyboard()

        self.isUserScrolling = false
        self.isWaitingForDeceleration = false

        self.scrollingAnimationCompletionTimer?.invalidate()
        self.scrollingAnimationCompletionTimer = nil
    }

    override public func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()

        // Title view sometimes disappears when orientation changes.
        // Reset it here as a workaround.
        navigationItem.titleView = nil
        navigationItem.titleView = headerView

        guard hasViewWillAppearEverBegun else {
            return
        }
        guard let inputToolbar else {
            return
        }

        // We resize the inputToolbar whenever it's text is modified, including when setting saved draft-text.
        // However it's possible this draft-text is set before the inputToolbar (an inputAccessoryView) is mounted
        // in the view hierarchy. Since it's not in the view hierarchy, it hasn't been laid out and has no width,
        // which is used to determine height.
        // So here we unsure the proper height once we know everything's been laid out.
        inputToolbar.ensureTextViewHeight()

        updateContentInsets()
    }

    override public var shouldAutorotate: Bool {
        // Don't allow orientation changes while recording voice messages.
        if viewState.inProgressVoiceMessage?.isRecording == true {
            return false
        }

        return super.shouldAutorotate
    }

    override public func contentSizeCategoryDidChange() {
        super.contentSizeCategoryDidChange()

        Logger.info("didChangePreferredContentSize")

        resetForSizeOrOrientationChange()
    }

    override public func themeDidChange() {
        super.themeDidChange()

        applyTheme()
        self.updateThemeIfNecessary()
    }

    private func updateThemeIfNecessary() {
        AssertIsOnMainThread()

        if self.isDarkThemeEnabled == Theme.isDarkThemeEnabled {
            return
        }
        self.isDarkThemeEnabled = Theme.isDarkThemeEnabled

        self.updateConversationStyle()

        self.applyTheme()
    }

    private func applyTheme() {
        guard hasViewWillAppearEverBegun else {
            owsFailDebug("Not yet ready.")
            return
        }

        // make sure toolbar extends below iPhoneX home button.
        self.view.backgroundColor = Theme.toolbarBackgroundColor

        self.updateWallpaperView()

        self.updateNavigationTitle()
        self.updateNavigationBarSubtitleLabel()

        self.updateBarButtonItems()
        self.ensureBannerState()

        dismissReactionsDetailSheet(animated: false)
    }

    func reloadCollectionViewForReset() {
        AssertIsOnMainThread()

        guard hasAppearedAndHasAppliedFirstLoad else {
            return
        }
        // We use an obj-c free function so that we can handle NSException.
        self.collectionView.cvc_reloadData(animated: false, cvc: self)
    }

    var isViewVisible: Bool {
        get { viewState.isViewVisible }
        set {
            viewState.isViewVisible = newValue

            updateCellsVisible()
        }
    }

    func updateCellsVisible() {
        AssertIsOnMainThread()

        let isAppInBackground = CurrentAppContext().isInBackground()
        let isCellVisible = self.isViewVisible && !isAppInBackground
        for cell in self.collectionView.visibleCells {
            guard let cell = cell as? CVCell else {
                owsFailDebug("Invalid cell.")
                continue
            }
            cell.isCellVisible = isCellVisible
        }
        self.updateScrollingContent()
    }

    // MARK: - Orientation

    override public func viewWillTransition(
        to size: CGSize,
        with coordinator: UIViewControllerTransitionCoordinator,
    ) {
        AssertIsOnMainThread()

        super.viewWillTransition(to: size, with: coordinator)

        dismissReactionsDetailSheet(animated: false)

        guard hasAppearedAndHasAppliedFirstLoad else {
            return
        }

        self.setScrollActionForSizeTransition()

        _ = coordinator.animate(
            alongsideTransition: { _ in
            },
            completion: { [weak self] _ in
                self?.clearScrollActionForSizeTransition()
            },
        )
    }

    override public func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        AssertIsOnMainThread()
        self.updateBarButtonItems()
        self.updateNavigationBarSubtitleLabel()
    }

    override public func viewSafeAreaInsetsDidChange() {
        AssertIsOnMainThread()

        super.viewSafeAreaInsetsDidChange()

        // Workaround for iOS 26 animating bottom bar getting in its final position
        // during view presentation animation.
        if #available(iOS 26, *) {
            UIView.performWithoutAnimation {
                bottomBarContainer.setNeedsLayout()
                bottomBarContainer.layoutIfNeeded()
                bottomBarContainer.frame = CGRect(
                    origin: CGPoint(
                        x: 0,
                        y: view.bounds.maxY - bottomBarContainer.frame.height,
                    ),
                    size: bottomBarContainer.bounds.size,
                )
            }
        }

        updateContentInsetsDebounced()
        viewSafeAreaInsetsDidChangeForLoad()
        updateConversationStyle()
    }
}

// MARK: -

// TODO: Is this necessary?
extension ConversationViewController: UINavigationControllerDelegate {
}

// MARK: -

extension ConversationViewController: ContactsViewHelperObserver {
    public func contactsViewHelperDidUpdateContacts() {
        AssertIsOnMainThread()

        loadCoordinator.enqueueReload(canReuseInteractionModels: true, canReuseComponentStates: false)
    }
}