mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
Merge 6717bacb779109fd60aed5cb27930a920a6a2531 into 1f59e814f90c3f489f48d68262cb1bf640bf6181
This commit is contained in:
commit
5ad3ed7462
File diff suppressed because it is too large
Load Diff
32
bitchat/Utils/ThreadMessageSearch.swift
Normal file
32
bitchat/Utils/ThreadMessageSearch.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
75
bitchatTests/ThreadMessageSearchTests.swift
Normal file
75
bitchatTests/ThreadMessageSearchTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user