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 (`[](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,
}) => {