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

import ContactsUI
import SignalServiceKit
import SignalUI

extension ConversationViewController {

    func updateV2GroupIfNecessary() async {
        AssertIsOnMainThread()

        guard let groupModel = (thread as? TSGroupThread)?.groupModel as? TSGroupModelV2 else {
            return
        }
        do {
            let secretParams = try groupModel.secretParams()
            // Try to update the v2 group to latest from the service.
            // This will help keep us in sync if we've missed any group updates, etc.
            let groupUpdater = SSKEnvironment.shared.groupV2UpdatesRef
            try await groupUpdater.refreshGroup(secretParams: secretParams, options: [.throttle])
        } catch {
            Logger.warn("Couldn't refresh group: \(error)")
        }
    }

    func showUnblockConversationUI(completion: BlockListUIUtils.Completion?) {
        self.userHasScrolled = false

        // To avoid "noisy" animations (hiding the keyboard before showing
        // the action sheet, re-showing it after), hide the keyboard before
        // showing the "unblock" action sheet.
        //
        // Unblocking is a rare interaction, so it's okay to leave the keyboard
        // hidden.
        dismissKeyBoard()

        BlockListUIUtils.showUnblockThreadActionSheet(
            thread,
            from: CurrentAppContext().frontmostViewController() ?? self,
            completion: completion,
        )
    }

    @MainActor
    func showUnblockConversationUI() async -> Bool {
        await withCheckedContinuation { continuation in
            self.showUnblockConversationUI { isBlocked in
                continuation.resume(returning: isBlocked)
            }
        }
    }

    // MARK: - Identity

    /**
     * Shows confirmation dialog if at least one of the recipient id's is not confirmed.
     *
     * returns YES if an alert was shown
     *          NO if there were no unconfirmed identities
     */
    func showSafetyNumberConfirmationIfNecessary(
        confirmationText: String,
        untrustedThreshold: Date?,
        completion: @escaping (Bool) -> Void,
    ) -> Bool {
        SafetyNumberConfirmationSheet.presentIfNecessary(
            addresses: thread.recipientAddressesWithSneakyTransaction,
            confirmationText: confirmationText,
            untrustedThreshold: untrustedThreshold,
            completion: completion,
        )
    }

    /// Shows confirmation dialog if at least one of the recipient id's is not confirmed.
    /// - Returns: `true` if all recipients are confirmed.
    /// `false` if the alert was shown and the identity was left unconfirmed.
    @MainActor
    func showSafetyNumberConfirmationIfNecessary(
        from viewController: UIViewController,
        confirmationText: String,
        untrustedThreshold: Date?,
    ) async -> Bool {
        await SafetyNumberConfirmationSheet.presentRepeatedlyAsNecessary(
            for: { thread.recipientAddressesWithSneakyTransaction },
            from: viewController,
            confirmationText: confirmationText,
            untrustedThreshold: untrustedThreshold,
        )
    }

    // MARK: - Verification

    func noLongerVerifiedIdentityKeys(tx: DBReadTransaction) -> [SignalServiceAddress: Data] {
        if let groupThread = thread as? TSGroupThread {
            return OWSRecipientIdentity.noLongerVerifiedIdentityKeys(in: groupThread.uniqueId, tx: tx)
        }
        let identityManager = DependenciesBridge.shared.identityManager
        return thread.recipientAddresses(with: tx).reduce(into: [:]) { result, address in
            guard let recipientIdentity = identityManager.recipientIdentity(for: address, tx: tx) else {
                return
            }
            guard recipientIdentity.verificationState == .noLongerVerified else {
                return
            }
            result[address] = recipientIdentity.identityKey
        }
    }

    func resetVerificationStateToDefault(noLongerVerifiedIdentityKeys: [SignalServiceAddress: Data]) {
        AssertIsOnMainThread()

        SSKEnvironment.shared.databaseStorageRef.write { transaction in
            let identityManager = DependenciesBridge.shared.identityManager
            for (address, identityKey) in noLongerVerifiedIdentityKeys {
                owsAssertDebug(address.isValid)
                _ = identityManager.setVerificationState(
                    .implicit(isAcknowledged: true),
                    of: identityKey,
                    for: address,
                    isUserInitiatedChange: true,
                    tx: transaction,
                )
            }
        }
    }

