480 lines
18 KiB
Swift
480 lines
18 KiB
Swift
//
|
|
// Copyright 2021 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import AVFoundation
|
|
import Foundation
|
|
public import LibSignalClient
|
|
public import SignalServiceKit
|
|
public import SignalUI
|
|
|
|
extension ConversationViewController: AttachmentApprovalViewControllerDelegate {
|
|
|
|
public func attachmentApproval(
|
|
_ attachmentApproval: AttachmentApprovalViewController,
|
|
didApproveAttachments approvedAttachments: ApprovedAttachments,
|
|
messageBody: MessageBody?,
|
|
) {
|
|
ModalActivityIndicatorViewController.present(
|
|
fromViewController: attachmentApproval,
|
|
title: CommonStrings.preparingModal,
|
|
asyncBlock: { modal in
|
|
await self.sendAttachments(
|
|
approvedAttachments,
|
|
messageBody: messageBody,
|
|
from: attachmentApproval,
|
|
attachmentLimits: attachmentApproval.attachmentLimits,
|
|
)
|
|
modal.dismiss(completion: {
|
|
self.dismiss(animated: true)
|
|
})
|
|
},
|
|
)
|
|
}
|
|
|
|
public func attachmentApprovalDidCancel() {
|
|
dismiss(animated: true, completion: nil)
|
|
self.popKeyBoard()
|
|
}
|
|
|
|
public func attachmentApproval(
|
|
_ attachmentApproval: AttachmentApprovalViewController,
|
|
didChangeMessageBody newMessageBody: MessageBody?,
|
|
) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard hasViewWillAppearEverBegun else {
|
|
owsFailDebug("InputToolbar not yet ready.")
|
|
return
|
|
}
|
|
guard let inputToolbar else {
|
|
return
|
|
}
|
|
inputToolbar.setMessageBody(newMessageBody, animated: false)
|
|
}
|
|
|
|
public func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachmentApprovalItem: AttachmentApprovalItem) { }
|
|
|
|
public func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController) { }
|
|
|
|
public func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeViewOnceState isViewOnce: Bool) { }
|
|
}
|
|
|
|
extension ConversationViewController: AttachmentApprovalViewControllerDataSource {
|
|
|
|
public var attachmentApprovalTextInputContextIdentifier: String? { textInputContextIdentifier }
|
|
|
|
public var attachmentApprovalRecipientNames: [String] {
|
|
let displayName = SSKEnvironment.shared.databaseStorageRef.read { tx in SSKEnvironment.shared.contactManagerRef.displayName(for: thread, transaction: tx) }
|
|
return [displayName]
|
|
}
|
|
|
|
public func attachmentApprovalMentionableAcis(tx: DBReadTransaction) -> [Aci] {
|
|
supportsMentions ? thread.recipientAddresses(with: tx).compactMap(\.aci) : []
|
|
}
|
|
|
|
public func attachmentApprovalMentionCacheInvalidationKey() -> String {
|
|
return thread.uniqueId
|
|
}
|
|
}
|
|
|
|
extension ConversationViewController: UIAdaptivePresentationControllerDelegate {
|
|
public func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
|
|
switch presentationController.presentedViewController {
|
|
case is GifPickerNavigationViewController, is UIDocumentPickerViewController:
|
|
self.openAttachmentKeyboard()
|
|
case let navigationController as OWSNavigationController:
|
|
switch navigationController.viewControllers.first {
|
|
case is ContactPickerViewController, is LocationPicker:
|
|
self.openAttachmentKeyboard()
|
|
default:
|
|
break
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ConversationViewController: ContactPickerDelegate {
|
|
|
|
public func contactPickerDidCancel(_: ContactPickerViewController) {
|
|
dismiss(animated: true, completion: nil)
|
|
self.openAttachmentKeyboard()
|
|
}
|
|
|
|
public func contactPicker(_ contactPicker: ContactPickerViewController, didSelect systemContact: SystemContact) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let cnContact = SSKEnvironment.shared.contactManagerRef.cnContact(withId: systemContact.cnContactId) else {
|
|
owsFailDebug("Could not load system contact.")
|
|
return
|
|
}
|
|
|
|
let contactShareDraft = SSKEnvironment.shared.databaseStorageRef.read { tx in
|
|
return ContactShareDraft.load(
|
|
cnContact: cnContact,
|
|
signalContact: systemContact,
|
|
contactManager: SSKEnvironment.shared.contactManagerRef,
|
|
phoneNumberUtil: SSKEnvironment.shared.phoneNumberUtilRef,
|
|
profileManager: SSKEnvironment.shared.profileManagerRef,
|
|
recipientManager: DependenciesBridge.shared.recipientManager,
|
|
tsAccountManager: DependenciesBridge.shared.tsAccountManager,
|
|
tx: tx,
|
|
)
|
|
}
|
|
|
|
let approveContactShare = ContactShareViewController(contactShareDraft: contactShareDraft)
|
|
approveContactShare.shareDelegate = self
|
|
guard let navigationController = contactPicker.navigationController else {
|
|
owsFailDebug("Missing contactsPicker.navigationController.")
|
|
return
|
|
}
|
|
navigationController.pushViewController(approveContactShare, animated: true)
|
|
}
|
|
|
|
public func contactPicker(_: ContactPickerViewController, didSelectMultiple systemContacts: [SystemContact]) {
|
|
owsFailDebug("Multiple selection not allowed.")
|
|
dismiss(animated: true, completion: nil)
|
|
}
|
|
|
|
public func contactPicker(_: ContactPickerViewController, shouldSelect systemContact: SystemContact) -> Bool {
|
|
// Any reason to preclude contacts?
|
|
return true
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ConversationViewController: ContactShareViewControllerDelegate {
|
|
|
|
public func contactShareViewController(
|
|
_ viewController: ContactShareViewController,
|
|
didApproveContactShare contactShare:
|
|
ContactShareDraft,
|
|
) {
|
|
dismiss(animated: true) {
|
|
self.send(contactShareDraft: contactShare)
|
|
}
|
|
}
|
|
|
|
public func contactShareViewControllerDidCancel(_ viewController: ContactShareViewController) {
|
|
dismiss(animated: true, completion: nil)
|
|
}
|
|
|
|
public func titleForContactShareViewController(_ viewController: ContactShareViewController) -> String? {
|
|
return nil
|
|
}
|
|
|
|
public func recipientsDescriptionForContactShareViewController(_ viewController: ContactShareViewController) -> String? {
|
|
return SSKEnvironment.shared.databaseStorageRef.read { transaction in
|
|
SSKEnvironment.shared.contactManagerRef.displayName(for: self.thread, transaction: transaction)
|
|
}
|
|
}
|
|
|
|
public func approvalModeForContactShareViewController(_ viewController: ContactShareViewController) -> ApprovalMode {
|
|
return .send
|
|
}
|
|
|
|
private func send(contactShareDraft: ContactShareDraft) {
|
|
let thread = self.thread
|
|
SSKEnvironment.shared.databaseStorageRef.asyncWrite { transaction in
|
|
let didAddToProfileWhitelist = ThreadUtil.addThreadToProfileWhitelistIfEmptyOrPendingRequest(
|
|
thread,
|
|
setDefaultTimerIfNecessary: true,
|
|
tx: transaction,
|
|
)
|
|
transaction.addSyncCompletion {
|
|
Task { @MainActor in
|
|
ThreadUtil.enqueueMessage(withContactShare: contactShareDraft, thread: thread)
|
|
self.messageWasSent()
|
|
|
|
if didAddToProfileWhitelist {
|
|
self.ensureBannerState()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ConversationViewController: ConversationHeaderViewDelegate {
|
|
func didTapConversationHeaderView(_ conversationHeaderView: ConversationHeaderView) {
|
|
AssertIsOnMainThread()
|
|
|
|
showConversationSettings()
|
|
}
|
|
|
|
func didTapConversationHeaderViewAvatar(_ conversationHeaderView: ConversationHeaderView) {
|
|
AssertIsOnMainThread()
|
|
|
|
if conversationHeaderView.avatarView.configuration.hasStoriesToDisplay {
|
|
let vc = StoryPageViewController(
|
|
context: thread.storyContext,
|
|
spoilerState: spoilerState,
|
|
)
|
|
present(vc, animated: true)
|
|
} else {
|
|
showConversationSettings()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ConversationViewController: ConversationInputTextViewDelegate {
|
|
public func didAttemptAttachmentPaste() {
|
|
let attachmentLimits = OutgoingAttachmentLimits.currentLimits()
|
|
|
|
// If trying to paste a sticker, forego anything async since
|
|
// the pasteboard will be cleared as soon as paste() exits.
|
|
if PasteboardAttachment.hasStickerAttachment() {
|
|
do {
|
|
self.didPasteAttachments(
|
|
[try PasteboardAttachment.loadPreviewableStickerAttachment()].compacted(),
|
|
attachmentLimits: attachmentLimits,
|
|
)
|
|
} catch {
|
|
self.showErrorAlert(attachmentError: error as? SignalAttachmentError)
|
|
}
|
|
return
|
|
}
|
|
|
|
ModalActivityIndicatorViewController.present(
|
|
fromViewController: self,
|
|
title: OWSLocalizedString(
|
|
"ATTACHMENT_PASTING",
|
|
comment: "Displayed in a full screen modal when app is processing media that was pasted into message compose field.",
|
|
),
|
|
asyncBlock: { modal in
|
|
do {
|
|
let attachments = try await PasteboardAttachment.loadPreviewableAttachments(attachmentLimits: attachmentLimits)
|
|
modal.dismiss {
|
|
// Note: attachment array might be nil at this point; that's fine.
|
|
self.didPasteAttachments(attachments, attachmentLimits: attachmentLimits)
|
|
}
|
|
} catch {
|
|
modal.dismiss {
|
|
self.showErrorAlert(attachmentError: error as? SignalAttachmentError)
|
|
}
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
func didPasteAttachments(
|
|
_ attachments: [PreviewableAttachment]?,
|
|
attachmentLimits: OutgoingAttachmentLimits,
|
|
) {
|
|
AssertIsOnMainThread()
|
|
|
|
guard let attachments, attachments.count > 0 else {
|
|
owsFailDebug("Missing attachments")
|
|
return
|
|
}
|
|
|
|
// If the thing we pasted is sticker-like, send it immediately
|
|
// and render it borderless.
|
|
if attachments.count == 1, let a = attachments.first, a.rawValue.isBorderless {
|
|
Task {
|
|
await self.sendAttachments(
|
|
ApprovedAttachments(nonViewOnceAttachments: [a], imageQuality: .standard),
|
|
messageBody: nil,
|
|
from: self,
|
|
attachmentLimits: attachmentLimits,
|
|
)
|
|
}
|
|
} else {
|
|
dismissKeyBoard()
|
|
showApprovalDialog(forAttachments: attachments, attachmentLimits: attachmentLimits)
|
|
}
|
|
}
|
|
|
|
public func didAttemptAccountEntropyPoolPaste(completePaste: @escaping () -> Void) {
|
|
let warningSheet = BackupNeverShareRecoveryKeySheet(
|
|
primaryButton: .dismissing(
|
|
title: OWSLocalizedString(
|
|
"CONVERSATION_INPUT_PASTE_RECOVERY_KEY_WARNING_SHEET_DO_NOT_SHARE_BUTTON_TITLE",
|
|
comment: "Title for the button in a warning sheet shown before the user pastes their 'Recovery Key' into the chat input text field that dismisses the sheet without pasting the key.",
|
|
),
|
|
),
|
|
secondaryButton: HeroSheetViewController.Button(
|
|
title: OWSLocalizedString(
|
|
"CONVERSATION_INPUT_PASTE_RECOVERY_KEY_WARNING_SHEET_SHARE_BUTTON_TITLE",
|
|
comment: "Title for the button in a warning sheet shown before the user pastes their 'Recovery Key' into the chat input text field that acknowledges the warning and proceeds with the paste.",
|
|
),
|
|
style: .secondaryDestructive,
|
|
action: .custom({ [weak self] sheet in
|
|
sheet.dismiss(animated: true) {
|
|
let doubleWarningSheet = ActionSheetController(
|
|
title: OWSLocalizedString(
|
|
"CONVERSATION_INPUT_PASTE_RECOVERY_KEY_CONFIRM_SHEET_TITLE",
|
|
comment: "Title for a second confirmation sheet shown after the user opts to paste their 'Recovery Key' into the chat input text field anyway.",
|
|
),
|
|
message: OWSLocalizedString(
|
|
"CONVERSATION_INPUT_PASTE_RECOVERY_KEY_CONFIRM_SHEET_MESSAGE",
|
|
comment: "Message body for a second confirmation sheet shown after the user opts to paste their 'Recovery Key' into the chat input text field anyway, warning them not to share it.",
|
|
),
|
|
)
|
|
doubleWarningSheet.addAction(ActionSheetAction(
|
|
title: OWSLocalizedString(
|
|
"CONVERSATION_INPUT_PASTE_RECOVERY_KEY_CONFIRM_SHEET_PASTE_BUTTON_TITLE",
|
|
comment: "Title for the destructive button in a second confirmation sheet shown after the user opts to paste their 'Recovery Key' into the chat input text field anyway, that proceeds with the paste.",
|
|
),
|
|
style: .destructive,
|
|
handler: { _ in
|
|
completePaste()
|
|
},
|
|
))
|
|
doubleWarningSheet.addAction(ActionSheetAction(
|
|
title: OWSLocalizedString(
|
|
"CONVERSATION_INPUT_PASTE_RECOVERY_KEY_CONFIRM_SHEET_DO_NOT_SHARE_BUTTON_TITLE",
|
|
comment: "Title for the button in a second confirmation sheet shown after the user opts to paste their 'Recovery Key' into the chat input text field anyway, that dismisses the sheet without pasting the key.",
|
|
),
|
|
))
|
|
|
|
self?.present(doubleWarningSheet, animated: true)
|
|
}
|
|
}),
|
|
),
|
|
)
|
|
|
|
present(warningSheet, animated: true)
|
|
}
|
|
|
|
public func inputTextViewSendMessagePressed() {
|
|
AssertIsOnMainThread()
|
|
|
|
sendButtonPressed()
|
|
}
|
|
|
|
public func textViewDidChange(_ textView: UITextView) {
|
|
AssertIsOnMainThread()
|
|
|
|
if textView.text.strippedOrNil != nil {
|
|
SSKEnvironment.shared.typingIndicatorsRef.didStartTypingOutgoingInput(inThread: thread)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ConversationViewController: ConversationSearchControllerDelegate {
|
|
public func didDismissSearchController(_ searchController: UISearchController) {
|
|
AssertIsOnMainThread()
|
|
|
|
// This method is called not only when the user taps "cancel" in the searchController, but also
|
|
// called when the searchController was dismissed because we switched to another uiMode, like
|
|
// "selection". We only want to revert to "normal" in the former case - when the user tapped
|
|
// "cancel" in the search controller. Otherwise, if we're already in another mode, like
|
|
// "selection", we want to stay in that mode.
|
|
if uiMode == .search {
|
|
uiMode = .normal
|
|
}
|
|
}
|
|
|
|
public func conversationSearchController(
|
|
_ conversationSearchController: ConversationSearchController,
|
|
didUpdateSearchResults resultSet: ConversationScreenSearchResultSet?,
|
|
) {
|
|
AssertIsOnMainThread()
|
|
|
|
self.lastSearchedText = resultSet?.searchText
|
|
loadCoordinator.enqueueReload()
|
|
}
|
|
|
|
public func conversationSearchController(
|
|
_ conversationSearchController: ConversationSearchController,
|
|
didSelectMessageId messageId: String,
|
|
) {
|
|
AssertIsOnMainThread()
|
|
|
|
ensureInteractionLoadedThenScrollToInteraction(
|
|
messageId,
|
|
onScreenPercentage: 1,
|
|
alignment: .centerIfNotEntirelyOnScreen,
|
|
isAnimated: true,
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ConversationViewController: ConversationCollectionViewDelegate {
|
|
public func collectionViewWillChangeSize(from oldSize: CGSize, to newSize: CGSize) {
|
|
AssertIsOnMainThread()
|
|
|
|
// Do nothing.
|
|
}
|
|
|
|
public func collectionViewDidChangeSize(from oldSize: CGSize, to newSize: CGSize) {
|
|
AssertIsOnMainThread()
|
|
|
|
if oldSize.width != newSize.width {
|
|
resetForSizeOrOrientationChange()
|
|
}
|
|
|
|
updateScrollingContent()
|
|
}
|
|
|
|
public func collectionViewWillAnimate() {
|
|
AssertIsOnMainThread()
|
|
|
|
scrollingAnimationDidStart()
|
|
}
|
|
|
|
public func collectionViewShouldRecognizeSimultaneously(with otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
return otherGestureRecognizer == collectionViewContextMenuGestureRecognizer
|
|
}
|
|
|
|
public func scrollingAnimationDidStart() {
|
|
AssertIsOnMainThread()
|
|
|
|
// scrollingAnimationStartDate blocks landing of loads, so we must ensure
|
|
// that it is always cleared in a timely way, even if the animation
|
|
// is cancelled. Wait no more than N seconds.
|
|
scrollingAnimationCompletionTimer?.invalidate()
|
|
scrollingAnimationCompletionTimer = .scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in
|
|
self?.scrollingAnimationCompletionTimerDidFire()
|
|
}
|
|
}
|
|
|
|
private func scrollingAnimationCompletionTimerDidFire() {
|
|
AssertIsOnMainThread()
|
|
|
|
Logger.warn("Scrolling animation did not complete in a timely way.")
|
|
|
|
// scrollingAnimationCompletionTimer should already have been cleared,
|
|
// but we need to ensure that it is cleared in a timely way.
|
|
scrollingAnimationDidComplete()
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ConversationViewController {
|
|
func scrollingAnimationDidComplete() {
|
|
AssertIsOnMainThread()
|
|
|
|
scrollingAnimationCompletionTimer?.invalidate()
|
|
scrollingAnimationCompletionTimer = nil
|
|
|
|
autoLoadMoreIfNecessary()
|
|
|
|
performMessageHighlightAnimationIfNeeded()
|
|
|
|
focusVoiceoverElementAfterScroll()
|
|
}
|
|
|
|
func resetForSizeOrOrientationChange() {
|
|
AssertIsOnMainThread()
|
|
|
|
updateConversationStyle()
|
|
}
|
|
}
|