From 00c25d9803554519f284cec73317a40c815afeba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Sat, 2 May 2026 19:23:21 +0100 Subject: [PATCH] feat(web): client-side search filter in topic chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A "search" toggle in the chat header opens a small input that client-filters loaded messages by plaintext match on body or sender name. Live tail auto-scroll suspends while a query is active so matches stay visible when new messages arrive. Server-side fulltext search lands when ciphertext moves to per-topic symmetric keys in v0.3.0 — until then there's no server index to query, and the loaded window (last 100 plus forward stream) covers most "find that thing from earlier" needs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web/src/modules/mesh/topic-chat-panel.tsx | 72 +++++++++++++++++-- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/apps/web/src/modules/mesh/topic-chat-panel.tsx b/apps/web/src/modules/mesh/topic-chat-panel.tsx index 721f094..9a86b82 100644 --- a/apps/web/src/modules/mesh/topic-chat-panel.tsx +++ b/apps/web/src/modules/mesh/topic-chat-panel.tsx @@ -180,6 +180,8 @@ export function TopicChatPanel({ start: number; selected: number; } | null>(null); + const [searchQuery, setSearchQuery] = useState(""); + const [searchOpen, setSearchOpen] = useState(false); const scrollRef = useRef(null); const textareaRef = useRef(null); const seenIdsRef = useRef>(new Set()); @@ -344,11 +346,14 @@ export function TopicChatPanel({ }, [apiKeySecret, topicName, markRead]); useEffect(() => { + // Don't yank scroll while the user is searching — they're reading + // matches, not the live tail. + if (searchQuery.trim()) return; scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth", }); - }, [messages.length]); + }, [messages.length, searchQuery]); // Member name lookup for autocomplete. Filtered by case-insensitive // prefix match on displayName; shorter names rank higher so e.g. "@al" @@ -457,6 +462,19 @@ export function TopicChatPanel({ const onlineCount = members.filter((m) => m.online).length; + // Client-side search over loaded messages. Decodes once per query so + // we can filter on plaintext, then highlights matches in render. + // Server-side fulltext lands when we move ciphertext to per-topic + // keys (v0.3.0) — until then there's no server index to query. + const searchTerm = searchQuery.trim().toLowerCase(); + const filteredMessages = useMemo(() => { + if (!searchTerm) return messages; + return messages.filter((m) => + decodeIncoming(m.ciphertext).toLowerCase().includes(searchTerm) || + (m.senderName ?? "").toLowerCase().includes(searchTerm), + ); + }, [messages, searchTerm]); + return (
{/* Header — mono strip, clay-pulse dot, metadata right */} @@ -470,9 +488,46 @@ export function TopicChatPanel({ #{topicName}
- - {messages.length} msg · {stateLabel} - +
+ {searchOpen ? ( + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.preventDefault(); + setSearchQuery(""); + setSearchOpen(false); + } + }} + placeholder="search…" + className="w-44 rounded-[var(--cm-radius-sm)] border border-[var(--cm-border)] bg-[var(--cm-bg)] px-2 py-1 text-[11px] text-[var(--cm-fg)] placeholder:text-[var(--cm-fg-tertiary)] focus:border-[var(--cm-border-hover)] focus:outline-none" + /> + ) : null} + + + {searchTerm + ? `${filteredMessages.length}/${messages.length}` + : `${messages.length} msg`} + {" · "} + {stateLabel} + +
{/* Body — message stream + member sidebar */} @@ -486,9 +541,16 @@ export function TopicChatPanel({ > no envelopes on this topic yet

+ ) : filteredMessages.length === 0 ? ( +

+ no matches for “{searchTerm}” +

) : (
    - {messages.map((m) => ( + {filteredMessages.map((m) => (