TM-SGNL-iOS/Signal/ConversationView/ConversationViewController+Misc.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

485 lines
17 KiB
Swift

//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import ContactsUI
public import SignalServiceKit
public import SignalUI
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.
SSKEnvironment.shared.groupV2UpdatesRef.tryToRefreshV2GroupUpToCurrentRevisionAfterMessageProcessingWithThrottling(groupThread)
}
func showUnblockConversationUI(completion: BlockListUIUtils.Completion?) {
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, completion: 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,
untrustedThreshold: Date?,
completion: @escaping (Bool) -> Void
) -> Bool {
SafetyNumberConfirmationSheet.presentIfNecessary(
addresses: thread.recipientAddressesWithSneakyTransaction,
confirmationText: confirmationText,
untrustedThreshold: untrustedThreshold,
completion: completion
)
}
// MARK: - Verification
func noLongerVerifiedIdentityKeys(tx: SDSAnyReadTransaction) -> [SignalServiceAddress: Data] {
if let groupThread = thread as? TSGroupThread {
return OWSRecipientIdentity.noLongerVerifiedIdentityKeys(in: groupThread.uniqueId, tx: tx)
}
let identityManager = DependenciesBridge.shared.identityManager
return thread.recipientAddresses(with: tx).reduce(into: [:]) { result, address in
guard let recipientIdentity = identityManager.recipientIdentity(for: address, tx: tx.asV2Read) else {
return
}
guard recipientIdentity.verificationState == .noLongerVerified else {
return
}
result[address] = recipientIdentity.identityKey
}
}
func resetVerificationStateToDefault(noLongerVerifiedIdentityKeys: [SignalServiceAddress: Data]) {
AssertIsOnMainThread()
SSKEnvironment.shared.databaseStorageRef.write { transaction in
let identityManager = DependenciesBridge.shared.identityManager
for (address, identityKey) in noLongerVerifiedIdentityKeys {
owsAssertDebug(address.isValid)
_ = identityManager.setVerificationState(
.implicit(isAcknowledged: true),
of: identityKey,
for: address,
isUserInitiatedChange: true,
tx: transaction.asV2Write
)
}
}
}
func showNoLongerVerifiedUI(noLongerVerifiedIdentityKeys: [SignalServiceAddress: Data]) {
AssertIsOnMainThread()
switch noLongerVerifiedIdentityKeys.count {
case 0:
break
case 1:
showFingerprint(address: noLongerVerifiedIdentityKeys.first!.key)
default:
showConversationSettingsAndShowVerification()
}
}
// 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(from: .bottom, of: self.view, inset: bottomInset)
}
func presentMissingQuotedReplyToast() {
Logger.info("")
let toastText = OWSLocalizedString("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 = OWSLocalizedString("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 = OWSLocalizedString("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 = OWSLocalizedString("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,
isSystemContact: conversationViewModel.isSystemContact,
spoilerState: viewState.spoilerState
)
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(AllMediaViewController(
thread: thread,
spoilerState: viewState.spoilerState,
name: title
))
}
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: - Member Action Sheet
func showMemberActionSheet(forAddress address: SignalServiceAddress, withHapticFeedback: Bool) {
AssertIsOnMainThread()
if withHapticFeedback {
ImpactHapticFeedback.impactOccurred(style: .light)
}
var groupViewHelper: GroupViewHelper?
if threadViewModel.isGroupThread {
groupViewHelper = GroupViewHelper(threadViewModel: threadViewModel)
groupViewHelper!.delegate = self
}
ProfileSheetSheetCoordinator(
address: address,
groupViewHelper: groupViewHelper,
spoilerState: spoilerState
)
.presentAppropriateSheet(from: self)
}
}
// MARK: -
extension ConversationViewController: ConversationSettingsViewDelegate {
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)
}
}
}
private 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
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
}
searchController.uiSearchController.searchBar.becomeFirstResponder()
}
}
// MARK: - Preview / 3D Touch / UIContextMenu Methods
public extension ConversationViewController {
var isInPreviewPlatter: Bool {
get { viewState.isInPreviewPlatter }
set {
guard viewState.isInPreviewPlatter != newValue else {
return
}
viewState.isInPreviewPlatter = newValue
if hasViewWillAppearEverBegun {
ensureBottomViewType()
}
configureScrollDownButtons()
}
}
@objc
func previewSetup() {
isInPreviewPlatter = true
actionOnOpen = .none
}
}
// MARK: - Timers
extension ConversationViewController {
public func startReadTimer(caller: String = #function) {
AssertIsOnMainThread()
readTimer?.invalidate()
let readTimer = Timer(timeInterval: 0.1, repeats: true) { [weak self] timer in
guard let self else {
timer.invalidate()
return
}
guard self.view.window != nil else {
owsFailDebug("Read timer fired when ConversationViewController is not in a view hierarchy")
timer.invalidate()
return
}
self.readTimerDidFire()
}
self.readTimer = readTimer
RunLoop.main.add(readTimer, forMode: .common)
}
private func readTimerDidFire() {
AssertIsOnMainThread()
if layout.isPerformBatchUpdatesOrReloadDataBeingApplied {
return
}
markVisibleMessagesAsRead()
}
public func cancelReadTimer(caller: String = #function) {
AssertIsOnMainThread()
readTimer?.invalidate()
self.readTimer = nil
}
private var readTimer: Timer? {
get { viewState.readTimer }
set { viewState.readTimer = newValue }
}
public var reloadTimer: Timer? {
get { viewState.reloadTimer }
set { viewState.reloadTimer = newValue }
}
func startReloadTimer() {
AssertIsOnMainThread()
let reloadTimer = Timer(timeInterval: 1, repeats: true) { [weak self] timer in
guard let self else {
timer.invalidate()
return
}
self.reloadTimerDidFire()
}
self.reloadTimer = reloadTimer
RunLoop.main.add(reloadTimer, forMode: .common)
}
private func reloadTimerDidFire() {
AssertIsOnMainThread()
if isUserScrolling || !isViewCompletelyAppeared || !isViewVisible
|| !CurrentAppContext().isAppForegroundAndActive() || !viewHasEverAppeared {
return
}
let timeSinceLastReload = abs(self.lastReloadDate.timeIntervalSinceNow)
let kReloadFrequency: TimeInterval = 60
if timeSinceLastReload < kReloadFrequency {
return
}
// 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
}
public func markVisibleMessagesAsRead(caller: String = #function) {
AssertIsOnMainThread()
guard
let navigationController,
navigationController.topViewController === self
else {
// If this CVC has presented other view controllers, such as
// conversation settings, we shouldn't mark as read.
return
}
guard self.presentedViewController == nil else {
return
}
guard !AppEnvironment.shared.windowManagerRef.shouldShowCallView else {
return
}
// Always clear the thread unread flag
clearThreadUnreadFlagIfNecessary()
let lastVisibleSortId = self.lastVisibleSortId
let isShowingUnreadMessage = lastVisibleSortId > self.lastSortIdMarkedRead
if !self.isMarkingAsRead && isShowingUnreadMessage {
self.isMarkingAsRead = true
SSKEnvironment.shared.receiptManagerRef.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.
}
}
}
}