feat(messages): distinguish deleted message links

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-17 16:35:44 -07:00
committed by Carl
parent 7c68c8637e
commit 2a9c3ed597
10 changed files with 184 additions and 14 deletions
@@ -44,6 +44,9 @@ export function SentFromThreadLine({
channels={channels}
interactive
link={link}
onOpenChannel={(targetChannelId) => {
void goChannel(targetChannelId);
}}
onOpenMessageLink={onOpenMessageLink}
threadExcerpt={reference.rootExcerpt}
variant="sent-from-thread"
@@ -87,6 +87,12 @@
color: hsl(var(--primary));
}
.message-markdown .mention-chip.buzz-link-deleted,
.message-markdown .mention-chip.buzz-link-deleted:hover {
background: hsl(var(--muted));
color: hsl(var(--muted-foreground));
}
.message-markdown .mention-chip.inbox-channel-chip {
background: hsl(var(--muted));
color: hsl(var(--muted-foreground) / 0.82);
+5 -5
View File
@@ -1272,6 +1272,7 @@ export function createMarkdownComponents(
const {
channels,
imetaByUrl,
onOpenChannel,
onOpenEntityLink,
onOpenMessageLink,
onImportSnapshotFromUrl,
@@ -1281,10 +1282,6 @@ export function createMarkdownComponents(
if (!interactive) {
return <span className="font-medium text-current">{children}</span>;
}
// Markdown image-link syntax (`[![alt](src)](href)`) otherwise nests the
// image lightbox button inside an anchor. Keep the image as the lightbox
// trigger and suppress the parent link activation for block media.
if (hasBlockMedia(React.Children.toArray(children))) {
return <>{children}</>;
}
@@ -1359,6 +1356,7 @@ export function createMarkdownComponents(
channels={channels}
interactive={interactive}
link={messageLinkTarget.link}
onOpenChannel={onOpenChannel}
onOpenMessageLink={onOpenMessageLink}
/>
);
@@ -1682,7 +1680,8 @@ export function createMarkdownComponents(
}: {
children?: React.ReactNode;
}) {
const { channels, onOpenMessageLink } = useMarkdownRuntime();
const { channels, onOpenChannel, onOpenMessageLink } =
useMarkdownRuntime();
const href = String(children ?? "");
const parsed = parseMessageLink(href);
if (!parsed.ok) {
@@ -1694,6 +1693,7 @@ export function createMarkdownComponents(
channels={channels}
interactive={interactive}
link={parsed.value}
onOpenChannel={onOpenChannel}
onOpenMessageLink={onOpenMessageLink}
/>
);
@@ -0,0 +1,34 @@
import assert from "node:assert/strict";
import test from "node:test";
import { channelTooltipFooter } from "./ChannelDeepLink.tsx";
const channel = {
id: "channel-id",
name: "history",
channelType: "forum",
visibility: "private",
description: "",
topic: null,
purpose: null,
memberCount: 0,
memberPubkeys: [],
lastMessageAt: null,
archivedAt: "2026-08-17T00:00:00Z",
participants: [],
participantPubkeys: [],
isMember: true,
ttlSeconds: null,
ttlDeadline: null,
};
test("channelTooltipFooter adds archived status without changing existing metadata", () => {
assert.equal(
channelTooltipFooter(channel),
"Private channel · Forum · Archived",
);
assert.equal(
channelTooltipFooter({ ...channel, archivedAt: null }),
"Private channel · Forum",
);
});
@@ -35,10 +35,11 @@ function formatChannelActivity(timestamp: string): string | null {
return `Active ${Math.floor(elapsedDays / 7)}w ago`;
}
function channelTooltipFooter(channel: Channel) {
export function channelTooltipFooter(channel: Channel) {
const details = [
channel.visibility === "private" ? "Private channel" : "Public channel",
channel.channelType === "forum" ? "Forum" : null,
channel.archivedAt ? "Archived" : null,
channel.lastMessageAt ? formatChannelActivity(channel.lastMessageAt) : null,
];
return details.filter(Boolean).join(" · ");
@@ -136,6 +137,7 @@ export function ChannelDeepLinkAnchor({
href={href}
interactive={interactive}
link={messageLink}
onOpenChannel={onOpenChannel}
onOpenMessageLink={onOpenMessageLink}
/>
);
@@ -184,6 +186,7 @@ export function MarkdownChannelDeepLink({
href={href}
interactive={interactive}
link={messageLink}
onOpenChannel={onOpenChannel}
onOpenMessageLink={onOpenMessageLink}
/>
);
@@ -68,6 +68,16 @@ function MessageLinkMetadataTooltip({
footer: string;
metadata: ReturnType<typeof useMessageLinkMetadata>;
}) {
if (metadata.state.kind === "deleted") {
return (
<TooltipProvider delayDuration={500} skipDelayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side="top">Message deleted</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
if (metadata.state.kind !== "ready" || !metadata.state.snippet.trim()) {
return children;
}
@@ -105,6 +115,7 @@ export function MessageLinkPill({
href,
interactive,
link,
onOpenChannel,
onOpenMessageLink,
threadExcerpt,
variant = "default",
@@ -118,6 +129,7 @@ export function MessageLinkPill({
const shouldLoadMetadata =
channelReadable && interactive && variant === "default";
const metadata = useMessageLinkMetadata(link, shouldLoadMetadata);
const isDeleted = metadata.state.kind === "deleted";
const metadataPending =
shouldLoadMetadata &&
(metadata.state.kind === "idle" || metadata.state.kind === "loading");
@@ -150,10 +162,18 @@ export function MessageLinkPill({
data-message-link=""
href={permalink}
icon="message"
aria-label={`Open message in channel ${channelLabel}`}
className="max-w-64"
aria-label={
isDeleted
? `Deleted message in channel ${channelLabel}`
: `Open message in channel ${channelLabel}`
}
className={cn("max-w-64", isDeleted && "buzz-link-deleted")}
interactive={interactive}
onOpenLink={() => {
if (isDeleted) {
onOpenChannel(link.channelId);
return;
}
onOpenMessageLink(link);
}}
>
+1
View File
@@ -26,6 +26,7 @@ export type MessageLinkPillProps = {
href?: string;
interactive: boolean;
link: ParsedMessageLink;
onOpenChannel: (channelId: string) => void;
onOpenMessageLink: (link: ParsedMessageLink) => void;
threadExcerpt?: string | null;
variant?: "default" | "sent-from-thread";
@@ -7,8 +7,18 @@ import { getUserProfile } from "@/shared/api/tauriProfiles";
import { truncatePubkey } from "@/shared/lib/pubkey";
const MESSAGE_METADATA_RETRY_DELAY_MS = 750;
const EVENT_NOT_FOUND_MESSAGE = "event not found";
const PREVIEWABLE_MESSAGE_KINDS = new Set([9, 40002, 45001, 45003]);
function isEventNotFoundError(error: unknown): boolean {
if (typeof error === "string") {
return error.includes(EVENT_NOT_FOUND_MESSAGE);
}
return (
error instanceof Error && error.message.includes(EVENT_NOT_FOUND_MESSAGE)
);
}
function waitForMessageMetadataRetry(): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, MESSAGE_METADATA_RETRY_DELAY_MS);
@@ -18,7 +28,8 @@ function waitForMessageMetadataRetry(): Promise<void> {
async function getMessageLinkEvent(messageId: string) {
try {
return await getEventById(messageId);
} catch {
} catch (error) {
if (isEventNotFoundError(error)) throw error;
await waitForMessageMetadataRetry();
return getEventById(messageId);
}
@@ -33,10 +44,12 @@ type MessageLinkMetadataState =
| { kind: "idle" }
| { kind: "loading" }
| ({ kind: "ready" } & MessageLinkMetadata)
| { kind: "deleted" }
| { kind: "unavailable" };
type CachedMessageLinkMetadata =
| ({ kind: "ready" } & MessageLinkMetadata)
| { kind: "deleted" }
| { kind: "unavailable" };
const metadataCache = new Map<string, Promise<CachedMessageLinkMetadata>>();
@@ -71,7 +84,11 @@ function fetchMetadata(
snippet: summarizeMessageLinkContent(event.content),
};
})
.catch(() => ({ kind: "unavailable" }) as const);
.catch((error) =>
isEventNotFoundError(error)
? ({ kind: "deleted" } as const)
: ({ kind: "unavailable" } as const),
);
metadataCache.set(key, request);
void request.then((result) => {
if (result.kind === "unavailable" && metadataCache.get(key) === request) {
+4 -4
View File
@@ -9631,12 +9631,12 @@ async function resolveGetEvent(
},
config: E2eConfig | undefined,
) {
// Allow test specs to mark specific event IDs as definitively deleted.
if (config?.mock?.deletedEventIds?.includes(args.eventId)) {
throw new Error("event not found");
}
const identity = getIdentity(config);
if (!identity) {
// Allow test specs to mark specific event IDs as definitively deleted.
if (config?.mock?.deletedEventIds?.includes(args.eventId)) {
throw new Error("event not found");
}
const knownEvents: RelayEvent[] = [
...Array.from(mockMessages.values()).flat(),
{
@@ -379,6 +379,92 @@ test("reopening the same entity link reapplies its workspace state", async ({
await expect(issueHeading).toBeVisible();
});
test("definitively deleted message links open their channel and remain copyable", async ({
page,
}) => {
const deletedMessageId = "d".repeat(64);
const channelId = "9dae0116-799b-5071-a0a8-fdd30a91a35d";
const link = `buzz://message?channel=${channelId}&id=${deletedMessageId}`;
await installMockBridge(page, { deletedEventIds: [deletedMessageId] });
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByTestId("channel-general").click();
await page
.getByTestId("message-input")
.fill(`Deleted link \`reference\` ${link}`);
await page.getByTestId("send-message").click();
const linkMessage = page
.getByTestId("message-row")
.filter({ hasText: "Deleted link" })
.last();
const deletedLink = linkMessage.getByLabel(
"Deleted message in channel random",
);
await expect(deletedLink).toHaveText("random");
await expect(deletedLink).toHaveClass(/buzz-link-deleted/);
const inlineCode = linkMessage
.locator("code")
.filter({ hasText: "reference" });
await expect(inlineCode).toBeVisible();
await expect
.poll(async () => {
const [deletedStyles, codeStyles] = await Promise.all([
deletedLink.evaluate((element) => {
const styles = getComputedStyle(element);
return [styles.backgroundColor, styles.color];
}),
inlineCode.evaluate((element) => {
const styles = getComputedStyle(element);
return [styles.backgroundColor, styles.color];
}),
]);
return JSON.stringify(deletedStyles) === JSON.stringify(codeStyles);
})
.toBe(true);
await expect(deletedLink).toHaveJSProperty("tagName", "BUTTON");
const deletedColors = await deletedLink.evaluate((element) => {
const styles = getComputedStyle(element);
return [styles.backgroundColor, styles.color];
});
await deletedLink.hover();
await expect
.poll(() =>
deletedLink.evaluate((element) => {
const styles = getComputedStyle(element);
return [styles.backgroundColor, styles.color];
}),
)
.toEqual(deletedColors);
await expect(page.getByRole("tooltip")).toHaveText("Message deleted");
await deletedLink.click({ button: "right" });
const linkMenu = page.locator("[data-buzz-link-context-menu]");
await expect(
linkMenu.getByRole("button", { name: "Open link" }),
).toBeVisible();
await linkMenu.getByRole("button", { name: "Copy link" }).click();
await expect
.poll(() =>
page.evaluate(() => {
return (
window as Window & {
__BUZZ_E2E_COMMAND_LOG__?: Array<{
command: string;
payload: { text?: string };
}>;
}
).__BUZZ_E2E_COMMAND_LOG__?.findLast(
({ command }) => command === "copy_text_to_clipboard",
)?.payload.text;
}),
)
.toBe(link);
await deletedLink.click();
await expect(page.getByTestId("chat-title")).toHaveText("random");
await expect(page).toHaveURL(new RegExp(`#/channels/${channelId}$`));
});
test("cold-start entity links drain after the React listener mounts", async ({
page,
}) => {