    func showNoLongerVerifiedUI(noLongerVerifiedIdentityKeys: [SignalServiceAddress: Data]) {
        AssertIsOnMainThread()

        switch noLongerVerifiedIdentityKeys.count {
        case 0:
            break

        case 1:
            showFingerprint(address: noLongerVerifiedIdentityKeys.first!.key)

        default:
            showConversationSettingsAndShowVerification()
        }
    }

    // MARK: - Toast

    func presentToastCVC(_ toastText: String, image: UIImage? = nil) {
        let toastController = ToastController(text: toastText, image: image)
        let kToastInset: CGFloat = 10
        let bottomInset = kToastInset + collectionView.contentInset.bottom + view.layoutMargins.bottom
        toastController.presentToastView(from: .bottom, of: self.view, inset: bottomInset)
    }

    func presentMissingQuotedReplyToast() {
        Logger.info("")

        let toastText = OWSLocalizedString(
            "QUOTED_REPLY_ORIGINAL_MESSAGE_DELETED",
            comment: "Toast alert text shown when tapping on a quoted message which we cannot scroll to because the local copy of the message was since deleted.",
        )
        presentToastCVC(toastText)
    }

    func presentRemotelySourcedQuotedReplyToast() {
        Logger.info("")

        let toastText = OWSLocalizedString(
            "QUOTED_REPLY_ORIGINAL_MESSAGE_REMOTELY_SOURCED",
            comment: "Toast alert text shown when tapping on a quoted message which we cannot scroll to because the local copy of the message didn't exist when the quote was received.",
        )
        presentToastCVC(toastText)
    }

    func presentViewOnceAlreadyViewedToast() {
        Logger.info("")

        let toastText = OWSLocalizedString(
            "VIEW_ONCE_ALREADY_VIEWED_TOAST",
            comment: "Toast alert text shown when tapping on a view-once message that has already been viewed.",
        )
        presentToastCVC(toastText, image: .viewOnceDash)
    }

    func presentViewOnceOutgoingToast() {
        Logger.info("")

        let toastText = OWSLocalizedString(
            "VIEW_ONCE_OUTGOING_TOAST",
            comment: "Toast alert text shown when tapping on a view-once message that you have sent.",
        )
        presentToastCVC(toastText)
    }

    // MARK: - Conversation Settings

    func showConversationSettings() {
        showConversationSettings(mode: .default)
    }

    func showConversationSettingsAndShowAllMedia() {
        showConversationSettings(mode: .showAllMedia)
    }

    func showConversationSettingsAndShowVerification() {
        showConversationSettings(mode: .showVerification)
    }

    func showConversationSettingsAndShowMemberRequests() {
        showConversationSettings(mode: .showMemberRequests)
    }

    func showConversationSettings(mode: ConversationSettingsPresentationMode) {
        guard let viewControllersUpToSelf = self.viewControllersUpToSelf else {
            return
        }
        var viewControllers = viewControllersUpToSelf

        let settingsView = ConversationSettingsViewController(
            threadViewModel: threadViewModel,
            isSystemContact: conversationViewModel.isSystemContact,
            spoilerState: viewState.spoilerState,
            memberLabelCoordinator: memberLabelCoordinator,
        )
        settingsView.conversationSettingsViewDelegate = self
        viewControllers.append(settingsView)

        switch mode {
        case .default:
            break
        case .showVerification:
            settingsView.showVerificationOnAppear = true
        case .showMemberRequests:
            if let view = settingsView.buildMemberRequestsAndInvitesView() {
                viewControllers.append(view)
            }
        case .showAllMedia:
            viewControllers.append(AllMediaViewController(
                thread: thread,
                spoilerState: viewState.spoilerState,
                name: title,
            ))
        }

        dismissKeyBoard()
        navigationController?.setViewControllers(viewControllers, animated: true)
    }

