mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(previews): unfurl safe link images
Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
co-authored by
Taylor Ho
parent
f7b1fb12d8
commit
da3bba54ec
@@ -29,6 +29,7 @@ pub struct LinkPreviewMetadata {
|
||||
description: Option<String>,
|
||||
image_data_url: Option<String>,
|
||||
image_domain: Option<String>,
|
||||
favicon_data_url: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -78,11 +79,16 @@ async fn fetch_link_preview_metadata_inner(
|
||||
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 {
|
||||
if let Ok((data_url, domain)) = fetch_sanitized_image(image_url, false).await {
|
||||
metadata.image_data_url = Some(data_url);
|
||||
metadata.image_domain = Some(domain);
|
||||
}
|
||||
}
|
||||
if let Some(favicon_url) = extract_favicon_url(&body, &url) {
|
||||
if let Ok((data_url, _)) = fetch_sanitized_image(favicon_url, true).await {
|
||||
metadata.favicon_data_url = Some(data_url);
|
||||
}
|
||||
}
|
||||
return Ok(Some(metadata));
|
||||
}
|
||||
|
||||
@@ -190,6 +196,53 @@ async fn read_limited_bytes(response: reqwest::Response, limit: usize) -> Result
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn extract_favicon_url(html: &str, page_url: &Url) -> Option<Url> {
|
||||
let lower = html.to_ascii_lowercase();
|
||||
let mut search_from = 0;
|
||||
let mut fallback = None;
|
||||
|
||||
while let Some(relative_start) = lower[search_from..].find("<link") {
|
||||
let start = search_from + relative_start;
|
||||
let Some(relative_end) = lower[start..].find('>') else {
|
||||
break;
|
||||
};
|
||||
let end = start + relative_end + 1;
|
||||
let tag = &html[start..end];
|
||||
let rel = attr_value(tag, "rel");
|
||||
let is_icon = rel.as_ref().is_some_and(|value| {
|
||||
value.split_ascii_whitespace().any(|token| {
|
||||
token.eq_ignore_ascii_case("icon") || token.eq_ignore_ascii_case("apple-touch-icon")
|
||||
})
|
||||
});
|
||||
if is_icon {
|
||||
if let Some(href) = attr_value(tag, "href") {
|
||||
if let Ok(url) = page_url.join(href.trim()) {
|
||||
let declared_type = attr_value(tag, "type");
|
||||
let is_supported_raster = declared_type.as_ref().is_some_and(|value| {
|
||||
matches!(
|
||||
value.to_ascii_lowercase().as_str(),
|
||||
"image/jpeg" | "image/png" | "image/webp"
|
||||
)
|
||||
}) || matches!(
|
||||
url.path()
|
||||
.rsplit_once('.')
|
||||
.map(|(_, extension)| extension.to_ascii_lowercase())
|
||||
.as_deref(),
|
||||
Some("jpg" | "jpeg" | "png" | "webp")
|
||||
);
|
||||
if is_supported_raster {
|
||||
return Some(url);
|
||||
}
|
||||
fallback.get_or_insert(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
search_from = end;
|
||||
}
|
||||
|
||||
fallback
|
||||
}
|
||||
|
||||
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"))
|
||||
@@ -197,7 +250,10 @@ fn extract_image_url(html: &str, page_url: &Url) -> Option<Url> {
|
||||
page_url.join(raw.trim()).ok()
|
||||
}
|
||||
|
||||
async fn fetch_sanitized_image(mut url: Url) -> Result<(String, String), String> {
|
||||
async fn fetch_sanitized_image(
|
||||
mut url: Url,
|
||||
preserve_transparency: bool,
|
||||
) -> 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?;
|
||||
@@ -245,16 +301,22 @@ async fn fetch_sanitized_image(mut url: Url) -> Result<(String, String), String>
|
||||
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 data_url = tokio::task::spawn_blocking(move || {
|
||||
sanitize_image(&bytes, &declared_mime, preserve_transparency)
|
||||
})
|
||||
.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> {
|
||||
fn sanitize_image(
|
||||
bytes: &[u8],
|
||||
declared_mime: &str,
|
||||
preserve_transparency: bool,
|
||||
) -> 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())?;
|
||||
@@ -299,6 +361,15 @@ fn sanitize_image(bytes: &[u8], declared_mime: &str) -> Result<String, String> {
|
||||
decoded.apply_orientation(orientation);
|
||||
let decoded = decoded.thumbnail(MAX_SANITIZED_DIMENSION, MAX_SANITIZED_DIMENSION);
|
||||
let mut output = Vec::new();
|
||||
if preserve_transparency && decoded.color().has_alpha() {
|
||||
decoded
|
||||
.write_to(&mut Cursor::new(&mut output), image::ImageFormat::Png)
|
||||
.map_err(|_| "link preview image could not be sanitized".to_string())?;
|
||||
return Ok(format!(
|
||||
"data:image/png;base64,{}",
|
||||
BASE64_STANDARD.encode(output)
|
||||
));
|
||||
}
|
||||
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut output, 82)
|
||||
.encode_image(&decoded)
|
||||
.map_err(|_| "link preview image could not be sanitized".to_string())?;
|
||||
@@ -339,6 +410,7 @@ fn extract_link_preview_metadata(html: &str) -> Option<LinkPreviewMetadata> {
|
||||
description,
|
||||
image_data_url: None,
|
||||
image_domain: None,
|
||||
favicon_data_url: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -463,13 +535,14 @@ fn decode_html_entities(value: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
declares_animation, extract_image_url, extract_link_preview_metadata, is_html_response,
|
||||
read_bytes_prefix, sanitize_image, LinkPreviewMetadata,
|
||||
declares_animation, extract_favicon_url, extract_image_url, extract_link_preview_metadata,
|
||||
is_html_response, read_bytes_prefix, sanitize_image, LinkPreviewMetadata,
|
||||
};
|
||||
use axum::{body::Body, http::Response, routing::get, Router};
|
||||
use base64::Engine as _;
|
||||
use bytes::Bytes;
|
||||
use futures_util::stream;
|
||||
use image::{DynamicImage, ImageFormat, Rgb, RgbImage};
|
||||
use image::{DynamicImage, ImageFormat, Rgb, RgbImage, Rgba, RgbaImage};
|
||||
use std::{convert::Infallible, io::Cursor};
|
||||
use url::Url;
|
||||
|
||||
@@ -498,6 +571,7 @@ mod tests {
|
||||
description: Some("Safe & useful previews".to_string()),
|
||||
image_data_url: None,
|
||||
image_domain: None,
|
||||
favicon_data_url: None,
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -516,6 +590,40 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn favicon_metadata_resolves_relative_icon_links() {
|
||||
let page = Url::parse("https://example.com/articles/one").unwrap();
|
||||
let html = r#"<link rel="stylesheet" href="styles.css">
|
||||
<link href="../favicon.png" rel="shortcut icon">"#;
|
||||
assert_eq!(
|
||||
extract_favicon_url(html, &page).unwrap().as_str(),
|
||||
"https://example.com/favicon.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn favicon_metadata_prefers_a_supported_raster_candidate() {
|
||||
let page = Url::parse("https://github.com/block/buzz").unwrap();
|
||||
let html = r#"<link rel="mask-icon" href="https://assets.example/favicon.svg">
|
||||
<link rel="alternate icon" type="image/png" href="https://assets.example/favicon.png">
|
||||
<link rel="icon" type="image/svg+xml" href="https://assets.example/favicon.svg">"#;
|
||||
assert_eq!(
|
||||
extract_favicon_url(html, &page).unwrap().as_str(),
|
||||
"https://assets.example/favicon.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn favicon_metadata_uses_touch_icon_before_unsupported_ico() {
|
||||
let page = Url::parse("https://twitter.com/tellaho").unwrap();
|
||||
let html = r#"<link rel="icon" href="/favicon.ico">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="/apple-touch-icon.png">"#;
|
||||
assert_eq!(
|
||||
extract_favicon_url(html, &page).unwrap().as_str(),
|
||||
"https://twitter.com/apple-touch-icon.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_metadata_resolves_relative_urls_and_prefers_open_graph() {
|
||||
let page = Url::parse("https://example.com/articles/one").unwrap();
|
||||
@@ -602,11 +710,26 @@ mod tests {
|
||||
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!(sanitize_image(png.get_ref(), "image/jpeg", false).is_err());
|
||||
let sanitized = sanitize_image(png.get_ref(), "image/png", false).unwrap();
|
||||
assert!(sanitized.starts_with("data:image/jpeg;base64,"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn favicon_sanitizer_preserves_png_transparency() {
|
||||
let source = DynamicImage::ImageRgba8(RgbaImage::from_pixel(2, 2, Rgba([36, 41, 47, 0])));
|
||||
let mut png = Cursor::new(Vec::new());
|
||||
source.write_to(&mut png, ImageFormat::Png).unwrap();
|
||||
|
||||
let sanitized = sanitize_image(png.get_ref(), "image/png", true).unwrap();
|
||||
assert!(sanitized.starts_with("data:image/png;base64,"));
|
||||
let encoded = sanitized.split_once(',').unwrap().1;
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.unwrap();
|
||||
assert!(image::load_from_memory(&bytes).unwrap().color().has_alpha());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animation_markers_are_rejected_before_decode() {
|
||||
let mut apng = b"\x89PNG\r\n\x1a\n".to_vec();
|
||||
|
||||
@@ -10,6 +10,7 @@ type LinkPreviewMetadata = {
|
||||
description: string | null;
|
||||
imageDataUrl: string | null;
|
||||
imageDomain: string | null;
|
||||
faviconDataUrl?: string | null;
|
||||
};
|
||||
|
||||
const metadataCache = new Map<
|
||||
@@ -55,6 +56,7 @@ export type LinkPreviewImageState = "pending" | "image" | "none";
|
||||
|
||||
export type ResolvedLinkPreview = SupportedLinkPreview & {
|
||||
description?: string | null;
|
||||
faviconDataUrl?: string | null;
|
||||
imageState: LinkPreviewImageState;
|
||||
};
|
||||
|
||||
@@ -79,6 +81,7 @@ export function resolveLinkPreview(
|
||||
...preview,
|
||||
title: metadata.title,
|
||||
description: metadata.description,
|
||||
faviconDataUrl: metadata.faviconDataUrl,
|
||||
provider:
|
||||
preview.kind === "generic-link" && metadata.siteName
|
||||
? metadata.siteName
|
||||
|
||||
@@ -1,115 +1,23 @@
|
||||
import { Globe, X } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentContent,
|
||||
AttachmentDescription,
|
||||
AttachmentMedia,
|
||||
AttachmentTitle,
|
||||
AttachmentTrigger,
|
||||
} from "@/shared/ui/attachment";
|
||||
|
||||
function LinearLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleDriveLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12.01 1.485c-2.082 0-3.754.02-3.743.047.01.02 1.708 3.001 3.774 6.62l3.76 6.574h3.76c2.081 0 3.753-.02 3.742-.047-.005-.02-1.708-3.001-3.775-6.62l-3.76-6.574zm-4.76 1.73a789.828 789.861 0 0 0-3.63 6.319L0 15.868l1.89 3.298 1.885 3.297 3.62-6.335 3.618-6.33-1.88-3.287C8.1 4.704 7.255 3.22 7.25 3.214zm2.259 12.653-.203.348c-.114.198-.96 1.672-1.88 3.287a423.93 423.948 0 0 1-1.698 2.97c-.01.026 3.24.042 7.222.042h7.244l1.796-3.157c.992-1.734 1.85-3.23 1.906-3.323l.104-.167h-7.249z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleDocsLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M14.727 6.727H14V0H4.91c-.905 0-1.637.732-1.637 1.636v20.728c0 .904.732 1.636 1.636 1.636h14.182c.904 0 1.636-.732 1.636-1.636V6.727h-6zm-.545 10.455H7.09v-1.364h7.09v1.364zm2.727-3.273H7.091v-1.364h9.818v1.364zm0-3.273H7.091V9.273h9.818v1.363zM14.727 6h6l-6-6v6z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleSheetsLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M11.318 12.545H7.91v-1.909h3.41v1.91zM14.728 0v6h6l-6-6zm1.363 10.636h-3.41v1.91h3.41v-1.91zm0 3.273h-3.41v1.91h3.41v-1.91zM20.727 6.5v15.864c0 .904-.732 1.636-1.636 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.318v6.5h6.5zm-3.273 2.773H6.545v7.909h10.91v-7.91zm-6.136 4.636H7.91v1.91h3.41v-1.91z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleSlidesLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M16.09 15.273H7.91v-4.637h8.18v4.637zm1.728-8.523h2.91v15.614c0 .904-.733 1.636-1.637 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.068v6.75h3.841zm-.363 2.523H6.545v7.363h10.91V9.273zm-2.728-5.979V6h6.001l-6-6v3.294z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkPreviewLogo({ preview }: { preview: ResolvedLinkPreview }) {
|
||||
switch (preview.kind) {
|
||||
case "github-issue":
|
||||
case "github-pull-request":
|
||||
case "github-repository":
|
||||
return <GitHubLogo className="h-4 w-4" />;
|
||||
case "linear-issue":
|
||||
return <LinearLogo className="h-4 w-4" />;
|
||||
case "google-drive-file":
|
||||
case "google-drive-folder":
|
||||
return <GoogleDriveLogo className="h-4 w-4" />;
|
||||
case "google-docs-document":
|
||||
return <GoogleDocsLogo className="h-4 w-4" />;
|
||||
case "google-sheets-spreadsheet":
|
||||
return <GoogleSheetsLogo className="h-4 w-4" />;
|
||||
case "google-slides-presentation":
|
||||
return <GoogleSlidesLogo className="h-4 w-4" />;
|
||||
case "generic-link":
|
||||
return <Globe aria-hidden="true" className="h-4 w-4" />;
|
||||
function LinkPreviewIdentity({ preview }: { preview: ResolvedLinkPreview }) {
|
||||
if (preview.faviconDataUrl) {
|
||||
return (
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="size-4 rounded-sm object-contain"
|
||||
data-link-preview-favicon=""
|
||||
src={preview.faviconDataUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getHostname(preview: ResolvedLinkPreview): string {
|
||||
@@ -120,7 +28,19 @@ function getHostname(preview: ResolvedLinkPreview): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function LinkPreviewAttachment({
|
||||
function isTweetPreview(preview: ResolvedLinkPreview): boolean {
|
||||
try {
|
||||
const url = new URL(preview.href);
|
||||
return (
|
||||
(url.hostname === "x.com" || url.hostname === "twitter.com") &&
|
||||
/^\/[^/]+\/status\/\d+/.test(url.pathname)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function TweetPreview({
|
||||
className,
|
||||
onRemove,
|
||||
preview,
|
||||
@@ -129,81 +49,252 @@ export function LinkPreviewAttachment({
|
||||
onRemove?: () => void;
|
||||
preview: ResolvedLinkPreview;
|
||||
}) {
|
||||
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
|
||||
const [descriptionOverflows, setDescriptionOverflows] = useState(false);
|
||||
const [imageExpanded, setImageExpanded] = useState(true);
|
||||
const descriptionRef = useRef<HTMLDivElement>(null);
|
||||
const reserveImage = preview.imageState !== "none";
|
||||
const showImage = preview.imageState === "image";
|
||||
const hostname = getHostname(preview);
|
||||
|
||||
useEffect(() => {
|
||||
if (!preview.description) return;
|
||||
|
||||
const description = descriptionRef.current;
|
||||
if (!description || descriptionExpanded) return;
|
||||
|
||||
const measure = () => {
|
||||
setDescriptionOverflows(
|
||||
description.scrollHeight > description.clientHeight + 1,
|
||||
);
|
||||
};
|
||||
const frame = requestAnimationFrame(measure);
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(description);
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [descriptionExpanded, preview.description]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative w-[22.5rem] max-w-full shrink-0", className)}>
|
||||
<Attachment
|
||||
className={cn(
|
||||
"w-full no-underline shadow-none",
|
||||
reserveImage && "h-20 min-h-20 max-h-20 gap-0 p-0",
|
||||
)}
|
||||
data-image-state={preview.imageState}
|
||||
data-link-preview={preview.kind}
|
||||
orientation="horizontal"
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-[26rem] max-w-full shrink-0 border-l-[3px] border-border pl-3",
|
||||
className,
|
||||
)}
|
||||
data-image-state={preview.imageState}
|
||||
data-link-preview={preview.kind}
|
||||
data-tweet-preview=""
|
||||
>
|
||||
<div
|
||||
className="text-xs leading-4 text-muted-foreground"
|
||||
data-link-preview-hostname=""
|
||||
>
|
||||
{reserveImage ? (
|
||||
<AttachmentMedia
|
||||
aria-hidden={showImage ? undefined : "true"}
|
||||
className="aspect-auto h-full min-h-0 w-28 min-w-28 max-w-28 self-stretch rounded-none bg-muted sm:w-32 sm:min-w-32 sm:max-w-32"
|
||||
data-link-preview-thumbnail=""
|
||||
variant="image"
|
||||
>
|
||||
{showImage ? (
|
||||
<img
|
||||
alt={`Preview from ${preview.imageDomain}`}
|
||||
className="h-full w-full object-cover"
|
||||
src={preview.imageDataUrl ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="h-full w-full animate-pulse bg-muted-foreground/10"
|
||||
data-link-preview-skeleton=""
|
||||
/>
|
||||
)}
|
||||
</AttachmentMedia>
|
||||
) : (
|
||||
<AttachmentMedia className="link-preview-media">
|
||||
<LinkPreviewLogo preview={preview} />
|
||||
</AttachmentMedia>
|
||||
)}
|
||||
<AttachmentContent className={reserveImage ? "px-3 py-2.5" : undefined}>
|
||||
{hostname}
|
||||
</div>
|
||||
<a
|
||||
className="mt-0.5 block truncate text-sm font-semibold leading-5 text-foreground hover:underline"
|
||||
href={preview.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{preview.title}
|
||||
</a>
|
||||
{preview.description ? (
|
||||
<>
|
||||
<div
|
||||
className="truncate text-xs font-normal leading-4 text-muted-foreground"
|
||||
data-link-preview-hostname=""
|
||||
className={cn(
|
||||
"mt-1 whitespace-normal text-sm leading-5 text-foreground",
|
||||
!descriptionExpanded && "line-clamp-5",
|
||||
)}
|
||||
data-slot="attachment-description"
|
||||
ref={descriptionRef}
|
||||
>
|
||||
{reserveImage ? hostname : preview.provider}
|
||||
{preview.description}
|
||||
</div>
|
||||
<AttachmentTitle
|
||||
className={
|
||||
reserveImage
|
||||
? preview.description
|
||||
? "truncate"
|
||||
: "line-clamp-2 whitespace-normal"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{preview.title}
|
||||
</AttachmentTitle>
|
||||
{reserveImage && preview.description ? (
|
||||
<AttachmentDescription>{preview.description}</AttachmentDescription>
|
||||
{descriptionOverflows ? (
|
||||
<button
|
||||
aria-expanded={descriptionExpanded}
|
||||
className="mt-1 text-xs font-medium leading-4 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setDescriptionExpanded((expanded) => !expanded)}
|
||||
type="button"
|
||||
>
|
||||
{descriptionExpanded ? "Show less" : "Show more"}
|
||||
</button>
|
||||
) : null}
|
||||
</AttachmentContent>
|
||||
<AttachmentTrigger asChild>
|
||||
<a
|
||||
aria-label={`Open ${preview.provider} ${preview.typeLabel}: ${preview.title}`}
|
||||
href={preview.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
</>
|
||||
) : null}
|
||||
{reserveImage ? (
|
||||
<div className="mt-2">
|
||||
{imageExpanded ? (
|
||||
<a
|
||||
aria-label={`Open tweet: ${preview.title}`}
|
||||
className="block overflow-hidden rounded-xl bg-muted"
|
||||
href={preview.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<div
|
||||
className="aspect-video w-full"
|
||||
data-link-preview-thumbnail=""
|
||||
>
|
||||
{showImage ? (
|
||||
<img
|
||||
alt={`Preview from ${preview.imageDomain}`}
|
||||
className="h-full w-full object-cover"
|
||||
src={preview.imageDataUrl ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="h-full w-full animate-pulse bg-muted-foreground/10"
|
||||
data-link-preview-skeleton=""
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
) : null}
|
||||
<button
|
||||
aria-expanded={imageExpanded}
|
||||
className="mt-1 flex items-center gap-1 text-xs leading-4 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setImageExpanded((expanded) => !expanded)}
|
||||
type="button"
|
||||
>
|
||||
<span className="sr-only">
|
||||
Open {preview.provider} {preview.typeLabel}: {preview.title}
|
||||
</span>
|
||||
</a>
|
||||
</AttachmentTrigger>
|
||||
</Attachment>
|
||||
{imageExpanded ? (
|
||||
<ChevronUp aria-hidden="true" className="size-3" />
|
||||
) : (
|
||||
<ChevronDown aria-hidden="true" className="size-3" />
|
||||
)}
|
||||
{imageExpanded ? "Hide image" : "Show image"}
|
||||
</button>
|
||||
</div>
|
||||
) : 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>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LinkPreviewAttachment({
|
||||
className,
|
||||
onRemove,
|
||||
preview,
|
||||
}: {
|
||||
className?: string;
|
||||
onRemove?: () => void;
|
||||
preview: ResolvedLinkPreview;
|
||||
}) {
|
||||
const [imageExpanded, setImageExpanded] = useState(true);
|
||||
|
||||
if (isTweetPreview(preview)) {
|
||||
return (
|
||||
<TweetPreview
|
||||
className={className}
|
||||
onRemove={onRemove}
|
||||
preview={preview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const reserveImage = preview.imageState !== "none";
|
||||
const showImage = preview.imageState === "image";
|
||||
const hostname = getHostname(preview);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-[26rem] max-w-full shrink-0 border-l-[3px] border-border pl-3",
|
||||
className,
|
||||
)}
|
||||
data-image-state={preview.imageState}
|
||||
data-link-preview={preview.kind}
|
||||
data-link-preview-inline=""
|
||||
>
|
||||
<div className={cn(reserveImage && "min-h-[3.875rem]")}>
|
||||
<div
|
||||
className="flex items-center gap-1.5 text-xs leading-4 text-muted-foreground"
|
||||
data-link-preview-identity=""
|
||||
>
|
||||
<LinkPreviewIdentity preview={preview} />
|
||||
<span data-link-preview-hostname="">{hostname}</span>
|
||||
</div>
|
||||
<a
|
||||
aria-label={`Open ${preview.provider} ${preview.typeLabel}: ${preview.title}`}
|
||||
className="mt-0.5 block text-sm font-semibold leading-5 text-foreground hover:underline"
|
||||
href={preview.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span
|
||||
className={preview.description ? "line-clamp-1" : "line-clamp-2"}
|
||||
>
|
||||
{preview.title}
|
||||
</span>
|
||||
</a>
|
||||
{preview.description ? (
|
||||
<div
|
||||
className="mt-1 line-clamp-2 whitespace-normal text-sm leading-5 text-muted-foreground"
|
||||
data-slot="attachment-description"
|
||||
>
|
||||
{preview.description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{reserveImage ? (
|
||||
<div className="mt-2">
|
||||
{imageExpanded ? (
|
||||
<a
|
||||
aria-label={`Open preview image from ${hostname}`}
|
||||
className="block overflow-hidden rounded-xl bg-muted"
|
||||
href={preview.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<div
|
||||
className="aspect-[1.91/1] w-full"
|
||||
data-link-preview-thumbnail=""
|
||||
>
|
||||
{showImage ? (
|
||||
<img
|
||||
alt={`Preview from ${preview.imageDomain}`}
|
||||
className="h-full w-full object-cover"
|
||||
src={preview.imageDataUrl ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="h-full w-full animate-pulse bg-muted-foreground/10"
|
||||
data-link-preview-skeleton=""
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</a>
|
||||
) : null}
|
||||
<button
|
||||
aria-expanded={imageExpanded}
|
||||
className="mt-1 flex items-center gap-1 text-xs leading-4 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setImageExpanded((expanded) => !expanded)}
|
||||
type="button"
|
||||
>
|
||||
{imageExpanded ? (
|
||||
<ChevronUp aria-hidden="true" className="size-3" />
|
||||
) : (
|
||||
<ChevronDown aria-hidden="true" className="size-3" />
|
||||
)}
|
||||
{imageExpanded ? "Hide image" : "Show image"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{onRemove ? (
|
||||
<Button
|
||||
aria-label="Remove previews for everyone"
|
||||
|
||||
@@ -1999,15 +1999,14 @@ function MarkdownInner({
|
||||
) : null}
|
||||
{resolvedLinkPreviews.length > 0 ? (
|
||||
<AttachmentGroup
|
||||
className="max-w-full flex-wrap overflow-visible pb-0"
|
||||
className="max-w-full flex-col items-start overflow-visible pb-0"
|
||||
data-link-preview-list=""
|
||||
>
|
||||
{resolvedLinkPreviews.map((preview, index) => (
|
||||
<LinkPreviewAttachment
|
||||
key={preview.href}
|
||||
onRemove={
|
||||
onRemoveLinkPreviewsForEveryone &&
|
||||
index === resolvedLinkPreviews.length - 1
|
||||
onRemoveLinkPreviewsForEveryone && index === 0
|
||||
? () => setRemovePreviewDialogOpen(true)
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -291,6 +291,7 @@ type E2eConfig = {
|
||||
description: string | null;
|
||||
imageDataUrl: string | null;
|
||||
imageDomain: string | null;
|
||||
faviconDataUrl?: string | null;
|
||||
} | null;
|
||||
linkPreviewMetadataDelayMs?: number;
|
||||
searchProfiles?: MockSearchProfileSeed[];
|
||||
|
||||
+193
-180
@@ -1,7 +1,5 @@
|
||||
import { expect, test, type Locator } from "@playwright/test";
|
||||
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
|
||||
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
||||
import { expectCornerRadiusPx, expectSmoothCorners } from "../helpers/css";
|
||||
import { openSettings } from "../helpers/settings";
|
||||
@@ -110,25 +108,49 @@ test.beforeEach(async ({ page }, testInfo) => {
|
||||
},
|
||||
],
|
||||
}
|
||||
: testInfo.title.includes("link preview image geometry")
|
||||
: testInfo.title.includes("cardless short tweet preview")
|
||||
? {
|
||||
linkPreviewMetadata: {
|
||||
title:
|
||||
"Ship a wider horizontal preview with a two-line title that wraps cleanly",
|
||||
siteName: "GitHub",
|
||||
description: "A polished, stable preview for shared links.",
|
||||
imageDataUrl:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1600' height='120'%3E%3Crect width='1600' height='120' fill='%237c3aed'/%3E%3Ccircle cx='92' cy='60' r='48' fill='%23fff' fill-opacity='.9'/%3E%3C/svg%3E",
|
||||
imageDomain: "opengraph.githubassets.com",
|
||||
title: "jack (@jack) on X",
|
||||
siteName: "X (formerly Twitter)",
|
||||
description: "just setting up my twttr",
|
||||
imageDataUrl: null,
|
||||
imageDomain: null,
|
||||
},
|
||||
linkPreviewMetadataDelayMs: 800,
|
||||
}
|
||||
: testInfo.title.includes("link preview no-image collapse")
|
||||
: testInfo.title.includes("cardless tweet preview")
|
||||
? {
|
||||
linkPreviewMetadata: null,
|
||||
linkPreviewMetadataDelayMs: 2_000,
|
||||
linkPreviewMetadata: {
|
||||
title: "Buzz (@buzz) on X",
|
||||
siteName: "X (formerly Twitter)",
|
||||
description:
|
||||
"This is a real tweet-style description long enough to wrap across several lines while preserving the message-like treatment. It keeps going with enough distinct words to exceed five rendered lines at the preview width, proving that the overflow-aware control appears only when the content is genuinely clipped rather than relying on a brittle character-count guess.",
|
||||
imageDataUrl:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1200' height='675'%3E%3Crect width='1200' height='675' fill='%231d9bf0'/%3E%3C/svg%3E",
|
||||
imageDomain: "pbs.twimg.com",
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
: testInfo.title.includes("link preview image geometry")
|
||||
? {
|
||||
linkPreviewMetadata: {
|
||||
title:
|
||||
"Ship a wider horizontal preview with a two-line title that wraps cleanly",
|
||||
siteName: "GitHub",
|
||||
description: "A polished, stable preview for shared links.",
|
||||
imageDataUrl:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='1600' height='120'%3E%3Crect width='1600' height='120' fill='%237c3aed'/%3E%3Ccircle cx='92' cy='60' r='48' fill='%23fff' fill-opacity='.9'/%3E%3C/svg%3E",
|
||||
imageDomain: "opengraph.githubassets.com",
|
||||
faviconDataUrl:
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
|
||||
},
|
||||
linkPreviewMetadataDelayMs: 800,
|
||||
}
|
||||
: testInfo.title.includes("link preview no-image collapse")
|
||||
? {
|
||||
linkPreviewMetadata: null,
|
||||
linkPreviewMetadataDelayMs: 2_000,
|
||||
}
|
||||
: undefined;
|
||||
await installMockBridge(page, mock);
|
||||
});
|
||||
|
||||
@@ -278,186 +300,176 @@ test("markdown tables overflow wide content and fill the message when narrow", a
|
||||
.toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("link preview image geometry stays stable while loading", async ({
|
||||
page,
|
||||
}) => {
|
||||
for (const width of [800, 420]) {
|
||||
const previewUrl = `https://github.com/block/buzz/pull/3246?viewport=${width}`;
|
||||
await page.setViewportSize({ width: 800, height: 700 });
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await page.setViewportSize({ width, height: 700 });
|
||||
await page.getByTestId("message-input").fill(previewUrl);
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
const card = page
|
||||
.getByTestId("message-row")
|
||||
.last()
|
||||
.locator('[data-link-preview="github-pull-request"]');
|
||||
await expect(card).toHaveAttribute("data-image-state", "pending");
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.locator("[data-link-preview-thumbnail]")).toBeVisible();
|
||||
if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) {
|
||||
await card.screenshot({
|
||||
animations: "disabled",
|
||||
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/${width}-pending.png`,
|
||||
});
|
||||
}
|
||||
const pending = await card.evaluate((element) => {
|
||||
const cardRect = element.getBoundingClientRect();
|
||||
const contentRect = element
|
||||
.querySelector('[data-slot="attachment-content"]')
|
||||
?.getBoundingClientRect();
|
||||
const thumbnailRect = element
|
||||
.querySelector("[data-link-preview-thumbnail]")
|
||||
?.getBoundingClientRect();
|
||||
return {
|
||||
height: cardRect.height,
|
||||
imageBottomInset: thumbnailRect
|
||||
? cardRect.bottom - thumbnailRect.bottom
|
||||
: undefined,
|
||||
imageLeftInset: thumbnailRect
|
||||
? thumbnailRect.left - cardRect.left
|
||||
: undefined,
|
||||
imageTopInset: thumbnailRect
|
||||
? thumbnailRect.top - cardRect.top
|
||||
: undefined,
|
||||
textInset: contentRect
|
||||
? Number.parseFloat(
|
||||
getComputedStyle(
|
||||
element.querySelector(
|
||||
'[data-slot="attachment-content"]',
|
||||
) as Element,
|
||||
).paddingLeft,
|
||||
)
|
||||
: undefined,
|
||||
titleHeight: element
|
||||
.querySelector('[data-slot="attachment-title"]')
|
||||
?.getBoundingClientRect().height,
|
||||
textLeft: contentRect?.left,
|
||||
thumbnailWidth: thumbnailRect?.width,
|
||||
width: cardRect.width,
|
||||
};
|
||||
});
|
||||
|
||||
await expect(card).toHaveAttribute("data-image-state", "image");
|
||||
if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) {
|
||||
await waitForAnimations(page);
|
||||
await card.screenshot({
|
||||
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/${width}-loaded.png`,
|
||||
});
|
||||
}
|
||||
const loaded = await card.evaluate((element) => ({
|
||||
height: element.getBoundingClientRect().height,
|
||||
titleHeight: element
|
||||
.querySelector('[data-slot="attachment-title"]')
|
||||
?.getBoundingClientRect().height,
|
||||
descriptionHeight: element
|
||||
.querySelector('[data-slot="attachment-description"]')
|
||||
?.getBoundingClientRect().height,
|
||||
textLeft: element
|
||||
.querySelector('[data-slot="attachment-content"]')
|
||||
?.getBoundingClientRect().left,
|
||||
thumbnailWidth: element
|
||||
.querySelector("[data-link-preview-thumbnail]")
|
||||
?.getBoundingClientRect().width,
|
||||
}));
|
||||
|
||||
expect(loaded.height).toBe(pending.height);
|
||||
expect(loaded.textLeft).toBe(pending.textLeft);
|
||||
expect(loaded.thumbnailWidth).toBe(pending.thumbnailWidth);
|
||||
expect(loaded.thumbnailWidth).toBe(width < 640 ? 112 : 128);
|
||||
expect(pending.height).toBe(80);
|
||||
expect(pending.width).toBe(width < 640 ? 325 : 360);
|
||||
expect(pending.titleHeight).toBe(20);
|
||||
expect(loaded.titleHeight).toBe(20);
|
||||
expect(loaded.descriptionHeight).toBe(16);
|
||||
await expect(card.locator("[data-link-preview-hostname]")).toHaveText(
|
||||
"github.com",
|
||||
);
|
||||
expect(pending.imageBottomInset).toBeCloseTo(1, 1);
|
||||
expect(pending.imageLeftInset).toBeCloseTo(1, 1);
|
||||
expect(pending.imageTopInset).toBeCloseTo(1, 1);
|
||||
expect(pending.textInset).toBe(12);
|
||||
}
|
||||
});
|
||||
|
||||
test("link preview image geometry constrains multiple cards and shows one remove control", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 1218, height: 700 });
|
||||
test("cardless short tweet preview omits show more", async ({ page }) => {
|
||||
const previewUrl = "https://x.com/jack/status/20";
|
||||
await page.setViewportSize({ width: 800, height: 700 });
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await page.getByTestId("message-input").fill(previewUrl);
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
const preview = page
|
||||
.getByTestId("message-row")
|
||||
.last()
|
||||
.locator("[data-tweet-preview]");
|
||||
await expect(preview).toContainText("just setting up my twttr");
|
||||
await expect(preview.getByRole("button", { name: "Show more" })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("cardless tweet preview clamps text and collapses its image", async ({
|
||||
page,
|
||||
}) => {
|
||||
const previewUrl = "https://x.com/buzz/status/1234567890";
|
||||
await page.setViewportSize({ width: 800, height: 900 });
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await page.getByTestId("message-input").fill(previewUrl);
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
const preview = page
|
||||
.getByTestId("message-row")
|
||||
.last()
|
||||
.locator("[data-tweet-preview]");
|
||||
await expect(preview).toBeVisible();
|
||||
await expect(preview).toHaveCSS("border-left-width", "3px");
|
||||
await expect(preview.locator("[data-link-preview-hostname]")).toHaveText(
|
||||
"x.com",
|
||||
);
|
||||
|
||||
const description = preview.locator('[data-slot="attachment-description"]');
|
||||
await expect(description).toHaveClass(/line-clamp-5/);
|
||||
expect(
|
||||
await description.evaluate((element) => element.clientHeight),
|
||||
).toBeLessThanOrEqual(100);
|
||||
const collapsedHeight = await description.evaluate(
|
||||
(element) => element.clientHeight,
|
||||
);
|
||||
await preview.getByRole("button", { name: "Show more" }).click();
|
||||
await expect(description).not.toHaveClass(/line-clamp-5/);
|
||||
await expect
|
||||
.poll(() => description.evaluate((element) => element.clientHeight))
|
||||
.toBeGreaterThan(collapsedHeight);
|
||||
await preview.getByRole("button", { name: "Show less" }).click();
|
||||
await expect(description).toHaveClass(/line-clamp-5/);
|
||||
|
||||
const thumbnail = preview.locator("[data-link-preview-thumbnail]");
|
||||
await expect(thumbnail).toBeVisible();
|
||||
await preview.getByRole("button", { name: "Hide image" }).click();
|
||||
await expect(thumbnail).toHaveCount(0);
|
||||
await preview.getByRole("button", { name: "Show image" }).click();
|
||||
await expect(thumbnail).toBeVisible();
|
||||
});
|
||||
|
||||
test("link preview image geometry stays stable in the inline variant while loading", async ({
|
||||
page,
|
||||
}) => {
|
||||
const previewUrl = "https://github.com/block/buzz/pull/3246?inline=1";
|
||||
await page.setViewportSize({ width: 800, height: 700 });
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await page.getByTestId("message-input").fill(previewUrl);
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
const preview = page
|
||||
.getByTestId("message-row")
|
||||
.last()
|
||||
.locator('[data-link-preview="github-pull-request"]');
|
||||
await expect(preview).toHaveAttribute("data-link-preview-inline", "");
|
||||
await expect(preview).toHaveAttribute("data-image-state", "pending");
|
||||
await expect(preview).toHaveCSS("border-left-width", "3px");
|
||||
await expect(preview.locator("[data-link-preview-favicon]")).toBeVisible();
|
||||
const pendingImage = await preview
|
||||
.locator("[data-link-preview-thumbnail]")
|
||||
.boundingBox();
|
||||
|
||||
await expect(preview).toHaveAttribute("data-image-state", "image");
|
||||
const loadedImage = await preview
|
||||
.locator("[data-link-preview-thumbnail]")
|
||||
.boundingBox();
|
||||
expect(loadedImage).toEqual(pendingImage);
|
||||
expect(loadedImage?.width).toBe(390);
|
||||
expect(loadedImage?.height).toBeCloseTo(204, 0);
|
||||
await expect(preview.locator("[data-link-preview-hostname]")).toHaveText(
|
||||
"github.com",
|
||||
);
|
||||
await preview.getByRole("button", { name: "Hide image" }).click();
|
||||
await expect(preview.locator("[data-link-preview-thumbnail]")).toHaveCount(0);
|
||||
await preview.getByRole("button", { name: "Show image" }).click();
|
||||
await expect(preview.locator("[data-link-preview-thumbnail]")).toBeVisible();
|
||||
});
|
||||
|
||||
test("multiple unfurled link previews stack vertically", async ({ page }) => {
|
||||
const previewUrls = [
|
||||
"https://github.com/block/buzz/pull/3246?card=1",
|
||||
"https://github.com/block/buzz/pull/3246?card=2",
|
||||
"https://github.com/block/buzz/pull/3246?card=3",
|
||||
"https://github.com/block/buzz/pull/3246?stack=1",
|
||||
"https://github.com/block/buzz/pull/3246?stack=2",
|
||||
"https://github.com/block/buzz/pull/3246?stack=3",
|
||||
];
|
||||
await page.setViewportSize({ width: 1218, height: 1000 });
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await page.getByTestId("message-input").fill(previewUrls.join(" "));
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
const row = page.getByTestId("message-row").last();
|
||||
const cards = row.locator('[data-link-preview="github-pull-request"]');
|
||||
await expect(cards).toHaveCount(3);
|
||||
const list = row.locator("[data-link-preview-list]");
|
||||
const previews = list.locator("[data-link-preview-inline]");
|
||||
await expect(previews).toHaveCount(3);
|
||||
await expect(list).toHaveCSS("flex-direction", "column");
|
||||
|
||||
for (const card of await cards.all()) {
|
||||
await expect(card).toHaveAttribute("data-image-state", "image");
|
||||
await expect(card).toHaveCSS("height", "80px");
|
||||
await expect(card.locator("[data-link-preview-thumbnail]")).toHaveCSS(
|
||||
"width",
|
||||
"128px",
|
||||
);
|
||||
}
|
||||
|
||||
const removeControls = row.getByRole("button", {
|
||||
const boxes = await previews.evaluateAll((elements) =>
|
||||
elements.map((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { left: rect.left, top: rect.top };
|
||||
}),
|
||||
);
|
||||
expect(boxes[1]?.left).toBeCloseTo(boxes[0]?.left ?? 0, 0);
|
||||
expect(boxes[2]?.left).toBeCloseTo(boxes[0]?.left ?? 0, 0);
|
||||
expect(boxes[1]?.top).toBeGreaterThan(boxes[0]?.top ?? 0);
|
||||
expect(boxes[2]?.top).toBeGreaterThan(boxes[1]?.top ?? 0);
|
||||
await expect(
|
||||
row.getByRole("button", { name: "Remove previews for everyone" }),
|
||||
).toHaveCount(1);
|
||||
const removeButton = row.getByRole("button", {
|
||||
name: "Remove previews for everyone",
|
||||
});
|
||||
await expect(removeControls).toHaveCount(1);
|
||||
await expect(
|
||||
cards.last().locator("..").getByRole("button"),
|
||||
).toHaveAccessibleName("Remove previews for everyone");
|
||||
expect((await removeButton.boundingBox())?.y).toBeLessThan(
|
||||
(await previews.nth(1).boundingBox())?.y ?? 0,
|
||||
);
|
||||
});
|
||||
|
||||
test("link preview no-image collapse restores the compact card", async ({
|
||||
test("link preview no-image collapse restores the inline text stack", async ({
|
||||
page,
|
||||
}) => {
|
||||
for (const width of [800, 420]) {
|
||||
const previewUrl = `https://github.com/block/buzz/pull/3246?viewport=${width}`;
|
||||
await page.setViewportSize({ width: 800, height: 700 });
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await page.setViewportSize({ width, height: 700 });
|
||||
await page.getByTestId("message-input").fill(previewUrl);
|
||||
await page.getByTestId("send-message").click();
|
||||
const previewUrl = "https://github.com/block/buzz/pull/3246?inline=none";
|
||||
await page.setViewportSize({ width: 800, height: 700 });
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await page.getByTestId("message-input").fill(previewUrl);
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
const card = page
|
||||
.getByTestId("message-row")
|
||||
.last()
|
||||
.locator('[data-link-preview="github-pull-request"]');
|
||||
await expect(card).toHaveAttribute("data-image-state", "pending");
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card).toHaveCSS("height", "80px");
|
||||
const pending = await card.evaluate((element) => ({
|
||||
height: element.getBoundingClientRect().height,
|
||||
textLeft: element
|
||||
.querySelector('[data-slot="attachment-content"]')
|
||||
?.getBoundingClientRect().left,
|
||||
}));
|
||||
|
||||
await expect(card).toHaveAttribute("data-image-state", "none");
|
||||
await expect(card.locator("[data-link-preview-thumbnail]")).toHaveCount(0);
|
||||
await expect(card.locator(".link-preview-media")).toBeVisible();
|
||||
const collapsed = await card.evaluate((element) => ({
|
||||
height: element.getBoundingClientRect().height,
|
||||
textLeft: element
|
||||
.querySelector('[data-slot="attachment-content"]')
|
||||
?.getBoundingClientRect().left,
|
||||
}));
|
||||
|
||||
expect(collapsed.height).toBeLessThan(pending.height);
|
||||
expect(collapsed.textLeft).toBeLessThan(pending.textLeft ?? 0);
|
||||
}
|
||||
const preview = page
|
||||
.getByTestId("message-row")
|
||||
.last()
|
||||
.locator('[data-link-preview="github-pull-request"]');
|
||||
await expect(preview).toHaveAttribute("data-image-state", "pending");
|
||||
await expect(preview.locator("[data-link-preview-thumbnail]")).toBeVisible();
|
||||
const pendingHeight = await preview.evaluate(
|
||||
(element) => element.getBoundingClientRect().height,
|
||||
);
|
||||
await expect(preview).toHaveAttribute("data-image-state", "none");
|
||||
await expect(preview.locator("[data-link-preview-thumbnail]")).toHaveCount(0);
|
||||
await expect(preview.locator("[data-link-preview-hostname]")).toHaveText(
|
||||
"github.com",
|
||||
);
|
||||
await expect(preview.locator("[data-link-preview-favicon]")).toHaveCount(0);
|
||||
await expect(preview.locator("[data-link-preview-identity] svg")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
expect(
|
||||
await preview.evaluate((element) => element.getBoundingClientRect().height),
|
||||
).toBeLessThan(pendingHeight);
|
||||
});
|
||||
|
||||
test("supported link previews keep the message link visible", async ({
|
||||
@@ -478,8 +490,9 @@ test("supported link previews keep the message link visible", async ({
|
||||
).toBeVisible();
|
||||
const previewCard = row.locator('[data-link-preview="github-pull-request"]');
|
||||
await expect(previewCard).toBeVisible();
|
||||
await expectCornerRadiusPx(previewCard, 16);
|
||||
await expectSmoothCorners(previewCard);
|
||||
await expect(previewCard).toHaveAttribute("data-link-preview-inline", "");
|
||||
await expect(previewCard).toHaveCSS("border-left-width", "3px");
|
||||
await expect(previewCard).toHaveCSS("border-top-left-radius", "0px");
|
||||
});
|
||||
|
||||
test("send multiple messages in sequence", async ({ page }) => {
|
||||
|
||||
@@ -276,6 +276,7 @@ type MockBridgeOptions = {
|
||||
description: string | null;
|
||||
imageDataUrl: string | null;
|
||||
imageDomain: string | null;
|
||||
faviconDataUrl?: string | null;
|
||||
} | null;
|
||||
linkPreviewMetadataDelayMs?: number;
|
||||
searchProfiles?: MockSearchProfileSeed[];
|
||||
|
||||
Reference in New Issue
Block a user