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
}
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 {
@@ -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:
@@ -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