feat(desktop): add inline link preview display controls

- Add a hover-only vertical ellipsis beside the first compact or rich preview.
- Provide a Link display submenu for switching the global Rich or Compact appearance preference.
- Show an actionable confirmation-backed Remove preview option for message authors while keeping display controls available to everyone.
- Link preference-change toasts to Appearance settings and expand rich preview images to match uploaded image width.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-07-30 23:21:07 -07:00
parent 2cabbbcc50
commit 984eb693be
5 changed files with 151 additions and 44 deletions
@@ -1,8 +1,7 @@
import { Globe, X } from "lucide-react";
import { Globe } 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,
@@ -11,6 +10,7 @@ import {
AttachmentTitle,
AttachmentTrigger,
} from "@/shared/ui/attachment";
import { LinkPreviewControls } from "@/shared/ui/link-preview-controls";
function LinearLogo({ className }: { className?: string }) {
return (
@@ -136,10 +136,12 @@ export function CompactLinkPreviewAttachment({
className,
onRemove,
preview,
showControls = false,
}: {
className?: string;
onRemove?: () => void;
preview: ResolvedLinkPreview;
showControls?: boolean;
}) {
const reserveImage = preview.imageState !== "none";
const showImage = preview.imageState === "image";
@@ -227,18 +229,8 @@ export function CompactLinkPreviewAttachment({
</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>
{showControls ? (
<LinkPreviewControls onRemove={onRemove} placement="left" />
) : null}
</div>
);
@@ -11,11 +11,13 @@ export function LinkPreviewAttachment({
ImageLightbox,
onRemove,
preview,
showControls,
}: {
className?: string;
ImageLightbox: LinkPreviewImageLightboxComponent;
onRemove?: () => void;
preview: ResolvedLinkPreview;
showControls?: boolean;
}) {
const style = useLinkPreviewStyle();
if (style === "rich") {
@@ -25,6 +27,7 @@ export function LinkPreviewAttachment({
ImageLightbox={ImageLightbox}
onRemove={onRemove}
preview={preview}
showControls={showControls}
/>
);
}
@@ -34,6 +37,7 @@ export function LinkPreviewAttachment({
className={className}
onRemove={onRemove}
preview={preview}
showControls={showControls}
/>
);
}
@@ -0,0 +1,125 @@
import { EllipsisVertical, EyeOff } from "lucide-react";
import { toast } from "sonner";
import { useAppShell } from "@/app/AppShellContext";
import {
setLinkPreviewStyle,
type LinkPreviewStyle,
useLinkPreviewStyle,
} from "@/shared/lib/linkPreviewStylePreference";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/shared/ui/dropdown-menu";
const CONTROL_BUTTON_CLASS =
"h-5 w-5 rounded-full text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/message:opacity-100 data-[state=open]:opacity-100";
const LINK_PREVIEW_STYLE_OPTIONS: {
value: LinkPreviewStyle;
label: string;
}[] = [
{ value: "rich", label: "Rich" },
{ value: "compact", label: "Compact" },
];
export function LinkPreviewControls({
onRemove,
placement = "right",
}: {
onRemove?: () => void;
placement?: "left" | "right";
}) {
const style = useLinkPreviewStyle();
const { onOpenSettings } = useAppShell();
const handleStyleChange = (nextStyle: string) => {
if (
(nextStyle !== "rich" && nextStyle !== "compact") ||
nextStyle === style
) {
return;
}
setLinkPreviewStyle(nextStyle);
toast.success(
`Link previews set to ${nextStyle === "rich" ? "Rich" : "Compact"}.`,
{
action: onOpenSettings
? {
label: "Appearance",
onClick: () => onOpenSettings("appearance"),
}
: undefined,
description:
"You can always modify this and other settings in Appearance.",
},
);
};
return (
<div
className={cn(
"absolute top-0 z-20 flex flex-col",
placement === "left" ? "right-full" : "left-full ml-1",
)}
>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
aria-label="Link display settings"
className={CONTROL_BUTTON_CLASS}
size="icon-xs"
title="Link display settings"
type="button"
variant="ghost"
>
<EllipsisVertical aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="right">
<DropdownMenuSub>
<DropdownMenuSubTrigger>Link display</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup
onValueChange={handleStyleChange}
value={style}
>
{LINK_PREVIEW_STYLE_OPTIONS.map((option) => (
<DropdownMenuRadioItem
key={option.value}
value={option.value}
>
{option.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
{onRemove ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={onRemove}
>
<EyeOff aria-hidden="true" />
Remove preview
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
+3 -2
View File
@@ -32,7 +32,7 @@ export function LinkPreviewList({
if (removed || previews.length === 0) return null;
const previewNoun = previews.length === 1 ? "preview" : "previews";
const removeButtonIndex = style === "compact" ? previews.length - 1 : 0;
const controlsIndex = 0;
return (
<>
<AttachmentGroup
@@ -48,11 +48,12 @@ export function LinkPreviewList({
key={preview.href}
ImageLightbox={ImageLightbox}
onRemove={
onRemoveForEveryone && index === removeButtonIndex
onRemoveForEveryone && index === controlsIndex
? () => setDialogOpen(true)
: undefined
}
preview={preview}
showControls={index === controlsIndex}
/>
))}
</AttachmentGroup>
@@ -1,10 +1,10 @@
import { ChevronDown, ChevronUp, X } from "lucide-react";
import { ChevronDown, ChevronUp } from "lucide-react";
import type { ComponentType, ReactNode } from "react";
import { useState } from "react";
import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { LinkPreviewControls } from "@/shared/ui/link-preview-controls";
export type LinkPreviewImageLightboxProps = {
alt: string;
@@ -129,11 +129,13 @@ function TweetPreview({
ImageLightbox,
onRemove,
preview,
showControls,
}: {
className?: string;
ImageLightbox: LinkPreviewImageLightboxComponent;
onRemove?: () => void;
preview: ResolvedLinkPreview;
showControls: boolean;
}) {
const [contentExpanded, setContentExpanded] = useState(true);
const reserveImage = preview.imageState !== "none";
@@ -173,7 +175,7 @@ function TweetPreview({
{contentExpanded && reserveImage ? (
<LinkPreviewImage
aspectClassName="aspect-video"
className="mt-2 w-full max-w-75"
className="mt-2 w-full max-w-96"
ImageLightbox={ImageLightbox}
preview={preview}
/>
@@ -193,18 +195,8 @@ function TweetPreview({
{contentExpanded ? "Show less" : "Show more"}
</button>
) : 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>
{showControls ? (
<LinkPreviewControls onRemove={onRemove} placement="left" />
) : null}
</div>
);
@@ -215,11 +207,13 @@ export function RichLinkPreviewAttachment({
ImageLightbox,
onRemove,
preview,
showControls = false,
}: {
className?: string;
ImageLightbox: LinkPreviewImageLightboxComponent;
onRemove?: () => void;
preview: ResolvedLinkPreview;
showControls?: boolean;
}) {
const [contentExpanded, setContentExpanded] = useState(true);
@@ -230,6 +224,7 @@ export function RichLinkPreviewAttachment({
ImageLightbox={ImageLightbox}
onRemove={onRemove}
preview={preview}
showControls={showControls}
/>
);
}
@@ -277,7 +272,7 @@ export function RichLinkPreviewAttachment({
{contentExpanded && reserveImage ? (
<LinkPreviewImage
aspectClassName="aspect-[1.91/1]"
className="mt-2 w-full max-w-75"
className="mt-2 w-full max-w-96"
ImageLightbox={ImageLightbox}
preview={preview}
/>
@@ -297,18 +292,8 @@ export function RichLinkPreviewAttachment({
{contentExpanded ? "Show less" : "Show more"}
</button>
) : 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>
{showControls ? (
<LinkPreviewControls onRemove={onRemove} placement="left" />
) : null}
</div>
);