diff --git a/desktop/src/features/settings/ui/SettingsPanels.tsx b/desktop/src/features/settings/ui/SettingsPanels.tsx
index e74d1f383..593a4a7b6 100644
--- a/desktop/src/features/settings/ui/SettingsPanels.tsx
+++ b/desktop/src/features/settings/ui/SettingsPanels.tsx
@@ -36,6 +36,11 @@ import {
useThreadViewMode,
type ThreadViewMode,
} from "@/features/channels/lib/threadViewModePreference";
+import {
+ setLinkPreviewStyle,
+ useLinkPreviewStyle,
+ type LinkPreviewStyle,
+} from "@/shared/lib/linkPreviewStylePreference";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
@@ -646,11 +651,86 @@ function ThemeSettingsCard() {
)}
+
);
}
+const LINK_PREVIEW_STYLE_OPTIONS: {
+ value: LinkPreviewStyle;
+ label: string;
+ description: string;
+}[] = [
+ {
+ value: "compact",
+ label: "Compact",
+ description: "Show links as compact horizontal cards",
+ },
+ {
+ value: "rich",
+ label: "Rich",
+ description: "Unfurl links with larger images and descriptions",
+ },
+];
+
+function LinkPreviewStyleSetting() {
+ const style = useLinkPreviewStyle();
+ const activeOption =
+ LINK_PREVIEW_STYLE_OPTIONS.find((option) => option.value === style) ??
+ LINK_PREVIEW_STYLE_OPTIONS[0];
+
+ return (
+
+
+
+
Links
+
+ {activeOption.description}
+
+
+
+
+
+
+
+
+ setLinkPreviewStyle(next as LinkPreviewStyle)
+ }
+ value={style}
+ >
+ {LINK_PREVIEW_STYLE_OPTIONS.map((option) => (
+
+
+ {option.label}
+
+ {option.description}
+
+
+
+ ))}
+
+
+
+
+
+ );
+}
+
const THREAD_VIEW_MODE_OPTIONS: {
value: ThreadViewMode;
label: string;
diff --git a/desktop/src/shared/lib/linkPreviewStylePreference.test.mjs b/desktop/src/shared/lib/linkPreviewStylePreference.test.mjs
new file mode 100644
index 000000000..2cbfdfad4
--- /dev/null
+++ b/desktop/src/shared/lib/linkPreviewStylePreference.test.mjs
@@ -0,0 +1,23 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+const values = new Map();
+globalThis.localStorage = {
+ getItem: (key) => values.get(key) ?? null,
+ setItem: (key, value) => values.set(key, value),
+};
+
+const preference = await import("./linkPreviewStylePreference.ts");
+
+test("defaults invalid and missing link preview styles to compact", () => {
+ assert.equal(preference.parseLinkPreviewStyle(null), "compact");
+ assert.equal(preference.parseLinkPreviewStyle("expanded"), "compact");
+ assert.equal(preference.parseLinkPreviewStyle("compact"), "compact");
+ assert.equal(preference.parseLinkPreviewStyle("rich"), "rich");
+});
+
+test("persists and exposes the selected link preview style", () => {
+ preference.setLinkPreviewStyle("rich");
+ assert.equal(preference.getLinkPreviewStyle(), "rich");
+ assert.equal(values.get(preference.LINK_PREVIEW_STYLE_STORAGE_KEY), "rich");
+});
diff --git a/desktop/src/shared/lib/linkPreviewStylePreference.ts b/desktop/src/shared/lib/linkPreviewStylePreference.ts
new file mode 100644
index 000000000..3a3c5d789
--- /dev/null
+++ b/desktop/src/shared/lib/linkPreviewStylePreference.ts
@@ -0,0 +1,56 @@
+import * as React from "react";
+
+/** User preference for how link previews are presented. */
+export type LinkPreviewStyle = "compact" | "rich";
+
+export const LINK_PREVIEW_STYLE_STORAGE_KEY =
+ "buzz.appearance.linkPreviewStyle";
+export const DEFAULT_LINK_PREVIEW_STYLE: LinkPreviewStyle = "compact";
+
+const listeners = new Set<() => void>();
+let linkPreviewStyle = readStoredLinkPreviewStyle();
+
+export function parseLinkPreviewStyle(
+ value: string | null | undefined,
+): LinkPreviewStyle {
+ return value === "rich" || value === "compact"
+ ? value
+ : DEFAULT_LINK_PREVIEW_STYLE;
+}
+
+function readStoredLinkPreviewStyle(): LinkPreviewStyle {
+ try {
+ return parseLinkPreviewStyle(
+ globalThis.localStorage?.getItem(LINK_PREVIEW_STYLE_STORAGE_KEY),
+ );
+ } catch {
+ return DEFAULT_LINK_PREVIEW_STYLE;
+ }
+}
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => listeners.delete(listener);
+}
+
+export function getLinkPreviewStyle(): LinkPreviewStyle {
+ return linkPreviewStyle;
+}
+
+export function setLinkPreviewStyle(style: LinkPreviewStyle): void {
+ linkPreviewStyle = style;
+ try {
+ globalThis.localStorage?.setItem(LINK_PREVIEW_STYLE_STORAGE_KEY, style);
+ } catch {
+ // Persistence is best-effort; the in-memory preference still applies.
+ }
+ for (const listener of listeners) listener();
+}
+
+export function useLinkPreviewStyle(): LinkPreviewStyle {
+ return React.useSyncExternalStore(
+ subscribe,
+ getLinkPreviewStyle,
+ () => DEFAULT_LINK_PREVIEW_STYLE,
+ );
+}
diff --git a/desktop/src/shared/ui/compact-link-preview-attachment.tsx b/desktop/src/shared/ui/compact-link-preview-attachment.tsx
new file mode 100644
index 000000000..e14e440a8
--- /dev/null
+++ b/desktop/src/shared/ui/compact-link-preview-attachment.tsx
@@ -0,0 +1,245 @@
+import { Globe, X } from "lucide-react";
+
+import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews";
+import { cn } from "@/shared/lib/cn";
+import { Button } from "@/shared/ui/button";
+import {
+ Attachment,
+ AttachmentContent,
+ AttachmentDescription,
+ AttachmentMedia,
+ AttachmentTitle,
+ AttachmentTrigger,
+} from "@/shared/ui/attachment";
+
+function LinearLogo({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function GitHubLogo({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function GoogleDriveLogo({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function GoogleDocsLogo({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function GoogleSheetsLogo({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function GoogleSlidesLogo({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function getHostname(preview: ResolvedLinkPreview): string {
+ try {
+ return new URL(preview.href).hostname.replace(/^www\./, "");
+ } catch {
+ return preview.provider;
+ }
+}
+
+function LinkPreviewLogo({ preview }: { preview: ResolvedLinkPreview }) {
+ if (preview.faviconDataUrl) {
+ return (
+
+ );
+ }
+
+ switch (preview.kind) {
+ case "github-issue":
+ case "github-pull-request":
+ case "github-repository":
+ return ;
+ case "linear-issue":
+ return ;
+ case "google-drive-file":
+ case "google-drive-folder":
+ return ;
+ case "google-docs-document":
+ return ;
+ case "google-sheets-spreadsheet":
+ return ;
+ case "google-slides-presentation":
+ return ;
+ case "generic-link":
+ return ;
+ }
+}
+
+export function CompactLinkPreviewAttachment({
+ className,
+ onRemove,
+ preview,
+}: {
+ className?: string;
+ onRemove?: () => void;
+ preview: ResolvedLinkPreview;
+}) {
+ const reserveImage = preview.imageState !== "none";
+ const showImage = preview.imageState === "image";
+ const hostname = getHostname(preview);
+
+ return (
+
+
+ {reserveImage ? (
+
+ {showImage ? (
+
+ ) : (
+
+ )}
+
+ ) : (
+
+
+
+ )}
+
+
+ {reserveImage && preview.faviconDataUrl ? (
+

+ ) : null}
+
+ {reserveImage ? hostname : preview.provider}
+
+
+
+ {preview.title}
+
+ {reserveImage && preview.description ? (
+ {preview.description}
+ ) : null}
+
+
+
+
+ Open {preview.provider} {preview.typeLabel}: {preview.title}
+
+
+
+
+ {onRemove ? (
+
+ ) : null}
+
+ );
+}
diff --git a/desktop/src/shared/ui/link-preview-attachment.tsx b/desktop/src/shared/ui/link-preview-attachment.tsx
index da225aec7..bb8fe352b 100644
--- a/desktop/src/shared/ui/link-preview-attachment.tsx
+++ b/desktop/src/shared/ui/link-preview-attachment.tsx
@@ -1,136 +1,7 @@
-import { Globe, X } from "lucide-react";
-
import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews";
-import { cn } from "@/shared/lib/cn";
-import { Button } from "@/shared/ui/button";
-import {
- Attachment,
- AttachmentContent,
- AttachmentDescription,
- AttachmentMedia,
- AttachmentTitle,
- AttachmentTrigger,
-} from "@/shared/ui/attachment";
-
-function LinearLogo({ className }: { className?: string }) {
- return (
-
- );
-}
-
-function GitHubLogo({ className }: { className?: string }) {
- return (
-
- );
-}
-
-function GoogleDriveLogo({ className }: { className?: string }) {
- return (
-
- );
-}
-
-function GoogleDocsLogo({ className }: { className?: string }) {
- return (
-
- );
-}
-
-function GoogleSheetsLogo({ className }: { className?: string }) {
- return (
-
- );
-}
-
-function GoogleSlidesLogo({ className }: { className?: string }) {
- return (
-
- );
-}
-
-function getHostname(preview: ResolvedLinkPreview): string {
- try {
- return new URL(preview.href).hostname.replace(/^www\./, "");
- } catch {
- return preview.provider;
- }
-}
-
-function LinkPreviewLogo({ preview }: { preview: ResolvedLinkPreview }) {
- if (preview.faviconDataUrl) {
- return (
-
- );
- }
-
- switch (preview.kind) {
- case "github-issue":
- case "github-pull-request":
- case "github-repository":
- return ;
- case "linear-issue":
- return ;
- case "google-drive-file":
- case "google-drive-folder":
- return ;
- case "google-docs-document":
- return ;
- case "google-sheets-spreadsheet":
- return ;
- case "google-slides-presentation":
- return ;
- case "generic-link":
- return ;
- }
-}
+import { useLinkPreviewStyle } from "@/shared/lib/linkPreviewStylePreference";
+import { CompactLinkPreviewAttachment } from "@/shared/ui/compact-link-preview-attachment";
+import { RichLinkPreviewAttachment } from "@/shared/ui/rich-link-preview-attachment";
export function LinkPreviewAttachment({
className,
@@ -141,105 +12,11 @@ export function LinkPreviewAttachment({
onRemove?: () => void;
preview: ResolvedLinkPreview;
}) {
- const reserveImage = preview.imageState !== "none";
- const showImage = preview.imageState === "image";
- const hostname = getHostname(preview);
+ const style = useLinkPreviewStyle();
+ const Preview =
+ style === "rich" ? RichLinkPreviewAttachment : CompactLinkPreviewAttachment;
return (
-
-
- {reserveImage ? (
-
- {showImage ? (
-
- ) : (
-
- )}
-
- ) : (
-
-
-
- )}
-
-
- {reserveImage && preview.faviconDataUrl ? (
-

- ) : null}
-
- {reserveImage ? hostname : preview.provider}
-
-
-
- {preview.title}
-
- {reserveImage && preview.description ? (
- {preview.description}
- ) : null}
-
-
-
-
- Open {preview.provider} {preview.typeLabel}: {preview.title}
-
-
-
-
- {onRemove ? (
-
- ) : null}
-
+
);
}
diff --git a/desktop/src/shared/ui/rich-link-preview-attachment.tsx b/desktop/src/shared/ui/rich-link-preview-attachment.tsx
new file mode 100644
index 000000000..349fb540a
--- /dev/null
+++ b/desktop/src/shared/ui/rich-link-preview-attachment.tsx
@@ -0,0 +1,313 @@
+import { ChevronDown, ChevronUp, X } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+
+import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews";
+import { cn } from "@/shared/lib/cn";
+import { Button } from "@/shared/ui/button";
+
+function LinkPreviewIdentity({ preview }: { preview: ResolvedLinkPreview }) {
+ if (preview.faviconDataUrl) {
+ return (
+
+ );
+ }
+ return null;
+}
+
+function getHostname(preview: ResolvedLinkPreview): string {
+ try {
+ return new URL(preview.href).hostname.replace(/^www\./, "");
+ } catch {
+ return preview.provider;
+ }
+}
+
+function isTweetPreview(preview: ResolvedLinkPreview): boolean {
+ try {
+ const url = new URL(preview.href);
+ return (
+ (url.hostname === "x.com" || url.hostname === "twitter.com") &&
+ /^\/[^/]+\/status\/\d+/.test(url.pathname)
+ );
+ } catch {
+ return false;
+ }
+}
+
+function TweetPreview({
+ className,
+ onRemove,
+ preview,
+}: {
+ className?: string;
+ onRemove?: () => void;
+ preview: ResolvedLinkPreview;
+}) {
+ const [descriptionExpanded, setDescriptionExpanded] = useState(false);
+ const [descriptionOverflows, setDescriptionOverflows] = useState(false);
+ const [imageExpanded, setImageExpanded] = useState(true);
+ const descriptionRef = useRef(null);
+ const reserveImage = preview.imageState !== "none";
+ const showImage = preview.imageState === "image";
+ const hostname = getHostname(preview);
+
+ useEffect(() => {
+ if (!preview.description) return;
+
+ const description = descriptionRef.current;
+ if (!description || descriptionExpanded) return;
+
+ const measure = () => {
+ setDescriptionOverflows(
+ description.scrollHeight > description.clientHeight + 1,
+ );
+ };
+ const frame = requestAnimationFrame(measure);
+ const observer = new ResizeObserver(measure);
+ observer.observe(description);
+ return () => {
+ cancelAnimationFrame(frame);
+ observer.disconnect();
+ };
+ }, [descriptionExpanded, preview.description]);
+
+ return (
+
+
+ {hostname}
+
+
+ {preview.title}
+
+ {preview.description ? (
+ <>
+
+ {preview.description}
+
+ {descriptionOverflows ? (
+
+ ) : null}
+ >
+ ) : null}
+ {reserveImage ? (
+
+ {imageExpanded ? (
+
+
+ {showImage ? (
+

+ ) : (
+
+ )}
+
+
+ ) : null}
+
+
+ ) : null}
+ {onRemove ? (
+
+ ) : null}
+
+ );
+}
+
+export function RichLinkPreviewAttachment({
+ className,
+ onRemove,
+ preview,
+}: {
+ className?: string;
+ onRemove?: () => void;
+ preview: ResolvedLinkPreview;
+}) {
+ const [imageExpanded, setImageExpanded] = useState(true);
+
+ if (isTweetPreview(preview)) {
+ return (
+
+ );
+ }
+
+ const reserveImage = preview.imageState !== "none";
+ const showImage = preview.imageState === "image";
+ const hostname = getHostname(preview);
+
+ return (
+
+
+ {reserveImage ? (
+
+ {imageExpanded ? (
+
+
+ {showImage ? (
+

+ ) : (
+
+ )}
+
+
+ ) : null}
+
+
+ ) : null}
+ {onRemove ? (
+
+ ) : null}
+
+ );
+}
diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts
index faa6ddea4..ad649a572 100644
--- a/desktop/tests/e2e/messaging.spec.ts
+++ b/desktop/tests/e2e/messaging.spec.ts
@@ -300,6 +300,51 @@ test("markdown tables overflow wide content and fill the message when narrow", a
.toBeLessThanOrEqual(1);
});
+test("link preview style defaults to compact and Rich unfurls descriptions", async ({
+ page,
+}) => {
+ const previewUrl = "https://github.com/block/buzz/pull/3246";
+ await page.setViewportSize({ width: 800, height: 900 });
+ await page.goto("/");
+ await page.getByTestId("channel-general").click();
+ await page.getByTestId("message-input").fill(previewUrl);
+ await page.getByTestId("send-message").click();
+
+ const row = page.getByTestId("message-row").last();
+ await expect(
+ row.locator('[data-link-preview="github-pull-request"]'),
+ ).toHaveCSS("border-top-left-radius", "16px");
+
+ await openSettings(page, "appearance");
+ await expect(page.getByTestId("link-preview-style-trigger")).toHaveText(
+ "Compact",
+ );
+ await page.getByTestId("link-preview-style-trigger").click();
+ await page.getByTestId("link-preview-style-rich").click();
+ await expect(page.getByTestId("link-preview-style-trigger")).toHaveText(
+ "Rich",
+ );
+ await expect
+ .poll(() =>
+ page.evaluate(() =>
+ localStorage.getItem("buzz.appearance.linkPreviewStyle"),
+ ),
+ )
+ .toBe("rich");
+
+ await page.getByTestId("settings-back-to-app").click();
+ const richPreview = row.locator(
+ '[data-link-preview="github-pull-request"][data-link-preview-inline]',
+ );
+ await expect(richPreview).toBeVisible();
+ await expect(
+ richPreview.locator('[data-slot="attachment-description"]'),
+ ).toBeVisible();
+ await expect(richPreview.locator("[data-link-preview-hostname]")).toHaveText(
+ "github.com",
+ );
+});
+
test("link preview image geometry stays stable while loading", async ({
page,
}) => {