    private var viewControllersUpToSelf: [UIViewController]? {
        AssertIsOnMainThread()

        guard let navigationController else {
            owsFailDebug("Missing navigationController.")
            return nil
        }

        if navigationController.topViewController == self {
            return navigationController.viewControllers
        }

        let viewControllers = navigationController.viewControllers
        guard let index = viewControllers.firstIndex(of: self) else {
            owsFailDebug("Unexpectedly missing from view hierarchy")
            return viewControllers
        }

        return Array(viewControllers.prefix(upTo: index + 1))
    }

    // MARK: - Member Action Sheet

    func showMemberActionSheet(forAddress address: SignalServiceAddress, withHapticFeedback: Bool) {
        AssertIsOnMainThread()

        if withHapticFeedback {
            ImpactHapticFeedback.impactOccurred(style: .light)
        }

        var groupViewHelper: GroupViewHelper?
        if threadViewModel.isGroupThread {
            groupViewHelper = GroupViewHelper(threadViewModel: threadViewModel, memberLabelCoordinator: memberLabelCoordinator)
            groupViewHelper!.delegate = self
        }

        var memberLabel: MemberLabelForRendering?
        if
            let groupThread = thread as? TSGroupThread,
            let memberAci = address.aci,
            let memberLabelString = groupThread.groupModel.groupMembership.memberLabel(for: memberAci)?.labelForRendering()
        {
            let groupNameColors = GroupNameColors.forThread(groupThread)
            memberLabel = MemberLabelForRendering(label: memberLabelString, groupNameColor: groupNameColors.color(for: memberAci))
        }

        ProfileSheetSheetCoordinator(
            address: address,
            groupViewHelper: groupViewHelper,
            spoilerState: spoilerState,
            memberLabel: memberLabel,
        )
        .presentAppropriateSheet(from: self)
    }
}

// MARK: -

extension ConversationViewController: ConversationSettingsViewDelegate {

    public func conversationSettingsDidRequestConversationSearch() {
        AssertIsOnMainThread()

        self.uiMode = .search

        self.popAllConversationSettingsViews {
            self.searchController.uiSearchController.searchBar.becomeFirstResponder()
        }
    }

    private func popAllConversationSettingsViews(completion: @escaping () -> Void) {
        AssertIsOnMainThread()

        guard let presentedViewController else {
            navigationController?.popToViewController(self, animated: true, completion: completion)
            return
        }
        presentedViewController.dismiss(animated: true) {
            self.navigationController?.popToViewController(self, animated: true, completion: completion)
        }
    }

    public func deleteConversation() {
        threadActionProviderDelegate?.deleteThreadWithConfirmation(threadViewModel: threadViewModel)
    }

    public func toggleConversationArchived() {
        threadActionProviderDelegate?.toggleThreadIsArchived(threadViewModel: threadViewModel)
    }
}

// MARK: - Preview / 3D Touch / UIContextMenu Methods

public extension ConversationViewController {
    var isInPreviewPlatter: Bool {
        get { viewState.isInPreviewPlatter }
        set {
            guard viewState.isInPreviewPlatter != newValue else {
                return
            }
            viewState.isInPreviewPlatter = newValue
            if hasViewWillAppearEverBegun {
                ensureBottomViewType()
            }
            configureScrollDownButtons()
        }
    }

    @objc
    func previewSetup() {
        isInPreviewPlatter = true
        actionOnOpen = .none
    }
}

// MARK: - Timers

extension ConversationViewController {
    public func startReadTimer(caller: String = #function) {
        AssertIsOnMainThread()

        readTimer?.invalidate()
        let readTimer = Timer(timeInterval: 0.1, repeats: true) { [weak self] timer in
            guard let self else {
                timer.invalidate()
                return
            }
            guard self.view.window != nil else {
                owsFailDebug("Read timer fired when ConversationViewController is not in a view hierarchy")
                timer.invalidate()
                return
            }
            self.readTimerDidFire()
        }
        self.readTimer = readTimer
        RunLoop.main.add(readTimer, forMode: .common)
    }

