From 984eb693bef98d851d1c81cd098f42eebc447bef Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 30 Jul 2026 22:44:41 -0700 Subject: [PATCH] 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 Signed-off-by: Taylor Ho --- .../ui/compact-link-preview-attachment.tsx | 20 +-- .../src/shared/ui/link-preview-attachment.tsx | 4 + .../src/shared/ui/link-preview-controls.tsx | 125 ++++++++++++++++++ desktop/src/shared/ui/link-preview-list.tsx | 5 +- .../ui/rich-link-preview-attachment.tsx | 41 ++---- 5 files changed, 151 insertions(+), 44 deletions(-) create mode 100644 desktop/src/shared/ui/link-preview-controls.tsx diff --git a/desktop/src/shared/ui/compact-link-preview-attachment.tsx b/desktop/src/shared/ui/compact-link-preview-attachment.tsx index e14e440a8..018ec7201 100644 --- a/desktop/src/shared/ui/compact-link-preview-attachment.tsx +++ b/desktop/src/shared/ui/compact-link-preview-attachment.tsx @@ -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({ - {onRemove ? ( - + {showControls ? ( + ) : null} ); diff --git a/desktop/src/shared/ui/link-preview-attachment.tsx b/desktop/src/shared/ui/link-preview-attachment.tsx index c125f5898..88b61fdaa 100644 --- a/desktop/src/shared/ui/link-preview-attachment.tsx +++ b/desktop/src/shared/ui/link-preview-attachment.tsx @@ -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} /> ); } diff --git a/desktop/src/shared/ui/link-preview-controls.tsx b/desktop/src/shared/ui/link-preview-controls.tsx new file mode 100644 index 000000000..40ab1d3e1 --- /dev/null +++ b/desktop/src/shared/ui/link-preview-controls.tsx @@ -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 ( +
+ + + + + + + Link display + + + {LINK_PREVIEW_STYLE_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + + {onRemove ? ( + <> + + + + + ) : null} + + +
+ ); +} diff --git a/desktop/src/shared/ui/link-preview-list.tsx b/desktop/src/shared/ui/link-preview-list.tsx index 2c36e7596..bcfa3c9ae 100644 --- a/desktop/src/shared/ui/link-preview-list.tsx +++ b/desktop/src/shared/ui/link-preview-list.tsx @@ -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 ( <> setDialogOpen(true) : undefined } preview={preview} + showControls={index === controlsIndex} /> ))} diff --git a/desktop/src/shared/ui/rich-link-preview-attachment.tsx b/desktop/src/shared/ui/rich-link-preview-attachment.tsx index c906e1d4e..205e0e76e 100644 --- a/desktop/src/shared/ui/rich-link-preview-attachment.tsx +++ b/desktop/src/shared/ui/rich-link-preview-attachment.tsx @@ -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 ? ( @@ -193,18 +195,8 @@ function TweetPreview({ {contentExpanded ? "Show less" : "Show more"} ) : null} - {onRemove ? ( - + {showControls ? ( + ) : null} ); @@ -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 ? ( @@ -297,18 +292,8 @@ export function RichLinkPreviewAttachment({ {contentExpanded ? "Show less" : "Show more"} ) : null} - {onRemove ? ( - + {showControls ? ( + ) : null} );