From 50b38555b6cc3129d2d8033010e3eef5a6ad2844 Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Tue, 29 Sep 2026 14:54:59 +0300 Subject: [PATCH] Allow removing individual search breadcrumbs Each recent search now has a trailing close button to remove it from the history. The view action is logged without its payload so removed queries don't end up in the logs. --- .../SearchScreen/SearchScreenModels.swift | 16 ++++++++++- .../SearchScreen/SearchScreenViewModel.swift | 10 +++++++ .../SearchScreen/View/SearchScreen.swift | 28 +++++++++++++++++-- ...earchScreen.Recent-searches-iPad-en-GB.png | 4 +-- ...archScreen.Recent-searches-iPad-pseudo.png | 4 +-- ...rchScreen.Recent-searches-iPhone-en-GB.png | 4 +-- ...chScreen.Recent-searches-iPhone-pseudo.png | 4 +-- .../Sources/SearchScreenViewModelTests.swift | 13 +++++++++ 8 files changed, 72 insertions(+), 11 deletions(-) diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift index fe17a27c7..949cb29fe 100644 --- a/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenModels.swift @@ -46,14 +46,28 @@ struct SearchScreenViewStateBindings { var searchMode: SearchScreenMode = .rooms } -enum SearchScreenViewAction { +enum SearchScreenViewAction: CustomStringConvertible { case appeared case submitQuery case selectRoom(roomID: String) case selectMessage(roomID: String, eventID: String) + case removeBreadcrumb(SearchScreenBreadcrumb) case reachedTop case reachedBottom case cancel + + var description: String { + switch self { + case .appeared: "appeared" + case .submitQuery: "submitQuery" + case .selectRoom(let roomID): "selectRoom(\(roomID))" + case .selectMessage(let roomID, let eventID): "selectMessage(\(roomID), \(eventID))" + case .removeBreadcrumb: "removeBreadcrumb" + case .reachedTop: "reachedTop" + case .reachedBottom: "reachedBottom" + case .cancel: "cancel" + } + } } struct SearchScreenRoom: Identifiable, Equatable { diff --git a/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift b/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift index e022e7e32..fc42b4415 100644 --- a/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift +++ b/ElementX/Sources/Screens/SearchScreen/SearchScreenViewModel.swift @@ -147,6 +147,8 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro case .selectMessage(let roomID, let eventID): recordBreadcrumb(.room(roomID: roomID)) actionsSubject.send(.presentRoom(roomID: roomID, eventID: eventID)) + case .removeBreadcrumb(let breadcrumb): + removeBreadcrumb(breadcrumb) case .reachedTop: if state.bindings.searchMode == .rooms { updateVisibleRange(edge: .top) @@ -222,6 +224,14 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro userSettings.searchBreadcrumbs = Array(breadcrumbs.prefix(Self.maximumBreadcrumbCount)) } + private func removeBreadcrumb(_ breadcrumb: SearchScreenBreadcrumb) { + let storedBreadcrumb: SearchBreadcrumb = switch breadcrumb { + case .query(let query): .query(query) + case .room(let room): .room(roomID: room.id) + } + userSettings.searchBreadcrumbs.removeAll { $0 == storedBreadcrumb } + } + private func setActiveTabLoading(_ isLoading: Bool) { switch state.bindings.searchMode { case .rooms: diff --git a/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift b/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift index 8c14cf989..06ee73082 100644 --- a/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift +++ b/ElementX/Sources/Screens/SearchScreen/View/SearchScreen.swift @@ -199,11 +199,13 @@ struct SearchScreen: View { ForEach(context.viewState.breadcrumbs) { breadcrumb in let isSelected = isHardwareKeyboardConnected && selectedID == breadcrumb.id + let onRemove = { context.send(viewAction: .removeBreadcrumb(breadcrumb)) } + switch breadcrumb { case .query(let query): - SearchScreenQueryCell(query: query, isSelected: isSelected) { context.searchQuery = query } + SearchScreenQueryCell(query: query, isSelected: isSelected, onRemove: onRemove) { context.searchQuery = query } case .room(let room): - SearchScreenRoomCell(room: room, context: context, isSelected: isSelected, avatarSize: .searchHistory) + SearchScreenRoomCell(room: room, context: context, isSelected: isSelected, avatarSize: .searchHistory, onRemove: onRemove) } } } header: { @@ -283,6 +285,7 @@ private struct SearchScreenRoomCell: View { let context: SearchScreenViewModel.Context let isSelected: Bool var avatarSize: RoomAvatarSizeOnScreen = .search + var onRemove: (() -> Void)? @Environment(\.dynamicTypeSize) private var dynamicTypeSize @@ -307,6 +310,10 @@ private struct SearchScreenRoomCell: View { } } .frame(maxWidth: .infinity, alignment: .leading) + + if let onRemove { + SearchScreenRemoveBreadcrumbButton(action: onRemove) + } } .padding(.horizontal, 16) .padding(.vertical, 12) @@ -360,6 +367,7 @@ private struct SearchScreenRoomCellButtonStyle: ButtonStyle { private struct SearchScreenQueryCell: View { let query: String let isSelected: Bool + let onRemove: () -> Void let action: () -> Void var body: some View { @@ -382,6 +390,8 @@ private struct SearchScreenQueryCell: View { .lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) + + SearchScreenRemoveBreadcrumbButton(action: onRemove) } .padding(.horizontal, 16) .padding(.vertical, 12) @@ -393,6 +403,20 @@ private struct SearchScreenQueryCell: View { } } +private struct SearchScreenRemoveBreadcrumbButton: View { + let action: () -> Void + + var body: some View { + Button(action: action) { + CompoundIcon(\.close) + .foregroundStyle(.compound.iconTertiary) + .accessibilityLabel(L10n.actionRemove) + } + // Borderless so the button handles its own taps instead of the row it's nested in. + .buttonStyle(.borderless) + } +} + private struct SearchScreenMessageCell: View { let message: SearchScreenMessage let context: SearchScreenViewModel.Context diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPad-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPad-en-GB.png index 47fae9a69..34911adfd 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPad-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPad-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:64633ca3b3c02ea5232a753cca290aec12a76541405ce5ef23735270de1bfd2b -size 105464 +oid sha256:4bc96637d784a679d6be590589758889a367355ee32bfe970a82e2f6079c3bbe +size 107266 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPad-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPad-pseudo.png index 187304dc0..fd4255f67 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPad-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPad-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9d910a4120f4e0971d5a4d78673b0ec0faeac890f02a3efb2bf66b4908e66981 -size 107678 +oid sha256:37dc8e32e88ead1940ae69f7b21426e0ec93136061fef5aa217962f8a2b3a9a8 +size 109477 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPhone-en-GB.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPhone-en-GB.png index c7e68edcd..7b918cd20 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPhone-en-GB.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPhone-en-GB.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2c4147f5fa913d830a6dc54c876458633b2532b9f50ca04a6287fc3253cfde65 -size 59617 +oid sha256:316e278e53ff463ac5a232b4767a420b31c09cc2da2d22e302dccee9d36f6520 +size 61192 diff --git a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPhone-pseudo.png b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPhone-pseudo.png index 141ec74f0..08a441271 100644 --- a/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPhone-pseudo.png +++ b/PreviewTests/Sources/__Snapshots__/PreviewTests/searchScreen.Recent-searches-iPhone-pseudo.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:92654ae660070262a38777684a38894d802488f98f54f6729d230787f7bee83c -size 62130 +oid sha256:437431e2bf9b05ebedb52638034bb367ab6990ec3ecdeb4d930b8a3d9078ed11 +size 63703 diff --git a/UnitTests/Sources/SearchScreenViewModelTests.swift b/UnitTests/Sources/SearchScreenViewModelTests.swift index 22c2230f0..b26b2f202 100644 --- a/UnitTests/Sources/SearchScreenViewModelTests.swift +++ b/UnitTests/Sources/SearchScreenViewModelTests.swift @@ -149,6 +149,19 @@ struct SearchScreenViewModelTests { #expect(userSettings.searchBreadcrumbs == [.room(roomID: "2"), .query("Second")]) } + @Test + func removingBreadcrumbs() { + userSettings.searchBreadcrumbs = [.query("Second"), .room(roomID: "2")] + + context.send(viewAction: .removeBreadcrumb(.query("Second"))) + + #expect(userSettings.searchBreadcrumbs == [.room(roomID: "2")]) + + context.send(viewAction: .removeBreadcrumb(.room(SearchScreenRoom(.mock(id: "2", name: "Second"))))) + + #expect(userSettings.searchBreadcrumbs.isEmpty) + } + @Test func breadcrumbsAreDisplayed() async throws { staticRoomListSubject.send([.mock(id: "2", name: "Second")])