diff --git a/bitchat/Utils/ThreadMessageSearch.swift b/bitchat/Utils/ThreadMessageSearch.swift new file mode 100644 index 00000000..2a58a1d5 --- /dev/null +++ b/bitchat/Utils/ThreadMessageSearch.swift @@ -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 + } + } +} diff --git a/bitchat/Views/MessageListView.swift b/bitchat/Views/MessageListView.swift index 6e1c6b95..4bcbbf5d 100644 --- a/bitchat/Views/MessageListView.swift +++ b/bitchat/Views/MessageListView.swift @@ -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.