diff --git a/desktop/src/features/channels/ui/ChannelCanvas.tsx b/desktop/src/features/channels/ui/ChannelCanvas.tsx index d7ea8616b3..e2ebca6390 100644 --- a/desktop/src/features/channels/ui/ChannelCanvas.tsx +++ b/desktop/src/features/channels/ui/ChannelCanvas.tsx @@ -5,6 +5,7 @@ import { useCanvasQuery, useSetCanvasMutation, } from "@/features/channels/hooks"; +import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { Button } from "@/shared/ui/button"; import { Markdown } from "@/shared/ui/markdown"; import { Textarea } from "@/shared/ui/textarea"; @@ -22,6 +23,11 @@ export function ChannelCanvas({ }: ChannelCanvasProps) { const canvasQuery = useCanvasQuery(channelId, channelId !== null); const setCanvasMutation = useSetCanvasMutation(channelId); + const { channels } = useChannelNavigation(); + const channelNames = React.useMemo( + () => channels.filter((c) => c.channelType !== "dm").map((c) => c.name), + [channels], + ); const [isEditing, setIsEditing] = React.useState(false); const [draft, setDraft] = React.useState(""); @@ -109,7 +115,11 @@ export function ChannelCanvas({ className="rounded-2xl border border-border/70 bg-muted/20 px-4 py-3" data-testid="channel-canvas-content" > - + ) : (

diff --git a/desktop/src/features/forum/ui/ForumThreadPanel.tsx b/desktop/src/features/forum/ui/ForumThreadPanel.tsx index 3f52dabc76..0d30eee6f3 100644 --- a/desktop/src/features/forum/ui/ForumThreadPanel.tsx +++ b/desktop/src/features/forum/ui/ForumThreadPanel.tsx @@ -8,6 +8,7 @@ import { } from "@/features/profile/lib/identity"; import type { ForumThreadResponse, ThreadReply } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; +import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; import { resolveMentionNames } from "@/shared/lib/resolveMentionNames"; import { AlertDialog, @@ -96,11 +97,13 @@ function ReplyRow({ reply, currentPubkey, profiles, + channelNames, onDelete, }: { reply: ThreadReply; currentPubkey?: string; profiles?: UserProfileLookup; + channelNames?: string[]; onDelete?: (eventId: string) => void; }) { const [isDeleteOpen, setIsDeleteOpen] = React.useState(false); @@ -163,6 +166,7 @@ function ReplyRow({

(null); const [isDeletePostOpen, setIsDeletePostOpen] = React.useState(false); + const { channels } = useChannelNavigation(); + const channelNames = React.useMemo( + () => channels.filter((c) => c.channelType !== "dm").map((c) => c.name), + [channels], + ); if (isLoading || !thread) { return ( @@ -292,7 +301,11 @@ export function ForumThreadPanel({ ) : null}
- +
@@ -306,6 +319,7 @@ export function ForumThreadPanel({
{replies.map((reply) => ( (""); const latestCursorRef = React.useRef(0); + /** Channel names (original casing) for overlay highlighting. */ + const knownChannelNames = React.useMemo( + () => channels.filter((ch) => ch.channelType !== "dm").map((ch) => ch.name), + [channels], + ); + + /** Lower-cased channel names for case-insensitive prefix matching. */ + const knownNamesLower = React.useMemo( + () => knownChannelNames.map((n) => n.toLowerCase()), + [knownChannelNames], + ); + + const knownNamesLowerRef = React.useRef(knownNamesLower); + + // Keep the known-names ref in sync so the debounced callback never reads stale data. + React.useEffect(() => { + knownNamesLowerRef.current = knownNamesLower; + }, [knownNamesLower]); + // Clean up pending timeout on unmount React.useEffect(() => { return () => { @@ -106,9 +111,11 @@ export function useChannelLinks() { debounceTimerRef.current = setTimeout(() => { debounceTimerRef.current = null; - const channel = detectChannelQuery( + const channel = detectPrefixQuery( + "#", latestValueRef.current, latestCursorRef.current, + knownNamesLowerRef.current, ); if (channel) { setChannelQuery(channel.query); @@ -189,6 +196,7 @@ export function useChannelLinks() { handleChannelKeyDown, insertChannel, isChannelOpen, + knownChannelNames, updateChannelQuery, }; } diff --git a/desktop/src/features/messages/lib/useMentions.ts b/desktop/src/features/messages/lib/useMentions.ts index 1e6a5a89a8..66d1f48677 100644 --- a/desktop/src/features/messages/lib/useMentions.ts +++ b/desktop/src/features/messages/lib/useMentions.ts @@ -3,57 +3,11 @@ import * as React from "react"; import { useManagedAgentsQuery } from "@/features/agents/hooks"; import { useChannelMembersQuery } from "@/features/channels/hooks"; import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete"; +import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; import { escapeRegExp } from "@/shared/lib/mentionPattern"; const MENTION_DEBOUNCE_MS = 120; -function detectMentionQuery( - value: string, - cursorPosition: number, - knownNamesLower: string[], -): { query: string; startIndex: number } | null { - const beforeCursor = value.slice(0, cursorPosition); - - // Fast path: single-word mention query (no spaces after @) - const simpleMatch = beforeCursor.match(/(?:^|[\s])@([^\s]*)$/); - if (simpleMatch) { - const query = simpleMatch[1]; - const startIndex = beforeCursor.length - query.length - 1; // -1 for @ - return { query, startIndex }; - } - - // Multi-word path: scan backwards for an `@` and check if the text between - // `@` and the cursor is a prefix of any known multi-word display name. - const scanStart = Math.max(0, beforeCursor.length - 80); - for (let i = beforeCursor.length - 1; i >= scanStart; i--) { - const ch = beforeCursor[i]; - if (ch === "@") { - // Ensure `@` is at start or preceded by whitespace - if (i > 0 && !/\s/.test(beforeCursor[i - 1])) { - continue; - } - const candidate = beforeCursor.slice(i + 1); - if (candidate.length === 0) { - break; - } - const lowerCandidate = candidate.toLowerCase(); - const isPrefix = knownNamesLower.some((name) => - name.startsWith(lowerCandidate), - ); - if (isPrefix) { - return { query: candidate, startIndex: i }; - } - break; - } - // Stop scanning if we hit a newline — mentions don't span lines - if (ch === "\n") { - break; - } - } - - return null; -} - export function useMentions(channelId: string | null) { const [mentionQuery, setMentionQuery] = React.useState(null); const [mentionStartIndex, setMentionStartIndex] = React.useState(0); @@ -88,7 +42,7 @@ export function useMentions(channelId: string | null) { return names; }, [members, managedAgentNamesByPubkey]); - /** Lower-cased version of knownNames, used for case-insensitive prefix matching in detectMentionQuery. */ + /** Lower-cased version of knownNames, used for case-insensitive prefix matching. */ const knownNamesLower = React.useMemo( () => knownNames.map((n) => n.toLowerCase()), [knownNames], @@ -190,7 +144,8 @@ export function useMentions(channelId: string | null) { debounceTimerRef.current = setTimeout(() => { debounceTimerRef.current = null; - const mention = detectMentionQuery( + const mention = detectPrefixQuery( + "@", latestValueRef.current, latestCursorRef.current, knownNamesLowerRef.current, diff --git a/desktop/src/features/messages/ui/ComposerMentionOverlay.tsx b/desktop/src/features/messages/ui/ComposerMentionOverlay.tsx index a66b96f25f..c0d61da454 100644 --- a/desktop/src/features/messages/ui/ComposerMentionOverlay.tsx +++ b/desktop/src/features/messages/ui/ComposerMentionOverlay.tsx @@ -1,17 +1,19 @@ import React from "react"; -import { buildMentionPattern } from "@/shared/lib/mentionPattern"; +import { buildPrefixPattern } from "@/shared/lib/mentionPattern"; type Segment = { type: "text" | "mention" | "channel"; value: string }; -const CHANNEL_RE_PART = "#[a-zA-Z0-9][\\w-]*"; - /** * Extends the shared mention pattern to also match `#channel-name` references. */ -function buildOverlayPattern(mentionNames: string[]): RegExp { - const mentionSource = buildMentionPattern(mentionNames).source; - return new RegExp(`${mentionSource}|${CHANNEL_RE_PART}`, "g"); +function buildOverlayPattern( + mentionNames: string[], + channelNames: string[], +): RegExp { + const mentionSource = buildPrefixPattern("@", mentionNames).source; + const channelSource = buildPrefixPattern("#", channelNames).source; + return new RegExp(`${mentionSource}|${channelSource}`, "gi"); } function parseSegments(text: string, pattern: RegExp): Segment[] { @@ -41,6 +43,7 @@ function parseSegments(text: string, pattern: RegExp): Segment[] { } type ComposerMentionOverlayProps = { + channelNames: string[]; content: string; mentionNames: string[]; scrollTop: number; @@ -54,13 +57,14 @@ type ComposerMentionOverlayProps = { */ export const ComposerMentionOverlay = React.memo( function ComposerMentionOverlay({ + channelNames, content, mentionNames, scrollTop, }: ComposerMentionOverlayProps) { const pattern = React.useMemo( - () => buildOverlayPattern(mentionNames), - [mentionNames], + () => buildOverlayPattern(mentionNames, channelNames), + [mentionNames, channelNames], ); // Memoize regex parsing so it only re-runs when content or the mention // pattern actually changes — avoids expensive matchAll on every render. diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 3179d07dce..6aff994a42 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -586,6 +586,7 @@ export function MessageComposer({ className="pointer-events-none absolute inset-0 overflow-hidden" > channels.filter((c) => c.channelType !== "dm").map((c) => c.name), + [channels], + ); + const visibleDepth = Math.min(message.depth, 6); const indentPx = visibleDepth * 28; const initials = message.author @@ -91,6 +98,7 @@ export const MessageRow = React.memo( default: return ( { + // biome-ignore lint/suspicious/noExplicitAny: building mdast-compatible nodes + [key: string]: any; +}; + +/** + * Create a remark plugin that walks the tree, finds regex matches in text + * nodes, and replaces each match with a node produced by `buildNode`. + */ +export function createRemarkPrefixPlugin( + pattern: RegExp, + buildNode: NodeBuilder, +) { + return ( + // biome-ignore lint/suspicious/noExplicitAny: remark tree types are not available + tree: any, + ) => { + walkChildren(tree, pattern, buildNode); + }; +} + +// biome-ignore lint/suspicious/noExplicitAny: remark tree types are not available +function walkChildren(node: any, pattern: RegExp, buildNode: NodeBuilder) { + if (!node?.children || !Array.isArray(node.children)) { + return; + } + + for (let i = node.children.length - 1; i >= 0; i--) { + const child = node.children[i]; + + if (child.type === "text") { + const parts = splitByPattern(child.value, pattern, buildNode); + if (parts.length > 1) { + node.children.splice(i, 1, ...parts); + } + } else { + walkChildren(child, pattern, buildNode); + } + } +} + +function splitByPattern(text: string, pattern: RegExp, buildNode: NodeBuilder) { + // Reset lastIndex — the pattern is reused across text nodes with the `g` flag + pattern.lastIndex = 0; + // biome-ignore lint/suspicious/noExplicitAny: building mdast-compatible nodes + const parts: any[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null = null; + + while (true) { + match = pattern.exec(text); + if (!match) { + break; + } + + if (match.index > lastIndex) { + parts.push({ type: "text", value: text.slice(lastIndex, match.index) }); + } + + parts.push(buildNode(match[0])); + + lastIndex = match.index + match[0].length; + } + + if (parts.length === 0) { + return [{ type: "text", value: text }]; + } + + if (lastIndex < text.length) { + parts.push({ type: "text", value: text.slice(lastIndex) }); + } + + return parts; +} diff --git a/desktop/src/shared/lib/detectPrefixQuery.ts b/desktop/src/shared/lib/detectPrefixQuery.ts new file mode 100644 index 0000000000..fd7e20214b --- /dev/null +++ b/desktop/src/shared/lib/detectPrefixQuery.ts @@ -0,0 +1,64 @@ +/** + * Shared helper for detecting prefix-based queries (e.g. @mention, #channel) + * within a text input, used by both useMentions and useChannelLinks hooks. + */ + +/** + * Detect whether the user is typing a prefix-based query (e.g. @name or #channel) + * at the current cursor position. + * + * @param prefix - The trigger character (e.g. "@" or "#") + * @param value - The full text content + * @param cursorPosition - The current cursor position in the text + * @param knownNamesLower - Lower-cased known names for multi-word prefix matching + * @returns The query string and start index, or null if no query is detected + */ +export function detectPrefixQuery( + prefix: string, + value: string, + cursorPosition: number, + knownNamesLower: string[], +): { query: string; startIndex: number } | null { + const beforeCursor = value.slice(0, cursorPosition); + + // Fast path: single-word query (no spaces after the prefix) + const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const simplePattern = new RegExp(`(?:^|[\\s])${escapedPrefix}([^\\s]*)$`); + const simpleMatch = beforeCursor.match(simplePattern); + if (simpleMatch) { + const query = simpleMatch[1]; + const startIndex = beforeCursor.length - query.length - 1; // -1 for prefix + return { query, startIndex }; + } + + // Multi-word path: scan backwards for the prefix and check if the text between + // the prefix and the cursor is a prefix of any known multi-word name. + const scanStart = Math.max(0, beforeCursor.length - 80); + for (let i = beforeCursor.length - 1; i >= scanStart; i--) { + const ch = beforeCursor[i]; + if (ch === prefix) { + // Ensure prefix is at start or preceded by whitespace + if (i > 0 && !/\s/.test(beforeCursor[i - 1])) { + continue; + } + const candidate = beforeCursor.slice(i + 1); + if (candidate.length === 0) { + break; + } + const lowerCandidate = candidate.toLowerCase(); + const isPrefix = knownNamesLower.some((name) => + name.startsWith(lowerCandidate), + ); + if (isPrefix) { + return { query: candidate, startIndex: i }; + } + break; + } + // Stop scanning if we hit a newline + if (ch === "\n") { + break; + } + } + + return null; +} diff --git a/desktop/src/shared/lib/mentionPattern.ts b/desktop/src/shared/lib/mentionPattern.ts index c4d8e4eaf2..ea93a5b125 100644 --- a/desktop/src/shared/lib/mentionPattern.ts +++ b/desktop/src/shared/lib/mentionPattern.ts @@ -5,19 +5,36 @@ export function escapeRegExp(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +/** + * Build a regex that matches a given prefix followed by known multi-word names + * (longest-first to avoid partial matches), then falling back to prefix + \S+. + */ +export function buildPrefixPattern( + prefix: string, + knownNames: string[], +): RegExp { + const sorted = [...new Set(knownNames)] + .filter((name) => name.trim().length > 0) + .sort((a, b) => b.length - a.length); + + const escapedPrefix = escapeRegExp(prefix); + + if (sorted.length === 0) { + return new RegExp(`${escapedPrefix}\\S+`, "gi"); + } + + const nameAlternatives = sorted.map((name) => escapeRegExp(name)).join("|"); + const boundary = "(?=[\\s,;.!?:)\\]}]|$)"; + return new RegExp( + `${escapedPrefix}(?:(?:${nameAlternatives})${boundary}|\\S+)`, + "gi", + ); +} + /** * Build a regex that matches @mentions, trying known multi-word names first * (longest-first to avoid partial matches), then falling back to @\S+. */ export function buildMentionPattern(mentionNames: string[]): RegExp { - const sorted = [...new Set(mentionNames)] - .filter((name) => name.trim().length > 0) - .sort((a, b) => b.length - a.length); - - if (sorted.length === 0) { - return /@\S+/g; - } - - const nameAlternatives = sorted.map((name) => escapeRegExp(name)).join("|"); - return new RegExp(`@(?:${nameAlternatives}|\\S+)`, "g"); + return buildPrefixPattern("@", mentionNames); } diff --git a/desktop/src/shared/lib/remarkChannelLinks.ts b/desktop/src/shared/lib/remarkChannelLinks.ts index 5c11fd777b..ca1225a435 100644 --- a/desktop/src/shared/lib/remarkChannelLinks.ts +++ b/desktop/src/shared/lib/remarkChannelLinks.ts @@ -1,73 +1,34 @@ /** * Remark plugin that detects #channel-name patterns in text nodes and wraps them * in custom HAST `channel-link` elements for styled rendering via react-markdown. + * + * When `channelNames` is provided, multi-word channel names (e.g. "my channel") + * are matched first (longest-first to avoid partial matches), then the plugin + * falls back to the generic `#\S+` pattern for unknown channels. */ -export default function remarkChannelLinks() { - // biome-ignore lint/suspicious/noExplicitAny: remark tree types are not available - return (tree: any) => { - walkChildren(tree); - }; -} -// biome-ignore lint/suspicious/noExplicitAny: remark tree types are not available -function walkChildren(node: any) { - if (!node?.children || !Array.isArray(node.children)) { - return; - } +import { createRemarkPrefixPlugin } from "./createRemarkPrefixPlugin"; +import { buildPrefixPattern } from "./mentionPattern"; - for (let i = node.children.length - 1; i >= 0; i--) { - const child = node.children[i]; +type RemarkChannelLinksOptions = { + channelNames?: string[]; +}; - if (child.type === "text") { - const parts = splitChannelLinks(child.value); - if (parts.length > 1) { - node.children.splice(i, 1, ...parts); - } - } else { - walkChildren(child); - } - } -} +export default function remarkChannelLinks( + options?: RemarkChannelLinksOptions, +) { + const channelPattern = buildPrefixPattern("#", options?.channelNames ?? []); -function splitChannelLinks(text: string) { - const channelPattern = /#[a-zA-Z0-9][\w-]*/g; - // biome-ignore lint/suspicious/noExplicitAny: building mdast-compatible nodes - const parts: any[] = []; - let lastIndex = 0; - let match: RegExpExecArray | null = null; - - while (true) { - match = channelPattern.exec(text); - if (!match) { - break; - } - - if (match.index > lastIndex) { - parts.push({ type: "text", value: text.slice(lastIndex, match.index) }); - } - - const channelName = match[0].slice(1); - - parts.push({ + return createRemarkPrefixPlugin(channelPattern, (matchText) => { + const channelName = matchText.slice(1); + return { type: "channel-link", - value: match[0], + value: matchText, data: { hName: "channel-link", - hChildren: [{ type: "text", value: match[0] }], + hChildren: [{ type: "text", value: matchText }], channelName, }, - }); - - lastIndex = match.index + match[0].length; - } - - if (parts.length === 0) { - return [{ type: "text", value: text }]; - } - - if (lastIndex < text.length) { - parts.push({ type: "text", value: text.slice(lastIndex) }); - } - - return parts; + }; + }); } diff --git a/desktop/src/shared/lib/remarkMentions.ts b/desktop/src/shared/lib/remarkMentions.ts index 98df839140..b958ea1bb1 100644 --- a/desktop/src/shared/lib/remarkMentions.ts +++ b/desktop/src/shared/lib/remarkMentions.ts @@ -7,6 +7,7 @@ * falls back to the generic `@\S+` pattern for unknown mentions. */ +import { createRemarkPrefixPlugin } from "./createRemarkPrefixPlugin"; import { buildMentionPattern } from "./mentionPattern"; type RemarkMentionsOptions = { @@ -16,71 +17,12 @@ type RemarkMentionsOptions = { export default function remarkMentions(options?: RemarkMentionsOptions) { const mentionPattern = buildMentionPattern(options?.mentionNames ?? []); - return ( - // biome-ignore lint/suspicious/noExplicitAny: remark tree types are not available - tree: any, - ) => { - walkChildren(tree, mentionPattern); - }; -} - -// biome-ignore lint/suspicious/noExplicitAny: remark tree types are not available -function walkChildren(node: any, mentionPattern: RegExp) { - if (!node?.children || !Array.isArray(node.children)) { - return; - } - - for (let i = node.children.length - 1; i >= 0; i--) { - const child = node.children[i]; - - if (child.type === "text") { - const parts = splitMentions(child.value, mentionPattern); - if (parts.length > 1) { - node.children.splice(i, 1, ...parts); - } - } else { - walkChildren(child, mentionPattern); - } - } -} - -function splitMentions(text: string, mentionPattern: RegExp) { - // Reset lastIndex — the pattern is reused across text nodes with the `g` flag - mentionPattern.lastIndex = 0; - // biome-ignore lint/suspicious/noExplicitAny: building mdast-compatible nodes - const parts: any[] = []; - let lastIndex = 0; - let match: RegExpExecArray | null = null; - - while (true) { - match = mentionPattern.exec(text); - if (!match) { - break; - } - - if (match.index > lastIndex) { - parts.push({ type: "text", value: text.slice(lastIndex, match.index) }); - } - - parts.push({ - type: "mention", - value: match[0], - data: { - hName: "mention", - hChildren: [{ type: "text", value: match[0] }], - }, - }); - - lastIndex = match.index + match[0].length; - } - - if (parts.length === 0) { - return [{ type: "text", value: text }]; - } - - if (lastIndex < text.length) { - parts.push({ type: "text", value: text.slice(lastIndex) }); - } - - return parts; + return createRemarkPrefixPlugin(mentionPattern, (matchText) => ({ + type: "mention", + value: matchText, + data: { + hName: "mention", + hChildren: [{ type: "text", value: matchText }], + }, + })); } diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index df9bbd16d3..1956a8ada3 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -11,6 +11,7 @@ import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext" import type { Channel } from "@/shared/api/types"; type MarkdownProps = { + channelNames?: string[]; className?: string; compact?: boolean; content: string; @@ -192,6 +193,7 @@ function shallowArrayEqual(a?: string[], b?: string[]): boolean { } function MarkdownInner({ + channelNames, className, compact = false, content, @@ -210,6 +212,17 @@ function MarkdownInner({ [variant, channels, onOpenChannel], ); + // biome-ignore lint/suspicious/noExplicitAny: PluggableList type not directly importable + const remarkPlugins = React.useMemo( + () => [ + remarkGfm, + remarkBreaks, + [remarkMentions, { mentionNames }], + [remarkChannelLinks, { channelNames }], + ], + [mentionNames, channelNames], + ); + let processedContent = content; if (/^(?:\s{2}\n)+/.test(content)) { @@ -231,15 +244,7 @@ function MarkdownInner({ className, )} > - + {processedContent}
@@ -253,7 +258,8 @@ export const Markdown = React.memo( prev.className === next.className && prev.compact === next.compact && prev.tight === next.tight && - shallowArrayEqual(prev.mentionNames, next.mentionNames), + shallowArrayEqual(prev.mentionNames, next.mentionNames) && + shallowArrayEqual(prev.channelNames, next.channelNames), ); Markdown.displayName = "Markdown";