Path: blob/main/Signal/ConversationView/ConversationViewController+Misc.swift
1 views
//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import ContactsUI
import SignalServiceKit
import SignalUI
extension ConversationViewController {
func updateV2GroupIfNecessary() async {
AssertIsOnMainThread()
guard let groupModel = (thread as? TSGroupThread)?.groupModel as? TSGroupModelV2 else {
return
}
do {
let secretParams = try groupModel.secretParams()
// 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.
let groupUpdater = SSKEnvironment.shared.groupV2UpdatesRef
try await groupUpdater.refreshGroup(secretParams: secretParams, options: [.throttle])
} catch {
Logger.warn("Couldn't refresh group: \(error)")
}
}
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: CurrentAppContext().frontmostViewController() ?? self,
completion: completion,
)
}
@MainActor
func showUnblockConversationUI() async -> Bool {
await withCheckedContinuation { continuation in
self.showUnblockConversationUI { isBlocked in
continuation.resume(returning: isBlocked)
}
}
}
// 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,
)
}
/// Shows confirmation dialog if at least one of the recipient id's is not confirmed.
/// - Returns: `true` if all recipients are confirmed.
/// `false` if the alert was shown and the identity was left unconfirmed.
@MainActor
func showSafetyNumberConfirmationIfNecessary(
from viewController: UIViewController,
confirmationText: String,
untrustedThreshold: Date?,
) async -> Bool {
await SafetyNumberConfirmationSheet.presentRepeatedlyAsNecessary(
for: { thread.recipientAddressesWithSneakyTransaction },
from: viewController,
confirmationText: confirmationText,
untrustedThreshold: untrustedThreshold,
)
}
// MARK: - Verification
func noLongerVerifiedIdentityKeys(tx: DBReadTransaction) -> [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) 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,
)
}
}
}
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, image: UIImage? = nil) {
let toastController = ToastController(text: toastText, image: image)
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, image: .viewOnceDash)
}
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,
memberLabelCoordinator: memberLabelCoordinator,
)
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,
))
}
dismissKeyBoard()
navigationController?.setViewControllers(viewControllers, animated: true)
}
private var viewControllersUpToSelf: [UIViewController]? {
AssertIsOnMainThread()
guard let 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, memberLabelCoordinator: memberLabelCoordinator)
groupViewHelper!.delegate = self
}
var memberLabel: MemberLabelForRendering?
if
let groupThread = thread as? TSGroupThread,
let memberAci = address.aci,
let memberLabelString = groupThread.groupModel.groupMembership.memberLabel(for: memberAci)?.labelForRendering()
{
let groupNameColors = GroupNameColors.forThread(groupThread)
memberLabel = MemberLabelForRendering(label: memberLabelString, groupNameColor: groupNameColors.color(for: memberAci))
}
ProfileSheetSheetCoordinator(
address: address,
groupViewHelper: groupViewHelper,
spoilerState: spoilerState,
memberLabel: memberLabel,
)
.presentAppropriateSheet(from: self)
}
}
// MARK: -
extension ConversationViewController: ConversationSettingsViewDelegate {
public func conversationSettingsDidRequestConversationSearch() {
AssertIsOnMainThread()
self.uiMode = .search
self.popAllConversationSettingsViews {
self.searchController.uiSearchController.searchBar.becomeFirstResponder()
}
}
private func popAllConversationSettingsViews(completion: @escaping () -> Void) {
AssertIsOnMainThread()
guard let presentedViewController else {
navigationController?.popToViewController(self, animated: true, completion: completion)
return
}
presentedViewController.dismiss(animated: true) {
self.navigationController?.popToViewController(self, animated: true, completion: completion)
}
}
public func deleteConversation() {
threadActionProviderDelegate?.deleteThreadWithConfirmation(threadViewModel: threadViewModel)
}
public func toggleConversationArchived() {
threadActionProviderDelegate?.toggleThreadIsArchived(threadViewModel: threadViewModel)
}
}
// 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 }
}
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.
}
}
}
}