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

public import SignalServiceKit
public import SignalUI

extension ConversationViewController {

    public func renderItem(forIndex index: NSInteger) -> CVRenderItem? {
        guard index >= 0, index < renderItems.count else {
            owsFailDebug("Invalid view item index: \(index)")
            return nil
        }
        return renderItems[index]
    }

    var renderState: CVRenderState {
        AssertIsOnMainThread()

        return loadCoordinator.renderState
    }

    public var renderItems: [CVRenderItem] {
        AssertIsOnMainThread()

        return loadCoordinator.renderItems
    }

    public var allIndexPaths: [IndexPath] {
        AssertIsOnMainThread()

        return loadCoordinator.allIndexPaths
    }

    func ensureIndexPath(of interaction: TSMessage) -> IndexPath? {
        // CVC TODO: This is incomplete.
        self.indexPath(forInteractionUniqueId: interaction.uniqueId)
    }

    func clearThreadUnreadFlagIfNecessary() {
        if threadViewModel.isMarkedUnread {
            SSKEnvironment.shared.databaseStorageRef.write { transaction in
                self.threadViewModel.associatedData.updateWith(
                    isMarkedUnread: false,
                    updateStorageService: true,
                    transaction: transaction,
                )
            }
        }
    }

    public static func canCall(threadViewModel: ThreadViewModel) -> Bool {
        if threadViewModel.hasPendingMessageRequest {
            return false
        }
        switch threadViewModel.threadRecord {
        case let thread as TSContactThread:
            return thread.canCall
        case let thread as TSGroupThread:
            return thread.canCall
        default:
            return false
        }
    }

    // MARK: -

    // When performing an interactive dismiss, safe area updates rapidly in quick succession,
    // which causes this method to go haywire, recomputing insets a few times and incorrectly determining
    // that it needs to scroll as a result. To avoid this, apply a debounce to rapid updates.
    public func updateContentInsetsDebounced() {
        updateContentInsetsEvent.requestNotify()
    }

    func updateContentInsets() {
        AssertIsOnMainThread()

        // Don't update the content insets if an interactive pop is in progress
        guard let navigationController else {
            return
        }
        if let interactivePopGestureRecognizer = navigationController.interactivePopGestureRecognizer {
            switch interactivePopGestureRecognizer.state {
            case .possible, .failed:
                break
            default:
                return
            }
        }

        view.layoutIfNeeded()

        let oldInsets = collectionView.contentInset
        var newInsets = oldInsets

        newInsets.bottom = bottomBarContainer.frame.height - collectionView.safeAreaInsets.bottom
        newInsets.top = (bannerStackView?.height ?? 0)

        let wasScrolledToBottom = self.isScrolledToBottom

        // Changing the contentInset can change the contentOffset, so make sure we
        // stash the current value before making any changes.
        let oldYOffset = collectionView.contentOffset.y

        let didChangeInsets = oldInsets != newInsets

        UIView.performWithoutAnimation {
            if didChangeInsets {
                let contentOffset = self.collectionView.contentOffset
                self.collectionView.contentInset = newInsets
                self.collectionView.setContentOffset(contentOffset, animated: false)
            }
            self.collectionView.scrollIndicatorInsets = newInsets
        }

        // If content inset didn't change, no need to update content offset.
        guard didChangeInsets else { return }

        // UIKit updates collection view's scroll position when user drags with the keyboard
        // We don't need to do anything.
        guard !collectionView.isDragging else { return }

        // Adjust content offset to prevent the presented keyboard from obscuring content.
        if !hasAppearedAndHasAppliedFirstLoad {
            // Do nothing.
        } else if isPresentingContextMenu {
            // Do nothing
        } else if wasScrolledToBottom {
            // If we were scrolled to the bottom, don't do any fancy math. Just stay at the bottom.
            scrollToBottomOfLoadWindow(animated: false)
        } else if isViewCompletelyAppeared {
            // If we were scrolled away from the bottom, shift the content in lockstep with the
            // keyboard, up to the limits of the content bounds.
            let insetChange = newInsets.bottom - oldInsets.bottom

            // Only update the content offset if the inset has changed.
            if insetChange != 0 {
                // The content offset can go negative, up to the size of the top layout guide.
                // This accounts for the extended layout under the navigation bar.
                let minYOffset = -view.safeAreaInsets.top
                let newYOffset = (oldYOffset + insetChange).clamp(minYOffset, safeContentHeight)
                let newOffset = CGPoint(x: 0, y: newYOffset)

                // This offset change will be animated by UIKit's UIView animation block
                // which updateContentInsets() is called within
                collectionView.setContentOffset(newOffset, animated: false)
            }
        }
    }

