Merge 6717bacb779109fd60aed5cb27930a920a6a2531 into 1f59e814f90c3f489f48d68262cb1bf640bf6181

This commit is contained in:
Taksh Kothari 2026-08-05 14:02:15 +00:00 committed by GitHub
commit 5ad3ed7462
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 54483 additions and 53182 deletions

File diff suppressed because it is too large Load Diff

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.

View File

@ -0,0 +1,75 @@
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)
}
}