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

public import SignalServiceKit
public import SignalUI

public protocol CVViewStateDelegate: AnyObject {
    func viewStateUIModeDidChange(oldValue: ConversationUIMode)
}

// MARK: -

// This can be a simple place to hang CVC's mutable view state.
//
// These properties should only be accessed on the main thread.
public class CVViewState: NSObject {
    public weak var delegate: CVViewStateDelegate?

    public let threadUniqueId: String
    public var conversationStyle: ConversationStyle
    public var inputToolbar: ConversationInputToolbar?
    let headerView = ConversationHeaderView()

    public var bottomBarContainer = UIView.container()
    public var requestView: UIView?
    public var bannerStackView: UIStackView?
    public var groupNameCollisionFinder: GroupMembershipNameCollisionFinder?

    public var isDismissingInteractively = false

    public var isViewCompletelyAppeared = false
    public var isViewVisible = false
    public var shouldAnimateKeyboardChanges = false
    public var isInPreviewPlatter = false
    public let viewCreationDate = Date()
    public var hasAppliedFirstLoad = false

    public var isUserScrolling = false
    public var scrollingAnimationCompletionTimer: Timer?
    public var hasScrollingAnimation: Bool {
        AssertIsOnMainThread()

        return scrollingAnimationCompletionTimer != nil
    }

    public var scrollActionForSizeTransition: CVScrollAction?
    public var scrollActionForUpdate: CVScrollAction?
    public var lastKnownDistanceFromBottom: CGFloat?
    public var lastSearchedText: String?

    public var activeCellAnimations = Set<UUID>()

    public func beginCellAnimation(identifier: UUID) {
        activeCellAnimations.insert(identifier)
    }

    public func endCellAnimation(identifier: UUID) {
        activeCellAnimations.remove(identifier)
    }

    var bottomViewType: CVCBottomViewType = .none

    public var uiMode: ConversationUIMode = .normal {
        didSet {
            AssertIsOnMainThread()
            let didChange = uiMode != oldValue
            if didChange {
                selectionState.reset()
                delegate?.viewStateUIModeDidChange(oldValue: oldValue)
            }
        }
    }

    enum SelectionAnimationState { case idle, willAnimate, animating }
    var selectionAnimationState: SelectionAnimationState = .idle

    public let selectionState = CVSelectionState()
    public let textExpansion = CVTextExpansion()
    public let spoilerState = SpoilerRenderState()
    public let messageSwipeActionState = CVMessageSwipeActionState()

    public var isDarkThemeEnabled: Bool = Theme.isDarkThemeEnabled

    public var sendMessageController: SendMessageController?

    public let mediaCache = CVMediaCache()

    let contactShareViewHelper = ContactShareViewHelper()

    public var userHasScrolled = false

    public var groupCallBarButtonItem: UIBarButtonItem?

    public var lastMessageSentDate: Date?

    public let scrollDownButton = ConversationScrollButton(iconName: "chevron-down")
    public var isHidingScrollDownButton = false
    public let scrollToNextMentionButton = ConversationScrollButton(iconName: "at")
    public var isHidingScrollToNextMentionButton = false
    public var scrollUpdateTimer: Timer?
    public var isWaitingForDeceleration = false
    public var highlightedMessageId: String?
    public var focusedMessageId: String?

    public var actionOnOpen: ConversationViewAction = .none

    public var readTimer: Timer?
    public var reloadTimer: Timer?

    public var lastSortIdMarkedRead: UInt64 = 0
    public var isMarkingAsRead = false

    // MARK: - Gestures

    public var collectionViewGestureRecongnizersConfigured = false
    public let collectionViewTapGestureRecognizer = SingleOrDoubleTapGestureRecognizer()
    public let collectionViewLongPressGestureRecognizer = UILongPressGestureRecognizer()
    public let collectionViewContextMenuGestureRecognizer = UILongPressGestureRecognizer()
    public var collectionViewContextMenuSecondaryClickRecognizer = UITapGestureRecognizer()
    public let collectionViewPanGestureRecognizer = UIPanGestureRecognizer()

    public var collectionViewActiveContextMenuInteraction: ChatHistoryContextMenuInteraction?
    public var longPressHandler: CVLongPressHandler?
    public var panHandler: CVPanHandler?

    // MARK: -

    var initialScrollState: CVInitialScrollState?

    public var presentationStatus: CVPresentationStatus = .notYetPresented

    public let backgroundContainer = CVBackgroundContainer()
    public var wallpaperViewBuilder: WallpaperViewBuilder?
    var chatColor: ColorOrGradientSetting

    weak var reactionsDetailSheet: ReactionsDetailSheet?

    public var lastKeyboardAnimationDate: Date?

    // MARK: - Voice Messages

    var inProgressVoiceMessage: VoiceMessageInProgressDraft?

    // MARK: - Gift Badges

    var shakenGiftMessageIds = Set<String>()

    var unwrappedGiftMessageIds = Set<String>()

    /// The set of collapse set IDs that have been expanded by the user.
    /// Resets to empty when leaving the conversation.
    var expandedCollapseSets = Set<String>()