    public func showUnknownThreadWarningAlert() {
        // TODO: Finalize this copy.
        let message = (
            thread.isGroupThread
                ? OWSLocalizedString(
                    "ALERT_UNKNOWN_THREAD_WARNING_GROUP_MESSAGE",
                    comment: "Message for UI warning about an unknown group thread.",
                )
                : OWSLocalizedString(
                    "ALERT_UNKNOWN_THREAD_WARNING_CONTACT_MESSAGE",
                    comment: "Message for UI warning about an unknown contact thread.",
                ),
        )
        let actionSheet = ActionSheetController(message: message)
        actionSheet.addAction(ActionSheetAction(
            title: OWSLocalizedString(
                "ALERT_UNKNOWN_THREAD_WARNING_LEARN_MORE",
                comment: "Label for button to learn more about message requests.",
            ),
            style: .default,
            handler: { _ in
                CurrentAppContext().open(URL.Support.profilesAndMessageRequests, completion: nil)
            },
        ))
        actionSheet.addAction(OWSActionSheets.cancelAction)
        presentActionSheet(actionSheet)
    }

    public func showDeliveryIssueWarningAlert(from senderAddress: SignalServiceAddress, isKnownThread: Bool) {
        let senderName = SSKEnvironment.shared.databaseStorageRef.read { transaction in
            SSKEnvironment.shared.contactManagerRef.displayName(for: senderAddress, tx: transaction).resolvedValue()
        }
        let alertTitle = OWSLocalizedString("ALERT_DELIVERY_ISSUE_TITLE", comment: "Title for delivery issue sheet")
        let alertMessageFormat: String
        if isKnownThread {
            alertMessageFormat = OWSLocalizedString("ALERT_DELIVERY_ISSUE_MESSAGE_FORMAT", comment: "Format string for delivery issue sheet message. Embeds {{ sender name }}.")
        } else {
            alertMessageFormat = OWSLocalizedString("ALERT_DELIVERY_ISSUE_UNKNOWN_THREAD_MESSAGE_FORMAT", comment: "Format string for delivery issue sheet message where the original thread is unknown. Embeds {{ sender name }}.")
        }

        let alertMessage = String.nonPluralLocalizedStringWithFormat(alertMessageFormat, senderName)

        let headerImageView = UIImageView(image: .init(named: "delivery-issue"))
        headerImageView.autoSetDimension(.height, toSize: 110)
        headerImageView.autoSetDimension(.width, toSize: 200)

        let headerView = UIView()
        headerView.addSubview(headerImageView)
        headerImageView.autoPinEdge(toSuperviewEdge: .top, withInset: 22)
        headerImageView.autoPinEdge(toSuperviewEdge: .bottom)
        headerImageView.autoHCenterInSuperview()

        let actionSheet = ActionSheetController(
            title: alertTitle,
            message: alertMessage,
        )
        actionSheet.customHeader = headerView
        actionSheet.addAction(OWSActionSheets.okayAction)
        actionSheet.addAction(
            ActionSheetAction(
                title: CommonStrings.learnMore,
                style: .default,
            ) { _ in
                CurrentAppContext().open(URL.Support.deliveryIssue, completion: nil)
            },
        )
        presentActionSheet(actionSheet)
    }
}

// MARK: - ForwardMessageDelegate

extension ConversationViewController: ForwardMessageDelegate {
    func forwardMessageFlowDidComplete(
        items: [ForwardMessageItem],
        recipientThreads: [TSThread],
    ) {
        AssertIsOnMainThread()

        self.uiMode = .normal

        self.dismiss(animated: true) {
            ForwardMessageViewController.finalizeForward(
                items: items,
                recipientThreads: recipientThreads,
                fromViewController: self,
            )
        }
    }

    func forwardMessageFlowDidCancel() {
        self.dismiss(animated: true)
    }
}

// MARK: - MessageActionsToolbarDelegate

extension ConversationViewController: MessageActionsToolbarDelegate {
    public func messageActionsToolbar(_ messageActionsToolbar: MessageActionsToolbar, executedAction: MessageAction) {
        executedAction.block(messageActionsToolbar)
    }

    public var messageActionsToolbarSelectedInteractionCount: Int {
        self.selectionState.interactionCount
    }
}

// MARK: -

extension ConversationViewController: GroupViewHelperDelegate {
    func groupViewHelperDidUpdateGroup() {
        // Do nothing.
    }

    var currentGroupModel: TSGroupModel? {
        guard let groupThread = self.thread as? TSGroupThread else {
            return nil
        }
        return groupThread.groupModel
    }

    var fromViewController: UIViewController? {
        return self
    }
}

// MARK: - UIMode

