diff --git a/apps/web/src/modules/mesh/topic-chat-panel.tsx b/apps/web/src/modules/mesh/topic-chat-panel.tsx index 04cb4d8..721f094 100644 --- a/apps/web/src/modules/mesh/topic-chat-panel.tsx +++ b/apps/web/src/modules/mesh/topic-chat-panel.tsx @@ -1,5 +1,6 @@ "use client"; +import type React from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Button } from "@turbostarter/ui-web/button"; @@ -68,6 +69,38 @@ function decodeIncoming(ciphertext: string): string { } } +/** + * Render plaintext with @mentions highlighted in clay. We split on the + * mention regex and rebuild as alternating spans so React can reconcile + * keys cleanly. URL/markdown parsing is out of scope for v0.2.0. + */ +function renderWithMentions(text: string): React.ReactNode[] { + const parts: React.ReactNode[] = []; + const re = /(^|\s)(@[A-Za-z0-9_-]+)/g; + let lastIndex = 0; + let match: RegExpExecArray | null; + let key = 0; + while ((match = re.exec(text)) !== null) { + const [, lead, mention] = match; + const matchStart = match.index + (lead?.length ?? 0); + if (matchStart > lastIndex) { + parts.push( + {text.slice(lastIndex, matchStart)}, + ); + } + parts.push( + + {mention} + , + ); + lastIndex = matchStart + (mention?.length ?? 0); + } + if (lastIndex < text.length) { + parts.push({text.slice(lastIndex)}); + } + return parts; +} + function fmtTime(iso: string): string { try { return new Date(iso).toLocaleTimeString([], { @@ -142,7 +175,13 @@ export function TopicChatPanel({ "connecting" | "live" | "reconnecting" | "stopped" >("connecting"); const [lastEventAt, setLastEventAt] = useState(null); + const [mentionState, setMentionState] = useState<{ + query: string; + start: number; + selected: number; + } | null>(null); const scrollRef = useRef(null); + const textareaRef = useRef(null); const seenIdsRef = useRef>(new Set()); const lastMarkReadAtRef = useRef(0); @@ -311,6 +350,65 @@ export function TopicChatPanel({ }); }, [messages.length]); + // Member name lookup for autocomplete. Filtered by case-insensitive + // prefix match on displayName; shorter names rank higher so e.g. "@al" + // surfaces "Alice" above "Alejandro" if both exist. Capped at 8. + const mentionMatches = useMemo(() => { + if (!mentionState) return []; + const q = mentionState.query.toLowerCase(); + return members + .filter((m) => m.displayName.toLowerCase().startsWith(q)) + .sort((a, b) => { + if (a.online !== b.online) return a.online ? -1 : 1; + return a.displayName.length - b.displayName.length; + }) + .slice(0, 8); + }, [members, mentionState]); + + // Re-evaluate the @-mention context whenever the textarea changes — + // we look at the substring before the cursor and check whether it + // ends in `@` with no whitespace between the @ and the cursor. + const updateMentionFromCursor = useCallback( + (value: string, cursor: number) => { + const before = value.slice(0, cursor); + const m = before.match(/(^|\s)@([A-Za-z0-9_-]*)$/); + if (!m) { + setMentionState(null); + return; + } + const query = m[2] ?? ""; + const start = before.length - query.length - 1; // index of '@' + setMentionState((prev) => + prev && prev.start === start && prev.query === query + ? prev + : { query, start, selected: 0 }, + ); + }, + [], + ); + + const insertMention = useCallback( + (memberName: string) => { + if (!mentionState) return; + const ta = textareaRef.current; + if (!ta) return; + const before = draft.slice(0, mentionState.start); + const after = draft.slice(ta.selectionStart); + const replacement = `@${memberName} `; + const next = before + replacement + after; + const nextCursor = before.length + replacement.length; + setDraft(next); + setMentionState(null); + // Restore cursor + focus on the next tick — React schedules the + // value update, so we can't mutate selection in the same frame. + requestAnimationFrame(() => { + ta.focus(); + ta.setSelectionRange(nextCursor, nextCursor); + }); + }, + [draft, mentionState], + ); + const send = async () => { const text = draft.trim(); if (!text) return; @@ -407,7 +505,7 @@ export function TopicChatPanel({

- {decodeIncoming(m.ciphertext)} + {renderWithMentions(decodeIncoming(m.ciphertext))}

))} @@ -513,19 +611,137 @@ export function TopicChatPanel({

) : null}
{ e.preventDefault(); void send(); }} > + {/* @-mention dropdown anchored above the textarea */} + {mentionState && mentionMatches.length > 0 ? ( +
    + {mentionMatches.map((m, i) => { + const selected = i === mentionState.selected; + return ( +
  • + +
  • + ); + })} +
+ ) : null}