Merge 77fa81bccc7921834806b241ead452bda72e4e7a into 9b84b361225facd8e623f25d76f889d3dc54a879

This commit is contained in:
Taksh Kothari 2026-08-11 17:28:57 +00:00 committed by GitHub
commit 708b7fbd85
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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))
}
}