extension ConversationViewController {
    func uiModeDidChange(oldValue: ConversationUIMode) {
        if oldValue == .search {
            navigationItem.searchController = nil
            // HACK: For some reason at this point the OWSNavbar retains the extra space it
            // used to house the search bar. This only seems to occur when dismissing
            // the search UI when scrolled to the very top of the conversation.
            navigationController?.navigationBar.sizeToFit()
        }

        switch uiMode {
        case .normal:
            if navigationItem.titleView != headerView {
                navigationItem.titleView = headerView
            }
        case .search:
            navigationItem.searchController = searchController.uiSearchController
        case .selection:
            navigationItem.titleView = nil
        }

        updateBarButtonItems()
        ensureBottomViewType()
    }
}

extension ConversationViewController: MediaPresentationContextProvider {
    func mediaPresentationContext(item: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? {
        guard case let .gallery(galleryItem) = item else {
            owsFailDebug("Unexpected media type")
            return nil
        }

        guard let indexPath = ensureIndexPath(of: galleryItem.message) else {
            owsFailDebug("indexPath was unexpectedly nil")
            return nil
        }

        // `indexPath(of:)` can change the load window which requires re-laying out our view
        // in order to correctly determine:
        //  - `indexPathsForVisibleItems`
        //  - the correct presentation frame
        collectionView.layoutIfNeeded()

        guard let visibleIndex = collectionView.indexPathsForVisibleItems.firstIndex(of: indexPath) else {
            // This could happen if, after presenting media, you navigated within the gallery
            // to media not within the collectionView's visible bounds.
            return nil
        }

        guard let messageCell = collectionView.visibleCells[safe: visibleIndex] as? CVCell else {
            owsFailDebug("messageCell was unexpectedly nil")
            return nil
        }

        guard let mediaView = messageCell.albumItemView(forAttachment: galleryItem.attachmentStream) else {
            owsFailDebug("itemView was unexpectedly nil")
            return nil
        }

        guard let mediaSuperview = mediaView.superview else {
            owsFailDebug("mediaSuperview was unexpectedly nil")
            return nil
        }

        let presentationFrame = coordinateSpace.convert(mediaView.frame, from: mediaSuperview)

        var roundedCorners = RoundedCorners.all(CVComponentMessage.bubbleWideCornerRadius)
        let mediaViewFrame = mediaView.convert(mediaView.bounds, to: messageCell)
        var sharpBubbleCorners: UIRectCorner = []
        if let componentMessage = messageCell.rootComponent as? CVComponentMessage {
            sharpBubbleCorners = UIView.uiRectCorner(forOWSDirectionalRectCorner: componentMessage.sharpCorners)
        }
        if mediaViewFrame.minY > messageCell.bounds.minY {
            // Media isn't aligned to cell's top edge - both top corners are square.
            roundedCorners.topLeft = 0
            roundedCorners.topRight = 0
        } else {
            // If media isn't pinned to cell's left edge it's left corners would be square.
            if mediaView.frame.minX > mediaSuperview.bounds.minX {
                roundedCorners.topLeft = 0
            } else if sharpBubbleCorners.contains(.topLeft) {
                roundedCorners.topLeft = CVComponentMessage.bubbleSharpCornerRadius
            }
            // If media isn't pinned to cell's right edge it's right corners would be square.
            if mediaView.frame.maxX < mediaSuperview.bounds.maxX {
                roundedCorners.topRight = 0
            } else if sharpBubbleCorners.contains(.topRight) {
                roundedCorners.topRight = CVComponentMessage.bubbleSharpCornerRadius
            }
        }
        if mediaViewFrame.maxY < messageCell.bounds.maxY {
            // Media isn't aligned to cell's bottom edge - both bottom corners are square.
            roundedCorners.bottomLeft = 0
            roundedCorners.bottomRight = 0
        } else {
            // If media isn't pinned to cell's left edge it's left corners would be square.
            if mediaView.frame.minX > mediaSuperview.bounds.minX {
                roundedCorners.bottomLeft = 0
            } else if sharpBubbleCorners.contains(.bottomLeft) {
                roundedCorners.bottomLeft = CVComponentMessage.bubbleSharpCornerRadius
            }
            // If media isn't pinned to cell's right edge it's right corners would be square.
            if mediaView.frame.maxX < mediaSuperview.bounds.maxX {
                roundedCorners.bottomRight = 0
            } else if sharpBubbleCorners.contains(.bottomRight) {
                roundedCorners.bottomRight = CVComponentMessage.bubbleSharpCornerRadius
            }
        }

        // Avoid using `variableRoundedCorners` as much as possible because that doesn't work well
        // with spring animations.
        let mediaViewShape: MediaViewShape
        if roundedCorners.isAllCornerRadiiEqual {
            mediaViewShape = .rectangle(roundedCorners.topLeft)
        } else {
            mediaViewShape = .variableRoundedCorners(roundedCorners)
        }

        return MediaPresentationContext(
            mediaView: mediaView,
            presentationFrame: presentationFrame,
            mediaViewShape: mediaViewShape,
            clippingAreaInsets: collectionView.adjustedContentInset,
        )
    }

    func mediaWillDismiss(toContext: MediaPresentationContext) {
        // To avoid flicker when transition view is animated over the message bubble,
        // we initially hide the overlaying elements and fade them in.
        toContext.mediaOverlayViews.forEach { $0.alpha = 0 }
    }

    func mediaDidDismiss(toContext: MediaPresentationContext) {
        // To avoid flicker when transition view is animated over the message bubble,
        // we initially hide the overlaying elements and fade them in.
        let mediaOverlayViews = toContext.mediaOverlayViews
        UIView.animate(
            withDuration: MediaPresentationContext.animationDuration,
            animations: {
                mediaOverlayViews.forEach { $0.alpha = 1 }
            },
        )
    }
}

// MARK: -

public extension ConversationViewController {
    func showGroupLinkPromotionActionSheet() {
        guard let groupThread = thread as? TSGroupThread else {
            owsFailDebug("Invalid thread.")
            return
        }
        guard groupThread.isGroupV2Thread else {
            return
        }

        if groupThread.isTerminatedGroup {
            showUnableToTakeActionInEndedGroupSheet()
            return
        }

        let view = GroupLinkPromotionActionSheet(
            groupThread: groupThread,
            conversationViewController: self,
        )
        view.present(fromViewController: self)
    }

