diff --git a/desktop/src/features/messages/ui/SentFromThreadLine.tsx b/desktop/src/features/messages/ui/SentFromThreadLine.tsx index 75e8d1ae1..a78d7df2c 100644 --- a/desktop/src/features/messages/ui/SentFromThreadLine.tsx +++ b/desktop/src/features/messages/ui/SentFromThreadLine.tsx @@ -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" diff --git a/desktop/src/shared/styles/globals/markdown.css b/desktop/src/shared/styles/globals/markdown.css index 7d0a238c1..b9c70e2db 100644 --- a/desktop/src/shared/styles/globals/markdown.css +++ b/desktop/src/shared/styles/globals/markdown.css @@ -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); diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index a556278ea..d1d92144e 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -1272,6 +1272,7 @@ export function createMarkdownComponents( const { channels, imetaByUrl, + onOpenChannel, onOpenEntityLink, onOpenMessageLink, onImportSnapshotFromUrl, @@ -1281,10 +1282,6 @@ export function createMarkdownComponents( if (!interactive) { return {children}; } - - // 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} /> ); diff --git a/desktop/src/shared/ui/markdown/ChannelDeepLink.test.mjs b/desktop/src/shared/ui/markdown/ChannelDeepLink.test.mjs new file mode 100644 index 000000000..75858a10f --- /dev/null +++ b/desktop/src/shared/ui/markdown/ChannelDeepLink.test.mjs @@ -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", + ); +}); diff --git a/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx b/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx index 1d705994b..8dcb25601 100644 --- a/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx +++ b/desktop/src/shared/ui/markdown/ChannelDeepLink.tsx @@ -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} /> ); diff --git a/desktop/src/shared/ui/markdown/MessageLinkPill.tsx b/desktop/src/shared/ui/markdown/MessageLinkPill.tsx index b31b5fd2e..15d38b07c 100644 --- a/desktop/src/shared/ui/markdown/MessageLinkPill.tsx +++ b/desktop/src/shared/ui/markdown/MessageLinkPill.tsx @@ -68,6 +68,16 @@ function MessageLinkMetadataTooltip({ footer: string; metadata: ReturnType; }) { + if (metadata.state.kind === "deleted") { + return ( + + + {children} + Message deleted + + + ); + } 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); }} > diff --git a/desktop/src/shared/ui/markdown/types.ts b/desktop/src/shared/ui/markdown/types.ts index 69e575acb..057a811f9 100644 --- a/desktop/src/shared/ui/markdown/types.ts +++ b/desktop/src/shared/ui/markdown/types.ts @@ -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"; diff --git a/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts b/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts index 1bd5c9d23..66f3955bd 100644 --- a/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts +++ b/desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts @@ -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 { return new Promise((resolve) => { window.setTimeout(resolve, MESSAGE_METADATA_RETRY_DELAY_MS); @@ -18,7 +28,8 @@ function waitForMessageMetadataRetry(): Promise { 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>(); @@ -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) { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 4215d183a..df1709653 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -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(), { diff --git a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts index b7a6ee996..0762b767b 100644 --- a/desktop/tests/e2e/entity-link-recipient-cards.spec.ts +++ b/desktop/tests/e2e/entity-link-recipient-cards.spec.ts @@ -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, }) => {