feat: search messages within the current thread

Filters the open conversation by message body or sender nickname.

Uses a plain search field rather than .searchable. That modifier only
renders inside a NavigationStack/NavigationSplitView, and the public
timeline has none — ContentView builds MessageListView directly, so the
field never appeared where most conversation happens. Only the DM sheet,
which ContentPeopleSheetView wraps in a NavigationStack, would have shown
it. A plain field has no navigation dependency and renders the same in
both placements.

The field lives inside MessageListView so neither header needs changing
and one implementation serves both call sites. It collapses to a
magnifier until used, and resets when switching conversations so a stale
filter never applies to a different thread.

Results are capped at 200, newest first, with a line saying how many
matches were withheld. Search bypasses the message window, so an
uncapped common term rendered every historical match in one pass.

The accessibility label now sits on the search field itself; it was
applied to the whole list chain, which relabelled every message for
VoiceOver.
This commit is contained in:
Taksh 2026-08-11 22:44:57 +05:30
parent 9b84b36122
commit 77fa81bccc
4 changed files with 1253 additions and 11 deletions

View File

@ -13579,6 +13579,936 @@
}
}
},
"thread.search.accessibility.label" : {
"comment" : "Accessibility label for in-thread message search",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "ابحث في رسائل هذه المحادثة"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "এই কথোপকথনের বার্তা খোঁজো"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "nachrichten in dieser unterhaltung durchsuchen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "search messages in this conversation"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "buscar mensajes en esta conversación"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "جستجوی پیام‌های این گفتگو"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "maghanap ng mensahe sa usapang ito"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "rechercher des messages dans cette conversation"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "חיפוש הודעות בשיחה הזו"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "इस बातचीत के संदेश खोजें"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "cari pesan di percakapan ini"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "cerca messaggi in questa conversazione"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "この会話のメッセージを検索"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "이 대화의 메시지 검색"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "cari mesej dalam perbualan ini"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "यो कुराकानीका सन्देश खोज्नुहोस्"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "berichten in dit gesprek zoeken"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "szukaj wiadomości w tej rozmowie"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "pesquisar mensagens nesta conversa"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "buscar mensagens nesta conversa"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "искать сообщения в этой переписке"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "sök meddelanden i den här konversationen"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "இந்த உரையாடலின் செய்திகளைத் தேடு"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "ค้นหาข้อความในบทสนทนานี้"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "bu sohbetteki mesajlarda ara"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "шукати повідомлення в цій розмові"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "اس گفتگو کے پیغامات تلاش کریں"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "tìm tin nhắn trong cuộc trò chuyện này"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "搜索此对话中的消息"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "搜尋此對話中的訊息"
}
}
}
},
"thread.search.close" : {
"comment" : "Accessibility label for the button that closes the in-thread search field",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "إغلاق البحث"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "খোঁজা বন্ধ করো"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "suche schließen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "close search"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "cerrar búsqueda"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "بستن جستجو"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "isara ang paghahanap"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "fermer la recherche"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "סגירת החיפוש"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "खोज बंद करें"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "tutup pencarian"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "chiudi ricerca"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "検索を閉じる"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "검색 닫기"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "tutup carian"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "खोज बन्द गर्नुहोस्"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "zoeken sluiten"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "zamknij wyszukiwanie"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "fechar pesquisa"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "fechar busca"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "закрыть поиск"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "stäng sökning"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "தேடலை மூடு"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "ปิดการค้นหา"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "aramayı kapat"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "закрити пошук"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "تلاش بند کریں"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "đóng tìm kiếm"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "关闭搜索"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "關閉搜尋"
}
}
}
},
"thread.search.no_results" : {
"comment" : "Empty state when in-thread search finds no matching messages",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "لا توجد رسائل تطابق بحثك"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "তোমার খোঁজের সাথে কোনো বার্তা মেলেনি"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "keine nachrichten passen zu deiner suche"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "no messages match your search"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "ningún mensaje coincide con tu búsqueda"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "پیامی با جستجوی شما هم‌خوانی ندارد"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "walang mensaheng tumutugma sa paghahanap mo"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "aucun message ne correspond à ta recherche"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "אין הודעות שתואמות לחיפוש"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "आपकी खोज से कोई संदेश मेल नहीं खाता"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "tidak ada pesan yang cocok"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "nessun messaggio corrisponde alla ricerca"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "検索に一致するメッセージはありません"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "검색과 일치하는 메시지가 없습니다"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "tiada mesej sepadan dengan carian anda"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "तपाईंको खोजसँग मिल्ने सन्देश छैन"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "geen berichten komen overeen met je zoekopdracht"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "żadna wiadomość nie pasuje do wyszukiwania"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "nenhuma mensagem corresponde à tua pesquisa"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "nenhuma mensagem corresponde à sua busca"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "нет сообщений по вашему запросу"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "inga meddelanden matchar din sökning"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "உங்கள் தேடலுக்கு பொருந்தும் செய்திகள் இல்லை"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "ไม่มีข้อความที่ตรงกับการค้นหา"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "aramanla eşleşen mesaj yok"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "немає повідомлень за вашим запитом"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "آپ کی تلاش سے کوئی پیغام میل نہیں کھاتا"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "không có tin nhắn nào khớp với tìm kiếm"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "没有匹配的消息"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "沒有相符的訊息"
}
}
}
},
"thread.search.placeholder" : {
"comment" : "Placeholder for the in-thread message search field",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "ابحث في هذه المحادثة"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "এই কথোপকথনে খোঁজো"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "diese unterhaltung durchsuchen"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "search this conversation"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "buscar en esta conversación"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "در این گفتگو جستجو کنید"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "maghanap sa usapang ito"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "rechercher dans cette conversation"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "חיפוש בשיחה הזו"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "इस बातचीत में खोजें"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "cari di percakapan ini"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "cerca in questa conversazione"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "この会話を検索"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "이 대화 검색"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "cari dalam perbualan ini"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "यो कुराकानीमा खोज्नुहोस्"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "in dit gesprek zoeken"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "szukaj w tej rozmowie"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "pesquisar nesta conversa"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "buscar nesta conversa"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "искать в этой переписке"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "sök i den här konversationen"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "இந்த உரையாடலில் தேடு"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "ค้นหาในบทสนทนานี้"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "bu sohbette ara"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "шукати в цій розмові"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "اس گفتگو میں تلاش کریں"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "tìm trong cuộc trò chuyện này"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "搜索此对话"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "搜尋此對話"
}
}
}
},
"thread.search.truncated" : {
"comment" : "Shown when in-thread search results were capped; first %lld is the cap, second is the total match count",
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "عرض أحدث %lld من %lld نتيجة"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld টির মধ্যে সর্বশেষ %lld দেখানো হচ্ছে"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "die neuesten %lld von %lld treffern"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "showing the newest %lld of %lld matches"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "mostrando los %lld más recientes de %lld coincidencias"
}
},
"fa" : {
"stringUnit" : {
"state" : "translated",
"value" : "نمایش %lld مورد تازه از %lld"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "ipinapakita ang pinakabagong %lld sa %lld tugma"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "affichage des %lld correspondances les plus récentes sur %lld"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "מוצגות %lld התוצאות האחרונות מתוך %lld"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld में से नवीनतम %lld दिखा रहे हैं"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "menampilkan %lld terbaru dari %lld kecocokan"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "mostrate le %lld corrispondenze più recenti su %lld"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld 件中、新しい %lld 件を表示"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld개 중 최신 %lld개 표시"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "menunjukkan %lld terbaharu daripada %lld padanan"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld मध्ये नवीनतम %lld देखाइँदै"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "de nieuwste %lld van %lld overeenkomsten"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "pokazano %lld najnowszych z %lld dopasowań"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "a mostrar as %lld correspondências mais recentes de %lld"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "mostrando as %lld correspondências mais recentes de %lld"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "показаны последние %lld из %lld совпадений"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "visar de %lld senaste av %lld träffar"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld இல் புதிய %lld காட்டப்படுகிறது"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "แสดง %lld รายการล่าสุดจาก %lld รายการ"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld eşleşmeden en yeni %lld tanesi gösteriliyor"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "показано останні %lld із %lld збігів"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld میں سے تازہ ترین %lld دکھائے جا رہے ہیں"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "hiển thị %lld kết quả mới nhất trong %lld"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "显示 %lld 条匹配中最新的 %lld 条"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "顯示 %lld 條相符中最新的 %lld 條"
}
}
}
},
"notification.nearby.body_many" : {
"comment" : "Body of the nearby notification; placeholder is the peer count",
"extractionState" : "manual",

View File

@ -0,0 +1,54 @@
//
// ThreadMessageSearch.swift
// bitchat
//
// Filters the visible timeline for in-thread search.
//
import BitFoundation
import Foundation
enum ThreadMessageSearch {
/// Most matches rendered at once.
///
/// Searching bypasses the message window, so without a cap a common term
/// in a long thread renders every match in full history in one pass —
/// unbounded work on the main thread. The newest matches are kept, which
/// is the end of the thread people are usually looking at.
static let resultLimit = 200
/// Returns messages whose body or sender name contains `query`
/// (case-insensitive), newest-biased and capped at `resultLimit`. An empty
/// or whitespace-only query returns the original list unchanged.
static func matchingMessages(
in messages: [BitchatMessage],
query: String,
limit: Int = ThreadMessageSearch.resultLimit
) -> [BitchatMessage] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return messages }
let needle = trimmed.lowercased()
let matches = messages.filter { message in
guard !message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
return false
}
if message.content.lowercased().contains(needle) { return true }
if message.sender.lowercased().contains(needle) { return true }
return false
}
guard limit > 0, matches.count > limit else { return matches }
// Keep the tail: `messages` is oldest-first, so the newest matches are
// the ones a reader is most likely to want.
return Array(matches.suffix(limit))
}
/// Whether a result set was truncated, so the UI can say so instead of
/// quietly showing a partial list.
static func didTruncate(
matchCount: Int,
limit: Int = ThreadMessageSearch.resultLimit
) -> Bool {
limit > 0 && matchCount > limit
}
}

