fix(desktop): support multi-word #channel name highlighting (#229)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wes
2026-04-03 19:08:18 -07:00
committed by GitHub
parent bf083dda5d
commit b1c40ea434
13 changed files with 293 additions and 221 deletions

View File

@@ -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"
>
<Markdown compact content={canvasContent} />
<Markdown
channelNames={channelNames}
compact
content={canvasContent}
/>
</div>
) : (
<p className="text-sm text-muted-foreground">

View File

@@ -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({
</div>
<div className="mt-1.5 pl-8">
<Markdown
channelNames={channelNames}
compact
content={reply.content}
mentionNames={replyMentionNames}
@@ -188,6 +192,11 @@ export function ForumThreadPanel({
}: ForumThreadPanelProps) {
const scrollRef = React.useRef<HTMLDivElement>(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}
</div>
<div className="mt-3">
<Markdown content={post.content} mentionNames={postMentionNames} />
<Markdown
channelNames={channelNames}
content={post.content}
mentionNames={postMentionNames}
/>
</div>
</div>
@@ -306,6 +319,7 @@ export function ForumThreadPanel({
<div className="divide-y divide-border/40">
{replies.map((reply) => (
<ReplyRow
channelNames={channelNames}
currentPubkey={currentPubkey}
key={reply.eventId}
onDelete={onDeleteReply}

View File

@@ -1,6 +1,7 @@
import * as React from "react";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
export type ChannelSuggestion = {
id: string;
@@ -8,21 +9,6 @@ export type ChannelSuggestion = {
channelType: "stream" | "forum";
};
function detectChannelQuery(
value: string,
cursorPosition: number,
): { query: string; startIndex: number } | null {
const beforeCursor = value.slice(0, cursorPosition);
const match = beforeCursor.match(/(?:^|[\s])#([^\s]*)$/);
if (!match) {
return null;
}
const query = match[1];
const startIndex = beforeCursor.length - query.length - 1; // -1 for #
return { query, startIndex };
}
const CHANNEL_QUERY_DEBOUNCE_MS = 120;
export function useChannelLinks() {
@@ -38,6 +24,25 @@ export function useChannelLinks() {
const latestValueRef = React.useRef<string>("");
const latestCursorRef = React.useRef<number>(0);
/** Channel names (original casing) for overlay highlighting. */
const knownChannelNames = React.useMemo<string[]>(
() => channels.filter((ch) => ch.channelType !== "dm").map((ch) => ch.name),
[channels],
);
/** Lower-cased channel names for case-insensitive prefix matching. */
const knownNamesLower = React.useMemo<string[]>(
() => knownChannelNames.map((n) => n.toLowerCase()),
[knownChannelNames],
);
const knownNamesLowerRef = React.useRef<string[]>(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,
};
}

View File

@@ -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<string | null>(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<string[]>(
() => 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,

View File

@@ -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.

View File

@@ -586,6 +586,7 @@ export function MessageComposer({
className="pointer-events-none absolute inset-0 overflow-hidden"
>
<ComposerMentionOverlay
channelNames={channelLinks.knownChannelNames}
content={content}
mentionNames={mentions.knownNames}
scrollTop={composerScrollTop}

View File

@@ -7,6 +7,7 @@ import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import { Markdown } from "@/shared/ui/markdown";
import { MessageActionBar } from "./MessageActionBar";
@@ -52,6 +53,12 @@ export const MessageRow = React.memo(
[profiles, message.tags],
);
const { channels } = useChannelNavigation();
const channelNames = React.useMemo(
() => 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 (
<Markdown
channelNames={channelNames}
className="max-w-3xl"
content={message.body}
mentionNames={mentionNames}

View File

@@ -0,0 +1,82 @@
/**
* Factory for remark plugins that detect prefix-based patterns (e.g. @mention,
* #channel) in text nodes and replace them with custom HAST elements.
*
* Both `remarkMentions` and `remarkChannelLinks` share identical tree-walking
* and text-splitting logic — this factory captures that once.
*/
type NodeBuilder = (matchText: string) => {
// 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;
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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;
};
});
}

View File

@@ -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 }],
},
}));
}

View File

@@ -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<any[]>(
() => [
remarkGfm,
remarkBreaks,
[remarkMentions, { mentionNames }],
[remarkChannelLinks, { channelNames }],
],
[mentionNames, channelNames],
);
let processedContent = content;
if (/^(?:\s{2}\n)+/.test(content)) {
@@ -231,15 +244,7 @@ function MarkdownInner({
className,
)}
>
<ReactMarkdown
components={components}
remarkPlugins={[
remarkGfm,
remarkBreaks,
[remarkMentions, { mentionNames }],
remarkChannelLinks,
]}
>
<ReactMarkdown components={components} remarkPlugins={remarkPlugins}>
{processedContent}
</ReactMarkdown>
</div>
@@ -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";