mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(previews): add safe automatic preview images
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
64caedcd4a
commit
65f9ddec6a
@@ -1,4 +1,7 @@
|
||||
use std::{net::IpAddr, time::Duration};
|
||||
use std::{io::Cursor, net::IpAddr, time::Duration};
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
|
||||
use image::ImageDecoder;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::{
|
||||
@@ -9,6 +12,10 @@ use serde::Serialize;
|
||||
use url::Url;
|
||||
|
||||
const MAX_PREVIEW_FETCH_BYTES: usize = 256 * 1024;
|
||||
const MAX_IMAGE_FETCH_BYTES: usize = 2 * 1024 * 1024;
|
||||
const MAX_IMAGE_DIMENSION: u32 = 4096;
|
||||
const MAX_IMAGE_PIXELS: u64 = 16_000_000;
|
||||
const MAX_SANITIZED_DIMENSION: u32 = 1200;
|
||||
const PREVIEW_FETCH_TIMEOUT: Duration = Duration::from_secs(4);
|
||||
const PREVIEW_TOTAL_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const MAX_REDIRECTS: usize = 3;
|
||||
@@ -19,6 +26,8 @@ const MAX_METADATA_CHARS: usize = 180;
|
||||
pub struct LinkPreviewMetadata {
|
||||
title: String,
|
||||
site_name: Option<String>,
|
||||
image_data_url: Option<String>,
|
||||
image_domain: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -40,7 +49,7 @@ async fn fetch_link_preview_metadata_inner(
|
||||
validate_public_https_url(&url).await?;
|
||||
|
||||
for redirect_count in 0..=MAX_REDIRECTS {
|
||||
let response = send_pinned_request(&url).await?;
|
||||
let response = send_pinned_request(&url, "text/html,application/xhtml+xml;q=0.9").await?;
|
||||
|
||||
if response.status().is_redirection() {
|
||||
if redirect_count == MAX_REDIRECTS {
|
||||
@@ -69,8 +78,18 @@ async fn fetch_link_preview_metadata_inner(
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let body = read_limited_text(response).await?;
|
||||
return Ok(extract_link_preview_metadata(&body));
|
||||
let body = read_limited_bytes(response, MAX_PREVIEW_FETCH_BYTES).await?;
|
||||
let body = String::from_utf8_lossy(&body);
|
||||
let Some(mut metadata) = extract_link_preview_metadata(&body) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(image_url) = extract_image_url(&body, &url) {
|
||||
if let Ok((data_url, domain)) = fetch_sanitized_image(image_url).await {
|
||||
metadata.image_data_url = Some(data_url);
|
||||
metadata.image_domain = Some(domain);
|
||||
}
|
||||
}
|
||||
return Ok(Some(metadata));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
@@ -108,7 +127,7 @@ async fn resolve_public_addresses(host: &str) -> Result<Vec<IpAddr>, String> {
|
||||
Ok(addresses)
|
||||
}
|
||||
|
||||
async fn send_pinned_request(url: &Url) -> Result<reqwest::Response, String> {
|
||||
async fn send_pinned_request(url: &Url, accept: &str) -> Result<reqwest::Response, String> {
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| "link preview URL has no host".to_string())?;
|
||||
@@ -126,7 +145,7 @@ async fn send_pinned_request(url: &Url) -> Result<reqwest::Response, String> {
|
||||
.map_err(|error| format!("link preview client failed: {error}"))?;
|
||||
let request = client
|
||||
.get(url.as_str())
|
||||
.header(ACCEPT, "text/html,application/xhtml+xml;q=0.9")
|
||||
.header(ACCEPT, accept)
|
||||
.header(USER_AGENT, "Buzz Desktop link preview");
|
||||
|
||||
tokio::time::timeout(PREVIEW_FETCH_TIMEOUT, request.send())
|
||||
@@ -154,19 +173,150 @@ fn is_html_response(response: &reqwest::Response) -> bool {
|
||||
.is_none_or(|size| size <= MAX_PREVIEW_FETCH_BYTES)
|
||||
}
|
||||
|
||||
async fn read_limited_text(response: reqwest::Response) -> Result<String, String> {
|
||||
async fn read_limited_bytes(response: reqwest::Response, limit: usize) -> Result<Vec<u8>, String> {
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut bytes = Vec::new();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|error| format!("reading link preview failed: {error}"))?;
|
||||
if bytes.len() + chunk.len() > MAX_PREVIEW_FETCH_BYTES {
|
||||
if bytes.len().saturating_add(chunk.len()) > limit {
|
||||
return Err("link preview response exceeded the size limit".to_string());
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&bytes).into_owned())
|
||||
fn extract_image_url(html: &str, page_url: &Url) -> Option<Url> {
|
||||
let raw = extract_meta_content(html, "property", "og:image")
|
||||
.or_else(|| extract_meta_content(html, "property", "og:image:secure_url"))
|
||||
.or_else(|| extract_meta_content(html, "name", "twitter:image"))?;
|
||||
page_url.join(raw.trim()).ok()
|
||||
}
|
||||
|
||||
async fn fetch_sanitized_image(mut url: Url) -> Result<(String, String), String> {
|
||||
validate_public_https_url(&url).await?;
|
||||
for redirect_count in 0..=MAX_REDIRECTS {
|
||||
let response = send_pinned_request(&url, "image/jpeg,image/png,image/webp").await?;
|
||||
if response.status().is_redirection() {
|
||||
if redirect_count == MAX_REDIRECTS {
|
||||
return Err("link preview image redirected too many times".to_string());
|
||||
}
|
||||
let location = response
|
||||
.headers()
|
||||
.get(LOCATION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.ok_or_else(|| "link preview image redirect has an invalid location".to_string())?;
|
||||
url = url
|
||||
.join(location)
|
||||
.map_err(|error| format!("invalid link preview image redirect: {error}"))?;
|
||||
validate_public_https_url(&url).await?;
|
||||
continue;
|
||||
}
|
||||
if !response.status().is_success() {
|
||||
return Err("link preview image request was unsuccessful".to_string());
|
||||
}
|
||||
let declared_mime = response
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| {
|
||||
value
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_ascii_lowercase()
|
||||
})
|
||||
.ok_or_else(|| "link preview image has no content type".to_string())?;
|
||||
if !matches!(
|
||||
declared_mime.as_str(),
|
||||
"image/jpeg" | "image/png" | "image/webp"
|
||||
) {
|
||||
return Err("link preview image type is unsupported".to_string());
|
||||
}
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|size| size > MAX_IMAGE_FETCH_BYTES as u64)
|
||||
{
|
||||
return Err("link preview image exceeded the size limit".to_string());
|
||||
}
|
||||
let bytes = read_limited_bytes(response, MAX_IMAGE_FETCH_BYTES).await?;
|
||||
let data_url = tokio::task::spawn_blocking(move || sanitize_image(&bytes, &declared_mime))
|
||||
.await
|
||||
.map_err(|_| "link preview image sanitizer failed".to_string())??;
|
||||
let domain = url.host_str().unwrap_or_default().to_string();
|
||||
return Ok((data_url, domain));
|
||||
}
|
||||
Err("link preview image fetch failed".to_string())
|
||||
}
|
||||
|
||||
fn sanitize_image(bytes: &[u8], declared_mime: &str) -> Result<String, String> {
|
||||
let sniffed = infer::get(bytes)
|
||||
.map(|kind| kind.mime_type())
|
||||
.ok_or_else(|| "link preview image magic bytes are unsupported".to_string())?;
|
||||
if sniffed != declared_mime {
|
||||
return Err("link preview image content type does not match its bytes".to_string());
|
||||
}
|
||||
let format = match sniffed {
|
||||
"image/jpeg" => image::ImageFormat::Jpeg,
|
||||
"image/png" => image::ImageFormat::Png,
|
||||
"image/webp" => image::ImageFormat::WebP,
|
||||
_ => return Err("link preview image type is unsupported".to_string()),
|
||||
};
|
||||
if declares_animation(bytes, format) {
|
||||
return Err("animated link preview images are unsupported".to_string());
|
||||
}
|
||||
|
||||
let reader = image::ImageReader::with_format(Cursor::new(bytes), format);
|
||||
let mut decoder = reader
|
||||
.into_decoder()
|
||||
.map_err(|_| "link preview image is malformed".to_string())?;
|
||||
let (width, height) = decoder.dimensions();
|
||||
if width == 0
|
||||
|| height == 0
|
||||
|| width > MAX_IMAGE_DIMENSION
|
||||
|| height > MAX_IMAGE_DIMENSION
|
||||
|| u64::from(width) * u64::from(height) > MAX_IMAGE_PIXELS
|
||||
{
|
||||
return Err("link preview image dimensions exceed safe limits".to_string());
|
||||
}
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(MAX_IMAGE_DIMENSION);
|
||||
limits.max_image_height = Some(MAX_IMAGE_DIMENSION);
|
||||
limits.max_alloc = Some(MAX_IMAGE_PIXELS * 4);
|
||||
decoder
|
||||
.set_limits(limits)
|
||||
.map_err(|_| "link preview image exceeds safe decoding limits".to_string())?;
|
||||
let orientation = decoder
|
||||
.orientation()
|
||||
.unwrap_or(image::metadata::Orientation::NoTransforms);
|
||||
let mut decoded = image::DynamicImage::from_decoder(decoder)
|
||||
.map_err(|_| "link preview image could not be decoded".to_string())?;
|
||||
decoded.apply_orientation(orientation);
|
||||
let decoded = decoded.thumbnail(MAX_SANITIZED_DIMENSION, MAX_SANITIZED_DIMENSION);
|
||||
let mut output = Vec::new();
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 82)
|
||||
.encode_image(&decoded)
|
||||
.map_err(|_| "link preview image could not be sanitized".to_string())?;
|
||||
Ok(format!(
|
||||
"data:image/jpeg;base64,{}",
|
||||
BASE64_STANDARD.encode(output)
|
||||
))
|
||||
}
|
||||
|
||||
fn declares_animation(bytes: &[u8], format: image::ImageFormat) -> bool {
|
||||
match format {
|
||||
image::ImageFormat::Png => bytes.windows(4).any(|chunk| chunk == b"acTL"),
|
||||
image::ImageFormat::WebP => {
|
||||
bytes.len() >= 21
|
||||
&& bytes.starts_with(b"RIFF")
|
||||
&& &bytes[8..12] == b"WEBP"
|
||||
&& ((&bytes[12..16] == b"VP8X" && bytes[20] & 0x02 != 0)
|
||||
|| bytes.windows(4).any(|chunk| chunk == b"ANIM"))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_link_preview_metadata(html: &str) -> Option<LinkPreviewMetadata> {
|
||||
@@ -177,7 +327,12 @@ fn extract_link_preview_metadata(html: &str) -> Option<LinkPreviewMetadata> {
|
||||
let site_name = extract_meta_content(html, "property", "og:site_name")
|
||||
.and_then(|value| normalize_metadata_text(&value));
|
||||
|
||||
Some(LinkPreviewMetadata { title, site_name })
|
||||
Some(LinkPreviewMetadata {
|
||||
title,
|
||||
site_name,
|
||||
image_data_url: None,
|
||||
image_domain: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_meta_content(html: &str, key_attr: &str, key_value: &str) -> Option<String> {
|
||||
@@ -300,7 +455,13 @@ fn decode_html_entities(value: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{extract_link_preview_metadata, LinkPreviewMetadata};
|
||||
use super::{
|
||||
declares_animation, extract_image_url, extract_link_preview_metadata, sanitize_image,
|
||||
LinkPreviewMetadata,
|
||||
};
|
||||
use image::{DynamicImage, ImageFormat, Rgb, RgbImage};
|
||||
use std::io::Cursor;
|
||||
use url::Url;
|
||||
|
||||
#[test]
|
||||
fn metadata_prefers_open_graph_and_reads_site_name() {
|
||||
@@ -312,6 +473,8 @@ mod tests {
|
||||
Some(LinkPreviewMetadata {
|
||||
title: "Rich previews & cards".to_string(),
|
||||
site_name: Some("Buzz".to_string()),
|
||||
image_data_url: None,
|
||||
image_domain: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -330,6 +493,38 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_metadata_resolves_relative_urls_and_prefers_open_graph() {
|
||||
let page = Url::parse("https://example.com/articles/one").unwrap();
|
||||
let html = r#"<meta name="twitter:image" content="https://cdn.example/twitter.jpg">
|
||||
<meta property="og:image" content="../preview.png">"#;
|
||||
assert_eq!(
|
||||
extract_image_url(html, &page).unwrap().as_str(),
|
||||
"https://example.com/preview.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizer_rejects_mime_mismatch_and_outputs_static_jpeg() {
|
||||
let source = DynamicImage::ImageRgb8(RgbImage::from_pixel(2, 2, Rgb([10, 20, 30])));
|
||||
let mut png = Cursor::new(Vec::new());
|
||||
source.write_to(&mut png, ImageFormat::Png).unwrap();
|
||||
assert!(sanitize_image(png.get_ref(), "image/jpeg").is_err());
|
||||
let sanitized = sanitize_image(png.get_ref(), "image/png").unwrap();
|
||||
assert!(sanitized.starts_with("data:image/jpeg;base64,"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animation_markers_are_rejected_before_decode() {
|
||||
let mut apng = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
apng.extend_from_slice(b"junkacTLjunk");
|
||||
assert!(declares_animation(&apng, ImageFormat::Png));
|
||||
|
||||
let mut webp = b"RIFF\x00\x00\x00\x00WEBPVP8X\x0a\x00\x00\x00".to_vec();
|
||||
webp.push(0x02);
|
||||
assert!(declares_animation(&webp, ImageFormat::WebP));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_requires_a_non_empty_title() {
|
||||
assert_eq!(extract_link_preview_metadata("<title> </title>"), None);
|
||||
|
||||
@@ -121,6 +121,7 @@ export function ForumPostCard({
|
||||
<Markdown
|
||||
className="text-sm"
|
||||
content={previewContent}
|
||||
messageId={post.eventId}
|
||||
imetaByUrl={imetaByUrl}
|
||||
mentionNames={mentionNames}
|
||||
mentionPubkeysByName={mentionPubkeysByName}
|
||||
|
||||
@@ -115,6 +115,7 @@ function ReplyRow({
|
||||
channelNames={channelNames}
|
||||
className="text-sm"
|
||||
content={reply.content}
|
||||
messageId={reply.eventId}
|
||||
imetaByUrl={parseImetaTags(reply.tags)}
|
||||
mentionNames={replyMentionNames}
|
||||
mentionPubkeysByName={replyMentionPubkeysByName}
|
||||
@@ -258,6 +259,7 @@ export function ForumThreadPanel({
|
||||
channelNames={channelNames}
|
||||
className="text-sm"
|
||||
content={post.content}
|
||||
messageId={post.eventId}
|
||||
imetaByUrl={parseImetaTags(post.tags)}
|
||||
mentionNames={postMentionNames}
|
||||
mentionPubkeysByName={postMentionPubkeysByName}
|
||||
|
||||
@@ -228,6 +228,7 @@ export function FeedSection({
|
||||
<Markdown
|
||||
className="max-w-none text-sm leading-snug text-muted-foreground"
|
||||
content={feedContent(item)}
|
||||
messageId={item.id}
|
||||
mentionNames={mentionNames}
|
||||
mentionPubkeysByName={mentionPubkeysByName}
|
||||
/>
|
||||
|
||||
@@ -212,6 +212,7 @@ export function InboxMessageRow({
|
||||
isKnownAgentPubkey,
|
||||
)}
|
||||
content={message.content}
|
||||
messageId={message.id}
|
||||
customEmoji={customEmoji}
|
||||
mentionNames={message.mentionNames}
|
||||
mentionPubkeysByName={message.mentionPubkeysByName}
|
||||
|
||||
@@ -370,6 +370,7 @@ export const MessageRow = React.memo(
|
||||
isKnownAgentPubkey,
|
||||
)}
|
||||
content={message.body}
|
||||
messageId={message.id}
|
||||
customEmoji={customEmoji}
|
||||
imetaByUrl={imetaByUrl}
|
||||
agentMentionPubkeysByName={agentMentionPubkeysByName}
|
||||
|
||||
@@ -15,6 +15,9 @@ export type SupportedLinkPreview = {
|
||||
href: string;
|
||||
provider: string;
|
||||
title: string;
|
||||
/** Sanitized native-fetched bitmap; never a remote URL. */
|
||||
imageDataUrl?: string | null;
|
||||
imageDomain?: string | null;
|
||||
typeLabel:
|
||||
| "PR"
|
||||
| "issue"
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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 },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
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)),
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import type { SupportedLinkPreview } from "./linkPreview";
|
||||
type LinkPreviewMetadata = {
|
||||
title: string;
|
||||
siteName: string | null;
|
||||
imageDataUrl: string | null;
|
||||
imageDomain: string | null;
|
||||
};
|
||||
|
||||
const metadataCache = new Map<
|
||||
@@ -96,6 +98,8 @@ export function useResolvedLinkPreviews(
|
||||
preview.kind === "generic-link" && metadata.siteName
|
||||
? metadata.siteName
|
||||
: preview.provider,
|
||||
imageDataUrl: metadata.imageDataUrl,
|
||||
imageDomain: metadata.imageDomain,
|
||||
};
|
||||
}),
|
||||
[previews, resolvedMetadata],
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { ExternalLink, Globe } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { ExternalLink, Globe, EyeOff } 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,
|
||||
@@ -113,11 +120,32 @@ 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,
|
||||
);
|
||||
|
||||
return (
|
||||
<Attachment
|
||||
className={cn(
|
||||
@@ -125,11 +153,39 @@ export function LinkPreviewAttachment({
|
||||
className,
|
||||
)}
|
||||
data-link-preview={preview.kind}
|
||||
orientation={showImage ? "vertical" : "horizontal"}
|
||||
>
|
||||
<AttachmentMedia className="link-preview-media">
|
||||
<LinkPreviewLogo preview={preview} />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
{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}
|
||||
/>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -1837,6 +1837,7 @@ function MarkdownInner({
|
||||
interactive = true,
|
||||
agentMentionPubkeysByName,
|
||||
mediaInset = false,
|
||||
messageId,
|
||||
mentionNames,
|
||||
mentionPubkeysByName,
|
||||
searchQuery,
|
||||
@@ -1977,7 +1978,11 @@ function MarkdownInner({
|
||||
data-link-preview-list=""
|
||||
>
|
||||
{resolvedLinkPreviews.map((preview) => (
|
||||
<LinkPreviewAttachment key={preview.href} preview={preview} />
|
||||
<LinkPreviewAttachment
|
||||
key={preview.href}
|
||||
messageId={messageId}
|
||||
preview={preview}
|
||||
/>
|
||||
))}
|
||||
</AttachmentGroup>
|
||||
) : null}
|
||||
@@ -1995,6 +2000,7 @@ export const Markdown = React.memo(
|
||||
prev.customEmoji === next.customEmoji &&
|
||||
prev.interactive === next.interactive &&
|
||||
prev.mediaInset === next.mediaInset &&
|
||||
prev.messageId === next.messageId &&
|
||||
shallowRecordEqual(
|
||||
prev.agentMentionPubkeysByName,
|
||||
next.agentMentionPubkeysByName,
|
||||
|
||||
@@ -59,6 +59,8 @@ export type MarkdownProps = {
|
||||
mentionNames?: string[];
|
||||
mentionPubkeysByName?: Record<string, string>;
|
||||
mediaInset?: boolean;
|
||||
/** Event/message identity used only for local preview-image visibility. */
|
||||
messageId?: string;
|
||||
searchQuery?: string;
|
||||
/** Display name shown in shared-agent card metadata. */
|
||||
snapshotSharedBy?: string;
|
||||
|
||||
Reference in New Issue
Block a user