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) => (