View File

@ -51,9 +51,15 @@ struct MessageListView: View {
/// Whether this instance holds the nearby-notes counter active (mesh
/// public timeline only); balanced against activate/deactivate.
@State private var holdsNotesCounter = false
@State private var threadSearchQuery = ""
@State private var isThreadSearchFieldVisible = false
@ThemedPalette private var palette
private var isThreadSearching: Bool {
!threadSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var body: some View {
let currentWindowCount: Int = {
if let peer = privatePeer {
@ -62,8 +68,20 @@ struct MessageListView: View {
return windowCountPublic
}()
let messages = conversationMessages(for: privatePeer)
let windowedMessages = Array(messages.suffix(currentWindowCount))
let allMessages = conversationMessages(for: privatePeer)
// Uncapped so the count is known, then capped for rendering — a common
// term in a long thread would otherwise render every historical match
// in one pass.
let searchMatches: [BitchatMessage] = isThreadSearching
? ThreadMessageSearch.matchingMessages(in: allMessages, query: threadSearchQuery, limit: 0)
: []
let searchTruncated = ThreadMessageSearch.didTruncate(matchCount: searchMatches.count)
let displayedMessages: [BitchatMessage] = {
if isThreadSearching {
return Array(searchMatches.suffix(ThreadMessageSearch.resultLimit))
}
return Array(allMessages.suffix(currentWindowCount))
}()
let contextKey: String = {
if let peer = privatePeer {
@ -73,12 +91,16 @@ struct MessageListView: View {
}
}()
let messageItems: [MessageDisplayItem] = windowedMessages.compactMap { message in
let messageItems: [MessageDisplayItem] = displayedMessages.compactMap { message in
guard !message.content.trimmed.isEmpty else { return nil }
return MessageDisplayItem(id: "\(contextKey)|\(message.id)", message: message)
}
VStack(spacing: 0) {
threadSearchBar
if searchTruncated {
threadSearchTruncationNotice(matchCount: searchMatches.count)
}
// Notes pinned to this place stay visible while chatting — a
// conversation starting must not hide what's left here.
if privatePeer == nil,
@ -89,30 +111,33 @@ struct MessageListView: View {
GeometryReader { geometry in
ScrollViewReader { proxy in
ScrollView {
if messageItems.isEmpty && privatePeer == nil {
if messageItems.isEmpty && privatePeer == nil && !isThreadSearching {
publicEmptyState(fillHeight: geometry.size.height)
} else if messageItems.isEmpty && isThreadSearching {
threadSearchEmptyState(fillHeight: geometry.size.height)
}
LazyVStack(alignment: .leading, spacing: 0) {
ForEach(messageItems) { item in
let message = item.message
messageRow(for: message)
.onAppear {
if message.id == windowedMessages.last?.id {
if message.id == displayedMessages.last?.id {
isAtBottom = true
unseenCount = 0
}
if message.id == windowedMessages.first?.id,
messages.count > windowedMessages.count {
if !isThreadSearching,
message.id == displayedMessages.first?.id,
allMessages.count > displayedMessages.count {
expandWindow(
ifNeededFor: message,
allMessages: messages,
allMessages: allMessages,
privatePeer: privatePeer,
proxy: proxy
)
}
}
.onDisappear {
if message.id == windowedMessages.last?.id {
if message.id == displayedMessages.last?.id {
isAtBottom = false
}
}
@ -278,8 +303,14 @@ struct MessageListView: View {
}
.onAppear { updateNotesCounterHold() }
.onDisappear { releaseNotesCounterHold() }
.onChange(of: locationChannelsModel.selectedChannel) { _ in updateNotesCounterHold() }
.onChange(of: privatePeer) { _ in updateNotesCounterHold() }
.onChange(of: locationChannelsModel.selectedChannel) { _ in
resetThreadSearch()
updateNotesCounterHold()
}
.onChange(of: privatePeer) { _ in
resetThreadSearch()
updateNotesCounterHold()
}
.environment(\.openURL, OpenURLAction { url in
// Intercept custom cashu: links created in attributed text
if let scheme = url.scheme?.lowercased(), scheme == "cashu" || scheme == "lightning" {
@ -403,6 +434,17 @@ private extension MessageListView {
.frame(maxWidth: .infinity, alignment: .leading)
}
func threadSearchEmptyState(fillHeight: CGFloat) -> some View {
VStack(alignment: .leading, spacing: 6) {
emptyStateLine(
String(localized: "thread.search.no_results", comment: "Empty state when in-thread search finds no matching messages")
)
}
.padding(.horizontal, 12)
.padding(.top, 12)
.frame(maxWidth: .infinity, minHeight: max(0, fillHeight - 24), alignment: .topLeading)
}
func emptyStateLine(_ text: String) -> some View {
// Non-breaking space before the closing asterisk so a tight wrap
// can't orphan a lone "*" onto its own line.
@ -506,6 +548,109 @@ private extension MessageListView {
return "\(base), \(count)"
}
/// Search is per-conversation: switching threads drops the query and the
/// field so a stale filter never applies to a different conversation.
func resetThreadSearch() {
threadSearchQuery = ""
isThreadSearchFieldVisible = false
}
/// Says plainly that older matches are not shown, rather than presenting a
/// capped list as if it were everything.
@ViewBuilder
func threadSearchTruncationNotice(matchCount: Int) -> some View {
Text(
String(
format: String(
localized: "thread.search.truncated",
defaultValue: "showing the newest %lld of %lld matches",
comment: "Shown when in-thread search results were capped; first %lld is the cap, second is the total match count"
),
locale: .current,
ThreadMessageSearch.resultLimit,
matchCount
)
)
.bitchatFont(size: 11)
.foregroundColor(palette.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 12)
.padding(.bottom, 4)
}
/// Collapsed affordance plus the expanded field. Kept inside the message
/// list so the feature needs no changes to either header, and so the same
/// code serves the public timeline and the DM sheet.
@ViewBuilder
var threadSearchBar: some View {
if isThreadSearchFieldVisible {
threadSearchField
} else {
HStack {
Spacer()
Button {
isThreadSearchFieldVisible = true
} label: {
Image(systemName: "magnifyingglass")
.font(.bitchatSystem(size: 12))
.foregroundColor(palette.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "thread.search.accessibility.label", comment: "Accessibility label for in-thread message search")
)
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
}
}
/// Inline search field for the current conversation.
///
/// Deliberately not `.searchable`: that modifier only renders inside a
/// `NavigationStack`/`NavigationSplitView`, and the public timeline
/// (`ContentView` → `MessageListView`) has no navigation ancestor, so the
/// field silently did not appear where most conversation happens. A plain
/// field has no such dependency and renders identically in both placements.
@ViewBuilder
var threadSearchField: some View {
HStack(spacing: 6) {
Image(systemName: "magnifyingglass")
.font(.bitchatSystem(size: 12))
.foregroundColor(palette.secondary)
TextField(
String(localized: "thread.search.placeholder", comment: "Placeholder for the in-thread message search field"),
text: $threadSearchQuery
)
.textFieldStyle(.plain)
.bitchatFont(size: 13)
.foregroundColor(palette.primary)
.autocorrectionDisabled(true)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
// Scoped to the field: applying this to the list chain relabelled
// the whole message list for VoiceOver.
.accessibilityLabel(
String(localized: "thread.search.accessibility.label", comment: "Accessibility label for in-thread message search")
)
Button {
threadSearchQuery = ""
isThreadSearchFieldVisible = false
} label: {
Image(systemName: "xmark.circle.fill")
.font(.bitchatSystem(size: 12))
.foregroundColor(palette.secondary)
}
.buttonStyle(.plain)
.accessibilityLabel(
String(localized: "thread.search.close", defaultValue: "close search", comment: "Accessibility label for the button that closes the in-thread search field")
)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
@ViewBuilder
func messageRow(for message: BitchatMessage) -> some View {
Group {

View File

@ -0,0 +1,113 @@
import BitFoundation
import Foundation
import Testing
@testable import bitchat
struct ThreadMessageSearchTests {
private func message(
id: String,
sender: String,
content: String
) -> BitchatMessage {
BitchatMessage(
id: id,
sender: sender,
content: content,
timestamp: Date(),
isRelay: false,
senderPeerID: PeerID(str: "0011223344556677")
)
}
@Test("empty query returns the original timeline")
func emptyQueryReturnsAll() {
let messages = [
message(id: "1", sender: "alice", content: "hello mesh"),
message(id: "2", sender: "bob", content: "wave back")
]
let result = ThreadMessageSearch.matchingMessages(in: messages, query: " ")
#expect(result == messages)
}
@Test("matches message body case-insensitively")
func matchesContent() {
let messages = [
message(id: "1", sender: "alice", content: "Meet at NOON"),
message(id: "2", sender: "bob", content: "see you later")
]
let result = ThreadMessageSearch.matchingMessages(in: messages, query: "noon")
#expect(result.map(\.id) == ["1"])
}
@Test("matches sender nickname")
func matchesSender() {
let messages = [
message(id: "1", sender: "alice", content: "ping"),
message(id: "2", sender: "mallory", content: "pong")
]
let result = ThreadMessageSearch.matchingMessages(in: messages, query: "MALL")
#expect(result.map(\.id) == ["2"])
}
@Test("skips whitespace-only message bodies")
func skipsBlankBodies() {
let messages = [
message(id: "1", sender: "alice", content: " "),
message(id: "2", sender: "bob", content: "visible")
]
let result = ThreadMessageSearch.matchingMessages(in: messages, query: "alice")
#expect(result.isEmpty)
}
@Test("returns empty when nothing matches")
func noMatches() {
let messages = [
message(id: "1", sender: "alice", content: "hello")
]
let result = ThreadMessageSearch.matchingMessages(in: messages, query: "missing")
#expect(result.isEmpty)
}
// MARK: - Result cap
/// Search bypasses the message window, so without a cap a common term in a
/// long thread renders every historical match in one pass.
@Test("Results are capped at the limit")
func resultsAreCapped() {
let messages = (0..<500).map { message(id: "m\($0)", sender: "alice", content: "needle \($0)") }
let matches = ThreadMessageSearch.matchingMessages(in: messages, query: "needle", limit: 200)
#expect(matches.count == 200)
}
@Test("The cap keeps the newest matches")
func capKeepsNewestMatches() {
let messages = (0..<10).map { message(id: "m\($0)", sender: "alice", content: "needle \($0)") }
let matches = ThreadMessageSearch.matchingMessages(in: messages, query: "needle", limit: 3)
#expect(matches.map(\.id) == ["m7", "m8", "m9"])
}
@Test("A result set under the cap is returned whole")
func underCapIsUntouched() {
let messages = (0..<5).map { message(id: "m\($0)", sender: "alice", content: "needle") }
#expect(ThreadMessageSearch.matchingMessages(in: messages, query: "needle", limit: 200).count == 5)
}
@Test("A non-positive limit disables the cap")
func nonPositiveLimitIsUncapped() {
let messages = (0..<300).map { message(id: "m\($0)", sender: "alice", content: "needle") }
#expect(ThreadMessageSearch.matchingMessages(in: messages, query: "needle", limit: 0).count == 300)
}
@Test("Truncation is reported only when it happened")
func truncationFlag() {
#expect(ThreadMessageSearch.didTruncate(matchCount: 201, limit: 200))
#expect(!ThreadMessageSearch.didTruncate(matchCount: 200, limit: 200))
#expect(!ThreadMessageSearch.didTruncate(matchCount: 5, limit: 200))
#expect(!ThreadMessageSearch.didTruncate(matchCount: 5, limit: 0))
}
}