mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f
parent
15b2b8dc0e
commit
c4aaba97ce
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user