Merge 778752ce17a77e9a2d1376733556861da9ff8b16 into 9b84b361225facd8e623f25d76f889d3dc54a879

This commit is contained in:
Taksh Kothari 2026-08-11 09:05:57 +02:00 committed by GitHub
commit 9151353fac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 904 additions and 16 deletions

View File

@ -19396,7 +19396,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" : {
@ -35151,6 +35151,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" : {

View File

@ -0,0 +1,22 @@
//
// PeopleNameFilter.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
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 {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return true }
return displayName.localizedCaseInsensitiveContains(trimmed)
}
}

View File

@ -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)

View File

@ -321,6 +321,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) {
@ -363,6 +375,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)
@ -378,7 +419,8 @@ private struct ContentPeopleListView: View {
GeohashPeopleList(
onTapPerson: {
showSidebar = true
}
},
nameFilter: nameFilter
)
// Direct conversations survive channel switches; the
// geoDM someone opened from another cell must stay
@ -413,17 +455,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
)
// Conversations with people no roster above lists
// anymore — without this, a read DM from an offline

View File

@ -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,14 +35,42 @@ struct GeohashPeopleList: View {
.padding(.top, 12)
}
} else {
let people = peerListModel.geohashPeople
let currentIDs = people.map(\.id)
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)
}
if people.isEmpty && filterActive {
VStack(alignment: .leading, spacing: 0) {
Text(Strings.noMatches)
.bitchatFont(size: 14)
.foregroundColor(palette.secondary)
.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 = 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
@ -142,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
}
@ -152,6 +184,7 @@ struct GeohashPeopleList: View {
for id in ids where !newOrder.contains(id) { newOrder.append(id) }
if newOrder != orderedIDs { orderedIDs = newOrder }
}
}
}
}

View File

@ -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)

View File

@ -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,21 @@ 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)
.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..<peers.count, id: \.self) { idx in

View File

@ -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"))
}
}