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

1142 lines
45 KiB
Swift

//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public import LibSignalClient
import PassKit
import QuickLook
public import SignalServiceKit
public import SignalUI
extension ConversationViewController: CVComponentDelegate {
public var componentDelegate: CVComponentDelegate { self }
public var isConversationPreview: Bool { false }
public var wallpaperBlurProvider: WallpaperBlurProvider? { backgroundContainer }
public var spoilerState: SpoilerRenderState { return self.viewState.spoilerState }
public func enqueueReload() {
self.loadCoordinator.enqueueReload()
}
public func enqueueReloadWithoutCaches() {
self.loadCoordinator.enqueueReloadWithoutCaches()
}
// MARK: - Double-Tap
public func didDoubleTapTextViewItem(_ viewModel: CVItemViewModelImpl) {
AssertIsOnMainThread()
let controller = DoubleTapToEditOnboardingController(presentationContext: self) {
self.messageActionsEditItem(viewModel)
}
controller.beginEditing(animated: true)
}
// MARK: - Long Press
public func didLongPressTextViewItem(_ cell: CVCell,
itemViewModel: CVItemViewModelImpl,
shouldAllowReply: Bool) {
AssertIsOnMainThread()
let messageActions = MessageActions.textActions(itemViewModel: itemViewModel,
shouldAllowReply: shouldAllowReply,
delegate: self)
self.presentContextMenu(with: messageActions, focusedOn: cell, andModel: itemViewModel)
}
public func didLongPressMediaViewItem(_ cell: CVCell,
itemViewModel: CVItemViewModelImpl,
shouldAllowReply: Bool) {
AssertIsOnMainThread()
let messageActions = MessageActions.mediaActions(itemViewModel: itemViewModel,
shouldAllowReply: shouldAllowReply,
delegate: self)
self.presentContextMenu(with: messageActions, focusedOn: cell, andModel: itemViewModel)
}
public func didLongPressQuote(_ cell: CVCell,
itemViewModel: CVItemViewModelImpl,
shouldAllowReply: Bool) {
AssertIsOnMainThread()
let messageActions = MessageActions.quotedMessageActions(itemViewModel: itemViewModel,
shouldAllowReply: shouldAllowReply,
delegate: self)
self.presentContextMenu(with: messageActions, focusedOn: cell, andModel: itemViewModel)
}
public func didLongPressSystemMessage(_ cell: CVCell,
itemViewModel: CVItemViewModelImpl) {
AssertIsOnMainThread()
let messageActions = MessageActions.infoMessageActions(itemViewModel: itemViewModel,
delegate: self)
self.presentContextMenu(with: messageActions, focusedOn: cell, andModel: itemViewModel)
}
public func didLongPressSticker(_ cell: CVCell,
itemViewModel: CVItemViewModelImpl,
shouldAllowReply: Bool) {
AssertIsOnMainThread()
let messageActions = MessageActions.mediaActions(itemViewModel: itemViewModel,
shouldAllowReply: shouldAllowReply,
delegate: self)
self.presentContextMenu(with: messageActions, focusedOn: cell, andModel: itemViewModel)
}
public func didLongPressPaymentMessage(
_ cell: CVCell,
itemViewModel: CVItemViewModelImpl,
shouldAllowReply: Bool
) {
let messageActions = MessageActions.paymentActions(
itemViewModel: itemViewModel,
shouldAllowReply: shouldAllowReply,
delegate: self
)
self.presentContextMenu(with: messageActions, focusedOn: cell, andModel: itemViewModel)
}
public func didChangeLongPress(_ itemViewModel: CVItemViewModelImpl) {
AssertIsOnMainThread()
collectionViewActiveContextMenuInteraction?.initiatingGestureRecognizerDidChange()
}
public func didEndLongPress(_ itemViewModel: CVItemViewModelImpl) {
AssertIsOnMainThread()
collectionViewActiveContextMenuInteraction?.initiatingGestureRecognizerDidEnd()
}
public func didCancelLongPress(_ itemViewModel: CVItemViewModelImpl) {
AssertIsOnMainThread()
collectionViewActiveContextMenuInteraction?.initiatingGestureRecognizerDidEnd()
}
// MARK: -
public func didTapReplyToItem(_ itemViewModel: CVItemViewModelImpl) {
AssertIsOnMainThread()
populateReplyForMessage(itemViewModel)
}
public func didTapSenderAvatar(_ interaction: TSInteraction) {
AssertIsOnMainThread()
guard let incomingMessage = interaction as? TSIncomingMessage else {
owsFailDebug("not an incoming message.")
return
}
showMemberActionSheet(forAddress: incomingMessage.authorAddress, withHapticFeedback: false)
}
public func shouldAllowReplyForItem(_ itemViewModel: CVItemViewModelImpl) -> Bool {
AssertIsOnMainThread()
if thread.isGroupThread && !thread.isLocalUserFullMemberOfThread {
return false
}
if self.threadViewModel.hasPendingMessageRequest {
return false
}
if itemViewModel.wasRemotelyDeleted {
return false
}
if itemViewModel.isSmsMessageRestoredFromBackup {
return false
}
if let outgoingMessage = itemViewModel.interaction as? TSOutgoingMessage {
if outgoingMessage.messageState == .failed {
// Don't allow "delete" or "reply" on "failed" outgoing messages.
return false
} else if outgoingMessage.messageState == .sending {
// Don't allow "delete" or "reply" on "sending" outgoing messages.
return false
} else if outgoingMessage.messageState == .pending {
// Don't allow "delete" or "reply" on "sending" outgoing messages.
return false
}
}
return true
}
public func didTapReactions(reactionState: InteractionReactionState,
message: TSMessage) {
AssertIsOnMainThread()
if !reactionState.hasReactions {
owsFailDebug("missing reaction state")
return
}
let detailSheet = ReactionsDetailSheet(reactionState: reactionState, message: message)
self.present(detailSheet, animated: true, completion: nil)
self.reactionsDetailSheet = detailSheet
}
public var hasPendingMessageRequest: Bool {
AssertIsOnMainThread()
return self.threadViewModel.hasPendingMessageRequest
}
public func didTapTruncatedTextMessage(_ itemViewModel: CVItemViewModelImpl) {
AssertIsOnMainThread()
expandTruncatedTextOrPresentLongTextView(itemViewModel)
}
public func didTapShowEditHistory(_ itemViewModel: CVItemViewModelImpl) {
AssertIsOnMainThread()
guard let message = itemViewModel.interaction as? TSMessage else {
owsFailDebug("Invalid interaction.")
return
}
let sheet = EditHistoryTableSheetViewController(
message: message,
threadViewModel: self.threadViewModel,
spoilerState: viewState.spoilerState,
editManager: self.context.editManager,
database: SSKEnvironment.shared.databaseStorageRef,
databaseChangeObserver: DependenciesBridge.shared.databaseChangeObserver
)
sheet.delegate = self
self.present(sheet, animated: true)
}
public func didTapFailedOrPendingDownloads(_ message: TSMessage) {
AssertIsOnMainThread()
SSKEnvironment.shared.databaseStorageRef.write { tx in
DependenciesBridge.shared.attachmentDownloadManager.enqueueDownloadOfAttachmentsForMessage(
message,
priority: .userInitiated,
tx: tx.asV2Write
)
}
}
public func didTapBrokenVideo() {
let toastText = OWSLocalizedString("VIDEO_BROKEN",
comment: "Toast alert text shown when tapping on a video that cannot be played.")
presentToastCVC(toastText)
}
// MARK: - Messages
public func didTapBodyMedia(
itemViewModel: CVItemViewModelImpl,
attachmentStream: ReferencedAttachmentStream,
imageView: UIView
) {
AssertIsOnMainThread()
dismissKeyBoard()
guard let pageVC = MediaPageViewController(
initialMediaAttachment: attachmentStream,
thread: self.thread,
spoilerState: self.viewState.spoilerState
) else {
return
}
self.present(pageVC, animated: true, completion: nil)
}
public func didTapGenericAttachment(_ attachment: CVComponentGenericAttachment) -> CVAttachmentTapAction {
AssertIsOnMainThread()
if
PKAddPassesViewController.canAddPasses(),
let pkPass = attachment.representedPKPass(),
let addPassesVC = PKAddPassesViewController(pass: pkPass)
{
self.present(addPassesVC, animated: true, completion: nil)
return .handledByDelegate
} else if let previewController = attachment.createQLPreviewController() {
self.present(previewController, animated: true, completion: nil)
return .handledByDelegate
} else {
return .default
}
}
public func didTapQuotedReply(_ quotedReply: QuotedReplyModel) {
AssertIsOnMainThread()
owsAssertDebug(quotedReply.originalMessageAuthorAddress.isValid)
if quotedReply.originalContent.isStory {
guard
let quotedStoryAuthorAci = quotedReply.originalMessageAuthorAddress.aci,
let timestamp = quotedReply.originalMessageTimestamp
else {
return
}
guard let quotedStory = SSKEnvironment.shared.databaseStorageRef.read(
block: { StoryFinder.story(timestamp: timestamp, author: quotedStoryAuthorAci, transaction: $0) }
) else { return }
let context: StoryContext
if
let contactServiceId = self.threadViewModel.contactAddress?.serviceId,
quotedStory.authorAddress.isLocalAddress,
case let .outgoing(recipientStates) = quotedStory.manifest,
let recipientState = recipientStates[contactServiceId],
let validContext = recipientState.firstValidContext()
{
// If its an outgoing story from the local user and the contact
// is in the recipient states, set the context to the first valid
// context they are a part of.
context = validContext
} else {
// Else fall back to thinking this is an incoming story from this contact.
context = .authorAci(quotedStory.authorAci)
}
let vc = StoryPageViewController(
context: context,
spoilerState: spoilerState,
loadMessage: quotedStory
)
presentFullScreen(vc, animated: true)
} else {
scrollToQuotedMessage(quotedReply, isAnimated: true)
}
}
public func didTapLinkPreview(_ linkPreview: OWSLinkPreview) {
AssertIsOnMainThread()
guard
let urlString = linkPreview.urlString,
let url = URL(string: urlString)
else {
owsFailDebug("Invalid link preview URL.")
return
}
self.handleUrl(url)
}
func handleUrl(_ url: URL) {
if StickerPackInfo.isStickerPackShare(url) {
if let stickerPackInfo = StickerPackInfo.parseStickerPackShare(url) {
didTapStickerPack(stickerPackInfo)
} else {
owsFailDebug("Could not parse sticker pack share URL: \(url)")
}
return
}
if GroupManager.isPossibleGroupInviteLink(url) {
didTapGroupInviteLink(url: url)
return
}
if SignalProxy.isValidProxyLink(url) {
didTapProxyLink(url: url)
return
}
if SignalDotMePhoneNumberLink.isPossibleUrl(url) {
cvc_didTapSignalMeLink(url: url)
return
}
if let usernameLink = Usernames.UsernameLink(usernameLinkUrl: url) {
didTapUsernameLink(usernameLink: usernameLink)
return
}
if let callLink = CallLink(url: url) {
didTapCallLink(callLink)
return
}
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
public func didTapContactShare(_ contactShare: ContactShareViewModel) {
AssertIsOnMainThread()
let view = ContactViewController(contactShare: contactShare)
navigationController?.pushViewController(view, animated: true)
}
public func didTapSendMessage(to phoneNumbers: [String]) {
AssertIsOnMainThread()
contactShareViewHelper.sendMessage(to: phoneNumbers, from: self)
}
public func didTapSendInvite(toContactShare contactShare: ContactShareViewModel) {
AssertIsOnMainThread()
contactShareViewHelper.showInviteContact(contactShare: contactShare, from: self)
}
public func didTapAddToContacts(contactShare: ContactShareViewModel) {
AssertIsOnMainThread()
contactShareViewHelper.showAddToContactsPrompt(contactShare: contactShare, from: self)
}
public func didTapStickerPack(_ stickerPackInfo: StickerPackInfo) {
AssertIsOnMainThread()
let packView = StickerPackViewController(stickerPackInfo: stickerPackInfo)
packView.present(from: self, animated: true)
}
public func didTapPayment(_ payment: PaymentsHistoryItem) {
AssertIsOnMainThread()
let paymentsDetailViewController = PaymentsDetailViewController(
paymentItem: payment
)
navigationController?.pushViewController(paymentsDetailViewController, animated: true)
}
public func didTapGroupInviteLink(url: URL) {
AssertIsOnMainThread()
owsAssertDebug(GroupManager.isPossibleGroupInviteLink(url))
GroupInviteLinksUI.openGroupInviteLink(url, fromViewController: self)
}
public func didTapProxyLink(url: URL) {
AssertIsOnMainThread()
guard let vc = ProxyLinkSheetViewController(url: url) else { return }
present(vc, animated: true)
}
func didTapCallLink(_ callLink: CallLink) {
AssertIsOnMainThread()
GroupCallViewController.presentLobby(for: callLink)
}
public func cvc_didTapSignalMeLink(url: URL) {
SignalDotMePhoneNumberLink.openChat(url: url, fromViewController: self)
}
public func didTapUsernameLink(usernameLink: Usernames.UsernameLink) {
SSKEnvironment.shared.databaseStorageRef.read { tx in
UsernameQuerier().queryForUsernameLink(
link: usernameLink,
fromViewController: self,
tx: tx,
onSuccess: { _, aci in
SignalApp.shared.presentConversationForAddress(
SignalServiceAddress(aci),
animated: true
)
}
)
}
}
public func didTapShowMessageDetail(_ itemViewModel: CVItemViewModelImpl) {
showDetailView(itemViewModel)
}
public func prepareMessageDetailForInteractivePresentation(_ itemViewModel: CVItemViewModelImpl) {
prepareDetailViewForInteractivePresentation(itemViewModel)
}
public func beginCellAnimation(maximumDuration: TimeInterval) -> EndCellAnimation {
AssertIsOnMainThread()
if maximumDuration > 0.8 {
owsFailDebug("Animation is too long, skipping.")
return {}
}
let identifier = UUID()
viewState.beginCellAnimation(identifier: identifier)
var timer: Timer?
let endAnimation = { [weak self] in
AssertIsOnMainThread()
guard let self = self else { return }
timer?.invalidate()
self.viewState.endCellAnimation(identifier: identifier)
self.loadCoordinator.enqueueReload()
}
// Automatically unblock loads once the max duration is reached, even
// if the cell didn't tell us it finished.
timer = Timer.scheduledTimer(withTimeInterval: maximumDuration, repeats: false) { _ in
endAnimation()
}
return endAnimation
}
// MARK: - System Cell
public func didTapPreviouslyVerifiedIdentityChange(_ address: SignalServiceAddress) {
AssertIsOnMainThread()
showFingerprint(address: address)
}
public func showFingerprint(address: SignalServiceAddress) {
AssertIsOnMainThread()
// Ensure keyboard isn't hiding the "safety numbers changed" interaction when we
// return from FingerprintViewController.
dismissKeyBoard()
let addressAci: Aci? = address.aci ?? {
guard let phoneNumber = address.phoneNumber else {
return nil
}
// Reload the address from disk if we lack an ACI.
let recipientDatabaseTable = DependenciesBridge.shared.recipientDatabaseTable
return SSKEnvironment.shared.databaseStorageRef.read { tx in
return recipientDatabaseTable.fetchRecipient(phoneNumber: phoneNumber, transaction: tx.asV2Read)?.aci
}
}()
FingerprintViewController.present(for: addressAci, from: self)
}
public func didTapUnverifiedIdentityChange(_ address: SignalServiceAddress) {
AssertIsOnMainThread()
owsAssertDebug(address.isValid)
dismissKeyBoard()
let headerImageView = UIImageView(image: UIImage(named: "safety-number-change"))
let headerView = UIView()
headerView.addSubview(headerImageView)
headerImageView.autoPinEdge(toSuperviewEdge: .top, withInset: 22)
headerImageView.autoPinEdge(toSuperviewEdge: .bottom)
headerImageView.autoHCenterInSuperview()
headerImageView.autoSetDimension(.width, toSize: 200)
headerImageView.autoSetDimension(.height, toSize: 110)
let displayName = SSKEnvironment.shared.databaseStorageRef.read { tx in SSKEnvironment.shared.contactManagerRef.displayName(for: address, tx: tx).resolvedValue() }
let messageFormat = OWSLocalizedString("UNVERIFIED_SAFETY_NUMBER_CHANGE_DESCRIPTION_FORMAT",
comment: "Description for the unverified safety number change. Embeds {name of contact with identity change}")
let actionSheet = ActionSheetController(title: nil,
message: String(format: messageFormat, displayName))
actionSheet.customHeader = headerView
actionSheet.addAction(ActionSheetAction(title: OWSLocalizedString("UNVERIFIED_SAFETY_NUMBER_VERIFY_ACTION",
comment: "Action to verify a safety number after it has changed"),
style: .default) { [weak self] _ in
self?.showFingerprint(address: address)
})
actionSheet.addAction(ActionSheetAction(title: CommonStrings.notNowButton,
style: .cancel,
handler: nil))
presentActionSheet(actionSheet)
}
public func didTapInvalidIdentityKeyErrorMessage(_ message: TSInvalidIdentityKeyErrorMessage) {
AssertIsOnMainThread()
let keyOwner = SSKEnvironment.shared.databaseStorageRef.read { tx in
return SSKEnvironment.shared.contactManagerRef.displayName(for: message.theirSignalAddress(), tx: tx).resolvedValue()
}
let titleFormat = OWSLocalizedString("SAFETY_NUMBERS_ACTIONSHEET_TITLE", comment: "Action sheet heading")
let titleText = String(format: titleFormat, keyOwner)
let actionSheet = ActionSheetController(title: titleText, message: nil)
actionSheet.addAction(OWSActionSheets.cancelAction)
actionSheet.addAction(ActionSheetAction(title: OWSLocalizedString("SHOW_SAFETY_NUMBER_ACTION",
comment: "Action sheet item"),
accessibilityIdentifier: "show_safety_number",
style: .default) { [weak self] _ in
Logger.info("Remote Key Changed actions: Show fingerprint display")
self?.showFingerprint(address: message.theirSignalAddress())
})
actionSheet.addAction(ActionSheetAction(title: OWSLocalizedString("ACCEPT_NEW_IDENTITY_ACTION",
comment: "Action sheet item"),
accessibilityIdentifier: "accept_safety_number",
style: .default) { _ in
Logger.info("Remote Key Changed actions: Accepted new identity key")
// DEPRECATED: we're no longer creating these incoming SN error's per message,
// but there will be some legacy ones in the wild, behind which await
// as-of-yet-undecrypted messages
if let errorMessage = message as? TSInvalidIdentityKeyReceivingErrorMessage {
do {
_ = try errorMessage.acceptNewIdentityKey()
} catch {
// Deliberately crash if the user fails to explicitly accept the new identity
// key. In practice we haven't been creating these messages in over a year.
owsFail("Error: \(error)")
}
}
})
dismissKeyBoard()
self.presentActionSheet(actionSheet)
}
public func didTapCorruptedMessage(_ message: TSErrorMessage) {
AssertIsOnMainThread()
let threadName = SSKEnvironment.shared.databaseStorageRef.read { transaction in
SSKEnvironment.shared.contactManagerRef.displayName(for: self.thread, transaction: transaction)
}
let alertMessage = String(format: OWSLocalizedString("CORRUPTED_SESSION_DESCRIPTION",
comment: "ActionSheet title"),
threadName)
let alert = ActionSheetController(title: nil, message: alertMessage)
alert.addAction(OWSActionSheets.cancelAction)
alert.addAction(ActionSheetAction(title: OWSLocalizedString("FINGERPRINT_SHRED_KEYMATERIAL_BUTTON",
comment: ""),
accessibilityIdentifier: "reset_session",
style: .default) { [weak self] _ in
guard let self = self else { return }
guard let contactThread = self.thread as? TSContactThread else {
// Corrupt Message errors only appear in contact threads.
Logger.error("Unexpected request to reset session in group thread.")
return
}
SSKEnvironment.shared.databaseStorageRef.asyncWrite { transaction in
SSKEnvironment.shared.smJobQueuesRef.sessionResetJobQueue.add(contactThread: contactThread, transaction: transaction)
}
})
dismissKeyBoard()
self.presentActionSheet(alert)
}
public func didTapSessionRefreshMessage(_ message: TSErrorMessage) {
dismissKeyBoard()
let headerImageView = UIImageView(image: UIImage(named: "chat-session-refresh"))
let headerView = UIView()
headerView.addSubview(headerImageView)
headerImageView.autoPinEdge(toSuperviewEdge: .top, withInset: 22)
headerImageView.autoPinEdge(toSuperviewEdge: .bottom)
headerImageView.autoHCenterInSuperview()
headerImageView.autoSetDimension(.width, toSize: 200)
headerImageView.autoSetDimension(.height, toSize: 110)
ContactSupportAlert.presentAlert(title: OWSLocalizedString("SESSION_REFRESH_ALERT_TITLE",
comment: "Title for the session refresh alert"),
message: OWSLocalizedString("SESSION_REFRESH_ALERT_MESSAGE",
comment: "Description for the session refresh alert"),
emailSupportFilter: "Signal iOS Session Refresh",
fromViewController: self,
additionalActions: [
ActionSheetAction(title: CommonStrings.okayButton,
accessibilityIdentifier: "okay",
style: .default,
handler: nil)
],
customHeader: headerView,
showCancel: false)
}
// See: resendGroupUpdate
public func didTapResendGroupUpdateForErrorMessage(_ message: TSErrorMessage) {
AssertIsOnMainThread()
guard let groupThread = self.thread as? TSGroupThread else {
owsFailDebug("Invalid thread.")
return
}
Task {
await GroupManager.sendGroupUpdateMessage(thread: groupThread)
Logger.info("Group updated, removing group creation error.")
await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in
DependenciesBridge.shared.interactionDeleteManager
.delete(message, sideEffects: .default(), tx: tx.asV2Write)
}
}
}
public func didTapShowFingerprint(_ address: SignalServiceAddress) {
AssertIsOnMainThread()
showFingerprint(address: address)
}
// MARK: -
public func didTapIndividualCall(_ call: TSCall) {
AssertIsOnMainThread()
owsAssertDebug(self.inputToolbar != nil)
guard let contactThread = thread as? TSContactThread else {
owsFailDebug("Invalid thread.")
return
}
let displayName = SSKEnvironment.shared.databaseStorageRef.read { tx in
return SSKEnvironment.shared.contactManagerRef.displayName(for: contactThread.contactAddress, tx: tx).resolvedValue()
}
let alert = ActionSheetController(title: CallStrings.callBackAlertTitle,
message: String(format: CallStrings.callBackAlertMessageFormat,
displayName))
alert.addAction(ActionSheetAction(title: CallStrings.callBackAlertCallButton,
accessibilityIdentifier: "call_back",
style: .default) { [weak self] _ in
guard let self = self else { return }
switch call.offerType {
case .audio:
self.startIndividualAudioCall()
case .video:
self.startIndividualVideoCall()
}
})
alert.addAction(OWSActionSheets.cancelAction)
inputToolbar?.clearDesiredKeyboard()
dismissKeyBoard()
self.presentActionSheet(alert)
}
public func didTapLearnMoreMissedCallFromBlockedContact(_ call: TSCall) {
AssertIsOnMainThread()
guard let contactThread = thread as? TSContactThread else {
owsFailDebug("Invalid thread.")
return
}
let address = contactThread.contactAddress
let displayName = SSKEnvironment.shared.databaseStorageRef.read { tx in SSKEnvironment.shared.contactManagerRef.displayName(for: address, tx: tx).resolvedValue() }
let alert = ActionSheetController(
title: String(
format: OWSLocalizedString(
"MISSED_CALL_BLOCKED_SYSTEM_SETTINGS_SHEET_TITLE",
comment: "Title for sheet shown when the user taps a missed call from a contact blocked in iOS settings. Embeds {{ Contact's name }}"
),
displayName
),
message: OWSLocalizedString(
"MISSED_CALL_BLOCKED_SYSTEM_SETTINGS_SHEET_MESSAGE",
comment: "Message for sheet shown when the user taps a missed call from a contact blocked in iOS settings.")
)
alert.addAction(
ActionSheetAction(
title: OWSLocalizedString(
"MISSED_CALL_BLOCKED_SYSTEM_SETTINGS_SHEET_BLOCK_ACTION",
comment: "Action to block contact in Signal for sheet shown when the user taps a missed call from a contact blocked in iOS settings."
),
accessibilityIdentifier: "block_contact",
style: .destructive
) { [weak self] _ in
guard self != nil else { return }
SSKEnvironment.shared.databaseStorageRef.write { tx in
SSKEnvironment.shared.blockingManagerRef.addBlockedAddress(
address,
blockMode: .localShouldLeaveGroups,
transaction: tx
)
}
}
)
alert.addAction(OWSActionSheets.okayAction)
inputToolbar?.clearDesiredKeyboard()
dismissKeyBoard()
self.presentActionSheet(alert)
}
public func didTapGroupCall() {
AssertIsOnMainThread()
showGroupLobbyOrActiveCall()
}
public func didTapPendingOutgoingMessage(_ message: TSOutgoingMessage) {
AssertIsOnMainThread()
if SSKEnvironment.shared.spamChallengeResolverRef.isPausingMessages {
SpamCaptchaViewController.presentActionSheet(from: self)
} else {
SSKEnvironment.shared.spamChallengeResolverRef.retryPausedMessagesIfReady()
}
}
public func didTapFailedOutgoingMessage(_ message: TSOutgoingMessage) {
AssertIsOnMainThread()
let promptBuilder = ResendMessagePromptBuilder(
databaseStorage: SSKEnvironment.shared.databaseStorageRef,
messageSenderJobQueue: SSKEnvironment.shared.messageSenderJobQueueRef
)
dismissKeyBoard()
self.present(promptBuilder.build(for: message), animated: true)
}
public func didTapGroupMigrationLearnMore() {
AssertIsOnMainThread()
presentFormSheet(
LegacyGroupLearnMoreViewController(mode: .explainNewGroups),
animated: true
)
}
public func didTapGroupInviteLinkPromotion(groupModel: TSGroupModel) {
AssertIsOnMainThread()
showGroupLinkPromotionActionSheet()
}
public func didTapViewGroupDescription(newGroupDescription: String) {
AssertIsOnMainThread()
func getGroupModel() -> TSGroupModel? {
if let groupThread = thread as? TSGroupThread {
return groupThread.groupModel
}
return nil
}
guard let groupModel = getGroupModel() else {
owsFailDebug("Unexpectedly missing group model.")
return
}
let vc = GroupDescriptionViewController(
groupModel: groupModel,
groupDescriptionCurrent: newGroupDescription,
options: []
)
let navigationController = OWSNavigationController(rootViewController: vc)
self.presentFormSheet(navigationController, animated: true)
}
public func didTapShowConversationSettings() {
AssertIsOnMainThread()
showConversationSettings()
}
public func didTapShowConversationSettingsAndShowMemberRequests() {
AssertIsOnMainThread()
showConversationSettingsAndShowMemberRequests()
}
public func didTapBlockRequest(
groupModel: TSGroupModelV2,
requesterName: String,
requesterAci: Aci
) {
AssertIsOnMainThread()
let actionSheet = ActionSheetController(
title: OWSLocalizedString(
"GROUPS_BLOCK_REQUEST_SHEET_TITLE",
comment: "Title for sheet asking if the user wants to block a request to join the group."
),
message: String(
format: OWSLocalizedString(
"GROUPS_BLOCK_REQUEST_SHEET_MESSAGE",
comment: "Message for sheet offering to let the user block a request to join the group. Embeds {{ the requester's name }}."
),
requesterName
))
actionSheet.addAction(.init(
title: OWSLocalizedString(
"GROUPS_BLOCK_REQUEST_SHEET_BLOCK_BUTTON",
comment: "Label for button that will block a request to join a group."
),
style: .default,
handler: { _ in
GroupViewUtils.updateGroupWithActivityIndicator(
fromViewController: self,
updateDescription: "Blocking join request",
updateBlock: {
// If the user in question has canceled their request,
// this call will still block them.
return try await GroupManager.acceptOrDenyMemberRequestsV2(
groupModel: groupModel,
aci: requesterAci,
shouldAccept: false
)
},
completion: nil
)
})
)
actionSheet.addAction(OWSActionSheets.cancelAction)
OWSActionSheets.showActionSheet(actionSheet, fromViewController: self)
}
public func didTapShowUpgradeAppUI() {
AssertIsOnMainThread()
UIApplication.shared.open(TSConstants.appStoreUrl, options: [:], completionHandler: nil)
}
public func didTapUpdateSystemContact(_ address: SignalServiceAddress, newNameComponents: PersonNameComponents) {
SUIEnvironment.shared.contactsViewHelperRef.presentSystemContactsFlow(
CreateOrEditContactFlow(address: address, nameComponents: newNameComponents),
from: self
)
}
public func didTapPhoneNumberChange(aci: Aci, phoneNumberOld: String, phoneNumberNew: String) {
SUIEnvironment.shared.contactsViewHelperRef.checkEditAuthorization(
performWhenAllowed: {
let existingContact: CNContact? = {
guard let cnContactId = SSKEnvironment.shared.contactManagerRef.cnContactId(for: phoneNumberOld) else {
return nil
}
return SSKEnvironment.shared.contactManagerRef.cnContact(withId: cnContactId)
}()
guard let existingContact else {
owsFailDebug("Missing existing contact for phone number change.")
return
}
let address = SignalServiceAddress(serviceId: aci, phoneNumber: phoneNumberNew)
SUIEnvironment.shared.contactsViewHelperRef.presentSystemContactsFlow(
CreateOrEditContactFlow(address: address, contact: existingContact),
from: self
)
},
presentErrorFrom: self
)
}
public func didTapViewOnceAttachment(_ interaction: TSInteraction) {
AssertIsOnMainThread()
ViewOnceMessageViewController.tryToPresent(interaction: interaction, from: self)
}
public func didTapViewOnceExpired(_ interaction: TSInteraction) {
AssertIsOnMainThread()
if interaction is TSOutgoingMessage {
presentViewOnceOutgoingToast()
} else {
presentViewOnceAlreadyViewedToast()
}
}
public func didTapContactName(thread: TSContactThread) {
AssertIsOnMainThread()
ContactAboutSheet(thread: thread, spoilerState: self.spoilerState)
.present(from: self)
}
public func didTapUnknownThreadWarningGroup() {
AssertIsOnMainThread()
showUnknownThreadWarningAlert()
}
public func didTapUnknownThreadWarningContact() {
AssertIsOnMainThread()
showUnknownThreadWarningAlert()
}
public func didTapDeliveryIssueWarning(_ message: TSErrorMessage) {
AssertIsOnMainThread()
guard let senderAddress = message.sender else {
owsFailDebug("Expected a sender address")
return
}
// If the error message was added to a group thread, we must know that the failed decryption was
// associated with the current thread. Why?
//
// - If we fail to decrypt a message, the sender may have tagged the envelope with a groupId. That
// groupId is used to look up the source thread and insert this error message.
// - If there is no groupId on the envelope, we don't know anything about which thread the original
// message belongs to, so we fall back to inserting this message in the author's 1:1 thread.
// - There's no other information that would allow us to determine the originating thread other
// that this groupId field.
// - Therefore, if this error message was added to a group thread, we know we must have the right thread
// thread. If it's not in a group thread, we can't infer anything about the thread of the original message.
//
// Maybe one day the envelope will be annotated with additional information to always allow us to tie
// the failed decryption to the originating thread. But until then, this heuristic will always be correct.
// There's no reason to add an additional bit to the interactions db to track whether or not we know
// the originating thread.
showDeliveryIssueWarningAlert(from: senderAddress, isKnownThread: thread.isGroupThread)
}
public func didTapActivatePayments() {
AssertIsOnMainThread()
SignalApp.shared.showAppSettings(mode: .payments)
}
public func didTapSendPayment() {
AssertIsOnMainThread()
// Same action as tapping on the attachment toolbar.
paymentButtonPressed()
}
public func didTapThreadMergeLearnMore(phoneNumber: String) {
guard let contactAddress = (thread as? TSContactThread)?.contactAddress else {
owsFailDebug("Can't handle a merge event in a group.")
return
}
let formattedMessage: String = {
let formatString = OWSLocalizedString(
"THREAD_MERGE_LEARN_MORE",
comment: "Shown after tapping a 'Learn More' button when multiple conversations for the same person have been merged into one. The first parameter is a phone number (eg +1 650-555-0100) and the second parameter is a name (eg John)."
)
let formattedPhoneNumber = PhoneNumber.bestEffortLocalizedPhoneNumber(e164: phoneNumber)
let shortDisplayName = SSKEnvironment.shared.databaseStorageRef.read { tx in
return SSKEnvironment.shared.contactManagerRef.displayName(for: contactAddress, tx: tx).resolvedValue(useShortNameIfAvailable: true)
}
return String(format: formatString, formattedPhoneNumber, shortDisplayName)
}()
let customHeader: UIView = {
let imageView = UIImageView(image: UIImage(named: "merged-chat")!)
imageView.contentMode = .scaleAspectFit
imageView.autoSetDimensions(to: .square(88))
let stackView = UIStackView()
stackView.isLayoutMarginsRelativeArrangement = true
stackView.layoutMargins = UIEdgeInsets(top: 16, leading: 0, bottom: 0, trailing: 0)
stackView.axis = .vertical
stackView.alignment = .center
stackView.addArrangedSubview(imageView)
return stackView
}()
let actionSheet = ActionSheetController(message: formattedMessage)
actionSheet.customHeader = customHeader
actionSheet.addAction(ActionSheetAction(title: CommonStrings.okButton))
presentActionSheet(actionSheet)
}
public func didTapReportSpamLearnMore() {
AssertIsOnMainThread()
let alert = ActionSheetController(
title: OWSLocalizedString(
"INFO_MESSAGE_REPORTED_SPAM_LEARN_MORE_TITLE",
comment: "Title of the alert shown when a user taps on 'learn more' via the spam info message."
),
message: OWSLocalizedString(
"INFO_MESSAGE_REPORTED_SPAM_LEARN_MORE_MESSAGE",
comment: "Body message of the alert shown when a user taps on 'learn more' via the spam info message.")
)
alert.addAction(OWSActionSheets.okayAction)
inputToolbar?.clearDesiredKeyboard()
dismissKeyBoard()
self.presentActionSheet(alert)
}
public func didTapMessageRequestAcceptedOptions() {
AssertIsOnMainThread()
let message: String
if thread is TSContactThread {
message = String(
format: OWSLocalizedString(
"INFO_MESSAGE_ACCEPTED_MESSAGE_REQUEST_OPTIONS_ACTION_SHEET_HEADER_CONTACT",
comment: "Header for an action sheet providing options in response to an accepted 1:1 message request. Embeds {{ the name of your chat partner }}."
),
threadViewModel.shortName ?? threadViewModel.name
)
} else if thread is TSGroupThread {
message = OWSLocalizedString(
"INFO_MESSAGE_ACCEPTED_MESSAGE_REQUEST_OPTIONS_ACTION_SHEET_HEADER_GROUP",
comment: "Header for an action sheet providing options in response to an accepted group message request."
)
} else {
return
}
let alert = ActionSheetController(
message: message
)
alert.addAction(ActionSheetAction(
title: String(
format: OWSLocalizedString(
"MESSAGE_REQUEST_ACCEPTED_INFO_MESSAGE_SHEET_OPTION_BLOCK",
comment: "Sheet option for blocking a chat. In this case, the sheet appears when the user taps a button attached to a 'message request accepted' info message in-chat."
)
),
style: .default,
handler: { [weak self] _ in
guard let self else { return }
let blockThreadActionSheet = createBlockThreadActionSheet()
presentActionSheet(blockThreadActionSheet)
}
))
alert.addAction(ActionSheetAction(
title: String(
format: OWSLocalizedString(
"MESSAGE_REQUEST_ACCEPTED_INFO_MESSAGE_SHEET_OPTION_SPAM",
comment: "Sheet option for reporting a chat as spam. In this case, the sheet appears when the user taps a button attached to a 'message request accepted' info message in-chat."
)
),
style: .default,
handler: { [weak self] _ in
guard let self else { return }
let reportThreadActionSheet = createReportThreadActionSheet()
presentActionSheet(reportThreadActionSheet)
}
))
alert.addAction(ActionSheetAction(
title: String(
format: OWSLocalizedString(
"MESSAGE_REQUEST_ACCEPTED_INFO_MESSAGE_SHEET_OPTION_DELETE",
comment: "Sheet option for deleting a chat. In this case, the sheet appears when the user taps a button attached to a 'message request accepted' info message in-chat."
)
),
style: .default,
handler: { [weak self] _ in
guard let self else { return }
let deleteThreadActionSheet = createDeleteThreadActionSheet()
presentActionSheet(deleteThreadActionSheet)
}
))
alert.addAction(.cancel)
inputToolbar?.clearDesiredKeyboard()
dismissKeyBoard()
presentActionSheet(alert)
}
public func didTapJoinCallLinkCall(callLink: CallLink) {
GroupCallViewController.presentLobby(for: callLink)
}
}