feat: search messages within the current conversation thread

Add in-thread search via the platform searchable field, scanning message
bodies and sender names across the full timeline when a query is active.
This commit is contained in:
Taksh 2026-08-02 10:28:24 +05:30
parent 1f59e814f9
commit fa4c983b6f
2 changed files with 80 additions and 11 deletions

View File

@ -0,0 +1,32 @@
//
// ThreadMessageSearch.swift
// bitchat
//
// Filters the visible timeline for in-thread search.
//
import BitFoundation
import Foundation
enum ThreadMessageSearch {
/// Returns messages whose body or sender name contains `query`
/// (case-insensitive). An empty or whitespace-only query returns the
/// original list unchanged.
static func matchingMessages(
in messages: [BitchatMessage],
query: String
) -> [BitchatMessage] {
let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return messages }
let needle = trimmed.lowercased()
return 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
}
}
}

View File

@ -50,9 +50,14 @@ 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 = ""
@ThemedPalette private var palette
private var isThreadSearching: Bool {
!threadSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
var body: some View {
let currentWindowCount: Int = {
if let peer = privatePeer {
@ -61,8 +66,13 @@ struct MessageListView: View {
return windowCountPublic
}()
let messages = conversationMessages(for: privatePeer)
let windowedMessages = Array(messages.suffix(currentWindowCount))
let allMessages = conversationMessages(for: privatePeer)
let displayedMessages: [BitchatMessage] = {
if isThreadSearching {
return ThreadMessageSearch.matchingMessages(in: allMessages, query: threadSearchQuery)
}
return Array(allMessages.suffix(currentWindowCount))
}()
let contextKey: String = {
if let peer = privatePeer {
@ -72,7 +82,7 @@ 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)
}
@ -88,30 +98,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
}
}
@ -277,8 +290,21 @@ struct MessageListView: View {
}
.onAppear { updateNotesCounterHold() }
.onDisappear { releaseNotesCounterHold() }
.onChange(of: locationChannelsModel.selectedChannel) { _ in updateNotesCounterHold() }
.onChange(of: privatePeer) { _ in updateNotesCounterHold() }
.onChange(of: locationChannelsModel.selectedChannel) { _ in
threadSearchQuery = ""
updateNotesCounterHold()
}
.onChange(of: privatePeer) { _ in
threadSearchQuery = ""
updateNotesCounterHold()
}
.searchable(
text: $threadSearchQuery,
prompt: Text("thread.search.placeholder")
)
.accessibilityLabel(
String(localized: "thread.search.accessibility.label", comment: "Accessibility label for in-thread message search")
)
.environment(\.openURL, OpenURLAction { url in
// Intercept custom cashu: links created in attributed text
if let scheme = url.scheme?.lowercased(), scheme == "cashu" || scheme == "lightning" {
@ -390,6 +416,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.