diff --git a/bitchat/Localizable.xcstrings b/bitchat/Localizable.xcstrings index e498a20e..d8691105 100644 --- a/bitchat/Localizable.xcstrings +++ b/bitchat/Localizable.xcstrings @@ -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", diff --git a/bitchat/Utils/ThreadMessageSearch.swift b/bitchat/Utils/ThreadMessageSearch.swift new file mode 100644 index 00000000..012206e5 --- /dev/null +++ b/bitchat/Utils/ThreadMessageSearch.swift @@ -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 + } +} diff --git a/bitchat/Views/MessageListView.swift b/bitchat/Views/MessageListView.swift index 1894a347..cf4c05a8 100644 --- a/bitchat/Views/MessageListView.swift +++ b/bitchat/Views/MessageListView.swift @@ -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 { diff --git a/bitchatTests/ThreadMessageSearchTests.swift b/bitchatTests/ThreadMessageSearchTests.swift new file mode 100644 index 00000000..34a6933f --- /dev/null +++ b/bitchatTests/ThreadMessageSearchTests.swift @@ -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)) + } +}