mirror of
https://github.com/lux-tel/session-ios.git
synced 2026-10-30 20:38:32 +00:00
• Added a few missing conversation updated events • Fixed an issue where deleting the group wasn't deleting for everyone
3387 lines
156 KiB
Swift
3387 lines
156 KiB
Swift
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
|
|
|
import UIKit
|
|
import AVKit
|
|
import AVFoundation
|
|
import Combine
|
|
import CoreServices
|
|
import Photos
|
|
import PhotosUI
|
|
import UniformTypeIdentifiers
|
|
import GRDB
|
|
import SessionUIKit
|
|
import SessionMessagingKit
|
|
import SessionUtilitiesKit
|
|
import SignalUtilitiesKit
|
|
import SwiftUI
|
|
import SessionNetworkingKit
|
|
|
|
extension ConversationVC:
|
|
InputViewDelegate,
|
|
MessageCellDelegate,
|
|
ContextMenuActionDelegate,
|
|
SendMediaNavDelegate,
|
|
AttachmentApprovalViewControllerDelegate,
|
|
GifPickerViewControllerDelegate,
|
|
UIGestureRecognizerDelegate
|
|
{
|
|
// MARK: - Open Settings
|
|
|
|
@MainActor @objc func handleTitleViewTapped() {
|
|
// Don't take the user to settings for unapproved threads
|
|
guard !viewModel.state.threadInfo.requiresApproval else { return }
|
|
|
|
openSettingsFromTitleView()
|
|
}
|
|
|
|
// Handle taps outside of tableview cell to dismiss keyboard
|
|
@MainActor @objc func dismissKeyboardOnMessageListTap(_ recognizer: UITapGestureRecognizer) {
|
|
/// If the tap was inside the input then we **don't** want to dismiss the keyboard (the user should be able to interact with their
|
|
/// current text, or tap the buttons without the keyboard being dismissed)
|
|
let location: CGPoint = recognizer.location(in: self.snInputView)
|
|
|
|
guard !snInputView.bounds.contains(location) else { return }
|
|
|
|
_ = self.snInputView.resignFirstResponder()
|
|
}
|
|
|
|
@MainActor func openSettingsFromTitleView() {
|
|
// If we shouldn't be able to access settings then disable the title view shortcuts
|
|
guard viewModel.state.threadInfo.canAccessSettings else { return }
|
|
|
|
switch (titleView.currentLabelType, viewModel.state.threadVariant, viewModel.state.threadInfo.groupInfo?.currentUserRole) {
|
|
case (.userCount, .group, .admin), (.userCount, .legacyGroup, .admin):
|
|
let viewController = SessionTableViewController(
|
|
viewModel: EditGroupViewModel(
|
|
threadId: self.viewModel.state.threadId,
|
|
using: self.viewModel.dependencies
|
|
)
|
|
)
|
|
navigationController?.pushViewController(viewController, animated: true)
|
|
|
|
case (.userCount, .group, .some), (.userCount, .legacyGroup, .some):
|
|
let viewController: UIViewController = ThreadSettingsViewModel.createMemberListViewController(
|
|
threadId: self.viewModel.state.threadId,
|
|
transitionToConversation: { [weak self, dependencies = viewModel.dependencies] maybeThreadInfo in
|
|
guard let threadInfo: ConversationInfoViewModel = maybeThreadInfo else {
|
|
self?.navigationController?.present(
|
|
ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "theError".localized(),
|
|
body: .text("errorUnknown".localized()),
|
|
cancelTitle: "okay".localized(),
|
|
cancelStyle: .alert_text
|
|
)
|
|
),
|
|
animated: true,
|
|
completion: nil
|
|
)
|
|
return
|
|
}
|
|
|
|
self?.navigationController?.pushViewController(
|
|
ConversationVC(
|
|
threadInfo: threadInfo,
|
|
focusedInteractionInfo: nil,
|
|
using: dependencies
|
|
),
|
|
animated: true
|
|
)
|
|
},
|
|
using: viewModel.dependencies
|
|
)
|
|
navigationController?.pushViewController(viewController, animated: true)
|
|
|
|
case (.disappearingMessageSetting, _, _):
|
|
guard let config: DisappearingMessagesConfiguration = self.viewModel.state.threadInfo.disappearingMessagesConfiguration else {
|
|
return openSettings()
|
|
}
|
|
|
|
let viewController = SessionTableViewController(
|
|
viewModel: ThreadDisappearingMessagesSettingsViewModel(
|
|
threadId: self.viewModel.state.threadId,
|
|
threadVariant: self.viewModel.state.threadVariant,
|
|
currentUserRole: self.viewModel.state.threadInfo.groupInfo?.currentUserRole,
|
|
config: config,
|
|
using: self.viewModel.dependencies
|
|
)
|
|
)
|
|
navigationController?.pushViewController(viewController, animated: true)
|
|
|
|
case (.userCount, _, _), (.none, _, _), (.notificationSettings, _, _): openSettings()
|
|
}
|
|
}
|
|
|
|
@objc func openSettings() {
|
|
let viewController = SessionTableViewController(
|
|
viewModel: ThreadSettingsViewModel(
|
|
threadInfo: self.viewModel.state.threadInfo,
|
|
didTriggerSearch: { [weak self] in
|
|
DispatchQueue.main.async {
|
|
self?.hasPendingInputKeyboardPresentationEvent = true
|
|
self?.showSearchUI()
|
|
self?.popAllConversationSettingsViews()
|
|
}
|
|
},
|
|
using: self.viewModel.dependencies
|
|
)
|
|
)
|
|
navigationController?.pushViewController(viewController, animated: true)
|
|
}
|
|
|
|
// MARK: - External keyboard
|
|
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
|
|
for press in presses {
|
|
guard let key = press.key else { continue }
|
|
|
|
if key.keyCode == .keyboardReturnOrEnter && key.modifierFlags.isEmpty {
|
|
// Enter only -> send
|
|
handleSendButtonTapped()
|
|
return
|
|
}
|
|
}
|
|
super.pressesBegan(presses, with: event)
|
|
}
|
|
|
|
// MARK: - Call
|
|
|
|
@objc func startCall(_ sender: Any?) {
|
|
guard !viewModel.state.threadInfo.isBlocked else {
|
|
self.showBlockedModalIfNeeded()
|
|
return
|
|
}
|
|
guard viewModel.dependencies.mutate(cache: .libSession, { $0.get(.areCallsEnabled) }) else {
|
|
let confirmationModal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "callsPermissionsRequired".localized(),
|
|
body: .text("callsPermissionsRequiredDescription".localized()),
|
|
confirmTitle: "sessionSettings".localized(),
|
|
dismissOnConfirm: false // Custom dismissal logic
|
|
) { [weak self, dependencies = viewModel.dependencies] _ in
|
|
self?.dismiss(animated: true) {
|
|
let navController: UINavigationController = StyledNavigationController(
|
|
rootViewController: SessionTableViewController(
|
|
viewModel: PrivacySettingsViewModel(
|
|
shouldShowCloseButton: true,
|
|
shouldAutomaticallyShowCallModal: true,
|
|
using: dependencies
|
|
)
|
|
)
|
|
)
|
|
navController.modalPresentationStyle = .fullScreen
|
|
self?.present(navController, animated: true, completion: nil)
|
|
}
|
|
}
|
|
)
|
|
|
|
self.navigationController?.present(confirmationModal, animated: true, completion: nil)
|
|
return
|
|
}
|
|
|
|
guard Permissions.microphone == .granted else {
|
|
let confirmationModal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "permissionsRequired".localized(),
|
|
body: .text("permissionsMicrophoneAccessRequiredCallsIos".localized()),
|
|
showCondition: .disabled,
|
|
confirmTitle: "sessionSettings".localized(),
|
|
onConfirm: { _ in
|
|
UIApplication.shared.openSystemSettings()
|
|
}
|
|
)
|
|
)
|
|
|
|
self.navigationController?.present(confirmationModal, animated: true, completion: nil)
|
|
return
|
|
}
|
|
|
|
guard Permissions.localNetwork(using: viewModel.dependencies) == .granted else {
|
|
let confirmationModal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "permissionsRequired".localized(),
|
|
body: .text("permissionsLocalNetworkAccessRequiredCallsIos".localized()),
|
|
showCondition: .disabled,
|
|
confirmTitle: "sessionSettings".localized(),
|
|
onConfirm: { _ in
|
|
UIApplication.shared.openSystemSettings()
|
|
}
|
|
)
|
|
)
|
|
|
|
self.navigationController?.present(confirmationModal, animated: true, completion: nil)
|
|
return
|
|
}
|
|
|
|
guard
|
|
Permissions.microphone == .granted,
|
|
self.viewModel.state.threadVariant == .contact,
|
|
viewModel.dependencies[singleton: .callManager].currentCall == nil
|
|
else { return }
|
|
|
|
let call: SessionCall = SessionCall(
|
|
for: self.viewModel.state.threadId,
|
|
contactName: self.viewModel.state.threadInfo.displayName.deformatted(),
|
|
uuid: UUID().uuidString.lowercased(),
|
|
mode: .offer,
|
|
using: viewModel.dependencies
|
|
)
|
|
let callVC = CallVC(for: call, using: viewModel.dependencies)
|
|
present(callVC, animated: true, completion: nil)
|
|
}
|
|
|
|
// MARK: - Blocking
|
|
|
|
@MainActor @discardableResult func showBlockedModalIfNeeded() -> Bool {
|
|
guard
|
|
self.viewModel.state.threadVariant == .contact &&
|
|
self.viewModel.state.threadInfo.isBlocked
|
|
else { return false }
|
|
|
|
let confirmationModal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: String(
|
|
format: "blockUnblock".localized(),
|
|
self.viewModel.state.threadInfo.displayName.deformatted()
|
|
),
|
|
body: .attributedText(
|
|
"blockUnblockName"
|
|
.put(key: "name", value: viewModel.state.threadInfo.displayName.deformatted())
|
|
.localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize))
|
|
),
|
|
confirmTitle: "blockUnblock".localized(),
|
|
confirmStyle: .danger,
|
|
cancelStyle: .alert_text,
|
|
dismissOnConfirm: false // Custom dismissal logic
|
|
) { [weak self] _ in
|
|
self?.viewModel.unblockContact()
|
|
self?.dismiss(animated: true, completion: nil)
|
|
}
|
|
)
|
|
present(confirmationModal, animated: true, completion: nil)
|
|
|
|
return true
|
|
}
|
|
|
|
// MARK: - UIGestureRecognizerDelegate
|
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
return true
|
|
}
|
|
|
|
// MARK: - SendMediaNavDelegate
|
|
|
|
func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController?) {
|
|
dismiss(animated: true, completion: nil)
|
|
}
|
|
|
|
func sendMediaNav(
|
|
_ sendMediaNavigationController: SendMediaNavigationController,
|
|
didApproveAttachments attachments: [PendingAttachment],
|
|
forThreadId threadId: String,
|
|
threadVariant: SessionThread.Variant,
|
|
messageText: String?,
|
|
quoteViewModel: QuoteViewModel?
|
|
) {
|
|
sendMessage(
|
|
text: (messageText ?? ""),
|
|
attachments: attachments,
|
|
quoteViewModel: quoteViewModel
|
|
)
|
|
resetMentions()
|
|
|
|
dismiss(animated: true)
|
|
}
|
|
|
|
func sendMediaNavInitialMessageText(_ sendMediaNavigationController: SendMediaNavigationController) -> String? {
|
|
return snInputView.text
|
|
}
|
|
|
|
func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeMessageText newMessageText: String?) {
|
|
snInputView.text = (newMessageText ?? "")
|
|
}
|
|
|
|
// MARK: - AttachmentApprovalViewControllerDelegate
|
|
|
|
func attachmentApproval(
|
|
_ attachmentApproval: AttachmentApprovalViewController,
|
|
didApproveAttachments attachments: [PendingAttachment],
|
|
forThreadId threadId: String,
|
|
threadVariant: SessionThread.Variant,
|
|
messageText: String?,
|
|
quoteViewModel: QuoteViewModel?
|
|
) {
|
|
sendMessage(text: (messageText ?? ""), attachments: attachments, quoteViewModel: quoteViewModel)
|
|
resetMentions()
|
|
|
|
dismiss(animated: true)
|
|
}
|
|
|
|
func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) {
|
|
dismiss(animated: true, completion: nil)
|
|
}
|
|
|
|
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeMessageText newMessageText: String?) {
|
|
snInputView.text = (newMessageText ?? "")
|
|
}
|
|
|
|
func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: PendingAttachment) {
|
|
}
|
|
|
|
func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController) {
|
|
}
|
|
|
|
// MARK: - Attachment Buttons
|
|
|
|
func handleGIFButtonTapped() {
|
|
guard viewModel.dependencies.mutate(cache: .libSession, { $0.get(.isGiphyEnabled) }) else {
|
|
let modal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "giphyWarning".localized(),
|
|
body: .text(
|
|
"giphyWarningDescription"
|
|
.put(key: "app_name", value: Constants.app_name)
|
|
.localized()
|
|
),
|
|
confirmTitle: "theContinue".localized()
|
|
) { [weak self, dependencies = viewModel.dependencies] _ in
|
|
dependencies.setAsync(.isGiphyEnabled, true) {
|
|
self?.handleGIFButtonTapped()
|
|
}
|
|
}
|
|
)
|
|
|
|
present(modal, animated: true, completion: nil)
|
|
return
|
|
}
|
|
|
|
let gifVC = GifPickerViewController(using: viewModel.dependencies)
|
|
gifVC.delegate = self
|
|
|
|
let navController = StyledNavigationController(rootViewController: gifVC)
|
|
navController.modalPresentationStyle = .fullScreen
|
|
present(navController, animated: true) { }
|
|
}
|
|
|
|
func handleDocumentButtonTapped() {
|
|
// UIDocumentPickerModeImport copies to a temp file within our container.
|
|
// It uses more memory than "open" but lets us avoid working with security scoped URLs.
|
|
let documentPickerVC = UIDocumentPickerViewController(forOpeningContentTypes: [.item], asCopy: true)
|
|
documentPickerVC.modalPresentationStyle = .fullScreen
|
|
|
|
self.documentHandler = DocumentPickerHandler(
|
|
didPickDocumentsAt: { [weak self, dependencies = viewModel.dependencies] _, urls in
|
|
defer {
|
|
self?.documentHandler = nil
|
|
}
|
|
|
|
guard let url: URL = urls.first else { return }
|
|
|
|
let urlResourceValues: URLResourceValues
|
|
do {
|
|
urlResourceValues = try url.resourceValues(forKeys: [ .typeIdentifierKey, .isDirectoryKey, .nameKey ])
|
|
}
|
|
catch {
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.viewModel.showToast(text: "attachmentsErrorLoad".localized())
|
|
}
|
|
return
|
|
}
|
|
|
|
let type: UTType = (urlResourceValues.typeIdentifier.map({ UTType($0) }) ?? .data)
|
|
guard urlResourceValues.isDirectory != true else {
|
|
DispatchQueue.main.async { [weak self] in
|
|
let modal: ConfirmationModal = ConfirmationModal(
|
|
targetView: self?.view,
|
|
info: ConfirmationModal.Info(
|
|
title: "attachmentsErrorLoad".localized(),
|
|
body: .text("attachmentsErrorNotSupported".localized()),
|
|
cancelTitle: "okay".localized(),
|
|
cancelStyle: .alert_text
|
|
)
|
|
)
|
|
self?.present(modal, animated: true)
|
|
}
|
|
return
|
|
}
|
|
|
|
let fileName: String = (urlResourceValues.name ?? "attachment".localized())
|
|
let pendingAttachment: PendingAttachment = PendingAttachment(
|
|
source: .file(url),
|
|
utType: type,
|
|
sourceFilename: fileName,
|
|
using: dependencies
|
|
)
|
|
|
|
/// Although we want to be able to send higher quality attachments through the document picker
|
|
/// it's more imporant that we ensure the sent format is one all clients can accept (e.g. *not* quicktime .mov)
|
|
if
|
|
UTType.supportedVideoTypes.contains(pendingAttachment.utType) &&
|
|
!UTType.supportedOutputVideoTypes.contains(pendingAttachment.utType)
|
|
{
|
|
let indicator: ModalActivityIndicatorViewController = ModalActivityIndicatorViewController(
|
|
canCancel: true
|
|
)
|
|
self?.present(indicator, animated: false)
|
|
|
|
Task.detached(priority: .userInitiated) { [weak self, indicator, dependencies] in
|
|
do {
|
|
let preparedAttachment: PreparedAttachment = try await pendingAttachment.prepare(
|
|
operations: [.convert(to: .mp4)],
|
|
using: dependencies
|
|
)
|
|
guard await !indicator.wasCancelled else { return }
|
|
|
|
let convertedAttachment: PendingAttachment = PendingAttachment(
|
|
source: .media(
|
|
.videoUrl(
|
|
URL(fileURLWithPath: preparedAttachment.filePath),
|
|
.mpeg4Movie,
|
|
pendingAttachment.sourceFilename,
|
|
dependencies[singleton: .attachmentManager]
|
|
)
|
|
),
|
|
utType: .mpeg4Movie,
|
|
sourceFilename: pendingAttachment.sourceFilename,
|
|
using: dependencies
|
|
)
|
|
try convertedAttachment.ensureExpectedEncryptedSize(
|
|
domain: .attachment,
|
|
maxFileSize: Network.maxFileSize,
|
|
using: dependencies
|
|
)
|
|
|
|
await indicator.dismiss {
|
|
self?.showAttachmentApprovalDialog(for: [ convertedAttachment ])
|
|
}
|
|
}
|
|
catch {
|
|
await indicator.dismiss {
|
|
self?.showErrorAlert(for: error)
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
/// Validate the expected attachment size before proceeding
|
|
do {
|
|
try pendingAttachment.ensureExpectedEncryptedSize(
|
|
domain: .attachment,
|
|
maxFileSize: Network.maxFileSize,
|
|
using: dependencies
|
|
)
|
|
}
|
|
catch {
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.showErrorAlert(for: error)
|
|
}
|
|
return
|
|
}
|
|
|
|
// "Document picker" attachments _SHOULD NOT_ be resized
|
|
self?.showAttachmentApprovalDialog(for: [ pendingAttachment ])
|
|
},
|
|
wasCancelled: { [weak self] _ in
|
|
self?.documentHandler = nil
|
|
}
|
|
)
|
|
documentPickerVC.delegate = self.documentHandler
|
|
|
|
present(documentPickerVC, animated: true, completion: nil)
|
|
}
|
|
|
|
func handleLibraryButtonTapped() {
|
|
let threadId: String = self.viewModel.state.threadId
|
|
let threadVariant: SessionThread.Variant = self.viewModel.state.threadVariant
|
|
let quoteViewModel: QuoteViewModel? = self.snInputView.quoteViewModel
|
|
|
|
Permissions.requestLibraryPermissionIfNeeded(isSavingMedia: false, using: viewModel.dependencies) { [weak self, dependencies = viewModel.dependencies] granted in
|
|
guard granted else { return }
|
|
|
|
DispatchQueue.main.async {
|
|
let sendMediaNavController = SendMediaNavigationController.showingMediaLibraryFirst(
|
|
threadId: threadId,
|
|
threadVariant: threadVariant,
|
|
quoteViewModel: quoteViewModel,
|
|
onQuoteCancelled: { [weak self] in
|
|
self?.snInputView.quoteViewModel = nil
|
|
},
|
|
using: dependencies
|
|
)
|
|
sendMediaNavController.sendMediaNavDelegate = self
|
|
sendMediaNavController.modalPresentationStyle = .fullScreen
|
|
self?.present(sendMediaNavController, animated: true, completion: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
func handleCameraButtonTapped() {
|
|
guard Permissions.requestCameraPermissionIfNeeded(presentingViewController: self, using: viewModel.dependencies) else { return }
|
|
|
|
Permissions.requestMicrophonePermissionIfNeeded(using: viewModel.dependencies)
|
|
|
|
if Permissions.microphone != .granted {
|
|
Log.warn(.conversation, "Proceeding without microphone access. Any recorded video will be silent.")
|
|
}
|
|
|
|
let sendMediaNavController = SendMediaNavigationController.showingCameraFirst(
|
|
threadId: self.viewModel.state.threadId,
|
|
threadVariant: self.viewModel.state.threadVariant,
|
|
quoteViewModel: self.snInputView.quoteViewModel,
|
|
onQuoteCancelled: { [weak self] in
|
|
self?.snInputView.quoteViewModel = nil
|
|
},
|
|
using: self.viewModel.dependencies
|
|
)
|
|
sendMediaNavController.sendMediaNavDelegate = self
|
|
sendMediaNavController.modalPresentationStyle = .fullScreen
|
|
|
|
present(sendMediaNavController, animated: true, completion: nil)
|
|
}
|
|
|
|
// MARK: - GifPickerViewControllerDelegate
|
|
|
|
func gifPickerDidSelect(attachment: PendingAttachment) {
|
|
showAttachmentApprovalDialog(for: [ attachment ])
|
|
}
|
|
|
|
func showAttachmentApprovalDialog(for attachments: [PendingAttachment]) {
|
|
let viewController: AttachmentApprovalViewController = AttachmentApprovalViewController(
|
|
mode: .modal,
|
|
delegate: self,
|
|
threadId: self.viewModel.state.threadId,
|
|
threadVariant: self.viewModel.state.threadVariant,
|
|
attachments: attachments,
|
|
messageText: snInputView.text,
|
|
quoteViewModel: snInputView.quoteViewModel,
|
|
disableLinkPreviewImageDownload: !self.viewModel.state.threadInfo.canUpload,
|
|
didLoadLinkPreview: nil,
|
|
onQuoteCancelled: { [weak self] in
|
|
self?.snInputView.quoteViewModel = nil
|
|
},
|
|
using: viewModel.dependencies
|
|
)
|
|
|
|
let navController = StyledNavigationController(rootViewController: viewController)
|
|
navController.modalPresentationStyle = .fullScreen
|
|
|
|
present(navController, animated: true, completion: nil)
|
|
}
|
|
|
|
// MARK: - InputViewDelegate
|
|
|
|
@MainActor func handleDisabledInputTapped() {
|
|
guard viewModel.state.threadInfo.isBlocked else { return }
|
|
|
|
self.showBlockedModalIfNeeded()
|
|
}
|
|
|
|
@MainActor func handleCharacterLimitLabelTapped() {
|
|
let manager: SessionProManagerType = viewModel.dependencies[singleton: .sessionProManager]
|
|
let didShowCTAModal: Bool = manager.showSessionProCTAIfNeeded(
|
|
.longerMessages(renew: (manager.currentUserCurrentProState.status == .expired)),
|
|
onConfirm: { [weak self, manager] in
|
|
manager.showSessionProBottomSheetIfNeeded(
|
|
afterClosed: { [weak self] in
|
|
self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "")
|
|
},
|
|
presenting: { bottomSheet in
|
|
self?.present(bottomSheet, animated: true)
|
|
}
|
|
)
|
|
},
|
|
onCancel: { [weak self] in
|
|
self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "")
|
|
},
|
|
presenting: { [weak self] modal in
|
|
self?.present(modal, animated: true)
|
|
}
|
|
)
|
|
|
|
guard !didShowCTAModal else { return }
|
|
|
|
let numberOfCharactersLeft: Int = viewModel.dependencies[singleton: .sessionProManager].numberOfCharactersLeft(
|
|
for: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
)
|
|
|
|
let confirmationModal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: (numberOfCharactersLeft >= 0 ?
|
|
"modalMessageCharacterDisplayTitle".localized() :
|
|
"modalMessageCharacterTooLongTitle".localized()
|
|
),
|
|
body: .text(
|
|
(numberOfCharactersLeft >= 0 ?
|
|
"modalMessageCharacterDisplayDescription"
|
|
.putNumber(numberOfCharactersLeft)
|
|
.put(key: "limit", value: viewModel.dependencies[singleton: .sessionProManager].characterLimit)
|
|
.localized() :
|
|
"modalMessageCharacterTooLongDescription"
|
|
.put(key: "limit", value: viewModel.dependencies[singleton: .sessionProManager].characterLimit)
|
|
.localized()
|
|
),
|
|
scrollMode: .never
|
|
),
|
|
cancelTitle: "okay".localized(),
|
|
cancelStyle: .alert_text
|
|
)
|
|
)
|
|
present(confirmationModal, animated: true, completion: nil)
|
|
}
|
|
|
|
@MainActor func handleAttachmentButtonTapped() {
|
|
if attachmentButtonStackView.isHidden {
|
|
expandAttachmentButtons()
|
|
} else {
|
|
collapseAttachmentButtons()
|
|
}
|
|
}
|
|
|
|
@MainActor func expandAttachmentButtons() {
|
|
guard attachmentButtonStackView.isHidden else { return }
|
|
|
|
snInputView.attachmentsButton.accessibilityLabel = "Collapse attachment options"
|
|
attachmentButtonStackView.isHidden = false
|
|
|
|
UIView.animate(withDuration: 0.25) {
|
|
self.attachmentButtonStackView.arrangedSubviews.forEach { $0.isHidden = false }
|
|
self.attachmentButtonStackView.alpha = 1
|
|
}
|
|
}
|
|
|
|
@MainActor func collapseAttachmentButtons() {
|
|
guard !attachmentButtonStackView.isHidden else { return }
|
|
|
|
snInputView.attachmentsButton.accessibilityLabel = "Add attachment"
|
|
|
|
UIView.animate(
|
|
withDuration: 0.25,
|
|
animations: {
|
|
self.attachmentButtonStackView.arrangedSubviews.forEach { $0.isHidden = true }
|
|
self.attachmentButtonStackView.alpha = 0
|
|
},
|
|
completion: { [weak self] _ in
|
|
self?.attachmentButtonStackView.isHidden = true
|
|
}
|
|
)
|
|
}
|
|
|
|
@MainActor func handleDisabledAttachmentButtonTapped() {
|
|
/// This logic was added because an Apple reviewer rejected an emergency update as they thought these buttons were
|
|
/// unresponsive (even though there is copy on the screen communicating that they are intentionally disabled) - in order
|
|
/// to prevent this happening in the future we've added this toast when pressing on the disabled button
|
|
guard viewModel.state.threadInfo.isMessageRequest else { return }
|
|
|
|
let toastController: ToastController = ToastController(
|
|
text: "messageRequestDisabledToastAttachments".localized(),
|
|
background: .backgroundSecondary
|
|
)
|
|
toastController.presentToastView(
|
|
fromBottomOfView: self.view,
|
|
inset: (snInputView.bounds.height + Values.largeSpacing),
|
|
duration: .milliseconds(2500)
|
|
)
|
|
}
|
|
|
|
@MainActor func handleDisabledVoiceMessageButtonTapped() {
|
|
/// This logic was added because an Apple reviewer rejected an emergency update as they thought these buttons were
|
|
/// unresponsive (even though there is copy on the screen communicating that they are intentionally disabled) - in order
|
|
/// to prevent this happening in the future we've added this toast when pressing on the disabled button
|
|
guard viewModel.state.threadInfo.isMessageRequest else { return }
|
|
|
|
let toastController: ToastController = ToastController(
|
|
text: "messageRequestDisabledToastVoiceMessages".localized(),
|
|
background: .backgroundSecondary
|
|
)
|
|
toastController.presentToastView(
|
|
fromBottomOfView: self.view,
|
|
inset: (snInputView.bounds.height + Values.largeSpacing),
|
|
duration: .milliseconds(2500)
|
|
)
|
|
}
|
|
|
|
// MARK: --Message Sending
|
|
|
|
@MainActor func handleSendButtonTapped() {
|
|
guard viewModel.dependencies[singleton: .sessionProManager].numberOfCharactersLeft(
|
|
for: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
) >= 0 else {
|
|
return showModalForMessagesExceedingCharacterLimit()
|
|
}
|
|
|
|
sendMessage(
|
|
text: snInputView.text.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
linkPreviewViewModel: snInputView.linkPreviewViewModel,
|
|
quoteViewModel: snInputView.quoteViewModel
|
|
)
|
|
}
|
|
|
|
@MainActor func showModalForMessagesExceedingCharacterLimit() {
|
|
let manager: SessionProManagerType = viewModel.dependencies[singleton: .sessionProManager]
|
|
let didShowCTAModal: Bool = manager.showSessionProCTAIfNeeded(
|
|
.longerMessages(renew: (manager.currentUserCurrentProState.status == .expired)),
|
|
onConfirm: { [weak self, manager] in
|
|
manager.showSessionProBottomSheetIfNeeded(
|
|
afterClosed: { [weak self] in
|
|
self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "")
|
|
},
|
|
presenting: { bottomSheet in
|
|
self?.present(bottomSheet, animated: true)
|
|
}
|
|
)
|
|
},
|
|
onCancel: { [weak self] in
|
|
self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "")
|
|
},
|
|
presenting: { [weak self] modal in
|
|
self?.present(modal, animated: true)
|
|
}
|
|
)
|
|
|
|
guard !didShowCTAModal else { return }
|
|
|
|
let confirmationModal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "modalMessageCharacterTooLongTitle".localized(),
|
|
body: .text(
|
|
"modalMessageTooLongDescription"
|
|
.put(key: "limit", value: viewModel.dependencies[singleton: .sessionProManager].characterLimit)
|
|
.localized(),
|
|
scrollMode: .never
|
|
),
|
|
cancelTitle: "okay".localized(),
|
|
cancelStyle: .alert_text
|
|
)
|
|
)
|
|
present(confirmationModal, animated: true, completion: nil)
|
|
}
|
|
|
|
@MainActor func sendMessage(
|
|
text: String,
|
|
attachments: [PendingAttachment] = [],
|
|
linkPreviewViewModel: LinkPreviewViewModel? = nil,
|
|
quoteViewModel: QuoteViewModel? = nil,
|
|
hasPermissionToSendSeed: Bool = false
|
|
) {
|
|
guard !showBlockedModalIfNeeded() else { return }
|
|
|
|
/// Validate the expected attachment size before proceeding
|
|
do {
|
|
try attachments.forEach { attachment in
|
|
try attachment.ensureExpectedEncryptedSize(
|
|
domain: .attachment,
|
|
maxFileSize: Network.maxFileSize,
|
|
using: viewModel.dependencies
|
|
)
|
|
}
|
|
}
|
|
catch { return showErrorAlert(for: error) }
|
|
|
|
let processedText: String = mentions.update(text.trimmingCharacters(in: .whitespacesAndNewlines))
|
|
|
|
// If we have no content then do nothing
|
|
guard !processedText.isEmpty || !attachments.isEmpty else { return }
|
|
|
|
if processedText.contains(mnemonic) && !viewModel.state.threadInfo.isNoteToSelf && !hasPermissionToSendSeed {
|
|
// Warn the user if they're about to send their seed to someone
|
|
let modal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "warning".localized(),
|
|
body: .text("recoveryPasswordWarningSendDescription".localized()),
|
|
confirmTitle: "send".localized(),
|
|
confirmStyle: .danger,
|
|
cancelStyle: .alert_text,
|
|
onConfirm: { [weak self] _ in
|
|
self?.sendMessage(
|
|
text: text,
|
|
attachments: attachments,
|
|
linkPreviewViewModel: linkPreviewViewModel,
|
|
quoteViewModel: quoteViewModel,
|
|
hasPermissionToSendSeed: true
|
|
)
|
|
}
|
|
)
|
|
)
|
|
|
|
return present(modal, animated: true, completion: nil)
|
|
}
|
|
|
|
// Clearing this out immediately to make this appear more snappy
|
|
snInputView.text = ""
|
|
snInputView.quoteViewModel = nil
|
|
|
|
resetMentions()
|
|
scrollToBottom(isAnimated: false)
|
|
|
|
// Optimistically insert the outgoing message (this will trigger a UI update)
|
|
self.viewModel.sentMessageBeforeUpdate = true
|
|
let sentTimestampMs: Int64 = viewModel.dependencies[cache: .snodeAPI].currentOffsetTimestampMs()
|
|
|
|
Task.detached(priority: .userInitiated) { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
do {
|
|
let optimisticData: ConversationViewModel.OptimisticMessageData = try await viewModel.optimisticallyAppendOutgoingMessage(
|
|
text: processedText,
|
|
sentTimestampMs: sentTimestampMs,
|
|
attachments: attachments,
|
|
linkPreviewViewModel: linkPreviewViewModel,
|
|
quoteViewModel: quoteViewModel
|
|
)
|
|
await approveMessageRequestIfNeeded(
|
|
for: self.viewModel.state.threadId,
|
|
threadVariant: self.viewModel.state.threadVariant,
|
|
displayName: self.viewModel.state.threadInfo.displayName.deformatted(),
|
|
isDraft: self.viewModel.state.threadInfo.isDraft,
|
|
timestampMs: (sentTimestampMs - 1) // Set 1ms earlier as this is used for sorting
|
|
)
|
|
|
|
await sendMessage(optimisticData: optimisticData)
|
|
}
|
|
catch {
|
|
await MainActor.run { [weak self] in
|
|
self?.handleCharacterLimitLabelTapped()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sendMessage(optimisticData: ConversationViewModel.OptimisticMessageData) async {
|
|
let state: ConversationViewModel.State = self.viewModel.state
|
|
|
|
// Actually send the message
|
|
do {
|
|
try await viewModel.dependencies[singleton: .storage].writeAsync { [weak self, dependencies = viewModel.dependencies] db in
|
|
// Update the thread to be visible (if it isn't already)
|
|
try SessionThread.upsert(
|
|
db,
|
|
id: state.threadId,
|
|
variant: state.threadVariant,
|
|
values: SessionThread.TargetValues(
|
|
shouldBeVisible: .setTo(true),
|
|
isDraft: .setTo(false)
|
|
),
|
|
using: dependencies
|
|
)
|
|
|
|
// Insert the interaction and associated it with the optimistically inserted message so
|
|
// we can remove it once the database triggers a UI update
|
|
let insertedInteraction: Interaction = try optimisticData.interaction.inserted(db)
|
|
self?.viewModel.associate(
|
|
db,
|
|
optimisticMessageId: optimisticData.temporaryId,
|
|
to: insertedInteraction.id
|
|
)
|
|
|
|
// If there is a LinkPreview draft then check the state of any existing link previews and
|
|
// insert a new one if needed
|
|
if let linkPreviewViewModel: LinkPreviewViewModel = optimisticData.linkPreviewViewModel {
|
|
let invalidLinkPreviewAttachmentStates: [Attachment.State] = [
|
|
.failedDownload, .pendingDownload, .downloading, .failedUpload, .invalid
|
|
]
|
|
let linkPreviewAttachmentId: String? = try? Interaction
|
|
.linkPreview(
|
|
url: insertedInteraction.linkPreviewUrl,
|
|
timestampMs: insertedInteraction.timestampMs
|
|
)?
|
|
.fetchOne(db)?
|
|
.attachmentId
|
|
let linkPreviewAttachmentState: Attachment.State = linkPreviewAttachmentId
|
|
.map {
|
|
try? Attachment
|
|
.filter(id: $0)
|
|
.select(.state)
|
|
.asRequest(of: Attachment.State.self)
|
|
.fetchOne(db)
|
|
}
|
|
.defaulting(to: .invalid)
|
|
|
|
// If we don't have a "valid" existing link preview then upsert a new one
|
|
if invalidLinkPreviewAttachmentStates.contains(linkPreviewAttachmentState) {
|
|
try LinkPreview(
|
|
url: linkPreviewViewModel.urlString,
|
|
title: linkPreviewViewModel.title,
|
|
attachmentId: try optimisticData.linkPreviewPreparedAttachment?
|
|
.attachment
|
|
.inserted(db)
|
|
.id,
|
|
using: dependencies
|
|
).upsert(db)
|
|
}
|
|
}
|
|
|
|
// If there is a Quote the insert it now
|
|
if
|
|
let interactionId: Int64 = insertedInteraction.id,
|
|
let quoteViewModel: QuoteViewModel = optimisticData.quoteViewModel,
|
|
let quotedAuthorId: String = quoteViewModel.quotedInfo?.authorId,
|
|
let quotedTimestampMs: Int64 = quoteViewModel.quotedInfo?.timestampMs
|
|
{
|
|
try Quote(
|
|
interactionId: interactionId,
|
|
authorId: quotedAuthorId,
|
|
timestampMs: quotedTimestampMs
|
|
).insert(db)
|
|
}
|
|
|
|
// Link any attachments to their interaction
|
|
try AttachmentUploadJob.link(
|
|
db,
|
|
attachments: optimisticData.attachmentData,
|
|
toInteractionWithId: insertedInteraction.id
|
|
)
|
|
|
|
try MessageSender.send(
|
|
db,
|
|
interaction: insertedInteraction,
|
|
threadId: state.threadId,
|
|
threadVariant: state.threadVariant,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
await handleMessageSent()
|
|
}
|
|
catch {
|
|
await viewModel.failedToStoreOptimisticOutgoingMessage(id: optimisticData.temporaryId, error: error)
|
|
}
|
|
}
|
|
|
|
func handleMessageSent() async {
|
|
if viewModel.dependencies.mutate(cache: .libSession, { $0.get(.playNotificationSoundInForeground) }) {
|
|
let soundID = Preferences.Sound.systemSoundId(for: .messageSent, quiet: true)
|
|
AudioServicesPlaySystemSound(soundID)
|
|
}
|
|
|
|
await viewModel.dependencies[singleton: .typingIndicators].didStopTyping(
|
|
threadId: viewModel.state.threadId,
|
|
direction: .outgoing
|
|
)
|
|
try? await viewModel.dependencies[singleton: .storage].writeAsync { [threadId = viewModel.state.threadId] db in
|
|
_ = try SessionThread
|
|
.filter(id: threadId)
|
|
.updateAll(db, SessionThread.Columns.messageDraft.set(to: ""))
|
|
}
|
|
}
|
|
|
|
@MainActor func showLinkPreviewSuggestionModal() {
|
|
let linkPreviewModal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "linkPreviewsEnable".localized(),
|
|
body: .text(
|
|
"linkPreviewsFirstDescription"
|
|
.put(key: "app_name", value: Constants.app_name)
|
|
.localized()
|
|
),
|
|
confirmTitle: "enable".localized(),
|
|
confirmStyle: .danger,
|
|
cancelStyle: .alert_text,
|
|
onConfirm: { [weak self, dependencies = viewModel.dependencies] _ in
|
|
dependencies.setAsync(.areLinkPreviewsEnabled, true) {
|
|
Task {
|
|
await self?.snInputView.autoGenerateLinkPreview()
|
|
}
|
|
}
|
|
}
|
|
)
|
|
)
|
|
|
|
present(linkPreviewModal, animated: true, completion: nil)
|
|
}
|
|
|
|
@MainActor func inputTextViewDidChangeContent(_ inputTextView: InputTextView) {
|
|
// Note: If there is a 'draft' message then we don't want it to trigger the typing indicator to
|
|
// appear (as that is not expected/correct behaviour)
|
|
guard !viewIsAppearing else { return }
|
|
|
|
let newText: String = (inputTextView.text ?? "")
|
|
|
|
if !newText.isEmpty {
|
|
Task { [state = viewModel.state, dependencies = viewModel.dependencies] in
|
|
await viewModel.dependencies[singleton: .typingIndicators].startIfNeeded(
|
|
threadId: state.threadId,
|
|
threadVariant: state.threadVariant,
|
|
direction: .outgoing,
|
|
timestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs()
|
|
)
|
|
}
|
|
}
|
|
|
|
Task.detached(priority: .userInitiated) { [weak self] in
|
|
await self?.updateMentions(for: newText)
|
|
}
|
|
|
|
// Note: When calculating the number of characters left, we need to use the original mention
|
|
// text which contains the session id rather than display name.
|
|
snInputView.updateNumberOfCharactersLeft(mentions.update(newText))
|
|
}
|
|
|
|
// MARK: --Attachments
|
|
|
|
@MainActor func didPasteImageDataFromPasteboard(_ imageData: Data) {
|
|
let pendingAttachment: PendingAttachment = PendingAttachment(
|
|
source: .media(.data(UUID().uuidString, imageData)),
|
|
sourceFilename: nil,
|
|
using: viewModel.dependencies
|
|
)
|
|
|
|
showAttachmentApprovalDialog(for: [pendingAttachment])
|
|
}
|
|
|
|
// MARK: --Mentions
|
|
|
|
@MainActor func handleMentionSelected(_ viewModel: MentionSelectionView.ViewModel, from view: MentionSelectionView) {
|
|
guard let currentMentionStartIndex = currentMentionStartIndex else { return }
|
|
|
|
mentions.append(viewModel)
|
|
|
|
let newText: String = snInputView.text.replacingCharacters(
|
|
in: currentMentionStartIndex...,
|
|
with: "@\(viewModel.displayName) " // stringlint:ignore
|
|
)
|
|
|
|
snInputView.text = newText
|
|
self.currentMentionStartIndex = nil
|
|
snInputView.hideMentionsUI()
|
|
|
|
mentions = mentions.filter { newText.contains($0.displayName) }
|
|
}
|
|
|
|
func updateMentions(for newText: String) async {
|
|
let currentStartIndex: String.Index? = await MainActor.run { currentMentionStartIndex }
|
|
|
|
guard !newText.isEmpty else {
|
|
await MainActor.run {
|
|
if currentStartIndex != nil {
|
|
snInputView.hideMentionsUI()
|
|
}
|
|
|
|
resetMentions()
|
|
}
|
|
return
|
|
}
|
|
|
|
let lastCharacterIndex: String.Index = newText.index(before: newText.endIndex)
|
|
let lastCharacter: String = String(newText[lastCharacterIndex])
|
|
|
|
/// Check the surrounds of the last character to ensure the user is after a mention
|
|
let lastCharacterIsMentionChar: Bool = (lastCharacter == MentionSelectionView.ViewModel.mentionChar)
|
|
let whitespaceOrNewLineBeforeMentionChar: Bool = {
|
|
guard newText.count > 1 else { return true } /// Start of line
|
|
|
|
return newText[newText.index(before: lastCharacterIndex)].isWhitespace
|
|
}()
|
|
let isStartingNewMention: Bool = (
|
|
lastCharacterIsMentionChar &&
|
|
whitespaceOrNewLineBeforeMentionChar
|
|
)
|
|
let isContinuingMention: Bool = {
|
|
guard let startIndex: String.Index = currentStartIndex else { return false }
|
|
guard startIndex < newText.endIndex else { return false }
|
|
|
|
/// Make sure there's no whitespace between the mention start and current position
|
|
let mentionRange: Range<String.Index> = startIndex..<newText.endIndex
|
|
let textSinceMention: String = String(newText[mentionRange])
|
|
|
|
return !textSinceMention.contains(where: { $0.isWhitespace })
|
|
}()
|
|
|
|
/// If it's not a valid mention then we need to reset the state and hide the mentions UI (if visible)
|
|
guard isStartingNewMention || isContinuingMention else {
|
|
await MainActor.run {
|
|
currentMentionStartIndex = nil
|
|
snInputView.hideMentionsUI()
|
|
}
|
|
return
|
|
}
|
|
|
|
/// Determine the mention start index and query
|
|
let mentionStartIndex: String.Index
|
|
let query: String
|
|
|
|
if isStartingNewMention {
|
|
mentionStartIndex = lastCharacterIndex
|
|
query = ""
|
|
} else if let startIndex: String.Index = currentStartIndex {
|
|
mentionStartIndex = startIndex
|
|
|
|
/// Get text after the @ symbol
|
|
let queryStartIndex: String.Index = newText.index(after: startIndex)
|
|
query = String(newText[queryStartIndex...])
|
|
} else {
|
|
/// Shouldn't reach here, but handle gracefully
|
|
await MainActor.run {
|
|
currentMentionStartIndex = nil
|
|
snInputView.hideMentionsUI()
|
|
}
|
|
return
|
|
}
|
|
|
|
let mentions: [MentionSelectionView.ViewModel] = ((try? await self.viewModel.mentions(for: query)) ?? [])
|
|
|
|
await MainActor.run {
|
|
currentMentionStartIndex = mentionStartIndex
|
|
snInputView.showMentionsUI(for: mentions)
|
|
}
|
|
}
|
|
|
|
@MainActor func resetMentions() {
|
|
currentMentionStartIndex = nil
|
|
mentions = []
|
|
}
|
|
|
|
// MARK: MessageCellDelegate
|
|
|
|
func handleItemLongPressed(_ cellViewModel: MessageViewModel) {
|
|
// Show the unblock modal if needed
|
|
guard !self.viewModel.state.threadInfo.isBlocked else {
|
|
self.showBlockedModalIfNeeded()
|
|
return
|
|
}
|
|
// Show the context menu if applicable
|
|
guard
|
|
// FIXME: Need to update this when an appropriate replacement is added (see https://teng.pub/technical/2021/11/9/uiapplication-key-window-replacement)
|
|
let keyWindow: UIWindow = UIApplication.shared.keyWindow,
|
|
let sectionIndex: Int = self.sections
|
|
.firstIndex(where: { $0.model == .messages }),
|
|
let index = self.sections[sectionIndex]
|
|
.elements
|
|
.firstIndex(of: cellViewModel),
|
|
let cell = tableView.cellForRow(at: IndexPath(row: index, section: sectionIndex)) as? MessageCell,
|
|
let contextSnapshotView: UIView = cell.contextSnapshotView,
|
|
let snapshot = contextSnapshotView.snapshotView(afterScreenUpdates: false),
|
|
contextMenuWindow == nil,
|
|
let actions: [ContextMenuVC.Action] = ContextMenuVC.actions(
|
|
for: cellViewModel,
|
|
threadInfo: self.viewModel.state.threadInfo,
|
|
authMethod: self.viewModel.state.authMethod.value,
|
|
reactionsSupported: self.viewModel.state.reactionsSupported,
|
|
recentReactionEmoji: self.viewModel.state.recentReactionEmoji,
|
|
isUserModeratorOrAdmin: self.viewModel.state.isUserModeratorOrAdmin,
|
|
forMessageInfoScreen: false,
|
|
delegate: self,
|
|
using: viewModel.dependencies
|
|
)
|
|
else { return }
|
|
|
|
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
|
self.contextMenuWindow = ContextMenuWindow()
|
|
self.contextMenuVC = ContextMenuVC(
|
|
snapshot: snapshot,
|
|
frame: contextSnapshotView.convert(contextSnapshotView.bounds, to: keyWindow),
|
|
cellViewModel: cellViewModel,
|
|
actions: actions,
|
|
using: viewModel.dependencies
|
|
) { [weak self] in
|
|
self?.contextMenuWindow?.isHidden = true
|
|
self?.contextMenuVC = nil
|
|
self?.contextMenuWindow = nil
|
|
self?.scrollButton.alpha = 0
|
|
|
|
UIView.animate(withDuration: 0.25) {
|
|
self?.updateScrollToBottom()
|
|
}
|
|
}
|
|
|
|
self.hideSearchUI()
|
|
self.contextMenuWindow?.themeBackgroundColor = .clear
|
|
self.contextMenuWindow?.rootViewController = self.contextMenuVC
|
|
self.contextMenuWindow?.overrideUserInterfaceStyle = ThemeManager.currentTheme.interfaceStyle
|
|
self.contextMenuWindow?.makeKeyAndVisible()
|
|
}
|
|
|
|
func handleItemTapped(
|
|
_ cellViewModel: MessageViewModel,
|
|
cell: UITableViewCell,
|
|
cellLocation: CGPoint
|
|
) {
|
|
self.hideSearchUI()
|
|
|
|
// For call info messages show the "call missed" modal
|
|
guard cellViewModel.variant != .infoCall else {
|
|
// If the failure was due to the mic permission being denied then we want to show the permission modal,
|
|
// otherwise we want to show the call missed tips modal
|
|
guard
|
|
let infoMessageData: Data = (cellViewModel.rawBody ?? "").data(using: .utf8),
|
|
let messageInfo: CallMessage.MessageInfo = try? JSONDecoder().decode(
|
|
CallMessage.MessageInfo.self,
|
|
from: infoMessageData
|
|
)
|
|
else { return }
|
|
|
|
switch messageInfo.state {
|
|
case .permissionDenied:
|
|
let callMissedTipsModal: CallMissedTipsModal = CallMissedTipsModal(
|
|
caller: cellViewModel.authorName(),
|
|
presentingViewController: self,
|
|
using: viewModel.dependencies
|
|
)
|
|
present(callMissedTipsModal, animated: true, completion: nil)
|
|
return
|
|
|
|
case .permissionDeniedMicrophone:
|
|
Permissions.requestMicrophonePermissionIfNeeded(
|
|
presentingViewController: self,
|
|
using: viewModel.dependencies
|
|
)
|
|
return
|
|
|
|
case .incoming, .outgoing, .missed, .unknown: break
|
|
}
|
|
return
|
|
}
|
|
|
|
// For disappearing messages config update, show the following settings modal
|
|
guard cellViewModel.variant != .infoDisappearingMessagesUpdate else {
|
|
let messageDisappearingConfig = cellViewModel.messageDisappearingConfiguration()
|
|
let expirationTimerString: String = floor(messageDisappearingConfig.durationSeconds).formatted(format: .long)
|
|
let expirationTypeString: String = (messageDisappearingConfig.type?.localizedName ?? "")
|
|
let modalBodyString: String = {
|
|
if messageDisappearingConfig.isEnabled {
|
|
return "disappearingMessagesFollowSettingOn"
|
|
.put(key: "time", value: expirationTimerString)
|
|
.put(key: "disappearing_messages_type", value: expirationTypeString)
|
|
.localized()
|
|
} else {
|
|
return "disappearingMessagesFollowSettingOff"
|
|
.localized()
|
|
}
|
|
}()
|
|
let modalConfirmTitle: String = messageDisappearingConfig.isEnabled ? "set".localized() : "confirm".localized()
|
|
let confirmationModal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "disappearingMessagesFollowSetting".localized(),
|
|
body: .attributedText(modalBodyString.formatted(baseFont: .systemFont(ofSize: Values.smallFontSize))),
|
|
confirmTitle: modalConfirmTitle,
|
|
confirmStyle: .danger,
|
|
cancelStyle: .textPrimary,
|
|
dismissOnConfirm: false // Custom dismissal logic
|
|
) { [weak self, dependencies = viewModel.dependencies] _ in
|
|
dependencies[singleton: .storage].writeAsync { db in
|
|
let userSessionId: SessionId = dependencies[cache: .general].sessionId
|
|
let currentTimestampMs: UInt64 = dependencies[cache: .snodeAPI].currentOffsetTimestampMs()
|
|
|
|
let interactionId = try messageDisappearingConfig
|
|
.upserted(db)
|
|
.insertControlMessage(
|
|
db,
|
|
threadVariant: cellViewModel.threadVariant,
|
|
authorId: userSessionId.hexString,
|
|
timestampMs: currentTimestampMs,
|
|
serverHash: nil,
|
|
serverExpirationTimestamp: nil,
|
|
using: dependencies
|
|
)?
|
|
.interactionId
|
|
|
|
let expirationTimerUpdateMessage: ExpirationTimerUpdate = ExpirationTimerUpdate()
|
|
.with(sentTimestampMs: UInt64(currentTimestampMs))
|
|
.with(messageDisappearingConfig)
|
|
|
|
try MessageSender.send(
|
|
db,
|
|
message: expirationTimerUpdateMessage,
|
|
interactionId: interactionId,
|
|
threadId: cellViewModel.threadId,
|
|
threadVariant: cellViewModel.threadVariant,
|
|
using: dependencies
|
|
)
|
|
|
|
try LibSession
|
|
.update(
|
|
db,
|
|
sessionId: cellViewModel.threadId,
|
|
disappearingMessagesConfig: messageDisappearingConfig,
|
|
using: dependencies
|
|
)
|
|
|
|
/// Notify of update
|
|
db.addConversationEvent(
|
|
id: cellViewModel.threadId,
|
|
variant: cellViewModel.threadVariant,
|
|
type: .updated(.disappearingMessageConfiguration(messageDisappearingConfig))
|
|
)
|
|
}
|
|
self?.dismiss(animated: true, completion: nil)
|
|
}
|
|
)
|
|
|
|
present(confirmationModal, animated: true, completion: nil)
|
|
return
|
|
}
|
|
|
|
// If it's an incoming media message and the thread isn't trusted then show the placeholder view
|
|
if cellViewModel.cellType != .textOnlyMessage && cellViewModel.variant == .standardIncoming && !cellViewModel.threadIsTrusted {
|
|
let message: ThemedAttributedString = "attachmentsAutoDownloadModalDescription"
|
|
.put(key: "conversation_name", value: cellViewModel.authorName())
|
|
.localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize))
|
|
let confirmationModal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "attachmentsAutoDownloadModalTitle".localized(),
|
|
body: .attributedText(message),
|
|
confirmTitle: "download".localized(),
|
|
dismissOnConfirm: false // Custom dismissal logic
|
|
) { [weak self] _ in
|
|
self?.viewModel.trustContact()
|
|
self?.dismiss(animated: true, completion: nil)
|
|
}
|
|
)
|
|
|
|
present(confirmationModal, animated: true, completion: nil)
|
|
return
|
|
}
|
|
|
|
/// Takes the `cell` and a `targetView` and returns `true` if the user tapped a link in the cell body text instead
|
|
/// of the `targetView`
|
|
func handleLinkTapIfNeeded(cell: UITableViewCell, targetView: UIView?) -> Bool {
|
|
let locationInTargetView: CGPoint = cell.convert(cellLocation, to: targetView)
|
|
|
|
guard
|
|
let visibleCell: VisibleMessageCell = cell as? VisibleMessageCell,
|
|
targetView?.bounds.contains(locationInTargetView) != true,
|
|
let bodyLabel: LinkHighlightingLabel = visibleCell.bodyLabel,
|
|
bodyLabel.containsLinks,
|
|
let tappedUrlString: String = bodyLabel.urlString(at: cell.convert(cellLocation, to: bodyLabel))
|
|
else { return false }
|
|
|
|
openUrl(tappedUrlString)
|
|
return true
|
|
}
|
|
|
|
switch cellViewModel.cellType {
|
|
case .voiceMessage: viewModel.playOrPauseAudio(for: cellViewModel)
|
|
|
|
case .mediaMessage:
|
|
guard
|
|
let albumView: MediaAlbumView = (cell as? VisibleMessageCell)?.albumView,
|
|
!handleLinkTapIfNeeded(cell: cell, targetView: albumView)
|
|
else { return }
|
|
|
|
// Figure out which of the media views was tapped
|
|
let locationInAlbumView: CGPoint = cell.convert(cellLocation, to: albumView)
|
|
guard let mediaView = albumView.mediaView(forLocation: locationInAlbumView) else { return }
|
|
|
|
switch mediaView.attachment.state {
|
|
case .pendingDownload, .downloading, .uploading, .invalid: break
|
|
|
|
// Failed uploads should be handled via the "resend" process instead
|
|
case .failedUpload: break
|
|
|
|
case .failedDownload:
|
|
let threadId: String = self.viewModel.state.threadId
|
|
|
|
// Retry downloading the failed attachment
|
|
viewModel.dependencies[singleton: .storage].writeAsync { [dependencies = viewModel.dependencies] db in
|
|
dependencies[singleton: .jobRunner].add(
|
|
db,
|
|
job: Job(
|
|
variant: .attachmentDownload,
|
|
threadId: threadId,
|
|
interactionId: cellViewModel.id,
|
|
details: AttachmentDownloadJob.Details(
|
|
attachmentId: mediaView.attachment.id
|
|
)
|
|
),
|
|
canStartJob: true
|
|
)
|
|
}
|
|
break
|
|
|
|
default:
|
|
// Ignore invalid media
|
|
guard mediaView.attachment.isValid else { return }
|
|
|
|
guard albumView.numItems > 1 || !mediaView.attachment.isVideo else {
|
|
guard
|
|
let path: String = try? viewModel.dependencies[singleton: .attachmentManager]
|
|
.createTemporaryFileForOpening(
|
|
downloadUrl: mediaView.attachment.downloadUrl,
|
|
mimeType: mediaView.attachment.contentType,
|
|
sourceFilename: mediaView.attachment.sourceFilename
|
|
),
|
|
viewModel.dependencies[singleton: .fileManager].fileExists(atPath: path)
|
|
else { return Log.warn(.conversation, "Missing video file") }
|
|
|
|
/// When playing media we need to change the AVAudioSession to 'playback' mode so the device "silent mode"
|
|
/// doesn't prevent video audio from playing
|
|
try? AVAudioSession.sharedInstance().setCategory(.playback)
|
|
let viewController: DismissCallbackAVPlayerViewController = DismissCallbackAVPlayerViewController { [dependencies = viewModel.dependencies] in
|
|
/// Sanity check to make sure we don't unintentionally remove a proper attachment file
|
|
guard dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(path) else {
|
|
return
|
|
}
|
|
|
|
try? dependencies[singleton: .fileManager].removeItem(atPath: path)
|
|
}
|
|
viewController.player = AVPlayer(url: URL(fileURLWithPath: path))
|
|
self.present(viewController, animated: true)
|
|
return
|
|
}
|
|
|
|
let viewController: UIViewController? = MediaGalleryViewModel.createDetailViewController(
|
|
for: self.viewModel.state.threadId,
|
|
threadVariant: self.viewModel.state.threadVariant,
|
|
interactionId: cellViewModel.id,
|
|
selectedAttachmentId: mediaView.attachment.id,
|
|
options: [ .sliderEnabled, .showAllMediaButton ],
|
|
using: viewModel.dependencies
|
|
)
|
|
|
|
if let viewController: UIViewController = viewController {
|
|
present(viewController, animated: true)
|
|
}
|
|
}
|
|
|
|
case .audio:
|
|
guard
|
|
!handleLinkTapIfNeeded(cell: cell, targetView: (cell as? VisibleMessageCell)?.documentView),
|
|
let attachment: Attachment = cellViewModel.attachments.first,
|
|
let path: String = try? viewModel.dependencies[singleton: .attachmentManager]
|
|
.createTemporaryFileForOpening(
|
|
downloadUrl: attachment.downloadUrl,
|
|
mimeType: attachment.contentType,
|
|
sourceFilename: attachment.sourceFilename
|
|
),
|
|
viewModel.dependencies[singleton: .fileManager].fileExists(atPath: path)
|
|
else { return }
|
|
|
|
/// When playing media we need to change the AVAudioSession to 'playback' mode so the device "silent mode"
|
|
/// doesn't prevent video audio from playing
|
|
try? AVAudioSession.sharedInstance().setCategory(.playback)
|
|
let viewController: DismissCallbackAVPlayerViewController = DismissCallbackAVPlayerViewController { [dependencies = viewModel.dependencies] in
|
|
/// Sanity check to make sure we don't unintentionally remove a proper attachment file
|
|
guard dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(path) else {
|
|
return
|
|
}
|
|
|
|
try? dependencies[singleton: .fileManager].removeItem(atPath: path)
|
|
}
|
|
viewController.player = AVPlayer(url: URL(fileURLWithPath: path))
|
|
self.navigationController?.present(viewController, animated: true)
|
|
|
|
case .genericAttachment:
|
|
guard
|
|
!handleLinkTapIfNeeded(cell: cell, targetView: (cell as? VisibleMessageCell)?.documentView),
|
|
let attachment: Attachment = cellViewModel.attachments.first,
|
|
let path: String = try? viewModel.dependencies[singleton: .attachmentManager]
|
|
.createTemporaryFileForOpening(
|
|
downloadUrl: attachment.downloadUrl,
|
|
mimeType: attachment.contentType,
|
|
sourceFilename: attachment.sourceFilename
|
|
),
|
|
viewModel.dependencies[singleton: .fileManager].fileExists(atPath: path)
|
|
else { return }
|
|
|
|
let fileUrl: URL = URL(fileURLWithPath: path)
|
|
|
|
// Open a preview of the document for text, pdf or microsoft files
|
|
if
|
|
attachment.isText ||
|
|
attachment.isMicrosoftDoc ||
|
|
attachment.contentType == UTType.mimeTypePdf
|
|
{
|
|
// FIXME: If given an invalid text file (eg with binary data) this hangs forever
|
|
// Note: I tried dispatching after a short delay, detecting that the new UI is invalid and dismissing it
|
|
// if so but the dismissal didn't work (we may have to wait on Apple to handle this one)
|
|
let interactionController: UIDocumentInteractionController = UIDocumentInteractionController(url: fileUrl)
|
|
interactionController.delegate = self
|
|
interactionController.presentPreview(animated: true)
|
|
return
|
|
}
|
|
|
|
// Otherwise share the file
|
|
let shareVC = UIActivityViewController(activityItems: [ fileUrl ], applicationActivities: nil)
|
|
shareVC.completionWithItemsHandler = { [dependencies = viewModel.dependencies] _, success, _, _ in
|
|
UIActivityViewController.notifyIfNeeded(success, using: dependencies)
|
|
}
|
|
|
|
if UIDevice.current.isIPad {
|
|
shareVC.excludedActivityTypes = []
|
|
shareVC.popoverPresentationController?.permittedArrowDirections = []
|
|
shareVC.popoverPresentationController?.sourceView = self.view
|
|
shareVC.popoverPresentationController?.sourceRect = self.view.bounds
|
|
}
|
|
|
|
navigationController?.present(shareVC, animated: true, completion: nil)
|
|
|
|
case .textOnlyMessage:
|
|
guard let visibleCell: VisibleMessageCell = cell as? VisibleMessageCell else { return }
|
|
|
|
let quotePoint: CGPoint = visibleCell.convert(cellLocation, to: visibleCell.quoteView)
|
|
let linkPreviewPoint: CGPoint = visibleCell.convert(cellLocation, to: visibleCell.linkPreviewView?.previewView)
|
|
let tappableLabelPoint: CGPoint = visibleCell.convert(cellLocation, to: visibleCell.bodyLabel)
|
|
let containsLinks: Bool = {
|
|
guard let bodyLabel: LinkHighlightingLabel = visibleCell.bodyLabel else { return false }
|
|
|
|
return (
|
|
// If there is only a single link and it matches the LinkPreview then consider this _just_ a
|
|
// LinkPreview
|
|
bodyLabel.containsLinks == true && (
|
|
bodyLabel.links.count > 1 ||
|
|
bodyLabel.links[cellViewModel.linkPreview?.url ?? ""] == nil
|
|
)
|
|
)
|
|
}()
|
|
let quoteViewContainsTouch: Bool = (visibleCell.quoteView?.bounds.contains(quotePoint) == true)
|
|
let linkPreviewViewContainsTouch: Bool = (visibleCell.linkPreviewView?.previewView.bounds.contains(linkPreviewPoint) == true)
|
|
|
|
switch (containsLinks, quoteViewContainsTouch, linkPreviewViewContainsTouch, cellViewModel.quoteViewModel, cellViewModel.linkPreview) {
|
|
// If the message contains both links and a quote, and the user tapped on the quote; OR the
|
|
// message only contained a quote, then scroll to the quote
|
|
case (true, true, _, .some(let quoteViewModel), _), (false, _, _, .some(let quoteViewModel), _):
|
|
guard
|
|
let quotedInteractionId: Int64 = quoteViewModel.quotedInfo?.interactionId,
|
|
let quotedInteractionTimestampMs: Int64 = quoteViewModel.quotedInfo?.timestampMs
|
|
else { return }
|
|
|
|
self.scrollToInteractionIfNeeded(
|
|
with: Interaction.TimestampInfo(
|
|
id: quotedInteractionId,
|
|
timestampMs: quotedInteractionTimestampMs
|
|
),
|
|
focusBehaviour: .highlight,
|
|
originalIndexPath: self.tableView.indexPath(for: cell)
|
|
)
|
|
|
|
// If the message contains both links and a LinkPreview, and the user tapped on
|
|
// the LinkPreview; OR the message only contained a LinkPreview, then open the link
|
|
case (true, _, true, _, .some(let linkPreview)), (false, _, _, _, .some(let linkPreview)):
|
|
switch linkPreview.variant {
|
|
case .standard: openUrl(linkPreview.url)
|
|
case .openGroupInvitation: joinOpenGroup(name: linkPreview.title, url: linkPreview.url)
|
|
}
|
|
|
|
// If the message contained links then interact with them directly
|
|
case (true, _, _, _, _):
|
|
guard let tappedUrlString: String = visibleCell.bodyLabel?.urlString(at: tappableLabelPoint) else {
|
|
return
|
|
}
|
|
|
|
openUrl(tappedUrlString)
|
|
|
|
default: break
|
|
}
|
|
|
|
default: break
|
|
}
|
|
}
|
|
|
|
func handleItemDoubleTapped(_ cellViewModel: MessageViewModel) {
|
|
switch cellViewModel.cellType {
|
|
// The user can double tap a voice message when it's playing to speed it up
|
|
case .voiceMessage: self.viewModel.speedUpAudio(for: cellViewModel)
|
|
default: break
|
|
}
|
|
}
|
|
|
|
func handleItemSwiped(_ cellViewModel: MessageViewModel, state: SwipeState) {
|
|
switch state {
|
|
case .began: tableView.isScrollEnabled = false
|
|
case .ended, .cancelled: tableView.isScrollEnabled = true
|
|
}
|
|
}
|
|
|
|
func openUrl(_ urlString: String) {
|
|
guard let url: URL = URL(string: urlString) else { return }
|
|
|
|
let modal: ConfirmationModal = ConfirmationModal(
|
|
targetView: self.view,
|
|
info: ConfirmationModal.Info(
|
|
title: "urlOpen".localized(),
|
|
body: .attributedText(
|
|
"urlOpenDescription"
|
|
.put(key: "url", value: url.absoluteString)
|
|
.localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize))
|
|
),
|
|
confirmTitle: "open".localized(),
|
|
confirmStyle: .danger,
|
|
cancelTitle: "urlCopy".localized(),
|
|
cancelStyle: .alert_text,
|
|
hasCloseButton: true,
|
|
onConfirm: { modal in
|
|
UIApplication.shared.open(url, options: [:], completionHandler: nil)
|
|
modal.dismiss(animated: true)
|
|
},
|
|
onCancel: { modal in
|
|
UIPasteboard.general.string = url.absoluteString
|
|
}
|
|
)
|
|
)
|
|
|
|
self.present(modal, animated: true)
|
|
}
|
|
|
|
func handleReplyButtonTapped(for cellViewModel: MessageViewModel) {
|
|
reply(cellViewModel, completion: nil)
|
|
}
|
|
|
|
func showUserProfileModal(for cellViewModel: MessageViewModel) {
|
|
guard viewModel.state.threadInfo.canWrite else { return }
|
|
|
|
Task.detached(priority: .userInitiated) { [weak self, dependencies = viewModel.dependencies] in
|
|
guard
|
|
let info: UserProfileModal.Info = await cellViewModel.createUserProfileModalInfo(
|
|
openGroupServer: self?.viewModel.state.threadInfo.communityInfo?.server,
|
|
openGroupPublicKey: self?.viewModel.state.threadInfo.communityInfo?.publicKey,
|
|
onStartThread: {
|
|
Task.detached(priority: .userInitiated) { [weak self] in
|
|
await self?.startThread(
|
|
with: cellViewModel.authorId,
|
|
openGroupServer: self?.viewModel.state.threadInfo.communityInfo?.server,
|
|
openGroupPublicKey: self?.viewModel.state.threadInfo.communityInfo?.publicKey
|
|
)
|
|
}
|
|
},
|
|
onProBadgeTapped: { [weak self, dependencies] in
|
|
dependencies[singleton: .sessionProManager].showSessionProCTAIfNeeded(
|
|
.generic(renew: (dependencies[singleton: .sessionProManager].currentUserCurrentProState.status == .expired)),
|
|
dismissType: .single,
|
|
afterClosed: { [weak self] in
|
|
self?.snInputView.updateNumberOfCharactersLeft(self?.snInputView.text ?? "")
|
|
},
|
|
presenting: { modal in
|
|
dependencies[singleton: .appContext].frontMostViewController?.present(modal, animated: true)
|
|
}
|
|
)
|
|
},
|
|
using: dependencies
|
|
)
|
|
else { return }
|
|
|
|
await MainActor.run { [weak self] in
|
|
guard let self else { return }
|
|
|
|
let userProfileModal: ModalHostingViewController = ModalHostingViewController(
|
|
modal: UserProfileModal(
|
|
info: info,
|
|
dataManager: viewModel.dependencies[singleton: .imageDataManager]
|
|
)
|
|
)
|
|
present(userProfileModal, animated: true, completion: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
func startThread(
|
|
with sessionId: String,
|
|
openGroupServer: String?,
|
|
openGroupPublicKey: String?
|
|
) async {
|
|
guard viewModel.state.threadInfo.canWrite else { return }
|
|
|
|
let maybeThreadInfo: ConversationInfoViewModel? = try? await viewModel.dependencies[singleton: .storage].writeAsync { [dependencies = viewModel.dependencies] db in
|
|
let targetId: String
|
|
|
|
switch try? SessionId.Prefix(from: sessionId) {
|
|
case .blinded15, .blinded25:
|
|
/// If the sessionId is blinded then check if there is an existing un-blinded thread with the contact and use that,
|
|
/// otherwise just use the blinded id
|
|
guard
|
|
let openGroupServer: String = openGroupServer,
|
|
let openGroupPublicKey: String = openGroupPublicKey
|
|
else { throw StorageError.objectNotFound }
|
|
|
|
let lookup: BlindedIdLookup = try BlindedIdLookup.fetchOrCreate(
|
|
db,
|
|
blindedId: sessionId,
|
|
openGroupServer: openGroupServer,
|
|
openGroupPublicKey: openGroupPublicKey,
|
|
isCheckingForOutbox: false,
|
|
using: dependencies
|
|
)
|
|
|
|
targetId = (lookup.sessionId ?? lookup.blindedId)
|
|
|
|
default: targetId = sessionId
|
|
}
|
|
|
|
try SessionThread.upsert(
|
|
db,
|
|
id: targetId,
|
|
variant: .contact,
|
|
values: SessionThread.TargetValues(
|
|
creationDateTimestamp: .useExistingOrSetTo(
|
|
(dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000)
|
|
),
|
|
shouldBeVisible: .useLibSession,
|
|
isDraft: .useExistingOrSetTo(true)
|
|
),
|
|
using: dependencies
|
|
)
|
|
|
|
return try ConversationViewModel.fetchConversationInfo(
|
|
db,
|
|
threadId: sessionId,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
await MainActor.run { [dependencies = viewModel.dependencies] in
|
|
guard let threadInfo: ConversationInfoViewModel = maybeThreadInfo else {
|
|
self.navigationController?.present(
|
|
ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "theError".localized(),
|
|
body: .text("errorUnknown".localized()),
|
|
cancelTitle: "okay".localized(),
|
|
cancelStyle: .alert_text
|
|
)
|
|
),
|
|
animated: true,
|
|
completion: nil
|
|
)
|
|
return
|
|
}
|
|
|
|
self.navigationController?.pushViewController(
|
|
ConversationVC(
|
|
threadInfo: threadInfo,
|
|
focusedInteractionInfo: nil,
|
|
using: dependencies
|
|
),
|
|
animated: true
|
|
)
|
|
}
|
|
}
|
|
|
|
func showReactionList(_ cellViewModel: MessageViewModel, selectedReaction: EmojiWithSkinTones?) {
|
|
guard
|
|
!cellViewModel.reactionInfo.isEmpty &&
|
|
[
|
|
SessionThread.Variant.legacyGroup,
|
|
SessionThread.Variant.group,
|
|
SessionThread.Variant.community
|
|
].contains(self.viewModel.state.threadVariant),
|
|
let allMessages: [MessageViewModel] = self.sections
|
|
.first(where: { $0.model == .messages })?
|
|
.elements
|
|
else { return }
|
|
|
|
let reactionListSheet: ReactionListSheet = ReactionListSheet(for: cellViewModel.id, using: viewModel.dependencies) { [weak self] in
|
|
self?.currentReactionListSheet = nil
|
|
}
|
|
reactionListSheet.delegate = self
|
|
reactionListSheet.handleInteractionUpdates(
|
|
allMessages,
|
|
selectedReaction: selectedReaction,
|
|
initialLoad: true,
|
|
shouldShowClearAllButton: viewModel.state.isUserModeratorOrAdmin
|
|
)
|
|
reactionListSheet.modalPresentationStyle = .overFullScreen
|
|
present(reactionListSheet, animated: true, completion: nil)
|
|
|
|
// Store so we can updated the content based on the current VC
|
|
self.currentReactionListSheet = reactionListSheet
|
|
}
|
|
|
|
func needsLayout(for cellViewModel: MessageViewModel, expandingReactions: Bool) {
|
|
guard
|
|
let messageSectionIndex: Int = self.sections
|
|
.firstIndex(where: { $0.model == .messages }),
|
|
let targetMessageIndex = self.sections[messageSectionIndex]
|
|
.elements
|
|
.firstIndex(where: { $0.id == cellViewModel.id })
|
|
else { return }
|
|
|
|
if expandingReactions {
|
|
self.viewModel.expandReactions(for: cellViewModel.id)
|
|
}
|
|
else {
|
|
self.viewModel.collapseReactions(for: cellViewModel.id)
|
|
}
|
|
|
|
UIView.setAnimationsEnabled(false)
|
|
tableView.reloadRows(
|
|
at: [IndexPath(row: targetMessageIndex, section: messageSectionIndex)],
|
|
with: .none
|
|
)
|
|
|
|
// Only re-enable animations if the feature flag isn't disabled
|
|
if viewModel.dependencies[feature: .animationsEnabled] {
|
|
UIView.setAnimationsEnabled(true)
|
|
}
|
|
}
|
|
|
|
func handleReadMoreButtonTapped(_ cell: UITableViewCell, for cellViewModel: MessageViewModel) {
|
|
self.viewModel.expandMessage(for: cellViewModel.id)
|
|
|
|
UIView.setAnimationsEnabled(false)
|
|
cell.setNeedsLayout()
|
|
cell.layoutIfNeeded()
|
|
tableView.beginUpdates()
|
|
tableView.endUpdates()
|
|
|
|
// Only re-enable animations if the feature flag isn't disabled
|
|
if viewModel.dependencies[feature: .animationsEnabled] {
|
|
UIView.setAnimationsEnabled(true)
|
|
}
|
|
}
|
|
|
|
func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones) {
|
|
Task.detached(priority: .userInitiated) { [weak self] in
|
|
await self?.react(cellViewModel, with: emoji.rawValue, remove: false)
|
|
}
|
|
}
|
|
|
|
func removeReact(_ cellViewModel: MessageViewModel, for emoji: EmojiWithSkinTones) {
|
|
guard viewModel.state.threadVariant != .legacyGroup else { return }
|
|
|
|
Task.detached(priority: .userInitiated) { [weak self] in
|
|
await self?.react(cellViewModel, with: emoji.rawValue, remove: true)
|
|
}
|
|
}
|
|
|
|
func removeAllReactions(_ cellViewModel: MessageViewModel, for emoji: String) {
|
|
// Dismiss current reaction sheet to present alert dialog
|
|
currentReactionListSheet?.dismiss(animated: true)
|
|
currentReactionListSheet = nil
|
|
|
|
let modal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "clearAll".localized(),
|
|
body: .attributedText(
|
|
"emojiReactsClearAll"
|
|
.put(key: "emoji", value: emoji)
|
|
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
|
|
),
|
|
confirmTitle: "clear".localized(),
|
|
confirmStyle: .danger,
|
|
cancelStyle: .alert_text,
|
|
onConfirm: { [weak self] modal in
|
|
// Call clear reaction event
|
|
Task.detached(priority: .userInitiated) {
|
|
await self?.clearAllReactions(cellViewModel, for: emoji)
|
|
}
|
|
modal.dismiss(animated: true)
|
|
}
|
|
)
|
|
)
|
|
|
|
present(modal, animated: true, completion: nil)
|
|
}
|
|
|
|
func clearAllReactions(_ cellViewModel: MessageViewModel, for emoji: String) async {
|
|
guard
|
|
cellViewModel.threadVariant == .community,
|
|
let communityInfo: ConversationInfoViewModel.CommunityInfo = viewModel.state.threadInfo.communityInfo,
|
|
let openGroupServerMessageId: Int64 = cellViewModel.openGroupServerMessageId
|
|
else { return }
|
|
|
|
do {
|
|
let pendingChange: CommunityManager.PendingChange = await viewModel.dependencies[singleton: .communityManager]
|
|
.addPendingReaction(
|
|
emoji: emoji,
|
|
id: openGroupServerMessageId,
|
|
in: communityInfo.roomToken,
|
|
on: communityInfo.server,
|
|
type: .removeAll
|
|
)
|
|
let request = try Network.SOGS.preparedReactionDeleteAll(
|
|
emoji: emoji,
|
|
id: openGroupServerMessageId,
|
|
roomToken: communityInfo.roomToken,
|
|
authMethod: Authentication.Community(
|
|
info: LibSession.OpenGroupCapabilityInfo(
|
|
roomToken: communityInfo.roomToken,
|
|
server: communityInfo.server,
|
|
publicKey: communityInfo.publicKey,
|
|
capabilities: communityInfo.capabilities
|
|
)
|
|
),
|
|
using: viewModel.dependencies
|
|
)
|
|
|
|
// FIXME: Make this async/await when the refactored networking is merged
|
|
let response: Network.SOGS.ReactionRemoveAllResponse = try await request
|
|
.send(using: viewModel.dependencies)
|
|
.values
|
|
.first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }()
|
|
|
|
await viewModel.dependencies[singleton: .communityManager].updatePendingChange(
|
|
pendingChange,
|
|
seqNo: response.seqNo
|
|
)
|
|
|
|
try await viewModel.dependencies[singleton: .storage].writeAsync { db in
|
|
let rowIds: [Int64] = try Reaction
|
|
.select(Column.rowID)
|
|
.filter(Reaction.Columns.interactionId == cellViewModel.id)
|
|
.filter(Reaction.Columns.emoji == emoji)
|
|
.asRequest(of: Int64.self)
|
|
.fetchAll(db)
|
|
|
|
_ = try Reaction
|
|
.filter(Reaction.Columns.interactionId == cellViewModel.id)
|
|
.filter(Reaction.Columns.emoji == emoji)
|
|
.deleteAll(db)
|
|
|
|
rowIds.forEach {
|
|
db.addReactionEvent(
|
|
id: $0,
|
|
messageId: cellViewModel.id,
|
|
change: .removed(emoji)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
// FIXME: Should probably handle this error
|
|
}
|
|
}
|
|
|
|
func react(_ cellViewModel: MessageViewModel, with emoji: String, remove: Bool) async {
|
|
guard
|
|
self.viewModel.state.reactionsSupported &&
|
|
!self.viewModel.state.threadInfo.isMessageRequest && (
|
|
cellViewModel.variant == .standardIncoming ||
|
|
cellViewModel.variant == .standardOutgoing
|
|
)
|
|
else { return }
|
|
|
|
// Perform local rate limiting (don't allow more than 20 reactions within 60 seconds)
|
|
let threadId: String = self.viewModel.state.threadId
|
|
let threadVariant: SessionThread.Variant = self.viewModel.state.threadVariant
|
|
let communityInfo: ConversationInfoViewModel.CommunityInfo? = self.viewModel.state.threadInfo.communityInfo
|
|
let authMethod: AuthenticationMethod = self.viewModel.state.authMethod.value
|
|
let sentTimestampMs: Int64 = viewModel.dependencies[cache: .snodeAPI].currentOffsetTimestampMs()
|
|
let recentReactionTimestamps: [Int64] = viewModel.dependencies[cache: .general].recentReactionTimestamps
|
|
let currentUserSessionIds: Set<String> = viewModel.state.threadInfo.currentUserSessionIds
|
|
|
|
guard
|
|
recentReactionTimestamps.count < 20 ||
|
|
(sentTimestampMs - (recentReactionTimestamps.first ?? sentTimestampMs)) > (60 * 1000)
|
|
else {
|
|
await MainActor.run {
|
|
let toastController: ToastController = ToastController(
|
|
text: "emojiReactsCoolDown".localized(),
|
|
background: .backgroundSecondary
|
|
)
|
|
toastController.presentToastView(
|
|
fromBottomOfView: self.view,
|
|
inset: (snInputView.bounds.height + Values.largeSpacing),
|
|
duration: .milliseconds(2500)
|
|
)
|
|
}
|
|
return
|
|
}
|
|
|
|
viewModel.dependencies.mutate(cache: .general) {
|
|
$0.recentReactionTimestamps = Array($0.recentReactionTimestamps
|
|
.suffix(19))
|
|
.appending(sentTimestampMs)
|
|
}
|
|
|
|
/// Perform the sending logic, we generate the pending reaction first in a deferred future closure to prevent the OpenGroup
|
|
/// cache from blocking either the main thread or the database write thread
|
|
var pendingReaction: Reaction?
|
|
var pendingChange: CommunityManager.PendingChange?
|
|
|
|
do {
|
|
// Create the pending change if we have open group info
|
|
let threadShouldBeVisible: Bool? = self.viewModel.state.threadInfo.shouldBeVisible
|
|
|
|
if threadVariant == .community {
|
|
guard
|
|
let serverMessageId: Int64 = cellViewModel.openGroupServerMessageId,
|
|
let communityInfo: ConversationInfoViewModel.CommunityInfo = communityInfo
|
|
else { throw MessageError.invalidMessage("Missing community info for adding reaction") }
|
|
|
|
pendingChange = await viewModel.dependencies[singleton: .communityManager].addPendingReaction(
|
|
emoji: emoji,
|
|
id: serverMessageId,
|
|
in: communityInfo.server,
|
|
on: communityInfo.publicKey,
|
|
type: (remove ? .remove : .add)
|
|
)
|
|
}
|
|
|
|
let destination: Message.Destination = try await viewModel.dependencies[singleton: .storage].writeAsync { [state = viewModel.state, dependencies = viewModel.dependencies] db in
|
|
// Update the thread to be visible (if it isn't already)
|
|
try SessionThread.update(
|
|
db,
|
|
id: cellViewModel.threadId,
|
|
values: SessionThread.TargetValues(
|
|
shouldBeVisible: .setTo(true)
|
|
),
|
|
using: dependencies
|
|
)
|
|
|
|
// Get the pending reaction
|
|
if remove {
|
|
pendingReaction = try? Reaction
|
|
.filter(Reaction.Columns.interactionId == cellViewModel.id)
|
|
.filter(currentUserSessionIds.contains(Reaction.Columns.authorId))
|
|
.filter(Reaction.Columns.emoji == emoji)
|
|
.fetchOne(db)
|
|
}
|
|
else {
|
|
let sortId: Int64 = Reaction.getSortId(
|
|
db,
|
|
interactionId: cellViewModel.id,
|
|
emoji: emoji
|
|
)
|
|
|
|
pendingReaction = Reaction(
|
|
interactionId: cellViewModel.id,
|
|
serverHash: nil,
|
|
timestampMs: sentTimestampMs,
|
|
authorId: state.userSessionId.hexString,
|
|
emoji: emoji,
|
|
count: 1,
|
|
sortId: sortId
|
|
)
|
|
}
|
|
|
|
// Update the database
|
|
if remove {
|
|
let maybeRowId: Int64? = try Reaction
|
|
.select(Column.rowID)
|
|
.filter(Reaction.Columns.interactionId == cellViewModel.id)
|
|
.filter(currentUserSessionIds.contains(Reaction.Columns.authorId))
|
|
.filter(Reaction.Columns.emoji == emoji)
|
|
.asRequest(of: Int64.self)
|
|
.fetchOne(db)
|
|
|
|
try Reaction
|
|
.filter(Reaction.Columns.interactionId == cellViewModel.id)
|
|
.filter(currentUserSessionIds.contains(Reaction.Columns.authorId))
|
|
.filter(Reaction.Columns.emoji == emoji)
|
|
.deleteAll(db)
|
|
|
|
if let rowId: Int64 = maybeRowId {
|
|
db.addReactionEvent(
|
|
id: rowId,
|
|
messageId: cellViewModel.id,
|
|
change: .removed(emoji)
|
|
)
|
|
}
|
|
}
|
|
else {
|
|
try pendingReaction?.insert(db)
|
|
db.addReactionEvent(
|
|
id: db.lastInsertedRowID,
|
|
messageId: cellViewModel.id,
|
|
change: .added(emoji)
|
|
)
|
|
|
|
// Add it to the recent list
|
|
Emoji.addRecent(db, emoji: emoji)
|
|
}
|
|
|
|
return try Message.Destination.from(db, threadId: threadId, threadVariant: threadVariant)
|
|
}
|
|
|
|
switch threadVariant {
|
|
case .community:
|
|
guard
|
|
let serverMessageId: Int64 = cellViewModel.openGroupServerMessageId,
|
|
let communityInfo: ConversationInfoViewModel.CommunityInfo = communityInfo
|
|
else { throw MessageError.invalidMessage("Missing community info for adding reaction") }
|
|
guard !authMethod.isInvalid else {
|
|
throw MessageError.invalidMessage("Invalid auth method for adding reaction")
|
|
}
|
|
|
|
let request: Network.PreparedRequest<Int64?>
|
|
|
|
if remove {
|
|
request = try Network.SOGS
|
|
.preparedReactionDelete(
|
|
emoji: emoji,
|
|
id: serverMessageId,
|
|
roomToken: communityInfo.roomToken,
|
|
authMethod: authMethod,
|
|
using: viewModel.dependencies
|
|
)
|
|
.map { _, response in response.seqNo }
|
|
}
|
|
else {
|
|
request = try Network.SOGS
|
|
.preparedReactionAdd(
|
|
emoji: emoji,
|
|
id: serverMessageId,
|
|
roomToken: communityInfo.roomToken,
|
|
authMethod: authMethod,
|
|
using: viewModel.dependencies
|
|
)
|
|
.map { _, response in response.seqNo }
|
|
}
|
|
|
|
// FIXME: Make this async/await when the refactored networking is merged
|
|
let seqNo: Int64? = try await request
|
|
.send(using: viewModel.dependencies)
|
|
.values
|
|
.first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }()
|
|
|
|
if let pendingChange: CommunityManager.PendingChange = pendingChange {
|
|
await viewModel.dependencies[singleton: .communityManager].updatePendingChange(
|
|
pendingChange,
|
|
seqNo: seqNo
|
|
)
|
|
}
|
|
|
|
default:
|
|
let request: Network.PreparedRequest<Message> = try MessageSender.preparedSend(
|
|
message: VisibleMessage(
|
|
sentTimestampMs: UInt64(sentTimestampMs),
|
|
text: nil,
|
|
reaction: VisibleMessage.VMReaction(
|
|
timestamp: UInt64(cellViewModel.timestampMs),
|
|
publicKey: {
|
|
guard cellViewModel.variant == .standardIncoming else {
|
|
return viewModel.state.userSessionId.hexString
|
|
}
|
|
|
|
return cellViewModel.authorId
|
|
}(),
|
|
emoji: emoji,
|
|
kind: (remove ? .remove : .react)
|
|
)
|
|
),
|
|
to: destination,
|
|
namespace: destination.defaultNamespace,
|
|
interactionId: cellViewModel.id,
|
|
attachments: nil,
|
|
authMethod: authMethod,
|
|
onEvent: MessageSender.standardEventHandling(using: viewModel.dependencies),
|
|
using: viewModel.dependencies
|
|
)
|
|
// FIXME: Make this async/await when the refactored networking is merged
|
|
_ = try await request
|
|
.send(using: viewModel.dependencies)
|
|
.values
|
|
.first(where: { _ in true })?.1 ?? { throw NetworkError.invalidResponse }()
|
|
}
|
|
}
|
|
catch {
|
|
if let pendingChange: CommunityManager.PendingChange = pendingChange {
|
|
await viewModel.dependencies[singleton: .communityManager].removePendingChange(pendingChange)
|
|
}
|
|
|
|
await handleReactionSentFailure(pendingReaction, remove: remove)
|
|
}
|
|
}
|
|
|
|
func handleReactionSentFailure(_ pendingReaction: Reaction?, remove: Bool) async {
|
|
guard let pendingReaction = pendingReaction else { return }
|
|
|
|
try? await viewModel.dependencies[singleton: .storage].writeAsync { db in
|
|
// Reverse the database
|
|
if remove {
|
|
try pendingReaction.insert(db)
|
|
}
|
|
else {
|
|
try Reaction
|
|
.filter(Reaction.Columns.interactionId == pendingReaction.interactionId)
|
|
.filter(Reaction.Columns.authorId == pendingReaction.authorId)
|
|
.filter(Reaction.Columns.emoji == pendingReaction.emoji)
|
|
.deleteAll(db)
|
|
}
|
|
}
|
|
}
|
|
|
|
func showFullEmojiKeyboard(_ cellViewModel: MessageViewModel) {
|
|
let emojiPicker = EmojiPickerSheet(
|
|
completionHandler: { [weak self] emoji in
|
|
guard let emoji: EmojiWithSkinTones = emoji else { return }
|
|
|
|
self?.react(cellViewModel, with: emoji)
|
|
},
|
|
using: self.viewModel.dependencies
|
|
)
|
|
|
|
present(emojiPicker, animated: true, completion: nil)
|
|
}
|
|
|
|
func contextMenuDismissed() {
|
|
recoverInputView()
|
|
}
|
|
|
|
// MARK: --action handling
|
|
|
|
func joinOpenGroup(name: String?, url: String) {
|
|
// Open groups can be unsafe, so always ask the user whether they want to join one
|
|
let modal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "communityJoin".localized(),
|
|
body: .attributedText(
|
|
"communityJoinDescription"
|
|
.put(key: "community_name", value: (name ?? "communityUnknown".localized()))
|
|
.localizedFormatted(baseFont: ConfirmationModal.explanationFont)
|
|
),
|
|
confirmTitle: "join".localized(),
|
|
onConfirm: { [dependencies = viewModel.dependencies] modal in
|
|
guard let presentingViewController: UIViewController = modal.presentingViewController else {
|
|
return
|
|
}
|
|
|
|
guard let (room, server, publicKey) = LibSession.parseCommunity(url: url) else {
|
|
let errorModal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "communityJoinError".localized(),
|
|
cancelTitle: "okay".localized(),
|
|
cancelStyle: .alert_text
|
|
)
|
|
)
|
|
|
|
return presentingViewController.present(errorModal, animated: true, completion: nil)
|
|
}
|
|
|
|
dependencies[singleton: .storage]
|
|
.writePublisher { db in
|
|
dependencies[singleton: .communityManager].add(
|
|
db,
|
|
roomToken: room,
|
|
server: server,
|
|
publicKey: publicKey,
|
|
joinedAt: (dependencies[cache: .snodeAPI].currentOffsetTimestampMs() / 1000),
|
|
forceVisible: false
|
|
)
|
|
}
|
|
.flatMap { successfullyAddedGroup in
|
|
dependencies[singleton: .communityManager].performInitialRequestsAfterAdd(
|
|
queue: DispatchQueue.global(qos: .userInitiated),
|
|
successfullyAddedGroup: successfullyAddedGroup,
|
|
roomToken: room,
|
|
server: server,
|
|
publicKey: publicKey
|
|
)
|
|
}
|
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
|
.receive(on: DispatchQueue.main)
|
|
.sinkUntilComplete(
|
|
receiveCompletion: { result in
|
|
switch result {
|
|
case .finished: break
|
|
case .failure(let error):
|
|
// If there was a failure then the group will be in invalid state until
|
|
// the next launch so remove it (the user will be left on the previous
|
|
// screen so can re-trigger the join)
|
|
dependencies[singleton: .storage].writeAsync { db in
|
|
try dependencies[singleton: .communityManager].delete(
|
|
db,
|
|
openGroupId: OpenGroup.idFor(roomToken: room, server: server),
|
|
skipLibSessionUpdate: false
|
|
)
|
|
}
|
|
|
|
// Show the user an error indicating they failed to properly join the group
|
|
let errorModal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "communityJoinError".localized(),
|
|
body: .text("\(error)"),
|
|
cancelTitle: "okay".localized(),
|
|
cancelStyle: .alert_text
|
|
)
|
|
)
|
|
|
|
presentingViewController.present(errorModal, animated: true, completion: nil)
|
|
}
|
|
}
|
|
)
|
|
}
|
|
)
|
|
)
|
|
|
|
present(modal, animated: true, completion: nil)
|
|
}
|
|
|
|
// MARK: - ContextMenuActionDelegate
|
|
|
|
func info(_ cellViewModel: MessageViewModel) {
|
|
let actions: [ContextMenuVC.Action] = ContextMenuVC.actions(
|
|
for: cellViewModel,
|
|
threadInfo: self.viewModel.state.threadInfo,
|
|
authMethod: self.viewModel.state.authMethod.value,
|
|
reactionsSupported: self.viewModel.state.reactionsSupported,
|
|
recentReactionEmoji: self.viewModel.state.recentReactionEmoji,
|
|
isUserModeratorOrAdmin: self.viewModel.state.isUserModeratorOrAdmin,
|
|
forMessageInfoScreen: true,
|
|
delegate: self,
|
|
using: viewModel.dependencies
|
|
) ?? []
|
|
|
|
let messageInfoViewController = MessageInfoViewController(
|
|
actions: actions,
|
|
messageViewModel: cellViewModel,
|
|
threadCanWrite: viewModel.state.threadInfo.canWrite,
|
|
openGroupServer: viewModel.state.threadInfo.communityInfo?.server,
|
|
openGroupPublicKey: viewModel.state.threadInfo.communityInfo?.publicKey,
|
|
onStartThread: { [weak self] in
|
|
Task.detached(priority: .userInitiated) { [weak self] in
|
|
await self?.startThread(
|
|
with: cellViewModel.authorId,
|
|
openGroupServer: self?.viewModel.state.threadInfo.communityInfo?.server,
|
|
openGroupPublicKey: self?.viewModel.state.threadInfo.communityInfo?.publicKey
|
|
)
|
|
}
|
|
},
|
|
using: viewModel.dependencies
|
|
)
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
|
self?.navigationController?.pushViewController(messageInfoViewController, animated: true)
|
|
}
|
|
}
|
|
|
|
@MainActor func retry(_ cellViewModel: MessageViewModel, completion: (@MainActor () -> Void)?) {
|
|
guard cellViewModel.optimisticMessageId == nil else {
|
|
guard
|
|
let optimisticMessageId: Int64 = cellViewModel.optimisticMessageId,
|
|
let optimisticMessageData: ConversationViewModel.OptimisticMessageData = self.viewModel.state
|
|
.optimisticallyInsertedMessages[optimisticMessageId]
|
|
else {
|
|
// Show an error for the retry
|
|
let modal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "theError".localized(),
|
|
body: .text("shareExtensionDatabaseError".localized()),
|
|
cancelTitle: "okay".localized(),
|
|
cancelStyle: .alert_text,
|
|
afterClosed: {
|
|
completion?()
|
|
}
|
|
)
|
|
)
|
|
|
|
self.present(modal, animated: true, completion: nil)
|
|
return
|
|
}
|
|
|
|
// Try to send the optimistic message again
|
|
Task.detached(priority: .userInitiated) { [weak self] in
|
|
await self?.sendMessage(optimisticData: optimisticMessageData)
|
|
await MainActor.run {
|
|
completion?()
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
viewModel.dependencies[singleton: .storage].writeAsync { [weak self, dependencies = viewModel.dependencies] db in
|
|
guard
|
|
let threadId: String = self?.viewModel.state.threadId,
|
|
let threadVariant: SessionThread.Variant = self?.viewModel.state.threadVariant,
|
|
let interaction: Interaction = try? Interaction.fetchOne(db, id: cellViewModel.id)
|
|
else { return }
|
|
|
|
// Remove message sending jobs for the same interaction in database
|
|
// Prevent the same message being sent twice
|
|
try Job.filter(Job.Columns.interactionId == interaction.id).deleteAll(db)
|
|
|
|
try MessageSender.send(
|
|
db,
|
|
interaction: interaction,
|
|
threadId: threadId,
|
|
threadVariant: threadVariant,
|
|
isSyncMessage: (cellViewModel.state == .failedToSync),
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
completion?()
|
|
}
|
|
|
|
@MainActor func reply(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) {
|
|
guard
|
|
cellViewModel.variant == .standardOutgoing ||
|
|
cellViewModel.variant == .standardIncoming
|
|
else { return }
|
|
guard
|
|
(cellViewModel.bubbleBody ?? "")?.isEmpty == false ||
|
|
!cellViewModel.attachments.isEmpty
|
|
else { return }
|
|
|
|
snInputView.quoteViewModel = viewModel.draftQuote(for: cellViewModel)
|
|
|
|
// If the `MessageInfoViewController` is visible then we want to show the keyboard after
|
|
// the pop transition completes (and don't want to delay triggering the completion closure)
|
|
let messageInfoScreenVisible: Bool = (self.navigationController?.viewControllers.last is MessageInfoViewController)
|
|
|
|
guard !messageInfoScreenVisible else {
|
|
if self.isShowingSearchUI == true { self.willManuallyCancelSearchUI() }
|
|
self.hasPendingInputKeyboardPresentationEvent = true
|
|
completion?()
|
|
return
|
|
}
|
|
|
|
// Add delay before doing any ui updates
|
|
// Delay added to give time for long press actions to dismiss
|
|
let delay = completion == nil ? 0 : ContextMenuVC.dismissDuration
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
if self?.isShowingSearchUI == true { self?.willManuallyCancelSearchUI() }
|
|
_ = self?.snInputView.becomeFirstResponder()
|
|
completion?()
|
|
}
|
|
}
|
|
|
|
func copy(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) {
|
|
switch cellViewModel.cellType {
|
|
case .typingIndicator, .dateHeader, .unreadMarker, .infoMessage, .call: break
|
|
|
|
case .textOnlyMessage:
|
|
if cellViewModel.bodyForCopying == nil, let linkPreview: LinkPreview = cellViewModel.linkPreview {
|
|
UIPasteboard.general.string = linkPreview.url
|
|
return
|
|
}
|
|
else if let value: String = cellViewModel.bodyForCopying {
|
|
/// Don't override the pasteboard with a null value
|
|
UIPasteboard.general.string = value
|
|
}
|
|
|
|
case .audio, .voiceMessage, .genericAttachment, .mediaMessage:
|
|
guard
|
|
cellViewModel.attachments.count == 1,
|
|
let attachment: Attachment = cellViewModel.attachments.first,
|
|
attachment.isValid,
|
|
(
|
|
attachment.state == .downloaded ||
|
|
attachment.state == .uploaded
|
|
),
|
|
let type: UTType = UTType(sessionMimeType: attachment.contentType),
|
|
let path: String = try? viewModel.dependencies[singleton: .attachmentManager]
|
|
.path(for: attachment.downloadUrl),
|
|
let data: Data = try? Data(contentsOf: URL(fileURLWithPath: path))
|
|
else { return }
|
|
|
|
UIPasteboard.general.setData(data, forPasteboardType: type.identifier)
|
|
}
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(ContextMenuVC.dismissDurationPartOne * 1000))) { [weak self] in
|
|
self?.viewModel.showToast(
|
|
text: "copied".localized(),
|
|
backgroundColor: .toast_background,
|
|
inset: Values.largeSpacing + (self?.inputAccessoryView?.frame.height ?? 0)
|
|
)
|
|
}
|
|
|
|
completion?()
|
|
}
|
|
|
|
func copySessionID(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) {
|
|
guard cellViewModel.variant == .standardIncoming else { return }
|
|
|
|
UIPasteboard.general.string = cellViewModel.authorId
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(ContextMenuVC.dismissDurationPartOne * 1000))) { [weak self] in
|
|
self?.viewModel.showToast(
|
|
text: "copied".localized(),
|
|
backgroundColor: .toast_background,
|
|
inset: Values.largeSpacing + (self?.inputAccessoryView?.frame.height ?? 0)
|
|
)
|
|
}
|
|
|
|
completion?()
|
|
}
|
|
|
|
func delete(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) {
|
|
/// Retrieve the deletion actions for the selected message(s) of there are any
|
|
let messagesToDelete: [MessageViewModel] = [cellViewModel]
|
|
let deletionBehaviours: MessageViewModel.DeletionBehaviours
|
|
|
|
do {
|
|
guard let behaviours: MessageViewModel.DeletionBehaviours = try self.viewModel.deletionActions(for: messagesToDelete) else {
|
|
return
|
|
}
|
|
|
|
deletionBehaviours = behaviours
|
|
}
|
|
catch {
|
|
Log.error(.conversation, "Failed to retrieve deletion actions due to error: \(error)")
|
|
return
|
|
}
|
|
|
|
let modal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: deletionBehaviours.title,
|
|
body: .radio(
|
|
explanation: ThemedAttributedString(string: deletionBehaviours.body),
|
|
warning: deletionBehaviours.warning.map { ThemedAttributedString(string: $0) },
|
|
options: deletionBehaviours.actions.map { action in
|
|
ConfirmationModal.Info.Body.RadioOptionInfo(
|
|
title: action.title,
|
|
enabled: action.state != .disabled,
|
|
selected: action.state == .enabledAndDefaultSelected,
|
|
accessibility: action.accessibility
|
|
)
|
|
}
|
|
),
|
|
confirmTitle: "delete".localized(),
|
|
confirmStyle: .danger,
|
|
cancelTitle: "cancel".localized(),
|
|
cancelStyle: .alert_text,
|
|
dismissOnConfirm: false,
|
|
onConfirm: { [weak self, dependencies = viewModel.dependencies] modal in
|
|
/// Determine the selected action index
|
|
let selectedIndex: Int = {
|
|
switch modal.info.body {
|
|
case .radio(_, _, let options):
|
|
return options
|
|
.enumerated()
|
|
.first(where: { _, value in value.selected })
|
|
.map { index, _ in index }
|
|
.defaulting(to: 0)
|
|
|
|
default: return 0
|
|
}
|
|
}()
|
|
|
|
/// Stop the messages audio if needed
|
|
messagesToDelete.forEach { cellViewModel in
|
|
self?.viewModel.stopAudioIfNeeded(for: cellViewModel)
|
|
}
|
|
|
|
/// Trigger the deletion behaviours
|
|
deletionBehaviours
|
|
.publisherForAction(at: selectedIndex, using: dependencies)
|
|
.showingBlockingLoading(
|
|
in: deletionBehaviours.requiresNetworkRequestForAction(at: selectedIndex) ?
|
|
self?.viewModel.navigatableState :
|
|
nil
|
|
)
|
|
.sinkUntilComplete(
|
|
receiveCompletion: { result in
|
|
DispatchQueue.main.async {
|
|
switch result {
|
|
case .finished:
|
|
modal.dismiss(animated: true) {
|
|
/// Dispatch after a delay because becoming the first responder can cause
|
|
/// an odd appearance animation
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(150)) {
|
|
self?.viewModel.showToast(
|
|
text: "deleteMessageDeleted"
|
|
.putNumber(messagesToDelete.count)
|
|
.localized(),
|
|
backgroundColor: .backgroundSecondary,
|
|
inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing
|
|
)
|
|
}
|
|
}
|
|
|
|
case .failure:
|
|
self?.viewModel.showToast(
|
|
text: "deleteMessageFailed"
|
|
.putNumber(messagesToDelete.count)
|
|
.localized(),
|
|
backgroundColor: .backgroundSecondary,
|
|
inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing
|
|
)
|
|
}
|
|
completion?()
|
|
}
|
|
}
|
|
)
|
|
}
|
|
)
|
|
)
|
|
|
|
/// Show the modal after a small delay so it doesn't look as weird with the context menu dismissal
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(ContextMenuVC.dismissDurationPartOne * 1000))) { [weak self] in
|
|
self?.present(modal, animated: true)
|
|
self?.resignFirstResponder()
|
|
}
|
|
}
|
|
|
|
func save(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) {
|
|
let validAttachments: [(Attachment, String)] = cellViewModel.attachments
|
|
.filter { attachment in
|
|
attachment.isValid && (
|
|
cellViewModel.cellType != .mediaMessage ||
|
|
attachment.isVisualMedia
|
|
) && (
|
|
attachment.state == .downloaded ||
|
|
attachment.state == .uploaded
|
|
)
|
|
}
|
|
.compactMap { attachment in
|
|
guard
|
|
let path: String = try? viewModel.dependencies[singleton: .attachmentManager]
|
|
.createTemporaryFileForOpening(
|
|
downloadUrl: attachment.downloadUrl,
|
|
mimeType: attachment.contentType,
|
|
sourceFilename: attachment.sourceFilename
|
|
),
|
|
viewModel.dependencies[singleton: .fileManager].fileExists(atPath: path)
|
|
else { return nil }
|
|
|
|
return (attachment, path)
|
|
}
|
|
|
|
guard !validAttachments.isEmpty else { return }
|
|
|
|
switch cellViewModel.cellType {
|
|
case .audio, .genericAttachment:
|
|
let documentPicker = UIDocumentPickerViewController(
|
|
forExporting: validAttachments.map { _, path in URL(fileURLWithPath: path) },
|
|
asCopy: true
|
|
)
|
|
|
|
self.documentHandler = DocumentPickerHandler(
|
|
didPickDocumentsAt: { [weak self, dependencies = viewModel.dependencies] _, _ in
|
|
validAttachments.forEach { attachment, path in
|
|
/// Sanity check to make sure we don't unintentionally remove a proper attachment file
|
|
guard dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(path) else {
|
|
return
|
|
}
|
|
|
|
try? dependencies[singleton: .fileManager].removeItem(atPath: path)
|
|
}
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(ContextMenuVC.dismissDurationPartOne * 1000))) { [weak self] in
|
|
self?.viewModel.showToast(
|
|
text: "saved".localized(),
|
|
backgroundColor: .toast_background,
|
|
inset: Values.largeSpacing + (self?.inputAccessoryView?.frame.height ?? 0)
|
|
)
|
|
|
|
// Send a 'media saved' notification if needed
|
|
guard self?.viewModel.state.threadVariant == .contact, cellViewModel.variant == .standardIncoming else {
|
|
return
|
|
}
|
|
|
|
self?.sendDataExtraction(kind: .mediaSaved(timestamp: UInt64(cellViewModel.timestampMs)))
|
|
}
|
|
|
|
self?.documentHandler = nil
|
|
},
|
|
wasCancelled: { [weak self] _ in
|
|
self?.documentHandler = nil
|
|
}
|
|
)
|
|
documentPicker.delegate = documentHandler
|
|
present(documentPicker, animated: true)
|
|
|
|
case .mediaMessage:
|
|
Permissions.requestLibraryPermissionIfNeeded(
|
|
isSavingMedia: true,
|
|
presentingViewController: self,
|
|
using: viewModel.dependencies
|
|
) { [weak self, threadVariant = viewModel.state.threadVariant, dependencies = viewModel.dependencies] granted in
|
|
guard granted else { return }
|
|
|
|
PHPhotoLibrary.shared().performChanges(
|
|
{
|
|
validAttachments.forEach { attachment, path in
|
|
if attachment.isImage || attachment.isAnimated {
|
|
PHAssetChangeRequest.creationRequestForAssetFromImage(
|
|
atFileURL: URL(fileURLWithPath: path)
|
|
)
|
|
}
|
|
else if attachment.isVideo {
|
|
PHAssetChangeRequest.creationRequestForAssetFromVideo(
|
|
atFileURL: URL(fileURLWithPath: path)
|
|
)
|
|
}
|
|
}
|
|
},
|
|
completionHandler: { [weak self, dependencies] _, _ in
|
|
validAttachments.forEach { attachment, path in
|
|
/// Sanity check to make sure we don't unintentionally remove a proper attachment file
|
|
guard dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(path) else {
|
|
return
|
|
}
|
|
|
|
try? dependencies[singleton: .fileManager].removeItem(atPath: path)
|
|
}
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(ContextMenuVC.dismissDurationPartOne * 1000))) { [weak self] in
|
|
self?.viewModel.showToast(
|
|
text: "saved".localized(),
|
|
backgroundColor: .toast_background,
|
|
inset: Values.largeSpacing + (self?.inputAccessoryView?.frame.height ?? 0)
|
|
)
|
|
}
|
|
|
|
// Send a 'media saved' notification if needed
|
|
guard threadVariant == .contact, cellViewModel.variant == .standardIncoming else {
|
|
return
|
|
}
|
|
|
|
self?.sendDataExtraction(kind: .mediaSaved(timestamp: UInt64(cellViewModel.timestampMs)))
|
|
}
|
|
)
|
|
}
|
|
|
|
completion?()
|
|
|
|
default: break
|
|
}
|
|
}
|
|
|
|
func ban(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) {
|
|
guard cellViewModel.threadVariant == .community else { return }
|
|
|
|
let modal: ConfirmationModal = ConfirmationModal(
|
|
targetView: self.view,
|
|
info: ConfirmationModal.Info(
|
|
title: "banUser".localized(),
|
|
body: .text("communityBanDescription".localized()),
|
|
confirmTitle: "theContinue".localized(),
|
|
confirmStyle: .danger,
|
|
cancelStyle: .alert_text,
|
|
onConfirm: { [weak self, threadInfo = viewModel.state.threadInfo, authMethod = viewModel.state.authMethod.value, dependencies = viewModel.dependencies] _ in
|
|
Result {
|
|
guard
|
|
cellViewModel.threadVariant == .community,
|
|
let roomToken: String = threadInfo.communityInfo?.roomToken,
|
|
!authMethod.isInvalid,
|
|
cellViewModel.openGroupServerMessageId != nil
|
|
else { throw CryptoError.invalidAuthentication }
|
|
|
|
return roomToken
|
|
}
|
|
.publisher
|
|
.tryFlatMap { roomToken in
|
|
try Network.SOGS.preparedUserBan(
|
|
sessionId: cellViewModel.authorId,
|
|
from: [roomToken],
|
|
authMethod: authMethod,
|
|
using: dependencies
|
|
).send(using: dependencies)
|
|
}
|
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies)
|
|
.receive(on: DispatchQueue.main, using: dependencies)
|
|
.sinkUntilComplete(
|
|
receiveCompletion: { result in
|
|
DispatchQueue.main.async { [weak self] in
|
|
switch result {
|
|
case .finished:
|
|
self?.viewModel.showToast(
|
|
text: "banUserBanned".localized(),
|
|
backgroundColor: .backgroundSecondary,
|
|
inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing
|
|
)
|
|
case .failure:
|
|
self?.viewModel.showToast(
|
|
text: "banErrorFailed".localized(),
|
|
backgroundColor: .backgroundSecondary,
|
|
inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing
|
|
)
|
|
}
|
|
completion?()
|
|
}
|
|
}
|
|
)
|
|
},
|
|
afterClosed: {
|
|
completion?()
|
|
}
|
|
)
|
|
)
|
|
self.present(modal, animated: true)
|
|
}
|
|
|
|
func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) {
|
|
guard cellViewModel.threadVariant == .community else { return }
|
|
|
|
let modal: ConfirmationModal = ConfirmationModal(
|
|
targetView: self.view,
|
|
info: ConfirmationModal.Info(
|
|
title: "banDeleteAll".localized(),
|
|
body: .text("communityBanDeleteDescription".localized()),
|
|
confirmTitle: "theContinue".localized(),
|
|
confirmStyle: .danger,
|
|
cancelStyle: .alert_text,
|
|
onConfirm: { [weak self, threadInfo = viewModel.state.threadInfo, authMethod = viewModel.state.authMethod.value, dependencies = viewModel.dependencies] _ in
|
|
Result {
|
|
guard
|
|
cellViewModel.threadVariant == .community,
|
|
let roomToken: String = threadInfo.communityInfo?.roomToken,
|
|
!authMethod.isInvalid
|
|
else { throw CryptoError.invalidAuthentication }
|
|
|
|
return roomToken
|
|
}
|
|
.publisher
|
|
.tryFlatMap { roomToken in
|
|
try Network.SOGS.preparedUserBanAndDeleteAllMessages(
|
|
sessionId: cellViewModel.authorId,
|
|
roomToken: roomToken,
|
|
authMethod: authMethod,
|
|
using: dependencies
|
|
).send(using: dependencies)
|
|
}
|
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated), using: dependencies)
|
|
.receive(on: DispatchQueue.main, using: dependencies)
|
|
.sinkUntilComplete(
|
|
receiveCompletion: { result in
|
|
DispatchQueue.main.async { [weak self] in
|
|
switch result {
|
|
case .finished:
|
|
self?.viewModel.showToast(
|
|
text: "banUserBanned".localized(),
|
|
backgroundColor: .backgroundSecondary,
|
|
inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing
|
|
)
|
|
case .failure:
|
|
self?.viewModel.showToast(
|
|
text: "banErrorFailed".localized(),
|
|
backgroundColor: .backgroundSecondary,
|
|
inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing
|
|
)
|
|
}
|
|
completion?()
|
|
}
|
|
}
|
|
)
|
|
}
|
|
)
|
|
)
|
|
self.present(modal, animated: true)
|
|
}
|
|
|
|
// MARK: - VoiceMessageRecordingViewDelegate
|
|
|
|
func startVoiceMessageRecording() {
|
|
// Request permission if needed
|
|
Permissions.requestMicrophonePermissionIfNeeded(
|
|
using: viewModel.dependencies,
|
|
onComplete: { [weak self] granted in
|
|
guard !granted else { return }
|
|
|
|
DispatchQueue.main.async {
|
|
self?.cancelVoiceMessageRecording()
|
|
}
|
|
}
|
|
)
|
|
|
|
// Keep screen on
|
|
UIApplication.shared.isIdleTimerDisabled = false
|
|
guard Permissions.microphone == .granted else { return }
|
|
|
|
// Cancel any current audio playback
|
|
self.viewModel.stopAudio()
|
|
|
|
// Create URL
|
|
let currentOffsetTimestamp: Int64 = viewModel.dependencies[cache: .snodeAPI].currentOffsetTimestampMs()
|
|
let directory: String = viewModel.dependencies[singleton: .fileManager].temporaryDirectory
|
|
let fileName: String = "\(currentOffsetTimestamp).m4a" // stringlint:ignore
|
|
let url: URL = URL(fileURLWithPath: directory).appendingPathComponent(fileName)
|
|
|
|
// Set up audio session
|
|
guard viewModel.dependencies[singleton: .audioSession].startAudioActivity(recordVoiceMessageActivity) else {
|
|
return cancelVoiceMessageRecording()
|
|
}
|
|
|
|
// Set up audio recorder
|
|
let audioRecorder: AVAudioRecorder
|
|
do {
|
|
audioRecorder = try AVAudioRecorder(
|
|
url: url,
|
|
settings: [
|
|
AVFormatIDKey: NSNumber(value: kAudioFormatMPEG4AAC),
|
|
AVSampleRateKey: NSNumber(value: 44100),
|
|
AVNumberOfChannelsKey: NSNumber(value: 2),
|
|
AVEncoderBitRateKey: NSNumber(value: 128 * 1024)
|
|
]
|
|
)
|
|
audioRecorder.isMeteringEnabled = true
|
|
self.audioRecorder = audioRecorder
|
|
}
|
|
catch {
|
|
Log.error(.conversation, "Couldn't start audio recording due to error: \(error).")
|
|
return cancelVoiceMessageRecording()
|
|
}
|
|
|
|
// Limit voice messages to a minute
|
|
audioTimer = Timer.scheduledTimer(withTimeInterval: 180, repeats: false, block: { [weak self] _ in
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.snInputView.hideVoiceMessageUI()
|
|
self?.endVoiceMessageRecording()
|
|
}
|
|
})
|
|
|
|
// Prepare audio recorder and start recording
|
|
let successfullyPrepared: Bool = audioRecorder.prepareToRecord()
|
|
let startedRecording: Bool = (successfullyPrepared && audioRecorder.record())
|
|
|
|
guard successfullyPrepared && startedRecording else {
|
|
Log.error(.conversation, (successfullyPrepared ? "Couldn't record audio." : "Couldn't prepare audio recorder."))
|
|
|
|
// Dispatch to the next run loop to avoid
|
|
DispatchQueue.main.async {
|
|
let modal: ConfirmationModal = ConfirmationModal(
|
|
targetView: self.view,
|
|
info: ConfirmationModal.Info(
|
|
title: "theError".localized(),
|
|
body: .text("audioUnableToRecord".localized()),
|
|
cancelTitle: "okay".localized(),
|
|
cancelStyle: .alert_text
|
|
)
|
|
)
|
|
self.present(modal, animated: true)
|
|
}
|
|
|
|
return cancelVoiceMessageRecording()
|
|
}
|
|
}
|
|
|
|
func endVoiceMessageRecording() {
|
|
UIApplication.shared.isIdleTimerDisabled = true
|
|
|
|
// Hide the UI
|
|
snInputView.hideVoiceMessageUI()
|
|
|
|
// Cancel the timer
|
|
audioTimer?.invalidate()
|
|
|
|
// Check preconditions
|
|
guard let audioRecorder = audioRecorder else { return }
|
|
|
|
// Get duration
|
|
let duration = audioRecorder.currentTime
|
|
|
|
// Stop the recording
|
|
stopVoiceMessageRecording()
|
|
|
|
// Check for user misunderstanding
|
|
guard duration > 1 else {
|
|
self.audioRecorder = nil
|
|
|
|
let modal: ConfirmationModal = ConfirmationModal(
|
|
targetView: self.view,
|
|
info: ConfirmationModal.Info(
|
|
title: "messageVoice".localized(),
|
|
body: .text("messageVoiceErrorShort".localized()),
|
|
cancelTitle: "okay".localized(),
|
|
cancelStyle: .alert_text
|
|
)
|
|
)
|
|
self.present(modal, animated: true)
|
|
return
|
|
}
|
|
|
|
// Get data
|
|
let fileName = ("messageVoice".localized() as NSString)
|
|
.appendingPathExtension("m4a") // stringlint:ignore
|
|
let pendingAttachment: PendingAttachment = PendingAttachment(
|
|
source: .voiceMessage(audioRecorder.url),
|
|
utType: .mpeg4Audio,
|
|
sourceFilename: fileName,
|
|
using: viewModel.dependencies
|
|
)
|
|
|
|
// Send attachment
|
|
sendMessage(text: "", attachments: [pendingAttachment])
|
|
}
|
|
|
|
func cancelVoiceMessageRecording() {
|
|
snInputView.hideVoiceMessageUI()
|
|
audioTimer?.invalidate()
|
|
stopVoiceMessageRecording()
|
|
audioRecorder = nil
|
|
}
|
|
|
|
func stopVoiceMessageRecording() {
|
|
audioRecorder?.stop()
|
|
viewModel.dependencies[singleton: .audioSession].endAudioActivity(recordVoiceMessageActivity)
|
|
}
|
|
|
|
// MARK: - Data Extraction Notifications
|
|
|
|
func sendDataExtraction(kind: DataExtractionNotification.Kind) {
|
|
// Only send screenshot notifications to one-to-one conversations
|
|
guard self.viewModel.state.threadVariant == .contact else { return }
|
|
|
|
let threadId: String = self.viewModel.state.threadId
|
|
let threadVariant: SessionThread.Variant = self.viewModel.state.threadVariant
|
|
|
|
viewModel.dependencies[singleton: .storage].writeAsync { [dependencies = viewModel.dependencies] db in
|
|
try MessageSender.send(
|
|
db,
|
|
message: DataExtractionNotification(
|
|
kind: kind,
|
|
sentTimestampMs: dependencies[cache: .snodeAPI].currentOffsetTimestampMs()
|
|
)
|
|
.with(DisappearingMessagesConfiguration
|
|
.fetchOne(db, id: threadId)?
|
|
.forcedWithDisappearAfterReadIfNeeded()
|
|
),
|
|
interactionId: nil,
|
|
threadId: threadId,
|
|
threadVariant: threadVariant,
|
|
using: dependencies
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Convenience
|
|
|
|
@MainActor func showErrorAlert(for error: Error) {
|
|
DispatchQueue.main.async { [weak self] in
|
|
let modal: ConfirmationModal = ConfirmationModal(
|
|
targetView: self?.view,
|
|
info: ConfirmationModal.Info(
|
|
title: "attachmentsErrorSending".localized(),
|
|
body: .text("\(error)"),
|
|
cancelTitle: "okay".localized(),
|
|
cancelStyle: .alert_text
|
|
)
|
|
)
|
|
self?.present(modal, animated: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - UIDocumentInteractionControllerDelegate
|
|
|
|
extension ConversationVC: UIDocumentInteractionControllerDelegate {
|
|
func documentInteractionControllerViewControllerForPreview(_ controller: UIDocumentInteractionController) -> UIViewController {
|
|
return self
|
|
}
|
|
|
|
public func documentInteractionControllerDidEndPreview(_ controller: UIDocumentInteractionController) {
|
|
guard let temporaryFileUrl: URL = controller.url else { return }
|
|
|
|
/// Now that we are finished with it we want to remove the temporary file (just to be safe ensure that it starts with the
|
|
/// `temporaryDirectory` so we don't accidentally delete a proper file if logic elsewhere changes)
|
|
if viewModel.dependencies[singleton: .fileManager].isLocatedInTemporaryDirectory(temporaryFileUrl.path) {
|
|
try? viewModel.dependencies[singleton: .fileManager].removeItem(atPath: temporaryFileUrl.path)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Message Request Actions
|
|
|
|
extension ConversationVC {
|
|
@MainActor internal func removeMessageRequestsFromBackStackIfNeeded() {
|
|
/// Remove the `SessionTableViewController<MessageRequestsViewModel>` from the nav hierarchy if present
|
|
if
|
|
let viewControllers: [UIViewController] = self.navigationController?.viewControllers,
|
|
let messageRequestsIndex = viewControllers
|
|
.firstIndex(where: { viewCon -> Bool in
|
|
(viewCon as? SessionViewModelAccessible)?.viewModelType == MessageRequestsViewModel.self
|
|
}),
|
|
messageRequestsIndex > 0
|
|
{
|
|
var newViewControllers = viewControllers
|
|
newViewControllers.remove(at: messageRequestsIndex)
|
|
self.navigationController?.viewControllers = newViewControllers
|
|
}
|
|
}
|
|
|
|
fileprivate func approveMessageRequestIfNeeded(
|
|
for threadId: String,
|
|
threadVariant: SessionThread.Variant,
|
|
displayName: String,
|
|
isDraft: Bool,
|
|
timestampMs: Int64
|
|
) async {
|
|
switch threadVariant {
|
|
case .contact:
|
|
/// If the contact doesn't exist then we should create it so we can store the `isApproved` state (it'll be updated
|
|
/// with correct profile info if they accept the message request so this shouldn't cause weird behaviours)
|
|
let maybeContact: Contact? = try? await viewModel.dependencies[singleton: .storage].readAsync { [dependencies = viewModel.dependencies] db in
|
|
Contact.fetchOrCreate(db, id: threadId, using: dependencies)
|
|
}
|
|
|
|
guard
|
|
let contact: Contact = maybeContact,
|
|
!contact.isApproved
|
|
else { return }
|
|
|
|
try? await viewModel.dependencies[singleton: .storage].writeAsync { [dependencies = viewModel.dependencies] db in
|
|
/// If this isn't a draft thread (ie. sending a message request) then send a `messageRequestResponse`
|
|
/// back to the sender (this allows the sender to know that they have been approved and can now use this
|
|
/// contact in closed groups)
|
|
if !isDraft {
|
|
_ = try? Interaction(
|
|
threadId: threadId,
|
|
threadVariant: threadVariant,
|
|
authorId: dependencies[cache: .general].sessionId.hexString,
|
|
variant: .infoMessageRequestAccepted,
|
|
body: "messageRequestYouHaveAccepted"
|
|
.put(key: "name", value: displayName)
|
|
.localized(),
|
|
timestampMs: timestampMs,
|
|
using: dependencies
|
|
).inserted(db)
|
|
|
|
try MessageSender.send(
|
|
db,
|
|
message: MessageRequestResponse(
|
|
isApproved: true,
|
|
sentTimestampMs: UInt64(timestampMs)
|
|
),
|
|
interactionId: nil,
|
|
threadId: threadId,
|
|
threadVariant: threadVariant,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
// Default 'didApproveMe' to true for the person approving the message request
|
|
let updatedDidApproveMe: Bool = (contact.didApproveMe || !isDraft)
|
|
try contact.upsert(db)
|
|
try Contact
|
|
.filter(id: contact.id)
|
|
.updateAllAndConfig(
|
|
db,
|
|
Contact.Columns.isApproved.set(to: true),
|
|
Contact.Columns.didApproveMe.set(to: updatedDidApproveMe),
|
|
using: dependencies
|
|
)
|
|
db.addContactEvent(id: contact.id, change: .isApproved(true))
|
|
db.addContactEvent(id: contact.id, change: .didApproveMe(updatedDidApproveMe))
|
|
db.addEvent(contact.id, forKey: .messageRequestAccepted)
|
|
}
|
|
|
|
// Update the UI
|
|
await MainActor.run {
|
|
removeMessageRequestsFromBackStackIfNeeded()
|
|
}
|
|
return
|
|
|
|
case .group:
|
|
// If the group is not in the invited state then don't bother doing anything
|
|
let maybeGroup: ClosedGroup? = try? await viewModel.dependencies[singleton: .storage].readAsync { db in
|
|
try ClosedGroup.fetchOne(db, id: threadId)
|
|
}
|
|
|
|
guard
|
|
let group: ClosedGroup = maybeGroup,
|
|
group.invited == true
|
|
else { return }
|
|
|
|
try? await viewModel.dependencies[singleton: .storage].writeAsync { [dependencies = viewModel.dependencies] db in
|
|
/// Remove any existing `infoGroupInfoInvited` interactions from the group (don't want to have a
|
|
/// duplicate one from inside the group history)
|
|
try Interaction.deleteWhere(
|
|
db,
|
|
.filter(Interaction.Columns.threadId == group.id),
|
|
.filter(Interaction.Columns.variant == Interaction.Variant.infoGroupInfoInvited)
|
|
)
|
|
|
|
/// Optimistically insert a `standard` member for the current user in this group (it'll be update to the correct
|
|
/// one once we receive the first `GROUP_MEMBERS` config message but adding it here means the `canWrite`
|
|
/// state of the group will continue to be `true` while we wait on the initial poll to get back)
|
|
try GroupMember(
|
|
groupId: group.id,
|
|
profileId: dependencies[cache: .general].sessionId.hexString,
|
|
role: .standard,
|
|
roleStatus: .accepted,
|
|
isHidden: false
|
|
).upsert(db)
|
|
|
|
/// If this isn't a draft thread (ie. sending a message request) and the user is not an admin then schedule
|
|
/// sending a `GroupUpdateInviteResponseMessage` to the group (this allows other members to
|
|
/// know that the user has joined the group)
|
|
if !isDraft && group.groupIdentityPrivateKey == nil {
|
|
try MessageSender.send(
|
|
db,
|
|
message: GroupUpdateInviteResponseMessage(
|
|
isApproved: true,
|
|
sentTimestampMs: UInt64(timestampMs)
|
|
),
|
|
interactionId: nil,
|
|
threadId: threadId,
|
|
threadVariant: threadVariant,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
/// Actually trigger the approval
|
|
try ClosedGroup.approveGroupIfNeeded(
|
|
db,
|
|
group: group,
|
|
using: dependencies
|
|
)
|
|
}
|
|
|
|
// Update the UI
|
|
await MainActor.run {
|
|
removeMessageRequestsFromBackStackIfNeeded()
|
|
}
|
|
return
|
|
|
|
default: break
|
|
}
|
|
}
|
|
|
|
func acceptMessageRequest() {
|
|
Task.detached(priority: .userInitiated) { [weak self] in
|
|
guard let self = self else { return }
|
|
|
|
await approveMessageRequestIfNeeded(
|
|
for: self.viewModel.state.threadId,
|
|
threadVariant: self.viewModel.state.threadVariant,
|
|
displayName: self.viewModel.state.threadInfo.displayName.deformatted(),
|
|
isDraft: self.viewModel.state.threadInfo.isDraft,
|
|
timestampMs: viewModel.dependencies[cache: .snodeAPI].currentOffsetTimestampMs()
|
|
)
|
|
}
|
|
}
|
|
|
|
func declineMessageRequest() {
|
|
let actions: [UIContextualAction]? = UIContextualAction.generateSwipeActions(
|
|
[.delete],
|
|
for: .trailing,
|
|
indexPath: IndexPath(row: 0, section: 0),
|
|
tableView: self.tableView,
|
|
threadInfo: self.viewModel.state.threadInfo,
|
|
viewController: self,
|
|
navigatableStateHolder: nil,
|
|
using: viewModel.dependencies
|
|
)
|
|
|
|
guard let action: UIContextualAction = actions?.first else { return }
|
|
|
|
action.handler(action, self.view, { [weak self] didConfirm in
|
|
guard didConfirm else { return }
|
|
|
|
DispatchQueue.main.async {
|
|
self?.navigationController?.popViewController(animated: true)
|
|
}
|
|
})
|
|
}
|
|
|
|
func blockMessageRequest() {
|
|
let actions: [UIContextualAction]? = UIContextualAction.generateSwipeActions(
|
|
[.block],
|
|
for: .trailing,
|
|
indexPath: IndexPath(row: 0, section: 0),
|
|
tableView: self.tableView,
|
|
threadInfo: self.viewModel.state.threadInfo,
|
|
viewController: self,
|
|
navigatableStateHolder: nil,
|
|
using: viewModel.dependencies
|
|
)
|
|
|
|
guard let action: UIContextualAction = actions?.first else { return }
|
|
|
|
action.handler(action, self.view, { [weak self] didConfirm in
|
|
guard didConfirm else { return }
|
|
|
|
DispatchQueue.main.async {
|
|
self?.navigationController?.popViewController(animated: true)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// MARK: - Legacy Group Actions
|
|
|
|
extension ConversationVC {
|
|
@objc public func recreateLegacyGroupTapped() {
|
|
let threadId: String = self.viewModel.state.threadId
|
|
let closedGroupName: String? = self.viewModel.state.threadInfo.groupInfo?.name
|
|
let confirmationModal: ConfirmationModal = ConfirmationModal(
|
|
info: ConfirmationModal.Info(
|
|
title: "recreateGroup".localized(),
|
|
body: .text("legacyGroupChatHistory".localized()),
|
|
confirmTitle: "theContinue".localized(),
|
|
confirmStyle: .danger,
|
|
cancelStyle: .alert_text
|
|
) { [weak self, dependencies = viewModel.dependencies] _ in
|
|
let groupMemberProfileIds: [String] = dependencies[singleton: .storage]
|
|
.read { db in
|
|
try GroupMember
|
|
.select(.profileId)
|
|
.filter(GroupMember.Columns.groupId == threadId)
|
|
.asRequest(of: String.self)
|
|
.fetchAll(db)
|
|
}
|
|
.defaulting(to: [])
|
|
let currentUserSessionId: SessionId = dependencies[cache: .general].sessionId
|
|
let viewController: NewClosedGroupVC = NewClosedGroupVC(
|
|
hideCloseButton: true,
|
|
prefilledName: closedGroupName,
|
|
preselectedContactIds: groupMemberProfileIds
|
|
.filter { $0 != currentUserSessionId.hexString },
|
|
using: dependencies
|
|
)
|
|
|
|
// FIXME: Remove this when we can (it's very fragile)
|
|
/// There isn't current a way to animate the change of the `UINavigationBar` background color so instead we
|
|
/// insert this `colorAnimationView` on top of the internal `UIBarBackground` and fade it in/out alongside
|
|
/// the push/pop transitions
|
|
///
|
|
/// **Note:** If we are unable to get the `UIBarBackground` using the below hacks then the navbar will just
|
|
/// keep it's existing color and look a bit odd on the destination screen
|
|
let colorAnimationView: UIView = UIView(
|
|
frame: self?.navigationController?.navigationBar.bounds ?? .zero
|
|
)
|
|
colorAnimationView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
|
colorAnimationView.themeBackgroundColor = .backgroundSecondary
|
|
colorAnimationView.alpha = 0
|
|
|
|
if
|
|
let navigationBar: UINavigationBar = self?.navigationController?.navigationBar,
|
|
let barBackgroundView: UIView = navigationBar.subviews.first(where: { subview -> Bool in
|
|
"\(subview)".contains("_UIBarBackground") || (
|
|
subview.subviews.first is UIImageView &&
|
|
(subview.subviews.first as? UIImageView)?.image == nil
|
|
)
|
|
})
|
|
{
|
|
barBackgroundView.addSubview(colorAnimationView)
|
|
|
|
viewController.onViewWillAppear = { vc in
|
|
vc.transitionCoordinator?.animate { _ in
|
|
colorAnimationView.alpha = 1
|
|
}
|
|
}
|
|
viewController.onViewWillDisappear = { vc in
|
|
vc.transitionCoordinator?.animate(
|
|
alongsideTransition: { _ in
|
|
colorAnimationView.alpha = 0
|
|
},
|
|
completion: { _ in
|
|
colorAnimationView.removeFromSuperview()
|
|
}
|
|
)
|
|
}
|
|
viewController.onViewDidDisappear = { _ in
|
|
// If the screen is dismissed without an animation then 'onViewWillDisappear'
|
|
// won't be called so we need to clean up
|
|
colorAnimationView.removeFromSuperview()
|
|
}
|
|
}
|
|
self?.navigationController?.pushViewController(viewController, animated: true)
|
|
}
|
|
)
|
|
|
|
self.navigationController?.present(confirmationModal, animated: true, completion: nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - MediaPresentationContextProvider
|
|
|
|
extension ConversationVC: MediaPresentationContextProvider {
|
|
func mediaPresentationContext(mediaId: String, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? {
|
|
// Note: According to Apple's docs the 'indexPathsForVisibleRows' method returns an
|
|
// unsorted array which means we can't use it to determine the desired 'visibleCell'
|
|
// we are after, due to this we will need to iterate all of the visible cells to find
|
|
// the one we want
|
|
let maybeMessageCell: VisibleMessageCell? = tableView.visibleCells
|
|
.first { cell -> Bool in
|
|
((cell as? VisibleMessageCell)?
|
|
.albumView?
|
|
.itemViews
|
|
.contains(where: { mediaView in
|
|
mediaView.attachment.id == mediaId
|
|
}))
|
|
.defaulting(to: false)
|
|
}
|
|
.map { $0 as? VisibleMessageCell }
|
|
let maybeTargetView: MediaView? = maybeMessageCell?
|
|
.albumView?
|
|
.itemViews
|
|
.first(where: { $0.attachment.id == mediaId })
|
|
|
|
guard
|
|
let targetView: MediaView = maybeTargetView,
|
|
let mediaSuperview: UIView = targetView.superview
|
|
else { return nil }
|
|
|
|
return MediaPresentationContext(
|
|
mediaView: targetView.imageView,
|
|
presentationFrame: coordinateSpace.convert(targetView.frame, from: mediaSuperview),
|
|
cornerRadius: targetView.layer.cornerRadius,
|
|
cornerMask: targetView.layer.maskedCorners
|
|
)
|
|
}
|
|
|
|
func lowestViewToRenderAboveContent() -> UIView? {
|
|
return inputBackgroundView
|
|
}
|
|
}
|