Signal-iOS/Signal/src/ViewControllers/ConversationView/ConversationViewController+Misc.swift
2021-06-18 09:41:02 -03:00

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()
}
}
}
}
}