// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalServiceKit 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? public let headerView = ConversationHeaderView() public let inputAccessoryPlaceholder = InputAccessoryViewPlaceholder() public var bottomBar = UIView.container() public var bottomBarBottomConstraint: NSLayoutConstraint? public var requestView: UIView? public var bannerView: UIView? 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() 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 groupCallTooltip: GroupCallTooltip? public var groupCallTooltipTailReferenceView: UIView? public var didAlreadyShowGroupCallTooltipEnoughTimes: Bool public var hasIncrementedGroupCallTooltipShownCount = false public var groupCallBarButtonItem: UIBarButtonItem? public var lastMessageSentDate: Date? public let scrollDownButton = ConversationScrollButton(iconName: "chevron-down-20") public var isHidingScrollDownButton = false public let scrollToNextMentionButton = ConversationScrollButton(iconName: "at-display") public var isHidingScrollToNextMentionButton = false public var scrollUpdateTimer: Timer? public var isWaitingForDeceleration = false 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 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() var unwrappedGiftMessageIds = Set() // MARK: - public init( threadUniqueId: String, conversationStyle: ConversationStyle, didAlreadyShowGroupCallTooltipEnoughTimes: Bool, chatColor: ColorOrGradientSetting, wallpaperViewBuilder: WallpaperViewBuilder? ) { self.threadUniqueId = threadUniqueId self.conversationStyle = conversationStyle self.didAlreadyShowGroupCallTooltipEnoughTimes = didAlreadyShowGroupCallTooltipEnoughTimes 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: OWSDisappearingMessagesConfiguration { threadViewModel.disappearingMessagesConfiguration } var conversationStyle: ConversationStyle { get { viewState.conversationStyle } set { viewState.conversationStyle = newValue } } var headerView: ConversationHeaderView { viewState.headerView } var inputToolbar: ConversationInputToolbar? { get { viewState.inputToolbar } set { viewState.inputToolbar = newValue } } var inputAccessoryPlaceholder: InputAccessoryViewPlaceholder { viewState.inputAccessoryPlaceholder } var bottomBar: UIView { viewState.bottomBar } var bottomBarBottomConstraint: NSLayoutConstraint? { get { viewState.bottomBarBottomConstraint } set { viewState.bottomBarBottomConstraint = newValue } } var requestView: UIView? { get { viewState.requestView } set { viewState.requestView = newValue } } var bannerView: UIView? { get { viewState.bannerView } set { viewState.bannerView = 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 isMeasuringKeyboardHeight: Bool { inputToolbar?.isMeasuringKeyboardHeight ?? false } var isSwitchingKeyboard: Bool { // See comment in `ConversationInputToolbar.isSwitchingKeyboard`. guard #available(iOS 17, *) else { return false } return inputToolbar?.isSwitchingKeyboard ?? false } 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 collectionViewTapGestureRecognizer: SingleOrDoubleTapGestureRecognizer { viewState.collectionViewTapGestureRecognizer } var collectionViewLongPressGestureRecognizer: UILongPressGestureRecognizer { viewState.collectionViewLongPressGestureRecognizer } var collectionViewContextMenuGestureRecognizer: UILongPressGestureRecognizer { viewState.collectionViewContextMenuGestureRecognizer } var collectionViewContextMenuSecondaryClickRecognizer: UITapGestureRecognizer? { get { viewState.collectionViewContextMenuSecondaryClickRecognizer } set { viewState.collectionViewContextMenuSecondaryClickRecognizer = newValue } } var collectionViewPanGestureRecognizer: UIPanGestureRecognizer { viewState.collectionViewPanGestureRecognizer } var collectionViewActiveContextMenuInteraction: ChatHistoryContextMenuInteraction? { get { viewState.collectionViewActiveContextMenuInteraction } set { viewState.collectionViewActiveContextMenuInteraction = newValue } } var backgroundContainer: CVBackgroundContainer { viewState.backgroundContainer } internal 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() init(expandedTextInteractionsIds: Set? = nil) { if let expandedTextInteractionsIds = 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 = 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 } }