    func showUnableToTakeActionInEndedGroupSheet() {
        let alert = ActionSheetController(
            title: nil,
            message: OWSLocalizedString(
                "END_GROUP_ACTION_ERROR",
                comment: "Description for error sheet that says the user can no longer take this action because the group has ended.",
            ),
        )

        alert.addAction(OWSActionSheets.okayAction)
        presentActionSheet(alert)
    }
}

// MARK: -

extension ConversationViewController: MessageDetailViewDelegate {

    func detailViewMessageWasDeleted(_ messageDetailViewController: MessageDetailViewController) {
        Logger.info("")

        navigationController?.popToViewController(self, animated: true)
    }
}

// MARK: - MessageEditHistoryViewDelegate

extension ConversationViewController: MessageEditHistoryViewDelegate {
    func editHistoryMessageWasDeleted() {
        self.dismiss(animated: true)
    }
}

// MARK: -

extension ConversationViewController: LongTextViewDelegate {

    func longTextViewMessageWasDeleted(_ longTextViewController: LongTextViewController) {
        Logger.info("")

        navigationController?.popToViewController(self, animated: true)
    }

    func expandTruncatedTextOrPresentLongTextView(_ itemViewModel: CVItemViewModelImpl) {
        AssertIsOnMainThread()

        guard let displayableBodyText = itemViewModel.displayableBodyText else {
            owsFailDebug("Missing displayableBodyText.")
            return
        }
        if displayableBodyText.canRenderTruncatedTextInline {
            self.setTextExpanded(interactionId: itemViewModel.interaction.uniqueId)
            self.loadCoordinator.enqueueReload(
                updatedInteractionIds: [itemViewModel.interaction.uniqueId],
                deletedInteractionIds: [],
            )
        } else {
            let viewController = LongTextViewController(
                itemViewModel: itemViewModel,
                threadViewModel: self.threadViewModel,
                spoilerState: self.viewState.spoilerState,
            )
            viewController.delegate = self
            navigationController?.pushViewController(viewController, animated: true)
        }
    }
}

// MARK: -

extension ConversationViewController: SendPaymentViewDelegate {
    public func didSendPayment(success: Bool) {

        func paymentSettingsNavigationController() -> OWSNavigationController {
            let paymentSettingsView = PaymentsSettingsViewController(mode: .standalone, appReadiness: appReadiness)
            return OWSNavigationController(rootViewController: paymentSettingsView)
        }

        // only prompt users to enable payments lock when successful.
        guard success else {
            // TODO - Remove when in-chat payment bubble implemented.
            self.presentFormSheet(paymentSettingsNavigationController(), animated: true)
            return
        }

        PaymentOnboarding.presentBiometricLockPromptIfNeeded { [weak self] in
            // TODO - Remove when in-chat payment bubble implemented.
            self?.presentFormSheet(paymentSettingsNavigationController(), animated: true)
        }
    }
}