feat(messages): add link preview controls

Co-authored-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@buzz.block.builderlab.xyz>
Signed-off-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@buzz.block.builderlab.xyz>
This commit is contained in:
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
2026-07-30 23:20:58 -07:00
co-authored by npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f
parent 15b2b8dc0e
commit c4aaba97ce
19 changed files with 606 additions and 148 deletions
+2
View File
@@ -621,6 +621,7 @@ export async function editMessage(
mediaTags?: string[][],
emojiTags?: string[][],
mentionPubkeys?: string[],
suppressLinkPreviews?: boolean,
): Promise<void> {
await invokeTauri("edit_message", {
channelId,
@@ -629,6 +630,7 @@ export async function editMessage(
mediaTags: mediaTags ?? [],
emojiTags: emojiTags ?? [],
mentionPubkeys: mentionPubkeys ?? null,
suppressLinkPreviews: suppressLinkPreviews ?? false,
});
}
@@ -1,28 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
linkPreviewImageKey,
readHiddenPreviewImages,
} from "./linkPreviewImageVisibility.ts";
test("preview image visibility keys are scoped to message and link", () => {
assert.notEqual(
linkPreviewImageKey("message-a", "https://example.com"),
linkPreviewImageKey("message-b", "https://example.com"),
);
});
test("hidden preview storage rejects malformed entries", () => {
const storage = {
getItem: () =>
JSON.stringify([
null,
{ key: 1, hiddenAt: "bad" },
{ key: "ok", hiddenAt: 2 },
]),
};
assert.deepEqual(readHiddenPreviewImages(storage), [
{ key: "ok", hiddenAt: 2 },
]);
});
@@ -1,41 +0,0 @@
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
const STORAGE_KEY = "buzz-hidden-link-preview-images.v1";
const MAX_ENTRIES = 1_000;
type HiddenPreviewImage = { key: string; hiddenAt: number };
export function linkPreviewImageKey(messageId: string, href: string): string {
return `${messageId}:${href}`;
}
export function readHiddenPreviewImages(
storage: Pick<Storage, "getItem"> = window.localStorage,
): HiddenPreviewImage[] {
try {
const parsed: unknown = JSON.parse(storage.getItem(STORAGE_KEY) ?? "[]");
if (!Array.isArray(parsed)) return [];
return parsed
.filter(
(entry): entry is HiddenPreviewImage =>
typeof entry === "object" &&
entry !== null &&
typeof (entry as HiddenPreviewImage).key === "string" &&
typeof (entry as HiddenPreviewImage).hiddenAt === "number",
)
.slice(-MAX_ENTRIES);
} catch {
return [];
}
}
export function hidePreviewImage(key: string): void {
const entries = readHiddenPreviewImages().filter(
(entry) => entry.key !== key,
);
entries.push({ key, hiddenAt: Date.now() });
setLocalStorageItemWithRecovery(
STORAGE_KEY,
JSON.stringify(entries.slice(-MAX_ENTRIES)),
);
}
@@ -0,0 +1,18 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readDismissedLinkPreviews } from "./linkPreviewVisibility.ts";
test("dismissed preview storage rejects malformed entries", () => {
const storage = {
getItem: () =>
JSON.stringify([
null,
{ messageId: 1, dismissedAt: "bad" },
{ messageId: "message-a", dismissedAt: 2 },
]),
};
assert.deepEqual(readDismissedLinkPreviews(storage), [
{ messageId: "message-a", dismissedAt: 2 },
]);
});
@@ -0,0 +1,46 @@
import { setLocalStorageItemWithRecovery } from "@/shared/lib/localStorageQuota";
const STORAGE_KEY = "buzz-dismissed-link-previews.v1";
const MAX_ENTRIES = 1_000;
type DismissedPreview = { messageId: string; dismissedAt: number };
export function readDismissedLinkPreviews(
storage: Pick<Storage, "getItem"> = window.localStorage,
): DismissedPreview[] {
try {
const parsed: unknown = JSON.parse(storage.getItem(STORAGE_KEY) ?? "[]");
if (!Array.isArray(parsed)) return [];
return parsed
.filter(
(entry): entry is DismissedPreview =>
typeof entry === "object" &&
entry !== null &&
typeof (entry as DismissedPreview).messageId === "string" &&
typeof (entry as DismissedPreview).dismissedAt === "number",
)
.slice(-MAX_ENTRIES);
} catch {
return [];
}
}
export function isLinkPreviewDismissed(messageId: string): boolean {
return readDismissedLinkPreviews().some(
(entry) => entry.messageId === messageId,
);
}
export function setLinkPreviewDismissed(
messageId: string,
dismissed: boolean,
): void {
const entries = readDismissedLinkPreviews().filter(
(entry) => entry.messageId !== messageId,
);
if (dismissed) entries.push({ messageId, dismissedAt: Date.now() });
setLocalStorageItemWithRecovery(
STORAGE_KEY,
JSON.stringify(entries.slice(-MAX_ENTRIES)),
);
}
@@ -1,14 +1,7 @@
import * as React from "react";
import { ExternalLink, Globe, EyeOff } from "lucide-react";
import { ExternalLink, Globe } from "lucide-react";
import type { SupportedLinkPreview } from "@/shared/lib/linkPreview";
import { cn } from "@/shared/lib/cn";
import {
hidePreviewImage,
linkPreviewImageKey,
readHiddenPreviewImages,
} from "@/shared/lib/linkPreviewImageVisibility";
import { Button } from "@/shared/ui/button";
import {
Attachment,
AttachmentActions,
@@ -120,31 +113,12 @@ function LinkPreviewLogo({ preview }: { preview: SupportedLinkPreview }) {
export function LinkPreviewAttachment({
className,
messageId,
preview,
}: {
className?: string;
messageId?: string;
preview: SupportedLinkPreview;
}) {
const visibilityKey = messageId
? linkPreviewImageKey(messageId, preview.href)
: null;
const [imageHidden, setImageHidden] = React.useState(() =>
visibilityKey
? readHiddenPreviewImages().some((entry) => entry.key === visibilityKey)
: false,
);
React.useEffect(() => {
setImageHidden(
visibilityKey
? readHiddenPreviewImages().some((entry) => entry.key === visibilityKey)
: false,
);
}, [visibilityKey]);
const showImage = Boolean(
messageId && preview.imageDataUrl && preview.imageDomain && !imageHidden,
);
const showImage = Boolean(preview.imageDataUrl && preview.imageDomain);
return (
<Attachment
@@ -162,19 +136,6 @@ export function LinkPreviewAttachment({
className="aspect-[1.91/1] w-full object-cover"
src={preview.imageDataUrl ?? undefined}
/>
<Button
aria-label={`Hide automatic preview image from ${preview.imageDomain}`}
className="absolute right-2 top-2 z-20 bg-background/90 shadow-sm"
onClick={() => {
if (visibilityKey) hidePreviewImage(visibilityKey);
setImageHidden(true);
}}
size="icon-xs"
title="Hide preview image"
variant="secondary"
>
<EyeOff aria-hidden="true" className="h-3.5 w-3.5" />
</Button>
<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>
+120 -13
View File
@@ -33,6 +33,21 @@ import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { AttachmentGroup } from "@/shared/ui/attachment";
import { ConfigNudgeCard } from "@/shared/ui/config-nudge-attachment";
import { LinkPreviewAttachment } from "@/shared/ui/link-preview-attachment";
import {
isLinkPreviewDismissed,
setLinkPreviewDismissed,
} from "@/shared/lib/linkPreviewVisibility";
import { Button } from "@/shared/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/shared/ui/alert-dialog";
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
import {
computeConfigNudge,
@@ -1838,6 +1853,8 @@ function MarkdownInner({
agentMentionPubkeysByName,
mediaInset = false,
messageId,
linkPreviewsSuppressed = false,
onRemoveLinkPreviewsForEveryone,
mentionNames,
mentionPubkeysByName,
searchQuery,
@@ -1869,9 +1886,24 @@ function MarkdownInner({
},
[goChannel],
);
const [previewsDismissed, setPreviewsDismissed] = React.useState(() =>
messageId ? isLinkPreviewDismissed(messageId) : false,
);
const [removePreviewDialogOpen, setRemovePreviewDialogOpen] =
React.useState(false);
const [removedForEveryone, setRemovedForEveryone] = React.useState(false);
React.useEffect(() => {
setPreviewsDismissed(messageId ? isLinkPreviewDismissed(messageId) : false);
setRemovedForEveryone(false);
}, [messageId]);
const previewsGloballySuppressed =
linkPreviewsSuppressed || removedForEveryone;
const linkPreviews = React.useMemo(
() => (interactive ? extractSupportedLinkPreviews(content) : []),
[content, interactive],
() =>
interactive && !previewsGloballySuppressed && !previewsDismissed
? extractSupportedLinkPreviews(content)
: [],
[content, interactive, previewsGloballySuppressed, previewsDismissed],
);
const configNudge = React.useMemo(
() => computeConfigNudge(content, interactive, configNudgeAuthorPubkey),
@@ -1973,18 +2005,90 @@ function MarkdownInner({
</AttachmentGroup>
) : null}
{resolvedLinkPreviews.length > 0 ? (
<AttachmentGroup
className="max-w-full flex-wrap overflow-visible pb-0"
data-link-preview-list=""
<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>
{messageId ? (
<div className="flex flex-wrap gap-2 text-2xs text-muted-foreground">
<button
className="hover:text-foreground hover:underline"
onClick={() => {
setLinkPreviewDismissed(messageId, true);
setPreviewsDismissed(true);
}}
type="button"
>
Dismiss previews for me
</button>
{onRemoveLinkPreviewsForEveryone ? (
<button
className="hover:text-destructive hover:underline"
onClick={() => setRemovePreviewDialogOpen(true)}
type="button"
>
Remove previews for everyone
</button>
) : null}
</div>
) : null}
</div>
) : previewsDismissed && messageId && !previewsGloballySuppressed ? (
<button
className="text-2xs text-muted-foreground hover:text-foreground hover:underline"
onClick={() => {
setLinkPreviewDismissed(messageId, false);
setPreviewsDismissed(false);
}}
type="button"
>
{resolvedLinkPreviews.map((preview) => (
<LinkPreviewAttachment
key={preview.href}
messageId={messageId}
preview={preview}
/>
))}
</AttachmentGroup>
Show dismissed link previews
</button>
) : null}
{onRemoveLinkPreviewsForEveryone ? (
<AlertDialog
onOpenChange={setRemovePreviewDialogOpen}
open={removePreviewDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Remove previews 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.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
onClick={() => {
setRemovedForEveryone(true);
void onRemoveLinkPreviewsForEveryone().catch(() => {
setRemovedForEveryone(false);
});
}}
type="button"
variant="destructive"
>
Remove previews
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : null}
</VideoReviewMarkdownContext.Provider>
</MarkdownRuntimeContext.Provider>
@@ -2001,6 +2105,9 @@ export const Markdown = React.memo(
prev.interactive === next.interactive &&
prev.mediaInset === next.mediaInset &&
prev.messageId === next.messageId &&
prev.linkPreviewsSuppressed === next.linkPreviewsSuppressed &&
prev.onRemoveLinkPreviewsForEveryone ===
next.onRemoveLinkPreviewsForEveryone &&
shallowRecordEqual(
prev.agentMentionPubkeysByName,
next.agentMentionPubkeysByName,
+2
View File
@@ -61,6 +61,8 @@ export type MarkdownProps = {
mediaInset?: boolean;
/** Event/message identity used only for local preview-image visibility. */
messageId?: string;
linkPreviewsSuppressed?: boolean;
onRemoveLinkPreviewsForEveryone?: () => Promise<void>;
searchQuery?: string;
/** Display name shown in shared-agent card metadata. */
snapshotSharedBy?: string;