1492 lines
55 KiB
Swift
1492 lines
55 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 wallpaperBlurProvider: WallpaperBlurProvider? { backgroundContainer }
|
|
|
|
public var spoilerState: SpoilerRenderState { return self.viewState.spoilerState }
|
|
|
|
public func enqueueReload() {
|
|
self.loadCoordinator.enqueueReload()
|
|
}
|
|
|
|
public func enqueueReloadWithoutCaches() {
|
|
self.loadCoordinator.enqueueReloadWithoutCaches()
|
|
}
|
|
|
|
// MARK: - Collapse Sets
|
|
|
|
public func didTapCollapseSet(collapseSetId: String) {
|
|
AssertIsOnMainThread()
|
|
if viewState.expandedCollapseSets.contains(collapseSetId) {
|
|
viewState.expandedCollapseSets.remove(collapseSetId)
|
|
} else {
|
|
viewState.expandedCollapseSets.insert(collapseSetId)
|
|
}
|
|
loadCoordinator.enqueueReload(
|
|
updatedInteractionIds: [collapseSetId],
|
|
deletedInteractionIds: [],
|
|
preferredScrollContinuityAnchorInteractionId: collapseSetId,
|
|
)
|
|
}
|
|
|
|
// 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,
|
|
shouldAllowMessageSendActions: Bool,
|
|
) {
|
|
AssertIsOnMainThread()
|
|
|
|
let messageActions = MessageActions.textActions(
|
|
itemViewModel: itemViewModel,
|
|
shouldAllowMessageSendActions: shouldAllowMessageSendActions,
|
|
delegate: self,
|
|
)
|
|
self.presentContextMenu(with: messageActions, focusedOn: cell, andModel: itemViewModel)
|
|
}
|
|
|
|
public func didLongPressMediaViewItem(
|
|
_ cell: CVCell,
|
|
itemViewModel: CVItemViewModelImpl,
|
|
shouldAllowMessageSendActions: Bool,
|
|
) {
|
|
AssertIsOnMainThread()
|
|
|
|
let messageActions = MessageActions.mediaActions(
|
|
itemViewModel: itemViewModel,
|
|
shouldAllowMessageSendActions: shouldAllowMessageSendActions,
|
|
delegate: self,
|
|
)
|
|
self.presentContextMenu(with: messageActions, focusedOn: cell, andModel: itemViewModel)
|
|
}
|
|
|
|
public func didLongPressQuote(
|
|
_ cell: CVCell,
|
|
itemViewModel: CVItemViewModelImpl,
|
|
shouldAllowMessageSendActions: Bool,
|
|
) {
|
|
AssertIsOnMainThread()
|
|
|
|
let messageActions = MessageActions.quotedMessageActions(
|
|
itemViewModel: itemViewModel,
|
|
shouldAllowMessageSendActions: shouldAllowMessageSendActions,
|
|
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,
|
|
shouldAllowMessageSendActions: Bool,
|
|
) {
|
|
AssertIsOnMainThread()
|
|
|
|
let messageActions = MessageActions.mediaActions(
|
|
itemViewModel: itemViewModel,
|
|
shouldAllowMessageSendActions: shouldAllowMessageSendActions,
|
|
delegate: self,
|
|
)
|
|
self.presentContextMenu(with: messageActions, focusedOn: cell, andModel: itemViewModel)
|
|
}
|
|
|
|
public func didLongPressPaymentMessage(
|
|
_ cell: CVCell,
|
|
itemViewModel: CVItemViewModelImpl,
|
|
shouldAllowMessageSendActions: Bool,
|
|
) {
|
|
let messageActions = MessageActions.paymentActions(
|
|
itemViewModel: itemViewModel,
|
|
shouldAllowMessageSendActions: shouldAllowMessageSendActions,
|
|
delegate: self,
|
|
)
|
|
self.presentContextMenu(with: messageActions, focusedOn: cell, andModel: itemViewModel)
|
|
}
|
|
|
|
public func didLongPressPoll(
|
|
_ cell: CVCell,
|
|
itemViewModel: CVItemViewModelImpl,
|
|
shouldAllowMessageSendActions: Bool,
|
|
) {
|
|
let messageActions = MessageActions.pollActions(
|
|
itemViewModel: itemViewModel,
|
|
shouldAllowMessageSendActions: shouldAllowMessageSendActions,
|
|
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 willBecomeVisibleWithSkippedDownloads(_ message: TSMessage) {
|
|
AssertIsOnMainThread()
|
|
|
|
if viewState.manuallyCanceledDownloadsMessageIds.contains(message.uniqueId) {
|
|
// Don't auto-enqueue download if the user has previously manually
|
|
// cancelled downloads for this message.
|
|
return
|
|
}
|
|
|
|
// If any of the failed or pending downloads were enqueued by a Backup
|
|
// restore, immediately attempt to download those attachments.
|
|
Task.detached {
|
|
let attachmentDownloadManager = DependenciesBridge.shared.attachmentDownloadManager
|
|
let attachmentStore = DependenciesBridge.shared.attachmentStore
|
|
let backupAttachmentDownloadStore = DependenciesBridge.shared.backupAttachmentDownloadStore
|
|
let db = DependenciesBridge.shared.db
|
|
|
|
guard let messageRowId = message.sqliteRowId else {
|
|
owsFailDebug("Cannot increase priority for uninserted message!")
|
|
return
|
|
}
|
|
|
|
enum DownloadTypeToEnqueue {
|
|
case thumbnail
|
|
case fullsize
|
|
}
|
|
|
|
let messageTypeToDownload: DownloadTypeToEnqueue? = db.read { tx in
|
|
let referencedAttachments = attachmentStore.fetchReferencedAttachmentsOwnedByMessage(
|
|
messageRowId: messageRowId,
|
|
tx: tx,
|
|
)
|
|
|
|
let downloadTypes: [DownloadTypeToEnqueue] = referencedAttachments.compactMap { referencedAttachment in
|
|
// We only auto-download on appear if we've got a cdn number to try.
|
|
// The user can still manual download if there isn't one (using fallback cdn).
|
|
guard referencedAttachment.attachment.mediaTierInfo?.cdnNumber != nil else {
|
|
return nil
|
|
}
|
|
// Otherwise use presence in the backup download queue to indicate
|
|
// downloadability; this just functionally bumps the priority so the
|
|
// download happens immediately and unconditionally.
|
|
let enqueuedDownload = backupAttachmentDownloadStore.getEnqueuedDownload(
|
|
attachmentRowId: referencedAttachment.attachment.id,
|
|
thumbnail: false,
|
|
tx: tx,
|
|
)
|
|
switch enqueuedDownload?.state {
|
|
case .ineligible:
|
|
if referencedAttachment.attachment.localRelativeFilePathThumbnail != nil {
|
|
return nil
|
|
}
|
|
let enqueuedThumbnail = backupAttachmentDownloadStore.getEnqueuedDownload(
|
|
attachmentRowId: referencedAttachment.attachment.id,
|
|
thumbnail: true,
|
|
tx: tx,
|
|
)
|
|
switch enqueuedThumbnail?.state {
|
|
case .ready:
|
|
return .thumbnail
|
|
case .done, .ineligible, nil:
|
|
// There is already a thumbnail, or never will be a thumbnail to display here.
|
|
// Either way, no need to re-enqueue the thumbnail
|
|
return nil
|
|
}
|
|
case nil, .done:
|
|
return nil
|
|
case .ready:
|
|
return .fullsize
|
|
}
|
|
}
|
|
|
|
if downloadTypes.contains(.fullsize) {
|
|
return .fullsize
|
|
} else if downloadTypes.contains(.thumbnail) {
|
|
return .thumbnail
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
switch messageTypeToDownload {
|
|
case .fullsize:
|
|
await db.awaitableWrite { tx in
|
|
attachmentDownloadManager.enqueueDownloadOfAttachmentsForMessage(
|
|
message,
|
|
priority: .default,
|
|
useThumbnails: false,
|
|
tx: tx,
|
|
)
|
|
}
|
|
case .thumbnail:
|
|
await db.awaitableWrite { tx in
|
|
attachmentDownloadManager.enqueueDownloadOfAttachmentsForMessage(
|
|
message,
|
|
priority: .default,
|
|
useThumbnails: true,
|
|
tx: tx,
|
|
)
|
|
}
|
|
case .none:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
public func didTapSkippedDownloads(_ message: TSMessage) {
|
|
AssertIsOnMainThread()
|
|
|
|
let db = DependenciesBridge.shared.db
|
|
let attachmentDownloadManager = DependenciesBridge.shared.attachmentDownloadManager
|
|
db.write { tx in
|
|
attachmentDownloadManager.enqueueDownloadOfAttachmentsForMessage(
|
|
message,
|
|
priority: .userInitiated,
|
|
useThumbnails: false,
|
|
tx: tx,
|
|
)
|
|
}
|
|
}
|
|
|
|
public func didCancelDownload(_ message: TSMessage, attachmentId: Attachment.IDType) {
|
|
AssertIsOnMainThread()
|
|
|
|
// Record that the user manually canceled download for this message.
|
|
viewState.manuallyCanceledDownloadsMessageIds.insert(message.uniqueId)
|
|
|
|
let db = DependenciesBridge.shared.db
|
|
let attachmentDownloadManager = DependenciesBridge.shared.attachmentDownloadManager
|
|
db.write { tx in
|
|
attachmentDownloadManager.cancelDownload(
|
|
for: attachmentId,
|
|
tx: tx,
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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 shouldAllowMessageSendActionsForItem(_ itemViewModel: CVItemViewModelImpl) -> Bool {
|
|
AssertIsOnMainThread()
|
|
|
|
if thread.isGroupThread, !thread.isLocalUserFullMemberOfThread {
|
|
return false
|
|
}
|
|
if thread.isGroupThread, thread.isTerminatedGroup {
|
|
return false
|
|
}
|
|
if self.threadViewModel.hasPendingMessageRequest {
|
|
return false
|
|
}
|
|
if let groupModelV2 = currentGroupModel as? TSGroupModelV2 {
|
|
if groupModelV2.isAnnouncementsOnly, !groupModelV2.groupMembership.isLocalUserFullMemberAndAdministrator {
|
|
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)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public func didTapUndownloadableMedia() {
|
|
let toast = ToastController(
|
|
text: OWSLocalizedString(
|
|
"UNAVAILABLE_MEDIA_TAP_TOAST",
|
|
comment: "Toast shown when tapping older media that can no longer be downloaded",
|
|
),
|
|
image: .photoSlash,
|
|
)
|
|
let inset = (self.inputToolbar?.height ?? 0) + 16
|
|
toast.presentToastView(from: .bottom, of: self.view, inset: inset)
|
|
}
|
|
|
|
public func didTapUndownloadableGenericFile() {
|
|
let actionSheet = ActionSheetController(
|
|
title: OWSLocalizedString(
|
|
"FILE_UNAVAILABLE_SHEET_TITLE",
|
|
comment: "Title for sheet shown when tapping a document/file that has expired and is unavailable for download",
|
|
),
|
|
message: String.localizedStringWithFormat(
|
|
OWSLocalizedString(
|
|
"UNAVAILABLE_ATTACHMENT_FILE_SHEET_MESSAGE_%d",
|
|
tableName: "PluralAware",
|
|
comment: "Message for sheet shown when tapping a document/file that has expired and is unavailable for download. Embeds {{ the number of days that files are available, e.g. '45' }}.",
|
|
),
|
|
_freeTierMediaDays(),
|
|
),
|
|
)
|
|
actionSheet.addAction(.okay)
|
|
actionSheet.isCancelable = true
|
|
(conversationSplitViewController ?? self).present(actionSheet, animated: true)
|
|
}
|
|
|
|
public func didTapUndownloadableOversizeText() {
|
|
let actionSheet = ActionSheetController(
|
|
title: OWSLocalizedString(
|
|
"OVERSIZE_TEXT_UNAVAILABLE_SHEET_TITLE",
|
|
comment: "Title for sheet shown when tapping oversized text that has expired and is unavailable for download",
|
|
),
|
|
message: String.localizedStringWithFormat(
|
|
OWSLocalizedString(
|
|
"UNAVAILABLE_ATTACHMENT_OVERSIZE_TEXT_SHEET_MESSAGE_%d",
|
|
tableName: "PluralAware",
|
|
comment: "Message for sheet shown when tapping oversized text that has expired and is unavailable for download. Embeds {{ the number of days that files are available, e.g. '45' }}.",
|
|
),
|
|
_freeTierMediaDays(),
|
|
),
|
|
)
|
|
actionSheet.addAction(.okay)
|
|
actionSheet.isCancelable = true
|
|
(conversationSplitViewController ?? self).present(actionSheet, animated: true)
|
|
}
|
|
|
|
public func didTapUndownloadableAudio() {
|
|
let actionSheet = ActionSheetController(
|
|
title: OWSLocalizedString(
|
|
"AUDIO_UNAVAILABLE_SHEET_TITLE",
|
|
comment: "Title for sheet shown when tapping a voice message that has expired and is unavailable for download",
|
|
),
|
|
message: String.localizedStringWithFormat(
|
|
OWSLocalizedString(
|
|
"UNAVAILABLE_ATTACHMENT_AUDIO_SHEET_MESSAGE_%d",
|
|
tableName: "PluralAware",
|
|
comment: "Message for sheet shown when tapping a voice message that has expired and is unavailable for download. Embeds {{ the number of days that files are available, e.g. '45' }}.",
|
|
),
|
|
_freeTierMediaDays(),
|
|
),
|
|
)
|
|
actionSheet.addAction(.okay)
|
|
actionSheet.isCancelable = true
|
|
(conversationSplitViewController ?? self).present(actionSheet, animated: true)
|
|
}
|
|
|
|
public func didTapUndownloadableSticker() {
|
|
let actionSheet = ActionSheetController(
|
|
title: OWSLocalizedString(
|
|
"STICKER_UNAVAILABLE_SHEET_TITLE",
|
|
comment: "Title for sheet shown when tapping a sticker that has expired and is unavailable for download",
|
|
),
|
|
message: String.localizedStringWithFormat(
|
|
OWSLocalizedString(
|
|
"UNAVAILABLE_ATTACHMENT_STICKER_SHEET_MESSAGE_%d",
|
|
tableName: "PluralAware",
|
|
comment: "Message for sheet shown when tapping a sticker that has expired and is unavailable for download. Embeds {{ the number of days that files are available, e.g. '45' }}.",
|
|
),
|
|
_freeTierMediaDays(),
|
|
),
|
|
)
|
|
actionSheet.addAction(.okay)
|
|
actionSheet.isCancelable = true
|
|
(conversationSplitViewController ?? self).present(actionSheet, animated: true)
|
|
}
|
|
|
|
public func didTapBrokenVideo() {
|
|
let toastText = OWSLocalizedString(
|
|
"VIDEO_BROKEN",
|
|
comment: "Toast alert text shown when tapping on a video that cannot be played.",
|
|
)
|
|
presentToastCVC(toastText)
|
|
}
|
|
|
|
private func _freeTierMediaDays() -> UInt64 {
|
|
let db = DependenciesBridge.shared.db
|
|
let subscriptionConfigManager = DependenciesBridge.shared.subscriptionConfigManager
|
|
|
|
return db.read { tx in
|
|
subscriptionConfigManager.backupConfigurationOrDefault(tx: tx).freeTierMediaDays
|
|
}
|
|
}
|
|
|
|
// MARK: - Messages
|
|
|
|
public func didTapBodyMedia(
|
|
itemViewModel: CVItemViewModelImpl,
|
|
attachment: ReferencedAttachment,
|
|
imageView: UIView,
|
|
) {
|
|
AssertIsOnMainThread()
|
|
|
|
dismissKeyBoard()
|
|
|
|
guard
|
|
let pageVC = MediaPageViewController(
|
|
initialMediaAttachment: attachment,
|
|
thread: self.thread,
|
|
spoilerState: self.viewState.spoilerState,
|
|
)
|
|
else {
|
|
return
|
|
}
|
|
|
|
self.present(pageVC, animated: true, completion: nil)
|
|
}
|
|
|
|
public func didTapGenericAttachment(_ attachment: CVComponentGenericAttachment) -> CVAttachmentTapAction {
|
|
AssertIsOnMainThread()
|
|
|
|
let timestamp = Date().ows_millisecondsSince1970
|
|
let attachmentId = attachment.attachmentId
|
|
Task {
|
|
await DependenciesBridge.shared.db.awaitableWrite { tx in
|
|
DependenciesBridge.shared.attachmentStore.markViewedFullscreen(
|
|
attachmentId: attachmentId,
|
|
timestamp: timestamp,
|
|
tx: tx,
|
|
)
|
|
}
|
|
}
|
|
|
|
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(url: URL) {
|
|
AssertIsOnMainThread()
|
|
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) {
|
|
Task {
|
|
guard
|
|
let (_, aci) = await UsernameQuerier().queryForUsernameLink(
|
|
link: usernameLink,
|
|
fromViewController: self,
|
|
)
|
|
else {
|
|
return
|
|
}
|
|
|
|
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 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)?.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.nonPluralLocalizedStringWithFormat(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 didTapSessionRefreshMessage(_ message: TSErrorMessage) {
|
|
dismissKeyBoard()
|
|
|
|
OWSActionSheets.showContactSupportActionSheet(
|
|
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",
|
|
),
|
|
emailFilter: .custom("Signal iOS Session Refresh"),
|
|
fromViewController: self,
|
|
)
|
|
}
|
|
|
|
// See: resendGroupUpdate
|
|
public func didTapResendGroupUpdateForErrorMessage(_ message: TSErrorMessage) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let groupId = try? (self.thread as? TSGroupThread)?.groupIdentifier else {
|
|
owsFailDebug("Invalid thread.")
|
|
return
|
|
}
|
|
Task {
|
|
_ = await GroupManager.sendGroupUpdateMessage(groupId: groupId)
|
|
Logger.info("Group updated, removing group creation error.")
|
|
|
|
await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in
|
|
DependenciesBridge.shared.interactionDeleteManager
|
|
.delete(message, sideEffects: .default(), tx: tx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.nonPluralLocalizedStringWithFormat(
|
|
CallStrings.callBackAlertMessageFormat,
|
|
displayName,
|
|
),
|
|
)
|
|
|
|
alert.addAction(ActionSheetAction(
|
|
title: CallStrings.callBackAlertCallButton,
|
|
style: .default,
|
|
) { [weak self] _ in
|
|
guard let 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.nonPluralLocalizedStringWithFormat(
|
|
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.",
|
|
),
|
|
style: .destructive,
|
|
) { [weak self] _ in
|
|
guard self != nil else { return }
|
|
SSKEnvironment.shared.databaseStorageRef.write { tx in
|
|
SSKEnvironment.shared.blockingManagerRef.addBlockedAddress(
|
|
address,
|
|
blockMode: .local,
|
|
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)
|
|
DependenciesBridge.shared.db.write { tx in
|
|
SupportKeyValueStore().setLastChallengeDate(value: Date(), transaction: tx)
|
|
}
|
|
} else {
|
|
SSKEnvironment.shared.spamChallengeResolverRef.retryPausedMessagesIfReady()
|
|
}
|
|
|
|
}
|
|
|
|
public func didTapFailedMessage(_ message: TSMessage) {
|
|
AssertIsOnMainThread()
|
|
|
|
let promptBuilder = ResendMessagePromptBuilder(
|
|
databaseStorage: SSKEnvironment.shared.databaseStorageRef,
|
|
messageSenderJobQueue: SSKEnvironment.shared.messageSenderJobQueueRef,
|
|
)
|
|
dismissKeyBoard()
|
|
|
|
self.present(promptBuilder.build(for: message, isTerminatedGroup: thread.isTerminatedGroup), animated: true)
|
|
}
|
|
|
|
public func didTapGroupMigrationLearnMore() {
|
|
LegacyGroupLearnMoreUI.presentActionSheet(for: .explainNewGroups, from: self)
|
|
}
|
|
|
|
public func didTapGroupInviteLinkPromotion(groupModel: TSGroupModel) {
|
|
AssertIsOnMainThread()
|
|
|
|
showGroupLinkPromotionActionSheet()
|
|
}
|
|
|
|
public func didTapViewGroupDescription(newGroupDescription: String) {
|
|
AssertIsOnMainThread()
|
|
guard let groupModel = (thread as? TSGroupThread)?.groupModel 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 didTapNameEducation(type: SafetyTipsType) {
|
|
AssertIsOnMainThread()
|
|
present(NameEducationSheet(type: type), animated: true)
|
|
}
|
|
|
|
public func didTapShowConversationSettings() {
|
|
AssertIsOnMainThread()
|
|
|
|
showConversationSettings()
|
|
}
|
|
|
|
public func didTapShowConversationSettingsAndShowMemberRequests() {
|
|
AssertIsOnMainThread()
|
|
|
|
if thread.isTerminatedGroup {
|
|
showUnableToTakeActionInEndedGroupSheet()
|
|
return
|
|
}
|
|
|
|
showConversationSettingsAndShowMemberRequests()
|
|
}
|
|
|
|
public func didTapBlockRequest(
|
|
groupModel: TSGroupModelV2,
|
|
requesterName: String,
|
|
requesterAci: Aci,
|
|
) {
|
|
AssertIsOnMainThread()
|
|
|
|
if thread.isTerminatedGroup {
|
|
showUnableToTakeActionInEndedGroupSheet()
|
|
return
|
|
}
|
|
|
|
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.nonPluralLocalizedStringWithFormat(
|
|
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,
|
|
updateBlock: {
|
|
// If the user in question has canceled their request,
|
|
// this call will still block them.
|
|
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.nonPluralLocalizedStringWithFormat(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.nonPluralLocalizedStringWithFormat(
|
|
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.nonPluralLocalizedStringWithFormat(
|
|
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.nonPluralLocalizedStringWithFormat(
|
|
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.nonPluralLocalizedStringWithFormat(
|
|
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)
|
|
}
|
|
|
|
public func didTapViewVotes(poll: OWSPoll) {
|
|
let message: TSMessage? = DependenciesBridge.shared.db.read { tx in
|
|
InteractionFinder.fetch(rowId: poll.interactionId, transaction: tx) as? TSMessage
|
|
}
|
|
|
|
guard let message else {
|
|
return
|
|
}
|
|
|
|
let pollDetails = PollDetailsViewController(
|
|
poll: poll,
|
|
message: message,
|
|
pollManager: DependenciesBridge.shared.pollMessageManager,
|
|
db: DependenciesBridge.shared.db,
|
|
databaseChangeObserver: DependenciesBridge.shared.databaseChangeObserver,
|
|
isTerminatedGroup: thread.isTerminatedGroup,
|
|
)
|
|
pollDetails.delegate = self
|
|
self.present(OWSNavigationController(rootViewController: pollDetails), animated: true)
|
|
}
|
|
|
|
public func didTapViewPoll(pollInteractionUniqueId: String) {
|
|
ensureInteractionLoadedThenScrollToInteraction(
|
|
pollInteractionUniqueId,
|
|
alignment: .centerIfNotEntirelyOnScreen,
|
|
isAnimated: true,
|
|
)
|
|
}
|
|
|
|
public func didTapVoteOnPoll(poll: OWSPoll, optionIndex: UInt32, isUnvote: Bool) {
|
|
guard !threadViewModel.hasPendingMessageRequest else {
|
|
return
|
|
}
|
|
|
|
if let groupThread = self.thread as? TSGroupThread, !groupThread.groupModel.groupMembership.isLocalUserFullMember || groupThread.isTerminatedGroup {
|
|
return
|
|
}
|
|
|
|
do {
|
|
try DependenciesBridge.shared.db.write { tx in
|
|
let targetPoll = DependenciesBridge.shared.interactionStore.fetchInteraction(
|
|
rowId: poll.interactionId,
|
|
tx: tx,
|
|
)
|
|
|
|
guard let targetPoll else {
|
|
return
|
|
}
|
|
|
|
guard
|
|
let pollVoteMessage = try DependenciesBridge.shared.pollMessageManager.applyPendingVoteToLocalState(
|
|
pollInteraction: targetPoll,
|
|
optionIndex: optionIndex,
|
|
isUnvote: isUnvote,
|
|
thread: thread,
|
|
tx: tx,
|
|
)
|
|
else {
|
|
Logger.error("Unable to update local poll state with votes")
|
|
return
|
|
}
|
|
|
|
// Touch message so it reloads to show updated vote state.
|
|
SSKEnvironment.shared.databaseStorageRef.touch(interaction: targetPoll, shouldReindex: false, tx: tx)
|
|
|
|
let preparedMessage = PreparedOutgoingMessage.preprepared(
|
|
transientMessageWithoutAttachments: pollVoteMessage,
|
|
)
|
|
|
|
SSKEnvironment.shared.messageSenderJobQueueRef.add(
|
|
message: preparedMessage,
|
|
transaction: tx,
|
|
)
|
|
}
|
|
} catch {
|
|
Logger.error("Unable to update local poll state with votes: \(error)")
|
|
}
|
|
}
|
|
|
|
public func didTapViewPinnedMessage(pinnedMessageUniqueId: String) {
|
|
ensureInteractionLoadedThenScrollToInteraction(
|
|
pinnedMessageUniqueId,
|
|
alignment: .centerIfNotEntirelyOnScreen,
|
|
isAnimated: true,
|
|
)
|
|
}
|
|
|
|
public func didTapSafetyTips() {
|
|
let viewController = SafetyTipsViewController(
|
|
mode: .messageRequest,
|
|
primaryButton: SafetyTipsViewController.Button(
|
|
title: CommonStrings.viewMoreButton,
|
|
action: { [weak self] in
|
|
let viewController = MoreSafetyTipsViewController()
|
|
self?.present(viewController, animated: true)
|
|
},
|
|
),
|
|
)
|
|
present(viewController, animated: true)
|
|
}
|
|
}
|
|
|
|
// MARK: - OWSNavigationChildController
|
|
|
|
extension ConversationViewController: OWSNavigationChildController {
|
|
public var shouldCancelNavigationBack: Bool {
|
|
// If presentedViewController is not nil, it means we haven't finished dismissing
|
|
// and should not allow the back navigation gesture.
|
|
return presentedViewController != nil
|
|
}
|
|
}
|
|
|
|
// MARK: - PollDetailsViewControllerDelegate
|
|
|
|
extension ConversationViewController: PollDetailsViewControllerDelegate {
|
|
public func terminatePoll(poll: OWSPoll) {
|
|
do {
|
|
try DependenciesBridge.shared.pollMessageManager.sendPollTerminateMessage(poll: poll, thread: thread)
|
|
} catch {
|
|
Logger.error("Failed to end poll: \(error)")
|
|
}
|
|
}
|
|
}
|