From 8ddd161625b2a2fcd84abc33c71dfb10663a813e Mon Sep 17 00:00:00 2001 From: Mauro Romito Date: Thu, 1 Oct 2026 15:45:29 +0200 Subject: [PATCH] Keep message selection active when every message is unselected Unselecting the last message used to end the selection, closing it without the user asking. Selection mode is now its own state: Forward starts it and it only ends with the close button, once the messages are forwarded, when the timeline changes or the feature is turned off. With nothing selected the forward button stays visible but disabled. --- .../View/PinnedEventsTimelineScreen.swift | 1 + .../Screens/RoomScreen/View/RoomScreen.swift | 1 + .../Screens/Timeline/TimelineModels.swift | 13 +++++++----- .../Screens/Timeline/TimelineViewModel.swift | 7 ++++--- .../Style/TimelineItemBubbledStylerView.swift | 1 + .../TimelineMessageSelectionActionBar.swift | 20 ++++++++++++++----- ...nActionBar.Nothing-selected-iPad-en-GB.png | 3 +++ ...ActionBar.Nothing-selected-iPad-pseudo.png | 3 +++ ...ctionBar.Nothing-selected-iPhone-en-GB.png | 3 +++ ...tionBar.Nothing-selected-iPhone-pseudo.png | 3 +++ ...electionActionBar.Selected-iPad-en-GB.png} | 0 ...lectionActionBar.Selected-iPad-pseudo.png} | 0 ...ectionActionBar.Selected-iPhone-en-GB.png} | 0 ...ctionActionBar.Selected-iPhone-pseudo.png} | 0 .../Sources/TimelineViewModelTests.swift | 9 +++++++-- 15 files changed, 49 insertions(+), 15 deletions(-) create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.Nothing-selected-iPad-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.Nothing-selected-iPad-pseudo.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.Nothing-selected-iPhone-en-GB.png create mode 100644 PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.Nothing-selected-iPhone-pseudo.png rename PreviewTests/Sources/__Snapshots__/PreviewTests/{timelineMessageSelectionActionBar.iPad-en-GB-0.png => timelineMessageSelectionActionBar.Selected-iPad-en-GB.png} (100%) rename PreviewTests/Sources/__Snapshots__/PreviewTests/{timelineMessageSelectionActionBar.iPad-pseudo-0.png => timelineMessageSelectionActionBar.Selected-iPad-pseudo.png} (100%) rename PreviewTests/Sources/__Snapshots__/PreviewTests/{timelineMessageSelectionActionBar.iPhone-en-GB-0.png => timelineMessageSelectionActionBar.Selected-iPhone-en-GB.png} (100%) rename PreviewTests/Sources/__Snapshots__/PreviewTests/{timelineMessageSelectionActionBar.iPhone-pseudo-0.png => timelineMessageSelectionActionBar.Selected-iPhone-pseudo.png} (100%) diff --git a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift index 1baddfff9..095afb438 100644 --- a/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift +++ b/ElementX/Sources/Screens/PinnedEventsTimelineScreen/View/PinnedEventsTimelineScreen.swift @@ -122,6 +122,7 @@ struct PinnedEventsTimelineScreen_Previews: PreviewProvider, TestablePreview { timelineControllerFactory: TimelineControllerFactoryMock(.init())) if isSelecting { + timelineViewModel.state.messageSelection.isActive = true timelineViewModel.state.messageSelection.selectedEventIDs = Set(eventIDs.prefix(2)) } diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 731521f4a..8b4d90953 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -413,6 +413,7 @@ struct RoomScreen_Previews: PreviewProvider, TestablePreview { if isSelecting { let eventIDs = timelineController.timelineItems.compactMap { ($0 as? EventBasedTimelineItemProtocol)?.id.eventID } + timelineViewModel.state.messageSelection.isActive = true timelineViewModel.state.messageSelection.selectedEventIDs = Set(eventIDs.prefix(2)) } diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index 240bfc7c1..e8d6c2119 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -205,17 +205,15 @@ struct TimelineViewStateBindings { var textToBeTranslated: String? } -/// The state of the multi-selection of messages, active as soon as an item is selected. +/// The state of the multi-selection of messages. struct TimelineMessageSelectionState: Equatable { static let limit = 10 var isEnabled = false + /// Stays on when every message is unselected, it only ends when closed or once the messages are forwarded. + var isActive = false var selectedEventIDs: Set = [] - var isActive: Bool { - !selectedEventIDs.isEmpty - } - var count: Int { selectedEventIDs.count } @@ -228,6 +226,11 @@ struct TimelineMessageSelectionState: Equatable { guard let eventID else { return false } return selectedEventIDs.contains(eventID) } + + mutating func end() { + isActive = false + selectedEventIDs.removeAll() + } } struct TimelineItemActionMenuInfo: Equatable, Identifiable { diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index 01c61790d..30f837a5c 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -1200,7 +1200,7 @@ extension TimelineViewModel { .sink { [weak self] isEnabled in self?.state.messageSelection.isEnabled = isEnabled if !isEnabled { - self?.state.messageSelection.selectedEventIDs.removeAll() + self?.state.messageSelection.end() } } .store(in: &cancellables) @@ -1218,6 +1218,7 @@ extension TimelineViewModel { return } + state.messageSelection.isActive = true state.messageSelection.selectedEventIDs.insert(eventID) } @@ -1239,7 +1240,7 @@ extension TimelineViewModel { guard state.messageSelection.isActive else { return } if isSwitchingTimelines { - state.messageSelection.selectedEventIDs.removeAll() + state.messageSelection.end() return } @@ -1249,7 +1250,7 @@ extension TimelineViewModel { /// Ends the selection, either from the close button or once the selected messages have been forwarded. func clearMessageSelection() { - state.messageSelection.selectedEventIDs.removeAll() + state.messageSelection.end() } /// Forwards the selection in timeline order, which is unrelated to the order the messages were selected in. diff --git a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift index 7b1347c3a..5604c3b44 100644 --- a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift @@ -476,6 +476,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview emojiProvider: EmojiProvider(userSettings: userSettings), linkMetadataProvider: LinkMetadataProvider(), timelineControllerFactory: TimelineControllerFactoryMock(.init())) + viewModel.state.messageSelection.isActive = true 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 index c531f2ff8..98bad2b29 100644 --- a/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineMessageSelectionActionBar.swift +++ b/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineMessageSelectionActionBar.swift @@ -19,6 +19,7 @@ struct TimelineMessageSelectionActionBar: View { Label(L10n.actionForwardMessages(context.viewState.messageSelection.count), icon: \.forward) } .buttonStyle(.compound(.primary)) + .disabled(context.viewState.messageSelection.selectedEventIDs.isEmpty) .padding(16) .background(Color.compound.bgCanvasDefault.ignoresSafeArea()) } @@ -27,14 +28,23 @@ struct TimelineMessageSelectionActionBar: View { // 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 let viewModel = makeViewModel(selectedEventIDs: ["$1", "$2", "$3"]) + static let emptyViewModel = makeViewModel(selectedEventIDs: []) static var previews: some View { TimelineMessageSelectionActionBar(context: viewModel.context) .previewLayout(.sizeThatFits) + .previewDisplayName("Selected") + + TimelineMessageSelectionActionBar(context: emptyViewModel.context) + .previewLayout(.sizeThatFits) + .previewDisplayName("Nothing selected") + } + + static func makeViewModel(selectedEventIDs: Set) -> TimelineViewModel { + let viewModel = TimelineViewModel.mock() // A fresh instance so the shared mock isn't left selecting. + viewModel.state.messageSelection.isActive = true + viewModel.state.messageSelection.selectedEventIDs = selectedEventIDs + return viewModel } } diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.Nothing-selected-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.Nothing-selected-iPad-en-GB.png new file mode 100644 index 000000000..d6ea898fd --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.Nothing-selected-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:240161bc2235106fd4e74d0c25b3d82fa5bf2380425441a2db101693429afbfe +size 16087 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.Nothing-selected-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.Nothing-selected-iPad-pseudo.png new file mode 100644 index 000000000..097e6ffc7 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.Nothing-selected-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:22abae64ed63c2ac1f7d317524a75fe9114f62b299bbf6f17973e6a593c881b5 +size 16241 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.Nothing-selected-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.Nothing-selected-iPhone-en-GB.png new file mode 100644 index 000000000..5f81524c7 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.Nothing-selected-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:338fbbe237edd16a6720c7f66544fc79db607e114909a491d2935cc397eac40c +size 10566 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.Nothing-selected-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.Nothing-selected-iPhone-pseudo.png new file mode 100644 index 000000000..ccd6635b2 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.Nothing-selected-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d2e8a5312d7b2ecce636347b369d9802d93242583f745887f70baefe6a5e5e1 +size 10642 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.iPad-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.Selected-iPad-en-GB.png similarity index 100% rename from PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.iPad-en-GB-0.png rename to PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.Selected-iPad-en-GB.png diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.iPad-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.Selected-iPad-pseudo.png similarity index 100% rename from PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.iPad-pseudo-0.png rename to PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.Selected-iPad-pseudo.png diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.iPhone-en-GB-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.Selected-iPhone-en-GB.png similarity index 100% rename from PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.iPhone-en-GB-0.png rename to PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.Selected-iPhone-en-GB.png diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.iPhone-pseudo-0.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.Selected-iPhone-pseudo.png similarity index 100% rename from PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.iPhone-pseudo-0.png rename to PreviewTests/Sources/__Snapshots__/PreviewTests/timelineMessageSelectionActionBar.Selected-iPhone-pseudo.png diff --git a/UnitTests/Sources/TimelineViewModelTests.swift b/UnitTests/Sources/TimelineViewModelTests.swift index d5977b8f2..b8a0bd84b 100644 --- a/UnitTests/Sources/TimelineViewModelTests.swift +++ b/UnitTests/Sources/TimelineViewModelTests.swift @@ -781,9 +781,14 @@ final class TimelineViewModelTests { viewModel.process(viewAction: .toggleMessageSelection(itemID: items[0].id)) #expect(viewModel.state.messageSelection.selectedEventIDs == ["$2"]) - // Deselecting the last item ends the selection. + // Deselecting the last item keeps the selection going, with nothing selected. viewModel.process(viewAction: .toggleMessageSelection(itemID: items[1].id)) - #expect(!viewModel.state.messageSelection.isActive) + #expect(viewModel.state.messageSelection.isActive) + #expect(viewModel.state.messageSelection.selectedEventIDs.isEmpty) + + // Selecting again still works. + viewModel.process(viewAction: .toggleMessageSelection(itemID: items[0].id)) + #expect(viewModel.state.messageSelection.selectedEventIDs == ["$1"]) } @Test