fix(desktop): highlight full multi-word display names in @mentions (#142)

This commit is contained in:
Wes
2026-03-20 16:38:00 -07:00
committed by GitHub
parent d68d0a6a33
commit 0c9bb19fd2
8 changed files with 117 additions and 15 deletions
@@ -8,6 +8,7 @@ import {
} from "@/features/profile/lib/identity";
import type { ForumPost } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import {
AlertDialog,
AlertDialogAction,
@@ -58,6 +59,7 @@ export function ForumPostCard({
preferResolvedSelfLabel: true,
});
const avatarUrl = profiles?.[post.pubkey.toLowerCase()]?.avatarUrl ?? null;
const mentionNames = resolveMentionNames(post.tags, profiles);
const summary = post.threadSummary;
const previewContent =
post.content.length > 200
@@ -149,7 +151,11 @@ export function ForumPostCard({
</div>
<div className="mt-2">
<Markdown compact content={previewContent} />
<Markdown
compact
content={previewContent}
mentionNames={mentionNames}
/>
</div>
{summary && summary.replyCount > 0 ? (
@@ -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 { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import {
AlertDialog,
AlertDialogAction,
@@ -112,6 +113,7 @@ function ReplyRow({
const replyAvatarUrl =
profiles?.[reply.pubkey.toLowerCase()]?.avatarUrl ?? null;
const showDelete = onDelete && canDeleteReply(reply, currentPubkey);
const replyMentionNames = resolveMentionNames(reply.tags, profiles);
return (
<div className="group px-4 py-3">
@@ -160,7 +162,11 @@ function ReplyRow({
) : null}
</div>
<div className="mt-1.5 pl-8">
<Markdown compact content={reply.content} />
<Markdown
compact
content={reply.content}
mentionNames={replyMentionNames}
/>
</div>
</div>
);
@@ -207,6 +213,7 @@ export function ForumThreadPanel({
}
const { post, replies } = thread;
const postMentionNames = resolveMentionNames(post.tags, profiles);
const postAuthorLabel = resolveUserLabel({
pubkey: post.pubkey,
currentPubkey,
@@ -285,7 +292,7 @@ export function ForumThreadPanel({
) : null}
</div>
<div className="mt-3">
<Markdown content={post.content} />
<Markdown content={post.content} mentionNames={postMentionNames} />
</div>
</div>
@@ -5,6 +5,7 @@ import {
type UserProfileLookup,
} from "@/features/profile/lib/identity";
import type { FeedItem } from "@/shared/api/types";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
@@ -152,6 +153,7 @@ export function FeedSection({
const canOpenChannel =
channelId !== null && availableChannelIds.has(channelId);
const isDone = doneSet.has(item.id);
const mentionNames = resolveMentionNames(item.tags, profiles);
return (
<div
@@ -199,6 +201,7 @@ export function FeedSection({
className="pointer-events-none relative mt-0.5 max-w-none text-[13px] leading-snug text-muted-foreground"
compact
content={feedContent(item)}
mentionNames={mentionNames}
/>
{showDoneAction ? (
@@ -2,9 +2,11 @@ import * as React from "react";
import type { TimelineMessage } from "@/features/messages/types";
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn";
import { resolveMentionNames } from "@/shared/lib/resolveMentionNames";
import { Markdown } from "@/shared/ui/markdown";
import { MessageActionBar } from "./MessageActionBar";
@@ -18,6 +20,7 @@ export const MessageRow = React.memo(
message,
onToggleReaction,
onReply,
profiles,
}: {
activeReplyTargetId?: string | null;
highlighted?: boolean;
@@ -28,6 +31,7 @@ export const MessageRow = React.memo(
remove: boolean,
) => Promise<void>;
onReply?: (message: TimelineMessage) => void;
profiles?: UserProfileLookup;
}) {
const [hasAvatarError, setHasAvatarError] = React.useState(false);
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
@@ -37,6 +41,11 @@ export const MessageRow = React.memo(
string | null
>(null);
const [reactionPending, setReactionPending] = React.useState(false);
const mentionNames = React.useMemo(
() => resolveMentionNames(message.tags, profiles),
[profiles, message.tags],
);
const visibleDepth = Math.min(message.depth, 6);
const indentPx = visibleDepth * 28;
const initials = message.author
@@ -75,7 +84,12 @@ export const MessageRow = React.memo(
);
default:
return (
<Markdown className="max-w-3xl" content={message.body} tight />
<Markdown
className="max-w-3xl"
content={message.body}
mentionNames={mentionNames}
tight
/>
);
}
};
@@ -297,7 +311,8 @@ export const MessageRow = React.memo(
prev.message.tags === next.message.tags &&
prev.message.role === next.message.role &&
prev.highlighted === next.highlighted &&
prev.activeReplyTargetId === next.activeReplyTargetId,
prev.activeReplyTargetId === next.activeReplyTargetId &&
prev.profiles === next.profiles,
);
MessageRow.displayName = "MessageRow";
@@ -118,6 +118,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
message={message}
onToggleReaction={onToggleReaction}
onReply={onReply}
profiles={profiles}
/>
),
)
+42 -9
View File
@@ -1,16 +1,29 @@
/**
* Remark plugin that detects @mention patterns in text nodes and wraps them
* in custom HAST `mention` elements for styled rendering via react-markdown.
*
* When `mentionNames` is provided, multi-word display names (e.g. "John Doe")
* are matched first (longest-first to avoid partial matches), then the plugin
* falls back to the generic `@\S+` pattern for unknown mentions.
*/
export default function remarkMentions() {
// biome-ignore lint/suspicious/noExplicitAny: remark tree types are not available
return (tree: any) => {
walkChildren(tree);
type RemarkMentionsOptions = {
mentionNames?: string[];
};
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) {
function walkChildren(node: any, mentionPattern: RegExp) {
if (!node?.children || !Array.isArray(node.children)) {
return;
}
@@ -19,18 +32,38 @@ function walkChildren(node: any) {
const child = node.children[i];
if (child.type === "text") {
const parts = splitMentions(child.value);
const parts = splitMentions(child.value, mentionPattern);
if (parts.length > 1) {
node.children.splice(i, 1, ...parts);
}
} else {
walkChildren(child);
walkChildren(child, mentionPattern);
}
}
}
function splitMentions(text: string) {
const mentionPattern = /@\S+/g;
function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function buildMentionPattern(mentionNames: string[]): RegExp {
// Deduplicate and sort longest-first so "John Doe" is matched before "John"
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;
}
// Build alternation: try known names first, then fall back to @\S+
const nameAlternatives = sorted.map((name) => escapeRegExp(name)).join("|");
return new RegExp(`@(?:${nameAlternatives}|\\S+)`, "g");
}
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;
@@ -0,0 +1,35 @@
import type { UserProfileSummary } from "@/shared/api/types";
/**
* Resolves display names for mentioned users from message `p` tags.
*
* Extracts pubkeys from `p` tags, looks them up in the profiles map,
* and returns a deduplicated list of display names. Returns `undefined`
* when no names can be resolved (so the remark plugin falls back to
* the generic `@\S+` pattern).
*/
export function resolveMentionNames(
tags: string[][] | undefined,
profiles: Record<string, UserProfileSummary> | undefined,
): string[] | undefined {
if (!profiles || !tags) {
return undefined;
}
const names = new Set<string>();
for (const tag of tags) {
if (tag[0] !== "p" || !tag[1]) {
continue;
}
const profile = profiles[tag[1].toLowerCase()];
const displayName = profile?.displayName?.trim();
if (displayName) {
names.add(displayName);
}
}
return names.size > 0 ? [...names] : undefined;
}
+3 -1
View File
@@ -13,6 +13,7 @@ type MarkdownProps = {
className?: string;
compact?: boolean;
content: string;
mentionNames?: string[];
tight?: boolean;
};
@@ -183,6 +184,7 @@ export function Markdown({
className,
compact = false,
content,
mentionNames,
tight = false,
}: MarkdownProps) {
const variant = tight ? "tight" : compact ? "compact" : "default";
@@ -213,7 +215,7 @@ export function Markdown({
remarkPlugins={[
remarkGfm,
remarkBreaks,
remarkMentions,
[remarkMentions, { mentionNames }],
remarkChannelLinks,
]}
>