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.
This commit is contained in:
Mauro Romito
2026-10-01 15:45:29 +02:00
committed by Mauro
parent cdb5d4ee31
commit 8ddd161625
15 changed files with 49 additions and 15 deletions
@@ -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))
}
@@ -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))
}
@@ -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<String> = []
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 {
@@ -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.
@@ -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
}()
@@ -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<String>) -> 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
}
}
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:240161bc2235106fd4e74d0c25b3d82fa5bf2380425441a2db101693429afbfe
size 16087
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:22abae64ed63c2ac1f7d317524a75fe9114f62b299bbf6f17973e6a593c881b5
size 16241
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:338fbbe237edd16a6720c7f66544fc79db607e114909a491d2935cc397eac40c
size 10566
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6d2e8a5312d7b2ecce636347b369d9802d93242583f745887f70baefe6a5e5e1
size 10642
@@ -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