feat(previews): replace remove-previews text link with inline X on preview card

- link-preview-attachment.tsx: add optional onRemove prop that renders a
  small X button floating just outside the card's top-right corner
  (left-full top-0, z-20 above the link trigger), fading in on message
  row hover via group-hover/message and reachable by keyboard via
  focus-visible
- link-preview-attachment.tsx: wrap the card in a relative container
  carrying the width classes (w-80 max-w-full shrink-0) since the card's
  overflow-hidden would clip a button positioned outside its bounds
- link-preview-attachment.tsx: remove the AttachmentActions block with
  the hover-revealed ExternalLink icon, eliminating the reserved flex
  gap on card hover
- markdown.tsx: drop the "Remove previews for everyone" text button
  below the preview list and its spacing wrapper; pass onRemove to each
  LinkPreviewAttachment instead, opening the same confirmation dialog
- removal flow and AlertDialog confirmation are unchanged; X only
  renders when onRemoveLinkPreviewsForEveryone is available (own
  messages)

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:20:59 -07:00
parent a7f02994f5
commit 7e9240409d
2 changed files with 86 additions and 78 deletions
@@ -1,10 +1,9 @@
import { ExternalLink, Globe } from "lucide-react";
import { Globe, X } from "lucide-react";
import type { SupportedLinkPreview } from "@/shared/lib/linkPreview";
import { cn } from "@/shared/lib/cn";
import {
Attachment,
AttachmentActions,
AttachmentContent,
AttachmentMedia,
AttachmentTitle,
@@ -113,65 +112,71 @@ function LinkPreviewLogo({ preview }: { preview: SupportedLinkPreview }) {
export function LinkPreviewAttachment({
className,
onRemove,
preview,
}: {
className?: string;
onRemove?: () => void;
preview: SupportedLinkPreview;
}) {
const showImage = Boolean(preview.imageDataUrl && preview.imageDomain);
return (
<Attachment
className={cn(
"w-80 max-w-full shrink-0 no-underline shadow-none",
className,
)}
data-link-preview={preview.kind}
orientation={showImage ? "vertical" : "horizontal"}
>
{showImage ? (
<div className="relative -mx-3 -mt-2.5 w-[calc(100%+1.5rem)] overflow-hidden border-b border-border/70 bg-muted">
<img
alt={`Automatic preview from ${preview.imageDomain}`}
className="aspect-[1.91/1] w-full object-cover"
src={preview.imageDataUrl ?? undefined}
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent px-3 pb-2 pt-6 text-2xs text-white">
Automatic preview from {preview.imageDomain}
<div className={cn("relative w-80 max-w-full shrink-0", className)}>
<Attachment
className="w-full no-underline shadow-none"
data-link-preview={preview.kind}
orientation={showImage ? "vertical" : "horizontal"}
>
{showImage ? (
<div className="relative -mx-3 -mt-2.5 w-[calc(100%+1.5rem)] overflow-hidden border-b border-border/70 bg-muted">
<img
alt={`Automatic preview from ${preview.imageDomain}`}
className="aspect-[1.91/1] w-full object-cover"
src={preview.imageDataUrl ?? undefined}
/>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent px-3 pb-2 pt-6 text-2xs text-white">
Automatic preview from {preview.imageDomain}
</div>
</div>
</div>
) : null}
{!showImage ? (
<AttachmentMedia className="link-preview-media">
<LinkPreviewLogo preview={preview} />
</AttachmentMedia>
) : null}
<AttachmentContent className={showImage ? "w-full" : undefined}>
<div className="truncate text-xs font-medium leading-4 text-muted-foreground">
{preview.provider}
<span aria-hidden="true"> · </span>
{preview.typeLabel}
</div>
<AttachmentTitle>{preview.title}</AttachmentTitle>
</AttachmentContent>
<AttachmentActions>
<ExternalLink
aria-hidden="true"
className="h-4 w-4 text-muted-foreground opacity-0 transition-opacity group-hover/attachment:opacity-100 group-focus-within/attachment:opacity-100"
/>
</AttachmentActions>
<AttachmentTrigger asChild>
<a
aria-label={`Open ${preview.provider} ${preview.typeLabel}: ${preview.title}`}
href={preview.href}
rel="noreferrer"
target="_blank"
) : null}
{!showImage ? (
<AttachmentMedia className="link-preview-media">
<LinkPreviewLogo preview={preview} />
</AttachmentMedia>
) : null}
<AttachmentContent className={showImage ? "w-full" : undefined}>
<div className="truncate text-xs font-medium leading-4 text-muted-foreground">
{preview.provider}
<span aria-hidden="true"> · </span>
{preview.typeLabel}
</div>
<AttachmentTitle>{preview.title}</AttachmentTitle>
</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 flex h-5 w-5 items-center justify-center rounded-full text-muted-foreground opacity-0 transition-opacity hover:bg-muted hover:text-destructive focus-visible:opacity-100 focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring group-hover/message:opacity-100"
onClick={onRemove}
title="Remove previews for everyone"
type="button"
>
<span className="sr-only">
Open {preview.provider} {preview.typeLabel}: {preview.title}
</span>
</a>
</AttachmentTrigger>
</Attachment>
<X aria-hidden="true" className="h-3.5 w-3.5" />
</button>
) : null}
</div>
);
}
+29 -26
View File
@@ -1946,6 +1946,8 @@ function MarkdownInner({
}
const resolvedLinkPreviews = useResolvedLinkPreviews(linkPreviews);
const previewNoun =
resolvedLinkPreviews.length === 1 ? "preview" : "previews";
// When a config-nudge suppresses the prose (selectProseOrNudge returns
// null), skip the parse entirely — it would be thrown away unrendered.
@@ -1996,27 +1998,22 @@ function MarkdownInner({
</AttachmentGroup>
) : null}
{resolvedLinkPreviews.length > 0 ? (
<div className="space-y-1.5" data-link-preview-container="">
<AttachmentGroup
className="max-w-full flex-wrap overflow-visible pb-0"
data-link-preview-list=""
>
{resolvedLinkPreviews.map((preview) => (
<LinkPreviewAttachment key={preview.href} preview={preview} />
))}
</AttachmentGroup>
{onRemoveLinkPreviewsForEveryone ? (
<div className="text-2xs text-muted-foreground">
<button
className="hover:text-destructive hover:underline"
onClick={() => setRemovePreviewDialogOpen(true)}
type="button"
>
Remove previews for everyone
</button>
</div>
) : null}
</div>
<AttachmentGroup
className="max-w-full flex-wrap overflow-visible pb-0"
data-link-preview-list=""
>
{resolvedLinkPreviews.map((preview) => (
<LinkPreviewAttachment
key={preview.href}
onRemove={
onRemoveLinkPreviewsForEveryone
? () => setRemovePreviewDialogOpen(true)
: undefined
}
preview={preview}
/>
))}
</AttachmentGroup>
) : null}
{onRemoveLinkPreviewsForEveryone ? (
<AlertDialog
@@ -2026,12 +2023,18 @@ function MarkdownInner({
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Remove previews for everyone?
Remove {previewNoun} for everyone?
</AlertDialogTitle>
<AlertDialogDescription>
This permanently removes all generated link previews from
this message for supporting clients. The links stay in the
message. This can't be undone.
No one will see{" "}
{resolvedLinkPreviews.length === 1
? "the preview"
: "the previews"}{" "}
on this message anymore.{" "}
{resolvedLinkPreviews.length === 1
? "The link itself will stay"
: "The links themselves will stay"}{" "}
in the message. This can't be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -2051,7 +2054,7 @@ function MarkdownInner({
type="button"
variant="destructive"
>
Remove previews
Remove {previewNoun}
</Button>
</AlertDialogAction>
</AlertDialogFooter>