diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 000185bc20..789f642d2e 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -341,6 +341,8 @@ 34A4D8872678F24300A794E7 /* ConversationViewController+Delegates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A4D8862678F24300A794E7 /* ConversationViewController+Delegates.swift */; }; 34A4D8892679296600A794E7 /* ConversationViewController+UIScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A4D8882679296600A794E7 /* ConversationViewController+UIScrollView.swift */; }; 34A4D88B26795AF800A794E7 /* ConversationViewController+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A4D88A26795AF800A794E7 /* ConversationViewController+UI.swift */; }; + 34A4D88D267A363A00A794E7 /* ConversationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A4D88C267A363A00A794E7 /* ConversationViewController.swift */; }; + 34A4D88F267A402100A794E7 /* ConversationViewController+Notifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A4D88E267A402100A794E7 /* ConversationViewController+Notifications.swift */; }; 34A6C28021E503E700B5B12E /* OWSImagePickerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */; }; 34A8B3512190A40E00218A25 /* CVMediaAlbumView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34A8B3502190A40E00218A25 /* CVMediaAlbumView.swift */; }; 34AC09DD211B39B100997B47 /* ViewControllerUtils.h in Headers */ = {isa = PBXBuildFile; fileRef = 34AC09BF211B39AE00997B47 /* ViewControllerUtils.h */; settings = {ATTRIBUTES = (Public, ); }; }; @@ -1397,6 +1399,8 @@ 34A4D8862678F24300A794E7 /* ConversationViewController+Delegates.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ConversationViewController+Delegates.swift"; sourceTree = ""; }; 34A4D8882679296600A794E7 /* ConversationViewController+UIScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ConversationViewController+UIScrollView.swift"; sourceTree = ""; }; 34A4D88A26795AF800A794E7 /* ConversationViewController+UI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ConversationViewController+UI.swift"; sourceTree = ""; }; + 34A4D88C267A363A00A794E7 /* ConversationViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConversationViewController.swift; sourceTree = ""; }; + 34A4D88E267A402100A794E7 /* ConversationViewController+Notifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "ConversationViewController+Notifications.swift"; sourceTree = ""; }; 34A6C27F21E503E600B5B12E /* OWSImagePickerController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSImagePickerController.swift; sourceTree = ""; }; 34A8B3502190A40E00218A25 /* CVMediaAlbumView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CVMediaAlbumView.swift; sourceTree = ""; }; 34AC09BF211B39AE00997B47 /* ViewControllerUtils.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = ViewControllerUtils.h; sourceTree = ""; }; @@ -2546,6 +2550,7 @@ 343A65961FC4CFE6000477A1 /* ConversationScrollButton.m */, 34D1F06D1F8678AA0066283D /* ConversationViewController.h */, 34D1F06E1F8678AA0066283D /* ConversationViewController.m */, + 34A4D88C267A363A00A794E7 /* ConversationViewController.swift */, 3405C06D25435048008B24EF /* ConversationViewController+Banners.swift */, 34E93F9126668F7500CE6FA5 /* ConversationViewController+BodyTextItems.swift */, 347342F62548587800D440CD /* ConversationViewController+BottomBar.swift */, @@ -2561,6 +2566,7 @@ 3426A367255D7BCA0036407F /* ConversationViewController+MessageActionsDelegate.swift */, 3457811A23EB56B300CE01C3 /* ConversationViewController+MessageRequest.swift */, 34A4D8822677C59300A794E7 /* ConversationViewController+Misc.swift */, + 34A4D88E267A402100A794E7 /* ConversationViewController+Notifications.swift */, 4CD675C422E7CF22008010D2 /* ConversationViewController+OWS.swift */, 88FE237D249C22080041670F /* ConversationViewController+Scroll.swift */, 3426A369255D9D7C0036407F /* ConversationViewController+Selection.swift */, @@ -5601,6 +5607,7 @@ 88A4CC19246CE6810082211F /* DeviceTransferInitialViewController.swift in Sources */, 3406D32425DD4A8F00885B14 /* PaymentsQRScanViewController.swift in Sources */, 8835DE01230DBF7E00DC6B66 /* ComposeViewController.swift in Sources */, + 34A4D88D267A363A00A794E7 /* ConversationViewController.swift in Sources */, 3495FF0F25F9538900959D6E /* PaymentsViewPassphraseSplashViewController.swift in Sources */, 3406D32225DD40F000885B14 /* PaymentsTransferOutViewController.swift in Sources */, 34A17D81253F7237009F8C02 /* ConversationSettingsViewController+LegacyGroups.swift in Sources */, @@ -5872,6 +5879,7 @@ 32ECD08A24BFBF9500EDDED0 /* ContactSupportViewController.swift in Sources */, 3448E15E221333F5004B052E /* OnboardingController.swift in Sources */, 88A4CC10246CE2760082211F /* TransferProgressView.swift in Sources */, + 34A4D88F267A402100A794E7 /* ConversationViewController+Notifications.swift in Sources */, 32E958AA25C12B3800BF12AD /* AnimatedProgressView.swift in Sources */, 3470C8802555F25200F5847C /* CVContactShareView.swift in Sources */, 32B3286524C6957B00E4F974 /* ComposeSupportEmailOperation.swift in Sources */, diff --git a/Signal/src/ViewControllers/ConversationView/CV/CVLoadCoordinator.swift b/Signal/src/ViewControllers/ConversationView/CV/CVLoadCoordinator.swift index ecf3f825f9..a3918c6bcc 100644 --- a/Signal/src/ViewControllers/ConversationView/CV/CVLoadCoordinator.swift +++ b/Signal/src/ViewControllers/ConversationView/CV/CVLoadCoordinator.swift @@ -46,8 +46,6 @@ struct CVUpdateToken { let lastMessageForInboxSortId: UInt64? } -// MARK: - - @objc public class CVLoadCoordinator: NSObject { @@ -87,17 +85,12 @@ public class CVLoadCoordinator: NSObject { private var loadDidLandResolver: Resolver? - required init(delegate: CVLoadCoordinatorDelegate, - componentDelegate: CVComponentDelegate, - conversationStyle: ConversationStyle, - focusMessageIdOnOpen: String?) { - self.delegate = delegate - self.componentDelegate = componentDelegate - self.viewState = delegate.viewState + required init(viewState: CVViewState) { + self.viewState = viewState let threadViewModel = viewState.threadViewModel self.threadUniqueId = threadViewModel.threadRecord.uniqueId self.thread = threadViewModel.threadRecord - self.conversationStyle = conversationStyle + self.conversationStyle = viewState.conversationStyle let viewStateSnapshot = CVViewStateSnapshot.snapshot(viewState: viewState, typingIndicatorsSender: nil, @@ -108,6 +101,13 @@ public class CVLoadCoordinator: NSObject { self.messageMapping = CVMessageMapping(thread: threadViewModel.threadRecord) super.init() + } + + func configure(delegate: CVLoadCoordinatorDelegate, + componentDelegate: CVComponentDelegate, + focusMessageIdOnOpen: String?) { + self.delegate = delegate + self.componentDelegate = componentDelegate Self.databaseStorage.appendUIDatabaseSnapshotDelegate(self) diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+Banners.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+Banners.swift index d1d7d74242..2566e82444 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController+Banners.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+Banners.swift @@ -467,7 +467,7 @@ private class NameCollisionBanner: UIView { imageName: "x-circle-16", tintColor: Theme.secondaryTextAndIconColor) button.accessibilityLabel = NSLocalizedString("BANNER_CLOSE_ACCESSIBILITY_LABEL", - comment: "Accessibility label for banner close button") + comment: "Accessibility label for banner close button") button.translatesAutoresizingMaskIntoConstraints = false button.setCompressionResistanceHigh() button.setContentHuggingHigh() diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+BottomBar.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+BottomBar.swift index c6285e239c..06bf77f0bf 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController+BottomBar.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+BottomBar.swift @@ -180,7 +180,7 @@ public extension ConversationViewController { AssertIsOnMainThread() guard let messageDraft = (Self.databaseStorage.uiRead { transaction in - self.thread.currentDraft(with: transaction) + self.thread.currentDraft(with: transaction) }) else { return } diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+CVC.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+CVC.swift index b6b42ee7a5..12d59cd4e8 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController+CVC.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+CVC.swift @@ -86,15 +86,6 @@ extension ConversationViewController: CVLoadCoordinatorDelegate { updateConversationStyle() } - @objc - public func buildLoadCoordinator(conversationStyle: ConversationStyle, - focusMessageIdOnOpen: String?) -> CVLoadCoordinator { - CVLoadCoordinator(delegate: self, - componentDelegate: self, - conversationStyle: conversationStyle, - focusMessageIdOnOpen: focusMessageIdOnOpen) - } - func willUpdateWithNewRenderState(_ renderState: CVRenderState) -> CVUpdateToken { AssertIsOnMainThread() @@ -240,7 +231,7 @@ extension ConversationViewController: CVLoadCoordinatorDelegate { DispatchQueue.main.async { let benchSteps = BenchSteps() Self.databaseStorage.uiRead { transaction in - self.reloadReactionsDetailSheet(with: transaction) + self.reloadReactionsDetailSheet(transaction: transaction) self.updateUnreadMessageFlag(transaction: transaction) } if hasViewDidAppearEverCompleted { diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+ConversationInputToolbarDelegate.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+ConversationInputToolbarDelegate.swift index 2e36afddee..13058a1215 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController+ConversationInputToolbarDelegate.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+ConversationInputToolbarDelegate.swift @@ -293,9 +293,9 @@ extension ConversationViewController: ConversationInputToolbarDelegate { let didShowSNAlert = self.showSafetyNumberConfirmationIfNecessary( confirmationText: SafetyNumberStrings.confirmSendButton) { [weak self] didConfirmIdentity in - if didConfirmIdentity { + if didConfirmIdentity { self?.tryToSendAttachments(attachments, messageBody: messageBody) - } + } } if didShowSNAlert { return diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+Delegates.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+Delegates.swift index 02b070cfba..9e4fd51010 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController+Delegates.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+Delegates.swift @@ -119,7 +119,7 @@ extension ConversationViewController: ContactsPickerDelegate { contactShareRecord.isProfileAvatar = isProfileAvatar let contactShare = ContactShareViewModel(contactShareRecord: contactShareRecord, - avatarImageData: avatarImageData) + avatarImageData: avatarImageData) let approveContactShare = ContactShareApprovalViewController(contactShare: contactShare) approveContactShare.delegate = self @@ -167,7 +167,7 @@ extension ConversationViewController: ContactShareApprovalViewControllerDelegate private func send(contactShare: ContactShareViewModel) { AssertIsOnMainThread() - Logger.verbose("Sending contact share.") + Logger.verbose("Sending contact share.") let thread = self.thread Self.databaseStorage.asyncWrite { transaction in @@ -212,7 +212,7 @@ extension ConversationViewController: ContactShareApprovalViewControllerDelegate public func contactApprovalRecipientsDescription(_ contactApproval: ContactShareApprovalViewController) -> String? { AssertIsOnMainThread() - Logger.info("") + Logger.info("") return databaseStorage.read { transaction in Self.contactsManager.displayName(for: self.thread, transaction: transaction) diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+LastVisibleSortId.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+LastVisibleSortId.swift index e17ea36858..2577bd57cd 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController+LastVisibleSortId.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+LastVisibleSortId.swift @@ -1,5 +1,5 @@ // -// Copyright (c) 2020 Open Whisper Systems. All rights reserved. +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. // import Foundation diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+Mentions.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+Mentions.swift index 409ae8935f..2a78821d91 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController+Mentions.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+Mentions.swift @@ -1,5 +1,5 @@ // -// Copyright (c) 2020 Open Whisper Systems. All rights reserved. +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. // import Foundation diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+MessageActions.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+MessageActions.swift index 68710343a3..c08dc371b1 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController+MessageActions.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+MessageActions.swift @@ -134,4 +134,43 @@ extension ConversationViewController { // Check whether there is still a view item for this interaction. return self.indexPath(forInteractionUniqueId: messageActionInteractionId) == nil } + + public func reloadReactionsDetailSheet(transaction: SDSAnyReadTransaction) { + AssertIsOnMainThread() + + guard let reactionsDetailSheet = self.reactionsDetailSheet else { + return + } + + let messageId = reactionsDetailSheet.messageId + + guard let indexPath = self.indexPath(forInteractionUniqueId: messageId), + let renderItem = self.renderItem(forIndex: indexPath.row) else { + // The message no longer exists, dismiss the sheet. + dismissReactionsDetailSheet(animated: true) + return + } + guard let reactionState = renderItem.reactionState, + reactionState.hasReactions else { + // There are no longer reactions on this message, dismiss the sheet. + dismissReactionsDetailSheet(animated: true) + return + } + + // Update the detail sheet with the latest reaction + // state, in case the reactions have changed. + reactionsDetailSheet.setReactionState(reactionState, transaction: transaction) + } + + public func dismissReactionsDetailSheet(animated: Bool) { + AssertIsOnMainThread() + + guard let reactionsDetailSheet = self.reactionsDetailSheet else { + return + } + + reactionsDetailSheet.dismiss(animated: animated) { + self.reactionsDetailSheet = nil + } + } } diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+MessageRequest.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+MessageRequest.swift index e41b6e51f8..8d89422156 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController+MessageRequest.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+MessageRequest.swift @@ -238,14 +238,14 @@ extension ConversationViewController: NameCollisionResolutionDelegate { let actionSheetMessage: String if thread.isGroupThread { actionSheetTitleFormat = NSLocalizedString("MESSAGE_REQUEST_BLOCK_GROUP_TITLE_FORMAT", - comment: "Action sheet title to confirm blocking a group via a message request. Embeds {{group name}}") + comment: "Action sheet title to confirm blocking a group via a message request. Embeds {{group name}}") actionSheetMessage = NSLocalizedString("MESSAGE_REQUEST_BLOCK_GROUP_MESSAGE", - comment: "Action sheet message to confirm blocking a group via a message request.") + comment: "Action sheet message to confirm blocking a group via a message request.") } else { actionSheetTitleFormat = NSLocalizedString("MESSAGE_REQUEST_BLOCK_CONVERSATION_TITLE_FORMAT", - comment: "Action sheet title to confirm blocking a contact via a message request. Embeds {{contact name or phone number}}") + comment: "Action sheet title to confirm blocking a contact via a message request. Embeds {{contact name or phone number}}") actionSheetMessage = NSLocalizedString("MESSAGE_REQUEST_BLOCK_CONVERSATION_MESSAGE", - comment: "Action sheet message to confirm blocking a conversation via a message request.") + comment: "Action sheet message to confirm blocking a conversation via a message request.") } let threadName = contactsManager.displayNameWithSneakyTransaction(thread: thread) @@ -253,9 +253,9 @@ extension ConversationViewController: NameCollisionResolutionDelegate { let actionSheet = ActionSheetController(title: actionSheetTitle, message: actionSheetMessage) let blockActionTitle = NSLocalizedString("MESSAGE_REQUEST_BLOCK_ACTION", - comment: "Action sheet action to confirm blocking a thread via a message request.") + comment: "Action sheet action to confirm blocking a thread via a message request.") let blockAndDeleteActionTitle = NSLocalizedString("MESSAGE_REQUEST_BLOCK_AND_DELETE_ACTION", - comment: "Action sheet action to confirm blocking and deleting a thread via a message request.") + comment: "Action sheet action to confirm blocking and deleting a thread via a message request.") actionSheet.addAction(ActionSheetAction(title: blockActionTitle) { [weak self] _ in self?.blockThread() @@ -287,14 +287,14 @@ extension ConversationViewController: NameCollisionResolutionDelegate { actionSheetMessage = NSLocalizedString("MESSAGE_REQUEST_LEAVE_AND_DELETE_GROUP_MESSAGE", comment: "Action sheet message to confirm deleting a group via a message request.") confirmationText = NSLocalizedString("MESSAGE_REQUEST_LEAVE_AND_DELETE_GROUP_ACTION", - comment: "Action sheet action to confirm deleting a group via a message request.") + comment: "Action sheet action to confirm deleting a group via a message request.") } else { // either 1:1 thread, or a group of which I'm not a member actionSheetTitle = NSLocalizedString("MESSAGE_REQUEST_DELETE_CONVERSATION_TITLE", comment: "Action sheet title to confirm deleting a conversation via a message request.") actionSheetMessage = NSLocalizedString("MESSAGE_REQUEST_DELETE_CONVERSATION_MESSAGE", comment: "Action sheet message to confirm deleting a conversation via a message request.") confirmationText = NSLocalizedString("MESSAGE_REQUEST_DELETE_CONVERSATION_ACTION", - comment: "Action sheet action to confirm deleting a conversation via a message request.") + comment: "Action sheet action to confirm deleting a conversation via a message request.") } let actionSheet = ActionSheetController(title: actionSheetTitle, message: actionSheetMessage) diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+Misc.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+Misc.swift index f702e444a4..212061233a 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController+Misc.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+Misc.swift @@ -8,6 +8,26 @@ import ContactsUI @objc public extension ConversationViewController { + func updateV2GroupIfNecessary() { + AssertIsOnMainThread() + + guard let groupThread = thread as? TSGroupThread, + thread.isGroupV2Thread else { + return + } + // 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. + groupV2UpdatesObjc.tryToRefreshV2GroupUpToCurrentRevisionAfterMessageProcessingWithThrottling(groupThread) + } + + func presentAddThreadToProfileWhitelist(success: @escaping () -> Void) { + AssertIsOnMainThread() + + profileManagerImpl.presentAddThread(toProfileWhitelist: thread, + from: self, + success: success) + } + func showUnblockConversationUI(completion: BlockActionCompletionBlock?) { self.userHasScrolled = false @@ -447,10 +467,10 @@ extension ConversationViewController { readTimer?.invalidate() let readTimer = Timer.weakTimer(withTimeInterval: 0.1, - target: self, - selector: #selector(readTimerDidFire), - userInfo: nil, - repeats: true) + target: self, + selector: #selector(readTimerDidFire), + userInfo: nil, + repeats: true) self.readTimer = readTimer RunLoop.main.add(readTimer, forMode: .common) } @@ -501,8 +521,8 @@ extension ConversationViewController { AssertIsOnMainThread() if isUserScrolling || !isViewCompletelyAppeared || !isViewVisible - || !CurrentAppContext().isAppForegroundAndActive() || !viewHasEverAppeared - || isPresentingMessageActions { + || !CurrentAppContext().isAppForegroundAndActive() || !viewHasEverAppeared + || isPresentingMessageActions { return } diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+Notifications.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+Notifications.swift new file mode 100644 index 0000000000..9362627cb9 --- /dev/null +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+Notifications.swift @@ -0,0 +1,179 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +import Foundation + +extension ConversationViewController { + func addNotificationListeners() { + AssertIsOnMainThread() + + NotificationCenter.default.addObserver(self, + selector: #selector(blockListDidChange), + name: .blockListDidChange, + object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(windowManagerCallDidChange), + name: .OWSWindowManagerCallDidChange, + object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(identityStateDidChange), + name: .identityStateDidChange, + object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(didChangePreferredContentSize), + name: UIContentSizeCategory.didChangeNotification, + object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(applicationWillEnterForeground), + name: .OWSApplicationWillEnterForeground, + object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(applicationDidEnterBackground), + name: .OWSApplicationDidEnterBackground, + object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(applicationWillResignActive), + name: .OWSApplicationWillResignActive, + object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(applicationDidBecomeActive), + name: .OWSApplicationDidBecomeActive, + object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(otherUsersProfileDidChange), + name: .otherUsersProfileDidChange, + object: nil) + NotificationCenter.default.addObserver(self, + selector: #selector(profileWhitelistDidChange), + name: .profileWhitelistDidChange, + object: nil) + } + + @objc + private func otherUsersProfileDidChange(_ notification: NSNotification) { + AssertIsOnMainThread() + + if let address = notification.userInfo?[kNSNotificationKey_ProfileAddress] as? SignalServiceAddress, + address.isValid, + thread.recipientAddresses.contains(address) { + if thread is TSContactThread { + // update title with profile name + updateNavigationTitle() + } + + // Reload all cells if this is a group conversation, + // since we may need to update the sender names on the messages. + // Use a DebounceEvent to de-bounce. + if isGroupConversation { + otherUsersProfileDidChangeEvent?.requestNotify() + } + } + } + + @objc + private func profileWhitelistDidChange(_ notification: NSNotification) { + AssertIsOnMainThread() + + // If profile whitelist just changed, we may want to hide a profile whitelist offer. + if let address = notification.userInfo?[kNSNotificationKey_ProfileAddress] as? SignalServiceAddress, + address.isValid, + thread.recipientAddresses.contains(address) { + ensureBannerState() + showMessageRequestDialogIfRequired() + } else if let groupId = notification.userInfo?[kNSNotificationKey_ProfileGroupId] as? Data, + !groupId.isEmpty, + let groupThread = thread as? TSGroupThread, + groupThread.groupModel.groupId == groupId { + ensureBannerState() + showMessageRequestDialogIfRequired() + } + } + + @objc + private func blockListDidChange(_ notification: NSNotification) { + AssertIsOnMainThread() + + ensureBannerState() + } + + @objc + private func identityStateDidChange(_ notification: NSNotification) { + AssertIsOnMainThread() + + updateNavigationBarSubtitleLabel() + ensureBannerState() + } + + @objc + private func windowManagerCallDidChange(_ notification: NSNotification) { + AssertIsOnMainThread() + + updateBarButtonItems() + } + + /** + Called whenever the user manually changes the dynamic type options inside Settings. + + @param notification NSNotification with the dynamic type change information. + */ + @objc + private func didChangePreferredContentSize(_ notification: NSNotification) { + AssertIsOnMainThread() + + Logger.info("didChangePreferredContentSize") + + resetForSizeOrOrientationChange() + + guard hasViewWillAppearEverBegun else { + return + } + guard let inputToolbar = inputToolbar else { + owsFailDebug("Missing inputToolbar.") + return + } + + inputToolbar.updateFontSizes() + } + + @objc + private func applicationWillEnterForeground(_ notification: NSNotification) { + AssertIsOnMainThread() + + startReadTimer() + updateCellsVisible() + } + + @objc + private func applicationDidEnterBackground(_ notification: NSNotification) { + AssertIsOnMainThread() + + updateCellsVisible() + mediaCache.removeAllObjects() + cancelReadTimer() + } + + @objc + private func applicationWillResignActive(_ notification: NSNotification) { + AssertIsOnMainThread() + + finishRecordingVoiceMessage(sendImmediately: false) + self.isUserScrolling = false + self.isWaitingForDeceleration = false + saveDraft() + markVisibleMessagesAsRead() + mediaCache.removeAllObjects() + cancelReadTimer() + dismissPresentedViewControllerIfNecessary() + saveLastVisibleSortIdAndOnScreenPercentage() + + self.dismissKeyBoard() + } + + @objc + private func applicationDidBecomeActive(_ notification: NSNotification) { + AssertIsOnMainThread() + + startReadTimer() + } +} diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+Scroll.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+Scroll.swift index cbc643fa93..bd3fdf0852 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController+Scroll.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+Scroll.swift @@ -667,7 +667,7 @@ extension ConversationViewController { return CGPoint(x: 0, y: cellFrame.origin.y - messageActionsOriginalFocusY) } -// MARK: - + // MARK: - private struct LastVisibleInteraction { public let interaction: TSInteraction @@ -689,8 +689,8 @@ extension ConversationViewController { private func lastVisibleInteractionWithSneakyTransaction(thread: TSThread) -> LastVisibleInteraction? { databaseStorage.read { transaction in guard let lastVisibleInteraction = thread.lastVisibleInteraction(transaction: transaction), - let interaction = thread.firstInteraction(atOrAroundSortId: lastVisibleInteraction.sortId, - transaction: transaction) else { + let interaction = thread.firstInteraction(atOrAroundSortId: lastVisibleInteraction.sortId, + transaction: transaction) else { return nil } diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+Selection.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+Selection.swift index f4e7d44ab9..46d99b86a1 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController+Selection.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+Selection.swift @@ -147,6 +147,10 @@ extension ConversationViewController { @objc public func updateSelectionButtons() { + guard let selectionToolbar = self.selectionToolbar else { + owsFailDebug("Missing selectionToolbar.") + return + } guard let deleteButton = selectionToolbar.buttonItem(for: .delete) else { owsFailDebug("deleteButton was unexpectedly nil") return diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+UI.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+UI.swift index fe5b37f0cb..5c00ac0c27 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController+UI.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+UI.swift @@ -227,10 +227,10 @@ extension ConversationViewController { subtitleText.appendTemplatedImage(named: "timer-outline-16", font: subtitleFont) subtitleText.append(iconSpacer, attributes: attributes) subtitleText.append(NSString.formatDurationSeconds( - disappearingMessagesConfiguration.durationSeconds, - useShortFormat: true - ), - attributes: attributes) + disappearingMessagesConfiguration.durationSeconds, + useShortFormat: true + ), + attributes: attributes) } if isVerified { @@ -279,3 +279,78 @@ extension ConversationViewController { return inputToolbar } } +// MARK: - Keyboard Shortcuts + +public extension ConversationViewController { + func focusInputToolbar() { + AssertIsOnMainThread() + + guard hasViewWillAppearEverBegun else { + owsFailDebug("InputToolbar not yet ready.") + return + } + guard let inputToolbar = inputToolbar else { + owsFailDebug("Missing inputToolbar.") + return + } + + inputToolbar.clearDesiredKeyboard() + self.popKeyBoard() + } + + func openAllMedia() { + AssertIsOnMainThread() + + guard hasViewWillAppearEverBegun else { + owsFailDebug("InputToolbar not yet ready.") + return + } + + self.showConversationSettingsAndShowAllMedia() + } + + func openStickerKeyboard() { + AssertIsOnMainThread() + + guard hasViewWillAppearEverBegun else { + owsFailDebug("InputToolbar not yet ready.") + return + } + guard let inputToolbar = inputToolbar else { + owsFailDebug("Missing inputToolbar.") + return + } + + inputToolbar.showStickerKeyboard() + } + + func openAttachmentKeyboard() { + AssertIsOnMainThread() + + guard hasViewWillAppearEverBegun else { + owsFailDebug("InputToolbar not yet ready.") + return + } + guard let inputToolbar = inputToolbar else { + owsFailDebug("Missing inputToolbar.") + return + } + + inputToolbar.showAttachmentKeyboard() + } + + func openGifSearch() { + AssertIsOnMainThread() + + guard hasViewWillAppearEverBegun else { + owsFailDebug("InputToolbar not yet ready.") + return + } + guard nil != inputToolbar else { + owsFailDebug("Missing inputToolbar.") + return + } + + self.showGifPicker() + } +} diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController+UIScrollView.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController+UIScrollView.swift index eccb2cfecc..1d13bcdd8d 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController+UIScrollView.swift +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController+UIScrollView.swift @@ -51,9 +51,9 @@ extension ConversationViewController { } let scrollSpaceToBottom = (safeContentHeight + collectionView.contentInset.bottom - - (collectionView.contentOffset.y + collectionView.frame.height)) + - (collectionView.contentOffset.y + collectionView.frame.height)) let pageHeight = (collectionView.frame.height - - (collectionView.contentInset.top + collectionView.contentInset.bottom)) + - (collectionView.contentInset.top + collectionView.contentInset.bottom)) let isScrolledUpOnePage = scrollSpaceToBottom > pageHeight * 1.0 let hasLaterMessageOffscreen = (lastSortIdInLoadedWindow > lastVisibleSortId) || canLoadNewerItems @@ -165,10 +165,10 @@ extension ConversationViewController: UIScrollViewDelegate { // We need to manually schedule this timer using NSRunLoopCommonModes // or it won't fire during scrolling. let scrollUpdateTimer = Timer.weakTimer(withTimeInterval: 0.1, - target: self, - selector: #selector(scrollUpdateTimerDidFire), - userInfo: nil, - repeats: false) + target: self, + selector: #selector(scrollUpdateTimerDidFire), + userInfo: nil, + repeats: false) self.scrollUpdateTimer = scrollUpdateTimer RunLoop.main.add(scrollUpdateTimer, forMode: .common) } @@ -191,53 +191,53 @@ extension ConversationViewController: UIScrollViewDelegate { } } -// @available(iOS 2.0, *) -// optional func scrollViewDidScroll(_ scrollView: UIScrollView) // any offset changes -// -// @available(iOS 3.2, *) -// optional func scrollViewDidZoom(_ scrollView: UIScrollView) // any zoom scale changes -// -// -// // called on start of dragging (may require some time and or distance to move) -// @available(iOS 2.0, *) -// -// // called on finger up if the user dragged. velocity is in points/millisecond. targetContentOffset may be changed to adjust where the scroll view comes to rest -// @available(iOS 5.0, *) -// optional func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) -// -// // called on finger up if the user dragged. decelerate is true if it will continue moving afterwards -// @available(iOS 2.0, *) -// -// -// @available(iOS 2.0, *) -// optional func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) // called on finger up as we are moving -// -// @available(iOS 2.0, *) -// -// -// @available(iOS 2.0, *) -// -// -// @available(iOS 2.0, *) -// optional func viewForZooming(in scrollView: UIScrollView) -> UIView? // return a view that will be scaled. if delegate returns nil, nothing happens -// -// @available(iOS 3.2, *) -// optional func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) // called before the scroll view begins zooming its content -// -// @available(iOS 2.0, *) -// optional func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) // scale between minimum and maximum. called after any 'bounce' animations -// -// -// @available(iOS 2.0, *) -// -// @available(iOS 2.0, *) -// -// -// /* Also see -[UIScrollView adjustedContentInsetDidChange] -// */ -// @available(iOS 11.0, *) -// optional func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView) -// + // @available(iOS 2.0, *) + // optional func scrollViewDidScroll(_ scrollView: UIScrollView) // any offset changes + // + // @available(iOS 3.2, *) + // optional func scrollViewDidZoom(_ scrollView: UIScrollView) // any zoom scale changes + // + // + // // called on start of dragging (may require some time and or distance to move) + // @available(iOS 2.0, *) + // + // // called on finger up if the user dragged. velocity is in points/millisecond. targetContentOffset may be changed to adjust where the scroll view comes to rest + // @available(iOS 5.0, *) + // optional func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) + // + // // called on finger up if the user dragged. decelerate is true if it will continue moving afterwards + // @available(iOS 2.0, *) + // + // + // @available(iOS 2.0, *) + // optional func scrollViewWillBeginDecelerating(_ scrollView: UIScrollView) // called on finger up as we are moving + // + // @available(iOS 2.0, *) + // + // + // @available(iOS 2.0, *) + // + // + // @available(iOS 2.0, *) + // optional func viewForZooming(in scrollView: UIScrollView) -> UIView? // return a view that will be scaled. if delegate returns nil, nothing happens + // + // @available(iOS 3.2, *) + // optional func scrollViewWillBeginZooming(_ scrollView: UIScrollView, with view: UIView?) // called before the scroll view begins zooming its content + // + // @available(iOS 2.0, *) + // optional func scrollViewDidEndZooming(_ scrollView: UIScrollView, with view: UIView?, atScale scale: CGFloat) // scale between minimum and maximum. called after any 'bounce' animations + // + // + // @available(iOS 2.0, *) + // + // @available(iOS 2.0, *) + // + // + // /* Also see -[UIScrollView adjustedContentInsetDidChange] + // */ + // @available(iOS 11.0, *) + // optional func scrollViewDidChangeAdjustedContentInset(_ scrollView: UIScrollView) + // @objc public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { AssertIsOnMainThread() diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.h b/Signal/src/ViewControllers/ConversationView/ConversationViewController.h index 5609bf1661..60547830dd 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.h +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.h @@ -2,12 +2,11 @@ // Copyright (c) 2021 Open Whisper Systems. All rights reserved. // -#import "ConversationInputToolbar.h" -#import - NS_ASSUME_NONNULL_BEGIN -typedef NS_ENUM(NSUInteger, ConversationViewAction) { +@class ConversationViewController; + +typedef NS_CLOSED_ENUM(NSUInteger, ConversationViewAction) { ConversationViewActionNone, ConversationViewActionCompose, ConversationViewActionAudioCall, @@ -17,80 +16,17 @@ typedef NS_ENUM(NSUInteger, ConversationViewAction) { ConversationViewActionUpdateDraft }; -@class CVLoadCoordinator; -@class CVViewState; -@class ConversationCollectionView; -@class ConversationHeaderView; -@class ConversationSearchController; -@class ConversationStyle; -@class ConversationViewLayout; -@class MessageActionsToolbar; -@class MessageBody; -@class SDSAnyReadTransaction; -@class SDSDatabaseStorage; -@class SignalAttachment; -@class TSMessage; -@class TSThread; -@class ThreadViewModel; +void CVCReloadCollectionViewForReset(ConversationViewController *cvc); -@interface ConversationViewController : OWSViewController +typedef void (^CVCPerformBatchUpdatesBlock)(void); +typedef void (^CVCPerformBatchUpdatesCompletion)(BOOL); +typedef void (^CVCPerformBatchUpdatesFailure)(void); -@property (nonatomic, readonly) CGFloat safeContentHeight; - -@property (nonatomic, readonly) CVLoadCoordinator *loadCoordinator; - -+ (instancetype)new NS_UNAVAILABLE; -- (instancetype)init NS_UNAVAILABLE; - -- (instancetype)initWithThreadViewModel:(ThreadViewModel *)threadViewModel - action:(ConversationViewAction)action - focusMessageId:(nullable NSString *)focusMessageId NS_DESIGNATED_INITIALIZER; - -#pragma mark - Keyboard Shortcuts - -- (void)focusInputToolbar; -- (void)openAllMedia; -- (void)openStickerKeyboard; -- (void)openAttachmentKeyboard; -- (void)openGifSearch; - -@end - -#pragma mark - Internal Methods. Used in extensions - -typedef NS_CLOSED_ENUM(NSUInteger, ConversationUIMode) { - ConversationUIMode_Normal, - ConversationUIMode_Search, - ConversationUIMode_Selection -}; - -@interface ConversationViewController (Internal) - -@property (nonatomic, readonly) ConversationCollectionView *collectionView; -@property (nonatomic, readonly) BOOL isViewVisible; -@property (nonatomic, readonly) ConversationHeaderView *headerView; - -@property (nonatomic, readonly) ConversationViewLayout *layout; -@property (nonatomic, readonly) CVViewState *viewState; - -// TODO: Remove or rework method. -- (void)reloadCollectionViewForReset; - -- (void)reloadReactionsDetailSheetWithTransaction:(SDSAnyReadTransaction *)transaction; -- (void)performBatchUpdates:(void (^_Nonnull)(void))batchUpdates - completion:(void (^_Nonnull)(BOOL))completion - logFailureBlock:(void (^_Nonnull)(void))logFailureBlock - shouldAnimateUpdates:(BOOL)shouldAnimateUpdates - isLoadAdjacent:(BOOL)isLoadAdjacent; - -#pragma mark - Search - -@property (nonatomic, readonly) ConversationSearchController *searchController; - -#pragma mark - Selection - -@property (nonatomic, readonly) MessageActionsToolbar *selectionToolbar; - -@end +void CVCPerformBatchUpdates(ConversationViewController *cvc, + CVCPerformBatchUpdatesBlock batchUpdates, + CVCPerformBatchUpdatesCompletion completion, + CVCPerformBatchUpdatesFailure logFailureBlock, + BOOL shouldAnimateUpdates, + BOOL isLoadAdjacent); NS_ASSUME_NONNULL_END diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m index f2f8a1fd35..628ab6399e 100644 --- a/Signal/src/ViewControllers/ConversationView/ConversationViewController.m +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.m @@ -3,881 +3,51 @@ // #import "ConversationViewController.h" -#import "AppDelegate.h" -#import "BlockListUIUtils.h" -#import "BlockListViewController.h" -#import "ContactsViewHelper.h" -#import "ConversationCollectionView.h" -#import "ConversationInputToolbar.h" -#import "ConversationScrollButton.h" -#import "DateUtil.h" -#import "DebugUITableViewController.h" -#import "FingerprintViewController.h" -#import "OWSDisappearingMessagesJob.h" -#import "OWSMath.h" #import "Signal-Swift.h" -#import "TSAttachmentPointer.h" -#import "TSCall.h" -#import "TSContactThread.h" -#import "TSErrorMessage.h" -#import "TSGroupThread.h" -#import "TSIncomingMessage.h" -#import "TSInfoMessage.h" -#import "TSInvalidIdentityKeyErrorMessage.h" -#import "UIFont+OWS.h" -#import "UIViewController+Permissions.h" -#import "ViewControllerUtils.h" -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import -#import - -@import SafariServices; NS_ASSUME_NONNULL_BEGIN -typedef enum : NSUInteger { - kMediaTypePicture, - kMediaTypeVideo, -} kMediaTypes; - -#pragma mark - - -// TODO: Audit protocol conformance, here and in header. -@interface ConversationViewController () - -@property (nonatomic, readonly) ConversationCollectionView *collectionView; -@property (nonatomic, readonly) ConversationViewLayout *layout; - -@property (nonatomic, readonly) CVViewState *viewState; - -@property (nonatomic) ConversationHeaderView *headerView; - -@property (nonatomic, nullable) NSNumber *viewHorizonTimestamp; - -@property (nonatomic, readonly) ConversationSearchController *searchController; - -@property (nonatomic) MessageActionsToolbar *selectionToolbar; - -@property (nonatomic) DebouncedEvent *otherUsersProfileDidChangeEvent; - -@end - -#pragma mark - - -@implementation ConversationViewController - -- (instancetype)initWithThreadViewModel:(ThreadViewModel *)threadViewModel - action:(ConversationViewAction)action - focusMessageId:(nullable NSString *)focusMessageId +void CVCReloadCollectionViewForReset(ConversationViewController *cvc) { - self = [super init]; - - OWSLogVerbose(@""); - - ConversationStyle *conversationStyle = - [ConversationViewController buildInitialConversationStyleWithThreadViewModel:threadViewModel]; - _viewState = [[CVViewState alloc] initWithThreadViewModel:threadViewModel conversationStyle:conversationStyle]; - self.viewState.delegate = self; - -#ifdef TESTABLE_BUILD - [self.initialLoadBenchSteps step:@"Init CVC"]; -#endif - - self.inputAccessoryPlaceholder.delegate = self; - - // If we're not scrolling to a specific message AND we don't have - // any unread messages, try to focus on the last visible interaction. - if (focusMessageId == nil && !threadViewModel.hasUnreadMessages) { - focusMessageId = [self lastVisibleInteractionIdWithSneakyTransaction:threadViewModel]; - } - - [self.contactsViewHelper addObserver:self]; - self.contactShareViewHelper.delegate = self; - - self.actionOnOpen = action; - - [self recordInitialScrollState:focusMessageId]; - - _loadCoordinator = [self buildLoadCoordinatorWithConversationStyle:conversationStyle - focusMessageIdOnOpen:focusMessageId]; - - _searchController = [[ConversationSearchController alloc] initWithThread:threadViewModel.threadRecord]; - _searchController.delegate = self; - - // because the search bar view is hosted in the navigation bar, it's not in the CVC's responder - // chain, and thus won't inherit our inputAccessoryView, so we manually set it here. - OWSAssertDebug(self.inputAccessoryPlaceholder != nil); - _searchController.uiSearchController.searchBar.inputAccessoryView = self.inputAccessoryPlaceholder; - - [self startReloadTimer]; - - __weak ConversationViewController *weakSelf = self; - _otherUsersProfileDidChangeEvent = - [[DebouncedEvent alloc] initWithMaxFrequencySeconds:1.0 - onQueue:dispatch_get_main_queue() - notifyBlock:^{ - // Reload all cells if this is a group conversation, - // since we may need to update the sender names on the messages. - [weakSelf.loadCoordinator - enqueueReloadWithCanReuseInteractionModels:YES - canReuseComponentStates:NO]; - }]; - return self; -} - -#pragma mark - - -- (void)addNotificationListeners -{ - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(blockListDidChange:) - name:kNSNotificationNameBlockListDidChange - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(windowManagerCallDidChange:) - name:OWSWindowManagerCallDidChangeNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(identityStateDidChange:) - name:kNSNotificationNameIdentityStateDidChange - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(didChangePreferredContentSize:) - name:UIContentSizeCategoryDidChangeNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationWillEnterForeground:) - name:OWSApplicationWillEnterForegroundNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationDidEnterBackground:) - name:OWSApplicationDidEnterBackgroundNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationWillResignActive:) - name:OWSApplicationWillResignActiveNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(applicationDidBecomeActive:) - name:OWSApplicationDidBecomeActiveNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(cancelReadTimer) - name:OWSApplicationDidEnterBackgroundNotification - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(otherUsersProfileDidChange:) - name:kNSNotificationNameOtherUsersProfileDidChange - object:nil]; - [[NSNotificationCenter defaultCenter] addObserver:self - selector:@selector(profileWhitelistDidChange:) - name:kNSNotificationNameProfileWhitelistDidChange - object:nil]; -} - -- (void)otherUsersProfileDidChange:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - SignalServiceAddress *address = notification.userInfo[kNSNotificationKey_ProfileAddress]; - OWSAssertDebug(address.isValid); - if (address.isValid && [self.thread.recipientAddresses containsObject:address]) { - if ([self.thread isKindOfClass:[TSContactThread class]]) { - // update title with profile name - [self updateNavigationTitle]; - } - - // Reload all cells if this is a group conversation, - // since we may need to update the sender names on the messages. - // Use a DebounceEvent to de-bounce. - if (self.isGroupConversation) { - [self.otherUsersProfileDidChangeEvent requestNotify]; - } - } -} - -- (void)profileWhitelistDidChange:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - // If profile whitelist just changed, we may want to hide a profile whitelist offer. - SignalServiceAddress *_Nullable address = notification.userInfo[kNSNotificationKey_ProfileAddress]; - NSData *_Nullable groupId = notification.userInfo[kNSNotificationKey_ProfileGroupId]; - if (address.isValid && [self.thread.recipientAddresses containsObject:address]) { - [self ensureBannerState]; - [self showMessageRequestDialogIfRequired]; - } else if (groupId.length > 0 && self.thread.isGroupThread) { - TSGroupThread *groupThread = (TSGroupThread *)self.thread; - if ([groupThread.groupModel.groupId isEqualToData:groupId]) { - [self ensureBannerState]; - [self showMessageRequestDialogIfRequired]; - } - } -} - -- (void)blockListDidChange:(id)notification -{ - OWSAssertIsOnMainThread(); - - [self ensureBannerState]; -} - -- (void)identityStateDidChange:(NSNotification *)notification -{ - OWSAssertIsOnMainThread(); - - [self updateNavigationBarSubtitleLabel]; - [self ensureBannerState]; -} - -- (void)themeDidChange -{ - [super themeDidChange]; - - [self updateThemeIfNecessary]; -} - -- (void)updateV2GroupIfNecessary -{ - if (!self.thread.isGroupV2Thread) { - return; - } - TSGroupThread *groupThread = (TSGroupThread *)self.thread; - // 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. - [self.groupV2UpdatesObjc tryToRefreshV2GroupUpToCurrentRevisionAfterMessageProcessingWithThrottling:groupThread]; -} - -- (void)dealloc -{ - [self.reloadTimer invalidate]; - [self.scrollUpdateTimer invalidate]; -} - -- (void)viewDidLoad -{ - // We won't have a navigation controller if we're presented in a preview - OWSAssertDebug(self.navigationController != nil || self.isInPreviewPlatter); - -#ifdef TESTABLE_BUILD - [self.initialLoadBenchSteps step:@"viewDidLoad.1"]; -#endif - - [super viewDidLoad]; - - [self createContents]; - [self createConversationScrollButtons]; - [self createHeaderViews]; - [self addNotificationListeners]; - [self.loadCoordinator viewDidLoad]; - -#ifdef TESTABLE_BUILD - [self.initialLoadBenchSteps step:@"viewDidLoad.2"]; -#endif -} - -- (void)createContents -{ - OWSAssertDebug(self.conversationStyle); - - _layout = [[ConversationViewLayout alloc] initWithConversationStyle:self.conversationStyle]; - self.layout.delegate = self.loadCoordinator; - - // We use the root view bounds as the initial frame for the collection - // view so that its contents can be laid out immediately. - // - // TODO: To avoid relayout, it'd be better to take into account safeAreaInsets, - // but they're not yet set when this method is called. - _collectionView = [[ConversationCollectionView alloc] initWithFrame:self.view.bounds - collectionViewLayout:self.layout]; - self.collectionView.layoutDelegate = self; - self.collectionView.delegate = self.loadCoordinator; - self.collectionView.dataSource = self.loadCoordinator; - self.collectionView.showsVerticalScrollIndicator = YES; - self.collectionView.showsHorizontalScrollIndicator = NO; - self.collectionView.keyboardDismissMode = UIScrollViewKeyboardDismissModeInteractive; - self.collectionView.allowsMultipleSelection = YES; - self.collectionView.backgroundColor = UIColor.clearColor; - - // To minimize time to initial apearance, we initially disable prefetching, but then - // re-enable it once the view has appeared. - self.collectionView.prefetchingEnabled = NO; - - [self.view addSubview:self.collectionView]; - [self.collectionView autoPinEdgeToSuperviewEdge:ALEdgeTop]; - [self.collectionView autoPinEdgeToSuperviewEdge:ALEdgeBottom]; - [self.collectionView autoPinEdgeToSuperviewSafeArea:ALEdgeLeading]; - [self.collectionView autoPinEdgeToSuperviewSafeArea:ALEdgeTrailing]; - - [self.collectionView applyScrollViewInsetsFix]; - SET_SUBVIEW_ACCESSIBILITY_IDENTIFIER(self, _collectionView); - - [self registerReuseIdentifiers]; - - // The view controller will only automatically adjust content insets for a - // scrollView at index 0, so we need the collection view to remain subview index 0. - // But the background views should appear visually behind the collection view. - CVBackgroundContainer *backgroundContainer = self.backgroundContainer; - backgroundContainer.delegate = self; - [self.view addSubview:backgroundContainer]; - [backgroundContainer autoPinEdgesToSuperviewEdges]; - [self setupWallpaper]; - - [self.view addSubview:self.bottomBar]; - self.bottomBarBottomConstraint = [self.bottomBar autoPinEdgeToSuperviewEdge:ALEdgeBottom]; - [self.bottomBar autoPinWidthToSuperview]; - - _selectionToolbar = [self buildSelectionToolbar]; - - // This should kick off the first load. - OWSAssertDebug(!self.hasRenderState); - OWSAssertDebug(!self.loadCoordinator.hasLoadInFlight); - [self updateConversationStyle]; -} - -- (BOOL)canBecomeFirstResponder -{ - return YES; -} - -- (BOOL)becomeFirstResponder -{ - BOOL result = [super becomeFirstResponder]; - - if (!self.hasViewWillAppearEverBegun) { - OWSFailDebug(@"InputToolbar not yet ready."); - return result; - } - if (self.inputToolbar == nil) { - OWSFailDebug(@"Missing inputToolbar."); - return result; - } - - // If we become the first responder, it means that the - // input toolbar is not the first responder. As such, - // we should clear out the desired keyboard since an - // interactive dismissal may have just occured and we - // need to update the UI to reflect that fact. We don't - // actually ever want to be the first responder, so resign - // immediately. We just want to know when the responder - // state of our children changed and that information is - // conveniently bubbled up the responder chain. - if (result) { - [self resignFirstResponder]; - [self.inputToolbar clearDesiredKeyboard]; - } - - return result; -} - -- (nullable UIView *)inputAccessoryView -{ - return self.inputAccessoryPlaceholder; -} - -- (nullable NSString *)textInputContextIdentifier -{ - return self.thread.uniqueId; -} - -- (void)applicationWillEnterForeground:(NSNotification *)notification -{ - [self startReadTimer]; - [self updateCellsVisible]; -} - -- (void)applicationDidEnterBackground:(NSNotification *)notification -{ - [self updateCellsVisible]; - [self.mediaCache removeAllObjects]; -} - -- (void)applicationWillResignActive:(NSNotification *)notification -{ - [self finishRecordingVoiceMessageAndSendImmediately:NO]; - self.isUserScrolling = NO; - self.isWaitingForDeceleration = NO; - [self saveDraft]; - [self markVisibleMessagesAsRead]; - [self.mediaCache removeAllObjects]; - [self cancelReadTimer]; - [self dismissPresentedViewControllerIfNecessary]; - [self saveLastVisibleSortIdAndOnScreenPercentage]; - - [self dismissKeyBoard]; -} - -- (void)applicationDidBecomeActive:(NSNotification *)notification -{ - [self startReadTimer]; -} - -- (void)dismissPresentedViewControllerIfNecessary -{ - UIViewController *_Nullable presentedViewController = self.presentedViewController; - if (!presentedViewController) { - OWSLogDebug(@"presentedViewController was nil"); - return; - } - - if ([presentedViewController isKindOfClass:[ActionSheetController class]] || - [presentedViewController isKindOfClass:[UIAlertController class]]) { - OWSLogDebug(@"dismissing presentedViewController: %@", presentedViewController); - [self dismissViewControllerAnimated:NO completion:nil]; - return; - } -} - -- (void)viewWillAppear:(BOOL)animated -{ - [self viewWillAppearDidBegin]; - -#ifdef TESTABLE_BUILD - [self.initialLoadBenchSteps step:@"viewWillAppear.1"]; -#endif - - OWSLogDebug(@"viewWillAppear"); - - [self ensureBannerState]; - - [super viewWillAppear:animated]; - - if (self.inputToolbar == nil) { - // This will create the input toolbar for the first time. - // It's important that we do this at the "last moment" to - // avoid expensive work that delays CVC presentation. - [self applyTheme]; - OWSAssertDebug(self.inputToolbar != nil); - - [self createGestureRecognizers]; - } - - self.isViewVisible = YES; - [self viewWillAppearForLoad]; - - // We should have already requested contact access at this point, so this should be a no-op - // unless it ever becomes possible to load this VC without going via the ConversationListViewController. - [self.contactsManagerImpl requestSystemContactsOnce]; - - [self updateBarButtonItems]; - [self updateNavigationTitle]; - - // One-time work performed the first time we enter the view. - if (!self.viewHasEverAppeared) { - [BenchManager - completeEventWithEventId:[NSString stringWithFormat:@"presenting-conversation-%@", self.thread.uniqueId]]; - } - [self ensureBottomViewType]; - [self updateInputToolbarLayout]; - [self refreshCallState]; - - [self showMessageRequestDialogIfRequired]; - [self viewWillAppearDidComplete]; -#ifdef TESTABLE_BUILD - [self.initialLoadBenchSteps step:@"viewWillAppear.2"]; -#endif -} - -- (void)viewDidAppear:(BOOL)animated -{ - [self viewDidAppearDidBegin]; - -#ifdef TESTABLE_BUILD - [self.initialLoadBenchSteps step:@"viewDidAppear.1"]; -#endif - OWSLogDebug(@"viewDidAppear"); - - [super viewDidAppear:animated]; - - // We don't present incoming message notifications for the presented - // conversation. But there's a narrow window *while* the conversationVC - // is being presented where a message notification for the not-quite-yet - // presented conversation can be shown. If that happens, dismiss it as soon - // as we enter the conversation. - [self.notificationPresenter cancelNotificationsWithThreadId:self.thread.uniqueId]; - - // recover status bar when returning from PhotoPicker, which is dark (uses light status bar) - [self setNeedsStatusBarAppearanceUpdate]; - - [self markVisibleMessagesAsRead]; - [self startReadTimer]; - [self updateNavigationBarSubtitleLabel]; - [self autoLoadMoreIfNecessary]; - if (!SSKDebugFlags.reduceLogChatter) { - [self.bulkProfileFetch fetchProfilesWithThread:self.thread]; - [self updateV2GroupIfNecessary]; - } - - if (!self.viewHasEverAppeared) { - // To minimize time to initial apearance, we initially disable prefetching, but then - // re-enable it once the view has appeared. - self.collectionView.prefetchingEnabled = YES; - } - - self.isViewCompletelyAppeared = YES; - self.shouldAnimateKeyboardChanges = YES; - - switch (self.actionOnOpen) { - case ConversationViewActionNone: - break; - case ConversationViewActionCompose: - // Don't pop the keyboard if we have a pending message request, since - // the user can't currently send a message until acting on this - if (!self.requestView) { - [self popKeyBoard]; - } - break; - case ConversationViewActionAudioCall: - [self startIndividualAudioCall]; - break; - case ConversationViewActionVideoCall: - [self startIndividualVideoCall]; - break; - case ConversationViewActionGroupCallLobby: - [self showGroupLobbyOrActiveCall]; - break; - case ConversationViewActionNewGroupActionSheet: { - dispatch_async(dispatch_get_main_queue(), ^{ [self showGroupLinkPromotionActionSheet]; }); - break; - } - case ConversationViewActionUpdateDraft: - // Do nothing; input toolbar was just created with the latest draft. - break; - } - - [self scrollToInitialPositionAnimated:NO]; - if (self.viewState.hasAppliedFirstLoad) { - [self clearInitialScrollState]; - } - - // Clear the "on open" state after the view has been presented. - self.actionOnOpen = ConversationViewActionNone; - - [self updateInputToolbarLayout]; - [self configureScrollDownButtons]; - [self.inputToolbar viewDidAppear]; - - if (!self.viewState.hasTriedToMigrateGroup) { - self.viewState.hasTriedToMigrateGroup = YES; - - if (!SSKDebugFlags.reduceLogChatter) { - [GroupsV2Migration autoMigrateThreadIfNecessary:self.thread]; - } - } - - [self viewDidAppearDidComplete]; -#ifdef TESTABLE_BUILD - [self.initialLoadBenchSteps step:@"viewDidAppear.2"]; -#endif -} - -// `viewWillDisappear` is called whenever the view *starts* to disappear, -// but, as is the case with the "pan left for message details view" gesture, -// this can be canceled. As such, we shouldn't tear down anything expensive -// until `viewDidDisappear`. -- (void)viewWillDisappear:(BOOL)animated -{ - OWSLogDebug(@""); - - [super viewWillDisappear:animated]; - - self.isViewCompletelyAppeared = NO; - - [self dismissMessageActionsWithAnimated:NO]; - [self dismissReactionsDetailSheetAnimated:NO]; - [self saveLastVisibleSortIdAndOnScreenPercentage]; -} - -- (void)viewDidDisappear:(BOOL)animated -{ - OWSLogDebug(@""); - - [super viewDidDisappear:animated]; - self.userHasScrolled = NO; - self.isViewVisible = NO; - self.shouldAnimateKeyboardChanges = NO; - - [self.cvAudioPlayer stopAll]; - - [self cancelReadTimer]; - [self saveDraft]; - [self markVisibleMessagesAsRead]; - [self finishRecordingVoiceMessageAndSendImmediately:NO]; - [self.mediaCache removeAllObjects]; - [self.inputToolbar clearDesiredKeyboard]; - - self.isUserScrolling = NO; - self.isWaitingForDeceleration = NO; - - [self.scrollingAnimationCompletionTimer invalidate]; - self.scrollingAnimationCompletionTimer = nil; -} - -- (void)viewDidLayoutSubviews -{ - [super viewDidLayoutSubviews]; - - if (!self.hasViewWillAppearEverBegun) { - return; - } - if (self.inputToolbar == nil) { - OWSFailDebug(@"Missing inputToolbar."); - return; - } - - // We resize the inputToolbar whenever it's text is modified, including when setting saved draft-text. - // However it's possible this draft-text is set before the inputToolbar (an inputAccessoryView) is mounted - // in the view hierarchy. Since it's not in the view hierarchy, it hasn't been laid out and has no width, - // which is used to determine height. - // So here we unsure the proper height once we know everything's been layed out. - [self.inputToolbar ensureTextViewHeight]; - - [self positionGroupCallTooltip]; -} - -- (BOOL)shouldAutorotate -{ - // Don't allow orientation changes while recording voice messages. - if (self.viewState.currentVoiceMessageModel.isRecording) { - return NO; - } - - return [super shouldAutorotate]; -} - -#pragma mark - Initializers - -- (void)windowManagerCallDidChange:(NSNotification *)notification -{ - [self updateBarButtonItems]; -} - -#pragma mark - Dynamic Text - -/** - Called whenever the user manually changes the dynamic type options inside Settings. - - @param notification NSNotification with the dynamic type change information. - */ -- (void)didChangePreferredContentSize:(NSNotification *)notification -{ - OWSLogInfo(@"didChangePreferredContentSize"); - - [self resetForSizeOrOrientationChange]; - - if (!self.hasViewWillAppearEverBegun) { - return; - } - if (self.inputToolbar == nil) { - OWSFailDebug(@"Missing inputToolbar."); - return; - } - - [self.inputToolbar updateFontSizes]; -} - -#pragma mark - Bubble User Actions - -- (void)reloadReactionsDetailSheetWithTransaction:(SDSAnyReadTransaction *)transaction -{ - if (!self.reactionsDetailSheet) { - return; - } - - NSString *messageId = self.reactionsDetailSheet.messageId; - - NSIndexPath *_Nullable indexPath = [self indexPathForInteractionUniqueId:messageId]; - if (indexPath == nil) { - // The message no longer exists, dismiss the sheet. - [self dismissReactionsDetailSheetAnimated:YES]; - } - - CVRenderItem *_Nullable renderItem = [self renderItemForIndex:indexPath.row]; - - InteractionReactionState *_Nullable reactionState = renderItem.reactionState; - if (!reactionState.hasReactions) { - // There are no longer reactions on this message, dismiss the sheet. - [self dismissReactionsDetailSheetAnimated:YES]; - return; - } - - // Update the detail sheet with the latest reaction - // state, in case the reactions have changed. - [self.reactionsDetailSheet setReactionState:reactionState transaction:transaction]; -} - -- (void)dismissReactionsDetailSheetAnimated:(BOOL)animated -{ - if (!self.reactionsDetailSheet) { - return; - } - - [self.reactionsDetailSheet dismissViewControllerAnimated:animated completion:^{ self.reactionsDetailSheet = nil; }]; -} - -- (void)presentAddThreadToProfileWhitelistWithSuccess:(void (^)(void))successHandler -{ - [[OWSProfileManager shared] presentAddThreadToProfileWhitelist:self.thread - fromViewController:self - success:successHandler]; -} - -#pragma mark - ContactsViewHelperObserver - -- (void)contactsViewHelperDidUpdateContacts -{ - [self updateNavigationTitle]; - [self.loadCoordinator enqueueReloadWithCanReuseInteractionModels:YES canReuseComponentStates:NO]; -} - -- (void)updateThemeIfNecessary -{ - OWSAssertIsOnMainThread(); - - if (self.isDarkThemeEnabled == Theme.isDarkThemeEnabled) { - return; - } - self.isDarkThemeEnabled = Theme.isDarkThemeEnabled; - - [self applyTheme]; - - [self updateConversationStyle]; -} - -- (void)applyTheme -{ - OWSAssertIsOnMainThread(); - - [super applyTheme]; - - if (!self.hasViewWillAppearEverBegun) { - OWSFailDebug(@"InputToolbar not yet ready."); - return; - } - - // make sure toolbar extends below iPhoneX home button. - self.view.backgroundColor = Theme.toolbarBackgroundColor; - - [self updateWallpaperView]; - - [self updateNavigationTitle]; - [self updateNavigationBarSubtitleLabel]; - - [self updateInputToolbar]; - [self updateInputToolbarLayout]; - [self updateBarButtonItems]; - [self ensureBannerState]; - - // Re-styling the message actions is tricky, - // since this happens rarely just dismiss - [self dismissMessageActionsWithAnimated:NO]; - [self dismissReactionsDetailSheetAnimated:NO]; -} - -- (void)reloadCollectionViewForReset -{ - if (!self.hasAppearedAndHasAppliedFirstLoad) { - return; - } @try { - [self.layout willReloadData]; - [self.collectionView reloadData]; - [self.layout invalidateLayout]; - [self.layout didReloadData]; + [cvc.layout willReloadData]; + [cvc.collectionView reloadData]; + [cvc.layout invalidateLayout]; + [cvc.layout didReloadData]; } @catch (NSException *exception) { - OWSLogWarn(@"currentRenderStateDebugDescription: %@", self.currentRenderStateDebugDescription); - OWSFailDebug(@"exception: %@ of type: %@ with reason: %@, user info: %@.", - exception.description, - exception.name, - exception.reason, - exception.userInfo); + OWSLogWarn(@"currentRenderStateDebugDescription: %@", cvc.currentRenderStateDebugDescription); + OWSCFailDebug(@"exception: %@ of type: %@ with reason: %@, user info: %@.", + exception.description, + exception.name, + exception.reason, + exception.userInfo); @throw exception; } } -#pragma mark - Database Observation - -- (BOOL)isViewVisible -{ - return self.viewState.isViewVisible; -} - -- (void)setIsViewVisible:(BOOL)isViewVisible -{ - self.viewState.isViewVisible = isViewVisible; - - [self updateCellsVisible]; -} - -- (void)updateCellsVisible -{ - BOOL isAppInBackground = CurrentAppContext().isInBackground; - BOOL isCellVisible = self.isViewVisible && !isAppInBackground; - for (CVCell *cell in self.collectionView.visibleCells) { - cell.isCellVisible = isCellVisible; - } - [self updateScrollingContent]; -} - -#pragma mark - CollectionView updates - -- (void)performBatchUpdates:(void (^_Nonnull)(void))batchUpdates - completion:(void (^_Nonnull)(BOOL))completion - logFailureBlock:(void (^_Nonnull)(void))logFailureBlock - shouldAnimateUpdates:(BOOL)shouldAnimateUpdates - isLoadAdjacent:(BOOL)isLoadAdjacent +void CVCPerformBatchUpdates(ConversationViewController *cvc, + CVCPerformBatchUpdatesBlock batchUpdates, + CVCPerformBatchUpdatesCompletion completion, + CVCPerformBatchUpdatesFailure logFailureBlock, + BOOL shouldAnimateUpdates, + BOOL isLoadAdjacent) { @try { void (^updateBlock)(void) = ^{ - ConversationViewLayout *layout = self.layout; - [layout willPerformBatchUpdatesWithAnimated:shouldAnimateUpdates isLoadAdjacent:isLoadAdjacent]; - [self.collectionView performBatchUpdates:batchUpdates - completion:^(BOOL finished) { - [layout didCompleteBatchUpdates]; - - completion(finished); - }]; + ConversationViewLayout *layout = cvc.layout; + [layout willPerformBatchUpdatesWithAnimated:shouldAnimateUpdates + isLoadAdjacent:isLoadAdjacent]; + [cvc.collectionView performBatchUpdates:batchUpdates + completion:^(BOOL finished) { + [layout didCompleteBatchUpdates]; + + completion(finished); + }]; [layout didPerformBatchUpdatesWithAnimated:shouldAnimateUpdates]; - + [BenchManager completeEventWithEventId:@"message-send"]; }; - + if (shouldAnimateUpdates) { updateBlock(); } else { @@ -897,120 +67,16 @@ typedef enum : NSUInteger { [UIView animateWithDuration:0.0 animations:updateBlock]; } } @catch (NSException *exception) { - OWSFailDebug(@"exception: %@ of type: %@ with reason: %@, user info: %@.", - exception.description, - exception.name, - exception.reason, - exception.userInfo); - + OWSCFailDebug(@"exception: %@ of type: %@ with reason: %@, user info: %@.", + exception.description, + exception.name, + exception.reason, + exception.userInfo); + logFailureBlock(); - + @throw exception; } } -#pragma mark - Orientation - -- (void)viewWillTransitionToSize:(CGSize)size - withTransitionCoordinator:(id)coordinator -{ - OWSAssertIsOnMainThread(); - - [super viewWillTransitionToSize:size withTransitionCoordinator:coordinator]; - - [self dismissMessageActionsWithAnimated:NO]; - [self dismissReactionsDetailSheetAnimated:NO]; - - self.scrollContinuity = ScrollContinuityBottom; - - if (!self.hasAppearedAndHasAppliedFirstLoad) { - return; - } - - [self setScrollActionForSizeTransition]; - - __weak ConversationViewController *weakSelf = self; - [coordinator - animateAlongsideTransition:^(id context) {} - completion:^(id context) { - [weakSelf clearScrollActionForSizeTransition]; - }]; -} - -- (void)traitCollectionDidChange:(nullable UITraitCollection *)previousTraitCollection -{ - [super traitCollectionDidChange:previousTraitCollection]; - [self updateBarButtonItems]; - [self updateNavigationBarSubtitleLabel]; - - // Invoking -ensureBannerState synchronously can lead to reenterant updates to the - // trait collection while building the banners. This can lead us to blow out the stack - // on unrelated trait collection changes (e.g. rotating to landscape). - // We workaround this by just asyncing any banner updates to break the synchronous - // dependency chain. - dispatch_async(dispatch_get_main_queue(), ^{ [self ensureBannerState]; }); -} - -- (void)viewSafeAreaInsetsDidChange -{ - [super viewSafeAreaInsetsDidChange]; - - [self updateContentInsetsAnimated:NO]; - [self updateInputToolbarLayout]; - [self viewSafeAreaInsetsDidChangeForLoad]; - [self updateConversationStyle]; -} - -#pragma mark - Keyboard Shortcuts - -- (void)focusInputToolbar -{ - OWSAssertIsOnMainThread(); - - if (!self.hasViewWillAppearEverBegun) { - OWSFailDebug(@"InputToolbar not yet ready."); - return; - } - if (self.inputToolbar == nil) { - OWSFailDebug(@"Missing inputToolbar."); - return; - } - - [self.inputToolbar clearDesiredKeyboard]; - [self popKeyBoard]; -} - -- (void)openAllMedia -{ - OWSAssertIsOnMainThread(); - - [self showConversationSettingsAndShowAllMedia]; -} - -- (void)openStickerKeyboard -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(self.inputToolbar != nil); - - - [self.inputToolbar showStickerKeyboard]; -} - -- (void)openAttachmentKeyboard -{ - OWSAssertIsOnMainThread(); - OWSAssertDebug(self.inputToolbar != nil); - - [self.inputToolbar showAttachmentKeyboard]; -} - -- (void)openGifSearch -{ - OWSAssertIsOnMainThread(); - - [self showGifPicker]; -} - -@end - NS_ASSUME_NONNULL_END diff --git a/Signal/src/ViewControllers/ConversationView/ConversationViewController.swift b/Signal/src/ViewControllers/ConversationView/ConversationViewController.swift new file mode 100644 index 0000000000..5976af1d9f --- /dev/null +++ b/Signal/src/ViewControllers/ConversationView/ConversationViewController.swift @@ -0,0 +1,616 @@ +// +// Copyright (c) 2021 Open Whisper Systems. All rights reserved. +// + +import Foundation + +@objc +public enum ConversationUIMode: UInt { + case normal + case search + case selection +} + +// MARK: - + +@objc +public class ConversationViewController: OWSViewController { + + public let viewState: CVViewState + public let loadCoordinator: CVLoadCoordinator + @objc + public let layout: ConversationViewLayout + @objc + public let collectionView: ConversationCollectionView + public let searchController: ConversationSearchController + + var selectionToolbar: MessageActionsToolbar? + + var otherUsersProfileDidChangeEvent: DebouncedEvent? + + // MARK: - + + @objc + public required init(threadViewModel: ThreadViewModel, + action: ConversationViewAction = .none, + focusMessageId: String? = nil) { + AssertIsOnMainThread() + + Logger.verbose("") + + let conversationStyle = ConversationViewController.buildInitialConversationStyle(threadViewModel: threadViewModel) + self.viewState = CVViewState(threadViewModel: threadViewModel, + conversationStyle: conversationStyle) + self.loadCoordinator = CVLoadCoordinator(viewState: viewState) + self.layout = ConversationViewLayout(conversationStyle: conversationStyle) + self.collectionView = ConversationCollectionView(frame: .zero, + collectionViewLayout: self.layout) + + self.searchController = ConversationSearchController(thread: threadViewModel.threadRecord) + + super.init() + + self.viewState.delegate = self + + #if TESTABLE_BUILD + self.initialLoadBenchSteps.step("Init CVC") + #endif + + self.inputAccessoryPlaceholder.delegate = self + + // If we're not scrolling to a specific message AND we don't have + // any unread messages, try to focus on the last visible interaction. + var focusMessageId = focusMessageId + if focusMessageId == nil, !threadViewModel.hasUnreadMessages { + focusMessageId = self.lastVisibleInteractionIdWithSneakyTransaction(threadViewModel) + } + + contactsViewHelper.addObserver(self) + contactShareViewHelper.delegate = self + + self.actionOnOpen = action + + self.recordInitialScrollState(focusMessageId) + + loadCoordinator.configure(delegate: self, + componentDelegate: self, + focusMessageIdOnOpen: focusMessageId) + + searchController.delegate = self + + // because the search bar view is hosted in the navigation bar, it's not in the CVC's responder + // chain, and thus won't inherit our inputAccessoryView, so we manually set it here. + searchController.uiSearchController.searchBar.inputAccessoryView = self.inputAccessoryPlaceholder + + self.otherUsersProfileDidChangeEvent = DebouncedEvent(maxFrequencySeconds: 1.0, + onQueue: DispatchQueue.main) { [weak self] in + // Reload all cells if this is a group conversation, + // since we may need to update the sender names on the messages. + self?.loadCoordinator.enqueueReload(canReuseInteractionModels: true, + canReuseComponentStates: false) + } + } + + deinit { + reloadTimer?.invalidate() + scrollUpdateTimer?.invalidate() + } + + // MARK: - View Lifecycle + + public override func viewDidLoad() { + AssertIsOnMainThread() + + // We won't have a navigation controller if we're presented in a preview + owsAssertDebug(self.navigationController != nil || self.isInPreviewPlatter) + + #if TESTABLE_BUILD + initialLoadBenchSteps.step("viewDidLoad.1") + #endif + + super.viewDidLoad() + + createContents() + createConversationScrollButtons() + createHeaderViews() + addNotificationListeners() + loadCoordinator.viewDidLoad() + + self.startReloadTimer() + + #if TESTABLE_BUILD + initialLoadBenchSteps.step("viewDidLoad.2") + #endif + } + + private func createContents() { + AssertIsOnMainThread() + + self.layout.delegate = self.loadCoordinator + + // We use the root view bounds as the initial frame for the collection + // view so that its contents can be laid out immediately. + // + // TODO: To avoid relayout, it'd be better to take into account safeAreaInsets, + // but they're not yet set when this method is called. + self.collectionView.frame = view.bounds + self.collectionView.layoutDelegate = self + self.collectionView.delegate = self.loadCoordinator + self.collectionView.dataSource = self.loadCoordinator + self.collectionView.showsVerticalScrollIndicator = true + self.collectionView.showsHorizontalScrollIndicator = false + self.collectionView.keyboardDismissMode = .interactive + self.collectionView.allowsMultipleSelection = true + self.collectionView.backgroundColor = .clear + + // To minimize time to initial apearance, we initially disable prefetching, but then + // re-enable it once the view has appeared. + self.collectionView.isPrefetchingEnabled = false + + self.view.addSubview(self.collectionView) + self.collectionView.autoPinEdge(toSuperviewEdge: .top) + self.collectionView.autoPinEdge(toSuperviewEdge: .bottom) + self.collectionView.autoPinEdge(toSuperviewSafeArea: .leading) + self.collectionView.autoPinEdge(toSuperviewSafeArea: .trailing) + + // TODO: Do we still need to do this? + self.collectionView.applyInsetsFix() + self.collectionView.accessibilityIdentifier = "collectionView" + + self.registerReuseIdentifiers() + + // The view controller will only automatically adjust content insets for a + // scrollView at index 0, so we need the collection view to remain subview index 0. + // But the background views should appear visually behind the collection view. + let backgroundContainer = self.backgroundContainer + backgroundContainer.delegate = self + self.view.addSubview(backgroundContainer) + backgroundContainer.autoPinEdgesToSuperviewEdges() + setupWallpaper() + + self.view.addSubview(bottomBar) + self.bottomBarBottomConstraint = bottomBar.autoPinEdge(toSuperviewEdge: .bottom) + bottomBar.autoPinWidthToSuperview() + + self.selectionToolbar = self.buildSelectionToolbar() + + // This should kick off the first load. + owsAssertDebug(!self.hasRenderState) + owsAssertDebug(!self.loadCoordinator.hasLoadInFlight) + self.updateConversationStyle() + } + + public override var canBecomeFirstResponder: Bool { + return true + } + + public override func becomeFirstResponder() -> Bool { + let result = super.becomeFirstResponder() + + guard hasViewWillAppearEverBegun else { + return result + } + guard let inputToolbar = inputToolbar else { + owsFailDebug("Missing inputToolbar.") + return result + } + + // If we become the first responder, it means that the + // input toolbar is not the first responder. As such, + // we should clear out the desired keyboard since an + // interactive dismissal may have just occured and we + // need to update the UI to reflect that fact. We don't + // actually ever want to be the first responder, so resign + // immediately. We just want to know when the responder + // state of our children changed and that information is + // conveniently bubbled up the responder chain. + if result { + self.resignFirstResponder() + inputToolbar.clearDesiredKeyboard() + } + + return result + } + + public override var inputAccessoryView: UIView? { + inputAccessoryPlaceholder + } + + public override var textInputContextIdentifier: String? { + thread.uniqueId + } + + public func dismissPresentedViewControllerIfNecessary() { + guard let presentedViewController = self.presentedViewController else { + Logger.verbose("presentedViewController was nil") + return + } + + if presentedViewController is ActionSheetController || + presentedViewController is UIAlertController { + Logger.verbose("Dismissing presentedViewController: \(type(of: presentedViewController))") + dismiss(animated: false, completion: nil) + return + } + } + + public override func viewWillAppear(_ animated: Bool) { + self.viewWillAppearDidBegin() + + #if TESTABLE_BUILD + initialLoadBenchSteps.step("viewWillAppear.1") + #endif + + Logger.verbose("viewWillAppear") + + self.ensureBannerState() + + super.viewWillAppear(animated) + + if self.inputToolbar == nil { + // This will create the input toolbar for the first time. + // It's important that we do this at the "last moment" to + // avoid expensive work that delays CVC presentation. + self.applyTheme() + owsAssertDebug(self.inputToolbar != nil) + + self.createGestureRecognizers() + } + + self.isViewVisible = true + self.viewWillAppearForLoad() + + // We should have already requested contact access at this point, so this should be a no-op + // unless it ever becomes possible to load this VC without going via the ConversationListViewController. + self.contactsManagerImpl.requestSystemContactsOnce() + + self.updateBarButtonItems() + self.updateNavigationTitle() + + // One-time work performed the first time we enter the view. + if !self.viewHasEverAppeared { + BenchManager.completeEvent(eventId: String(format: "presenting-conversation-\(thread.uniqueId)")) + } + self.ensureBottomViewType() + self.updateInputToolbarLayout() + self.refreshCallState() + + self.showMessageRequestDialogIfRequired() + self.viewWillAppearDidComplete() + #if TESTABLE_BUILD + initialLoadBenchSteps.step("viewWillAppear.2") + #endif + } + + public override func viewDidAppear(_ animated: Bool) { + self.viewDidAppearDidBegin() + + #if TESTABLE_BUILD + initialLoadBenchSteps.step("viewDidAppear.1") + #endif + Logger.verbose("viewDidAppear") + + super.viewDidAppear(animated) + + // We don't present incoming message notifications for the presented + // conversation. But there's a narrow window *while* the conversationVC + // is being presented where a message notification for the not-quite-yet + // presented conversation can be shown. If that happens, dismiss it as soon + // as we enter the conversation. + self.notificationPresenter.cancelNotifications(threadId: thread.uniqueId) + + // recover status bar when returning from PhotoPicker, which is dark (uses light status bar) + self.setNeedsStatusBarAppearanceUpdate() + + self.markVisibleMessagesAsRead() + self.startReadTimer() + self.updateNavigationBarSubtitleLabel() + _ = self.autoLoadMoreIfNecessary() + if !DebugFlags.reduceLogChatter { + self.bulkProfileFetch.fetchProfiles(thread: thread) + self.updateV2GroupIfNecessary() + } + + if !self.viewHasEverAppeared { + // To minimize time to initial apearance, we initially disable prefetching, but then + // re-enable it once the view has appeared. + self.collectionView.isPrefetchingEnabled = true + } + + self.isViewCompletelyAppeared = true + self.shouldAnimateKeyboardChanges = true + + switch self.actionOnOpen { + case .none: + break + case .compose: + // Don't pop the keyboard if we have a pending message request, since + // the user can't currently send a message until acting on this + if nil == requestView { + self.popKeyBoard() + } + case .audioCall: + self.startIndividualAudioCall() + case .videoCall: + self.startIndividualVideoCall() + case .groupCallLobby: + self.showGroupLobbyOrActiveCall() + case .newGroupActionSheet: + DispatchQueue.main.async { [weak self] in + self?.showGroupLinkPromotionActionSheet() + } + case .updateDraft: + // Do nothing input toolbar was just created with the latest draft. + break + } + + scrollToInitialPosition(animated: false) + if viewState.hasAppliedFirstLoad { + self.clearInitialScrollState() + } + + // Clear the "on open" state after the view has been presented. + self.actionOnOpen = .none + + self.updateInputToolbarLayout() + self.configureScrollDownButtons() + inputToolbar?.viewDidAppear() + + if !self.viewState.hasTriedToMigrateGroup { + self.viewState.hasTriedToMigrateGroup = true + + if !DebugFlags.reduceLogChatter { + GroupsV2Migration.autoMigrateThreadIfNecessary(thread: thread) + } + } + + self.viewDidAppearDidComplete() + #if TESTABLE_BUILD + initialLoadBenchSteps.step("viewDidAppear.2") + #endif + } + + // `viewWillDisappear` is called whenever the view *starts* to disappear, + // but, as is the case with the "pan left for message details view" gesture, + // this can be canceled. As such, we shouldn't tear down anything expensive + // until `viewDidDisappear`. + public override func viewWillDisappear(_ animated: Bool) { + Logger.verbose("") + + super.viewWillDisappear(animated) + + self.isViewCompletelyAppeared = false + + dismissMessageActions(animated: false) + self.dismissReactionsDetailSheet(animated: false) + self.saveLastVisibleSortIdAndOnScreenPercentage() + } + + public override func viewDidDisappear(_ animated: Bool) { + Logger.verbose("") + + super.viewDidDisappear(animated) + + self.userHasScrolled = false + self.isViewVisible = false + self.shouldAnimateKeyboardChanges = false + + self.cvAudioPlayer.stopAll() + + self.cancelReadTimer() + self.saveDraft() + self.markVisibleMessagesAsRead() + self.finishRecordingVoiceMessage(sendImmediately: false) + self.mediaCache.removeAllObjects() + inputToolbar?.clearDesiredKeyboard() + + self.isUserScrolling = false + self.isWaitingForDeceleration = false + + self.scrollingAnimationCompletionTimer?.invalidate() + self.scrollingAnimationCompletionTimer = nil + } + + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + guard hasViewWillAppearEverBegun else { + return + } + guard nil != inputToolbar else { + owsFailDebug("Missing inputToolbar.") + return + } + + // We resize the inputToolbar whenever it's text is modified, including when setting saved draft-text. + // However it's possible this draft-text is set before the inputToolbar (an inputAccessoryView) is mounted + // in the view hierarchy. Since it's not in the view hierarchy, it hasn't been laid out and has no width, + // which is used to determine height. + // So here we unsure the proper height once we know everything's been layed out. + self.inputToolbar?.ensureTextViewHeight() + + self.positionGroupCallTooltip() + } + + public override var shouldAutorotate: Bool { + // Don't allow orientation changes while recording voice messages. + if let currentVoiceMessageModel = viewState.currentVoiceMessageModel, + currentVoiceMessageModel.isRecording { + return false + } + + return super.shouldAutorotate + } + + public override func themeDidChange() { + super.themeDidChange() + + self.updateThemeIfNecessary() + } + + private func updateThemeIfNecessary() { + AssertIsOnMainThread() + + if self.isDarkThemeEnabled == Theme.isDarkThemeEnabled { + return + } + self.isDarkThemeEnabled = Theme.isDarkThemeEnabled + + self.applyTheme() + + self.updateConversationStyle() + } + + public override func applyTheme() { + AssertIsOnMainThread() + + super.applyTheme() + + guard hasViewWillAppearEverBegun else { + owsFailDebug("Not yet ready.") + return + } + + // make sure toolbar extends below iPhoneX home button. + self.view.backgroundColor = Theme.toolbarBackgroundColor + + self.updateWallpaperView() + + self.updateNavigationTitle() + self.updateNavigationBarSubtitleLabel() + + self.updateInputToolbar() + self.updateInputToolbarLayout() + self.updateBarButtonItems() + self.ensureBannerState() + + // Re-styling the message actions is tricky, + // since this happens rarely just dismiss + dismissMessageActions(animated: false) + dismissReactionsDetailSheet(animated: false) + } + + func reloadCollectionViewForReset() { + AssertIsOnMainThread() + + guard hasAppearedAndHasAppliedFirstLoad else { + return + } + // We use an obj-c free function so that we can handle NSException. + CVCReloadCollectionViewForReset(self) + } + + var isViewVisible: Bool { + get { viewState.isViewVisible } + set { + viewState.isViewVisible = newValue + + updateCellsVisible() + } + } + + func updateCellsVisible() { + AssertIsOnMainThread() + + let isAppInBackground = CurrentAppContext().isInBackground() + let isCellVisible = self.isViewVisible && !isAppInBackground + for cell in self.collectionView.visibleCells { + guard let cell = cell as? CVCell else { + owsFailDebug("Invalid cell.") + continue + } + cell.isCellVisible = isCellVisible + } + self.updateScrollingContent() + } + + // MARK: - CollectionView updates + + func performBatchUpdates(_ batchUpdates: @escaping () -> Void, + completion: @escaping (Bool) -> Void, + logFailureBlock: @escaping () -> Void, + shouldAnimateUpdates: Bool, + isLoadAdjacent: Bool) { + AssertIsOnMainThread() + + // We use an obj-c free function so that we can handle NSException. + CVCPerformBatchUpdates(self, + batchUpdates, + completion, + logFailureBlock, + shouldAnimateUpdates, + isLoadAdjacent) + } + + // MARK: - Orientation + + public override func viewWillTransition(to size: CGSize, + with coordinator: UIViewControllerTransitionCoordinator) { + AssertIsOnMainThread() + + super.viewWillTransition(to: size, with: coordinator) + + dismissMessageActions(animated: false) + dismissReactionsDetailSheet(animated: false) + + self.scrollContinuity = .bottom + + guard hasAppearedAndHasAppliedFirstLoad else { + return + } + + self.setScrollActionForSizeTransition() + + _ = coordinator.animate( + alongsideTransition: { _ in + }, + completion: { [weak self] _ in + self?.clearScrollActionForSizeTransition() + }) + } + + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + AssertIsOnMainThread() + + self.updateBarButtonItems() + self.updateNavigationBarSubtitleLabel() + + // Invoking -ensureBannerState synchronously can lead to reenterant updates to the + // trait collection while building the banners. This can lead us to blow out the stack + // on unrelated trait collection changes (e.g. rotating to landscape). + // We workaround this by just asyncing any banner updates to break the synchronous + // dependency chain. + DispatchQueue.main.async { + self.ensureBannerState() + } + } + + public override func viewSafeAreaInsetsDidChange() { + AssertIsOnMainThread() + + super.viewSafeAreaInsetsDidChange() + + updateContentInsets(animated: false) + self.updateInputToolbarLayout() + self.viewSafeAreaInsetsDidChangeForLoad() + self.updateConversationStyle() + } +} + +// MARK: - + +// TODO: Is this necessary? +extension ConversationViewController: UINavigationControllerDelegate { +} + +// MARK: - + +extension ConversationViewController: ContactsViewHelperObserver { + public func contactsViewHelperDidUpdateContacts() { + AssertIsOnMainThread() + + self.updateNavigationTitle() + loadCoordinator.enqueueReload(canReuseInteractionModels: true, + canReuseComponentStates: false) + } +} diff --git a/SignalMessaging/Views/InputAccessoryViewPlaceholder.swift b/SignalMessaging/Views/InputAccessoryViewPlaceholder.swift index 245319cc48..7cc0981db6 100644 --- a/SignalMessaging/Views/InputAccessoryViewPlaceholder.swift +++ b/SignalMessaging/Views/InputAccessoryViewPlaceholder.swift @@ -20,7 +20,8 @@ public protocol InputAccessoryViewPlaceholderDelegate: AnyObject { /// presenting view. @objc public class InputAccessoryViewPlaceholder: UIView { - @objc weak var delegate: InputAccessoryViewPlaceholderDelegate? + @objc + public weak var delegate: InputAccessoryViewPlaceholderDelegate? /// The amount of the application frame that is overlapped /// by the keyboard.