mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Copying a message out of the timeline lost the mention. The rendered chip drops the `@` for display, so the clipboard carried "John Smith" — two ordinary words no composer could bind back to a pubkey. Pasting into another channel produced dead text, and sending it tagged nobody. ## What changed Every Buzz copy now writes two clipboard flavors in one transaction: - **`text/plain`** — readable anywhere, sigils restored, no pubkeys. This is what TextEdit, Slack, and every other external app receive. - **`text/html`** — the same content with each mention wrapped in a span carrying `data-mention-pubkey` / `-label` / `-kind`. On paste, the composer harvests those records, registers each `name → pubkey` with the existing mention machinery, and inserts the content: the chip re-lights and the send path recovers the identity the author tagged. A marker attribute records what the plain flavor holds, so a Markdown copy pastes through the text pipeline and a rendered copy through the HTML one. **Covered surfaces:** timeline selection copy, thread-panel selection copy, forum post/reply selection copy, "Copy message", and composer copy/cut — plus paste in both the channel and forum composers. ## Trust boundary Clipboard HTML is untrusted, and the branch treats it that way: - Records are capped (50), labels bounded (200 chars), and a pubkey must be 64 hex before it can become a `p` tag. - **Only mentions the paste actually shows are registered.** An empty `<span data-mention-pubkey=… -label="Jane Doe">` would otherwise rebind that display name for the rest of the composer session, so a later hand-written @Jane Doe would chip-light convincingly against the attacker's key. Each branch registers only the records whose label appears in the text *it* inserts, matched with `getMentionOffsets` — the same matcher the send-time extractor uses. - **The visibility gate reads only what ProseMirror will insert.** `DOMParser` hard-drops `script`, `style`, `title`, `noscript`, `object`, and `head` content, so `visible<style>@Jane Doe</style>` beside an empty chip span used to smuggle a binding past the gate. Those elements are stripped before either output is derived. - **A partial chip never gains a sigil.** A selection crossing a chip boundary falls through to the browser's default copy, which serializes the full identity attributes around a slice of the text — pasting that invented "@Smith" out of "John Smith". Paste now leaves a fragment as plain text, tolerating only what a whole chip picks up in transit (restored sigil, author casing, U+00A0 swaps, and the label cap's own ellipsis). Both clipboard sides share one `matchChipTextToLabel` helper, in the module that owns the label attributes, so copy and paste cannot drift on what counts as a whole chip. ## Notes - Mention matching reuses `getMentionOffsets`, so code spans and fences are excluded and the longest display name wins. - The plain flavor inlines chip boxes before reading `innerText`; a chip is a flex container, so the browser's own copy split "@John Smith" onto its own line. - `MarkdownMention` and `MacEmacsTextShortcuts` are extracted verbatim from `markdown.tsx` and `useRichTextEditor.ts` to keep both files under the size gate. ## Testing - ~40 unit tests over the flavor builder/parser, the visibility filter, the ignored-tag sweep, and the chip-match verdicts. - A Playwright spec (`desktop/tests/e2e/mention-clipboard.spec.ts`) driving real copy/cut/paste DOM events: timeline and "Copy message" of a multi-word non-member mention pasted into another channel and sent with the original pubkey in its `p` tag; the forum copy → forum reply round trip; composer copy/cut; plain flavor asserted to contain no 64-hex string; the boundary-crossing drag; and the hidden-record and `<style>` smuggling vectors. - Every regression test is bound to a production seam and fails with its guard removed. - `just ci` / pre-push lanes green (5927 desktop unit tests, typecheck, lint, file-size gate). --------- Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
346 lines
11 KiB
TypeScript
346 lines
11 KiB
TypeScript
import { ArrowLeft, MessageSquare } from "lucide-react";
|
|
import * as React from "react";
|
|
|
|
import { handleTimelineMentionCopy } from "@/features/messages/lib/timelineMentionCopy";
|
|
import {
|
|
resolveUserLabel,
|
|
type UserProfileLookup,
|
|
} from "@/features/profile/lib/identity";
|
|
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
|
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
|
import type { ForumThreadResponse, ThreadReply } from "@/shared/api/types";
|
|
import { channelChrome } from "@/shared/layout/chromeLayout";
|
|
import { cn } from "@/shared/lib/cn";
|
|
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
|
|
import { resolveMentionProps } from "@/shared/lib/resolveMentionNames";
|
|
import { Button } from "@/shared/ui/button";
|
|
import { parseImetaTags } from "@/shared/ui/markdown/parseImeta";
|
|
import { Markdown } from "@/shared/ui/markdown";
|
|
import { hasLinkPreviewSuppression } from "@/features/messages/lib/formatTimelineMessages";
|
|
import { Skeleton } from "@/shared/ui/skeleton";
|
|
|
|
import { formatRelativeTime } from "../lib/time";
|
|
import { DeleteActionMenu } from "./DeleteActionMenu";
|
|
import { ForumComposer } from "./ForumComposer";
|
|
|
|
type ForumThreadPanelProps = {
|
|
thread: ForumThreadResponse | undefined;
|
|
isLoading: boolean;
|
|
isSendingReply: boolean;
|
|
channelId: string;
|
|
currentPubkey?: string;
|
|
profiles?: UserProfileLookup;
|
|
onBack: () => void;
|
|
onReply: (
|
|
content: string,
|
|
mentionPubkeys: string[],
|
|
mediaTags?: string[][],
|
|
) => undefined | Promise<unknown>;
|
|
onDeletePost?: (eventId: string) => void;
|
|
onDeleteReply?: (eventId: string) => void;
|
|
onTargetReached?: (eventId: string) => void;
|
|
canDeletePost?: boolean;
|
|
isDeletingPost?: boolean;
|
|
targetEventId?: string | null;
|
|
targetSearchMessageId?: string;
|
|
targetSearchQuery?: string;
|
|
};
|
|
|
|
function canDeleteReply(
|
|
reply: ThreadReply,
|
|
currentPubkey: string | undefined,
|
|
): boolean {
|
|
if (!currentPubkey) return false;
|
|
return reply.pubkey.toLowerCase() === currentPubkey.toLowerCase();
|
|
}
|
|
|
|
function ReplyRow({
|
|
reply,
|
|
currentPubkey,
|
|
profiles,
|
|
channelNames,
|
|
onDelete,
|
|
searchQuery,
|
|
}: {
|
|
reply: ThreadReply;
|
|
currentPubkey?: string;
|
|
profiles?: UserProfileLookup;
|
|
channelNames?: string[];
|
|
onDelete?: (eventId: string) => void;
|
|
searchQuery?: string;
|
|
}) {
|
|
const replyAuthorLabel = resolveUserLabel({
|
|
pubkey: reply.pubkey,
|
|
currentPubkey,
|
|
profiles,
|
|
preferResolvedSelfLabel: true,
|
|
});
|
|
const replyAvatarUrl =
|
|
profiles?.[reply.pubkey.toLowerCase()]?.avatarUrl ?? null;
|
|
const replyAuthorIsAgent =
|
|
profiles?.[reply.pubkey.toLowerCase()]?.isAgent === true;
|
|
const showDelete = onDelete && canDeleteReply(reply, currentPubkey);
|
|
const {
|
|
mentionNames: replyMentionNames,
|
|
mentionPubkeysByName: replyMentionPubkeysByName,
|
|
} = resolveMentionProps(reply.tags, profiles);
|
|
|
|
return (
|
|
<div
|
|
className="group content-visibility-auto px-4 py-3"
|
|
data-forum-event-id={reply.eventId}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<UserProfilePopover
|
|
pubkey={reply.pubkey}
|
|
role={replyAuthorIsAgent ? "bot" : undefined}
|
|
>
|
|
<button
|
|
className="flex items-center gap-2 rounded-lg focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
|
type="button"
|
|
>
|
|
<UserAvatar
|
|
accent={replyAuthorIsAgent}
|
|
avatarUrl={replyAvatarUrl}
|
|
displayName={replyAuthorLabel}
|
|
shape={replyAuthorIsAgent ? "squircle" : "circle"}
|
|
size="sm"
|
|
/>
|
|
<span className="text-sm font-medium text-foreground hover:underline">
|
|
{replyAuthorLabel}
|
|
</span>
|
|
</button>
|
|
</UserProfilePopover>
|
|
<span className="text-xs text-muted-foreground">
|
|
{formatRelativeTime(reply.createdAt)}
|
|
</span>
|
|
|
|
{showDelete ? (
|
|
<DeleteActionMenu
|
|
iconSize="sm"
|
|
label="reply"
|
|
onConfirm={() => onDelete(reply.eventId)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
<div className="mt-1.5 pl-8">
|
|
<Markdown
|
|
channelNames={channelNames}
|
|
className="text-sm"
|
|
content={reply.content}
|
|
messageId={reply.eventId}
|
|
linkPreviewsSuppressed={hasLinkPreviewSuppression(reply.tags)}
|
|
linkPreviewTags={reply.tags}
|
|
imetaByUrl={parseImetaTags(reply.tags)}
|
|
mentionNames={replyMentionNames}
|
|
mentionPubkeysByName={replyMentionPubkeysByName}
|
|
searchQuery={searchQuery}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function ForumThreadPanel({
|
|
thread,
|
|
isLoading,
|
|
isSendingReply,
|
|
channelId,
|
|
currentPubkey,
|
|
profiles,
|
|
onBack,
|
|
onReply,
|
|
onDeletePost,
|
|
onDeleteReply,
|
|
onTargetReached,
|
|
canDeletePost,
|
|
isDeletingPost,
|
|
targetEventId,
|
|
targetSearchMessageId,
|
|
targetSearchQuery,
|
|
}: ForumThreadPanelProps) {
|
|
const scrollRef = React.useRef<HTMLDivElement>(null);
|
|
const { channels } = useChannelNavigation();
|
|
const channelNames = React.useMemo(
|
|
() => channels.filter((c) => c.channelType !== "dm").map((c) => c.name),
|
|
[channels],
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
if (!thread || !targetEventId) {
|
|
return;
|
|
}
|
|
|
|
const targetElement =
|
|
scrollRef.current?.querySelector<HTMLElement>(
|
|
`[data-forum-event-id="${targetEventId}"]`,
|
|
) ?? null;
|
|
if (!targetElement) {
|
|
return;
|
|
}
|
|
|
|
targetElement.scrollIntoView({ block: "center" });
|
|
onTargetReached?.(targetEventId);
|
|
}, [onTargetReached, targetEventId, thread]);
|
|
|
|
if (isLoading || !thread) {
|
|
return (
|
|
<div className={cn("flex h-full flex-col", channelChrome.contentPadding)}>
|
|
<div className="border-b border-border/60 px-4 py-3">
|
|
<Button
|
|
className="gap-1.5 text-muted-foreground"
|
|
onClick={onBack}
|
|
size="sm"
|
|
variant="ghost"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
Back to posts
|
|
</Button>
|
|
</div>
|
|
<div className="flex-1 space-y-4 p-4">
|
|
<Skeleton className="h-8 w-3/4" />
|
|
<Skeleton className="h-24 w-full" />
|
|
<Skeleton className="h-16 w-full" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const { post, replies } = thread;
|
|
const {
|
|
mentionNames: postMentionNames,
|
|
mentionPubkeysByName: postMentionPubkeysByName,
|
|
} = resolveMentionProps(post.tags, profiles);
|
|
const postAuthorLabel = resolveUserLabel({
|
|
pubkey: post.pubkey,
|
|
currentPubkey,
|
|
profiles,
|
|
preferResolvedSelfLabel: true,
|
|
});
|
|
const postAvatarUrl =
|
|
profiles?.[post.pubkey.toLowerCase()]?.avatarUrl ?? null;
|
|
const postAuthorIsAgent =
|
|
profiles?.[post.pubkey.toLowerCase()]?.isAgent === true;
|
|
|
|
return (
|
|
<div className={cn("flex h-full flex-col", channelChrome.contentPadding)}>
|
|
<div className="border-b border-border/60 px-4 py-3">
|
|
<Button
|
|
className="gap-1.5 text-muted-foreground"
|
|
onClick={onBack}
|
|
size="sm"
|
|
variant="ghost"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
Back to posts
|
|
</Button>
|
|
</div>
|
|
|
|
<div
|
|
className="flex-1 overflow-y-auto"
|
|
data-scroll-restoration-id={`forum-thread:${channelId}`}
|
|
onCopy={handleTimelineMentionCopy}
|
|
ref={scrollRef}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"group border-b border-border/60 p-4",
|
|
isDeletingPost && "pointer-events-none opacity-50",
|
|
)}
|
|
data-forum-event-id={post.eventId}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<UserProfilePopover
|
|
pubkey={post.pubkey}
|
|
role={postAuthorIsAgent ? "bot" : undefined}
|
|
>
|
|
<button
|
|
className="flex items-center gap-2 rounded-xl focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
|
|
type="button"
|
|
>
|
|
<UserAvatar
|
|
accent={postAuthorIsAgent}
|
|
avatarUrl={postAvatarUrl}
|
|
displayName={postAuthorLabel}
|
|
shape={postAuthorIsAgent ? "squircle" : "circle"}
|
|
/>
|
|
<span className="text-sm font-semibold text-foreground hover:underline">
|
|
{postAuthorLabel}
|
|
</span>
|
|
</button>
|
|
</UserProfilePopover>
|
|
<span className="text-xs text-muted-foreground">
|
|
{formatRelativeTime(post.createdAt)}
|
|
</span>
|
|
|
|
{canDeletePost && onDeletePost ? (
|
|
<DeleteActionMenu
|
|
label="post"
|
|
onConfirm={() => onDeletePost(post.eventId)}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
<div className="mt-3">
|
|
<Markdown
|
|
channelNames={channelNames}
|
|
className="text-sm"
|
|
content={post.content}
|
|
messageId={post.eventId}
|
|
linkPreviewsSuppressed={hasLinkPreviewSuppression(post.tags)}
|
|
linkPreviewTags={post.tags}
|
|
imetaByUrl={parseImetaTags(post.tags)}
|
|
mentionNames={postMentionNames}
|
|
mentionPubkeysByName={postMentionPubkeysByName}
|
|
searchQuery={
|
|
targetSearchMessageId === post.eventId
|
|
? targetSearchQuery
|
|
: undefined
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1.5 border-b border-border/60 px-4 py-2.5 text-sm font-medium text-muted-foreground">
|
|
<MessageSquare className="h-4 w-4" />
|
|
{replies.length} {replies.length === 1 ? "reply" : "replies"}
|
|
</div>
|
|
|
|
<div className="divide-y divide-border/40">
|
|
{replies.map((reply) => (
|
|
<ReplyRow
|
|
channelNames={channelNames}
|
|
currentPubkey={currentPubkey}
|
|
key={reply.eventId}
|
|
onDelete={onDeleteReply}
|
|
profiles={profiles}
|
|
reply={reply}
|
|
searchQuery={
|
|
targetSearchMessageId === reply.eventId
|
|
? targetSearchQuery
|
|
: undefined
|
|
}
|
|
/>
|
|
))}
|
|
|
|
{replies.length === 0 ? (
|
|
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
|
|
No replies yet. Be the first to respond.
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="border-t border-border/60 p-4">
|
|
<ForumComposer
|
|
channelId={channelId}
|
|
channelType="forum"
|
|
isSending={isSendingReply}
|
|
onSubmit={onReply}
|
|
placeholder="Reply to this post..."
|
|
profiles={profiles}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|