    // MARK: - Attachment downloads

    var manuallyCanceledDownloadsMessageIds = Set<String>()

    // MARK: -

    public init(
        threadUniqueId: String,
        conversationStyle: ConversationStyle,
        chatColor: ColorOrGradientSetting,
        wallpaperViewBuilder: WallpaperViewBuilder?,
    ) {
        self.threadUniqueId = threadUniqueId
        self.conversationStyle = conversationStyle
        self.chatColor = chatColor
        self.wallpaperViewBuilder = wallpaperViewBuilder
    }
}

// MARK: -

extension ConversationViewController {

    var threadViewModel: ThreadViewModel { renderState.threadViewModel }

    var conversationViewModel: ConversationViewModel { renderState.conversationViewModel }

    var thread: TSThread { threadViewModel.threadRecord }

    var disappearingMessagesConfiguration: DisappearingMessagesConfigurationRecord { threadViewModel.disappearingMessagesConfiguration }

    var conversationStyle: ConversationStyle {
        get { viewState.conversationStyle }
        set {
            viewState.conversationStyle = newValue
            if #available(iOS 26, *) {
                let tintColor = newValue.chatColorValue.asChatUIElementTintColor()
                viewState.scrollDownButton.badgeTintColor = tintColor
                viewState.scrollToNextMentionButton.badgeTintColor = tintColor
            }
        }
    }

    var headerView: ConversationHeaderView { viewState.headerView }

    var inputToolbar: ConversationInputToolbar? {
        get { viewState.inputToolbar }
        set { viewState.inputToolbar = newValue }
    }

    var bottomBarContainer: UIView {
        viewState.bottomBarContainer
    }

    var requestView: UIView? {
        get { viewState.requestView }
        set { viewState.requestView = newValue }
    }

    var bannerStackView: UIStackView? {
        get { viewState.bannerStackView }
        set { viewState.bannerStackView = newValue }
    }

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

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

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

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

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

    var hasScrollingAnimation: Bool { viewState.hasScrollingAnimation }

    var uiMode: ConversationUIMode {
        get { viewState.uiMode }
        set {
            let oldValue = viewState.uiMode
            guard oldValue != newValue else {
                return
            }
            viewState.uiMode = newValue
            uiModeDidChange(oldValue: oldValue)
        }
    }

    var isShowingSelectionUI: Bool { viewState.uiMode.hasSelectionUI }

    var lastSearchedText: String? {
        get { viewState.lastSearchedText }
        set { viewState.lastSearchedText = newValue }
    }

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

    var mediaCache: CVMediaCache { viewState.mediaCache }

    var groupCallBarButtonItem: UIBarButtonItem? {
        get { viewState.groupCallBarButtonItem }
        set { viewState.groupCallBarButtonItem = newValue }
    }

    var lastMessageSentDate: Date? {
        get { viewState.lastMessageSentDate }
        set { viewState.lastMessageSentDate = newValue }
    }

    var actionOnOpen: ConversationViewAction {
        get { viewState.actionOnOpen }
        set { viewState.actionOnOpen = newValue }
    }

    // MARK: - Gestures

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

    var collectionViewTapGestureRecognizer: SingleOrDoubleTapGestureRecognizer {
        viewState.collectionViewTapGestureRecognizer
    }

    var collectionViewLongPressGestureRecognizer: UILongPressGestureRecognizer {
        viewState.collectionViewLongPressGestureRecognizer
    }

    var collectionViewContextMenuGestureRecognizer: UILongPressGestureRecognizer {
        viewState.collectionViewContextMenuGestureRecognizer
    }

    var collectionViewContextMenuSecondaryClickRecognizer: UITapGestureRecognizer {
        viewState.collectionViewContextMenuSecondaryClickRecognizer
    }

    var collectionViewPanGestureRecognizer: UIPanGestureRecognizer {
        viewState.collectionViewPanGestureRecognizer
    }

    var collectionViewActiveContextMenuInteraction: ChatHistoryContextMenuInteraction? {
        get { viewState.collectionViewActiveContextMenuInteraction }
        set { viewState.collectionViewActiveContextMenuInteraction = newValue }
    }

    var backgroundContainer: CVBackgroundContainer { viewState.backgroundContainer }
    var reactionsDetailSheet: ReactionsDetailSheet? {
        get { viewState.reactionsDetailSheet }
        set { viewState.reactionsDetailSheet = newValue }
    }

    var contactShareViewHelper: ContactShareViewHelper { viewState.contactShareViewHelper }
}

// MARK: -

extension CVViewState {

    var asCoreState: CVCoreState {
        CVCoreState(conversationStyle: conversationStyle, mediaCache: mediaCache)
    }
}

// MARK: -

// Accessors for the non-@objc properties.
extension ConversationViewController {

    var longPressHandler: CVLongPressHandler? {
        get { viewState.longPressHandler }
        set { viewState.longPressHandler = newValue }
    }

    var panHandler: CVPanHandler? {
        get { viewState.panHandler }
        set { viewState.panHandler = newValue }
    }

    public var selectionState: CVSelectionState { viewState.selectionState }

