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.
This commit is contained in:
Stefan Ceriu
2026-09-29 14:54:59 +03:00
parent 313365ad83
commit 50b38555b6
8 changed files with 72 additions and 11 deletions
@@ -46,14 +46,28 @@ struct SearchScreenViewStateBindings {
var searchMode: SearchScreenMode = .rooms var searchMode: SearchScreenMode = .rooms
} }
enum SearchScreenViewAction { enum SearchScreenViewAction: CustomStringConvertible {
case appeared case appeared
case submitQuery case submitQuery
case selectRoom(roomID: String) case selectRoom(roomID: String)
case selectMessage(roomID: String, eventID: String) case selectMessage(roomID: String, eventID: String)
case removeBreadcrumb(SearchScreenBreadcrumb)
case reachedTop case reachedTop
case reachedBottom case reachedBottom
case cancel 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 { struct SearchScreenRoom: Identifiable, Equatable {
@@ -147,6 +147,8 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
case .selectMessage(let roomID, let eventID): case .selectMessage(let roomID, let eventID):
recordBreadcrumb(.room(roomID: roomID)) recordBreadcrumb(.room(roomID: roomID))
actionsSubject.send(.presentRoom(roomID: roomID, eventID: eventID)) actionsSubject.send(.presentRoom(roomID: roomID, eventID: eventID))
case .removeBreadcrumb(let breadcrumb):
removeBreadcrumb(breadcrumb)
case .reachedTop: case .reachedTop:
if state.bindings.searchMode == .rooms { if state.bindings.searchMode == .rooms {
updateVisibleRange(edge: .top) updateVisibleRange(edge: .top)
@@ -222,6 +224,14 @@ class SearchScreenViewModel: SearchScreenViewModelType, SearchScreenViewModelPro
userSettings.searchBreadcrumbs = Array(breadcrumbs.prefix(Self.maximumBreadcrumbCount)) 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) { private func setActiveTabLoading(_ isLoading: Bool) {
switch state.bindings.searchMode { switch state.bindings.searchMode {
case .rooms: case .rooms:
@@ -199,11 +199,13 @@ struct SearchScreen: View {
ForEach(context.viewState.breadcrumbs) { breadcrumb in ForEach(context.viewState.breadcrumbs) { breadcrumb in
let isSelected = isHardwareKeyboardConnected && selectedID == breadcrumb.id let isSelected = isHardwareKeyboardConnected && selectedID == breadcrumb.id
let onRemove = { context.send(viewAction: .removeBreadcrumb(breadcrumb)) }
switch breadcrumb { switch breadcrumb {
case .query(let query): 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): 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: { } header: {
@@ -283,6 +285,7 @@ private struct SearchScreenRoomCell: View {
let context: SearchScreenViewModel.Context let context: SearchScreenViewModel.Context
let isSelected: Bool let isSelected: Bool
var avatarSize: RoomAvatarSizeOnScreen = .search var avatarSize: RoomAvatarSizeOnScreen = .search
var onRemove: (() -> Void)?
@Environment(\.dynamicTypeSize) private var dynamicTypeSize @Environment(\.dynamicTypeSize) private var dynamicTypeSize
@@ -307,6 +310,10 @@ private struct SearchScreenRoomCell: View {
} }
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
if let onRemove {
SearchScreenRemoveBreadcrumbButton(action: onRemove)
}
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
@@ -360,6 +367,7 @@ private struct SearchScreenRoomCellButtonStyle: ButtonStyle {
private struct SearchScreenQueryCell: View { private struct SearchScreenQueryCell: View {
let query: String let query: String
let isSelected: Bool let isSelected: Bool
let onRemove: () -> Void
let action: () -> Void let action: () -> Void
var body: some View { var body: some View {
@@ -382,6 +390,8 @@ private struct SearchScreenQueryCell: View {
.lineLimit(1) .lineLimit(1)
} }
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
SearchScreenRemoveBreadcrumbButton(action: onRemove)
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .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 { private struct SearchScreenMessageCell: View {
let message: SearchScreenMessage let message: SearchScreenMessage
let context: SearchScreenViewModel.Context let context: SearchScreenViewModel.Context
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:64633ca3b3c02ea5232a753cca290aec12a76541405ce5ef23735270de1bfd2b oid sha256:4bc96637d784a679d6be590589758889a367355ee32bfe970a82e2f6079c3bbe
size 105464 size 107266
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9d910a4120f4e0971d5a4d78673b0ec0faeac890f02a3efb2bf66b4908e66981 oid sha256:37dc8e32e88ead1940ae69f7b21426e0ec93136061fef5aa217962f8a2b3a9a8
size 107678 size 109477
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2c4147f5fa913d830a6dc54c876458633b2532b9f50ca04a6287fc3253cfde65 oid sha256:316e278e53ff463ac5a232b4767a420b31c09cc2da2d22e302dccee9d36f6520
size 59617 size 61192
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:92654ae660070262a38777684a38894d802488f98f54f6729d230787f7bee83c oid sha256:437431e2bf9b05ebedb52638034bb367ab6990ec3ecdeb4d930b8a3d9078ed11
size 62130 size 63703
@@ -149,6 +149,19 @@ struct SearchScreenViewModelTests {
#expect(userSettings.searchBreadcrumbs == [.room(roomID: "2"), .query("Second")]) #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 @Test
func breadcrumbsAreDisplayed() async throws { func breadcrumbsAreDisplayed() async throws {
staticRoomListSubject.send([.mock(id: "2", name: "Second")]) staticRoomListSubject.send([.mock(id: "2", name: "Second")])