From 5b5adf2e2ee0a1303c71e9161328baeaa07e2ed3 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 1 Aug 2026 14:08:13 +0300 Subject: [PATCH 01/11] feat: add PeopleNameFilter for people-sheet search Pure case-insensitive display-name match so mesh, geohash, bridge, and group lists can share one query without duplicating trim/empty rules. --- bitchat/Utils/PeopleNameFilter.swift | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 bitchat/Utils/PeopleNameFilter.swift diff --git a/bitchat/Utils/PeopleNameFilter.swift b/bitchat/Utils/PeopleNameFilter.swift new file mode 100644 index 00000000..3e3fafdd --- /dev/null +++ b/bitchat/Utils/PeopleNameFilter.swift @@ -0,0 +1,19 @@ +// +// PeopleNameFilter.swift +// bitchat +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +import Foundation + +/// Case-insensitive nickname / display-name match for the people sheet search field. +enum PeopleNameFilter { + /// Empty / whitespace-only queries match everything (no filter active). + static func matches(_ displayName: String, query: String) -> Bool { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return true } + return displayName.localizedCaseInsensitiveContains(trimmed) + } +} From addcc3e70165816c1654cdda3380ebf8e7b0f25f Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 1 Aug 2026 14:08:57 +0300 Subject: [PATCH 02/11] feat: filter mesh, geohash, bridge, and group rows by name Each people-sheet section accepts an optional nameFilter so a shared search field can narrow nicknames without reshaping PeerListModel. --- bitchat/Views/BridgePeopleList.swift | 9 +++++++-- bitchat/Views/GeohashPeopleList.swift | 20 ++++++++++++++++++-- bitchat/Views/GroupChatList.swift | 7 +++++-- bitchat/Views/MeshPeerList.swift | 12 +++++++++++- 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/bitchat/Views/BridgePeopleList.swift b/bitchat/Views/BridgePeopleList.swift index 89162397..0ab2463d 100644 --- a/bitchat/Views/BridgePeopleList.swift +++ b/bitchat/Views/BridgePeopleList.swift @@ -39,6 +39,8 @@ struct PeopleSectionHeader: View { struct BridgePeopleList: View { @ObservedObject private var bridgeService = BridgeService.shared @ThemedPalette private var palette + /// People-sheet search query; empty means show every row. + var nameFilter: String = "" private enum Strings { static let sectionTitle = String(localized: "bridge_people.section_title", defaultValue: "across the bridge", comment: "Section header in the people sheet for participants reachable via the mesh bridge") @@ -50,7 +52,10 @@ struct BridgePeopleList: View { // (a serving neighbor's carriers) even while this device's own // bridge is off — whoever is visible in the timeline belongs in the // sheet. - if !bridgeService.bridgedParticipants.isEmpty { + let people = bridgeService.bridgedParticipants.filter { + PeopleNameFilter.matches($0.displayName, query: nameFilter) + } + if !people.isEmpty { VStack(alignment: .leading, spacing: 0) { PeopleSectionHeader( icon: "network", @@ -58,7 +63,7 @@ struct BridgePeopleList: View { title: Strings.sectionTitle ) - ForEach(bridgeService.bridgedParticipants) { person in + ForEach(people) { person in HStack(spacing: 4) { Text(person.displayName) .bitchatFont(size: 14) diff --git a/bitchat/Views/GeohashPeopleList.swift b/bitchat/Views/GeohashPeopleList.swift index 9613f227..70b8189c 100644 --- a/bitchat/Views/GeohashPeopleList.swift +++ b/bitchat/Views/GeohashPeopleList.swift @@ -4,13 +4,16 @@ struct GeohashPeopleList: View { @EnvironmentObject private var peerListModel: PeerListModel @ThemedPalette private var palette let onTapPerson: () -> Void + /// People-sheet search query; empty means show every row. + var nameFilter: String = "" @Environment(\.colorScheme) var colorScheme @State private var orderedIDs: [String] = [] private enum Strings { static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby" + static let noMatches: LocalizedStringKey = "content.people.search.no_matches" static let youSuffix: LocalizedStringKey = "geohash_people.you_suffix" - static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to users blocked in geohash channels") + static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to people blocked in geohash channels") static let unblock: LocalizedStringKey = "geohash_people.action.unblock" static let block: LocalizedStringKey = "geohash_people.action.block" static let unblockText = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person") @@ -32,7 +35,19 @@ struct GeohashPeopleList: View { .padding(.top, 12) } } else { - let people = peerListModel.geohashPeople + let people = peerListModel.geohashPeople.filter { + PeopleNameFilter.matches($0.displayName, query: nameFilter) + } + let filterActive = !nameFilter.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + if people.isEmpty && filterActive { + VStack(alignment: .leading, spacing: 0) { + Text(Strings.noMatches) + .bitchatFont(size: 14) + .foregroundColor(palette.secondary) + .padding(.horizontal) + .padding(.top, 12) + } + } else { let currentIDs = people.map(\.id) let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) } @@ -152,6 +167,7 @@ struct GeohashPeopleList: View { for id in ids where !newOrder.contains(id) { newOrder.append(id) } if newOrder != orderedIDs { orderedIDs = newOrder } } + } } } diff --git a/bitchat/Views/GroupChatList.swift b/bitchat/Views/GroupChatList.swift index 4dff5c1d..e79d75b7 100644 --- a/bitchat/Views/GroupChatList.swift +++ b/bitchat/Views/GroupChatList.swift @@ -8,6 +8,8 @@ struct GroupChatList: View { let groups: [GroupChatRow] let onTapGroup: (PeerID) -> Void + /// People-sheet search query; empty means show every row. + var nameFilter: String = "" private enum Strings { static let header = String(localized: "groups.section.header", comment: "Section header above the private groups list") @@ -19,7 +21,8 @@ struct GroupChatList: View { } var body: some View { - if !groups.isEmpty { + let visible = groups.filter { PeopleNameFilter.matches($0.name, query: nameFilter) } + if !visible.isEmpty { VStack(alignment: .leading, spacing: 0) { // Same glyph+label header shape as #mesh / across the bridge. PeopleSectionHeader( @@ -28,7 +31,7 @@ struct GroupChatList: View { title: Strings.header ) - ForEach(groups) { group in + ForEach(visible) { group in HStack(spacing: 4) { Text("#\(group.name)") .bitchatFont(size: 14) diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index 1d94af5f..6dc42f09 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -10,12 +10,15 @@ struct MeshPeerList: View { /// Optional so existing call sites (and previews/tests) keep compiling; /// when absent the block/unblock context-menu entry is hidden. var onToggleBlock: ((MeshPeerRow) -> Void)? = nil + /// People-sheet search query; empty means show every row. + var nameFilter: String = "" @Environment(\.colorScheme) var colorScheme @State private var orderedIDs: [String] = [] private enum Strings { static let noneNearby: LocalizedStringKey = "geohash_people.none_nearby" + static let noMatches: LocalizedStringKey = "content.people.search.no_matches" static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to a blocked peer indicator") static let newMessagesTooltip = String(localized: "mesh_peers.tooltip.new_messages", comment: "Tooltip for the unread messages indicator") static let connected = String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator") @@ -41,7 +44,8 @@ struct MeshPeerList: View { let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) } let peers: [MeshPeerRow] = displayIDs.compactMap { id in peerListModel.meshRows.first(where: { $0.id == id }) - } + }.filter { PeopleNameFilter.matches($0.displayName, query: nameFilter) } + let filterActive = !nameFilter.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty if peerListModel.meshRows.isEmpty { // Match the section's row rhythm (same size, indent, and vertical @@ -52,6 +56,12 @@ struct MeshPeerList: View { .foregroundColor(palette.secondary) .padding(.horizontal) .padding(.vertical, 4) + } else if peers.isEmpty && filterActive { + Text(Strings.noMatches) + .bitchatFont(size: 14) + .foregroundColor(palette.secondary) + .padding(.horizontal) + .padding(.vertical, 4) } else { VStack(alignment: .leading, spacing: 0) { ForEach(0.. Date: Sat, 1 Aug 2026 14:09:40 +0300 Subject: [PATCH 03/11] feat: add nickname search field to the people sheet Places a compact filter above the scroll list and threads the query into mesh, geohash, bridge, and group sections. --- bitchat/Views/ContentSheetViews.swift | 52 ++++++++++++++++++++++++--- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/bitchat/Views/ContentSheetViews.swift b/bitchat/Views/ContentSheetViews.swift index a4092b43..b6e6de1b 100644 --- a/bitchat/Views/ContentSheetViews.swift +++ b/bitchat/Views/ContentSheetViews.swift @@ -306,6 +306,18 @@ private struct ContentPeopleListView: View { @Binding var showSidebar: Bool @Binding var showVerifySheet: Bool + @State private var nameFilter = "" + + private enum Strings { + static let searchPlaceholder = String( + localized: "content.people.search.placeholder", + comment: "Placeholder for the people-sheet nickname search field" + ) + static let searchAccessibility = String( + localized: "content.people.search.accessibility", + comment: "Accessibility label for the people-sheet nickname search field" + ) + } var body: some View { VStack(spacing: 0) { @@ -348,6 +360,35 @@ private struct ContentPeopleListView: View { .bitchatFont(size: 12) .foregroundColor(palette.locationAccent) } + + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.bitchatSystem(size: 12)) + .foregroundColor(palette.secondary) + TextField(Strings.searchPlaceholder, text: $nameFilter) + .bitchatFont(size: 14) + .textFieldStyle(.plain) + .autocorrectionDisabled() + #if os(iOS) + .textInputAutocapitalization(.never) + #endif + .accessibilityLabel(Strings.searchAccessibility) + if !nameFilter.isEmpty { + Button { + nameFilter = "" + } label: { + Image(systemName: "xmark.circle.fill") + .font(.bitchatSystem(size: 12)) + .foregroundColor(palette.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel(String(localized: "common.clear", comment: "Clear the current text field")) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background(palette.secondary.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) } .padding(.horizontal, 16) .padding(.top, 16) @@ -363,7 +404,8 @@ private struct ContentPeopleListView: View { GeohashPeopleList( onTapPerson: { showSidebar = true - } + }, + nameFilter: nameFilter ) } else { PeopleSectionHeader( @@ -388,17 +430,19 @@ private struct ContentPeopleListView: View { } else { conversationUIModel.block(peerID: peer.peerID, displayName: peer.displayName) } - } + }, + nameFilter: nameFilter ) // People in this area but beyond radio range, and // private groups: one sheet for the whole room. - BridgePeopleList() + BridgePeopleList(nameFilter: nameFilter) GroupChatList( groups: peerListModel.groupRows, onTapGroup: { peerID in peerListModel.startConversation(with: peerID) showSidebar = true - } + }, + nameFilter: nameFilter ) } } From c91fb50084e0f66db8f3254a8b8c2a3c49a6ef36 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 1 Aug 2026 14:11:00 +0300 Subject: [PATCH 04/11] i18n: catalog people-sheet search strings across locales Adds placeholder, accessibility label, no-matches empty state, and a shared clear action; also prefers "people" over "users" in the active- count comment. --- bitchat/Localizable.xcstrings | 742 +++++++++++++++++++++++++++++++++- 1 file changed, 741 insertions(+), 1 deletion(-) diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index c43da7a2..51edb268 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -2470,7 +2470,7 @@ "shouldTranslate" : false }, "%@ active" : { - "comment" : "A label at the bottom of the people list sheet showing the number of active users.", + "comment" : "A label at the bottom of the people list sheet showing the number of active people.", "extractionState" : "stale", "localizations" : { "ar" : { @@ -18225,6 +18225,746 @@ } } }, + "common.clear" : { + "comment" : "Clear the current text field", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "مسح" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "মুছুন" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "löschen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "clear" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "borrar" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "پاک کردن" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "i-clear" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "effacer" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "נקה" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "साफ़ करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "hapus" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "cancella" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "クリア" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "지우기" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "kosongkan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "खाली" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wissen" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "wyczyść" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "limpar" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "limpar" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "очистить" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "rensa" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "அழி" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ล้าง" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "temizle" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "очистити" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "صاف کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "xóa" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除" + } + } + } + }, + "content.people.search.accessibility" : { + "comment" : "Accessibility label for the people-sheet nickname search field", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تصفية الأشخاص حسب اللقب" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "ডাকনাম দিয়ে মানুষ ফিল্টার" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Personen nach Spitznamen filtern" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filter people by nickname" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filtrar personas por apodo" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "فیلتر افراد بر اساس نام مستعار" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "I-filter ang mga tao ayon sa palayaw" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filtrer les personnes par surnom" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "סינון אנשים לפי כינוי" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "उपनाम से लोग फ़िल्टर करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filter orang berdasarkan nama panggilan" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filtra persone per soprannome" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "ニックネームで人を絞り込む" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "닉네임으로 사람 필터" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tapis orang mengikut nama samaran" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "उपनामबाट मानिस फिल्टर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Personen filteren op bijnaam" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filtruj osoby po pseudonimie" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filtrar pessoas por alcunha" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filtrar pessoas por apelido" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Фильтровать людей по нику" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "Filtrera personer efter smeknamn" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "புனைப்பெயரால் நபர்களை வடிகட்டு" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "กรองผู้คนตามชื่อเล่น" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kişileri takma ada göre filtrele" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "Фільтрувати людей за нікнеймом" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "عرفی نام سے لوگ فلٹر کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lọc mọi người theo biệt danh" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "按昵称筛选联系人" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "依暱稱篩選聯絡人" + } + } + } + }, + "content.people.search.no_matches" : { + "comment" : "Empty state when people-sheet search matches no nicknames", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "لا نتائج" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "কোনো মিল নেই" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "keine treffer" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "no matches" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "sin coincidencias" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "موردی نیست" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "walang tugma" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "aucune correspondance" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "אין התאמות" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "कोई मेल नहीं" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "tidak ada hasil" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "nessuna corrispondenza" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "一致なし" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "일치 없음" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tiada padanan" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "मिल्ने छैन" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "geen resultaten" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "brak wyników" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "sem resultados" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "sem resultados" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "нет совпадений" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "inga träffar" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "பொருத்தம் இல்லை" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "ไม่พบรายการ" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "eş yok" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "немає збігів" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "کوئی مماثلت نہیں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "không khớp" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无匹配" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無相符項目" + } + } + } + }, + "content.people.search.placeholder" : { + "comment" : "Placeholder for the people-sheet nickname search field", + "localizations" : { + "ar" : { + "stringUnit" : { + "state" : "translated", + "value" : "تصفية الأشخاص" + } + }, + "bn" : { + "stringUnit" : { + "state" : "translated", + "value" : "মানুষ ফিল্টার" + } + }, + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "personen filtern" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "filter people" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "filtrar personas" + } + }, + "fa" : { + "stringUnit" : { + "state" : "translated", + "value" : "فیلتر افراد" + } + }, + "fil" : { + "stringUnit" : { + "state" : "translated", + "value" : "i-filter ang mga tao" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "filtrer les personnes" + } + }, + "he" : { + "stringUnit" : { + "state" : "translated", + "value" : "סינון אנשים" + } + }, + "hi" : { + "stringUnit" : { + "state" : "translated", + "value" : "लोग फ़िल्टर करें" + } + }, + "id" : { + "stringUnit" : { + "state" : "translated", + "value" : "filter orang" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "filtra persone" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "人を絞り込む" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "사람 필터" + } + }, + "ms" : { + "stringUnit" : { + "state" : "translated", + "value" : "tapis orang" + } + }, + "ne" : { + "stringUnit" : { + "state" : "translated", + "value" : "मानिस फिल्टर" + } + }, + "nl" : { + "stringUnit" : { + "state" : "translated", + "value" : "personen filteren" + } + }, + "pl" : { + "stringUnit" : { + "state" : "translated", + "value" : "filtruj osoby" + } + }, + "pt" : { + "stringUnit" : { + "state" : "translated", + "value" : "filtrar pessoas" + } + }, + "pt-BR" : { + "stringUnit" : { + "state" : "translated", + "value" : "filtrar pessoas" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "фильтр людей" + } + }, + "sv" : { + "stringUnit" : { + "state" : "translated", + "value" : "filtrera personer" + } + }, + "ta" : { + "stringUnit" : { + "state" : "translated", + "value" : "நபர்களை வடிகட்டு" + } + }, + "th" : { + "stringUnit" : { + "state" : "translated", + "value" : "กรองผู้คน" + } + }, + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "kişileri filtrele" + } + }, + "uk" : { + "stringUnit" : { + "state" : "translated", + "value" : "фільтр людей" + } + }, + "ur" : { + "stringUnit" : { + "state" : "translated", + "value" : "لوگ فلٹر کریں" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "lọc mọi người" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "筛选联系人" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "篩選聯絡人" + } + } + } + }, "common.close" : { "extractionState" : "manual", "localizations" : { From 285e82b852a0b598cd27b991185ec09ed27021d3 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 1 Aug 2026 14:11:00 +0300 Subject: [PATCH 05/11] test: cover PeopleNameFilter empty and case-insensitive matches Keeps the people-sheet search helper honest without mounting SwiftUI. --- .../Utils/PeopleNameFilterTests.swift | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 bitchatTests/Utils/PeopleNameFilterTests.swift diff --git a/bitchatTests/Utils/PeopleNameFilterTests.swift b/bitchatTests/Utils/PeopleNameFilterTests.swift new file mode 100644 index 00000000..f93c51e8 --- /dev/null +++ b/bitchatTests/Utils/PeopleNameFilterTests.swift @@ -0,0 +1,22 @@ +import Testing +@testable import bitchat + +struct PeopleNameFilterTests { + @Test func emptyQueryMatchesEverything() { + #expect(PeopleNameFilter.matches("alice", query: "")) + #expect(PeopleNameFilter.matches("alice", query: " ")) + } + + @Test func matchesAreCaseInsensitive() { + #expect(PeopleNameFilter.matches("Alice#abcd", query: "ali")) + #expect(PeopleNameFilter.matches("Bob", query: "BOB")) + } + + @Test func nonMatchingQueryRejects() { + #expect(!PeopleNameFilter.matches("alice", query: "bob")) + } + + @Test func matchesSubstringInsideNickname() { + #expect(PeopleNameFilter.matches("cool_alice", query: "ali")) + } +} From 4bf37340bdc4469986e0d8ba035fb1c72b2ea720 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 1 Aug 2026 14:11:05 +0300 Subject: [PATCH 06/11] docs: note people-sheet filter is display-only Filtering never mutates PeerListModel row order; clearing the query restores the full list with existing sticky ordering intact. --- bitchat/Utils/PeopleNameFilter.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bitchat/Utils/PeopleNameFilter.swift b/bitchat/Utils/PeopleNameFilter.swift index 3e3fafdd..0a10fe7d 100644 --- a/bitchat/Utils/PeopleNameFilter.swift +++ b/bitchat/Utils/PeopleNameFilter.swift @@ -9,6 +9,9 @@ import Foundation /// Case-insensitive nickname / display-name match for the people sheet search field. +/// +/// Filtering is display-only: callers never mutate `PeerListModel` row order. +/// Clearing the query restores the full list with existing sticky ordering. enum PeopleNameFilter { /// Empty / whitespace-only queries match everything (no filter active). static func matches(_ displayName: String, query: String) -> Bool { From f59285bc6d21ac04c6d9f74f2b09ccc82613221e Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 1 Aug 2026 14:41:44 +0300 Subject: [PATCH 07/11] fix: keep geohash people sticky order independent of name filter --- bitchat/Views/GeohashPeopleList.swift | 29 +++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/bitchat/Views/GeohashPeopleList.swift b/bitchat/Views/GeohashPeopleList.swift index 70b8189c..263f8116 100644 --- a/bitchat/Views/GeohashPeopleList.swift +++ b/bitchat/Views/GeohashPeopleList.swift @@ -35,10 +35,14 @@ struct GeohashPeopleList: View { .padding(.top, 12) } } else { - let people = peerListModel.geohashPeople.filter { + let allPeople = peerListModel.geohashPeople + // Sticky order must track the unfiltered roster — filtering before + // `currentIDs` would prune orderedIDs and reshuffle on clear (#1589 Codex). + let allIDs = allPeople.map(\.id) + let filterActive = !nameFilter.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + let people = allPeople.filter { PeopleNameFilter.matches($0.displayName, query: nameFilter) } - let filterActive = !nameFilter.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty if people.isEmpty && filterActive { VStack(alignment: .leading, spacing: 0) { Text(Strings.noMatches) @@ -47,14 +51,26 @@ struct GeohashPeopleList: View { .padding(.horizontal) .padding(.top, 12) } + .onAppear { + orderedIDs = allIDs + } + .onChange(of: allIDs) { ids in + var newOrder = orderedIDs + newOrder.removeAll { !ids.contains($0) } + for id in ids where !newOrder.contains(id) { newOrder.append(id) } + if newOrder != orderedIDs { orderedIDs = newOrder } + } } else { - let currentIDs = people.map(\.id) + let currentIDs = allIDs let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) } - let nonTele = displayIDs.filter { id in + let visibleIDs = displayIDs.filter { id in + people.contains(where: { $0.id == id }) + } + let nonTele = visibleIDs.filter { id in !(people.first(where: { $0.id == id })?.isTeleported ?? false) } - let tele = displayIDs.filter { id in + let tele = visibleIDs.filter { id in people.first(where: { $0.id == id })?.isTeleported ?? false } let finalOrder: [String] = nonTele + tele @@ -157,7 +173,8 @@ struct GeohashPeopleList: View { } } } - // Seed and update order outside result builder + // Seed and update order outside result builder — always from the + // full unfiltered ID list so search does not reshuffle sticky order. .onAppear { orderedIDs = currentIDs } From b5b4fde13d177e2e40467dfbf6f4d3e7233568b9 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sun, 2 Aug 2026 11:40:26 +0530 Subject: [PATCH 10/11] fix: preserve mesh people sticky order while search has no matches Keep orderedIDs synced from the unfiltered roster in the mesh no-matches branch so clearing search does not reshuffle rows. --- bitchat/Views/MeshPeerList.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bitchat/Views/MeshPeerList.swift b/bitchat/Views/MeshPeerList.swift index 6dc42f09..e55116bd 100644 --- a/bitchat/Views/MeshPeerList.swift +++ b/bitchat/Views/MeshPeerList.swift @@ -62,6 +62,15 @@ struct MeshPeerList: View { .foregroundColor(palette.secondary) .padding(.horizontal) .padding(.vertical, 4) + .onAppear { + orderedIDs = currentIDs + } + .onChange(of: currentIDs) { ids in + var newOrder = orderedIDs + newOrder.removeAll { !ids.contains($0) } + for id in ids where !newOrder.contains(id) { newOrder.append(id) } + if newOrder != orderedIDs { orderedIDs = newOrder } + } } else { VStack(alignment: .leading, spacing: 0) { ForEach(0..