mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user