From cc20f19552446957ff021dc31d061f0215e17132 Mon Sep 17 00:00:00 2001 From: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Date: Thu, 30 Jul 2026 15:19:44 -0700 Subject: [PATCH] feat(desktop): add rich link preview preference Co-authored-by: Taylor Ho Signed-off-by: Taylor Ho --- .../features/settings/ui/SettingsPanels.tsx | 80 +++++ .../lib/linkPreviewStylePreference.test.mjs | 23 ++ .../shared/lib/linkPreviewStylePreference.ts | 56 ++++ .../ui/compact-link-preview-attachment.tsx | 245 ++++++++++++++ .../src/shared/ui/link-preview-attachment.tsx | 237 +------------ .../ui/rich-link-preview-attachment.tsx | 313 ++++++++++++++++++ desktop/tests/e2e/messaging.spec.ts | 45 +++ 7 files changed, 769 insertions(+), 230 deletions(-) create mode 100644 desktop/src/shared/lib/linkPreviewStylePreference.test.mjs create mode 100644 desktop/src/shared/lib/linkPreviewStylePreference.ts create mode 100644 desktop/src/shared/ui/compact-link-preview-attachment.tsx create mode 100644 desktop/src/shared/ui/rich-link-preview-attachment.tsx 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