    func isTextExpanded(interactionId: String) -> Bool {
        viewState.textExpansion.isTextExpanded(interactionId: interactionId)
    }

    func setTextExpanded(interactionId: String) {
        viewState.textExpansion.setTextExpanded(interactionId: interactionId)
    }

    var initialScrollState: CVInitialScrollState? {
        get { viewState.initialScrollState }
        set { viewState.initialScrollState = newValue }
    }

    var lastKnownDistanceFromBottom: CGFloat? {
        get { viewState.lastKnownDistanceFromBottom }
        set { viewState.lastKnownDistanceFromBottom = newValue }
    }

    var sendMessageController: SendMessageController? {
        get { viewState.sendMessageController }
        set { viewState.sendMessageController = newValue }
    }
}

// MARK: -

// This struct facilitates passing around a few key
// pieces of CVC state during async loads.
struct CVCoreState {
    let conversationStyle: ConversationStyle
    let mediaCache: CVMediaCache
}

// MARK: -

public class CVTextExpansion {
    private var expandedTextInteractionsIds = Set<String>()

    init(expandedTextInteractionsIds: Set<String>? = nil) {
        if let expandedTextInteractionsIds {
            self.expandedTextInteractionsIds = expandedTextInteractionsIds
        }
    }

    public func isTextExpanded(interactionId: String) -> Bool {
        expandedTextInteractionsIds.contains(interactionId)
    }

    public func setTextExpanded(interactionId: String) {
        expandedTextInteractionsIds.insert(interactionId)
    }

    func copy() -> CVTextExpansion {
        CVTextExpansion(expandedTextInteractionsIds: expandedTextInteractionsIds)
    }

    //    // TODO: collapseCutoffDate
    //    let collapseCutoffDate = Date()
}

// MARK: -

public class CVMessageSwipeActionState {
    public struct Progress {
        let xOffset: CGFloat
    }

    public typealias ProgressMap = [String: Progress]
    private var progressMap = ProgressMap()

    init(progressMap: ProgressMap? = nil) {
        if let progressMap {
            self.progressMap = progressMap
        }
    }

    public func getProgress(interactionId: String) -> Progress? {
        progressMap[interactionId]
    }

    public func setProgress(interactionId: String, progress: Progress) {
        progressMap[interactionId] = progress
    }

    public func resetProgress(interactionId: String) {
        progressMap[interactionId] = nil
    }

    func copy() -> CVMessageSwipeActionState {
        CVMessageSwipeActionState(progressMap: progressMap)
    }
}

// MARK: -

// Describes the initial scroll state when we present CVC.
//
// Initial scroll state only applies until the first time
// CVC.viewDidAppear() is called.
struct CVInitialScrollState {
    let focusMessageId: String?
}

// MARK: -

// Records whether or not the conversation view
// has ever reached these milestones of its lifecycle.
public enum CVPresentationStatus: UInt, CustomStringConvertible {
    case notYetPresented = 0
    case firstViewWillAppearHasBegun
    case firstViewWillAppearHasCompleted
    case firstViewDidAppearHasBegun
    case firstViewDidAppearHasCompleted

    public var description: String {
        switch self {
        case .notYetPresented:
            return ".notYetPresented"
        case .firstViewWillAppearHasBegun:
            return ".firstViewWillAppearHasBegun"
        case .firstViewWillAppearHasCompleted:
            return ".firstViewWillAppearHasCompleted"
        case .firstViewDidAppearHasBegun:
            return ".firstViewDidAppearHasBegun"
        case .firstViewDidAppearHasCompleted:
            return ".firstViewDidAppearHasCompleted"
        }
    }
}

// MARK: -

public extension ConversationViewController {

    var presentationStatus: CVPresentationStatus { viewState.presentationStatus }

    private func updatePresentationStatus(_ value: CVPresentationStatus) {
        AssertIsOnMainThread()

        if viewState.presentationStatus.rawValue < value.rawValue {
            viewState.presentationStatus = value
        }
    }

    func viewWillAppearDidBegin() {
        updatePresentationStatus(.firstViewWillAppearHasBegun)
    }

    func viewWillAppearDidComplete() {
        updatePresentationStatus(.firstViewWillAppearHasCompleted)
    }

    func viewDidAppearDidBegin() {
        updatePresentationStatus(.firstViewDidAppearHasBegun)
    }

    func viewDidAppearDidComplete() {
        updatePresentationStatus(.firstViewDidAppearHasCompleted)
    }

    var hasViewWillAppearEverBegun: Bool {
        viewState.presentationStatus.rawValue >= CVPresentationStatus.firstViewWillAppearHasBegun.rawValue
    }

    var hasViewDidAppearEverBegun: Bool {
        viewState.presentationStatus.rawValue >= CVPresentationStatus.firstViewDidAppearHasBegun.rawValue
    }

    var hasViewDidAppearEverCompleted: Bool {
        viewState.presentationStatus.rawValue >= CVPresentationStatus.firstViewDidAppearHasCompleted.rawValue
    }

    var viewHasEverAppeared: Bool {
        hasViewDidAppearEverCompleted
    }
}