From f040964d3f9295859ea32889c2b19ed4d22b3d16 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Wed, 27 May 2026 13:55:35 +0300 Subject: [PATCH] Allow the user to forward the same message to multiple rooms at once This will send the item to the rooms in order they were chosen and only deep link into the target room if single, otherwise it will just dismiss the forwarding screen after sending. --- .../PinnedEventsTimelineFlowCoordinator.swift | 6 +-- .../RoomFlowCoordinator.swift | 18 ++++++--- .../MessageForwardingScreenCoordinator.swift | 6 +-- .../MessageForwardingScreenModels.swift | 4 +- .../MessageForwardingScreenViewModel.swift | 37 ++++++++++++------- .../View/MessageForwardingScreen.swift | 4 +- ...essageForwardingScreenViewModelTests.swift | 8 ++-- 7 files changed, 51 insertions(+), 32 deletions(-) diff --git a/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift index 4e09cabc4..a144f6df4 100644 --- a/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/PinnedEventsTimelineFlowCoordinator.swift @@ -12,7 +12,7 @@ import Foundation enum PinnedEventsTimelineFlowCoordinatorAction { case finished case displayUser(userID: String) - case forwardedMessageToRoom(roomID: String) + case forwardedMessageToRooms(roomIDs: [String]) case displayRoomScreenWithFocussedPin(eventID: String, threadRootEventID: String?) } @@ -148,9 +148,9 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol { switch action { case .dismiss: navigationStackCoordinator.setSheetCoordinator(nil) - case .sent(let roomID): + case .sent(let roomIDs): navigationStackCoordinator.setSheetCoordinator(nil) - actionsSubject.send(.forwardedMessageToRoom(roomID: roomID)) + actionsSubject.send(.forwardedMessageToRooms(roomIDs: roomIDs)) } } .store(in: &cancellables) diff --git a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift index c80508e17..dd12ea30d 100644 --- a/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift +++ b/ElementX/Sources/FlowCoordinators/RoomFlowCoordinator.swift @@ -1282,10 +1282,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { switch action { case .dismiss: navigationStackCoordinator.setSheetCoordinator(nil) - case .sent(let roomID): + case .sent(let roomIDs): navigationStackCoordinator.setSheetCoordinator(nil) - // Timelines are cached - the local echo will be visible when fetching the room by its ID. - stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: [], entryPoint: .room)) + processPostMessageForwardingTo(rooms: roomIDs) } } .store(in: &cancellables) @@ -1524,6 +1523,15 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { } } + private func processPostMessageForwardingTo(rooms roomIDs: [String]) { + // Only open forwarding target room if single. + guard roomIDs.count == 1, let roomID = roomIDs.last else { + return + } + + stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: [], entryPoint: .room)) + } + // MARK: - Other flows private func startChildFlow(for roomID: String, via: [String], entryPoint: RoomFlowCoordinatorEntryPoint) { @@ -1582,9 +1590,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol { case .displayUser(let userID): navigationStackCoordinator.setSheetCoordinator(nil) stateMachine.tryEvent(.startMembersFlow(entryPoint: .roomMember(userID: userID))) - case .forwardedMessageToRoom(let roomID): + case .forwardedMessageToRooms(let roomIDs): navigationStackCoordinator.setSheetCoordinator(nil) - stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: [], entryPoint: .room)) + processPostMessageForwardingTo(rooms: roomIDs) case .displayRoomScreenWithFocussedPin(let eventID, let threadRootEventID): navigationStackCoordinator.setSheetCoordinator(nil) if let threadRootEventID { diff --git a/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenCoordinator.swift b/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenCoordinator.swift index 6c4ee7982..4427c7075 100644 --- a/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenCoordinator.swift +++ b/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenCoordinator.swift @@ -18,7 +18,7 @@ struct MessageForwardingScreenCoordinatorParameters { enum MessageForwardingScreenCoordinatorAction { case dismiss - case sent(roomID: String) + case sent(roomIDs: [String]) } final class MessageForwardingScreenCoordinator: CoordinatorProtocol { @@ -42,8 +42,8 @@ final class MessageForwardingScreenCoordinator: CoordinatorProtocol { switch action { case .dismiss: self?.actionsSubject.send(.dismiss) - case .sent(let roomID): - self?.actionsSubject.send(.sent(roomID: roomID)) + case .sent(let roomIDs): + self?.actionsSubject.send(.sent(roomIDs: roomIDs)) } } .store(in: &cancellables) diff --git a/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenModels.swift b/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenModels.swift index 2a0488cc0..42d20c8ff 100644 --- a/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenModels.swift +++ b/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenModels.swift @@ -11,12 +11,12 @@ import MatrixRustSDK enum MessageForwardingScreenViewModelAction { case dismiss - case sent(roomID: String) + case sent(roomIDs: [String]) } struct MessageForwardingScreenViewState: BindableState { var rooms: [MessageForwardingRoom] = [] - var selectedRoomID: String? + var selectedRoomIDs = [String]() var bindings = MessageForwardingScreenViewStateBindings() } diff --git a/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenViewModel.swift b/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenViewModel.swift index 455f38d84..f59f5eee4 100644 --- a/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenViewModel.swift +++ b/ElementX/Sources/Screens/MessageForwardingScreen/MessageForwardingScreenViewModel.swift @@ -63,7 +63,11 @@ class MessageForwardingScreenViewModel: MessageForwardingScreenViewModelType, Me case .send: Task { await forward() } case .selectRoom(let roomID): - state.selectedRoomID = roomID + if let index = state.selectedRoomIDs.firstIndex(of: roomID) { + state.selectedRoomIDs.remove(at: index) + } else { + state.selectedRoomIDs.append(roomID) + } case .reachedTop: updateVisibleRange(edge: .top) case .reachedBottom: @@ -114,23 +118,30 @@ class MessageForwardingScreenViewModel: MessageForwardingScreenViewModelType, Me } private func forward() async { - guard let roomID = state.selectedRoomID else { + guard !state.selectedRoomIDs.isEmpty else { fatalError() } - 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)) - return + var succeededRoomIdentifiers = [String]() + + 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)) + 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 + } + + succeededRoomIdentifiers.append(roomID) } - 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)) - return + if !succeededRoomIdentifiers.isEmpty { + actionsSubject.send(.sent(roomIDs: succeededRoomIdentifiers)) } - - // Timelines are cached - the local echo will be visible when fetching the room by its ID. - actionsSubject.send(.sent(roomID: roomID)) } } diff --git a/ElementX/Sources/Screens/MessageForwardingScreen/View/MessageForwardingScreen.swift b/ElementX/Sources/Screens/MessageForwardingScreen/View/MessageForwardingScreen.swift index 2acb34f71..3d28a9145 100644 --- a/ElementX/Sources/Screens/MessageForwardingScreen/View/MessageForwardingScreen.swift +++ b/ElementX/Sources/Screens/MessageForwardingScreen/View/MessageForwardingScreen.swift @@ -17,7 +17,7 @@ struct MessageForwardingScreen: View { Section { ForEach(context.viewState.rooms) { room in MessageForwardingListRow(room: room, - isSelected: context.viewState.selectedRoomID == room.id, + isSelected: context.viewState.selectedRoomIDs.contains(room.id), context: context) } // Replace these with ScrollView's `scrollPosition` when dropping iOS 16. @@ -46,7 +46,7 @@ struct MessageForwardingScreen: View { Button(L10n.actionSend) { context.send(viewAction: .send) } - .disabled(context.viewState.selectedRoomID == nil) + .disabled(context.viewState.selectedRoomIDs.isEmpty) } } .searchController(query: $context.searchQuery, showsCancelButton: false) diff --git a/UnitTests/Sources/MessageForwardingScreenViewModelTests.swift b/UnitTests/Sources/MessageForwardingScreenViewModelTests.swift index 3414b885b..d568b6654 100644 --- a/UnitTests/Sources/MessageForwardingScreenViewModelTests.swift +++ b/UnitTests/Sources/MessageForwardingScreenViewModelTests.swift @@ -37,7 +37,7 @@ struct MessageForwardingScreenViewModelTests { @Test mutating func roomSelection() { context.send(viewAction: .selectRoom(roomID: "2")) - #expect(context.viewState.selectedRoomID == "2") + #expect(context.viewState.selectedRoomIDs == ["2"]) } @Test @@ -54,12 +54,12 @@ struct MessageForwardingScreenViewModelTests { @Test mutating func forwarding() async throws { context.send(viewAction: .selectRoom(roomID: "2")) - #expect(context.viewState.selectedRoomID == "2") + #expect(context.viewState.selectedRoomIDs == ["2"]) let deferred = deferFulfillment(viewModel.actions) { action in switch action { - case .sent(let roomID): - return roomID == "2" + case .sent(let roomIDs): + return roomIDs == ["2"] default: return false }