diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index ef261630d7..956544c36b 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -1173,6 +1173,9 @@ FDFE75B42ABD46B600655640 /* MockUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9D127D59495005E1583 /* MockUserDefaults.swift */; }; FDFE75B52ABD46B700655640 /* MockUserDefaults.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9D127D59495005E1583 /* MockUserDefaults.swift */; }; FDFF9FDF2A787F57005E0628 /* JSONEncoder+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFF9FDE2A787F57005E0628 /* JSONEncoder+Utilities.swift */; }; + FE2883272EA70C640097E240 /* MessageSelectionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE2883262EA70C640097E240 /* MessageSelectionManager.swift */; }; + FE28832B2EA74D440097E240 /* ManualDropdownPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE28832A2EA74D440097E240 /* ManualDropdownPresenter.swift */; }; + FE28832D2EA74D680097E240 /* CustomMenuView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE28832C2EA74D680097E240 /* CustomMenuView.swift */; }; FED288F32E4C28CF00C31171 /* AppReviewPromptDialog.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED288F22E4C28CF00C31171 /* AppReviewPromptDialog.swift */; }; FED288F82E4C3BE100C31171 /* AppReviewPromptModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FED288F72E4C3BE100C31171 /* AppReviewPromptModel.swift */; }; /* End PBXBuildFile section */ @@ -2467,6 +2470,9 @@ FDFDE129282D056B0098B17F /* MediaZoomAnimationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaZoomAnimationController.swift; sourceTree = ""; }; FDFE75B02ABD2D2400655640 /* _030_MakeBrokenProfileTimestampsNullable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _030_MakeBrokenProfileTimestampsNullable.swift; sourceTree = ""; }; FDFF9FDE2A787F57005E0628 /* JSONEncoder+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "JSONEncoder+Utilities.swift"; sourceTree = ""; }; + FE2883262EA70C640097E240 /* MessageSelectionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSelectionManager.swift; sourceTree = ""; }; + FE28832A2EA74D440097E240 /* ManualDropdownPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualDropdownPresenter.swift; sourceTree = ""; }; + FE28832C2EA74D680097E240 /* CustomMenuView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomMenuView.swift; sourceTree = ""; }; FED288F22E4C28CF00C31171 /* AppReviewPromptDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReviewPromptDialog.swift; sourceTree = ""; }; FED288F72E4C3BE100C31171 /* AppReviewPromptModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReviewPromptModel.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -2986,6 +2992,7 @@ B835246C25C38AA20089A44F /* Conversations */ = { isa = PBXGroup; children = ( + FE2883252EA70C5D0097E240 /* Selection */, B887C38125C7C79700E11DAE /* Input View */, B835247725C38D190089A44F /* Message Cells */, C328252E25CA54F70062D0A7 /* Context Menu */, @@ -5300,6 +5307,16 @@ path = Transitions; sourceTree = ""; }; + FE2883252EA70C5D0097E240 /* Selection */ = { + isa = PBXGroup; + children = ( + FE28832C2EA74D680097E240 /* CustomMenuView.swift */, + FE28832A2EA74D440097E240 /* ManualDropdownPresenter.swift */, + FE2883262EA70C640097E240 /* MessageSelectionManager.swift */, + ); + path = Selection; + sourceTree = ""; + }; FED288EF2E4C239800C31171 /* App Review */ = { isa = PBXGroup; children = ( @@ -6944,6 +6961,7 @@ 7BA37AFD2AEF7C3D002438F8 /* VoiceMessageView_SwiftUI.swift in Sources */, 7B1B52E028580D51006069F2 /* EmojiSkinTonePicker.swift in Sources */, B849789625D4A2F500D0D0B3 /* LinkPreviewView.swift in Sources */, + FE2883272EA70C640097E240 /* MessageSelectionManager.swift in Sources */, FD71164428E2CB8A00B47552 /* SessionCell+Accessory.swift in Sources */, 7B1B52DF28580D51006069F2 /* EmojiPickerCollectionView.swift in Sources */, FDE5219A2E08DBB800061B8E /* ImageLoading+Convenience.swift in Sources */, @@ -7061,6 +7079,7 @@ 34BECE2E1F7ABCE000D7438D /* GifPickerViewController.swift in Sources */, 9422568C2C23F8C800C0FDBF /* DisplayNameScreen.swift in Sources */, 7B9F71D72853100A006DFE7B /* Emoji+Available.swift in Sources */, + FE28832D2EA74D680097E240 /* CustomMenuView.swift in Sources */, FD09C5E628260FF9000CE219 /* MediaGalleryViewModel.swift in Sources */, 7B9F71D32852EEE2006DFE7B /* Emoji.swift in Sources */, C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */, @@ -7072,6 +7091,7 @@ FD4B200E283492210034334B /* InsetLockableTableView.swift in Sources */, FD12A8472AD63C3400EEBA0D /* PagedObservationSource.swift in Sources */, FDC1BD682CFE6EEB002CDC71 /* DeveloperSettingsViewModel.swift in Sources */, + FE28832B2EA74D440097E240 /* ManualDropdownPresenter.swift in Sources */, 7B0EFDF0275084AA00FFAAE7 /* CallMessageCell.swift in Sources */, C3AAFFF225AE99710089E6DD /* AppDelegate.swift in Sources */, FD10AF0C2AF32B9A007709E5 /* SessionListViewModel.swift in Sources */, diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index 53b44ea9b5..61db184a3e 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -163,6 +163,14 @@ extension ContextMenuVC { actionType: .dismiss ) { _ in delegate?.contextMenuDismissed() } } + + static func select(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + return Action( + icon: Lucide.image(icon: .circleCheck, size: 24), + title: "select".localized(), + accessibilityLabel: "Select message" + ) { completion in delegate?.select(cellViewModel, completion: completion) } + } } static func viewModelCanReply(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) -> Bool { @@ -202,6 +210,18 @@ extension ContextMenuVC { case .standardOutgoing, .standardIncoming: break } + var canSelect: Bool { + guard cellViewModel.variant == .standardIncoming || ( + cellViewModel.variant == .standardOutgoing && + cellViewModel.state != .failed && + cellViewModel.state != .sending + ) else { + return false + } + + return true && !forMessageInfoScreen + } + let canRetry: Bool = ( cellViewModel.threadVariant != .legacyGroup && cellViewModel.variant == .standardOutgoing && ( @@ -289,7 +309,9 @@ extension ContextMenuVC { .compactMap { EmojiWithSkinTones(rawValue: $0) } }() let generatedActions: [Action] = [ + (canRetry ? Action.retry(cellViewModel, delegate) : nil), + (canSelect ? Action.select(cellViewModel, delegate) : nil), (viewModelCanReply(cellViewModel, using: dependencies) ? Action.reply(cellViewModel, delegate) : nil), (canCopy ? Action.copy(cellViewModel, delegate) : nil), (canSave ? Action.save(cellViewModel, delegate) : nil), @@ -310,6 +332,37 @@ extension ContextMenuVC { return generatedActions.appending(forMessageInfoScreen ? nil : Action.dismiss(delegate)) } + + + static func navigationActions( + for cellViewModel: MessageViewModel, + in threadViewModel: SessionThreadViewModel, + delegate: ContextMenuActionDelegate?, + using dependencies: Dependencies + ) -> [Action]? { + let canDelete: Bool = (MessageViewModel.DeletionBehaviours.deletionActions( + for: [cellViewModel], + with: threadViewModel, + using: dependencies + ) != nil) + + var showDelete: Bool { + cellViewModel.attachments != nil && canDelete + } + + var showCopy: Bool { + cellViewModel.cellType == .textOnlyMessage + } + + let generatedActions: [Action] = [ + (showCopy ? Action.copy(cellViewModel, delegate) : nil), + (showDelete ? Action.delete(cellViewModel, delegate) : nil), + Action.info(cellViewModel, delegate) + ] + .compactMap { $0 } + + return generatedActions + } } // MARK: - Delegate @@ -327,4 +380,5 @@ protocol ContextMenuActionDelegate { func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones) func showFullEmojiKeyboard(_ cellViewModel: MessageViewModel) func contextMenuDismissed() + func select(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) } diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 58c317cf6f..7a03598117 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -3,6 +3,7 @@ import UIKit import AVKit import AVFoundation +import Lucide import Combine import CoreServices import Photos @@ -1101,6 +1102,11 @@ extension ConversationVC: } // MARK: MessageCellDelegate + func handleCellSelection(for cellViewModel: MessageViewModel, cell: UITableViewCell) { + guard isMultiSelectionEnabled else { return } + + shouldHandleMessageSelection(for: cellViewModel, in: cell) + } func handleItemLongPressed(_ cellViewModel: MessageViewModel) { // Show the unblock modal if needed @@ -1172,6 +1178,8 @@ extension ConversationVC: cell: UITableViewCell, cellLocation: CGPoint ) { + guard !isMultiSelectionEnabled else { return } + // 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, @@ -2488,103 +2496,7 @@ extension ConversationVC: func delete(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { /// Retrieve the deletion actions for the selected message(s) of there are any let messagesToDelete: [MessageViewModel] = [cellViewModel] - - guard let deletionBehaviours: MessageViewModel.DeletionBehaviours = self.viewModel.deletionActions(for: messagesToDelete) else { - 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?() - } - } - ) - }, - afterClosed: { [weak self] in - self?.becomeFirstResponder() - } - ) - ) - - /// 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() - } + onDeleteMessages(messagesToDelete, completion: completion) } func save(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { @@ -2869,6 +2781,19 @@ extension ConversationVC: ) self.present(modal, animated: true) } + + func select(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { + guard + let sectionIndex: Int = self.viewModel.interactionData + .firstIndex(where: { $0.model == .messages }), + let index = self.viewModel.interactionData[sectionIndex] + .elements + .firstIndex(of: cellViewModel), + let cell = tableView.cellForRow(at: IndexPath(row: index, section: sectionIndex)) as? MessageCell + else { return } + + shouldHandleMessageSelection(for: cellViewModel, in: cell) + } // MARK: - VoiceMessageRecordingViewDelegate @@ -3486,3 +3411,202 @@ extension ConversationVC: MediaPresentationContextProvider { return self.navigationController?.navigationBar.generateSnapshot(in: coordinateSpace) } } + +// MARK: - Delete messages +extension ConversationVC { + func onDeleteMessages(_ messagesToDelete: [MessageViewModel], completion: (() -> Void)? = nil) { + guard let topViewController = viewModel.dependencies[singleton: .appContext].frontMostViewController else { + return + } + + guard let deletionBehaviours: MessageViewModel.DeletionBehaviours = self.viewModel.deletionActions(for: messagesToDelete) else { + return + } + + let modal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "deleteAttachments" + .putNumber(messagesToDelete.count) + .localized(), + body: .radio( + explanation: ThemedAttributedString(string: "deleteAttachmentsDescription" + .putNumber(messagesToDelete.count) + .localized() + ), + 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?() + } + } + ) + }, + afterClosed: { [weak self] in + self?.becomeFirstResponder() + } + ) + ) + + /// 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 + topViewController.present(modal, animated: true) + self?.resignFirstResponder() + } + } +} + +// MARK: - Multiple selection handling +extension ConversationVC { + func shouldHandleMessageSelection(for message: MessageViewModel, in cell: UITableViewCell) { + guard message.variant == .standardIncoming || ( + message.variant == .standardOutgoing && + message.state != .failed && + message.state != .sending + ) else { + return + } + + if let selectedIndex = selectedMessages.firstIndex(where: { $0 == message }) { + selectedMessages.remove(at: selectedIndex) + } else { + selectedMessages.insert(message) + } + + isMultiSelectionEnabled = !selectedMessages.isEmpty + + guard let indexPath = tableView.indexPath(for: cell) else { return } + tableView.reloadRows(at: [indexPath], with: .none) + } + + func shouldUpdateNavigationBar() { + navigationItem.titleView = isMultiSelectionEnabled ? nil : titleView + + // Nav bar buttons + updateNavBarButtons( + threadData: viewModel.threadData, + initialVariant: viewModel.initialThreadVariant, + initialIsNoteToSelf: viewModel.threadData.threadIsNoteToSelf, + initialIsBlocked: (viewModel.threadData.threadIsBlocked == true) + ) + } + + func resetSelection() { + DispatchQueue.main.async { [weak self] in + self?.isMultiSelectionEnabled = false + self?.selectedMessages.removeAll() + self?.tableView.reloadData() + } + } +} + +extension ConversationVC: SelectionManagerDelegate { + func willDeleteMessages(_ messages: [SessionMessagingKit.MessageViewModel], completion: @escaping () -> Void) { + onDeleteMessages(messages, completion: completion) + } + + func shouldResetSelectionState() { + resetSelection() + } + + func shouldShowCopyToast() { + viewModel.showToast( + text: "copied".localized(), + backgroundColor: .toast_background, + inset: Values.largeSpacing + (inputAccessoryView?.frame.height ?? 0) + ) + } + + func showInfo(for message: MessageViewModel, withSender sender: UIBarButtonItem) { + if dropdownPresenter != nil { + dropdownPresenter?.hide() + dropdownPresenter = nil + } + + let actions = ContextMenuVC.navigationActions( + for: message, + in: viewModel.threadData, + delegate: self, + using: viewModel.dependencies + ) ?? [] + + let presenter = ManualDropdownPresenter() + self.dropdownPresenter = presenter + + presenter.show( + actions: actions, + anchorView: navigationController?.navigationBar.subviews.first, + using: viewModel.dependencies + ) { [weak self] in + self?.dropdownPresenter = nil + self?.resetSelection() + } + } +} diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 7ec260b5b3..289c9f06e2 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -69,6 +69,14 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa // Reaction var currentReactionListSheet: ReactionListSheet? var reactionExpandedMessageIds: Set = [] + + // Selected messages + var selectedMessages: Set = [] + var isMultiSelectionEnabled: Bool = false { + didSet { + shouldUpdateNavigationBar() + } + } /// This flag is used to temporarily prevent the ConversationVC from becoming the first responder (primarily used with /// custom transitions from preventing them from being buggy @@ -111,6 +119,15 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa behavior: .playAndRecord ) + // Message selection + lazy var selectionManger = MessageSelectionManager( + delegate: self + ) + + // Reference to dropdown view + var dropdownPresenter: ManualDropdownPresenter? + + // Search lazy var searchController: ConversationSearchController = { let result: ConversationSearchController = ConversationSearchController( threadId: self.viewModel.threadData.threadId @@ -1409,8 +1426,10 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa if isShowingSearchUI { navigationItem.leftBarButtonItem = nil navigationItem.rightBarButtonItems = [] - } - else { + } else if isMultiSelectionEnabled { + let items = selectionManger.createNavigationActions() + navigationItem.rightBarButtonItems = items + }else { let shouldHaveCallButton: Bool = ( (threadData?.threadVariant ?? initialVariant) == .contact && (threadData?.threadIsNoteToSelf ?? initialIsNoteToSelf) == false @@ -1693,6 +1712,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa case .messages: let cellViewModel: MessageViewModel = section.elements[indexPath.row] let cell: MessageCell = tableView.dequeue(type: MessageCell.cellType(for: cellViewModel), for: indexPath) + cell.update( with: cellViewModel, playbackInfo: viewModel.playbackInfo(for: cellViewModel) { [weak self] updatedInfo, error in @@ -1722,6 +1742,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa tableSize: tableView.bounds.size, using: viewModel.dependencies ) + cell.setSelectedState(selectedMessages.contains(cellViewModel)) cell.delegate = self return cell diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift index a4e2cd8585..8fc4779895 100644 --- a/Session/Conversations/Message Cells/MessageCell.swift +++ b/Session/Conversations/Message Cells/MessageCell.swift @@ -44,7 +44,11 @@ public class MessageCell: UITableViewCell { selectedBackgroundView.themeBackgroundColor = .clear self.selectedBackgroundView = selectedBackgroundView } - + + func setSelectedState(_ selected: Bool) { + themeBackgroundColor = selected ? .backgroundSecondary : .clear + } + func setUpGestureRecognizers() { var tapGestureRecognizer: UITapGestureRecognizer? var doubleTapGestureRecognizer: UITapGestureRecognizer? @@ -149,6 +153,7 @@ protocol MessageCellDelegate: ReactionDelegate { func showReactionList(_ cellViewModel: MessageViewModel, selectedReaction: EmojiWithSkinTones?) func needsLayout(for cellViewModel: MessageViewModel, expandingReactions: Bool) func handleReadMoreButtonTapped(_ cell: UITableViewCell, for cellViewModel: MessageViewModel) + func handleCellSelection(for cellViewModel: MessageViewModel, cell: UITableViewCell) // Added for selection handling of tapped item and tapped cell } extension MessageCellDelegate { diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index f809ec9b80..792127def6 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -1087,6 +1087,8 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { delegate?.handleItemTapped(cellViewModel, cell: self, cellLocation: location) } } + + delegate?.handleCellSelection(for: cellViewModel, cell: self) } override func handleDoubleTap() { diff --git a/Session/Conversations/Selection/CustomMenuView.swift b/Session/Conversations/Selection/CustomMenuView.swift new file mode 100644 index 0000000000..892e5c88a9 --- /dev/null +++ b/Session/Conversations/Selection/CustomMenuView.swift @@ -0,0 +1,56 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUtilitiesKit +import SessionUIKit + +class CustomMenuView: UIView { + private lazy var stackView: UIStackView = { + let result = UIStackView() + result.axis = .vertical + result.spacing = 0 + result.distribution = .fillEqually + result.translatesAutoresizingMaskIntoConstraints = false + return result + }() + + init() { + super.init(frame: .zero) + setupView() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupView() { + self.themeBackgroundColor = .contextMenu_background + self.layer.cornerRadius = 8 + self.clipsToBounds = true + + addSubview(stackView) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: self.topAnchor), + stackView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: self.trailingAnchor), + stackView.bottomAnchor.constraint(equalTo: self.bottomAnchor) + ]) + } + + func createMenuButtons(_ actions: [ContextMenuVC.Action], using dependencies: Dependencies, dismiss: @escaping () -> Void) { + actions.forEach { action in + let item = ContextMenuVC.ActionView( + for: action, + using: dependencies, + dismiss: dismiss + ) + stackView.addArrangedSubview(item) + } + + let buttonHeight: CGFloat = Values.largeButtonHeight + let menuWidth: CGFloat = Values.menuContainerWidth + let totalHeight = buttonHeight * CGFloat(actions.count) + self.frame = CGRect(origin: .zero, size: CGSize(width: menuWidth, height: totalHeight)) + } +} diff --git a/Session/Conversations/Selection/ManualDropdownPresenter.swift b/Session/Conversations/Selection/ManualDropdownPresenter.swift new file mode 100644 index 0000000000..8bcd7543fd --- /dev/null +++ b/Session/Conversations/Selection/ManualDropdownPresenter.swift @@ -0,0 +1,81 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUtilitiesKit + +class ManualDropdownPresenter: NSObject { + private lazy var menuView: CustomMenuView = { + let result = CustomMenuView() + result.layer.shadowOffset = CGSize.zero + result.layer.shadowOpacity = 0.4 + result.layer.shadowRadius = 4 + return result + }() + + private lazy var overlayView: UIView = { + let result = UIView() + result.backgroundColor = .black.withAlphaComponent(0.01) + let tapGesture = UITapGestureRecognizer(target: self, action: #selector(hide)) + result.addGestureRecognizer(tapGesture) + return result + }() + + private weak var presentingViewController: UIViewController? + + @MainActor + func show(actions: [ContextMenuVC.Action], anchorView: UIView?, using dependencies: Dependencies, completion: @escaping () -> Void) { + guard + let topViewController = dependencies[singleton: .appContext].frontMostViewController, + let barButtonView = anchorView + else { + return + } + + menuView.createMenuButtons( + actions, + using: dependencies + ) { [weak self] in + self?.menuView.removeFromSuperview() + self?.overlayView.removeFromSuperview() + + completion() + } + + self.presentingViewController = topViewController + + guard let targetView = topViewController.view else { + return + } + + overlayView.frame = targetView.bounds + targetView.addSubview(overlayView) + + targetView.addSubview(menuView) + + let buttonFrame = barButtonView.convert(barButtonView.bounds, to: targetView) + + let menuX = buttonFrame.maxX - menuView.frame.width + let menuY = buttonFrame.maxY + 5 + + menuView.frame.origin = CGPoint(x: menuX, y: menuY) + menuView.alpha = 0 + menuView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9) + + UIView.animate(withDuration: 0.2) { + self.menuView.alpha = 1 + self.menuView.transform = .identity + } + } + + @objc func hide() { + UIView.animate(withDuration: 0.2, animations: { + self.menuView.alpha = 0 + self.menuView.transform = CGAffineTransform(scaleX: 0.9, y: 0.9) + }) { _ in + self.menuView.removeFromSuperview() + self.overlayView.removeFromSuperview() + + self.presentingViewController = nil + } + } +} diff --git a/Session/Conversations/Selection/MessageSelectionManager.swift b/Session/Conversations/Selection/MessageSelectionManager.swift new file mode 100644 index 0000000000..81d93906db --- /dev/null +++ b/Session/Conversations/Selection/MessageSelectionManager.swift @@ -0,0 +1,167 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import Lucide +import SessionMessagingKit +import SessionUIKit + +protocol SelectionManagerDelegate: ContextMenuActionDelegate { + func willDeleteMessages(_ messages: [MessageViewModel], completion: @escaping () -> Void) + func shouldResetSelectionState() + func shouldShowCopyToast() + func showInfo(for message: MessageViewModel, withSender sender: UIBarButtonItem) + + var selectedMessages: Set { get } +} + +class MessageSelectionManager: NSObject { + var delegate: SelectionManagerDelegate? + var selectedMessages: Set { + delegate?.selectedMessages ?? [] + } + + init(delegate: SelectionManagerDelegate) { + self.delegate = delegate + } + + private func onButtonCreation(icon: UIImage?, accessibilityLabel: String, action: Selector) -> UIBarButtonItem? { + let result = UIBarButtonItem( + image: icon, + style: .plain, + target: self, + action: action + ) + result.accessibilityLabel = accessibilityLabel + result.isAccessibilityElement = true + return result + } + + @MainActor + func createNavigationActions() -> [UIBarButtonItem] { + // Create all possible buttons, using selectors pointing to the Manager's methods + let replyButtonItem = onButtonCreation( + icon: Lucide.image( + icon: .reply, + size: IconSize.medium.size + ), + accessibilityLabel: "Reply", + action: #selector(replySelected) + ) + let downloadButtonItem = onButtonCreation( + icon: Lucide.image( + icon: .download, + size: IconSize.medium.size + ), + accessibilityLabel: "Download", + action: #selector(saveSelected) + ) + let copyButtonItem = onButtonCreation( + icon: Lucide.image( + icon: .copy, + size: IconSize.medium.size + ), + accessibilityLabel: "Copy", + action: #selector(copySelected) + ) + let deleteButtonItem = onButtonCreation( + icon: Lucide.image( + icon: .trash2, + size: IconSize.medium.size + ), + accessibilityLabel: "Delete", + action: #selector(deleteSelected) + ) + let moreButtonItem = onButtonCreation( + icon: Lucide.image( + icon: .ellipsisVertical, + size: IconSize.medium.size + ), + accessibilityLabel: "More", + action: #selector(moreOptions) + ) + moreButtonItem?.tag = 99 + + var showDownload: Bool { + guard + selectedMessages.count <= 1, + selectedMessages.first(where: { $0.attachments != nil }) != nil + else { + return false + + } + return true + + } + + var showReply: Bool { + return selectedMessages.count <= 1 + } + + var showDelete: Bool { !showDownload } + + var showCopy: Bool { + guard selectedMessages.count <= 1 else { + return selectedMessages.contains(where: { $0.cellType == .textOnlyMessage }) + } + return false + } + + let items = [ + selectedMessages.count <= 1 ? moreButtonItem : nil, + showCopy ? copyButtonItem : nil, + showDelete ? deleteButtonItem : nil, + showDownload ? downloadButtonItem : nil, + showReply ? replyButtonItem : nil + ].compactMap { $0 } + + return items + } + + @objc + func replySelected() { + guard selectedMessages.count == 1, let message = selectedMessages.first, let delegate = delegate else { return } + delegate.reply(message) { [weak self] in self?.delegate?.shouldResetSelectionState() } + } + + @objc + func copySelected() { + let textMessages = selectedMessages + .filter { $0.cellType == .textOnlyMessage } + .sorted { $0.dateForUI < $1.dateForUI } + + if textMessages.count == 1, let textMessage = textMessages.first { + delegate?.copy(textMessage) { [weak self] in self?.delegate?.shouldResetSelectionState() } + } else if !textMessages.isEmpty { + let textToCopy = textMessages + .map { "\($0.dateForUI.formattedForDisplay): \($0.body ?? "")"} + .joined(separator: "\n") + + UIPasteboard.general.string = textToCopy + + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(0.3 * 1000))) { [weak self] in + self?.delegate?.shouldShowCopyToast() + } + + delegate?.shouldResetSelectionState() + } + } + + @objc + func deleteSelected() { + delegate?.willDeleteMessages(Array(selectedMessages)) { [weak self] in self?.delegate?.shouldResetSelectionState() } + } + + @objc + func saveSelected() { + guard selectedMessages.count == 1, let message = selectedMessages.first else { return } + delegate?.save(message) { [weak self] in self?.delegate?.shouldResetSelectionState() } + } + + @objc + func moreOptions(_ sender: UIBarButtonItem) { + guard let selectedMessage = selectedMessages.first else { + return + } + delegate?.showInfo(for: selectedMessage, withSender: sender) + } +} diff --git a/SessionUIKit/Style Guide/Values.swift b/SessionUIKit/Style Guide/Values.swift index d6a6894e9f..ae467fbdf0 100644 --- a/SessionUIKit/Style Guide/Values.swift +++ b/SessionUIKit/Style Guide/Values.swift @@ -28,6 +28,8 @@ public enum Values { public static let largeButtonHeight = isIPhone5OrSmaller ? CGFloat(40) : CGFloat(45) public static let alertButtonHeight: CGFloat = 51 // 19px tall font with 16px margins + public static let menuContainerWidth: CGFloat = isIPhone5OrSmaller ? CGFloat(180) : CGFloat(210) + public static let accentLineThickness = CGFloat(4) public static let searchBarHeight = CGFloat(36)