diff --git a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift index 7e62e1ddf..b3ea01f85 100644 --- a/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift +++ b/AccessibilityTests/Sources/GeneratedAccessibilityTests.swift @@ -803,6 +803,10 @@ extension AccessibilityTests { try await performAccessibilityAudit(named: "TimelineMediaPreviewRedactConfirmationView_Previews") } + func testTimelineMessageSelectionActionBar() async throws { + try await performAccessibilityAudit(named: "TimelineMessageSelectionActionBar_Previews") + } + func testTimelineReactionView() async throws { try await performAccessibilityAudit(named: "TimelineReactionView_Previews") } diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 1f68e2081..580de0adb 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -51,6 +51,7 @@ 053B8BD2496207838878C6C9 /* PinnedItemsBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60C9BAE9F9436B14E4E22E8F /* PinnedItemsBannerView.swift */; }; 059173B3C77056C406906B6D /* target.yml in Resources */ = {isa = PBXBuildFile; fileRef = D4DA544B2520BFA65D6DB4BB /* target.yml */; }; 059A6BEDE9BADF3C81AC4146 /* SpaceAddRoomsScreenSelectedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 77AB44FFCF7DA5300D943169 /* SpaceAddRoomsScreenSelectedItem.swift */; }; + 05A7A9E3653E018E383C9890 /* TimelineMessageSelectionActionBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4791FBED207151AE487DBE11 /* TimelineMessageSelectionActionBar.swift */; }; 05B02B9508E922677F0E9451 /* LocationSharingScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 052A8C0B00FE8AD7C972F2A4 /* LocationSharingScreenViewModel.swift */; }; 05BAB510CBC2ED35C154ADD0 /* AnalyticsPromptScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AFD012C3A9F5EF276DDD4AA /* AnalyticsPromptScreenViewModelProtocol.swift */; }; 05E797C4E0048BB487E5C4D6 /* AppLockTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8495F37D6245AD0CFA1F60B /* AppLockTests.swift */; }; @@ -426,6 +427,7 @@ 440123E29E2F9B001A775BBE /* TimelineItemProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D505843AB66822EB91F0DF0 /* TimelineItemProxy.swift */; }; 441857143FC100E0A7DE42A8 /* RoomPowerLevelProxyProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D635709C1D6D37C225AD40E /* RoomPowerLevelProxyProtocol.swift */; }; 446BCD2D0AE27E0CFD1BDC8F /* ImageMediaEventsTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FF584D757E768EA7776A532 /* ImageMediaEventsTimelineView.swift */; }; + 44B698EFE616E1E7FD34C115 /* TimelineItemsSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B35297BA5FF73D83D8C72A2A /* TimelineItemsSelectionTests.swift */; }; 44BDD670FF9095ACE240A3A2 /* VoiceMessageMediaManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AC4F10BDD56FA77FEC742333 /* VoiceMessageMediaManagerTests.swift */; }; 44D3EA8583C54BA3D8058499 /* SyncServiceSDKMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33DBBBC78047C669166C9F7C /* SyncServiceSDKMock.swift */; }; 44F0E1B576C7599DF8022071 /* SentrySwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 75361A9D8A3C5501EADB225D /* SentrySwiftUI */; }; @@ -1191,6 +1193,7 @@ BCC864190651B3A3CF51E4DF /* MediaFileHandleProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEC1D382565A4E9CAC2F14EA /* MediaFileHandleProxy.swift */; }; BD0BE20DBCE31253AE4490A1 /* RoomListFiltersEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC1DDB2293A51EA4C2739351 /* RoomListFiltersEmptyStateView.swift */; }; BD6685592716CA957D7BAAC4 /* RoomChangeRolesScreenSelectedItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D9B45D584D232CB9E5C7734 /* RoomChangeRolesScreenSelectedItem.swift */; }; + BDA14E4B772E28A792D7F5C8 /* TimelineMessageSelectionToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 455993E99087FE797278AFD0 /* TimelineMessageSelectionToolbar.swift */; }; BDA68E8D95B2B24B28825B8B /* LoginScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C368CAB3063EF275357ECD4 /* LoginScreenViewModel.swift */; }; BDC4EB54CC3036730475CB8B /* QRCodeLoginScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25E7E9B7FEAB6169D960C206 /* QRCodeLoginScreenViewModelTests.swift */; }; BDCF42966007770A33D90E15 /* PreviewScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5DB56ABD78E60920ACE43087 /* PreviewScrollView.swift */; }; @@ -1600,7 +1603,6 @@ FCF95603F1D056B1B106A415 /* AdvancedSettingsScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 83E2B20431F890ED64255CA1 /* AdvancedSettingsScreenViewModelProtocol.swift */; }; FD19654C18E7C6D8AC79408D /* libMatrixRustSDKMocks.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 73F3153AB9D628110878E24F /* libMatrixRustSDKMocks.a */; }; FD29471C72872F8B7580E3E1 /* KeychainControllerMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39C0D861FC397AC34BCF089E /* KeychainControllerMock.swift */; }; - FD387E0B8962056D2092DF3F /* TimelineSelectionToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7C3B926E1BBC7569AC6C682 /* TimelineSelectionToolbar.swift */; }; FD573B5D665824EB79EABF06 /* Observable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5327E3B3C58BEB0E65F4CF98 /* Observable.swift */; }; FD9777315A5D9CDC47458AD1 /* MediaEventsTimelineScreenViewModelProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A175D0FDEDBFA44C47FE13AE /* MediaEventsTimelineScreenViewModelProtocol.swift */; }; FDD5B4B616D9FF4DE3E9A418 /* QRCodeScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92DB574F954CC2B40F7BE892 /* QRCodeScannerView.swift */; }; @@ -2180,6 +2182,7 @@ 4525E8C0FBDD27D1ACE90952 /* SecureBackupKeyBackupScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureBackupKeyBackupScreenViewModelProtocol.swift; sourceTree = ""; }; 4549FCB53F43DB0B278374BC /* TemplateScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemplateScreen.swift; sourceTree = ""; }; 4552D3466B1453F287223ADA /* SwipeRightAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeRightAction.swift; sourceTree = ""; }; + 455993E99087FE797278AFD0 /* TimelineMessageSelectionToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMessageSelectionToolbar.swift; sourceTree = ""; }; 459A3921046977CBF4F3C359 /* SpaceScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpaceScreenViewModelProtocol.swift; sourceTree = ""; }; 45CDF9A107BFE6C79B58D6B5 /* RoomMembersListScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMembersListScreenViewModelProtocol.swift; sourceTree = ""; }; 45D8149FDDA0315CDC553B4B /* UserNotificationCenterProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserNotificationCenterProtocol.swift; sourceTree = ""; }; @@ -2197,6 +2200,7 @@ 475EB595D7527E9A8A14043E /* uz */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uz; path = uz.lproj/Localizable.strings; sourceTree = ""; }; 477A8C656B7B26E99C35927F /* StartChatFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatFlowCoordinator.swift; sourceTree = ""; }; 47873756E45B46683D97DC32 /* LegalInformationScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegalInformationScreenModels.swift; sourceTree = ""; }; + 4791FBED207151AE487DBE11 /* TimelineMessageSelectionActionBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMessageSelectionActionBar.swift; sourceTree = ""; }; 47EBB5D698CE9A25BB553A2D /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; 47F29139BC2A804CE5E0757E /* MediaUploadPreviewScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadPreviewScreenViewModel.swift; sourceTree = ""; }; 47F441A78A5CAA9E2937E463 /* KnockRequestsBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KnockRequestsBannerView.swift; sourceTree = ""; }; @@ -2829,6 +2833,7 @@ B2EAFFD44F81F86012D6EC27 /* AudioRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRoomTimelineView.swift; sourceTree = ""; }; B3005886F00029F058DB62BE /* StartChatScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreenCoordinator.swift; sourceTree = ""; }; B343C5255FB408DDE853CFDF /* RoomScreenHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreenHook.swift; sourceTree = ""; }; + B35297BA5FF73D83D8C72A2A /* TimelineItemsSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemsSelectionTests.swift; sourceTree = ""; }; B3561843E139EA7CDAED0933 /* ClientFactoryHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientFactoryHook.swift; sourceTree = ""; }; B383DCD3DCB19E00FD478A5F /* ConfirmationDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfirmationDialog.swift; sourceTree = ""; }; B4005D82E9D27BAF006A8FE1 /* AppLockScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockScreenViewModel.swift; sourceTree = ""; }; @@ -3131,7 +3136,6 @@ E7495E1119753B06FF2C2279 /* PhotoLibraryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoLibraryManager.swift; sourceTree = ""; }; E76A706B3EEA32B882DA5E2D /* BlockedUsersScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlockedUsersScreenViewModelProtocol.swift; sourceTree = ""; }; E78FC546F28E045A560F2963 /* EncryptionKeyProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncryptionKeyProviderProtocol.swift; sourceTree = ""; }; - E7C3B926E1BBC7569AC6C682 /* TimelineSelectionToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineSelectionToolbar.swift; sourceTree = ""; }; E8294DB9E95C0C0630418466 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; E8495F37D6245AD0CFA1F60B /* AppLockTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppLockTests.swift; sourceTree = ""; }; E858B68153E2C6E5800336AB /* CXCallControllerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CXCallControllerMock.swift; sourceTree = ""; }; @@ -3690,9 +3694,10 @@ 02EE0FABA8ED6D6C1D6CE71D /* ReactionsSummaryView.swift */, 66F91544AC136BF6477BDAB8 /* TimelineDeliveryStatusView.swift */, 72614BFF35B8394C6E13F55A /* TimelineItemStatusView.swift */, + 4791FBED207151AE487DBE11 /* TimelineMessageSelectionActionBar.swift */, + 455993E99087FE797278AFD0 /* TimelineMessageSelectionToolbar.swift */, 54AD70D6E03D2031AE1B5A52 /* TimelineReactionsView.swift */, 33035418BB35754232985871 /* TimelineReadReceiptsView.swift */, - E7C3B926E1BBC7569AC6C682 /* TimelineSelectionToolbar.swift */, ); path = Supplementary; sourceTree = ""; @@ -5334,6 +5339,7 @@ 53C65A839BC897F6C236E8A5 /* TimelineInteractionHandlerTests.swift */, 9AA3AF94A06D319BB37E52DA /* TimelineItemFactoryTests.swift */, C98C243A90369929D669873D /* TimelineItemMenuActionProviderTests.swift */, + B35297BA5FF73D83D8C72A2A /* TimelineItemsSelectionTests.swift */, ED0AD0C652385F69FA90FAF5 /* TimelineMediaPreviewDataSourceTests.swift */, 5C1F000589F2CEE6B03ECFAB /* TimelineMediaPreviewViewModelTests.swift */, F968FDB68F9D4CB9010FED70 /* TimelineStateTests.swift */, @@ -8451,6 +8457,7 @@ ECE4E03D17E309724013F825 /* TimelineInteractionHandlerTests.swift in Sources */, 0D4EB2ABAA5FE8CB10FDBCB8 /* TimelineItemFactoryTests.swift in Sources */, 0668BECCF11E8EE6936CDF16 /* TimelineItemMenuActionProviderTests.swift in Sources */, + 44B698EFE616E1E7FD34C115 /* TimelineItemsSelectionTests.swift in Sources */, C02DE5F62C81FB9E173C3D2F /* TimelineMediaPreviewDataSourceTests.swift in Sources */, F6BF52CB027393EE03CEC523 /* TimelineMediaPreviewViewModelTests.swift in Sources */, 75E55A763FE8BA64FB8513E4 /* TimelineStateTests.swift in Sources */, @@ -9585,6 +9592,8 @@ 6386EA3C898AD1A4BC1DC8A5 /* TimelineMediaPreviewModifier.swift in Sources */, A32384E3D85CA65342D3A908 /* TimelineMediaPreviewRedactConfirmationView.swift in Sources */, 86769B62BAE17601B3AE1B60 /* TimelineMediaPreviewViewModel.swift in Sources */, + 05A7A9E3653E018E383C9890 /* TimelineMessageSelectionActionBar.swift in Sources */, + BDA14E4B772E28A792D7F5C8 /* TimelineMessageSelectionToolbar.swift in Sources */, B818580464CFB5400A3EF6AE /* TimelineModels.swift in Sources */, 4E4EF97B9F9CEFAC726BA72F /* TimelineProviderMock.swift in Sources */, E82E13CC3EB923CCB8F8273C /* TimelineProxy.swift in Sources */, @@ -9594,7 +9603,6 @@ 4DAEE2468669848B6C9F55B4 /* TimelineReadReceiptsView.swift in Sources */, 6EB46C92ECFEAE71959D91D2 /* TimelineReplyView.swift in Sources */, 291A3E0EECD496D0FCC2ADAA /* TimelineScrollButton.swift in Sources */, - FD387E0B8962056D2092DF3F /* TimelineSelectionToolbar.swift in Sources */, 9FBE1FB20171012260A32492 /* TimelineSenderAvatarView.swift in Sources */, C4FE0E11A907C8999F92D5A8 /* TimelineStartRoomTimelineItem.swift in Sources */, 785613C0C092B532198EB3BB /* TimelineStartRoomTimelineView.swift in Sources */, diff --git a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings index 4f4ccf2d9..1b341c595 100644 --- a/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en-US.lproj/Localizable.strings @@ -174,7 +174,6 @@ "action_retry_decryption" = "Retry decryption"; "action_save" = "Save"; "action_search" = "Search"; -"action_select" = "Select"; "action_select_all" = "Select all"; "action_send" = "Send"; "action_send_edited_message" = "Send edited message"; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.strings b/ElementX/Resources/Localizations/en.lproj/Localizable.strings index d5a3a5b8e..228fbb46f 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.strings +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.strings @@ -174,7 +174,6 @@ "action_retry_decryption" = "Retry decryption"; "action_save" = "Save"; "action_search" = "Search"; -"action_select" = "Select"; "action_select_all" = "Select all"; "action_send" = "Send"; "action_send_edited_message" = "Send edited message"; diff --git a/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict b/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict index 949c6fdc9..12147d59e 100644 --- a/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict +++ b/ElementX/Resources/Localizations/en.lproj/Localizable.stringsdict @@ -66,6 +66,22 @@ NSStringLocalizedFormatKey %#@COUNT@ + action_forward_messages + + COUNT + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + Forward %1$d message + other + Forward %1$d messages + + NSStringLocalizedFormatKey + %#@COUNT@ + common_gallery_reply_attachments COUNT @@ -530,6 +546,22 @@ NSStringLocalizedFormatKey %#@COUNT@ + screen_room_messages_forwarded + + COUNT + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + %1$d message forwarded + other + %1$d messages forwarded + + NSStringLocalizedFormatKey + %#@COUNT@ + screen_room_multiple_knock_requests_title COUNT diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index c892490f7..695beb6dd 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -1,5 +1,4 @@ "screen_home_tab_search" = "Search"; -"screen_notification_settings_show_all_room_list_activity" = "Show all activity in the room list (dots or number of unread messages)"; "screen_search_empty_state_message" = "Search for chats and messages"; "screen_search_empty_state_title" = "Start searching..."; "screen_search_no_results_message" = "There are no results for “%1$@.” Try a new search term."; diff --git a/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift index 30d5ba242..61240a2e9 100644 --- a/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/MediaEventsTimelineFlowCoordinator.swift @@ -11,7 +11,7 @@ import Foundation enum MediaEventsTimelineFlowCoordinatorAction { case viewInRoomTimeline(TimelineItemIdentifier) - case displayMessageForwarding(MessageForwardingItem) + case displayMessageForwarding(MessageForwardingPayload) case finished } @@ -97,8 +97,8 @@ class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol { .sink { [weak self] action in guard let self else { return } switch action { - case .displayMessageForwarding(let forwardingItem): - actionsSubject.send(.displayMessageForwarding(forwardingItem)) + case .displayMessageForwarding(let forwardingPayload): + actionsSubject.send(.displayMessageForwarding(forwardingPayload)) case .viewInRoomTimeline(let itemID): navigationStackCoordinator.pop(animated: false) actionsSubject.send(.viewInRoomTimeline(itemID)) diff --git a/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift index 0fdd7388e..9ae6e46a8 100644 --- a/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift @@ -91,8 +91,8 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol { case .presentLiveLocationViewer(let sender, let initialLiveLocationShare): presentMapNavigator(interactionMode: .viewLive(sender: sender, initialLiveLocationShare: initialLiveLocationShare), timelineController: timelineController) - case .displayMessageForwarding(let forwardingItem): - presentMessageForwarding(with: forwardingItem) + case .displayMessageForwarding(let forwardingPayload): + presentMessageForwarding(with: forwardingPayload) case .displayRoomScreenWithFocussedPin(let eventID, let threadRootEventID): actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID, threadRootEventID: threadRootEventID)) } @@ -132,12 +132,12 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol { navigationStackCoordinator.setSheetCoordinator(stackCoordinator) } - private func presentMessageForwarding(with forwardingItem: MessageForwardingItem) { + private func presentMessageForwarding(with forwardingPayload: MessageForwardingPayload) { let roomSummaryProvider = userSession.clientProxy.alternateRoomSummaryProvider let stackCoordinator = NavigationStackCoordinator() - let parameters = MessageForwardingScreenCoordinatorParameters(forwardingItem: forwardingItem, + let parameters = MessageForwardingScreenCoordinatorParameters(forwardingPayload: forwardingPayload, userSession: userSession, roomSummaryProvider: roomSummaryProvider, userIndicatorController: flowParameters.userIndicatorController) diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index c2f8e82c0..2e5411158 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -438,8 +438,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { emojiPickerContinuation: continuation, animated: animated) - case (_, .presentMessageForwarding(let forwardingItem), .messageForwarding): - presentMessageForwarding(with: forwardingItem) + case (_, .presentMessageForwarding(let forwardingPayload), .messageForwarding): + presentMessageForwarding(with: forwardingPayload) case (_, .presentMapNavigator(let mode), .mapNavigator): guard let timelineController = (context.userInfo as? EventUserInfo)?.timelineController else { @@ -723,8 +723,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { userInfo: EventUserInfo(animated: animated, timelineController: timelineController)) case .presentRoomMemberDetails(userID: let userID): stateMachine.tryEvent(.startMembersFlow(entryPoint: .roomMember(userID: userID))) - case .presentMessageForwarding(let forwardingItem): - stateMachine.tryEvent(.presentMessageForwarding(forwardingItem: forwardingItem)) + case .presentMessageForwarding(let forwardingPayload): + stateMachine.tryEvent(.presentMessageForwarding(forwardingPayload: forwardingPayload)) case .presentCallScreen(let isVoiceCall): actionsSubject.send(.presentCallScreen(roomProxy: roomProxy, isVoiceCall: isVoiceCall)) case .presentPinnedEventsTimeline: @@ -835,8 +835,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { userInfo: EventUserInfo(animated: animated, emojiPickerContinuation: continuation)) case .presentRoomMemberDetails(let userID): stateMachine.tryEvent(.startMembersFlow(entryPoint: .roomMember(userID: userID))) - case .presentMessageForwarding(let forwardingItem): - stateMachine.tryEvent(.presentMessageForwarding(forwardingItem: forwardingItem)) + case .presentMessageForwarding(let forwardingPayload): + stateMachine.tryEvent(.presentMessageForwarding(forwardingPayload: forwardingPayload)) case .presentResolveSendFailure(let failure, let sendHandle): stateMachine.tryEvent(.presentResolveSendFailure(failure: failure, sendHandle: sendHandle)) @@ -1262,12 +1262,12 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } } - private func presentMessageForwarding(with forwardingItem: MessageForwardingItem) { + private func presentMessageForwarding(with forwardingPayload: MessageForwardingPayload) { let roomSummaryProvider = userSession.clientProxy.alternateRoomSummaryProvider let stackCoordinator = NavigationStackCoordinator() - let parameters = MessageForwardingScreenCoordinatorParameters(forwardingItem: forwardingItem, + let parameters = MessageForwardingScreenCoordinatorParameters(forwardingPayload: forwardingPayload, userSession: userSession, roomSummaryProvider: roomSummaryProvider, userIndicatorController: flowParameters.userIndicatorController) @@ -1281,6 +1281,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { navigationStackCoordinator.setSheetCoordinator(nil) case .sent(let roomIDs): navigationStackCoordinator.setSheetCoordinator(nil) + clearMessageSelection() processPostMessageForwardingTo(rooms: roomIDs) } } @@ -1293,6 +1294,12 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } } + /// Ends the message selection now that the forwarding has completed. Only one timeline can be selecting at a time. + private func clearMessageSelection() { + roomScreenCoordinator?.clearMessageSelection() + childThreadScreenCoordinators.forEach { $0.clearMessageSelection() } + } + private func presentNotificationSettingsScreen() { let parameters = RoomNotificationSettingsScreenCoordinatorParameters(notificationSettingsProxy: userSession.clientProxy.notificationSettings, roomProxy: roomProxy, @@ -1628,8 +1635,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { userInfo: EventUserInfo(animated: false)) // No animation so the timeline visible when the preview animates away. case .finished: stateMachine.tryEvent(.dismissMediaEventsTimeline) - case .displayMessageForwarding(let forwardingItem): - stateMachine.tryEvent(.presentMessageForwarding(forwardingItem: forwardingItem)) + case .displayMessageForwarding(let forwardingPayload): + stateMachine.tryEvent(.presentMessageForwarding(forwardingPayload: forwardingPayload)) } } .store(in: &cancellables) diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinatorStateMachine.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinatorStateMachine.swift index da33ca1bb..5592e4546 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinatorStateMachine.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinatorStateMachine.swift @@ -58,7 +58,7 @@ extension RoomFlowCoordinator { case mediaUploadPreview(mediaURLs: [URL], caption: NSAttributedString?, previousState: State) case emojiPicker(selectedEmojis: Set, previousState: State) case mapNavigator(previousState: State) - case messageForwarding(forwardingItem: MessageForwardingItem, previousState: State) + case messageForwarding(forwardingPayload: MessageForwardingPayload, previousState: State) case reportContent(itemID: TimelineItemIdentifier, senderID: String, previousState: State) case pollForm(previousState: State) case pollsHistory @@ -140,7 +140,7 @@ extension RoomFlowCoordinator { case presentMapNavigator(interactionMode: LocationSharingInteractionMode) case dismissMapNavigator - case presentMessageForwarding(forwardingItem: MessageForwardingItem) + case presentMessageForwarding(forwardingPayload: MessageForwardingPayload) case dismissMessageForwarding case presentPollForm(mode: PollFormMode) @@ -208,10 +208,10 @@ extension RoomFlowCoordinator { case (.room, .presentEmojiPicker(let selectedEmojis)): return .emojiPicker(selectedEmojis: selectedEmojis, previousState: fromState) - case (.room, .presentMessageForwarding(let forwardingItem)): - return .messageForwarding(forwardingItem: forwardingItem, previousState: fromState) - case (.mediaEventsTimeline, .presentMessageForwarding(forwardingItem: let forwardingItem)): - return .messageForwarding(forwardingItem: forwardingItem, previousState: fromState) + case (.room, .presentMessageForwarding(let forwardingPayload)): + return .messageForwarding(forwardingPayload: forwardingPayload, previousState: fromState) + case (.mediaEventsTimeline, .presentMessageForwarding(forwardingPayload: let forwardingPayload)): + return .messageForwarding(forwardingPayload: forwardingPayload, previousState: fromState) case (.room, .presentMapNavigator): return .mapNavigator(previousState: fromState) @@ -257,8 +257,8 @@ extension RoomFlowCoordinator { case (.thread, .presentEmojiPicker(let selectedEmojis)): return .emojiPicker(selectedEmojis: selectedEmojis, previousState: fromState) - case (.thread, .presentMessageForwarding(let forwardingItem)): - return .messageForwarding(forwardingItem: forwardingItem, previousState: fromState) + case (.thread, .presentMessageForwarding(let forwardingPayload)): + return .messageForwarding(forwardingPayload: forwardingPayload, previousState: fromState) case (.thread, .presentMapNavigator): return .mapNavigator(previousState: fromState) diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index 7f712878d..c4d09c595 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -12,8 +12,6 @@ import Foundation internal nonisolated enum UntranslatedL10n { /// Search internal static var screenHomeTabSearch: String { return UntranslatedL10n.tr("Untranslated", "screen_home_tab_search") } - /// Show all activity in the room list (dots or number of unread messages) - internal static var screenNotificationSettingsShowAllRoomListActivity: String { return UntranslatedL10n.tr("Untranslated", "screen_notification_settings_show_all_room_list_activity") } /// Search for chats and messages internal static var screenSearchEmptyStateMessage: String { return UntranslatedL10n.tr("Untranslated", "screen_search_empty_state_message") } /// Start searching... diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 757f4365f..8b41b1b6c 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -292,6 +292,10 @@ internal nonisolated enum L10n { internal static var actionForgotPassword: String { return L10n.tr("Localizable", "action_forgot_password") } /// Forward internal static var actionForward: String { return L10n.tr("Localizable", "action_forward") } + /// Plural format key: "%#@COUNT@" + internal static func actionForwardMessages(_ p1: Int) -> String { + return L10n.tr("Localizable", "action_forward_messages", p1) + } /// Go back internal static var actionGoBack: String { return L10n.tr("Localizable", "action_go_back") } /// Go to roles & permissions @@ -398,8 +402,6 @@ internal nonisolated enum L10n { internal static var actionSave: String { return L10n.tr("Localizable", "action_save") } /// Search internal static var actionSearch: String { return L10n.tr("Localizable", "action_search") } - /// Select - internal static var actionSelect: String { return L10n.tr("Localizable", "action_select") } /// Select all internal static var actionSelectAll: String { return L10n.tr("Localizable", "action_select_all") } /// Send @@ -3175,6 +3177,10 @@ internal nonisolated enum L10n { /// Everyone internal static var screenRoomMentionsAtRoomTitle: String { return L10n.tr("Localizable", "screen_room_mentions_at_room_title") } /// Plural format key: "%#@COUNT@" + internal static func screenRoomMessagesForwarded(_ p1: Int) -> String { + return L10n.tr("Localizable", "screen_room_messages_forwarded", p1) + } + /// Plural format key: "%#@COUNT@" internal static func screenRoomMultipleKnockRequestsTitle(_ p1: Int) -> String { return L10n.tr("Localizable", "screen_room_multiple_knock_requests_title", p1) } diff --git a/ElementX/Sources/Other/Extensions/Array.swift b/ElementX/Sources/Other/Extensions/Array.swift index a7fee5450..6b767c950 100644 --- a/ElementX/Sources/Other/Extensions/Array.swift +++ b/ElementX/Sources/Other/Extensions/Array.swift @@ -78,4 +78,20 @@ nonisolated extension Array where Element == RoomTimelineItemProtocol { return nil } + + /// The items that can be forwarded, in timeline order. + var forwardableItems: [EventBasedTimelineItemProtocol] { + compactMap { item in + guard let item = item as? EventBasedTimelineItemProtocol, item.isForwardable else { return nil } + return item + } + } + + /// The selected items in timeline order, whatever the order they were selected in. + func selectedItems(_ selectedEventIDs: Set) -> [EventBasedTimelineItemProtocol] { + forwardableItems.filter { item in + guard let eventID = item.id.eventID else { return false } + return selectedEventIDs.contains(eventID) + } + } } diff --git a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift index 1c190ab5b..c174d526d 100644 --- a/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift +++ b/ElementX/Sources/Other/TestablePreview/TestablePreviewsDictionary.swift @@ -195,6 +195,7 @@ enum TestablePreviewsDictionary { "TimelineItemStyler_Previews" : TimelineItemStyler_Previews.self, "TimelineMediaContentScanningFailureView_Previews" : TimelineMediaContentScanningFailureView_Previews.self, "TimelineMediaPreviewRedactConfirmationView_Previews" : TimelineMediaPreviewRedactConfirmationView_Previews.self, + "TimelineMessageSelectionActionBar_Previews" : TimelineMessageSelectionActionBar_Previews.self, "TimelineReactionView_Previews" : TimelineReactionView_Previews.self, "TimelineReadReceiptsView_Previews" : TimelineReadReceiptsView_Previews.self, "TimelineReplyView_Previews" : TimelineReplyView_Previews.self, diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift index 402f57a8b..3a4596dfb 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewModels.swift @@ -11,7 +11,7 @@ import SwiftUI enum TimelineMediaPreviewViewModelAction: Equatable { case viewInRoomTimeline(TimelineItemIdentifier) - case displayMessageForwarding(MessageForwardingItem) + case displayMessageForwarding(MessageForwardingPayload) case dismiss } diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift index 8b43fd3c7..3bab1823b 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift @@ -136,9 +136,9 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { } private func forwardItem(itemID: TimelineItemIdentifier) async { - guard let forwardingItem = await timelineViewModel.makeForwardingItem(for: itemID) else { return } + guard let forwardingPayload = await timelineViewModel.makeForwardingPayload(for: [itemID]) else { return } state.previewControllerDriver.send(.dismissDetailsSheet) - actionsSubject.send(.displayMessageForwarding(forwardingItem)) + actionsSubject.send(.displayMessageForwarding(forwardingPayload)) } private func updateCurrentItem(_ previewItem: TimelineMediaPreviewItem) async { @@ -231,7 +231,6 @@ class TimelineMediaPreviewViewModel: TimelineMediaPreviewViewModelType { pinnedEventIDs: timelineContext.viewState.pinnedEventIDs, isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled, areThreadsEnabled: timelineContext.viewState.areThreadsEnabled, - isMultiSelectEnabled: timelineContext.viewState.canSelectMessages, timelineKind: timelineContext.viewState.timelineKind, emojiProvider: timelineContext.viewState.emojiProvider) .makeActions() diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenCoordinator.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenCoordinator.swift index f51d4402c..9f9f6d32f 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenCoordinator.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenCoordinator.swift @@ -26,7 +26,7 @@ struct MediaEventsTimelineScreenCoordinatorParameters { enum MediaEventsTimelineScreenCoordinatorAction { case viewInRoomTimeline(TimelineItemIdentifier) - case displayMessageForwarding(MessageForwardingItem) + case displayMessageForwarding(MessageForwardingPayload) } final class MediaEventsTimelineScreenCoordinator: CoordinatorProtocol { @@ -74,8 +74,8 @@ final class MediaEventsTimelineScreenCoordinator: CoordinatorProtocol { .sink { [weak self] action in guard let self else { return } switch action { - case .displayMessageForwarding(let forwardingItem): - actionsSubject.send(.displayMessageForwarding(forwardingItem)) + case .displayMessageForwarding(let forwardingPayload): + actionsSubject.send(.displayMessageForwarding(forwardingPayload)) case .viewInRoomTimeline(let itemID): actionsSubject.send(.viewInRoomTimeline(itemID)) } diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenModels.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenModels.swift index 6528c3332..f6668f773 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenModels.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenModels.swift @@ -9,7 +9,7 @@ import SwiftUI enum MediaEventsTimelineScreenViewModelAction { - case displayMessageForwarding(MessageForwardingItem) + case displayMessageForwarding(MessageForwardingPayload) case viewInRoomTimeline(TimelineItemIdentifier) } diff --git a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift index 3abbef626..bfcc473c5 100644 --- a/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift +++ b/ElementX/Sources/Screens/MediaEventsTimelineScreen/MediaEventsTimelineScreenViewModel.swift @@ -169,8 +169,8 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType sheetModel.actions.sink { [weak self] action in guard let self else { return } switch action { - case .displayMessageForwarding(let forwardingItem): - displayMessageForwarding(forwardingItem: forwardingItem) + case .displayMessageForwarding(let forwardingPayload): + displayMessageForwarding(forwardingPayload: forwardingPayload) case .viewInRoomTimeline(let itemID): actionsSubject.send(.viewInRoomTimeline(itemID)) case .dismiss: @@ -246,8 +246,8 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType viewModel.actions.sink { [weak self] action in guard let self else { return } switch action { - case .displayMessageForwarding(let forwardingItem): - displayMessageForwarding(forwardingItem: forwardingItem) + case .displayMessageForwarding(let forwardingPayload): + displayMessageForwarding(forwardingPayload: forwardingPayload) case .viewInRoomTimeline(let itemID): state.bindings.mediaPreviewViewModel = nil actionsSubject.send(.viewInRoomTimeline(itemID)) @@ -268,12 +268,12 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType } } - private func displayMessageForwarding(forwardingItem: MessageForwardingItem) { + private func displayMessageForwarding(forwardingPayload: MessageForwardingPayload) { state.bindings.mediaPreviewViewModel = nil state.bindings.mediaPreviewSheetViewModel = nil // We need a small delay because we need to wait for the presented sheet to be fully dismissed. DispatchQueue.main.asyncAfter(deadline: .now() + TimelineMediaPreviewViewModel.displayMessageForwardingDelay) { - self.actionsSubject.send(.displayMessageForwarding(forwardingItem)) + self.actionsSubject.send(.displayMessageForwarding(forwardingPayload)) } } } diff --git a/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenCoordinator.swift b/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenCoordinator.swift index 4427c7075..a4471ab58 100644 --- a/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenCoordinator.swift +++ b/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenCoordinator.swift @@ -10,7 +10,7 @@ import Combine import SwiftUI struct MessageForwardingScreenCoordinatorParameters { - let forwardingItem: MessageForwardingItem + let forwardingPayload: MessageForwardingPayload let userSession: UserSessionProtocol let roomSummaryProvider: RoomSummaryProviderProtocol let userIndicatorController: UserIndicatorControllerProtocol @@ -31,7 +31,7 @@ final class MessageForwardingScreenCoordinator: CoordinatorProtocol { } init(parameters: MessageForwardingScreenCoordinatorParameters) { - viewModel = MessageForwardingScreenViewModel(forwardingItem: parameters.forwardingItem, + viewModel = MessageForwardingScreenViewModel(forwardingPayload: parameters.forwardingPayload, userSession: parameters.userSession, roomSummaryProvider: parameters.roomSummaryProvider, userIndicatorController: parameters.userIndicatorController) diff --git a/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenModels.swift b/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenModels.swift index b27590ac7..664bf68e7 100644 --- a/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenModels.swift +++ b/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenModels.swift @@ -17,7 +17,7 @@ enum MessageForwardingScreenViewModelAction { struct MessageForwardingScreenViewState: BindableState { var rooms: [MessageForwardingRoom] = [] var selectedRoomIDs: Set = [] - let maxRoomSelectionCount = 10 + let maxRoomSelectionCount = 5 var bindings = MessageForwardingScreenViewStateBindings() var isAtRoomSelectionLimit: Bool { @@ -44,20 +44,20 @@ struct MessageForwardingRoom: Identifiable, Equatable { let avatar: RoomAvatar } -struct MessageForwardingItem: Hashable { - /// The source item's timeline ID. Only necessary for a rough Hashable conformance. - let id: TimelineItemIdentifier - /// The source item's room ID. +struct MessageForwardingPayload: Hashable { + /// The source items' timeline IDs. Only necessary for a rough Hashable conformance. + let ids: [TimelineItemIdentifier] + /// The source items' room ID. let roomID: String - /// The item's content to be forwarded. - let content: RoomMessageEventContentWithoutRelation + /// The contents to be forwarded, in timeline order. + let contents: [RoomMessageEventContentWithoutRelation] - static func == (lhs: MessageForwardingItem, rhs: MessageForwardingItem) -> Bool { - lhs.id == rhs.id && lhs.roomID == rhs.roomID + static func == (lhs: MessageForwardingPayload, rhs: MessageForwardingPayload) -> Bool { + lhs.ids == rhs.ids && lhs.roomID == rhs.roomID } func hash(into hasher: inout Hasher) { - hasher.combine(id) + hasher.combine(ids) hasher.combine(roomID) } } diff --git a/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenViewModel.swift b/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenViewModel.swift index b2e98119f..63c87aadc 100644 --- a/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenViewModel.swift +++ b/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenViewModel.swift @@ -12,7 +12,7 @@ import SwiftUI typealias MessageForwardingScreenViewModelType = StateStoreViewModel class MessageForwardingScreenViewModel: MessageForwardingScreenViewModelType, MessageForwardingScreenViewModelProtocol { - private let forwardingItem: MessageForwardingItem + private let forwardingPayload: MessageForwardingPayload private let clientProxy: ClientProxyProtocol private let roomSummaryProvider: RoomSummaryProviderProtocol private let userIndicatorController: UserIndicatorControllerProtocol @@ -23,11 +23,11 @@ class MessageForwardingScreenViewModel: MessageForwardingScreenViewModelType, Me actionsSubject.eraseToAnyPublisher() } - init(forwardingItem: MessageForwardingItem, + init(forwardingPayload: MessageForwardingPayload, userSession: UserSessionProtocol, roomSummaryProvider: RoomSummaryProviderProtocol, userIndicatorController: UserIndicatorControllerProtocol) { - self.forwardingItem = forwardingItem + self.forwardingPayload = forwardingPayload clientProxy = userSession.clientProxy self.roomSummaryProvider = roomSummaryProvider self.userIndicatorController = userIndicatorController @@ -86,7 +86,7 @@ class MessageForwardingScreenViewModel: MessageForwardingScreenViewModelType, Me var rooms = [MessageForwardingRoom]() for summary in roomSummaryProvider.roomListPublisher.value { - if summary.id == forwardingItem.roomID { + if summary.id == forwardingPayload.roomID { continue } @@ -123,23 +123,33 @@ class MessageForwardingScreenViewModel: MessageForwardingScreenViewModelType, Me } var succeededRoomIdentifiers = [String]() + var hasFailures = false for roomID in state.selectedRoomIDs { guard case let .joined(targetRoomProxy) = await clientProxy.roomForIdentifier(roomID) else { MXLog.error("Failed retrieving room to forward to with id: \(roomID)") - userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown)) + hasFailures = true continue } - if case .failure(let error) = await targetRoomProxy.timeline.sendMessageEventContent(forwardingItem.content) { - MXLog.error("Failed forwarding message with error: \(error)") - userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown)) - continue + // The contents are already in timeline order and the send queue preserves it. + for content in forwardingPayload.contents { + if case .failure(let error) = await targetRoomProxy.timeline.sendMessageEventContent(content) { + MXLog.error("Failed forwarding message with error: \(error)") + hasFailures = true + } } succeededRoomIdentifiers.append(roomID) } + if hasFailures { + userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown)) + } else if succeededRoomIdentifiers.count > 1 { + // The flow opens the room when there is a single one, otherwise the user stays here and needs to know it worked. + userIndicatorController.submitIndicator(UserIndicator(title: L10n.screenRoomMessagesForwarded(forwardingPayload.contents.count))) + } + if !succeededRoomIdentifiers.isEmpty { actionsSubject.send(.sent(roomIDs: succeededRoomIdentifiers)) } diff --git a/ElementX/Sources/Screens/MessageForwardingScreen/View/MessageForwardingScreen.swift b/ElementX/Sources/Screens/MessageForwardingScreen/View/MessageForwardingScreen.swift index fbefd8e5d..d1f47baca 100644 --- a/ElementX/Sources/Screens/MessageForwardingScreen/View/MessageForwardingScreen.swift +++ b/ElementX/Sources/Screens/MessageForwardingScreen/View/MessageForwardingScreen.swift @@ -99,9 +99,9 @@ private struct MessageForwardingListRow: View { struct MessageForwardingScreen_Previews: PreviewProvider, TestablePreview { static var previews: some View { let summaryProvider = RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))) - let viewModel = MessageForwardingScreenViewModel(forwardingItem: .init(id: .randomEvent, - roomID: "", - content: RoomMessageEventContentWithoutRelationSDKMock()), + let viewModel = MessageForwardingScreenViewModel(forwardingPayload: .init(ids: [.randomEvent], + roomID: "", + contents: [RoomMessageEventContentWithoutRelationSDKMock()]), userSession: UserSessionMock(.init()), roomSummaryProvider: summaryProvider, userIndicatorController: UserIndicatorControllerMock()) diff --git a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenCoordinator.swift b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenCoordinator.swift index e24cec72e..97e9eddeb 100644 --- a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenCoordinator.swift +++ b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenCoordinator.swift @@ -28,7 +28,7 @@ enum PinnedEventsTimelineScreenCoordinatorAction { case displayUser(userID: String) case presentLocationViewer(StaticLocationData) case presentLiveLocationViewer(sender: TimelineItemSender, initialLiveLocationShare: LiveLocationShare) - case displayMessageForwarding(forwardingItem: MessageForwardingItem) + case displayMessageForwarding(forwardingPayload: MessageForwardingPayload) case displayRoomScreenWithFocussedPin(eventID: String, threadRootEventID: String?) } @@ -67,8 +67,8 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol { guard let self else { return } switch action { - case .displayMessageForwarding(let forwardingItem): - actionsSubject.send(.displayMessageForwarding(forwardingItem: forwardingItem)) + case .displayMessageForwarding(let forwardingPayload): + actionsSubject.send(.displayMessageForwarding(forwardingPayload: forwardingPayload)) case .viewInRoomTimeline(let eventID, let threadRootEventID): actionsSubject.send(.displayRoomScreenWithFocussedPin(eventID: eventID, threadRootEventID: threadRootEventID)) case .dismiss: @@ -84,8 +84,8 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol { switch action { case .displaySenderDetails(let userID): actionsSubject.send(.displayUser(userID: userID)) - case .displayMessageForwarding(let forwardingItem): - actionsSubject.send(.displayMessageForwarding(forwardingItem: forwardingItem)) + case .displayMessageForwarding(let forwardingPayload): + actionsSubject.send(.displayMessageForwarding(forwardingPayload: forwardingPayload)) case .displayMediaPreview(let mediaPreviewViewModel): viewModel.displayMediaPreview(mediaPreviewViewModel) case .displayLocation(let location): diff --git a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenModels.swift b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenModels.swift index 73cbf3245..45a5eb92a 100644 --- a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenModels.swift +++ b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenModels.swift @@ -10,7 +10,7 @@ import Foundation enum PinnedEventsTimelineScreenViewModelAction { case viewInRoomTimeline(eventID: String, threadRootEventID: String?) - case displayMessageForwarding(MessageForwardingItem) + case displayMessageForwarding(MessageForwardingPayload) case dismiss } diff --git a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenViewModel.swift b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenViewModel.swift index 0ef821136..894601880 100644 --- a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenViewModel.swift +++ b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/PinnedEventsTimelineScreenViewModel.swift @@ -54,11 +54,11 @@ class PinnedEventsTimelineScreenViewModel: PinnedEventsTimelineScreenViewModelTy mediaPreviewViewModel.actions.sink { [weak self] action in guard let self else { return } switch action { - case .displayMessageForwarding(let forwardingItem): + case .displayMessageForwarding(let forwardingPayload): state.bindings.mediaPreviewViewModel = nil // We need a small delay because we need to wait for the media preview to be fully dismissed. DispatchQueue.main.asyncAfter(deadline: .now() + TimelineMediaPreviewViewModel.displayMessageForwardingDelay) { - self.actionsSubject.send(.displayMessageForwarding(forwardingItem)) + self.actionsSubject.send(.displayMessageForwarding(forwardingPayload)) } case .viewInRoomTimeline(let itemID): guard let eventID = itemID.eventID else { diff --git a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift index 3d0bba01e..107a39f12 100644 --- a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift +++ b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift @@ -13,6 +13,10 @@ struct PinnedEventsTimelineScreen: View { @ObservedObject var context: PinnedEventsTimelineScreenViewModel.Context @ObservedObject var timelineContext: TimelineViewModel.Context + private var isMessageSelectionActive: Bool { + timelineContext.viewState.messageSelection.isActive + } + private var title: String { let pinnedEventIDs = timelineContext.viewState.pinnedEventIDs guard !pinnedEventIDs.isEmpty else { @@ -26,6 +30,11 @@ struct PinnedEventsTimelineScreen: View { .navigationTitle(title) .navigationBarTitleDisplayMode(.inline) .toolbar { toolbar } + .safeAreaInset(edge: .bottom, spacing: 0) { + if isMessageSelectionActive { + TimelineMessageSelectionActionBar(context: timelineContext) + } + } .background(.compound.bgCanvasDefault) .interactiveDismissDisabled() .timelineMediaPreview(viewModel: $context.mediaPreviewViewModel) @@ -55,9 +64,15 @@ struct PinnedEventsTimelineScreen: View { @ToolbarContentBuilder private var toolbar: some ToolbarContent { - ToolbarItem(placement: .confirmationAction) { - Button(L10n.actionClose) { - context.send(viewAction: .close) + if isMessageSelectionActive { + TimelineMessageSelectionToolbar { + timelineContext.send(viewAction: .clearMessageSelection) + } + } else { + ToolbarItem(placement: .confirmationAction) { + Button(L10n.actionClose) { + context.send(viewAction: .close) + } } } } @@ -71,28 +86,45 @@ struct PinnedEventsTimelineScreen_Previews: PreviewProvider, TestablePreview { appSettings: .volatile(), analyticsService: AnalyticsServiceMock(.init())) - static let emptyTimelineViewModel: TimelineViewModel = { - let timelineController = TimelineControllerMock(.init(timelineKind: .pinned, timelineItems: [])) - - let appSettings = AppSettings.volatile() - - return TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")), - timelineController: timelineController, - userSession: UserSessionMock(.init()), - mediaPlayerProvider: MediaPlayerProviderMock(), - userIndicatorController: UserIndicatorControllerMock(), - appMediator: AppMediatorMock(.init()), - appSettings: appSettings, - analyticsService: AnalyticsServiceMock(.init()), - emojiProvider: EmojiProvider(appSettings: appSettings), - linkMetadataProvider: LinkMetadataProvider(), - timelineControllerFactory: TimelineControllerFactoryMock(.init())) - }() + static let emptyTimelineViewModel = makeTimelineViewModel(timelineItems: []) + static let selectingTimelineViewModel = makeTimelineViewModel(timelineItems: TimelineFixtures.default, isSelecting: true) static var previews: some View { ElementNavigationStack { PinnedEventsTimelineScreen(context: viewModel.context, timelineContext: emptyTimelineViewModel.context) } .previewDisplayName("Empty") + + ElementNavigationStack { + PinnedEventsTimelineScreen(context: viewModel.context, timelineContext: selectingTimelineViewModel.context) + } + .previewDisplayName("Selecting") + .snapshotPreferences(expect: selectingTimelineViewModel.context.$viewState.map(\.messageSelection.isActive)) + } + + static func makeTimelineViewModel(timelineItems: [RoomTimelineItemProtocol], isSelecting: Bool = false) -> TimelineViewModel { + let eventIDs = timelineItems.compactMap { ($0 as? EventBasedTimelineItemProtocol)?.id.eventID } + let timelineController = TimelineControllerMock(.init(timelineKind: .pinned, timelineItems: timelineItems)) + + let appSettings = AppSettings.volatile() + appSettings.messageMultiSelectEnabled = isSelecting + + let timelineViewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room", pinnedEventIDs: Set(eventIDs))), + timelineController: timelineController, + userSession: UserSessionMock(.init()), + mediaPlayerProvider: MediaPlayerProviderMock(), + userIndicatorController: UserIndicatorControllerMock(), + appMediator: AppMediatorMock(.init()), + appSettings: appSettings, + analyticsService: AnalyticsServiceMock(.init()), + emojiProvider: EmojiProvider(appSettings: appSettings), + linkMetadataProvider: LinkMetadataProvider(), + timelineControllerFactory: TimelineControllerFactoryMock(.init())) + + if isSelecting { + timelineViewModel.state.messageSelection.selectedEventIDs = Set(eventIDs.prefix(2)) + } + + return timelineViewModel } } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index 2ad2cee5c..8ce0ad64d 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -43,7 +43,7 @@ enum RoomScreenCoordinatorAction { case presentLiveLocationViewer(sender: TimelineItemSender?, initialLiveLocationShare: LiveLocationShare?) case presentEmojiPicker(selectedEmojis: Set, continuation: EmojiPickerScreenContinuation) case presentRoomMemberDetails(userID: String) - case presentMessageForwarding(forwardingItem: MessageForwardingItem) + case presentMessageForwarding(forwardingPayload: MessageForwardingPayload) case presentCallScreen(isVoiceCall: Bool) case presentPinnedEventsTimeline case presentResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy) @@ -146,8 +146,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol { caption: composerViewModel.context.plainComposerText)) case .displaySenderDetails(userID: let userID): actionsSubject.send(.presentRoomMemberDetails(userID: userID)) - case .displayMessageForwarding(let forwardingItem): - actionsSubject.send(.presentMessageForwarding(forwardingItem: forwardingItem)) + case .displayMessageForwarding(let forwardingPayload): + actionsSubject.send(.presentMessageForwarding(forwardingPayload: forwardingPayload)) case .displayLocation(let location): actionsSubject.send(.presentLocationViewer(location)) case .displayLiveLocation(let sender, let initialLiveLocationShare): @@ -200,8 +200,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol { actionsSubject.send(.presentKnockRequestsList) case .displayRoom(let roomID, let via): actionsSubject.send(.presentRoom(roomID: roomID, via: via)) - case .displayMessageForwarding(let forwardingItem): - actionsSubject.send(.presentMessageForwarding(forwardingItem: forwardingItem)) + case .displayMessageForwarding(let forwardingPayload): + actionsSubject.send(.presentMessageForwarding(forwardingPayload: forwardingPayload)) case .displayThreadList: actionsSubject.send(.presentThreadList) case .displayThread(let threadRootEventID, let focussedEventID): @@ -238,6 +238,10 @@ final class RoomScreenCoordinator: CoordinatorProtocol { composerViewModel.process(timelineAction: .setFocus) } + func clearMessageSelection() { + timelineViewModel.clearMessageSelection() + } + func stop() { composerViewModel.stop() roomViewModel.stop() diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index 4cb401d63..306ef7b81 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -19,7 +19,7 @@ enum RoomScreenViewModelAction: Equatable { case removeComposerFocus case displayKnockRequests case displayRoom(roomID: String, via: [String]) - case displayMessageForwarding(MessageForwardingItem) + case displayMessageForwarding(MessageForwardingPayload) case stopLiveLocationSharing case displayLiveLocation } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 7462844f3..21db6f7ac 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -149,11 +149,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol switch action { case .dismiss: state.bindings.mediaPreviewViewModel = nil - case .displayMessageForwarding(let forwardingItem): + case .displayMessageForwarding(let forwardingPayload): state.bindings.mediaPreviewViewModel = nil // We need a small delay because we need to wait for the media preview to be fully dismissed. DispatchQueue.main.asyncAfter(deadline: .now() + TimelineMediaPreviewViewModel.displayMessageForwardingDelay) { - self.actionsSubject.send(.displayMessageForwarding(forwardingItem)) + self.actionsSubject.send(.displayMessageForwarding(forwardingPayload)) } case .viewInRoomTimeline: fatalError("\(action) should not be visible on a room preview.") diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 395bd2d47..de38691bf 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -110,10 +110,14 @@ struct RoomScreen: View { .environment(\.timelineContext, timelineContext) // Make sure the reply header honours the hideTimelineMedia setting too. .environment(\.shouldAutomaticallyLoadImages, !timelineContext.viewState.hideTimelineMedia) - .collapsedInPlace(isSelectionActive) + .collapsedInPlace(isMessageSelectionActive) + + if isMessageSelectionActive { + TimelineMessageSelectionActionBar(context: timelineContext) + } } } - .navigationBarBackButtonHidden(isSelectionActive) + .navigationBarBackButtonHidden(isMessageSelectionActive) .toolbarRole(RoomHeaderView.toolbarRole) .navigationTitle(L10n.screenRoomTitle) // Hidden but used for back button text. .navigationBarTitleDisplayMode(.inline) @@ -287,23 +291,12 @@ struct RoomScreen: View { } } - private var isSelectionActive: Bool { - timelineContext.viewState.selection.isActive + private var isMessageSelectionActive: Bool { + timelineContext.viewState.messageSelection.isActive } @ToolbarContentBuilder private var toolbar: some ToolbarContent { - if isSelectionActive { - TimelineSelectionToolbar(count: timelineContext.viewState.selection.count) { - timelineContext.send(viewAction: .clearSelection) - } - } else { - roomToolbar - } - } - - @ToolbarContentBuilder - private var roomToolbar: some ToolbarContent { // .principal + .primaryAction works better than .navigation leading + trailing // as the latter disables interaction in the action button for rooms with long names ToolbarItem(placement: .principal) { @@ -314,8 +307,20 @@ struct RoomScreen: View { mediaProvider: context.mediaProvider) { context.send(viewAction: .displayRoomDetails) } + .disabled(isMessageSelectionActive) // The room details would cover the selection. } + if isMessageSelectionActive { + TimelineMessageSelectionToolbar { + timelineContext.send(viewAction: .clearMessageSelection) + } + } else { + roomActions + } + } + + @ToolbarContentBuilder + private var roomActions: some ToolbarContent { if !ProcessInfo.processInfo.isiOSAppOnMac || context.viewState.isNativeCallingEnabled { if context.viewState.shouldShowCallButton { RoomCallControlsToolbar(viewState: context.viewState) { isVoiceCall in @@ -379,7 +384,7 @@ struct RoomScreen_Previews: PreviewProvider, TestablePreview { composerToolbar: ComposerToolbar(context: composerViewModel.context)) } .previewDisplayName("Selecting") - .snapshotPreferences(expect: selectingViewModels.timeline.context.$viewState.map(\.selection.isActive)) + .snapshotPreferences(expect: selectingViewModels.timeline.context.$viewState.map(\.messageSelection.isActive)) } static func makeViewModels(canSendMessage: Bool = true, hasSuccessor: Bool = false, isSelecting: Bool = false) -> ViewModels { @@ -408,7 +413,7 @@ struct RoomScreen_Previews: PreviewProvider, TestablePreview { if isSelecting { let eventIDs = timelineController.timelineItems.compactMap { ($0 as? EventBasedTimelineItemProtocol)?.id.eventID } - timelineViewModel.state.selection.selectedEventIDs = Set(eventIDs.prefix(2)) + timelineViewModel.state.messageSelection.selectedEventIDs = Set(eventIDs.prefix(2)) } return .init(room: roomViewModel, timeline: timelineViewModel) diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift index 6779f0e9e..653c1c4c1 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift @@ -88,7 +88,7 @@ struct DeveloperOptionsScreen: View { Toggle(isOn: $context.messageMultiSelectEnabled) { Text("Multi-select messages") - Text("Adds a Select action to the message menu to pick several messages at once.") + Text("Forward starts a selection so several messages can be forwarded at once.") } } diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift index 4423be4d7..ed72f791a 100644 --- a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenCoordinator.swift @@ -38,7 +38,7 @@ enum ThreadTimelineScreenCoordinatorAction { case presentPollForm(mode: PollFormMode) case presentEmojiPicker(selectedEmojis: Set, continuation: EmojiPickerScreenContinuation) case presentRoomMemberDetails(userID: String) - case presentMessageForwarding(forwardingItem: MessageForwardingItem) + case presentMessageForwarding(forwardingPayload: MessageForwardingPayload) case presentResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy) } @@ -94,8 +94,8 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol { .sink { [weak self] action in guard let self else { return } switch action { - case .displayMessageForwarding(let forwardingItem): - actionsSubject.send(.presentMessageForwarding(forwardingItem: forwardingItem)) + case .displayMessageForwarding(let forwardingPayload): + actionsSubject.send(.presentMessageForwarding(forwardingPayload: forwardingPayload)) } } .store(in: &cancellables) @@ -135,8 +135,8 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol { caption: composerViewModel.context.plainComposerText)) case .displaySenderDetails(userID: let userID): actionsSubject.send(.presentRoomMemberDetails(userID: userID)) - case .displayMessageForwarding(let forwardingItem): - actionsSubject.send(.presentMessageForwarding(forwardingItem: forwardingItem)) + case .displayMessageForwarding(let forwardingPayload): + actionsSubject.send(.presentMessageForwarding(forwardingPayload: forwardingPayload)) case .displayResolveSendFailure(let failure, let sendHandle): actionsSubject.send(.presentResolveSendFailure(failure: failure, sendHandle: sendHandle)) @@ -179,4 +179,8 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol { func focusOnEvent(eventID: String) { Task { await timelineViewModel.focusOnEvent(eventID: eventID) } } + + func clearMessageSelection() { + timelineViewModel.clearMessageSelection() + } } diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenModels.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenModels.swift index de2c8ca47..5f13aff15 100644 --- a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenModels.swift +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenModels.swift @@ -9,7 +9,7 @@ import Foundation enum ThreadTimelineScreenViewModelAction { - case displayMessageForwarding(MessageForwardingItem) + case displayMessageForwarding(MessageForwardingPayload) } struct ThreadTimelineScreenViewState: BindableState { diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenViewModel.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenViewModel.swift index 6ef2a17fe..08701d621 100644 --- a/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenViewModel.swift +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/ThreadTimelineScreenViewModel.swift @@ -66,11 +66,11 @@ class ThreadTimelineScreenViewModel: ThreadTimelineScreenViewModelType, ThreadTi switch action { case .viewInRoomTimeline: fatalError("\(action) should not be visible on a thread preview.") - case .displayMessageForwarding(let forwardingItem): + case .displayMessageForwarding(let forwardingPayload): state.bindings.mediaPreviewViewModel = nil // We need a small delay because we need to wait for the media preview to be fully dismissed. DispatchQueue.main.asyncAfter(deadline: .now() + TimelineMediaPreviewViewModel.displayMessageForwardingDelay) { - self.actionsSubject.send(.displayMessageForwarding(forwardingItem)) + self.actionsSubject.send(.displayMessageForwarding(forwardingPayload)) } case .dismiss: state.bindings.mediaPreviewViewModel = nil diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift index 45aa167ce..99012d421 100644 --- a/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift @@ -30,7 +30,7 @@ struct ThreadTimelineScreen: View { .navigationBarTitleDisplayMode(.inline) .toolbar { toolbar } .toolbarBackground(.visible, for: .navigationBar) // Fix the toolbar's background. - .navigationBarBackButtonHidden(isSelectionActive) + .navigationBarBackButtonHidden(isMessageSelectionActive) .timelineMediaPreview(viewModel: $context.mediaPreviewViewModel) .overlay(alignment: .top) { FloatingDateBadge(dateText: timelineContext.floatingDate?.formattedDateSeparator()) { @@ -44,19 +44,25 @@ struct ThreadTimelineScreen: View { } } .safeAreaInset(edge: .bottom, spacing: 0) { - composer - .padding(.top, 8) - .background(Color.compound.bgCanvasDefault.ignoresSafeArea()) - .environmentObject(timelineContext) - .environment(\.timelineContext, timelineContext) - // Make sure the reply header honours the hideTimelineMedia setting too. - .environment(\.shouldAutomaticallyLoadImages, !timelineContext.viewState.hideTimelineMedia) - .collapsedInPlace(isSelectionActive) + VStack(spacing: 0) { + composer + .padding(.top, 8) + .background(Color.compound.bgCanvasDefault.ignoresSafeArea()) + .environmentObject(timelineContext) + .environment(\.timelineContext, timelineContext) + // Make sure the reply header honours the hideTimelineMedia setting too. + .environment(\.shouldAutomaticallyLoadImages, !timelineContext.viewState.hideTimelineMedia) + .collapsedInPlace(isMessageSelectionActive) + + if isMessageSelectionActive { + TimelineMessageSelectionActionBar(context: timelineContext) + } + } } } - private var isSelectionActive: Bool { - timelineContext.viewState.selection.isActive + private var isMessageSelectionActive: Bool { + timelineContext.viewState.messageSelection.isActive } @ViewBuilder @@ -70,22 +76,22 @@ struct ThreadTimelineScreen: View { @ToolbarContentBuilder private var toolbar: some ToolbarContent { - if isSelectionActive { - TimelineSelectionToolbar(count: timelineContext.viewState.selection.count) { - timelineContext.send(viewAction: .clearSelection) + // .principal + .primaryAction works better than .navigation leading + trailing + // as the latter disables interaction in the action button for rooms with long names + ToolbarItem(placement: .principal) { + RoomHeaderView(roomName: L10n.commonThread, + roomSubtitle: context.viewState.roomTitle, + roomAvatar: context.viewState.roomAvatar, + dmRecipientDetails: context.viewState.dmRecipientDetails, + roomHistorySharingState: context.viewState.roomHistorySharingState, + mediaProvider: context.mediaProvider) { + // There is no action but the iOS 26 designs have it looking like a button. } - } else { - // .principal + .primaryAction works better than .navigation leading + trailing - // as the latter disables interaction in the action button for rooms with long names - ToolbarItem(placement: .principal) { - RoomHeaderView(roomName: L10n.commonThread, - roomSubtitle: context.viewState.roomTitle, - roomAvatar: context.viewState.roomAvatar, - dmRecipientDetails: context.viewState.dmRecipientDetails, - roomHistorySharingState: context.viewState.roomHistorySharingState, - mediaProvider: context.mediaProvider) { - // There is no action but the iOS 26 designs have it looking like a button. - } + } + + if isMessageSelectionActive { + TimelineMessageSelectionToolbar { + timelineContext.send(viewAction: .clearMessageSelection) } } } diff --git a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift index 243662209..4df3334b3 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift @@ -224,8 +224,8 @@ class TimelineInteractionHandler { analyticsService.trackInteraction(name: .PinnedMessageListViewTimeline) guard let eventID = itemID.eventID else { return } actionsSubject.send(.viewInRoomTimeline(eventID: eventID)) - case .downloadMedia, .selectMessages: - break // Handled by the media preview screen and the TimelineViewModel respectively. + case .downloadMedia: + break // Handled inline in the media preview screen. case .translate: guard let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol else { return } actionsSubject.send(.showTranslation(text: messageTimelineItem.body)) diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index 3dcabd044..4c0f24b44 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -22,7 +22,7 @@ enum TimelineViewModelAction { case displayEditPollForm(eventID: String, poll: Poll) case displayMediaUploadPreviewScreen(mediaURLs: [URL]) case displaySenderDetails(userID: String) - case displayMessageForwarding(forwardingItem: MessageForwardingItem) + case displayMessageForwarding(forwardingPayload: MessageForwardingPayload) case displayMediaPreview(TimelineMediaPreviewViewModel) case displayLocation(StaticLocationData) case displayLiveLocation(sender: TimelineItemSender, initialLiveLocationShare: LiveLocationShare) @@ -69,11 +69,11 @@ enum TimelineViewAction { /// The user has confirmed the removal of an item, optionally giving a reason. case redactConfirmed(itemID: TimelineItemIdentifier, reason: String?) - /// Start a multi-selection with the specified item. - case startSelection(itemID: TimelineItemIdentifier) /// Add or remove an item from the active multi-selection. - case toggleSelection(itemID: TimelineItemIdentifier) - case clearSelection + case toggleMessageSelection(itemID: TimelineItemIdentifier) + case clearMessageSelection + /// Forward the selected messages, in timeline order. + case forwardMessageSelection case tappedOnSenderDetails(sender: TimelineItemSender) case displayReactionSummary(itemID: TimelineItemIdentifier, key: String) @@ -137,7 +137,7 @@ struct TimelineViewState: BindableState { var linkPreviewsEnabled: Bool var jumpToReadMarkerEnabled: Bool - var selection: TimelineSelectionState + var messageSelection: TimelineMessageSelectionState let hasPredecessor: Bool @@ -206,12 +206,10 @@ struct TimelineViewStateBindings { } /// The state of the multi-selection of messages, active as soon as an item is selected. -struct TimelineSelectionState: Equatable { - static let limit = 30 +struct TimelineMessageSelectionState: Equatable { + static let limit = 10 - /// Mirrors the `messageMultiSelectEnabled` feature flag. var isEnabled = false - /// The event IDs of the selected items. Only remote messages can be selected. var selectedEventIDs: Set = [] var isActive: Bool { @@ -354,12 +352,12 @@ enum ScrollDirection: Equatable { } extension TimelineViewState { - /// Multi-selection is only offered in the room and thread timelines. + /// Multi-selection is offered everywhere but the media timelines. var canSelectMessages: Bool { - guard selection.isEnabled else { return false } + guard messageSelection.isEnabled else { return false } return switch timelineKind { - case .live, .detached, .thread: true - case .pinned, .media: false + case .live, .detached, .thread, .pinned: true + case .media: false } } diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index a73829e60..d7fd84ea6 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -103,7 +103,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { areThreadsEnabled: appSettings.threadsEnabled, linkPreviewsEnabled: appSettings.linkPreviewsEnabled, jumpToReadMarkerEnabled: appSettings.jumpToReadMarkerEnabled, - selection: .init(isEnabled: appSettings.messageMultiSelectEnabled), + messageSelection: .init(isEnabled: appSettings.messageMultiSelectEnabled), hasPredecessor: roomProxy.predecessorRoom != nil, pinnedEventIDs: roomProxy.infoPublisher.value.pinnedEventIDs, emojiProvider: emojiProvider, @@ -198,7 +198,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { } case .displayTimelineItemMenu(let itemID): timelineInteractionHandler.displayTimelineItemActionMenu(for: itemID) - case .handleTimelineItemMenuAction(let itemID, .selectMessages): + case .handleTimelineItemMenuAction(let itemID, .forward) where state.canSelectMessages: startSelection(itemID: itemID) case .handleTimelineItemMenuAction(let itemID, let action): timelineInteractionHandler.handleTimelineItemMenuAction(action, itemID: itemID) @@ -206,12 +206,12 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { state.bindings.redactConfirmationInfo = nil // A blank reason is no reason at all, so don't send one. timelineInteractionHandler.redact(itemID, reason: reason?.isBlank == false ? reason : nil) - case .startSelection(let itemID): - startSelection(itemID: itemID) - case .toggleSelection(let itemID): + case .toggleMessageSelection(let itemID): toggleSelection(itemID: itemID) - case .clearSelection: - state.selection.selectedEventIDs.removeAll() + case .clearMessageSelection: + clearMessageSelection() + case .forwardMessageSelection: + forwardSelection() case .tappedOnSenderDetails(let sender): handleTappedOnSenderDetails(sender: sender) case .displayEmojiPicker(let itemID): @@ -313,11 +313,6 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { await userSession.liveLocationManager.stopLiveLocation(roomID: roomProxy.id) } - func makeForwardingItem(for itemID: TimelineItemIdentifier) async -> MessageForwardingItem? { - guard let content = await timelineController.messageEventContent(for: itemID) else { return nil } - return .init(id: itemID, roomID: roomProxy.id, content: content) - } - // MARK: - Private private func handleTappedOnSenderDetails(sender: TimelineItemSender) { @@ -1059,13 +1054,6 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { state.bindings.readReceiptsSummaryInfo = .init(orderedReceipts: eventTimelineItem.properties.orderedReadReceipts, id: eventTimelineItem.id) } - // MARK: - Message forwarding - - private func forwardMessage(itemID: TimelineItemIdentifier) async { - guard let forwardingItem = await makeForwardingItem(for: itemID) else { return } - actionsSubject.send(.displayMessageForwarding(forwardingItem: forwardingItem)) - } - // MARK: Pills private func pillContextUpdater(_ pillContext: PillContext) { @@ -1185,68 +1173,106 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { } } +// MARK: - Message forwarding + +extension TimelineViewModel { + func makeForwardingPayload(for itemIDs: [TimelineItemIdentifier]) async -> MessageForwardingPayload? { + var ids = [TimelineItemIdentifier]() + var contents = [RoomMessageEventContentWithoutRelation]() + for itemID in itemIDs { + guard let content = await timelineController.messageEventContent(for: itemID) else { continue } + ids.append(itemID) + contents.append(content) + } + + guard !contents.isEmpty else { return nil } + return .init(ids: ids, roomID: roomProxy.id, contents: contents) + } + + private func forwardMessage(itemID: TimelineItemIdentifier) async { + guard let forwardingPayload = await makeForwardingPayload(for: [itemID]) else { return } + actionsSubject.send(.displayMessageForwarding(forwardingPayload: forwardingPayload)) + } +} + // MARK: - Selection extension TimelineViewModel { private func setupSelectionSubscriptions() { appSettings.messageMultiSelectEnabledPublisher .sink { [weak self] isEnabled in - self?.state.selection.isEnabled = isEnabled + self?.state.messageSelection.isEnabled = isEnabled if !isEnabled { - self?.state.selection.selectedEventIDs.removeAll() + self?.state.messageSelection.selectedEventIDs.removeAll() } } .store(in: &cancellables) } private func startSelection(itemID: TimelineItemIdentifier) { - guard state.canSelectMessages, let eventID = selectableEventID(for: itemID) else { return } + guard let eventID = selectableEventID(for: itemID) else { return } // The composer is collapsed while selecting, so don't leave the microphone open behind it. Task { await timelineInteractionHandler.stopRecordingVoiceMessageIfNeeded() } actionsSubject.send(.composer(action: .removeFocus)) - guard !state.selection.isAtLimit || state.selection.selectedEventIDs.contains(eventID) else { + guard !state.messageSelection.isAtLimit || state.messageSelection.selectedEventIDs.contains(eventID) else { showSelectionLimitToast() return } - state.selection.selectedEventIDs.insert(eventID) + state.messageSelection.selectedEventIDs.insert(eventID) } private func toggleSelection(itemID: TimelineItemIdentifier) { - guard state.selection.isActive, let eventID = selectableEventID(for: itemID) else { return } + guard state.messageSelection.isActive, let eventID = selectableEventID(for: itemID) else { return } - if state.selection.selectedEventIDs.contains(eventID) { - state.selection.selectedEventIDs.remove(eventID) - } else if state.selection.isAtLimit { + if state.messageSelection.selectedEventIDs.contains(eventID) { + state.messageSelection.selectedEventIDs.remove(eventID) + } else if state.messageSelection.isAtLimit { showSelectionLimitToast() } else { - state.selection.selectedEventIDs.insert(eventID) + state.messageSelection.selectedEventIDs.insert(eventID) } } /// Drops selected items that are no longer selectable (e.g. redacted), or the whole selection /// when the timeline is swapped, so the selection always refers to items that are on screen. private func reconcileSelection(with timelineItems: [RoomTimelineItemProtocol], isSwitchingTimelines: Bool) { - guard state.selection.isActive else { return } + guard state.messageSelection.isActive else { return } if isSwitchingTimelines { - state.selection.selectedEventIDs.removeAll() + state.messageSelection.selectedEventIDs.removeAll() return } - let selectableEventIDs = timelineItems.compactMap { item -> String? in - guard let item = item as? EventBasedTimelineItemProtocol, item.isBulkSelectable else { return nil } - return item.id.eventID - } - state.selection.selectedEventIDs.formIntersection(selectableEventIDs) + let forwardableEventIDs = timelineItems.forwardableItems.compactMap(\.id.eventID) + state.messageSelection.selectedEventIDs.formIntersection(forwardableEventIDs) } - /// The event ID of the item, when it is part of this timeline and can be bulk selected. + /// Ends the selection, either from the close button or once the selected messages have been forwarded. + func clearMessageSelection() { + state.messageSelection.selectedEventIDs.removeAll() + } + + /// Forwards the selection in timeline order, which is unrelated to the order the messages were selected in. + /// The selection is kept until the forwarding completes, so cancelling the picker returns to it. + private func forwardSelection() { + let itemIDs = timelineController.timelineItems.selectedItems(state.messageSelection.selectedEventIDs).map(\.id) + + Task { + guard let forwardingPayload = await makeForwardingPayload(for: itemIDs) else { + displayErrorToast(L10n.errorUnknown) + return + } + actionsSubject.send(.displayMessageForwarding(forwardingPayload: forwardingPayload)) + } + } + + /// The event ID of the item, when it is part of this timeline and can be forwarded. private func selectableEventID(for itemID: TimelineItemIdentifier) -> String? { guard let item = timelineController.timelineItems.firstUsingStableID(itemID) as? EventBasedTimelineItemProtocol, - item.isBulkSelectable else { + item.isForwardable else { return nil } return item.id.eventID diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModelProtocol.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModelProtocol.swift index 031b2f127..8d9aea6df 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModelProtocol.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModelProtocol.swift @@ -19,6 +19,8 @@ protocol TimelineViewModelProtocol { func focusOnEvent(eventID: String) async /// Stops the current live location sharing func stopLiveLocationSharing() async - /// Handles getting the content to forward an item given its item ID. - func makeForwardingItem(for itemID: TimelineItemIdentifier) async -> MessageForwardingItem? + /// Handles getting the contents to forward the given items, keeping the order of the given IDs. + func makeForwardingPayload(for itemIDs: [TimelineItemIdentifier]) async -> MessageForwardingPayload? + /// Ends the message selection, e.g. once the selected messages have been forwarded. + func clearMessageSelection() } diff --git a/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenu.swift b/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenu.swift index d4b5a8540..d9a418832 100644 --- a/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenu.swift +++ b/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenu.swift @@ -339,7 +339,6 @@ struct TimelineItemMenu_Previews: PreviewProvider, TestablePreview { pinnedEventIDs: [], isViewSourceEnabled: true, areThreadsEnabled: true, - isMultiSelectEnabled: false, timelineKind: .live, emojiProvider: EmojiProvider(appSettings: .volatile())) guard let actions = provider.makeActions() else { return nil } diff --git a/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuAction.swift b/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuAction.swift index a605fb8b4..3f40a206a 100644 --- a/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuAction.swift +++ b/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuAction.swift @@ -68,7 +68,6 @@ enum TimelineItemMenuAction: Identifiable, Hashable { case reply(isThread: Bool) case replyInThread case forward(itemID: TimelineItemIdentifier) - case selectMessages case viewSource case report case react @@ -170,8 +169,6 @@ enum TimelineItemMenuAction: Identifiable, Hashable { Label(L10n.actionReplyInThread, icon: \.threads) case .forward: Label(L10n.actionForward, icon: \.forward) - case .selectMessages: - Label(L10n.actionSelect, icon: \.check) case .redact(let isMedia): Label(isMedia ? L10n.actionDeleteFile : L10n.actionRemoveMessage, icon: \.delete) case .viewSource: diff --git a/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift b/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift index 87a97f26e..0a11ce98e 100644 --- a/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift +++ b/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift @@ -17,7 +17,6 @@ struct TimelineItemMenuActionProvider { let pinnedEventIDs: Set let isViewSourceEnabled: Bool let areThreadsEnabled: Bool - let isMultiSelectEnabled: Bool let timelineKind: TimelineKind let emojiProvider: EmojiProviderProtocol @@ -66,10 +65,6 @@ struct TimelineItemMenuActionProvider { actions.append(.forward(itemID: item.id)) } - if isMultiSelectEnabled, item.isBulkSelectable { - actions.append(.selectMessages) - } - if item.isEditable, canCurrentUserSendMessage { if item.supportsMediaCaption { if item.hasMediaCaption { diff --git a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemAccessibilityModifier.swift b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemAccessibilityModifier.swift index 40e357654..7d2447383 100644 --- a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemAccessibilityModifier.swift +++ b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemAccessibilityModifier.swift @@ -9,7 +9,7 @@ import SwiftUI /// How a timeline item takes part in an active multi-selection, for accessibility purposes. -enum TimelineItemAccessibilitySelection { +enum TimelineItemAccessibilityMessageSelection { /// No selection is active, the item offers its regular message actions. case none /// A selection is active, the item acts as a toggle when it can be selected. @@ -18,7 +18,7 @@ enum TimelineItemAccessibilitySelection { private struct TimelineItemAccessibilityModifier: ViewModifier { let timelineItem: RoomTimelineItemProtocol - let selection: TimelineItemAccessibilitySelection + let selection: TimelineItemAccessibilityMessageSelection let action: () -> Void private var isSelecting: Bool { @@ -77,7 +77,7 @@ private struct TimelineItemAccessibilityModifier: ViewModifier { } } case .selecting(let isSelected): - if let item = timelineItem as? EventBasedTimelineItemProtocol, item.isBulkSelectable { + if let item = timelineItem as? EventBasedTimelineItemProtocol, item.isForwardable { view .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) .accessibilityAction(.default, action) @@ -91,7 +91,7 @@ private struct TimelineItemAccessibilityModifier: ViewModifier { extension View { func timelineItemAccessibility(_ timelineItem: RoomTimelineItemProtocol, - selection: TimelineItemAccessibilitySelection = .none, + selection: TimelineItemAccessibilityMessageSelection = .none, action: @escaping () -> Void) -> some View { modifier(TimelineItemAccessibilityModifier(timelineItem: timelineItem, selection: selection, action: action)) } diff --git a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift index 6531edd4a..2fa74ea13 100644 --- a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift @@ -42,12 +42,12 @@ struct TimelineItemBubbledStylerView: View { return context.viewState.pinnedEventIDs.contains(eventID) } - private var isSelectionActive: Bool { - context.viewState.selection.isActive + private var isMessageSelectionActive: Bool { + context.viewState.messageSelection.isActive } private var isSelected: Bool { - context.viewState.selection.contains(timelineItem.id.eventID) + context.viewState.messageSelection.contains(timelineItem.id.eventID) } /// The base padding applied to bubbles on either side. @@ -61,18 +61,17 @@ struct TimelineItemBubbledStylerView: View { } var body: some View { - HStack(spacing: 0) { - if isSelectionActive { + HStack(spacing: 10) { + if isMessageSelectionActive { selectionIndicator } bubble } .padding(EdgeInsets(top: 1, leading: 8, bottom: 1, trailing: 8)) - .background(isSelected ? Color.compound.bgAccentSelected : .clear) .highlightedTimelineItem(isFocussed) .overlay { selectionOverlay } - .animation(.elementDefault.disabledDuringTests(), value: isSelectionActive) + .animation(.elementDefault.disabledDuringTests(), value: isMessageSelectionActive) .onPreferenceChange(ContentScanningFailurePreferenceKey.self) { contentScanningFailure = $0 } } @@ -80,7 +79,7 @@ struct TimelineItemBubbledStylerView: View { // The ZStack keeps the transition on the indicator's appearance rather than its checked state. ZStack { ListRowAccessory.multiSelection(isSelected) - .opacity(timelineItem.isBulkSelectable ? 1 : 0) // Keeps the bubbles aligned. + .opacity(timelineItem.isForwardable ? 1 : 0) // Keeps the bubbles aligned. } .padding(.leading, 8) .transition(.move(edge: .leading).combined(with: .opacity)) @@ -90,7 +89,7 @@ struct TimelineItemBubbledStylerView: View { /// Captures every touch while selecting so the content's own gestures can't fire. @ViewBuilder private var selectionOverlay: some View { - if isSelectionActive { + if isMessageSelectionActive { Color.clear .contentShape(Rectangle()) .onTapGesture { toggleSelection() } @@ -172,8 +171,8 @@ struct TimelineItemBubbledStylerView: View { // Figma overlaps reactions by 3 VStack(alignment: alignment, spacing: -3) { messageBubbleWithActions - .timelineItemAccessibility(timelineItem, selection: isSelectionActive ? .selecting(isSelected: isSelected) : .none) { - if isSelectionActive { + .timelineItemAccessibility(timelineItem, selection: isMessageSelectionActive ? .selecting(isSelected: isSelected) : .none) { + if isMessageSelectionActive { toggleSelection() } else { context.send(viewAction: .displayTimelineItemMenu(itemID: timelineItem.id)) @@ -189,7 +188,7 @@ struct TimelineItemBubbledStylerView: View { isLayoutRTL: timelineItem.isOutgoing) // Workaround to stop the message long press stealing the touch from the reaction buttons .onTapGesture { } - .accessibilityHidden(isSelectionActive) + .accessibilityHidden(isMessageSelectionActive) } if context.viewState.areThreadsEnabled, @@ -199,7 +198,7 @@ struct TimelineItemBubbledStylerView: View { context.send(viewAction: .displayThread(itemID: timelineItem.id)) } .padding(5) - .accessibilityHidden(isSelectionActive) + .accessibilityHidden(isMessageSelectionActive) } } } @@ -213,7 +212,7 @@ struct TimelineItemBubbledStylerView: View { // specific taps within the timeline views themselves. } // The UIKit recogniser would still receive touches through the selection overlay. - .longPressWithFeedback(isEnabled: !isSelectionActive) { + .longPressWithFeedback(isEnabled: !isMessageSelectionActive) { context.send(viewAction: .displayTimelineItemMenu(itemID: timelineItem.id)) } .swipeRightAction { @@ -221,13 +220,13 @@ struct TimelineItemBubbledStylerView: View { .foregroundColor(.compound.iconPrimary) .accessibilityHidden(true) } shouldStartAction: { - !isSelectionActive && timelineItem.canBeRepliedTo + !isMessageSelectionActive && timelineItem.canBeRepliedTo } action: { context.send(viewAction: .handleTimelineItemMenuAction(itemID: timelineItem.id, action: .reply(isThread: timelineItem.properties.isThreaded))) } .contextMenu { - if !isSelectionActive { + if !isMessageSelectionActive { macContextMenu } } @@ -245,7 +244,6 @@ struct TimelineItemBubbledStylerView: View { pinnedEventIDs: context.viewState.pinnedEventIDs, isViewSourceEnabled: context.viewState.isViewSourceEnabled, areThreadsEnabled: context.viewState.areThreadsEnabled, - isMultiSelectEnabled: context.viewState.canSelectMessages, timelineKind: context.viewState.timelineKind, emojiProvider: context.viewState.emojiProvider) TimelineItemMacContextMenu(item: timelineItem, actionProvider: provider) { action in @@ -298,8 +296,8 @@ struct TimelineItemBubbledStylerView: View { } private func toggleSelection() { - guard timelineItem.isBulkSelectable else { return } - context.send(viewAction: .toggleSelection(itemID: timelineItem.id)) + guard timelineItem.isForwardable else { return } + context.send(viewAction: .toggleMessageSelection(itemID: timelineItem.id)) } private var messageBubbleTopPadding: CGFloat { @@ -478,7 +476,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview emojiProvider: EmojiProvider(appSettings: appSettings), linkMetadataProvider: LinkMetadataProvider(), timelineControllerFactory: TimelineControllerFactoryMock(.init())) - viewModel.state.selection.selectedEventIDs = ["selected"] + viewModel.state.messageSelection.selectedEventIDs = ["selected"] return viewModel }() diff --git a/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineMessageSelectionActionBar.swift b/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineMessageSelectionActionBar.swift new file mode 100644 index 000000000..c531f2ff8 --- /dev/null +++ b/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineMessageSelectionActionBar.swift @@ -0,0 +1,40 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import Compound +import SwiftUI + +/// Replaces the composer while messages are being selected, offering the action to take on them. +struct TimelineMessageSelectionActionBar: View { + @ObservedObject var context: TimelineViewModelType.Context + + var body: some View { + Button { + context.send(viewAction: .forwardMessageSelection) + } label: { + Label(L10n.actionForwardMessages(context.viewState.messageSelection.count), icon: \.forward) + } + .buttonStyle(.compound(.primary)) + .padding(16) + .background(Color.compound.bgCanvasDefault.ignoresSafeArea()) + } +} + +// MARK: - Previews + +struct TimelineMessageSelectionActionBar_Previews: PreviewProvider, TestablePreview { + static let viewModel: TimelineViewModel = { + let viewModel = TimelineViewModel.mock() // A fresh instance so the shared mock isn't left selecting. + viewModel.state.messageSelection.selectedEventIDs = ["$1", "$2", "$3"] + return viewModel + }() + + static var previews: some View { + TimelineMessageSelectionActionBar(context: viewModel.context) + .previewLayout(.sizeThatFits) + } +} diff --git a/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineMessageSelectionToolbar.swift b/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineMessageSelectionToolbar.swift new file mode 100644 index 000000000..5c8d81919 --- /dev/null +++ b/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineMessageSelectionToolbar.swift @@ -0,0 +1,19 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +import SwiftUI + +/// The trailing navigation bar item that cancels the selection of messages in a timeline. +struct TimelineMessageSelectionToolbar: ToolbarContent { + let onCancel: () -> Void + + var body: some ToolbarContent { + ToolbarItem(placement: .primaryAction) { + ToolbarButton(role: .cancel, action: onCancel) + } + } +} diff --git a/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineSelectionToolbar.swift b/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineSelectionToolbar.swift deleted file mode 100644 index 3fce44824..000000000 --- a/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineSelectionToolbar.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// Copyright 2026 Element Creations Ltd. -// -// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. -// Please see LICENSE files in the repository root for full details. -// - -import Compound -import SwiftUI - -/// The navigation bar content shown while messages are being selected in a timeline. -struct TimelineSelectionToolbar: ToolbarContent { - let count: Int - let onCancel: () -> Void - - var body: some ToolbarContent { - ToolbarItem(placement: .cancellationAction) { - Button(L10n.actionCancel, role: .cancel, action: onCancel) - } - - ToolbarItem(placement: .principal) { - Text(L10n.screenRoomSelectionCount(count)) - .font(.compound.bodyLGSemibold) - .foregroundStyle(.compound.textPrimary) - } - } -} diff --git a/ElementX/Sources/Screens/Timeline/View/TimelineView.swift b/ElementX/Sources/Screens/Timeline/View/TimelineView.swift index 389a9805d..42e207ef2 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineView.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineView.swift @@ -41,7 +41,6 @@ struct TimelineView: View { pinnedEventIDs: timelineContext.viewState.pinnedEventIDs, isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled, areThreadsEnabled: timelineContext.viewState.areThreadsEnabled, - isMultiSelectEnabled: timelineContext.viewState.canSelectMessages, timelineKind: timelineContext.viewState.timelineKind, emojiProvider: timelineContext.viewState.emojiProvider) .makeActions() diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift index 2755ada41..3208d16a1 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift @@ -28,17 +28,14 @@ nonisolated extension EventBasedTimelineItemProtocol { } var isForwardable: Bool { - isRemoteMessage && !(self is PollRoomTimelineItem) && !(self is LiveLocationRoomTimelineItem) - } - - /// Whether the item can be part of a multi-selection of messages. - var isBulkSelectable: Bool { isRemoteMessage && !isRedacted && !hasFailedDecryption && !(self is StateRoomTimelineItem) && !(self is UnsupportedRoomTimelineItem) + && !(self is PollRoomTimelineItem) && !(self is LiveLocationRoomTimelineItem) + && !(self is StickerRoomTimelineItem) // The SDK can only forward m.room.message content and stickers are m.sticker events. } var isRemoteMessage: Bool { diff --git a/PreviewTests/Sources/GeneratedPreviewTests.swift b/PreviewTests/Sources/GeneratedPreviewTests.swift index ce7822679..4b5753023 100644 --- a/PreviewTests/Sources/GeneratedPreviewTests.swift +++ b/PreviewTests/Sources/GeneratedPreviewTests.swift @@ -1404,6 +1404,13 @@ extension PreviewTests { } } + @Test + func timelineMessageSelectionActionBar() async throws { + for (index, preview) in TimelineMessageSelectionActionBar_Previews._allPreviews.enumerated() { + try await assertSnapshots(matching: preview, step: index) + } + } + @Test func timelineReactionView() async throws { for (index, preview) in TimelineReactionView_Previews._allPreviews.enumerated() { diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedEventsTimelineScreen.Selecting-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedEventsTimelineScreen.Selecting-iPad-en-GB.png new file mode 100644 index 000000000..168783fd0 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedEventsTimelineScreen.Selecting-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2113272aba349068ef746f57f993aa8f1cced5ce3be84acd7dcb83522dd34df7 +size 229720 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedEventsTimelineScreen.Selecting-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedEventsTimelineScreen.Selecting-iPad-pseudo.png new file mode 100644 index 000000000..2df20d3e7 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedEventsTimelineScreen.Selecting-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0cb91f8da5aec4cfcf57682d55fab5dbf55b602e8ab03bc4c5e2cb7607cd26fe +size 236621 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedEventsTimelineScreen.Selecting-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedEventsTimelineScreen.Selecting-iPhone-en-GB.png new file mode 100644 index 000000000..23a11c7d0 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedEventsTimelineScreen.Selecting-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:38711b2757b8cbf0e8e03449fd76e932e841e54a84d810ab138fc5329e42d28e +size 168069 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedEventsTimelineScreen.Selecting-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedEventsTimelineScreen.Selecting-iPhone-pseudo.png new file mode 100644 index 000000000..1e5c6b6a5 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/pinnedEventsTimelineScreen.Selecting-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0459720ea7376e5c62a7e29f0c69d06fa50e4f67a5c2d0c70abd008913370dc2 +size 160328 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPad-en-GB.png index a0650f086..afdd932c8 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a8c4d7de9bde6e8b8ed405139ef0b1a93c526efc0495dc9e060f7746e3851a27 -size 307560 +oid sha256:c71750ad42a0bf4ba7000886933a7b469b66f0a2e83ad2d9af3cdb93cc6d78bb +size 296680 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPad-pseudo.png index 9c91eb253..6ab31c3c2 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b736b917580ffb895f52e571f7ec6b88fa79618ef7d967af4be7a1dc1297d74a -size 314629 +oid sha256:e590117b3cef004295b78b851a8bab6a847e032d81a6a733165aa3d671fc8d39 +size 302175 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPhone-en-GB.png index 100121991..59e33a3b7 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:70802dcebe90358926b889013d49f094111426817c4a3c5739e5bf6c56a7f755 -size 185662 +oid sha256:4a5286947f95971372a4e5cc146df21821dc18374462b80da063c7b014189de6 +size 173954 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPhone-pseudo.png index 5649b8217..106df9817 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3cf29299b5003b889898629a533303496911b212b42e9e8dc10912ec7ac5e519 -size 179793 +oid sha256:2821e658003cb2d10e89c35a212ce44f2b543d030d8d2f698b660929f1fd8fa0 +size 166352 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPad-en-GB.png index acf32a5ae..7c52a538d 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f4ba074e3d13bc6ecfefe9e91e0515d0fa0a793908ae38fe400c845c389d79fd -size 103633 +oid sha256:4f1d033633be525d00add117f22cbd644738f557566652105e5bad06fa93f550 +size 100876 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPad-pseudo.png index 9493e791b..31d03997e 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1d1e836cbb773ab61e7c5470fec38a51dce0572b5a779eabdcd86a0171b7aa04 -size 106075 +oid sha256:453b6c26d0af9fc4f52acd6878eb521ea391803d859ca8d22ff6537b43af443a +size 103386 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPhone-en-GB.png index 67bed13a7..659a6d414 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aed3e91bdd708c6738cc2e82e8271988761adc46c8192e491cb5e7bcf4f33031 -size 59392 +oid sha256:0a39d152d8c2325e6600a335916be5af29a7d195df61ae33c7c2da138c3a225d +size 59166 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPhone-pseudo.png index 4412e0fa8..bff9dcfa5 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ecafd3c5f7ca87afbc5b88211205b0a5b8e950595fdc5f526456628b3f1c32d3 -size 61305 +oid sha256:0e86d354c76d520c1d45d263b5d25851d3db42b2767440876a322525c849479c +size 60994 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.iPad-en-GB-0.png new file mode 100644 index 000000000..b454e2605 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.iPad-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:acf13cf6aa2414522d9de6d68d631e1334072d72231be8ec552dbc1ebf02efb4 +size 17224 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.iPad-pseudo-0.png new file mode 100644 index 000000000..81248f77b --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.iPad-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31a3a51a1f5756e5b25ede739f05d33586c1e459d4fb5c147d69324a539f21ff +size 17343 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.iPhone-en-GB-0.png new file mode 100644 index 000000000..80640673e --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.iPhone-en-GB-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b6d05bd21a076f815bfcd766317e4ca1860c54a91f86faf30dbdbd503d2329f +size 11595 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.iPhone-pseudo-0.png new file mode 100644 index 000000000..a9ebdebb9 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.iPhone-pseudo-0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b945f049c4f2325bb6da028586a901c7b5144cce57ff49d55678c34e123a20e7 +size 11653 diff --git a/UnitTests/Sources/MessageForwardingScreenViewModelTests.swift b/UnitTests/Sources/MessageForwardingScreenViewModelTests.swift index e0f7a90b6..dd35ab98a 100644 --- a/UnitTests/Sources/MessageForwardingScreenViewModelTests.swift +++ b/UnitTests/Sources/MessageForwardingScreenViewModelTests.swift @@ -13,9 +13,11 @@ import Testing @MainActor struct MessageForwardingScreenViewModelTests { - let forwardingItem = MessageForwardingItem(id: .event(uniqueID: .init("t1"), eventOrTransactionID: .eventID("t1")), - roomID: "1", - content: RoomMessageEventContentWithoutRelationSDKMock()) + let forwardingPayload = MessageForwardingPayload(ids: [.event(uniqueID: .init("t1"), eventOrTransactionID: .eventID("t1")), + .event(uniqueID: .init("t2"), eventOrTransactionID: .eventID("t2"))], + roomID: "1", + contents: [RoomMessageEventContentWithoutRelationSDKMock(), + RoomMessageEventContentWithoutRelationSDKMock()]) var viewModel: MessageForwardingScreenViewModelProtocol! var context: MessageForwardingScreenViewModelType.Context! @@ -23,7 +25,7 @@ struct MessageForwardingScreenViewModelTests { let clientProxy = ClientProxyMock(.init()) clientProxy.roomForIdentifierClosure = { .joined(JoinedRoomProxyMock(.init(id: $0))) } - viewModel = MessageForwardingScreenViewModel(forwardingItem: forwardingItem, + viewModel = MessageForwardingScreenViewModel(forwardingPayload: forwardingPayload, userSession: UserSessionMock(.init(clientProxy: clientProxy)), roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))), userIndicatorController: UserIndicatorControllerMock()) @@ -32,7 +34,7 @@ struct MessageForwardingScreenViewModelTests { @Test func initialState() { - #expect(!context.viewState.rooms.contains { $0.id == forwardingItem.roomID }, "The source room ID shouldn't be shown") + #expect(!context.viewState.rooms.contains { $0.id == forwardingPayload.roomID }, "The source room ID shouldn't be shown") } @Test @@ -70,4 +72,122 @@ struct MessageForwardingScreenViewModelTests { try await deferred.fulfill() } + + @Test + mutating func forwardingSendsEveryMessageToEveryRoomInOrder() async throws { + let clientProxy = ClientProxyMock(.init()) + var roomProxies = [String: JoinedRoomProxyMock]() + clientProxy.roomForIdentifierClosure = { roomID in + let roomProxy = JoinedRoomProxyMock(.init(id: roomID)) + roomProxies[roomID] = roomProxy + return .joined(roomProxy) + } + viewModel = MessageForwardingScreenViewModel(forwardingPayload: forwardingPayload, + userSession: UserSessionMock(.init(clientProxy: clientProxy)), + roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))), + userIndicatorController: UserIndicatorControllerMock()) + context = viewModel.context + + context.send(viewAction: .selectRoom(roomID: "2")) + context.send(viewAction: .selectRoom(roomID: "3")) + + let deferred = deferFulfillment(viewModel.actions) { action in + if case .sent(let roomIDs) = action { + return Set(roomIDs) == ["2", "3"] + } + return false + } + context.send(viewAction: .send) + try await deferred.fulfill() + + let expectedContents = forwardingPayload.contents.map { ObjectIdentifier($0) } + for roomID in ["2", "3"] { + let timeline = try #require(roomProxies[roomID]?.timeline as? TimelineProxyMock) + let sentContents = timeline.sendMessageEventContentReceivedInvocations.map { ObjectIdentifier($0) } + #expect(sentContents == expectedContents) + } + } + + @Test + mutating func forwardingToSeveralRoomsShowsAToast() async throws { + let userIndicatorController = UserIndicatorControllerMock() + viewModel = makeViewModel(userIndicatorController: userIndicatorController) + context = viewModel.context + + context.send(viewAction: .selectRoom(roomID: "2")) + context.send(viewAction: .selectRoom(roomID: "3")) + + let deferred = deferFulfillment(viewModel.actions) { action in + if case .sent = action { + return true + } + return false + } + context.send(viewAction: .send) + try await deferred.fulfill() + + let titles = userIndicatorController.submitIndicatorDelayReceivedInvocations.map(\.indicator.title) + #expect(titles == [L10n.screenRoomMessagesForwarded(2)]) + } + + @Test + mutating func forwardingToOneRoomDoesNotShowAToast() async throws { + let userIndicatorController = UserIndicatorControllerMock() + viewModel = makeViewModel(userIndicatorController: userIndicatorController) + context = viewModel.context + + context.send(viewAction: .selectRoom(roomID: "2")) + + let deferred = deferFulfillment(viewModel.actions) { action in + if case .sent = action { + return true + } + return false + } + context.send(viewAction: .send) + try await deferred.fulfill() + + // The flow opens the room instead. + #expect(userIndicatorController.submitIndicatorDelayCallsCount == 0) + } + + @Test + mutating func forwardingReportsRoomsThatCouldNotBeReached() async throws { + let clientProxy = ClientProxyMock(.init()) + clientProxy.roomForIdentifierClosure = { roomID in + roomID == "3" ? nil : .joined(JoinedRoomProxyMock(.init(id: roomID))) + } + let userIndicatorController = UserIndicatorControllerMock() + viewModel = MessageForwardingScreenViewModel(forwardingPayload: forwardingPayload, + userSession: UserSessionMock(.init(clientProxy: clientProxy)), + roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))), + userIndicatorController: userIndicatorController) + context = viewModel.context + + context.send(viewAction: .selectRoom(roomID: "2")) + context.send(viewAction: .selectRoom(roomID: "3")) + + let deferred = deferFulfillment(viewModel.actions) { action in + if case .sent(let roomIDs) = action { + return roomIDs == ["2"] + } + return false + } + context.send(viewAction: .send) + try await deferred.fulfill() + + #expect(userIndicatorController.submitIndicatorDelayCallsCount == 1) + } + + // MARK: - Helpers + + private func makeViewModel(userIndicatorController: UserIndicatorControllerProtocol) -> MessageForwardingScreenViewModelProtocol { + let clientProxy = ClientProxyMock(.init()) + clientProxy.roomForIdentifierClosure = { .joined(JoinedRoomProxyMock(.init(id: $0))) } + + return MessageForwardingScreenViewModel(forwardingPayload: forwardingPayload, + userSession: UserSessionMock(.init(clientProxy: clientProxy)), + roomSummaryProvider: RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))), + userIndicatorController: userIndicatorController) + } } diff --git a/UnitTests/Sources/TimelineItemMenuActionProviderTests.swift b/UnitTests/Sources/TimelineItemMenuActionProviderTests.swift index 44d13c19f..782a27025 100644 --- a/UnitTests/Sources/TimelineItemMenuActionProviderTests.swift +++ b/UnitTests/Sources/TimelineItemMenuActionProviderTests.swift @@ -51,41 +51,23 @@ struct TimelineItemMenuActionProviderTests { #expect(hasForward) } - // MARK: - Select - @Test - func selectIsShownForRemoteMessageWhenEnabled() throws { - let actions = try #require(makeActions(for: makeTextItem(), isMultiSelectEnabled: true)) - #expect(actions.actions.contains(.selectMessages)) + func redactedMessageIsNotForwardable() throws { + let item = RedactedRoomTimelineItem(id: .randomEvent, + body: "Message removed", + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: false, + sender: .init(id: "@alice:matrix.org")) + let actions = try #require(makeActions(for: item)) + + let hasForward = actions.actions.contains(where: \.isForward) + #expect(!hasForward) } @Test - func selectIsHiddenWhenDisabled() throws { - let actions = try #require(makeActions(for: makeTextItem())) - #expect(!actions.actions.contains(.selectMessages)) - } - - @Test - func selectIsHiddenInPinnedTimeline() throws { - let actions = try #require(makeActions(for: makeTextItem(), isMultiSelectEnabled: true, timelineKind: .pinned)) - #expect(!actions.actions.contains(.selectMessages)) - } - - @Test - func selectIsHiddenForLocalEcho() throws { - let item = makeTextItem(id: .event(uniqueID: .init("local"), eventOrTransactionID: .transactionID("txn"))) - let actions = try #require(makeActions(for: item, isMultiSelectEnabled: true)) - #expect(!actions.actions.contains(.selectMessages)) - } - - @Test - func selectIsHiddenForLiveLocationShare() throws { - let actions = try #require(makeActions(for: makeLiveLocationItem(isLive: true), isMultiSelectEnabled: true)) - #expect(!actions.actions.contains(.selectMessages)) - } - - @Test - func selectIsHiddenForEncryptedItem() throws { + func encryptedMessageIsNotForwardable() throws { let item = EncryptedRoomTimelineItem(id: .randomEvent, body: "", encryptionType: .unknown, @@ -94,8 +76,19 @@ struct TimelineItemMenuActionProviderTests { isEditable: false, canBeRepliedTo: false, sender: .init(id: "@alice:matrix.org")) - let actions = try #require(makeActions(for: item, isMultiSelectEnabled: true)) - #expect(!actions.actions.contains(.selectMessages)) + let actions = try #require(makeActions(for: item)) + + let hasForward = actions.actions.contains(where: \.isForward) + #expect(!hasForward) + } + + @Test + func localEchoIsNotForwardable() throws { + let item = makeTextItem(id: .event(uniqueID: .init("local"), eventOrTransactionID: .transactionID("txn"))) + let actions = try #require(makeActions(for: item)) + + let hasForward = actions.actions.contains(where: \.isForward) + #expect(!hasForward) } // MARK: - Helpers @@ -120,9 +113,7 @@ struct TimelineItemMenuActionProviderTests { content: .init(isLive: isLive, timeoutDate: .mock, lastGeoURI: nil)) } - private func makeActions(for item: RoomTimelineItemProtocol, - isMultiSelectEnabled: Bool = false, - timelineKind: TimelineKind = .live) -> TimelineItemMenuActions? { + private func makeActions(for item: RoomTimelineItemProtocol, timelineKind: TimelineKind = .live) -> TimelineItemMenuActions? { TimelineItemMenuActionProvider(timelineItem: item, canCurrentUserSendMessage: true, canCurrentUserRedactSelf: true, @@ -131,7 +122,6 @@ struct TimelineItemMenuActionProviderTests { pinnedEventIDs: [], isViewSourceEnabled: true, areThreadsEnabled: true, - isMultiSelectEnabled: isMultiSelectEnabled, timelineKind: timelineKind, emojiProvider: EmojiProvider(appSettings: .volatile())) .makeActions() diff --git a/UnitTests/Sources/TimelineItemsSelectionTests.swift b/UnitTests/Sources/TimelineItemsSelectionTests.swift new file mode 100644 index 000000000..694bb9d8c --- /dev/null +++ b/UnitTests/Sources/TimelineItemsSelectionTests.swift @@ -0,0 +1,52 @@ +// +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. +// Please see LICENSE files in the repository root for full details. +// + +@testable import ElementX +import Testing + +@MainActor +struct TimelineItemsSelectionTests { + @Test + func selectedItemsFollowTheTimelineNotTheSelection() { + let items: [RoomTimelineItemProtocol] = (1...5).map { makeTextItem(eventID: "$\($0)") } + + let ordered = items.selectedItems(["$5", "$1", "$3"]) + + #expect(ordered.map(\.id) == [items[0].id, items[2].id, items[4].id]) + } + + @Test + func selectedItemsSkipUnforwardableAndUnknownEvents() { + let text = makeTextItem(eventID: "$1") + let redacted = RedactedRoomTimelineItem(id: .event(uniqueID: .init("2"), eventOrTransactionID: .eventID("$2")), + body: "Message removed", + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: false, + sender: .init(id: "@alice:matrix.org")) + let separator = SeparatorRoomTimelineItem(id: .virtual(uniqueID: .init("separator")), timestamp: .mock) + + let items: [RoomTimelineItemProtocol] = [separator, redacted, text] + + let ordered = items.selectedItems(["$1", "$2", "$missing"]) + + #expect(ordered.map(\.id) == [text.id]) + } + + // MARK: - Helpers + + private func makeTextItem(eventID: String) -> TextRoomTimelineItem { + .init(id: .event(uniqueID: .init(eventID), eventOrTransactionID: .eventID(eventID)), + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + sender: .init(id: "@alice:matrix.org"), + content: .init(body: eventID)) + } +} diff --git a/UnitTests/Sources/TimelineViewModelTests.swift b/UnitTests/Sources/TimelineViewModelTests.swift index 8a8023d4e..e3fe183ce 100644 --- a/UnitTests/Sources/TimelineViewModelTests.swift +++ b/UnitTests/Sources/TimelineViewModelTests.swift @@ -690,7 +690,7 @@ final class TimelineViewModelTests { // MARK: - Selection @Test - func selectMenuActionEntersSelection() async throws { + func forwardMenuActionStartsSelection() async throws { let items = [TextRoomTimelineItem(eventID: "$1"), TextRoomTimelineItem(eventID: "$2")] let viewModel = makeSelectionViewModel(items: items) @@ -700,22 +700,89 @@ final class TimelineViewModelTests { } return false } - viewModel.process(viewAction: .handleTimelineItemMenuAction(itemID: items[0].id, action: .selectMessages)) + viewModel.process(viewAction: .handleTimelineItemMenuAction(itemID: items[0].id, action: .forward(itemID: items[0].id))) try await deferred.fulfill() - #expect(viewModel.state.selection.isActive) - #expect(viewModel.state.selection.selectedEventIDs == ["$1"]) + #expect(viewModel.state.messageSelection.isActive) + #expect(viewModel.state.messageSelection.selectedEventIDs == ["$1"]) } @Test - func selectIsIgnoredWhenFlagIsOff() { + func forwardMenuActionForwardsTheMessageWhenFlagIsOff() async throws { let items = [TextRoomTimelineItem(eventID: "$1")] let viewModel = makeViewModel(timelineController: TimelineControllerMock(.init(timelineItems: items))) - viewModel.process(viewAction: .handleTimelineItemMenuAction(itemID: items[0].id, action: .selectMessages)) - viewModel.process(viewAction: .startSelection(itemID: items[0].id)) + let deferred = deferFulfillment(viewModel.actions) { action in + if case .displayMessageForwarding(let forwardingPayload) = action { + return forwardingPayload.ids == [items[0].id] && forwardingPayload.contents.count == 1 + } + return false + } + viewModel.process(viewAction: .handleTimelineItemMenuAction(itemID: items[0].id, action: .forward(itemID: items[0].id))) + try await deferred.fulfill() - #expect(!viewModel.state.selection.isActive) + #expect(!viewModel.state.messageSelection.isActive) + } + + @Test + func forwardMenuActionStartsSelectionInThePinnedTimeline() { + let items = [TextRoomTimelineItem(eventID: "$1")] + let viewModel = makeSelectionViewModel(timelineController: TimelineControllerMock(.init(timelineKind: .pinned, timelineItems: items))) + + viewModel.process(viewAction: .handleTimelineItemMenuAction(itemID: items[0].id, action: .forward(itemID: items[0].id))) + + #expect(viewModel.state.messageSelection.selectedEventIDs == ["$1"]) + } + + @Test + func forwardMenuActionForwardsTheMessageInTheMediaTimeline() async throws { + let items = [TextRoomTimelineItem(eventID: "$1")] + let viewModel = makeSelectionViewModel(timelineController: TimelineControllerMock(.init(timelineKind: .media(.mediaFilesScreen), timelineItems: items))) + + let deferred = deferFulfillment(viewModel.actions) { action in + if case .displayMessageForwarding(let forwardingPayload) = action { + return forwardingPayload.ids == [items[0].id] + } + return false + } + viewModel.process(viewAction: .handleTimelineItemMenuAction(itemID: items[0].id, action: .forward(itemID: items[0].id))) + try await deferred.fulfill() + + #expect(!viewModel.state.messageSelection.isActive) + } + + @Test + func forwardSelectionUsesTimelineOrder() async throws { + let items = (1...4).map { TextRoomTimelineItem(eventID: "$\($0)") } + let viewModel = makeSelectionViewModel(items: items) + + // Select the last message first, then the second one. + startSelection(items[3].id, in: viewModel) + viewModel.process(viewAction: .toggleMessageSelection(itemID: items[1].id)) + + let deferred = deferFulfillment(viewModel.actions) { action in + if case .displayMessageForwarding(let forwardingPayload) = action { + return forwardingPayload.ids == [items[1].id, items[3].id] && forwardingPayload.contents.count == 2 + } + return false + } + viewModel.process(viewAction: .forwardMessageSelection) + try await deferred.fulfill() + + // The selection is kept until the forwarding completes, so cancelling the picker returns to it. + #expect(viewModel.state.messageSelection.selectedEventIDs == ["$2", "$4"]) + } + + @Test + func forwardingCompletionClearsTheSelection() { + let items = [TextRoomTimelineItem(eventID: "$1"), TextRoomTimelineItem(eventID: "$2")] + let viewModel = makeSelectionViewModel(items: items) + + startSelection(items[0].id, in: viewModel) + viewModel.process(viewAction: .toggleMessageSelection(itemID: items[1].id)) + viewModel.clearMessageSelection() + + #expect(!viewModel.state.messageSelection.isActive) } @Test @@ -724,33 +791,33 @@ final class TimelineViewModelTests { let viewModel = makeSelectionViewModel(items: items) // Toggling before entering the selection does nothing. - viewModel.process(viewAction: .toggleSelection(itemID: items[0].id)) - #expect(!viewModel.state.selection.isActive) + viewModel.process(viewAction: .toggleMessageSelection(itemID: items[0].id)) + #expect(!viewModel.state.messageSelection.isActive) - viewModel.process(viewAction: .startSelection(itemID: items[0].id)) - viewModel.process(viewAction: .toggleSelection(itemID: items[1].id)) - #expect(viewModel.state.selection.selectedEventIDs == ["$1", "$2"]) - #expect(viewModel.state.selection.count == 2) + startSelection(items[0].id, in: viewModel) + viewModel.process(viewAction: .toggleMessageSelection(itemID: items[1].id)) + #expect(viewModel.state.messageSelection.selectedEventIDs == ["$1", "$2"]) + #expect(viewModel.state.messageSelection.count == 2) - viewModel.process(viewAction: .toggleSelection(itemID: items[0].id)) - #expect(viewModel.state.selection.selectedEventIDs == ["$2"]) + viewModel.process(viewAction: .toggleMessageSelection(itemID: items[0].id)) + #expect(viewModel.state.messageSelection.selectedEventIDs == ["$2"]) // Deselecting the last item ends the selection. - viewModel.process(viewAction: .toggleSelection(itemID: items[1].id)) - #expect(!viewModel.state.selection.isActive) + viewModel.process(viewAction: .toggleMessageSelection(itemID: items[1].id)) + #expect(!viewModel.state.messageSelection.isActive) } @Test - func clearSelectionEndsTheSelection() { + func clearMessageSelectionEndsTheSelection() { let items = [TextRoomTimelineItem(eventID: "$1"), TextRoomTimelineItem(eventID: "$2")] let viewModel = makeSelectionViewModel(items: items) - viewModel.process(viewAction: .startSelection(itemID: items[0].id)) - viewModel.process(viewAction: .toggleSelection(itemID: items[1].id)) - viewModel.process(viewAction: .clearSelection) + startSelection(items[0].id, in: viewModel) + viewModel.process(viewAction: .toggleMessageSelection(itemID: items[1].id)) + viewModel.process(viewAction: .clearMessageSelection) - #expect(!viewModel.state.selection.isActive) - #expect(viewModel.state.selection.selectedEventIDs.isEmpty) + #expect(!viewModel.state.messageSelection.isActive) + #expect(viewModel.state.messageSelection.selectedEventIDs.isEmpty) } @Test @@ -777,33 +844,34 @@ final class TimelineViewModelTests { canBeRepliedTo: true, sender: .init(id: "@bob:matrix.org"), content: .init(body: "Sending")) - let nonSelectableItems: [RoomTimelineItemProtocol] = [state, redacted, localEcho] + let poll = PollRoomTimelineItem.mock(poll: .emptyDisclosed) + let nonSelectableItems: [RoomTimelineItemProtocol] = [state, redacted, localEcho, poll] let viewModel = makeSelectionViewModel(items: [text] + nonSelectableItems) - viewModel.process(viewAction: .startSelection(itemID: state.id)) - #expect(!viewModel.state.selection.isActive) + startSelection(state.id, in: viewModel) + #expect(!viewModel.state.messageSelection.isActive) - viewModel.process(viewAction: .startSelection(itemID: text.id)) + startSelection(text.id, in: viewModel) for item in nonSelectableItems { - viewModel.process(viewAction: .toggleSelection(itemID: item.id)) + viewModel.process(viewAction: .toggleMessageSelection(itemID: item.id)) } - #expect(viewModel.state.selection.selectedEventIDs == ["$1"]) + #expect(viewModel.state.messageSelection.selectedEventIDs == ["$1"]) } @Test func selectionIsLimited() { - let items = (0...TimelineSelectionState.limit).map { TextRoomTimelineItem(eventID: "$\($0)") } + let items = (0...TimelineMessageSelectionState.limit).map { TextRoomTimelineItem(eventID: "$\($0)") } let userIndicatorController = UserIndicatorControllerMock() let viewModel = makeSelectionViewModel(items: items, userIndicatorController: userIndicatorController) - viewModel.process(viewAction: .startSelection(itemID: items[0].id)) + startSelection(items[0].id, in: viewModel) for item in items.dropFirst() { - viewModel.process(viewAction: .toggleSelection(itemID: item.id)) + viewModel.process(viewAction: .toggleMessageSelection(itemID: item.id)) } - #expect(viewModel.state.selection.count == TimelineSelectionState.limit) - #expect(viewModel.state.selection.isAtLimit) - #expect(!viewModel.state.selection.contains(items.last?.id.eventID)) + #expect(viewModel.state.messageSelection.count == TimelineMessageSelectionState.limit) + #expect(viewModel.state.messageSelection.isAtLimit) + #expect(!viewModel.state.messageSelection.contains(items.last?.id.eventID)) #expect(userIndicatorController.submitIndicatorDelayCallsCount == 1) } @@ -813,9 +881,9 @@ final class TimelineViewModelTests { let timelineController = TimelineControllerMock(.init(timelineItems: items)) let viewModel = makeSelectionViewModel(timelineController: timelineController) - viewModel.process(viewAction: .startSelection(itemID: items[0].id)) - viewModel.process(viewAction: .toggleSelection(itemID: items[1].id)) - #expect(viewModel.state.selection.count == 2) + startSelection(items[0].id, in: viewModel) + viewModel.process(viewAction: .toggleMessageSelection(itemID: items[1].id)) + #expect(viewModel.state.messageSelection.count == 2) // The first message gets redacted by someone else. let redacted = RedactedRoomTimelineItem(id: items[0].id, @@ -825,7 +893,7 @@ final class TimelineViewModelTests { isEditable: false, canBeRepliedTo: false, sender: .init(id: "@alice:matrix.org")) - let deferred = deferFulfillment(viewModel.context.$viewState) { $0.selection.selectedEventIDs == ["$2"] } + let deferred = deferFulfillment(viewModel.context.$viewState) { $0.messageSelection.selectedEventIDs == ["$2"] } timelineController.callbacks.send(.updatedTimelineItems(timelineItems: [redacted, items[1]], isSwitchingTimelines: false)) try await deferred.fulfill() } @@ -836,10 +904,10 @@ final class TimelineViewModelTests { let timelineController = TimelineControllerMock(.init(timelineItems: items)) let viewModel = makeSelectionViewModel(timelineController: timelineController) - viewModel.process(viewAction: .startSelection(itemID: items[0].id)) - #expect(viewModel.state.selection.isActive) + startSelection(items[0].id, in: viewModel) + #expect(viewModel.state.messageSelection.isActive) - let deferred = deferFulfillment(viewModel.context.$viewState) { !$0.selection.isActive } + let deferred = deferFulfillment(viewModel.context.$viewState) { !$0.messageSelection.isActive } timelineController.callbacks.send(.updatedTimelineItems(timelineItems: items, isSwitchingTimelines: true)) try await deferred.fulfill() } @@ -851,10 +919,10 @@ final class TimelineViewModelTests { appSettings.messageMultiSelectEnabled = true let viewModel = makeViewModel(timelineController: TimelineControllerMock(.init(timelineItems: items)), appSettings: appSettings) - viewModel.process(viewAction: .startSelection(itemID: items[0].id)) - #expect(viewModel.state.selection.isActive) + startSelection(items[0].id, in: viewModel) + #expect(viewModel.state.messageSelection.isActive) - let deferred = deferFulfillment(viewModel.context.$viewState) { !$0.selection.isEnabled && !$0.selection.isActive } + let deferred = deferFulfillment(viewModel.context.$viewState) { !$0.messageSelection.isEnabled && !$0.messageSelection.isActive } appSettings.messageMultiSelectEnabled = false try await deferred.fulfill() } @@ -880,6 +948,11 @@ final class TimelineViewModelTests { timelineControllerFactory: TimelineControllerFactoryMock(.init())) } + /// Starts a selection the way the app does, by choosing Forward on the item. + private func startSelection(_ itemID: TimelineItemIdentifier, in viewModel: TimelineViewModel) { + viewModel.process(viewAction: .handleTimelineItemMenuAction(itemID: itemID, action: .forward(itemID: itemID))) + } + private func makeSelectionViewModel(items: [RoomTimelineItemProtocol], userIndicatorController: UserIndicatorControllerProtocol = UserIndicatorControllerMock()) -> TimelineViewModel { makeSelectionViewModel(timelineController: TimelineControllerMock(.init(timelineItems: items)),