608 lines
23 KiB
Swift
608 lines
23 KiB
Swift
//
|
|
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
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
|
|
|
|
// To avoid "noisy" animations (hiding the keyboard before showing
|
|
// the action sheet, re-showing it after), hide the keyboard before
|
|
// showing the "unblock" action sheet.
|
|
//
|
|
// Unblocking is a rare interaction, so it's okay to leave the keyboard
|
|
// hidden.
|
|
dismissKeyBoard()
|
|
|
|
BlockListUIUtils.showUnblockThreadActionSheet(thread, from: self, completionBlock: completion)
|
|
}
|
|
|
|
// MARK: - Identity
|
|
|
|
/**
|
|
* Shows confirmation dialog if at least one of the recipient id's is not confirmed.
|
|
*
|
|
* returns YES if an alert was shown
|
|
* NO if there were no unconfirmed identities
|
|
*/
|
|
func showSafetyNumberConfirmationIfNecessary(confirmationText: String,
|
|
completion: @escaping (Bool) -> Void) -> Bool {
|
|
SafetyNumberConfirmationSheet.presentIfNecessary(addresses: thread.recipientAddresses,
|
|
confirmationText: confirmationText,
|
|
completion: completion)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
func resendFailedOutgoingMessage(_ message: TSOutgoingMessage) {
|
|
// If the message was remotely deleted, resend a *delete* message
|
|
// rather than the message itself.
|
|
let messageToSend = (message.wasRemotelyDeleted
|
|
? TSOutgoingDeleteMessage(thread: thread, message: message)
|
|
: message)
|
|
|
|
let recipientsWithChangedSafetyNumber = message.failedRecipientAddresses(errorCode: .untrustedIdentity)
|
|
if !recipientsWithChangedSafetyNumber.isEmpty {
|
|
// Show special safety number change dialog
|
|
let sheet = SafetyNumberConfirmationSheet(addressesToConfirm: recipientsWithChangedSafetyNumber,
|
|
confirmationText: MessageStrings.sendButton) { didConfirm in
|
|
if didConfirm {
|
|
Self.databaseStorage.asyncWrite { transaction in
|
|
Self.messageSenderJobQueue.add(message: messageToSend.asPreparer,
|
|
transaction: transaction)
|
|
}
|
|
}
|
|
}
|
|
self.present(sheet, animated: true, completion: nil)
|
|
return
|
|
}
|
|
|
|
let actionSheet = ActionSheetController(title: nil,
|
|
message: message.mostRecentFailureText)
|
|
actionSheet.addAction(OWSActionSheets.cancelAction)
|
|
|
|
actionSheet.addAction(ActionSheetAction(title: CommonStrings.deleteForMeButton,
|
|
style: .destructive) { _ in
|
|
Self.databaseStorage.write { transaction in
|
|
message.anyRemove(transaction: transaction)
|
|
}
|
|
})
|
|
|
|
actionSheet.addAction(ActionSheetAction(title: NSLocalizedString("SEND_AGAIN_BUTTON", comment: ""),
|
|
accessibilityIdentifier: "send_again",
|
|
style: .default) { _ in
|
|
Self.databaseStorage.asyncWrite { transaction in
|
|
Self.messageSenderJobQueue.add(message: messageToSend.asPreparer,
|
|
transaction: transaction)
|
|
}
|
|
})
|
|
|
|
dismissKeyBoard()
|
|
self.presentActionSheet(actionSheet)
|
|
}
|
|
|
|
// MARK: - Verification
|
|
|
|
// Returns a collection of the group members who are "no longer verified".
|
|
var noLongerVerifiedAddresses: [SignalServiceAddress] {
|
|
databaseStorage.read { transaction in
|
|
self.noLongerVerifiedAddresses(transaction: transaction)
|
|
}
|
|
}
|
|
|
|
// Returns a collection of the group members who are "no longer verified".
|
|
func noLongerVerifiedAddresses(transaction: SDSAnyReadTransaction) -> [SignalServiceAddress] {
|
|
thread.recipientAddresses.filter { address in
|
|
Self.identityManager.verificationState(for: address,
|
|
transaction: transaction) == .noLongerVerified
|
|
}
|
|
}
|
|
|
|
func resetVerificationStateToDefault() {
|
|
AssertIsOnMainThread()
|
|
|
|
databaseStorage.write { transaction in
|
|
let noLongerVerifiedAddresses = self.noLongerVerifiedAddresses(transaction: transaction)
|
|
for address in noLongerVerifiedAddresses {
|
|
owsAssertDebug(address.isValid)
|
|
|
|
guard let recipientIdentity = Self.identityManager.recipientIdentity(for: address,
|
|
transaction: transaction) else {
|
|
owsFailDebug("Missing recipientIdentity.")
|
|
continue
|
|
}
|
|
guard recipientIdentity.identityKey.count > 0 else {
|
|
owsFailDebug("Invalid identityKey.")
|
|
continue
|
|
}
|
|
Self.identityManager.setVerificationState(.default,
|
|
identityKey: recipientIdentity.identityKey,
|
|
address: address,
|
|
isUserInitiatedChange: true,
|
|
transaction: transaction)
|
|
}
|
|
}
|
|
}
|
|
|
|
func showNoLongerVerifiedUI() {
|
|
AssertIsOnMainThread()
|
|
|
|
let noLongerVerifiedAddresses = self.noLongerVerifiedAddresses
|
|
if noLongerVerifiedAddresses.count > 1 {
|
|
showConversationSettingsAndShowVerification()
|
|
} else if noLongerVerifiedAddresses.count == 1,
|
|
let address = noLongerVerifiedAddresses.last {
|
|
// Pick one in an arbitrary but deterministic manner.
|
|
showFingerprint(address: address)
|
|
}
|
|
}
|
|
|
|
// MARK: - Toast
|
|
|
|
func presentToastCVC(_ toastText: String) {
|
|
let toastController = ToastController(text: toastText)
|
|
let kToastInset: CGFloat = 10
|
|
let bottomInset = kToastInset + collectionView.contentInset.bottom + view.layoutMargins.bottom
|
|
toastController.presentToastView(fromBottomOfView: self.view, inset: bottomInset)
|
|
}
|
|
|
|
func presentMissingQuotedReplyToast() {
|
|
Logger.info("")
|
|
|
|
let toastText = NSLocalizedString("QUOTED_REPLY_ORIGINAL_MESSAGE_DELETED",
|
|
comment: "Toast alert text shown when tapping on a quoted message which we cannot scroll to because the local copy of the message was since deleted.")
|
|
presentToastCVC(toastText)
|
|
}
|
|
|
|
func presentRemotelySourcedQuotedReplyToast() {
|
|
Logger.info("")
|
|
|
|
let toastText = NSLocalizedString("QUOTED_REPLY_ORIGINAL_MESSAGE_REMOTELY_SOURCED",
|
|
comment: "Toast alert text shown when tapping on a quoted message which we cannot scroll to because the local copy of the message didn't exist when the quote was received.")
|
|
presentToastCVC(toastText)
|
|
}
|
|
|
|
func presentViewOnceAlreadyViewedToast() {
|
|
Logger.info("")
|
|
|
|
let toastText = NSLocalizedString("VIEW_ONCE_ALREADY_VIEWED_TOAST",
|
|
comment: "Toast alert text shown when tapping on a view-once message that has already been viewed.")
|
|
presentToastCVC(toastText)
|
|
}
|
|
|
|
func presentViewOnceOutgoingToast() {
|
|
Logger.info("")
|
|
|
|
let toastText = NSLocalizedString("VIEW_ONCE_OUTGOING_TOAST",
|
|
comment: "Toast alert text shown when tapping on a view-once message that you have sent.")
|
|
presentToastCVC(toastText)
|
|
}
|
|
|
|
// MARK: - Conversation Settings
|
|
|
|
func showConversationSettings() {
|
|
showConversationSettings(mode: .default)
|
|
}
|
|
|
|
func showConversationSettingsAndShowAllMedia() {
|
|
showConversationSettings(mode: .showAllMedia)
|
|
}
|
|
|
|
func showConversationSettingsAndShowVerification() {
|
|
showConversationSettings(mode: .showVerification)
|
|
}
|
|
|
|
func showConversationSettingsAndShowMemberRequests() {
|
|
showConversationSettings(mode: .showMemberRequests)
|
|
}
|
|
|
|
func showConversationSettings(mode: ConversationSettingsPresentationMode) {
|
|
guard let viewControllersUpToSelf = self.viewControllersUpToSelf else {
|
|
return
|
|
}
|
|
var viewControllers = viewControllersUpToSelf
|
|
|
|
let settingsView = ConversationSettingsViewController(threadViewModel: threadViewModel)
|
|
settingsView.conversationSettingsViewDelegate = self
|
|
viewControllers.append(settingsView)
|
|
|
|
switch mode {
|
|
case .default:
|
|
break
|
|
case .showVerification:
|
|
settingsView.showVerificationOnAppear = true
|
|
case .showMemberRequests:
|
|
if let view = settingsView.buildMemberRequestsAndInvitesView() {
|
|
viewControllers.append(view)
|
|
}
|
|
case .showAllMedia:
|
|
viewControllers.append(MediaTileViewController(thread: thread))
|
|
}
|
|
|
|
navigationController?.setViewControllers(viewControllers, animated: true)
|
|
}
|
|
|
|
private var viewControllersUpToSelf: [UIViewController]? {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let navigationController = navigationController else {
|
|
owsFailDebug("Missing navigationController.")
|
|
return nil
|
|
}
|
|
|
|
if navigationController.topViewController == self {
|
|
return navigationController.viewControllers
|
|
}
|
|
|
|
let viewControllers = navigationController.viewControllers
|
|
guard let index = viewControllers.firstIndex(of: self) else {
|
|
owsFailDebug("Unexpectedly missing from view hierarchy")
|
|
return viewControllers
|
|
}
|
|
|
|
return Array(viewControllers.prefix(upTo: index + 1))
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ConversationViewController: ConversationSettingsViewDelegate {
|
|
|
|
@objc
|
|
public func conversationColorWasUpdated() {
|
|
AssertIsOnMainThread()
|
|
|
|
updateConversationStyle()
|
|
headerView.updateAvatar()
|
|
}
|
|
|
|
@objc
|
|
public func conversationSettingsDidUpdate() {
|
|
AssertIsOnMainThread()
|
|
|
|
Self.databaseStorage.write { transaction in
|
|
// We updated the group, so if there was a pending message request we should accept it.
|
|
ThreadUtil.addThread(toProfileWhitelistIfEmptyOrPendingRequestAndSetDefaultTimer: self.thread,
|
|
transaction: transaction)
|
|
}
|
|
}
|
|
|
|
@objc
|
|
public func conversationSettingsDidRequestConversationSearch() {
|
|
AssertIsOnMainThread()
|
|
|
|
self.uiMode = .search
|
|
|
|
self.popAllConversationSettingsViews {
|
|
// This delay is unfortunate, but without it, self.searchController.uiSearchController.searchBar
|
|
// isn't yet ready to become first responder. Presumably we're still mid transition.
|
|
// A hardcorded constant like this isn't great because it's either too slow, making our users
|
|
// wait, or too fast, and fails to wait long enough to be ready to become first responder.
|
|
// Luckily in this case the stakes aren't catastrophic. In the case that we're too aggressive
|
|
// the user will just have to manually tap into the search field before typing.
|
|
|
|
// Leaving this assert in as proof that we're not ready to become first responder yet.
|
|
// If this assert fails, *great* maybe we can get rid of this delay.
|
|
owsAssertDebug(!self.searchController.uiSearchController.searchBar.canBecomeFirstResponder)
|
|
|
|
// We wait N seconds for it to become ready.
|
|
let initialDelay: TimeInterval = 0.4
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + initialDelay) { [weak self] in
|
|
self?.tryToBecomeFirstResponderForSearch(cumulativeDelay: initialDelay)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc
|
|
public func popAllConversationSettingsViews(completion: (() -> Void)?) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let presentedViewController = presentedViewController else {
|
|
navigationController?.popToViewController(self, animated: true, completion: completion)
|
|
return
|
|
}
|
|
presentedViewController.dismiss(animated: true) {
|
|
self.navigationController?.popToViewController(self, animated: true, completion: completion)
|
|
}
|
|
}
|
|
|
|
// MARK: - Conversation Search
|
|
|
|
@objc
|
|
private func tryToBecomeFirstResponderForSearch(cumulativeDelay: TimeInterval) {
|
|
// If this took more than N seconds, assume we're not going
|
|
// to be able to present search and bail.
|
|
if cumulativeDelay >= 1.5 {
|
|
owsFailDebug("Giving up presenting search after excessive retry attempts.")
|
|
self.uiMode = .normal
|
|
return
|
|
}
|
|
|
|
// Sometimes it takes longer, so we'll keep retrying..
|
|
if !searchController.uiSearchController.searchBar.canBecomeFirstResponder {
|
|
let additionalDelay: TimeInterval = 0.05
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + additionalDelay) { [weak self] in
|
|
self?.tryToBecomeFirstResponderForSearch(cumulativeDelay: cumulativeDelay + additionalDelay)
|
|
}
|
|
return
|
|
}
|
|
|
|
Logger.verbose("Search controller became ready after \(cumulativeDelay) seconds")
|
|
searchController.uiSearchController.searchBar.becomeFirstResponder()
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ConversationViewController: CNContactViewControllerDelegate {
|
|
@objc
|
|
public func contactViewController(_ viewController: CNContactViewController,
|
|
didCompleteWith contact: CNContact?) {
|
|
navigationController?.popToViewController(self, animated: true)
|
|
}
|
|
}
|
|
|
|
// MARK: - Preview / 3D Touch / UIContextMenu Methods
|
|
|
|
@objc
|
|
public extension ConversationViewController {
|
|
var isInPreviewPlatter: Bool {
|
|
get { viewState.isInPreviewPlatter }
|
|
set {
|
|
guard viewState.isInPreviewPlatter != newValue else {
|
|
return
|
|
}
|
|
viewState.isInPreviewPlatter = newValue
|
|
if hasViewWillAppearEverBegun {
|
|
ensureBottomViewType()
|
|
}
|
|
configureScrollDownButtons()
|
|
}
|
|
}
|
|
|
|
func previewSetup() {
|
|
isInPreviewPlatter = true
|
|
actionOnOpen = .none
|
|
}
|
|
}
|
|
|
|
// MARK: - Unread Counts
|
|
|
|
@objc
|
|
public extension ConversationViewController {
|
|
var unreadMessageCount: UInt {
|
|
get { viewState.unreadMessageCount }
|
|
set {
|
|
guard viewState.unreadMessageCount != newValue else {
|
|
return
|
|
}
|
|
viewState.unreadMessageCount = newValue
|
|
configureScrollDownButtons()
|
|
}
|
|
}
|
|
|
|
var unreadMentionMessages: [TSMessage] {
|
|
get { viewState.unreadMentionMessages }
|
|
set {
|
|
guard viewState.unreadMentionMessages != newValue else {
|
|
return
|
|
}
|
|
viewState.unreadMentionMessages = newValue
|
|
configureScrollDownButtons()
|
|
}
|
|
}
|
|
|
|
func updateUnreadMessageFlagUsingAsyncTransaction() {
|
|
// Resubmits to the main queue because we can't verify we're not already in a transaction we don't know about.
|
|
// This method may be called in response to all sorts of view state changes, e.g. scroll state. These changes
|
|
// can be a result of a UIKit response to app activity that already has an open transaction.
|
|
//
|
|
// We need a transaction to proceed, but we can't verify that we're not already in one (unless explicitly handed
|
|
// one) To workaround this, we async a block to open a fresh transaction on the main queue.
|
|
DispatchQueue.main.async {
|
|
Self.databaseStorage.read { transaction in
|
|
self.updateUnreadMessageFlag(transaction: transaction)
|
|
}
|
|
}
|
|
}
|
|
|
|
func updateUnreadMessageFlag(transaction: SDSAnyReadTransaction) {
|
|
AssertIsOnMainThread()
|
|
|
|
let interactionFinder = InteractionFinder(threadUniqueId: thread.uniqueId)
|
|
let unreadCount = interactionFinder.unreadCount(transaction: transaction.unwrapGrdbRead)
|
|
self.unreadMessageCount = unreadCount
|
|
|
|
if let localAddress = tsAccountManager.localAddress {
|
|
self.unreadMentionMessages = MentionFinder.messagesMentioning(address: localAddress,
|
|
in: thread,
|
|
includeReadMessages: false,
|
|
transaction: transaction.unwrapGrdbRead)
|
|
} else {
|
|
owsFailDebug("Missing localAddress.")
|
|
}
|
|
}
|
|
|
|
/// Checks to see if the unread message flag can be cleared. Shortcircuits if the flag is not set to begin with
|
|
func clearUnreadMessageFlagIfNecessary() {
|
|
AssertIsOnMainThread()
|
|
|
|
if unreadMessageCount > 0 {
|
|
updateUnreadMessageFlagUsingAsyncTransaction()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Timers
|
|
|
|
extension ConversationViewController {
|
|
@objc
|
|
public func startReadTimer() {
|
|
AssertIsOnMainThread()
|
|
|
|
readTimer?.invalidate()
|
|
let readTimer = Timer.weakTimer(withTimeInterval: 0.1,
|
|
target: self,
|
|
selector: #selector(readTimerDidFire),
|
|
userInfo: nil,
|
|
repeats: true)
|
|
self.readTimer = readTimer
|
|
RunLoop.main.add(readTimer, forMode: .common)
|
|
}
|
|
|
|
@objc
|
|
private func readTimerDidFire() {
|
|
AssertIsOnMainThread()
|
|
|
|
if layout.isUpdating {
|
|
return
|
|
}
|
|
markVisibleMessagesAsRead()
|
|
}
|
|
|
|
@objc
|
|
public func cancelReadTimer() {
|
|
AssertIsOnMainThread()
|
|
|
|
readTimer?.invalidate()
|
|
self.readTimer = nil
|
|
}
|
|
|
|
private var readTimer: Timer? {
|
|
get { viewState.readTimer }
|
|
set { viewState.readTimer = newValue }
|
|
}
|
|
|
|
@objc
|
|
public var reloadTimer: Timer? {
|
|
get { viewState.reloadTimer }
|
|
set { viewState.reloadTimer = newValue }
|
|
}
|
|
|
|
@objc
|
|
func startReloadTimer() {
|
|
AssertIsOnMainThread()
|
|
let reloadTimer = Timer.weakTimer(withTimeInterval: 1.0,
|
|
target: self,
|
|
selector: #selector(reloadTimerDidFire),
|
|
userInfo: nil,
|
|
repeats: true)
|
|
self.reloadTimer = reloadTimer
|
|
RunLoop.main.add(reloadTimer, forMode: .common)
|
|
}
|
|
|
|
@objc
|
|
private func reloadTimerDidFire() {
|
|
AssertIsOnMainThread()
|
|
|
|
if isUserScrolling || !isViewCompletelyAppeared || !isViewVisible
|
|
|| !CurrentAppContext().isAppForegroundAndActive() || !viewHasEverAppeared
|
|
|| isPresentingMessageActions {
|
|
return
|
|
}
|
|
|
|
let timeSinceLastReload = abs(self.lastReloadDate.timeIntervalSinceNow)
|
|
let kReloadFrequency: TimeInterval = 60
|
|
if timeSinceLastReload < kReloadFrequency {
|
|
return
|
|
}
|
|
|
|
Logger.verbose("reloading conversation view contents.")
|
|
|
|
// Auto-load more if necessary...
|
|
if !autoLoadMoreIfNecessary() {
|
|
// ...Otherwise, reload everything.
|
|
//
|
|
// TODO: We could make this cheaper by using enqueueReload()
|
|
// if we moved volatile profile / footer state to the view state.
|
|
loadCoordinator.enqueueReload()
|
|
}
|
|
}
|
|
|
|
var lastSortIdMarkedRead: UInt64 {
|
|
get { viewState.lastSortIdMarkedRead }
|
|
set { viewState.lastSortIdMarkedRead = newValue }
|
|
}
|
|
|
|
var isMarkingAsRead: Bool {
|
|
get { viewState.isMarkingAsRead }
|
|
set { viewState.isMarkingAsRead = newValue }
|
|
}
|
|
|
|
private func setLastSortIdMarkedRead(lastSortIdMarkedRead: UInt64) {
|
|
AssertIsOnMainThread()
|
|
owsAssertDebug(self.isMarkingAsRead)
|
|
|
|
self.lastSortIdMarkedRead = lastSortIdMarkedRead
|
|
}
|
|
|
|
@objc
|
|
public func markVisibleMessagesAsRead() {
|
|
AssertIsOnMainThread()
|
|
|
|
if nil != self.presentedViewController {
|
|
return
|
|
}
|
|
if OWSWindowManager.shared.shouldShowCallView {
|
|
return
|
|
}
|
|
if navigationController?.topViewController != self {
|
|
return
|
|
}
|
|
|
|
// Always clear the thread unread flag
|
|
clearThreadUnreadFlagIfNecessary()
|
|
|
|
let lastVisibleSortId = self.lastVisibleSortId
|
|
let isShowingUnreadMessage = lastVisibleSortId > self.lastSortIdMarkedRead
|
|
if !self.isMarkingAsRead && isShowingUnreadMessage {
|
|
self.isMarkingAsRead = true
|
|
clearUnreadMessageFlagIfNecessary()
|
|
|
|
BenchManager.benchAsync(title: "marking as read") { benchCompletion in
|
|
Self.receiptManager.markAsReadLocally(beforeSortId: lastVisibleSortId,
|
|
thread: self.thread,
|
|
hasPendingMessageRequest: self.threadViewModel.hasPendingMessageRequest) {
|
|
AssertIsOnMainThread()
|
|
self.setLastSortIdMarkedRead(lastSortIdMarkedRead: lastVisibleSortId)
|
|
self.isMarkingAsRead = false
|
|
|
|
// If -markVisibleMessagesAsRead wasn't invoked on a
|
|
// timer, we'd want to double check that the current
|
|
// -lastVisibleSortId hasn't incremented since we
|
|
// started the read receipt request. But we have a
|
|
// timer, so if it has changed, this method will just
|
|
// be reinvoked in < 100ms.
|
|
|
|
benchCompletion()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|