From 36380a01b87e830e129d04f482dadd8a1b48e909 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Mon, 20 Oct 2025 17:17:28 +0800 Subject: [PATCH 1/5] Added multi selection feature in messages Prepared message functionalities --- .../Context Menu/ContextMenuVC+Action.swift | 11 ++ .../ConversationVC+Interaction.swift | 161 ++++++++++++++++++ Session/Conversations/ConversationVC.swift | 19 ++- .../Message Cells/MessageCell.swift | 7 +- .../Message Cells/VisibleMessageCell.swift | 4 +- 5 files changed, 198 insertions(+), 4 deletions(-) diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index 53b44ea9b5..d815285724 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: UIImage(systemName: "arrow.triangle.2.circlepath"), + title: "Select", + accessibilityLabel: (cellViewModel.state == .failedToSync ? "Select message" : "Select message") + ) { completion in delegate?.select(cellViewModel, completion: completion) } + } } static func viewModelCanReply(_ cellViewModel: MessageViewModel, using dependencies: Dependencies) -> Bool { @@ -289,7 +297,9 @@ extension ContextMenuVC { .compactMap { EmojiWithSkinTones(rawValue: $0) } }() let generatedActions: [Action] = [ + (canRetry ? Action.retry(cellViewModel, delegate) : nil), + (viewModelCanReply(cellViewModel, using: dependencies) ? 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), @@ -327,4 +337,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..32743dbb9c 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, @@ -2869,6 +2877,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 +3507,143 @@ extension ConversationVC: MediaPresentationContextProvider { return self.navigationController?.navigationBar.generateSnapshot(in: coordinateSpace) } } + +extension ConversationVC { + func shouldHandleMessageSelection(for message: MessageViewModel, in cell: UITableViewCell) { + 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() { + tableView.allowsMultipleSelection = isMultiSelectionEnabled + + navigationItem.titleView = isMultiSelectionEnabled ? nil : titleView + + // Nav bar buttons + updateNavBarButtons( + threadData: viewModel.threadData, + initialVariant: viewModel.initialThreadVariant, + initialIsNoteToSelf: viewModel.threadData.threadIsNoteToSelf, + initialIsBlocked: (viewModel.threadData.threadIsBlocked == true) + ) + } + + // Selection navigation bar + @objc + func replySelected() { + guard let message = selectedMessages.first else { return } + + isMultiSelectionEnabled = false + selectedMessages.removeAll() + tableView.reloadData() + + reply(message, completion: nil) + } + + @objc + func copySelected() { + + } + + @objc + func deleteSelected() { + + } + + @objc + func saveSelected() { + guard let message = selectedMessages.first else { return } + + isMultiSelectionEnabled = false + selectedMessages.removeAll() + tableView.reloadData() + + save(message, completion: nil) + } + + @objc + func moreOptions() { + // TODO: Add context + } + + func setNavigationActions() -> [UIBarButtonItem] { + let replyButtonItem: UIBarButtonItem = UIBarButtonItem( + image: Lucide.image(icon: .reply, size: 24), + style: .plain, + target: self, + action: #selector(replySelected) + ) + replyButtonItem.accessibilityLabel = "Reply" + replyButtonItem.isAccessibilityElement = true + + let downloadButtonItem: UIBarButtonItem = UIBarButtonItem( + image: Lucide.image(icon: .download, size: 24), + style: .plain, + target: self, + action: #selector(saveSelected) + ) + downloadButtonItem.accessibilityLabel = "Download" + downloadButtonItem.isAccessibilityElement = true + + let copyButtonItem: UIBarButtonItem = UIBarButtonItem( + image: Lucide.image(icon: .copy, size: 24), + style: .plain, + target: self, + action: #selector(deleteSelected) + ) + copyButtonItem.accessibilityLabel = "Copy" + copyButtonItem.isAccessibilityElement = true + + let deleteButtonItem: UIBarButtonItem = UIBarButtonItem( + image: Lucide.image(icon: .trash2, size: 24), + style: .plain, + target: self, + action: #selector(deleteSelected) + ) + deleteButtonItem.accessibilityLabel = "Delete" + deleteButtonItem.isAccessibilityElement = true + + let moreButtonItem: UIBarButtonItem = UIBarButtonItem( + image: Lucide.image(icon: .ellipsisVertical, size: 24), + style: .plain, + target: self, + action: #selector(deleteSelected) + ) + moreButtonItem.accessibilityLabel = "More" + moreButtonItem.isAccessibilityElement = true + + var canBeDownloaded: Bool { + guard + selectedMessages.count <= 1, + selectedMessages.first(where: { $0.attachments != nil }) != nil + else { + return false + } + + return true + } + + var hasTextType: Bool { + return selectedMessages.contains(where: { $0.cellType == .textOnlyMessage }) + } + + + let items = [ + selectedMessages.count <= 1 ? moreButtonItem : nil, + hasTextType ? copyButtonItem : nil, + selectedMessages.count > 1 ? deleteButtonItem : nil, + canBeDownloaded ? downloadButtonItem : nil, + selectedMessages.count <= 1 ? replyButtonItem : nil + ].compactMap { $0 } + + return items + } +} diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 7ec260b5b3..7c38977de8 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 @@ -1409,8 +1417,10 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa if isShowingSearchUI { navigationItem.leftBarButtonItem = nil navigationItem.rightBarButtonItems = [] - } - else { + } else if isMultiSelectionEnabled { + let items = setNavigationActions() + navigationItem.rightBarButtonItems = items + }else { let shouldHaveCallButton: Bool = ( (threadData?.threadVariant ?? initialVariant) == .contact && (threadData?.threadIsNoteToSelf ?? initialIsNoteToSelf) == false @@ -1693,6 +1703,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 @@ -1724,6 +1735,10 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa ) cell.delegate = self + let isSelected = selectedMessages.contains(cellViewModel) + + cell.setSelectedState(isSelected) + return cell default: preconditionFailure("Other sections should have no content") diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift index a4e2cd8585..88d8fe318b 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) { + contentView.backgroundColor = selected ? .orange : .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) } extension MessageCellDelegate { diff --git a/Session/Conversations/Message Cells/VisibleMessageCell.swift b/Session/Conversations/Message Cells/VisibleMessageCell.swift index f809ec9b80..b40e150538 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -1032,7 +1032,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { override func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { guard let cellViewModel: MessageViewModel = self.viewModel else { return } - + let location = gestureRecognizer.location(in: self) let tappedAuthorName: Bool = ( authorLabel.bounds.contains(authorLabel.convert(location, from: self)) && @@ -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() { From 5809707755bc4818d8b74846e46c5ab5af5db280 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Tue, 21 Oct 2025 09:43:56 +0800 Subject: [PATCH 2/5] Added selection manager to handle button creation and events --- Session.xcodeproj/project.pbxproj | 12 + .../Context Menu/ContextMenuVC+Action.swift | 37 ++ .../ConversationVC+Interaction.swift | 335 +++++++----------- Session/Conversations/ConversationVC.swift | 7 +- .../Selection/MessageSelectionManager.swift | 162 +++++++++ 5 files changed, 351 insertions(+), 202 deletions(-) create mode 100644 Session/Conversations/Selection/MessageSelectionManager.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index ef261630d7..b81010d786 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -1173,6 +1173,7 @@ 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 */; }; 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 +2468,7 @@ 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 = ""; }; 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 +2988,7 @@ B835246C25C38AA20089A44F /* Conversations */ = { isa = PBXGroup; children = ( + FE2883252EA70C5D0097E240 /* Selection */, B887C38125C7C79700E11DAE /* Input View */, B835247725C38D190089A44F /* Message Cells */, C328252E25CA54F70062D0A7 /* Context Menu */, @@ -5300,6 +5303,14 @@ path = Transitions; sourceTree = ""; }; + FE2883252EA70C5D0097E240 /* Selection */ = { + isa = PBXGroup; + children = ( + FE2883262EA70C640097E240 /* MessageSelectionManager.swift */, + ); + path = Selection; + sourceTree = ""; + }; FED288EF2E4C239800C31171 /* App Review */ = { isa = PBXGroup; children = ( @@ -6944,6 +6955,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 */, diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index d815285724..524ed225a8 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -320,6 +320,43 @@ 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 canCopy: Bool = ( + cellViewModel.cellType == .textOnlyMessage || ( + ( + cellViewModel.cellType == .genericAttachment || + cellViewModel.cellType == .mediaMessage + ) && + (cellViewModel.attachments ?? []).count == 1 && + (cellViewModel.attachments ?? []).first?.isVisualMedia == true && + (cellViewModel.attachments ?? []).first?.isValid == true && ( + (cellViewModel.attachments ?? []).first?.state == .downloaded || + (cellViewModel.attachments ?? []).first?.state == .uploaded + ) + ) + ) + + let canDelete: Bool = (MessageViewModel.DeletionBehaviours.deletionActions( + for: [cellViewModel], + with: threadViewModel, + using: dependencies + ) != nil) + let generatedActions: [Action] = [ + (canCopy ? Action.copy(cellViewModel, delegate) : nil), + (canDelete ? Action.delete(cellViewModel, delegate) : nil), + Action.info(cellViewModel, delegate) + ] + .compactMap { $0 } + + return generatedActions + } } // MARK: - Delegate diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 32743dbb9c..67d413f2cc 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -2496,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)?) { @@ -3508,6 +3412,118 @@ extension ConversationVC: MediaPresentationContextProvider { } } +// 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) { if let selectedIndex = selectedMessages.firstIndex(where: { $0 == message }) { @@ -3523,8 +3539,6 @@ extension ConversationVC { } func shouldUpdateNavigationBar() { - tableView.allowsMultipleSelection = isMultiSelectionEnabled - navigationItem.titleView = isMultiSelectionEnabled ? nil : titleView // Nav bar buttons @@ -3536,114 +3550,33 @@ extension ConversationVC { ) } - // Selection navigation bar - @objc - func replySelected() { - guard let message = selectedMessages.first else { return } - - isMultiSelectionEnabled = false - selectedMessages.removeAll() - tableView.reloadData() - - reply(message, completion: nil) - } - - @objc - func copySelected() { - + func resetSelection() { + DispatchQueue.main.async { [weak self] in + self?.isMultiSelectionEnabled = false + self?.selectedMessages.removeAll() + self?.tableView.reloadData() + } } - - @objc - func deleteSelected() { - +} + +extension ConversationVC: SelectionManagerDelegate { + func willDeleteMessages(_ messages: [SessionMessagingKit.MessageViewModel], completion: @escaping () -> Void) { + onDeleteMessages(messages, completion: completion) } - - @objc - func saveSelected() { - guard let message = selectedMessages.first else { return } - - isMultiSelectionEnabled = false - selectedMessages.removeAll() - tableView.reloadData() - - save(message, completion: nil) + + func shouldResetSelectionState() { + resetSelection() } - @objc - func moreOptions() { - // TODO: Add context + func shouldShowCopyToast() { + viewModel.showToast( + text: "copied".localized(), + backgroundColor: .toast_background, + inset: Values.largeSpacing + (inputAccessoryView?.frame.height ?? 0) + ) } - func setNavigationActions() -> [UIBarButtonItem] { - let replyButtonItem: UIBarButtonItem = UIBarButtonItem( - image: Lucide.image(icon: .reply, size: 24), - style: .plain, - target: self, - action: #selector(replySelected) - ) - replyButtonItem.accessibilityLabel = "Reply" - replyButtonItem.isAccessibilityElement = true - - let downloadButtonItem: UIBarButtonItem = UIBarButtonItem( - image: Lucide.image(icon: .download, size: 24), - style: .plain, - target: self, - action: #selector(saveSelected) - ) - downloadButtonItem.accessibilityLabel = "Download" - downloadButtonItem.isAccessibilityElement = true - - let copyButtonItem: UIBarButtonItem = UIBarButtonItem( - image: Lucide.image(icon: .copy, size: 24), - style: .plain, - target: self, - action: #selector(deleteSelected) - ) - copyButtonItem.accessibilityLabel = "Copy" - copyButtonItem.isAccessibilityElement = true - - let deleteButtonItem: UIBarButtonItem = UIBarButtonItem( - image: Lucide.image(icon: .trash2, size: 24), - style: .plain, - target: self, - action: #selector(deleteSelected) - ) - deleteButtonItem.accessibilityLabel = "Delete" - deleteButtonItem.isAccessibilityElement = true - - let moreButtonItem: UIBarButtonItem = UIBarButtonItem( - image: Lucide.image(icon: .ellipsisVertical, size: 24), - style: .plain, - target: self, - action: #selector(deleteSelected) - ) - moreButtonItem.accessibilityLabel = "More" - moreButtonItem.isAccessibilityElement = true - - var canBeDownloaded: Bool { - guard - selectedMessages.count <= 1, - selectedMessages.first(where: { $0.attachments != nil }) != nil - else { - return false - } - - return true - } - - var hasTextType: Bool { - return selectedMessages.contains(where: { $0.cellType == .textOnlyMessage }) - } - - - let items = [ - selectedMessages.count <= 1 ? moreButtonItem : nil, - hasTextType ? copyButtonItem : nil, - selectedMessages.count > 1 ? deleteButtonItem : nil, - canBeDownloaded ? downloadButtonItem : nil, - selectedMessages.count <= 1 ? replyButtonItem : nil - ].compactMap { $0 } + func showMoreOptions(for message: MessageViewModel, canCopy: Bool, canDelete: Bool) { - return items } } diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index 7c38977de8..a3bed378da 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -119,6 +119,11 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa behavior: .playAndRecord ) + // Message selection + lazy var selectionManger = MessageSelectionManager( + delegate: self + ) + lazy var searchController: ConversationSearchController = { let result: ConversationSearchController = ConversationSearchController( threadId: self.viewModel.threadData.threadId @@ -1418,7 +1423,7 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa navigationItem.leftBarButtonItem = nil navigationItem.rightBarButtonItems = [] } else if isMultiSelectionEnabled { - let items = setNavigationActions() + let items = selectionManger.createNavigationActions() navigationItem.rightBarButtonItems = items }else { let shouldHaveCallButton: Bool = ( diff --git a/Session/Conversations/Selection/MessageSelectionManager.swift b/Session/Conversations/Selection/MessageSelectionManager.swift new file mode 100644 index 0000000000..b64f04d835 --- /dev/null +++ b/Session/Conversations/Selection/MessageSelectionManager.swift @@ -0,0 +1,162 @@ +// Copyright © 2025 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import Lucide +import SessionMessagingKit + +protocol SelectionManagerDelegate: ContextMenuActionDelegate { + func willDeleteMessages(_ messages: [MessageViewModel], completion: @escaping () -> Void) + func showMoreOptions(for message: MessageViewModel, canCopy: Bool, canDelete: Bool) + func shouldResetSelectionState() + func shouldShowCopyToast() + + 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 item = UIBarButtonItem( + image: icon, + style: .plain, + target: self, + action: action + ) + item.accessibilityLabel = accessibilityLabel + item.isAccessibilityElement = true + return item + } + + @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: 24), + accessibilityLabel: "Reply", + action: #selector(replySelected) + ) + let downloadButtonItem = onButtonCreation( + icon: Lucide.image(icon: .download, size: 24), + accessibilityLabel: "Download", + action: #selector(saveSelected) + ) + let copyButtonItem = onButtonCreation( + icon: Lucide.image(icon: .copy, size: 24), + accessibilityLabel: "Copy", + action: #selector(copySelected) + ) + let deleteButtonItem = onButtonCreation( + icon: Lucide.image(icon: .trash2, size: 24), + accessibilityLabel: "Delete", + action: #selector(deleteSelected) + ) + let moreButtonItem = onButtonCreation( + icon: Lucide.image(icon: .ellipsisVertical, size: 24), + accessibilityLabel: "More", + action: #selector(moreOptions) + ) + + 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() { + guard let selectedMessage = selectedMessages.first else { + return + } + + // TODO: Add context + var canCopy: Bool { + guard selectedMessages.count == 1 else { return false } + return selectedMessage.cellType == .textOnlyMessage + } + + var canDelete: Bool { + guard selectedMessages.count == 1 else { return false } + return selectedMessage.attachments != nil + } + + delegate?.showMoreOptions(for: selectedMessage, canCopy: canCopy, canDelete: canDelete) + } +} From 856de718510d7dfb4023d5cfac45d5085c1a30c7 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Tue, 21 Oct 2025 10:13:58 +0800 Subject: [PATCH 3/5] Added navigation to info page --- .../Context Menu/ContextMenuVC+Action.swift | 20 +++++++++++++++---- .../ConversationVC+Interaction.swift | 4 ---- .../Selection/MessageSelectionManager.swift | 16 +++------------ 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index 524ed225a8..36097d3dc6 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -166,9 +166,9 @@ extension ContextMenuVC { static func select(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( - icon: UIImage(systemName: "arrow.triangle.2.circlepath"), - title: "Select", - accessibilityLabel: (cellViewModel.state == .failedToSync ? "Select message" : "Select message") + icon: Lucide.image(icon: .circleCheck, size: 24), + title: "select".localized(), + accessibilityLabel: "Select message" ) { completion in delegate?.select(cellViewModel, completion: completion) } } } @@ -210,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 && ( @@ -299,7 +311,7 @@ extension ContextMenuVC { let generatedActions: [Action] = [ (canRetry ? Action.retry(cellViewModel, delegate) : nil), - (viewModelCanReply(cellViewModel, using: dependencies) ? Action.select(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), diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 67d413f2cc..dddf163510 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -3575,8 +3575,4 @@ extension ConversationVC: SelectionManagerDelegate { inset: Values.largeSpacing + (inputAccessoryView?.frame.height ?? 0) ) } - - func showMoreOptions(for message: MessageViewModel, canCopy: Bool, canDelete: Bool) { - - } } diff --git a/Session/Conversations/Selection/MessageSelectionManager.swift b/Session/Conversations/Selection/MessageSelectionManager.swift index b64f04d835..5573aeaf93 100644 --- a/Session/Conversations/Selection/MessageSelectionManager.swift +++ b/Session/Conversations/Selection/MessageSelectionManager.swift @@ -6,7 +6,6 @@ import SessionMessagingKit protocol SelectionManagerDelegate: ContextMenuActionDelegate { func willDeleteMessages(_ messages: [MessageViewModel], completion: @escaping () -> Void) - func showMoreOptions(for message: MessageViewModel, canCopy: Bool, canDelete: Bool) func shouldResetSelectionState() func shouldShowCopyToast() @@ -145,18 +144,9 @@ class MessageSelectionManager: NSObject { guard let selectedMessage = selectedMessages.first else { return } + // TODO: Show drop down instead + delegate?.info(selectedMessage) - // TODO: Add context - var canCopy: Bool { - guard selectedMessages.count == 1 else { return false } - return selectedMessage.cellType == .textOnlyMessage - } - - var canDelete: Bool { - guard selectedMessages.count == 1 else { return false } - return selectedMessage.attachments != nil - } - - delegate?.showMoreOptions(for: selectedMessage, canCopy: canCopy, canDelete: canDelete) + delegate?.shouldResetSelectionState() } } From 20b41b3aeed46f68e5b6acd4f3e4b52920afd83c Mon Sep 17 00:00:00 2001 From: mikoldin Date: Tue, 21 Oct 2025 11:58:54 +0800 Subject: [PATCH 4/5] Updated selected row color --- .../Context Menu/ContextMenuVC+Action.swift | 28 ++++++++----------- .../ConversationVC+Interaction.swift | 23 +++++++++++++++ .../Message Cells/MessageCell.swift | 2 +- .../Selection/MessageSelectionManager.swift | 11 +++++--- 4 files changed, 42 insertions(+), 22 deletions(-) diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index 36097d3dc6..61db184a3e 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -340,29 +340,23 @@ extension ContextMenuVC { delegate: ContextMenuActionDelegate?, using dependencies: Dependencies ) -> [Action]? { - let canCopy: Bool = ( - cellViewModel.cellType == .textOnlyMessage || ( - ( - cellViewModel.cellType == .genericAttachment || - cellViewModel.cellType == .mediaMessage - ) && - (cellViewModel.attachments ?? []).count == 1 && - (cellViewModel.attachments ?? []).first?.isVisualMedia == true && - (cellViewModel.attachments ?? []).first?.isValid == true && ( - (cellViewModel.attachments ?? []).first?.state == .downloaded || - (cellViewModel.attachments ?? []).first?.state == .uploaded - ) - ) - ) - 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] = [ - (canCopy ? Action.copy(cellViewModel, delegate) : nil), - (canDelete ? Action.delete(cellViewModel, delegate) : nil), + (showCopy ? Action.copy(cellViewModel, delegate) : nil), + (showDelete ? Action.delete(cellViewModel, delegate) : nil), Action.info(cellViewModel, delegate) ] .compactMap { $0 } diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index dddf163510..3b3a696f15 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -3526,6 +3526,14 @@ extension ConversationVC { // 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 { @@ -3575,4 +3583,19 @@ extension ConversationVC: SelectionManagerDelegate { inset: Values.largeSpacing + (inputAccessoryView?.frame.height ?? 0) ) } + + func showInfo(for message: MessageViewModel, withSender sender: UIBarButtonItem) { + /*guard + let actions = ContextMenuVC.navigationActions( + for: message, + in: viewModel.threadData, + delegate: self, + using: viewModel.dependencies + ) + else { return }*/ + + // TODO: - Show context menu below navigationbar for now navigate to info page + info(message) + resetSelection() + } } diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift index 88d8fe318b..e9bf6ee7d6 100644 --- a/Session/Conversations/Message Cells/MessageCell.swift +++ b/Session/Conversations/Message Cells/MessageCell.swift @@ -46,7 +46,7 @@ public class MessageCell: UITableViewCell { } func setSelectedState(_ selected: Bool) { - contentView.backgroundColor = selected ? .orange : .clear + themeBackgroundColor = selected ? .backgroundSecondary : .clear } func setUpGestureRecognizers() { diff --git a/Session/Conversations/Selection/MessageSelectionManager.swift b/Session/Conversations/Selection/MessageSelectionManager.swift index 5573aeaf93..7730e80f9c 100644 --- a/Session/Conversations/Selection/MessageSelectionManager.swift +++ b/Session/Conversations/Selection/MessageSelectionManager.swift @@ -8,6 +8,7 @@ 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 } } @@ -140,13 +141,15 @@ class MessageSelectionManager: NSObject { } @objc - func moreOptions() { + func moreOptions(_ sender: UIBarButtonItem) { guard let selectedMessage = selectedMessages.first else { return } - // TODO: Show drop down instead - delegate?.info(selectedMessage) - delegate?.shouldResetSelectionState() + // TODO: Show drop down instead + delegate?.showInfo(for: selectedMessage, withSender: sender) +// delegate?.info(selectedMessage) +// +// delegate?.shouldResetSelectionState() } } From 4d4c400b4033ccc4ca76de003d9e7c2a0fcc3791 Mon Sep 17 00:00:00 2001 From: mikoldin Date: Tue, 21 Oct 2025 14:35:31 +0800 Subject: [PATCH 5/5] Added navigation bar dropdown option --- Session.xcodeproj/project.pbxproj | 8 ++ .../ConversationVC+Interaction.swift | 33 +++++--- Session/Conversations/ConversationVC.swift | 9 ++- .../Message Cells/MessageCell.swift | 2 +- .../Message Cells/VisibleMessageCell.swift | 2 +- .../Selection/CustomMenuView.swift | 56 +++++++++++++ .../Selection/ManualDropdownPresenter.swift | 81 +++++++++++++++++++ .../Selection/MessageSelectionManager.swift | 40 +++++---- SessionUIKit/Style Guide/Values.swift | 2 + 9 files changed, 202 insertions(+), 31 deletions(-) create mode 100644 Session/Conversations/Selection/CustomMenuView.swift create mode 100644 Session/Conversations/Selection/ManualDropdownPresenter.swift diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index b81010d786..956544c36b 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -1174,6 +1174,8 @@ 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 */ @@ -2469,6 +2471,8 @@ 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 */ @@ -5306,6 +5310,8 @@ FE2883252EA70C5D0097E240 /* Selection */ = { isa = PBXGroup; children = ( + FE28832C2EA74D680097E240 /* CustomMenuView.swift */, + FE28832A2EA74D440097E240 /* ManualDropdownPresenter.swift */, FE2883262EA70C640097E240 /* MessageSelectionManager.swift */, ); path = Selection; @@ -7073,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 */, @@ -7084,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/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index 3b3a696f15..7a03598117 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -3585,17 +3585,28 @@ extension ConversationVC: SelectionManagerDelegate { } func showInfo(for message: MessageViewModel, withSender sender: UIBarButtonItem) { - /*guard - let actions = ContextMenuVC.navigationActions( - for: message, - in: viewModel.threadData, - delegate: self, - using: viewModel.dependencies - ) - else { return }*/ + if dropdownPresenter != nil { + dropdownPresenter?.hide() + dropdownPresenter = nil + } + + let actions = ContextMenuVC.navigationActions( + for: message, + in: viewModel.threadData, + delegate: self, + using: viewModel.dependencies + ) ?? [] - // TODO: - Show context menu below navigationbar for now navigate to info page - info(message) - resetSelection() + 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 a3bed378da..289c9f06e2 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -124,6 +124,10 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa delegate: self ) + // Reference to dropdown view + var dropdownPresenter: ManualDropdownPresenter? + + // Search lazy var searchController: ConversationSearchController = { let result: ConversationSearchController = ConversationSearchController( threadId: self.viewModel.threadData.threadId @@ -1738,12 +1742,9 @@ final class ConversationVC: BaseVC, LibSessionRespondingViewController, Conversa tableSize: tableView.bounds.size, using: viewModel.dependencies ) + cell.setSelectedState(selectedMessages.contains(cellViewModel)) cell.delegate = self - let isSelected = selectedMessages.contains(cellViewModel) - - cell.setSelectedState(isSelected) - return cell default: preconditionFailure("Other sections should have no content") diff --git a/Session/Conversations/Message Cells/MessageCell.swift b/Session/Conversations/Message Cells/MessageCell.swift index e9bf6ee7d6..8fc4779895 100644 --- a/Session/Conversations/Message Cells/MessageCell.swift +++ b/Session/Conversations/Message Cells/MessageCell.swift @@ -153,7 +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) + 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 b40e150538..792127def6 100644 --- a/Session/Conversations/Message Cells/VisibleMessageCell.swift +++ b/Session/Conversations/Message Cells/VisibleMessageCell.swift @@ -1032,7 +1032,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate { override func handleTap(_ gestureRecognizer: UITapGestureRecognizer) { guard let cellViewModel: MessageViewModel = self.viewModel else { return } - + let location = gestureRecognizer.location(in: self) let tappedAuthorName: Bool = ( authorLabel.bounds.contains(authorLabel.convert(location, from: self)) && 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 index 7730e80f9c..81d93906db 100644 --- a/Session/Conversations/Selection/MessageSelectionManager.swift +++ b/Session/Conversations/Selection/MessageSelectionManager.swift @@ -3,6 +3,7 @@ import UIKit import Lucide import SessionMessagingKit +import SessionUIKit protocol SelectionManagerDelegate: ContextMenuActionDelegate { func willDeleteMessages(_ messages: [MessageViewModel], completion: @escaping () -> Void) @@ -24,45 +25,61 @@ class MessageSelectionManager: NSObject { } private func onButtonCreation(icon: UIImage?, accessibilityLabel: String, action: Selector) -> UIBarButtonItem? { - let item = UIBarButtonItem( + let result = UIBarButtonItem( image: icon, style: .plain, target: self, action: action ) - item.accessibilityLabel = accessibilityLabel - item.isAccessibilityElement = true - return item + 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: 24), + icon: Lucide.image( + icon: .reply, + size: IconSize.medium.size + ), accessibilityLabel: "Reply", action: #selector(replySelected) ) let downloadButtonItem = onButtonCreation( - icon: Lucide.image(icon: .download, size: 24), + icon: Lucide.image( + icon: .download, + size: IconSize.medium.size + ), accessibilityLabel: "Download", action: #selector(saveSelected) ) let copyButtonItem = onButtonCreation( - icon: Lucide.image(icon: .copy, size: 24), + icon: Lucide.image( + icon: .copy, + size: IconSize.medium.size + ), accessibilityLabel: "Copy", action: #selector(copySelected) ) let deleteButtonItem = onButtonCreation( - icon: Lucide.image(icon: .trash2, size: 24), + icon: Lucide.image( + icon: .trash2, + size: IconSize.medium.size + ), accessibilityLabel: "Delete", action: #selector(deleteSelected) ) let moreButtonItem = onButtonCreation( - icon: Lucide.image(icon: .ellipsisVertical, size: 24), + icon: Lucide.image( + icon: .ellipsisVertical, + size: IconSize.medium.size + ), accessibilityLabel: "More", action: #selector(moreOptions) ) + moreButtonItem?.tag = 99 var showDownload: Bool { guard @@ -145,11 +162,6 @@ class MessageSelectionManager: NSObject { guard let selectedMessage = selectedMessages.first else { return } - - // TODO: Show drop down instead delegate?.showInfo(for: selectedMessage, withSender: sender) -// delegate?.info(selectedMessage) -// -// delegate?.shouldResetSelectionState() } } 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)