529 lines
16 KiB
Swift
529 lines
16 KiB
Swift
//
|
|
// 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?
|
|
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<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 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<String>()
|
|
|
|
var unwrappedGiftMessageIds = Set<String>()
|
|
|
|
// 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<String>()
|
|
|
|
init(expandedTextInteractionsIds: Set<String>? = 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
|
|
}
|
|
}
|