diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index e033fa9c6..eac022c4f 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -543,6 +543,7 @@ 5710AAB27D5D866292C1FE06 /* SessionVerificationScreenModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF848B41DAF1066F3054D4A1 /* SessionVerificationScreenModels.swift */; }; 572474C7CA4B03FF0B5DF548 /* ChatsTabFlowCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C782FCBBCC9A0CD30453C50 /* ChatsTabFlowCoordinator.swift */; }; 5732395A4F71F51F9C754C5A /* ElementCallService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33AE897D86784CCA5E4E9227 /* ElementCallService.swift */; }; + 577B71F0BF824801C0FFF3CE /* CollapsedInPlaceModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A38B60F40D47397CB3B3C9C /* CollapsedInPlaceModifier.swift */; }; 5780E444F405AA1304E1C23E /* DeveloperOptionsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38E521D6C2BF8DF0DFB35146 /* DeveloperOptionsScreen.swift */; }; 5787613A79C6D553DEA28C5F /* RoomThreadListScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F91AC45CDDF85E946391CA5C /* RoomThreadListScreenViewModel.swift */; }; 5790B18A7953860715B89A8B /* InverseMask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BA4D3DFEA16CDD3FE99064 /* InverseMask.swift */; }; @@ -1584,6 +1585,7 @@ 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 */; }; @@ -1999,6 +2001,7 @@ 29690F342FCBEE48E17129C8 /* ScanStateMediaEventsTimelineViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScanStateMediaEventsTimelineViews.swift; sourceTree = ""; }; 29A953B6C0C431DBF4DD00B4 /* RoomSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomSummary.swift; sourceTree = ""; }; 2A2BB38DF61F5100B8723112 /* TimelineMediaPreviewModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineMediaPreviewModels.swift; sourceTree = ""; }; + 2A38B60F40D47397CB3B3C9C /* CollapsedInPlaceModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsedInPlaceModifier.swift; sourceTree = ""; }; 2A5C6FBF97B6EED3D4FA5EFF /* AttributedStringBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedStringBuilder.swift; sourceTree = ""; }; 2A95C9B8299A36A6495DECA6 /* TracingHook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TracingHook.swift; sourceTree = ""; }; 2ADF12A50186B75C68017B61 /* DeclineAndBlockScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeclineAndBlockScreenViewModelTests.swift; sourceTree = ""; }; @@ -3104,6 +3107,7 @@ 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 = ""; }; E8A1F98AE670377B20679FF5 /* MediaPlayerProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaPlayerProvider.swift; sourceTree = ""; }; @@ -3459,6 +3463,7 @@ isa = PBXGroup; children = ( 76A46ABD27628CB5FC402541 /* Backports.swift */, + 2A38B60F40D47397CB3B3C9C /* CollapsedInPlaceModifier.swift */, F03AEAA2F66E796C365EFD58 /* ElementNavigationStack.swift */, 55BA4D3DFEA16CDD3FE99064 /* InverseMask.swift */, 5DB56ABD78E60920ACE43087 /* PreviewScrollView.swift */, @@ -3657,6 +3662,7 @@ 72614BFF35B8394C6E13F55A /* TimelineItemStatusView.swift */, 54AD70D6E03D2031AE1B5A52 /* TimelineReactionsView.swift */, 33035418BB35754232985871 /* TimelineReadReceiptsView.swift */, + E7C3B926E1BBC7569AC6C682 /* TimelineSelectionToolbar.swift */, ); path = Supplementary; sourceTree = ""; @@ -8682,6 +8688,7 @@ DDFBDEE1DC32BDD5488F898C /* ClientProxyMock.swift in Sources */, 24BDDD09A90B8BFE3793F3AA /* ClientProxyProtocol.swift in Sources */, 41C5DA0C06F30311A221E85B /* ClientSDKMock.swift in Sources */, + 577B71F0BF824801C0FFF3CE /* CollapsedInPlaceModifier.swift in Sources */, 0C797CD650DFD2876BEC5173 /* CollapsibleReactionLayout.swift in Sources */, 78A3D84BA47DAC69B4D0A34C /* CollapsibleRoomTimelineView.swift in Sources */, 0DC815CA24E1BD7F408F37D3 /* CollapsibleTimelineItem.swift in Sources */, @@ -9515,6 +9522,7 @@ 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/Sources/Application/Settings/AppSettings.swift b/ElementX/Sources/Application/Settings/AppSettings.swift index cbd955970..016657cd0 100644 --- a/ElementX/Sources/Application/Settings/AppSettings.swift +++ b/ElementX/Sources/Application/Settings/AppSettings.swift @@ -423,6 +423,9 @@ final nonisolated class AppSettings: @unchecked Sendable { @UserPreference(defaultValue: false) var threadsEnabled: Bool + @UserPreference(defaultValue: false) + var messageMultiSelectEnabled: Bool + @UserPreference(defaultValue: ProcessInfo().isiOSAppOnMac) var globalSearchEnabled: Bool diff --git a/ElementX/Sources/Other/SwiftUI/CollapsedInPlaceModifier.swift b/ElementX/Sources/Other/SwiftUI/CollapsedInPlaceModifier.swift new file mode 100644 index 000000000..5751ba5b2 --- /dev/null +++ b/ElementX/Sources/Other/SwiftUI/CollapsedInPlaceModifier.swift @@ -0,0 +1,28 @@ +// +// 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 + +/// Collapses the view to zero height while keeping it in the hierarchy, so that its state is +/// preserved and no `onAppear`/`onDisappear` side effects fire, unlike an `if` statement. +private struct CollapsedInPlaceModifier: ViewModifier { + let isCollapsed: Bool + + func body(content: Content) -> some View { + content + .frame(height: isCollapsed ? 0 : nil, alignment: .top) + .opacity(isCollapsed ? 0 : 1) + .allowsHitTesting(!isCollapsed) + .accessibilityHidden(isCollapsed) + } +} + +extension View { + func collapsedInPlace(_ isCollapsed: Bool) -> some View { + modifier(CollapsedInPlaceModifier(isCollapsed: isCollapsed)) + } +} diff --git a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift index 9aac6837d..8b43fd3c7 100644 --- a/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift +++ b/ElementX/Sources/Screens/FilePreviewScreen/TimelineMediaPreviewViewModel.swift @@ -231,6 +231,7 @@ 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/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index 92e8fca2a..8c2671c0b 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -110,8 +110,10 @@ struct RoomScreen: View { .environment(\.timelineContext, timelineContext) // Make sure the reply header honours the hideTimelineMedia setting too. .environment(\.shouldAutomaticallyLoadImages, !timelineContext.viewState.hideTimelineMedia) + .collapsedInPlace(isSelectionActive) } } + .navigationBarBackButtonHidden(isSelectionActive) .toolbarRole(RoomHeaderView.toolbarRole) .navigationTitle(L10n.screenRoomTitle) // Hidden but used for back button text. .navigationBarTitleDisplayMode(.inline) @@ -285,8 +287,23 @@ struct RoomScreen: View { } } + private var isSelectionActive: Bool { + timelineContext.viewState.selection.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) { @@ -329,6 +346,7 @@ struct RoomScreen_Previews: PreviewProvider, TestablePreview { static let viewModels = makeViewModels() static let readOnlyViewModels = makeViewModels(canSendMessage: false) static let tombstonedViewModels = makeViewModels(hasSuccessor: true) + static let selectingViewModels = makeViewModels(isSelecting: true) static let composerViewModel = ComposerToolbarViewModel.mock() static var previews: some View { @@ -354,9 +372,17 @@ struct RoomScreen_Previews: PreviewProvider, TestablePreview { } .previewDisplayName("Tombstoned") .snapshotPreferences(expect: tombstonedViewModels.room.context.$viewState.map(\.hasSuccessor)) + + ElementNavigationStack { + RoomScreen(context: selectingViewModels.room.context, + timelineContext: selectingViewModels.timeline.context, + composerToolbar: ComposerToolbar(context: composerViewModel.context)) + } + .previewDisplayName("Selecting") + .snapshotPreferences(expect: selectingViewModels.timeline.context.$viewState.map(\.selection.isActive)) } - static func makeViewModels(canSendMessage: Bool = true, hasSuccessor: Bool = false) -> ViewModels { + static func makeViewModels(canSendMessage: Bool = true, hasSuccessor: Bool = false, isSelecting: Bool = false) -> ViewModels { let roomProxyMock = JoinedRoomProxyMock(.init(id: "stable_id", name: "Preview room", hasOngoingCall: true, @@ -365,8 +391,11 @@ struct RoomScreen_Previews: PreviewProvider, TestablePreview { let roomViewModel = RoomScreenViewModel.mock(roomProxyMock: roomProxyMock) let appSettings = AppSettings.volatile() + appSettings.messageMultiSelectEnabled = isSelecting + + let timelineController = TimelineControllerMock(.init()) let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock, - timelineController: TimelineControllerMock(.init()), + timelineController: timelineController, userSession: UserSessionMock(.init()), mediaPlayerProvider: MediaPlayerProviderMock(), userIndicatorController: UserIndicatorControllerMock(), @@ -377,6 +406,11 @@ struct RoomScreen_Previews: PreviewProvider, TestablePreview { linkMetadataProvider: LinkMetadataProvider(), timelineControllerFactory: TimelineControllerFactoryMock(.init())) + if isSelecting { + let eventIDs = timelineController.timelineItems.compactMap { ($0 as? EventBasedTimelineItemProtocol)?.id.eventID } + timelineViewModel.state.selection.selectedEventIDs = Set(eventIDs.prefix(2)) + } + return .init(room: roomViewModel, timeline: timelineViewModel) } diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift index d68323d2d..6cb3b7226 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift @@ -69,6 +69,7 @@ protocol DeveloperOptionsProtocol: AnyObject { var linkPreviewsEnabled: Bool { get set } var jumpToReadMarkerEnabled: Bool { get set } + var messageMultiSelectEnabled: Bool { get set } var linkNewDeviceEnabled: Bool { get set } diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift index 191b8068e..1370f8a11 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift @@ -101,6 +101,11 @@ struct DeveloperOptionsScreen: View { Text("Jump to unread") Text("Adds a button to jump to the read marker, plus a presence dot on the scroll-to-bottom button when new messages arrive while scrolled away.") } + + Toggle(isOn: $context.messageMultiSelectEnabled) { + Text("Multi-select messages") + Text("Adds a Select action to the message menu to pick several messages at once.") + } } Section { diff --git a/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift b/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift index 3ab55300f..45aa167ce 100644 --- a/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift +++ b/ElementX/Sources/Screens/ThreadTimelineScreen/View/ThreadTimelineScreen.swift @@ -30,6 +30,7 @@ struct ThreadTimelineScreen: View { .navigationBarTitleDisplayMode(.inline) .toolbar { toolbar } .toolbarBackground(.visible, for: .navigationBar) // Fix the toolbar's background. + .navigationBarBackButtonHidden(isSelectionActive) .timelineMediaPreview(viewModel: $context.mediaPreviewViewModel) .overlay(alignment: .top) { FloatingDateBadge(dateText: timelineContext.floatingDate?.formattedDateSeparator()) { @@ -50,9 +51,14 @@ struct ThreadTimelineScreen: View { .environment(\.timelineContext, timelineContext) // Make sure the reply header honours the hideTimelineMedia setting too. .environment(\.shouldAutomaticallyLoadImages, !timelineContext.viewState.hideTimelineMedia) + .collapsedInPlace(isSelectionActive) } } + private var isSelectionActive: Bool { + timelineContext.viewState.selection.isActive + } + @ViewBuilder private var composer: some View { if context.viewState.canSendMessage { @@ -64,16 +70,22 @@ struct ThreadTimelineScreen: View { @ToolbarContentBuilder private var toolbar: 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) { - 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 isSelectionActive { + TimelineSelectionToolbar(count: timelineContext.viewState.selection.count) { + timelineContext.send(viewAction: .clearSelection) + } + } 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. + } } } } diff --git a/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift b/ElementX/Sources/Screens/Timeline/TimelineInteractionHandler.swift index 17f5dd582..1aa6403d2 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: - break // Handled inline in the media preview screen. + case .downloadMedia, .selectMessages: + break // Handled by the media preview screen and the TimelineViewModel respectively. case .translate: guard let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol else { return } actionsSubject.send(.showTranslation(text: messageTimelineItem.body)) @@ -372,6 +372,12 @@ class TimelineInteractionHandler { await voiceMessageRecorder.stopRecording() } + /// Stops the recording when one is in progress, moving the composer to the preview state. + func stopRecordingVoiceMessageIfNeeded() async { + guard voiceMessageRecorder.isRecording else { return } + await voiceMessageRecorder.stopRecording() + } + func cancelRecordingVoiceMessage() async { await voiceMessageRecorder.cancelRecording() voiceMessageRecorderObserver = nil diff --git a/ElementX/Sources/Screens/Timeline/TimelineModels.swift b/ElementX/Sources/Screens/Timeline/TimelineModels.swift index 5155af79c..3dcabd044 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineModels.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineModels.swift @@ -69,6 +69,12 @@ 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 tappedOnSenderDetails(sender: TimelineItemSender) case displayReactionSummary(itemID: TimelineItemIdentifier, key: String) case displayEmojiPicker(itemID: TimelineItemIdentifier) @@ -131,6 +137,8 @@ struct TimelineViewState: BindableState { var linkPreviewsEnabled: Bool var jumpToReadMarkerEnabled: Bool + var selection: TimelineSelectionState + let hasPredecessor: Bool /// The `pinnedEventIDs` are used only to determine if an item is already pinned or not. @@ -197,6 +205,33 @@ struct TimelineViewStateBindings { var textToBeTranslated: String? } +/// The state of the multi-selection of messages, active as soon as an item is selected. +struct TimelineSelectionState: Equatable { + static let limit = 30 + + /// 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 { + !selectedEventIDs.isEmpty + } + + var count: Int { + selectedEventIDs.count + } + + var isAtLimit: Bool { + count >= Self.limit + } + + func contains(_ eventID: String?) -> Bool { + guard let eventID else { return false } + return selectedEventIDs.contains(eventID) + } +} + struct TimelineItemActionMenuInfo: Equatable, Identifiable { static func == (lhs: TimelineItemActionMenuInfo, rhs: TimelineItemActionMenuInfo) -> Bool { lhs.id == rhs.id @@ -319,6 +354,15 @@ enum ScrollDirection: Equatable { } extension TimelineViewState { + /// Multi-selection is only offered in the room and thread timelines. + var canSelectMessages: Bool { + guard selection.isEnabled else { return false } + return switch timelineKind { + case .live, .detached, .thread: true + case .pinned, .media: false + } + } + /// The user is at the bottom of a live timeline (no jump-to-bottom button needed). var isAtBottomAndLive: Bool { bindings.isScrolledToBottom && timelineState.isLive diff --git a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift index b9b370a8a..a73829e60 100644 --- a/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift +++ b/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift @@ -20,6 +20,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { static let detachedTimelineSize: UInt16 = 100 static let focusTimelineToastIndicatorID = "RoomScreenFocusTimelineToastIndicator" static let toastErrorID = "RoomScreenToastError" + static let selectionLimitIndicatorID = "RoomScreenSelectionLimitIndicator" } private let roomProxy: JoinedRoomProxyProtocol @@ -102,6 +103,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { areThreadsEnabled: appSettings.threadsEnabled, linkPreviewsEnabled: appSettings.linkPreviewsEnabled, jumpToReadMarkerEnabled: appSettings.jumpToReadMarkerEnabled, + selection: .init(isEnabled: appSettings.messageMultiSelectEnabled), hasPredecessor: roomProxy.predecessorRoom != nil, pinnedEventIDs: roomProxy.infoPublisher.value.pinnedEventIDs, emojiProvider: emojiProvider, @@ -196,12 +198,20 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { } case .displayTimelineItemMenu(let itemID): timelineInteractionHandler.displayTimelineItemActionMenu(for: itemID) + case .handleTimelineItemMenuAction(let itemID, .selectMessages): + startSelection(itemID: itemID) case .handleTimelineItemMenuAction(let itemID, let action): timelineInteractionHandler.handleTimelineItemMenuAction(action, itemID: itemID) case .redactConfirmed(let itemID, let reason): 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): + toggleSelection(itemID: itemID) + case .clearSelection: + state.selection.selectedEventIDs.removeAll() case .tappedOnSenderDetails(let sender): handleTappedOnSenderDetails(sender: sender) case .displayEmojiPicker(let itemID): @@ -465,6 +475,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { switch callback { case .updatedTimelineItems(let updatedItems, let isSwitchingTimelines): buildTimelineViews(timelineItems: updatedItems, isSwitchingTimelines: isSwitchingTimelines) + reconcileSelection(with: updatedItems, isSwitchingTimelines: isSwitchingTimelines) if !updatedItems.isEmpty { analyticsService.signpost.finishTransaction(.openRoom) @@ -584,6 +595,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { .weakAssign(to: \.state.jumpToReadMarkerEnabled, on: self) .store(in: &cancellables) + setupSelectionSubscriptions() + userSession.clientProxy.timelineMediaVisibilityPublisher .removeDuplicates() .flatMap { [weak self] timelineMediaVisibility -> AnyPublisher in @@ -1172,6 +1185,81 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol { } } +// MARK: - Selection + +extension TimelineViewModel { + private func setupSelectionSubscriptions() { + appSettings.messageMultiSelectEnabledPublisher + .sink { [weak self] isEnabled in + self?.state.selection.isEnabled = isEnabled + if !isEnabled { + self?.state.selection.selectedEventIDs.removeAll() + } + } + .store(in: &cancellables) + } + + private func startSelection(itemID: TimelineItemIdentifier) { + guard state.canSelectMessages, 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 { + showSelectionLimitToast() + return + } + + state.selection.selectedEventIDs.insert(eventID) + } + + private func toggleSelection(itemID: TimelineItemIdentifier) { + guard state.selection.isActive, let eventID = selectableEventID(for: itemID) else { return } + + if state.selection.selectedEventIDs.contains(eventID) { + state.selection.selectedEventIDs.remove(eventID) + } else if state.selection.isAtLimit { + showSelectionLimitToast() + } else { + state.selection.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 } + + if isSwitchingTimelines { + state.selection.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) + } + + /// The event ID of the item, when it is part of this timeline and can be bulk selected. + private func selectableEventID(for itemID: TimelineItemIdentifier) -> String? { + guard let item = timelineController.timelineItems.firstUsingStableID(itemID) as? EventBasedTimelineItemProtocol, + item.isBulkSelectable else { + return nil + } + return item.id.eventID + } + + private func showSelectionLimitToast() { + userIndicatorController.submitIndicator(UserIndicator(id: Constants.selectionLimitIndicatorID, + type: .toast, + title: L10n.screenRoomMaximumMessagesSelected, + icon: \.info)) + } +} + // MARK: - Mocks extension TimelineViewModel { diff --git a/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenu.swift b/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenu.swift index d9a418832..d4b5a8540 100644 --- a/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenu.swift +++ b/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenu.swift @@ -339,6 +339,7 @@ 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 3f40a206a..a605fb8b4 100644 --- a/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuAction.swift +++ b/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuAction.swift @@ -68,6 +68,7 @@ enum TimelineItemMenuAction: Identifiable, Hashable { case reply(isThread: Bool) case replyInThread case forward(itemID: TimelineItemIdentifier) + case selectMessages case viewSource case report case react @@ -169,6 +170,8 @@ 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 0a11ce98e..87a97f26e 100644 --- a/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift +++ b/ElementX/Sources/Screens/Timeline/View/ItemMenu/TimelineItemMenuActionProvider.swift @@ -17,6 +17,7 @@ struct TimelineItemMenuActionProvider { let pinnedEventIDs: Set let isViewSourceEnabled: Bool let areThreadsEnabled: Bool + let isMultiSelectEnabled: Bool let timelineKind: TimelineKind let emojiProvider: EmojiProviderProtocol @@ -65,6 +66,10 @@ 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/LongPressWithFeedback.swift b/ElementX/Sources/Screens/Timeline/View/Style/LongPressWithFeedback.swift index 2c421e8b2..5842d4ee5 100644 --- a/ElementX/Sources/Screens/Timeline/View/Style/LongPressWithFeedback.swift +++ b/ElementX/Sources/Screens/Timeline/View/Style/LongPressWithFeedback.swift @@ -9,6 +9,7 @@ import SwiftUI struct LongPressWithFeedback: ViewModifier { + let isEnabled: Bool let action: () -> Void @State private var triggerTask: Task? @@ -17,7 +18,7 @@ struct LongPressWithFeedback: ViewModifier { func body(content: Content) -> some View { mainContent(content: content) - .gesture(LongPressGestureRepresentable { gesture in + .gesture(LongPressGestureRepresentable(isEnabled: isEnabled) { gesture in switch gesture.state { case .began: handleLongPress(isPressing: true) @@ -69,8 +70,10 @@ struct LongPressWithFeedback: ViewModifier { } extension View { - func longPressWithFeedback(action: @escaping () -> Void) -> some View { - modifier(LongPressWithFeedback(action: action)) + /// Adds a long press gesture with scale and haptic feedback. Pass `isEnabled: false` to + /// disable the recogniser entirely, so that neither the feedback nor the action fire. + func longPressWithFeedback(isEnabled: Bool = true, action: @escaping () -> Void) -> some View { + modifier(LongPressWithFeedback(isEnabled: isEnabled, action: action)) } } @@ -127,6 +130,7 @@ struct LongPressWithFeedback_Previews: PreviewProvider, TestablePreview { /// Fixes the issue on iOS 18 where LongPress conflicts with the scroll view /// https://github.com/feedback-assistant/reports/issues/542#issuecomment-2581322968 private struct LongPressGestureRepresentable: UIGestureRecognizerRepresentable { + var isEnabled: Bool var handle: (UILongPressGestureRecognizer) -> Void func makeCoordinator(converter: CoordinateSpaceConverter) -> Coordinator { @@ -137,10 +141,14 @@ private struct LongPressGestureRepresentable: UIGestureRecognizerRepresentable { let gesture = UILongPressGestureRecognizer() gesture.minimumPressDuration = 0.25 gesture.delegate = context.coordinator - gesture.isEnabled = true + gesture.isEnabled = isEnabled return gesture } + func updateUIGestureRecognizer(_ recognizer: UILongPressGestureRecognizer, context: Context) { + recognizer.isEnabled = isEnabled + } + func handleUIGestureRecognizerAction(_ recognizer: UILongPressGestureRecognizer, context: Context) { handle(recognizer) } diff --git a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemAccessibilityModifier.swift b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemAccessibilityModifier.swift index 8507c7cb0..40e357654 100644 --- a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemAccessibilityModifier.swift +++ b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemAccessibilityModifier.swift @@ -8,60 +8,91 @@ import SwiftUI +/// How a timeline item takes part in an active multi-selection, for accessibility purposes. +enum TimelineItemAccessibilitySelection { + /// 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. + case selecting(isSelected: Bool) +} + private struct TimelineItemAccessibilityModifier: ViewModifier { let timelineItem: RoomTimelineItemProtocol + let selection: TimelineItemAccessibilitySelection let action: () -> Void + private var isSelecting: Bool { + if case .selecting = selection { + return true + } + return false + } + func body(content: Content) -> some View { switch timelineItem { case is PollRoomTimelineItem: - content - .accessibilityActions { - Button(L10n.commonMessageActions) { - action() - } - } + // The answers stay traversable on their own, unless the whole poll acts as a selection toggle. + if isSelecting { + interactions(for: content.accessibilityElement(children: .combine)) + } else { + interactions(for: content) + } // A gallery is a container so that each of its attachments can be focussed on its own. // Everything that isn't an attachment is announced when entering it, as the caption and // the send info are hidden where they're shown to avoid being read twice. case let timelineItem as GalleryRoomTimelineItem: - content - .accessibilityElement(children: .contain) + interactions(for: content + .accessibilityElement(children: isSelecting ? .combine : .contain) .accessibilityLabel { _ in Text(timelineItem.sender.displayName ?? timelineItem.sender.id) if let caption = timelineItem.content.caption, !caption.isBlank { Text(caption) } Text(timelineItem.localizedSendInfo) - } - .accessibilityActions { - Button(L10n.commonMessageActions) { - action() - } - } + }) case let timelineItem as EventBasedTimelineItemProtocol: - content + interactions(for: content .accessibilityRepresentation { VStack(spacing: 8) { Text(timelineItem.sender.displayName ?? timelineItem.sender.id) content } } - .accessibilityElement(children: .combine) - .accessibilityActions { - Button(L10n.commonMessageActions) { - action() - } - } + .accessibilityElement(children: .combine)) default: content .accessibilityElement(children: .combine) } } + + /// Offers the message actions, or turns the item into a selection toggle while selecting. + @ViewBuilder + private func interactions(for view: some View) -> some View { + switch selection { + case .none: + view + .accessibilityActions { + Button(L10n.commonMessageActions) { + action() + } + } + case .selecting(let isSelected): + if let item = timelineItem as? EventBasedTimelineItemProtocol, item.isBulkSelectable { + view + .accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : .isButton) + .accessibilityAction(.default, action) + } else { + // Items that can't be selected are inert while selecting. + view + } + } + } } extension View { - func timelineItemAccessibility(_ timelineItem: RoomTimelineItemProtocol, action: @escaping () -> Void) -> some View { - modifier(TimelineItemAccessibilityModifier(timelineItem: timelineItem, action: action)) + func timelineItemAccessibility(_ timelineItem: RoomTimelineItemProtocol, + selection: TimelineItemAccessibilitySelection = .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 8d23bfd23..6531edd4a 100644 --- a/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/Timeline/View/Style/TimelineItemBubbledStylerView.swift @@ -42,6 +42,14 @@ struct TimelineItemBubbledStylerView: View { return context.viewState.pinnedEventIDs.contains(eventID) } + private var isSelectionActive: Bool { + context.viewState.selection.isActive + } + + private var isSelected: Bool { + context.viewState.selection.contains(timelineItem.id.eventID) + } + /// The base padding applied to bubbles on either side. /// /// **Note:** This is on top of the insets applied to the cells by the table view. @@ -53,6 +61,45 @@ struct TimelineItemBubbledStylerView: View { } var body: some View { + HStack(spacing: 0) { + if isSelectionActive { + 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) + .onPreferenceChange(ContentScanningFailurePreferenceKey.self) { contentScanningFailure = $0 } + } + + private var selectionIndicator: some 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. + } + .padding(.leading, 8) + .transition(.move(edge: .leading).combined(with: .opacity)) + .accessibilityHidden(true) // The bubble itself carries the selected state. + } + + /// Captures every touch while selecting so the content's own gestures can't fire. + @ViewBuilder + private var selectionOverlay: some View { + if isSelectionActive { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { toggleSelection() } + .onLongPressGesture { toggleSelection() } + .accessibilityHidden(true) + } + } + + private var bubble: some View { ZStack(alignment: .trailingFirstTextBaseline) { VStack(alignment: alignment, spacing: -8) { if !timelineItem.isOutgoing, !isDM { @@ -84,9 +131,6 @@ struct TimelineItemBubbledStylerView: View { .padding(.leading, bubbleAvatarPadding) } } - .padding(EdgeInsets(top: 1, leading: 8, bottom: 1, trailing: 8)) - .highlightedTimelineItem(isFocussed) - .onPreferenceChange(ContentScanningFailurePreferenceKey.self) { contentScanningFailure = $0 } } @ViewBuilder @@ -128,8 +172,12 @@ struct TimelineItemBubbledStylerView: View { // Figma overlaps reactions by 3 VStack(alignment: alignment, spacing: -3) { messageBubbleWithActions - .timelineItemAccessibility(timelineItem) { - context.send(viewAction: .displayTimelineItemMenu(itemID: timelineItem.id)) + .timelineItemAccessibility(timelineItem, selection: isSelectionActive ? .selecting(isSelected: isSelected) : .none) { + if isSelectionActive { + toggleSelection() + } else { + context.send(viewAction: .displayTimelineItemMenu(itemID: timelineItem.id)) + } } // Do not display reactions in the pinned events timeline @@ -141,6 +189,7 @@ struct TimelineItemBubbledStylerView: View { isLayoutRTL: timelineItem.isOutgoing) // Workaround to stop the message long press stealing the touch from the reaction buttons .onTapGesture { } + .accessibilityHidden(isSelectionActive) } if context.viewState.areThreadsEnabled, @@ -150,6 +199,7 @@ struct TimelineItemBubbledStylerView: View { context.send(viewAction: .displayThread(itemID: timelineItem.id)) } .padding(5) + .accessibilityHidden(isSelectionActive) } } } @@ -162,7 +212,8 @@ struct TimelineItemBubbledStylerView: View { // is no longer an issue on iOS 18. Note: it's fine for this to be empty, we handle // specific taps within the timeline views themselves. } - .longPressWithFeedback { + // The UIKit recogniser would still receive touches through the selection overlay. + .longPressWithFeedback(isEnabled: !isSelectionActive) { context.send(viewAction: .displayTimelineItemMenu(itemID: timelineItem.id)) } .swipeRightAction { @@ -170,30 +221,38 @@ struct TimelineItemBubbledStylerView: View { .foregroundColor(.compound.iconPrimary) .accessibilityHidden(true) } shouldStartAction: { - timelineItem.canBeRepliedTo + !isSelectionActive && timelineItem.canBeRepliedTo } action: { context.send(viewAction: .handleTimelineItemMenuAction(itemID: timelineItem.id, action: .reply(isThread: timelineItem.properties.isThreaded))) } .contextMenu { - let provider = TimelineItemMenuActionProvider(timelineItem: timelineItem, - canCurrentUserSendMessage: context.viewState.canCurrentUserSendMessage, - canCurrentUserRedactSelf: context.viewState.canCurrentUserRedactSelf, - canCurrentUserRedactOthers: context.viewState.canCurrentUserRedactOthers, - canCurrentUserPin: context.viewState.canCurrentUserPin, - pinnedEventIDs: context.viewState.pinnedEventIDs, - isViewSourceEnabled: context.viewState.isViewSourceEnabled, - areThreadsEnabled: context.viewState.areThreadsEnabled, - timelineKind: context.viewState.timelineKind, - emojiProvider: context.viewState.emojiProvider) - TimelineItemMacContextMenu(item: timelineItem, actionProvider: provider) { action in - context.send(viewAction: .handleTimelineItemMenuAction(itemID: timelineItem.id, action: action)) + if !isSelectionActive { + macContextMenu } } .pinnedIndicator(isPinned: isPinned, isOutgoing: timelineItem.isOutgoing) .padding(.top, messageBubbleTopPadding) } + @ViewBuilder + private var macContextMenu: some View { + let provider = TimelineItemMenuActionProvider(timelineItem: timelineItem, + canCurrentUserSendMessage: context.viewState.canCurrentUserSendMessage, + canCurrentUserRedactSelf: context.viewState.canCurrentUserRedactSelf, + canCurrentUserRedactOthers: context.viewState.canCurrentUserRedactOthers, + canCurrentUserPin: context.viewState.canCurrentUserPin, + 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 + context.send(viewAction: .handleTimelineItemMenuAction(itemID: timelineItem.id, action: action)) + } + } + var messageBubble: some View { contentWithReply .timelineItemSendInfo(timelineItem: timelineItem, @@ -238,6 +297,11 @@ struct TimelineItemBubbledStylerView: View { } } + private func toggleSelection() { + guard timelineItem.isBulkSelectable else { return } + context.send(viewAction: .toggleSelection(itemID: timelineItem.id)) + } + private var messageBubbleTopPadding: CGFloat { guard timelineItem.isOutgoing || isDM else { return 0 } return timelineGroupStyle == .single || timelineGroupStyle == .first ? 8 : 0 @@ -398,6 +462,26 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview static let unsafeViewModel = TimelineViewModel.mock(contentScannerService: ContentScannerServiceMock(.init(scanResult: false))) + static let selectingViewModel: TimelineViewModel = { + let appSettings = AppSettings.volatile() + appSettings.messageMultiSelectEnabled = true + + let viewModel = TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init()), + focussedEventID: nil, + timelineController: TimelineControllerMock(.init()), + userSession: UserSessionMock(.init()), + mediaPlayerProvider: MediaPlayerProviderMock(), + userIndicatorController: UserIndicatorControllerMock(), + appMediator: AppMediatorMock(.init()), + appSettings: appSettings, + analyticsService: AnalyticsServiceMock(.init()), + emojiProvider: EmojiProvider(appSettings: appSettings), + linkMetadataProvider: LinkMetadataProvider(), + timelineControllerFactory: TimelineControllerFactoryMock(.init())) + viewModel.state.selection.selectedEventIDs = ["selected"] + return viewModel + }() + static var previews: some View { mockTimeline .previewDisplayName("Mock Timeline") @@ -427,6 +511,39 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview .previewDisplayName("Pinned messages") .previewLayout(.fixed(width: 390, height: 1150)) .padding(.bottom, 20) + selection + .previewDisplayName("Selection") + } + + static var selection: some View { + VStack(spacing: 0) { + RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .event(uniqueID: .init("1"), eventOrTransactionID: .eventID("selected")), + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + sender: .init(id: "@alice:matrix.org", displayName: "Alice"), + content: .init(body: "A selected message.")), + groupStyle: .single)) + RoomTimelineItemView(viewState: .init(item: TextRoomTimelineItem(id: .event(uniqueID: .init("2"), eventOrTransactionID: .eventID("unselected")), + timestamp: .mock, + isOutgoing: true, + isEditable: false, + canBeRepliedTo: true, + sender: .init(id: "@bob:matrix.org"), + content: .init(body: "A message that isn't selected.")), + groupStyle: .single)) + RoomTimelineItemView(viewState: .init(item: RedactedRoomTimelineItem(id: .event(uniqueID: .init("3"), eventOrTransactionID: .eventID("redacted")), + body: "Message removed", + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: false, + sender: .init(id: "@alice:matrix.org", displayName: "Alice")), + groupStyle: .single)) + } + .environmentObject(selectingViewModel.context) + .environment(\.timelineContext, selectingViewModel.context) } static var mockTimeline: some View { diff --git a/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineSelectionToolbar.swift b/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineSelectionToolbar.swift new file mode 100644 index 000000000..3fce44824 --- /dev/null +++ b/ElementX/Sources/Screens/Timeline/View/Supplementary/TimelineSelectionToolbar.swift @@ -0,0 +1,27 @@ +// +// 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 42e207ef2..389a9805d 100644 --- a/ElementX/Sources/Screens/Timeline/View/TimelineView.swift +++ b/ElementX/Sources/Screens/Timeline/View/TimelineView.swift @@ -41,6 +41,7 @@ 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 7dfd5d3f6..2755ada41 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift @@ -31,6 +31,16 @@ nonisolated extension EventBasedTimelineItemProtocol { 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 LiveLocationRoomTimelineItem) + } + var isRemoteMessage: Bool { id.eventID != nil } diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPad-en-GB.png new file mode 100644 index 000000000..a0650f086 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a8c4d7de9bde6e8b8ed405139ef0b1a93c526efc0495dc9e060f7746e3851a27 +size 307560 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPad-pseudo.png new file mode 100644 index 000000000..9c91eb253 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b736b917580ffb895f52e571f7ec6b88fa79618ef7d967af4be7a1dc1297d74a +size 314629 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPhone-en-GB.png new file mode 100644 index 000000000..100121991 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:70802dcebe90358926b889013d49f094111426817c4a3c5739e5bf6c56a7f755 +size 185662 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPhone-pseudo.png new file mode 100644 index 000000000..5649b8217 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/roomScreen.Selecting-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3cf29299b5003b889898629a533303496911b212b42e9e8dc10912ec7ac5e519 +size 179793 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPad-en-GB.png new file mode 100644 index 000000000..acf32a5ae --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPad-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f4ba074e3d13bc6ecfefe9e91e0515d0fa0a793908ae38fe400c845c389d79fd +size 103633 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPad-pseudo.png new file mode 100644 index 000000000..9493e791b --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPad-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1d1e836cbb773ab61e7c5470fec38a51dce0572b5a779eabdcd86a0171b7aa04 +size 106075 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPhone-en-GB.png new file mode 100644 index 000000000..67bed13a7 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPhone-en-GB.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aed3e91bdd708c6738cc2e82e8271988761adc46c8192e491cb5e7bcf4f33031 +size 59392 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPhone-pseudo.png new file mode 100644 index 000000000..4412e0fa8 --- /dev/null +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/timelineItemBubbledStylerView.Selection-iPhone-pseudo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ecafd3c5f7ca87afbc5b88211205b0a5b8e950595fdc5f526456628b3f1c32d3 +size 61305 diff --git a/UnitTests/Sources/TimelineInteractionHandlerTests.swift b/UnitTests/Sources/TimelineInteractionHandlerTests.swift index b12d640ca..4ffa45043 100644 --- a/UnitTests/Sources/TimelineInteractionHandlerTests.swift +++ b/UnitTests/Sources/TimelineInteractionHandlerTests.swift @@ -159,6 +159,30 @@ struct TimelineInteractionHandlerTests { #expect(loadedSourceURLs == [Self.firstSourceURL, Self.secondSourceURL, Self.firstSourceURL]) } + // MARK: - Voice message recording + + @Test + func stopRecordingIfNeededStopsAnActiveRecording() async { + let voiceMessageRecorder = VoiceMessageRecorderMock() + voiceMessageRecorder.isRecording = true + let handler = makeHandler(timelineItems: [Self.textMessage], voiceMessageRecorder: voiceMessageRecorder) + + await handler.stopRecordingVoiceMessageIfNeeded() + + #expect(voiceMessageRecorder.stopRecordingCallsCount == 1) + } + + @Test + func stopRecordingIfNeededIgnoresAnIdleRecorder() async { + let voiceMessageRecorder = VoiceMessageRecorderMock() + voiceMessageRecorder.isRecording = false + let handler = makeHandler(timelineItems: [Self.textMessage], voiceMessageRecorder: voiceMessageRecorder) + + await handler.stopRecordingVoiceMessageIfNeeded() + + #expect(!voiceMessageRecorder.stopRecordingCalled) + } + // MARK: - Helpers private var loadedSourceURLs: [URL] { @@ -172,9 +196,13 @@ struct TimelineInteractionHandlerTests { } } - private func makeHandler(timelineItems: [RoomTimelineItemProtocol]) -> TimelineInteractionHandler { - let voiceMessageRecorder = VoiceMessageRecorderMock() - voiceMessageRecorder.isRecording = false + private func makeHandler(timelineItems: [RoomTimelineItemProtocol], + voiceMessageRecorder: VoiceMessageRecorderMock? = nil) -> TimelineInteractionHandler { + let voiceMessageRecorder = voiceMessageRecorder ?? { + let recorder = VoiceMessageRecorderMock() + recorder.isRecording = false + return recorder + }() let voiceMessageMediaManager = VoiceMessageMediaManagerMock() voiceMessageMediaManager.loadVoiceMessageFromSourceBodyReturnValue = URL("file:///voice-message.m4a") diff --git a/UnitTests/Sources/TimelineItemMenuActionProviderTests.swift b/UnitTests/Sources/TimelineItemMenuActionProviderTests.swift index ec7f1c70e..44d13c19f 100644 --- a/UnitTests/Sources/TimelineItemMenuActionProviderTests.swift +++ b/UnitTests/Sources/TimelineItemMenuActionProviderTests.swift @@ -51,8 +51,65 @@ struct TimelineItemMenuActionProviderTests { #expect(hasForward) } + // MARK: - Select + + @Test + func selectIsShownForRemoteMessageWhenEnabled() throws { + let actions = try #require(makeActions(for: makeTextItem(), isMultiSelectEnabled: true)) + #expect(actions.actions.contains(.selectMessages)) + } + + @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 { + let item = EncryptedRoomTimelineItem(id: .randomEvent, + body: "", + encryptionType: .unknown, + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: false, + sender: .init(id: "@alice:matrix.org")) + let actions = try #require(makeActions(for: item, isMultiSelectEnabled: true)) + #expect(!actions.actions.contains(.selectMessages)) + } + // MARK: - Helpers + private func makeTextItem(id: TimelineItemIdentifier = .randomEvent) -> TextRoomTimelineItem { + .init(id: id, + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: true, + sender: .init(id: "@alice:matrix.org"), + content: .init(body: "Hello")) + } + private func makeLiveLocationItem(isLive: Bool) -> LiveLocationRoomTimelineItem { .init(id: .randomEvent, timestamp: .mock, @@ -63,7 +120,9 @@ struct TimelineItemMenuActionProviderTests { content: .init(isLive: isLive, timeoutDate: .mock, lastGeoURI: nil)) } - private func makeActions(for item: RoomTimelineItemProtocol) -> TimelineItemMenuActions? { + private func makeActions(for item: RoomTimelineItemProtocol, + isMultiSelectEnabled: Bool = false, + timelineKind: TimelineKind = .live) -> TimelineItemMenuActions? { TimelineItemMenuActionProvider(timelineItem: item, canCurrentUserSendMessage: true, canCurrentUserRedactSelf: true, @@ -72,7 +131,8 @@ struct TimelineItemMenuActionProviderTests { pinnedEventIDs: [], isViewSourceEnabled: true, areThreadsEnabled: true, - timelineKind: .live, + isMultiSelectEnabled: isMultiSelectEnabled, + timelineKind: timelineKind, emojiProvider: EmojiProvider(appSettings: .volatile())) .makeActions() } diff --git a/UnitTests/Sources/TimelineViewModelTests.swift b/UnitTests/Sources/TimelineViewModelTests.swift index 5478b2e4e..8a8023d4e 100644 --- a/UnitTests/Sources/TimelineViewModelTests.swift +++ b/UnitTests/Sources/TimelineViewModelTests.swift @@ -687,25 +687,212 @@ final class TimelineViewModelTests { #expect(timelineController.redactReasonReceivedArguments?.reason == nil) } + // MARK: - Selection + + @Test + func selectMenuActionEntersSelection() async throws { + let items = [TextRoomTimelineItem(eventID: "$1"), TextRoomTimelineItem(eventID: "$2")] + let viewModel = makeSelectionViewModel(items: items) + + let deferred = deferFulfillment(viewModel.actions) { action in + if case .composer(action: .removeFocus) = action { + return true + } + return false + } + viewModel.process(viewAction: .handleTimelineItemMenuAction(itemID: items[0].id, action: .selectMessages)) + try await deferred.fulfill() + + #expect(viewModel.state.selection.isActive) + #expect(viewModel.state.selection.selectedEventIDs == ["$1"]) + } + + @Test + func selectIsIgnoredWhenFlagIsOff() { + 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)) + + #expect(!viewModel.state.selection.isActive) + } + + @Test + func toggleSelectionAddsAndRemovesItems() { + let items = [TextRoomTimelineItem(eventID: "$1"), TextRoomTimelineItem(eventID: "$2")] + 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: .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) + + viewModel.process(viewAction: .toggleSelection(itemID: items[0].id)) + #expect(viewModel.state.selection.selectedEventIDs == ["$2"]) + + // Deselecting the last item ends the selection. + viewModel.process(viewAction: .toggleSelection(itemID: items[1].id)) + #expect(!viewModel.state.selection.isActive) + } + + @Test + func clearSelectionEndsTheSelection() { + 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) + + #expect(!viewModel.state.selection.isActive) + #expect(viewModel.state.selection.selectedEventIDs.isEmpty) + } + + @Test + func nonSelectableItemsAreIgnored() { + let text = TextRoomTimelineItem(eventID: "$1") + let state = StateRoomTimelineItem(id: .randomEvent, + body: "Alice joined", + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: false, + sender: .init(id: "@alice:matrix.org")) + let redacted = RedactedRoomTimelineItem(id: .randomEvent, + body: "Message removed", + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: false, + sender: .init(id: "@alice:matrix.org")) + let localEcho = TextRoomTimelineItem(id: .event(uniqueID: .init("local"), eventOrTransactionID: .transactionID("txn")), + timestamp: .mock, + isOutgoing: true, + isEditable: false, + canBeRepliedTo: true, + sender: .init(id: "@bob:matrix.org"), + content: .init(body: "Sending")) + let nonSelectableItems: [RoomTimelineItemProtocol] = [state, redacted, localEcho] + let viewModel = makeSelectionViewModel(items: [text] + nonSelectableItems) + + viewModel.process(viewAction: .startSelection(itemID: state.id)) + #expect(!viewModel.state.selection.isActive) + + viewModel.process(viewAction: .startSelection(itemID: text.id)) + for item in nonSelectableItems { + viewModel.process(viewAction: .toggleSelection(itemID: item.id)) + } + #expect(viewModel.state.selection.selectedEventIDs == ["$1"]) + } + + @Test + func selectionIsLimited() { + let items = (0...TimelineSelectionState.limit).map { TextRoomTimelineItem(eventID: "$\($0)") } + let userIndicatorController = UserIndicatorControllerMock() + let viewModel = makeSelectionViewModel(items: items, userIndicatorController: userIndicatorController) + + viewModel.process(viewAction: .startSelection(itemID: items[0].id)) + for item in items.dropFirst() { + viewModel.process(viewAction: .toggleSelection(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(userIndicatorController.submitIndicatorDelayCallsCount == 1) + } + + @Test + func selectionIsPrunedWhenItemsBecomeUnselectable() async throws { + let items = [TextRoomTimelineItem(eventID: "$1"), TextRoomTimelineItem(eventID: "$2")] + 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) + + // The first message gets redacted by someone else. + let redacted = RedactedRoomTimelineItem(id: items[0].id, + body: "Message removed", + timestamp: .mock, + isOutgoing: false, + isEditable: false, + canBeRepliedTo: false, + sender: .init(id: "@alice:matrix.org")) + let deferred = deferFulfillment(viewModel.context.$viewState) { $0.selection.selectedEventIDs == ["$2"] } + timelineController.callbacks.send(.updatedTimelineItems(timelineItems: [redacted, items[1]], isSwitchingTimelines: false)) + try await deferred.fulfill() + } + + @Test + func selectionIsClearedWhenSwitchingTimelines() async throws { + let items = [TextRoomTimelineItem(eventID: "$1")] + let timelineController = TimelineControllerMock(.init(timelineItems: items)) + let viewModel = makeSelectionViewModel(timelineController: timelineController) + + viewModel.process(viewAction: .startSelection(itemID: items[0].id)) + #expect(viewModel.state.selection.isActive) + + let deferred = deferFulfillment(viewModel.context.$viewState) { !$0.selection.isActive } + timelineController.callbacks.send(.updatedTimelineItems(timelineItems: items, isSwitchingTimelines: true)) + try await deferred.fulfill() + } + + @Test + func disablingTheFlagClearsTheSelection() async throws { + let items = [TextRoomTimelineItem(eventID: "$1")] + let appSettings = AppSettings.volatile() + 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) + + let deferred = deferFulfillment(viewModel.context.$viewState) { !$0.selection.isEnabled && !$0.selection.isActive } + appSettings.messageMultiSelectEnabled = false + try await deferred.fulfill() + } + // MARK: - Helpers private func makeViewModel(roomProxy: JoinedRoomProxyProtocol? = nil, focussedEventID: String? = nil, - timelineController: TimelineControllerProtocol) -> TimelineViewModel { + timelineController: TimelineControllerProtocol, + userIndicatorController: UserIndicatorControllerProtocol = UserIndicatorControllerMock(), + appSettings: AppSettings = .volatile()) -> TimelineViewModel { + TimelineViewModel(roomProxy: roomProxy ?? JoinedRoomProxyMock(.init(name: "")), + focussedEventID: focussedEventID, + timelineController: timelineController, + userSession: UserSessionMock(.init()), + mediaPlayerProvider: MediaPlayerProviderMock(), + userIndicatorController: userIndicatorController, + appMediator: AppMediatorMock(.init()), + appSettings: appSettings, + analyticsService: AnalyticsServiceMock(.init()), + emojiProvider: EmojiProvider(appSettings: appSettings), + linkMetadataProvider: LinkMetadataProvider(), + timelineControllerFactory: TimelineControllerFactoryMock(.init())) + } + + private func makeSelectionViewModel(items: [RoomTimelineItemProtocol], + userIndicatorController: UserIndicatorControllerProtocol = UserIndicatorControllerMock()) -> TimelineViewModel { + makeSelectionViewModel(timelineController: TimelineControllerMock(.init(timelineItems: items)), + userIndicatorController: userIndicatorController) + } + + private func makeSelectionViewModel(timelineController: TimelineControllerProtocol, + userIndicatorController: UserIndicatorControllerProtocol = UserIndicatorControllerMock()) -> TimelineViewModel { let appSettings = AppSettings.volatile() - - return TimelineViewModel(roomProxy: roomProxy ?? JoinedRoomProxyMock(.init(name: "")), - focussedEventID: focussedEventID, - 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())) + appSettings.messageMultiSelectEnabled = true + return makeViewModel(timelineController: timelineController, + userIndicatorController: userIndicatorController, + appSettings: appSettings) } }