Skip to content
12 changes: 11 additions & 1 deletion desktop/src/features/channels/ui/ChannelCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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("");

Expand Down Expand Up @@ -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">
Expand Down
16 changes: 15 additions & 1 deletion desktop/src/features/forum/ui/ForumThreadPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -163,6 +166,7 @@ function ReplyRow({
</div>
<div className="mt-1.5 pl-8">
<Markdown
channelNames={channelNames}
compact
content={reply.content}
mentionNames={replyMentionNames}
Expand All @@ -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 (
Expand Down Expand Up @@ -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>

Expand All @@ -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}
Expand Down
40 changes: 24 additions & 16 deletions desktop/src/features/messages/lib/useChannelLinks.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,14 @@
import * as React from "react";

import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";

export type ChannelSuggestion = {
id: string;
name: string;
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() {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -189,6 +196,7 @@ export function useChannelLinks() {
handleChannelKeyDown,
insertChannel,
isChannelOpen,
knownChannelNames,
updateChannelQuery,
};
}
53 changes: 4 additions & 49 deletions desktop/src/features/messages/lib/useMentions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 12 additions & 8 deletions desktop/src/features/messages/ui/ComposerMentionOverlay.tsx
Original file line number Diff line number Diff line change
@@ -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[] {
Expand Down Expand Up @@ -41,6 +43,7 @@ function parseSegments(text: string, pattern: RegExp): Segment[] {
}

type ComposerMentionOverlayProps = {
channelNames: string[];
content: string;
mentionNames: string[];
scrollTop: number;
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions desktop/src/features/messages/ui/MessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
8 changes: 8 additions & 0 deletions desktop/src/features/messages/ui/MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -91,6 +98,7 @@ export const MessageRow = React.memo(
default:
return (
<Markdown
channelNames={channelNames}
className="max-w-3xl"
content={message.body}
mentionNames={mentionNames}
Expand Down
Loading
Loading