mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): highlight full multi-word display names in @mentions (#142)
This commit is contained in:
@@ -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}
|
||||
/>
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
]}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user