mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): add rich link preview preference
Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
co-authored by
Taylor Ho
parent
9b119dd6bc
commit
cc20f19552
@@ -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() {
|
||||
</AnimatePresence>
|
||||
)}
|
||||
|
||||
<LinkPreviewStyleSetting />
|
||||
<ThreadLayoutSetting />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<SettingsOptionGroup className="mt-8">
|
||||
<SettingsOptionRow>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">Links</p>
|
||||
<p className="text-sm font-normal text-muted-foreground">
|
||||
{activeOption.description}
|
||||
</p>
|
||||
</div>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="h-7 min-w-28 justify-between gap-1.5 rounded-full border border-border/50 bg-muted/45 px-2.5 text-xs font-medium text-foreground shadow-none hover:bg-muted/70"
|
||||
data-testid="link-preview-style-trigger"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<span className="truncate">{activeOption.label}</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="min-w-72">
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={(next) =>
|
||||
setLinkPreviewStyle(next as LinkPreviewStyle)
|
||||
}
|
||||
value={style}
|
||||
>
|
||||
{LINK_PREVIEW_STYLE_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
data-testid={`link-preview-style-${option.value}`}
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="font-medium">{option.label}</span>
|
||||
<span className="text-2xs text-muted-foreground">
|
||||
{option.description}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SettingsOptionRow>
|
||||
</SettingsOptionGroup>
|
||||
);
|
||||
}
|
||||
|
||||
const THREAD_VIEW_MODE_OPTIONS: {
|
||||
value: ThreadViewMode;
|
||||
label: string;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleDriveLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12.01 1.485c-2.082 0-3.754.02-3.743.047.01.02 1.708 3.001 3.774 6.62l3.76 6.574h3.76c2.081 0 3.753-.02 3.742-.047-.005-.02-1.708-3.001-3.775-6.62l-3.76-6.574zm-4.76 1.73a789.828 789.861 0 0 0-3.63 6.319L0 15.868l1.89 3.298 1.885 3.297 3.62-6.335 3.618-6.33-1.88-3.287C8.1 4.704 7.255 3.22 7.25 3.214zm2.259 12.653-.203.348c-.114.198-.96 1.672-1.88 3.287a423.93 423.948 0 0 1-1.698 2.97c-.01.026 3.24.042 7.222.042h7.244l1.796-3.157c.992-1.734 1.85-3.23 1.906-3.323l.104-.167h-7.249z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleDocsLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M14.727 6.727H14V0H4.91c-.905 0-1.637.732-1.637 1.636v20.728c0 .904.732 1.636 1.636 1.636h14.182c.904 0 1.636-.732 1.636-1.636V6.727h-6zm-.545 10.455H7.09v-1.364h7.09v1.364zm2.727-3.273H7.091v-1.364h9.818v1.364zm0-3.273H7.091V9.273h9.818v1.363zM14.727 6h6l-6-6v6z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleSheetsLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M11.318 12.545H7.91v-1.909h3.41v1.91zM14.728 0v6h6l-6-6zm1.363 10.636h-3.41v1.91h3.41v-1.91zm0 3.273h-3.41v1.91h3.41v-1.91zM20.727 6.5v15.864c0 .904-.732 1.636-1.636 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.318v6.5h6.5zm-3.273 2.773H6.545v7.909h10.91v-7.91zm-6.136 4.636H7.91v1.91h3.41v-1.91z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleSlidesLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M16.09 15.273H7.91v-4.637h8.18v4.637zm1.728-8.523h2.91v15.614c0 .904-.733 1.636-1.637 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.068v6.75h3.841zm-.363 2.523H6.545v7.363h10.91V9.273zm-2.728-5.979V6h6.001l-6-6v3.294z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="size-4 rounded-sm object-contain"
|
||||
data-link-preview-favicon=""
|
||||
src={preview.faviconDataUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (preview.kind) {
|
||||
case "github-issue":
|
||||
case "github-pull-request":
|
||||
case "github-repository":
|
||||
return <GitHubLogo className="h-4 w-4" />;
|
||||
case "linear-issue":
|
||||
return <LinearLogo className="h-4 w-4" />;
|
||||
case "google-drive-file":
|
||||
case "google-drive-folder":
|
||||
return <GoogleDriveLogo className="h-4 w-4" />;
|
||||
case "google-docs-document":
|
||||
return <GoogleDocsLogo className="h-4 w-4" />;
|
||||
case "google-sheets-spreadsheet":
|
||||
return <GoogleSheetsLogo className="h-4 w-4" />;
|
||||
case "google-slides-presentation":
|
||||
return <GoogleSlidesLogo className="h-4 w-4" />;
|
||||
case "generic-link":
|
||||
return <Globe aria-hidden="true" className="h-4 w-4" />;
|
||||
}
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={cn("relative w-[22.5rem] max-w-full shrink-0", className)}>
|
||||
<Attachment
|
||||
className={cn(
|
||||
"w-full no-underline shadow-none",
|
||||
reserveImage && "h-20 min-h-20 max-h-20 gap-0 p-0",
|
||||
)}
|
||||
data-image-state={preview.imageState}
|
||||
data-link-preview={preview.kind}
|
||||
orientation="horizontal"
|
||||
>
|
||||
{reserveImage ? (
|
||||
<AttachmentMedia
|
||||
aria-hidden={showImage ? undefined : "true"}
|
||||
className="aspect-auto h-full min-h-0 w-28 min-w-28 max-w-28 self-stretch rounded-none bg-muted sm:w-32 sm:min-w-32 sm:max-w-32"
|
||||
data-link-preview-thumbnail=""
|
||||
variant="image"
|
||||
>
|
||||
{showImage ? (
|
||||
<img
|
||||
alt={`Preview from ${preview.imageDomain}`}
|
||||
className="h-full w-full object-cover"
|
||||
src={preview.imageDataUrl ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="h-full w-full animate-pulse bg-muted-foreground/10"
|
||||
data-link-preview-skeleton=""
|
||||
/>
|
||||
)}
|
||||
</AttachmentMedia>
|
||||
) : (
|
||||
<AttachmentMedia className="link-preview-media">
|
||||
<LinkPreviewLogo preview={preview} />
|
||||
</AttachmentMedia>
|
||||
)}
|
||||
<AttachmentContent className={reserveImage ? "px-3 py-2.5" : undefined}>
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-1.5 text-xs font-normal leading-4 text-muted-foreground"
|
||||
data-link-preview-hostname=""
|
||||
>
|
||||
{reserveImage && preview.faviconDataUrl ? (
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="size-3 shrink-0 rounded-sm object-contain"
|
||||
data-link-preview-hostname-favicon=""
|
||||
src={preview.faviconDataUrl}
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate">
|
||||
{reserveImage ? hostname : preview.provider}
|
||||
</span>
|
||||
</div>
|
||||
<AttachmentTitle
|
||||
className={
|
||||
reserveImage
|
||||
? preview.description
|
||||
? "truncate"
|
||||
: "line-clamp-2 whitespace-normal"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{preview.title}
|
||||
</AttachmentTitle>
|
||||
{reserveImage && preview.description ? (
|
||||
<AttachmentDescription>{preview.description}</AttachmentDescription>
|
||||
) : null}
|
||||
</AttachmentContent>
|
||||
<AttachmentTrigger asChild>
|
||||
<a
|
||||
aria-label={`Open ${preview.provider} ${preview.typeLabel}: ${preview.title}`}
|
||||
href={preview.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="sr-only">
|
||||
Open {preview.provider} {preview.typeLabel}: {preview.title}
|
||||
</span>
|
||||
</a>
|
||||
</AttachmentTrigger>
|
||||
</Attachment>
|
||||
{onRemove ? (
|
||||
<Button
|
||||
aria-label="Remove previews for everyone"
|
||||
className="absolute left-full top-0 z-20 ml-1 h-5 w-5 rounded-full text-muted-foreground opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover/message:opacity-100"
|
||||
onClick={onRemove}
|
||||
size="icon-xs"
|
||||
title="Remove previews for everyone"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleDriveLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12.01 1.485c-2.082 0-3.754.02-3.743.047.01.02 1.708 3.001 3.774 6.62l3.76 6.574h3.76c2.081 0 3.753-.02 3.742-.047-.005-.02-1.708-3.001-3.775-6.62l-3.76-6.574zm-4.76 1.73a789.828 789.861 0 0 0-3.63 6.319L0 15.868l1.89 3.298 1.885 3.297 3.62-6.335 3.618-6.33-1.88-3.287C8.1 4.704 7.255 3.22 7.25 3.214zm2.259 12.653-.203.348c-.114.198-.96 1.672-1.88 3.287a423.93 423.948 0 0 1-1.698 2.97c-.01.026 3.24.042 7.222.042h7.244l1.796-3.157c.992-1.734 1.85-3.23 1.906-3.323l.104-.167h-7.249z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleDocsLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M14.727 6.727H14V0H4.91c-.905 0-1.637.732-1.637 1.636v20.728c0 .904.732 1.636 1.636 1.636h14.182c.904 0 1.636-.732 1.636-1.636V6.727h-6zm-.545 10.455H7.09v-1.364h7.09v1.364zm2.727-3.273H7.091v-1.364h9.818v1.364zm0-3.273H7.091V9.273h9.818v1.363zM14.727 6h6l-6-6v6z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleSheetsLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M11.318 12.545H7.91v-1.909h3.41v1.91zM14.728 0v6h6l-6-6zm1.363 10.636h-3.41v1.91h3.41v-1.91zm0 3.273h-3.41v1.91h3.41v-1.91zM20.727 6.5v15.864c0 .904-.732 1.636-1.636 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.318v6.5h6.5zm-3.273 2.773H6.545v7.909h10.91v-7.91zm-6.136 4.636H7.91v1.91h3.41v-1.91z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleSlidesLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M16.09 15.273H7.91v-4.637h8.18v4.637zm1.728-8.523h2.91v15.614c0 .904-.733 1.636-1.637 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.068v6.75h3.841zm-.363 2.523H6.545v7.363h10.91V9.273zm-2.728-5.979V6h6.001l-6-6v3.294z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="size-4 rounded-sm object-contain"
|
||||
data-link-preview-favicon=""
|
||||
src={preview.faviconDataUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (preview.kind) {
|
||||
case "github-issue":
|
||||
case "github-pull-request":
|
||||
case "github-repository":
|
||||
return <GitHubLogo className="h-4 w-4" />;
|
||||
case "linear-issue":
|
||||
return <LinearLogo className="h-4 w-4" />;
|
||||
case "google-drive-file":
|
||||
case "google-drive-folder":
|
||||
return <GoogleDriveLogo className="h-4 w-4" />;
|
||||
case "google-docs-document":
|
||||
return <GoogleDocsLogo className="h-4 w-4" />;
|
||||
case "google-sheets-spreadsheet":
|
||||
return <GoogleSheetsLogo className="h-4 w-4" />;
|
||||
case "google-slides-presentation":
|
||||
return <GoogleSlidesLogo className="h-4 w-4" />;
|
||||
case "generic-link":
|
||||
return <Globe aria-hidden="true" className="h-4 w-4" />;
|
||||
}
|
||||
}
|
||||
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 (
|
||||
<div className={cn("relative w-[22.5rem] max-w-full shrink-0", className)}>
|
||||
<Attachment
|
||||
className={cn(
|
||||
"w-full no-underline shadow-none",
|
||||
reserveImage && "h-20 min-h-20 max-h-20 gap-0 p-0",
|
||||
)}
|
||||
data-image-state={preview.imageState}
|
||||
data-link-preview={preview.kind}
|
||||
orientation="horizontal"
|
||||
>
|
||||
{reserveImage ? (
|
||||
<AttachmentMedia
|
||||
aria-hidden={showImage ? undefined : "true"}
|
||||
className="aspect-auto h-full min-h-0 w-28 min-w-28 max-w-28 self-stretch rounded-none bg-muted sm:w-32 sm:min-w-32 sm:max-w-32"
|
||||
data-link-preview-thumbnail=""
|
||||
variant="image"
|
||||
>
|
||||
{showImage ? (
|
||||
<img
|
||||
alt={`Preview from ${preview.imageDomain}`}
|
||||
className="h-full w-full object-cover"
|
||||
src={preview.imageDataUrl ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="h-full w-full animate-pulse bg-muted-foreground/10"
|
||||
data-link-preview-skeleton=""
|
||||
/>
|
||||
)}
|
||||
</AttachmentMedia>
|
||||
) : (
|
||||
<AttachmentMedia className="link-preview-media">
|
||||
<LinkPreviewLogo preview={preview} />
|
||||
</AttachmentMedia>
|
||||
)}
|
||||
<AttachmentContent className={reserveImage ? "px-3 py-2.5" : undefined}>
|
||||
<div
|
||||
className="flex min-w-0 items-center gap-1.5 text-xs font-normal leading-4 text-muted-foreground"
|
||||
data-link-preview-hostname=""
|
||||
>
|
||||
{reserveImage && preview.faviconDataUrl ? (
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="size-3 shrink-0 rounded-sm object-contain"
|
||||
data-link-preview-hostname-favicon=""
|
||||
src={preview.faviconDataUrl}
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate">
|
||||
{reserveImage ? hostname : preview.provider}
|
||||
</span>
|
||||
</div>
|
||||
<AttachmentTitle
|
||||
className={
|
||||
reserveImage
|
||||
? preview.description
|
||||
? "truncate"
|
||||
: "line-clamp-2 whitespace-normal"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{preview.title}
|
||||
</AttachmentTitle>
|
||||
{reserveImage && preview.description ? (
|
||||
<AttachmentDescription>{preview.description}</AttachmentDescription>
|
||||
) : null}
|
||||
</AttachmentContent>
|
||||
<AttachmentTrigger asChild>
|
||||
<a
|
||||
aria-label={`Open ${preview.provider} ${preview.typeLabel}: ${preview.title}`}
|
||||
href={preview.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="sr-only">
|
||||
Open {preview.provider} {preview.typeLabel}: {preview.title}
|
||||
</span>
|
||||
</a>
|
||||
</AttachmentTrigger>
|
||||
</Attachment>
|
||||
{onRemove ? (
|
||||
<Button
|
||||
aria-label="Remove previews for everyone"
|
||||
className="absolute left-full top-0 z-20 ml-1 h-5 w-5 rounded-full text-muted-foreground opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover/message:opacity-100"
|
||||
onClick={onRemove}
|
||||
size="icon-xs"
|
||||
title="Remove previews for everyone"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Preview className={className} onRemove={onRemove} preview={preview} />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="size-4 rounded-sm object-contain"
|
||||
data-link-preview-favicon=""
|
||||
src={preview.faviconDataUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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<HTMLDivElement>(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 (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-[26rem] max-w-full shrink-0 border-l-[3px] border-border pl-3",
|
||||
className,
|
||||
)}
|
||||
data-image-state={preview.imageState}
|
||||
data-link-preview={preview.kind}
|
||||
data-tweet-preview=""
|
||||
>
|
||||
<div
|
||||
className="text-xs leading-4 text-muted-foreground"
|
||||
data-link-preview-hostname=""
|
||||
>
|
||||
{hostname}
|
||||
</div>
|
||||
<a
|
||||
className="mt-0.5 block truncate text-sm font-semibold leading-5 text-foreground hover:underline"
|
||||
href={preview.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{preview.title}
|
||||
</a>
|
||||
{preview.description ? (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 whitespace-normal text-sm leading-5 text-foreground",
|
||||
!descriptionExpanded && "line-clamp-5",
|
||||
)}
|
||||
data-slot="attachment-description"
|
||||
ref={descriptionRef}
|
||||
>
|
||||
{preview.description}
|
||||
</div>
|
||||
{descriptionOverflows ? (
|
||||
<button
|
||||
aria-expanded={descriptionExpanded}
|
||||
className="mt-1 text-xs font-medium leading-4 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setDescriptionExpanded((expanded) => !expanded)}
|
||||
type="button"
|
||||
>
|
||||
{descriptionExpanded ? "Show less" : "Show more"}
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{reserveImage ? (
|
||||
<div className="mt-2">
|
||||
{imageExpanded ? (
|
||||
<a
|
||||
aria-label={`Open tweet: ${preview.title}`}
|
||||
className="block overflow-hidden rounded-xl bg-muted"
|
||||
href={preview.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<div
|
||||
className="aspect-video w-full"
|
||||
data-link-preview-thumbnail=""
|
||||
>
|
||||
{showImage ? (
|
||||
<img
|
||||
alt={`Preview from ${preview.imageDomain}`}
|
||||
className="h-full w-full object-cover"
|
||||
src={preview.imageDataUrl ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="h-full w-full animate-pulse bg-muted-foreground/10"
|
||||
data-link-preview-skeleton=""
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
) : null}
|
||||
<button
|
||||
aria-expanded={imageExpanded}
|
||||
className="mt-1 flex items-center gap-1 text-xs leading-4 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setImageExpanded((expanded) => !expanded)}
|
||||
type="button"
|
||||
>
|
||||
{imageExpanded ? (
|
||||
<ChevronUp aria-hidden="true" className="size-3" />
|
||||
) : (
|
||||
<ChevronDown aria-hidden="true" className="size-3" />
|
||||
)}
|
||||
{imageExpanded ? "Hide image" : "Show image"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{onRemove ? (
|
||||
<Button
|
||||
aria-label="Remove previews for everyone"
|
||||
className="absolute left-full top-0 z-20 ml-1 h-5 w-5 rounded-full text-muted-foreground opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover/message:opacity-100"
|
||||
onClick={onRemove}
|
||||
size="icon-xs"
|
||||
title="Remove previews for everyone"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RichLinkPreviewAttachment({
|
||||
className,
|
||||
onRemove,
|
||||
preview,
|
||||
}: {
|
||||
className?: string;
|
||||
onRemove?: () => void;
|
||||
preview: ResolvedLinkPreview;
|
||||
}) {
|
||||
const [imageExpanded, setImageExpanded] = useState(true);
|
||||
|
||||
if (isTweetPreview(preview)) {
|
||||
return (
|
||||
<TweetPreview
|
||||
className={className}
|
||||
onRemove={onRemove}
|
||||
preview={preview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const reserveImage = preview.imageState !== "none";
|
||||
const showImage = preview.imageState === "image";
|
||||
const hostname = getHostname(preview);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-[26rem] max-w-full shrink-0 border-l-[3px] border-border pl-3",
|
||||
className,
|
||||
)}
|
||||
data-image-state={preview.imageState}
|
||||
data-link-preview={preview.kind}
|
||||
data-link-preview-inline=""
|
||||
>
|
||||
<div className={cn(reserveImage && "min-h-[3.875rem]")}>
|
||||
<div
|
||||
className="flex items-center gap-1.5 text-xs leading-4 text-muted-foreground"
|
||||
data-link-preview-identity=""
|
||||
>
|
||||
<LinkPreviewIdentity preview={preview} />
|
||||
<span data-link-preview-hostname="">{hostname}</span>
|
||||
</div>
|
||||
<a
|
||||
aria-label={`Open ${preview.provider} ${preview.typeLabel}: ${preview.title}`}
|
||||
className="mt-0.5 block text-sm font-semibold leading-5 text-foreground hover:underline"
|
||||
href={preview.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
className={preview.description ? "line-clamp-1" : "line-clamp-2"}
|
||||
>
|
||||
{preview.title}
|
||||
</span>
|
||||
</a>
|
||||
{preview.description ? (
|
||||
<div
|
||||
className="mt-1 line-clamp-2 whitespace-normal text-sm leading-5 text-muted-foreground"
|
||||
data-slot="attachment-description"
|
||||
>
|
||||
{preview.description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{reserveImage ? (
|
||||
<div className="mt-2">
|
||||
{imageExpanded ? (
|
||||
<a
|
||||
aria-label={`Open preview image from ${hostname}`}
|
||||
className="block overflow-hidden rounded-xl bg-muted"
|
||||
href={preview.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<div
|
||||
className="aspect-[1.91/1] w-full"
|
||||
data-link-preview-thumbnail=""
|
||||
>
|
||||
{showImage ? (
|
||||
<img
|
||||
alt={`Preview from ${preview.imageDomain}`}
|
||||
className="h-full w-full object-cover"
|
||||
src={preview.imageDataUrl ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="h-full w-full animate-pulse bg-muted-foreground/10"
|
||||
data-link-preview-skeleton=""
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
) : null}
|
||||
<button
|
||||
aria-expanded={imageExpanded}
|
||||
className="mt-1 flex items-center gap-1 text-xs leading-4 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setImageExpanded((expanded) => !expanded)}
|
||||
type="button"
|
||||
>
|
||||
{imageExpanded ? (
|
||||
<ChevronUp aria-hidden="true" className="size-3" />
|
||||
) : (
|
||||
<ChevronDown aria-hidden="true" className="size-3" />
|
||||
)}
|
||||
{imageExpanded ? "Hide image" : "Show image"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{onRemove ? (
|
||||
<Button
|
||||
aria-label="Remove previews for everyone"
|
||||
className="absolute left-full top-0 z-20 ml-1 h-5 w-5 rounded-full text-muted-foreground opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover/message:opacity-100"
|
||||
onClick={onRemove}
|
||||
size="icon-xs"
|
||||
title="Remove previews for everyone"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X aria-hidden="true" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user