mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
82
desktop/src/shared/lib/createRemarkPrefixPlugin.ts
Normal file
82
desktop/src/shared/lib/createRemarkPrefixPlugin.ts
Normal 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;
|
||||
}
|
||||
64
desktop/src/shared/lib/detectPrefixQuery.ts
Normal file
64
desktop/src/shared/lib/detectPrefixQuery.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }],
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user