    private func readTimerDidFire() {
        AssertIsOnMainThread()

        if layout.isPerformBatchUpdatesOrReloadDataBeingApplied {
            return
        }
        markVisibleMessagesAsRead()
    }

    public func cancelReadTimer(caller: String = #function) {
        AssertIsOnMainThread()

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

    private var readTimer: Timer? {
        get { viewState.readTimer }
        set { viewState.readTimer = newValue }
    }

    var reloadTimer: Timer? {
        get { viewState.reloadTimer }
        set { viewState.reloadTimer = newValue }
    }

    func startReloadTimer() {
        AssertIsOnMainThread()
        let reloadTimer = Timer(timeInterval: 1, repeats: true) { [weak self] timer in
            guard let self else {
                timer.invalidate()
                return
            }
            self.reloadTimerDidFire()
        }
        self.reloadTimer = reloadTimer
        RunLoop.main.add(reloadTimer, forMode: .common)
    }

    private func reloadTimerDidFire() {
        AssertIsOnMainThread()

        if
            isUserScrolling || !isViewCompletelyAppeared || !isViewVisible
            || !CurrentAppContext().isAppForegroundAndActive() || !viewHasEverAppeared
        {
            return
        }

        let timeSinceLastReload = abs(self.lastReloadDate.timeIntervalSinceNow)
        let kReloadFrequency: TimeInterval = 60
        if timeSinceLastReload < kReloadFrequency {
            return
        }

        // Auto-load more if necessary...
        if !autoLoadMoreIfNecessary() {
            // ...Otherwise, reload everything.
            //
            // TODO: We could make this cheaper by using enqueueReload()
            // if we moved volatile profile / footer state to the view state.
            loadCoordinator.enqueueReload()
        }
    }

    var lastSortIdMarkedRead: UInt64 {
        get { viewState.lastSortIdMarkedRead }
        set { viewState.lastSortIdMarkedRead = newValue }
    }

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

    private func setLastSortIdMarkedRead(lastSortIdMarkedRead: UInt64) {
        AssertIsOnMainThread()
        owsAssertDebug(self.isMarkingAsRead)

        self.lastSortIdMarkedRead = lastSortIdMarkedRead
    }

    public func markVisibleMessagesAsRead(caller: String = #function) {
        AssertIsOnMainThread()

        guard
            let navigationController,
            navigationController.topViewController === self
        else {
            // If this CVC has presented other view controllers, such as
            // conversation settings, we shouldn't mark as read.
            return
        }

        guard self.presentedViewController == nil else {
            return
        }

        guard !AppEnvironment.shared.windowManagerRef.shouldShowCallView else {
            return
        }

        // Always clear the thread unread flag
        clearThreadUnreadFlagIfNecessary()

        let lastVisibleSortId = self.lastVisibleSortId
        let isShowingUnreadMessage = lastVisibleSortId > self.lastSortIdMarkedRead
        if !self.isMarkingAsRead, isShowingUnreadMessage {
            self.isMarkingAsRead = true

            SSKEnvironment.shared.receiptManagerRef.markAsReadLocally(
                beforeSortId: lastVisibleSortId,
                thread: self.thread,
                hasPendingMessageRequest: self.threadViewModel.hasPendingMessageRequest,
            ) {
                AssertIsOnMainThread()
                self.setLastSortIdMarkedRead(lastSortIdMarkedRead: lastVisibleSortId)
                self.isMarkingAsRead = false

                // If -markVisibleMessagesAsRead wasn't invoked on a
                // timer, we'd want to double check that the current
                // -lastVisibleSortId hasn't incremented since we
                // started the read receipt request. But we have a
                // timer, so if it has changed, this method will just
                // be reinvoked in < 100ms.
            }
        }
    }
}