mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
feat(composer): add draft-wide preview suppression
Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
parent
9b1f09b36e
commit
b602548a96
@@ -169,6 +169,7 @@ fn validate_link_preview_tags(event: &Event, media_base_url: &str) -> Result<(),
|
||||
const MAX_DESCRIPTION: usize = 1000;
|
||||
|
||||
let mut count = 0;
|
||||
let mut suppressed = false;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for tag in event.tags.iter() {
|
||||
let parts = tag.as_slice();
|
||||
@@ -176,7 +177,19 @@ fn validate_link_preview_tags(event: &Event, media_base_url: &str) -> Result<(),
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
if count > MAX_SNAPSHOTS || parts.len() != 11 || parts[1] != "snapshot" || parts[2] != "1" {
|
||||
if parts == ["link-preview", "none"] {
|
||||
if count > 1 {
|
||||
return Err("link-preview suppression cannot include snapshots".into());
|
||||
}
|
||||
suppressed = true;
|
||||
continue;
|
||||
}
|
||||
if suppressed
|
||||
|| count > MAX_SNAPSHOTS
|
||||
|| parts.len() != 11
|
||||
|| parts[1] != "snapshot"
|
||||
|| parts[2] != "1"
|
||||
{
|
||||
return Err("invalid link-preview snapshot tag".into());
|
||||
}
|
||||
let canonical =
|
||||
@@ -3549,6 +3562,55 @@ mod tests {
|
||||
assert!(validate_diff_event(&event).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_preview_suppression_accepts_blanket_marker() {
|
||||
let event = make_event_with_tags(
|
||||
KIND_STREAM_MESSAGE,
|
||||
"https://example.com",
|
||||
&[&["link-preview", "none"]],
|
||||
);
|
||||
|
||||
assert!(validate_link_preview_tags(&event, "https://media.example.com").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_preview_suppression_rejects_duplicate_marker() {
|
||||
let event = make_event_with_tags(
|
||||
KIND_STREAM_MESSAGE,
|
||||
"https://example.com",
|
||||
&[&["link-preview", "none"], &["link-preview", "none"]],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
validate_link_preview_tags(&event, "https://media.example.com"),
|
||||
Err("link-preview suppression cannot include snapshots".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_preview_suppression_rejects_mixed_snapshot_tags_in_either_order() {
|
||||
let snapshot = [
|
||||
"link-preview",
|
||||
"snapshot",
|
||||
"1",
|
||||
"https://example.com",
|
||||
"Example",
|
||||
"Example",
|
||||
"Description",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
];
|
||||
for tags in [
|
||||
vec![&["link-preview", "none"][..], &snapshot[..]],
|
||||
vec![&snapshot[..], &["link-preview", "none"][..]],
|
||||
] {
|
||||
let event = make_event_with_tags(KIND_STREAM_MESSAGE, "https://example.com", &tags);
|
||||
assert!(validate_link_preview_tags(&event, "https://media.example.com").is_err());
|
||||
}
|
||||
}
|
||||
|
||||
fn make_dummy_event() -> Event {
|
||||
let keys = nostr::Keys::generate();
|
||||
nostr::EventBuilder::new(nostr::Kind::Custom(9), "")
|
||||
|
||||
@@ -57,6 +57,16 @@ pub fn append(
|
||||
let base = url::Url::parse(relay_base).map_err(|_| "invalid relay base URL")?;
|
||||
let mut seen = HashSet::new();
|
||||
for preview_tag in preview_tags {
|
||||
if preview_tag.as_slice() == ["link-preview", "none"] {
|
||||
if preview_tags.len() != 1 {
|
||||
return Err("link-preview suppression cannot include snapshots".into());
|
||||
}
|
||||
tags.push(
|
||||
Tag::parse(["link-preview", "none"])
|
||||
.map_err(|e| format!("invalid link-preview tag: {e}"))?,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let valid = preview_tag.len() == 11
|
||||
&& preview_tag[0] == "link-preview"
|
||||
&& preview_tag[1] == "snapshot"
|
||||
@@ -119,6 +129,24 @@ mod tests {
|
||||
assert_eq!(tags.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_accepts_blanket_suppression_by_itself() {
|
||||
let mut tags = Vec::new();
|
||||
append(
|
||||
&[vec!["link-preview".into(), "none".into()]],
|
||||
BASE,
|
||||
&mut tags,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(tags[0].as_slice(), ["link-preview", "none"]);
|
||||
assert!(append(
|
||||
&[vec!["link-preview".into(), "none".into()], tag("", ""),],
|
||||
BASE,
|
||||
&mut Vec::new(),
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_rejects_untrusted_or_malformed_snapshot_media() {
|
||||
for url in [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { ImageOff, LoaderCircle, X } from "lucide-react";
|
||||
|
||||
import { getRelayHttpUrl, uploadMediaBytes } from "@/shared/api/tauri";
|
||||
import { extractSupportedLinkPreviews } from "@/shared/lib/linkPreview";
|
||||
@@ -7,9 +8,122 @@ import {
|
||||
beginRelayOriginFetch,
|
||||
getCachedRelayOrigin,
|
||||
} from "@/shared/lib/mediaUrl";
|
||||
import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews";
|
||||
import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews";
|
||||
import { LinkPreviewList } from "@/shared/ui/link-preview-list";
|
||||
import { LinkPreviewImageLightbox } from "@/shared/ui/markdown";
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentAction,
|
||||
AttachmentActions,
|
||||
AttachmentContent,
|
||||
AttachmentDescription,
|
||||
AttachmentGroup,
|
||||
AttachmentMedia,
|
||||
AttachmentTitle,
|
||||
AttachmentTrigger,
|
||||
} from "@/shared/ui/attachment";
|
||||
|
||||
function previewHostname(href: string): string {
|
||||
try {
|
||||
return new URL(href).hostname.replace(/^www\./, "");
|
||||
} catch {
|
||||
return href;
|
||||
}
|
||||
}
|
||||
|
||||
function ComposerLinkPreviewCard({
|
||||
onHideAll,
|
||||
preview,
|
||||
showHideControl,
|
||||
}: {
|
||||
onHideAll: () => void;
|
||||
preview: ResolvedLinkPreview;
|
||||
showHideControl: boolean;
|
||||
}) {
|
||||
const imageSrc = preview.imageState === "image" ? preview.imageDataUrl : null;
|
||||
const [failedImageSrc, setFailedImageSrc] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const showImage = Boolean(imageSrc && failedImageSrc !== imageSrc);
|
||||
const hostname = previewHostname(preview.href);
|
||||
let path = "";
|
||||
try {
|
||||
const url = new URL(preview.href);
|
||||
path = `${url.pathname}${url.search}`;
|
||||
} catch {}
|
||||
|
||||
return (
|
||||
<Attachment
|
||||
className="h-[55px] w-80 max-w-full gap-2 overflow-hidden p-0 pr-2 shadow-none"
|
||||
data-image-state={preview.imageState}
|
||||
data-link-preview={preview.kind}
|
||||
data-link-preview-composer-card=""
|
||||
state={preview.snapshotReady ? "done" : "processing"}
|
||||
>
|
||||
<AttachmentMedia
|
||||
className="h-[55px] w-[55px] rounded-none rounded-l-2xl bg-muted"
|
||||
data-link-preview-thumbnail=""
|
||||
variant="image"
|
||||
>
|
||||
{showImage ? (
|
||||
<img
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
onError={() => setFailedImageSrc(imageSrc ?? null)}
|
||||
src={imageSrc ?? undefined}
|
||||
/>
|
||||
) : preview.imageState === "pending" ? (
|
||||
<LoaderCircle
|
||||
aria-label="Loading link preview"
|
||||
className="size-4 animate-spin"
|
||||
/>
|
||||
) : preview.faviconDataUrl ? (
|
||||
<img
|
||||
alt=""
|
||||
className="size-7 rounded-md object-contain opacity-70"
|
||||
src={preview.faviconDataUrl}
|
||||
/>
|
||||
) : (
|
||||
<ImageOff aria-hidden="true" className="size-4 opacity-60" />
|
||||
)}
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<AttachmentTitle className="line-clamp-1" data-link-preview-hostname="">
|
||||
{preview.snapshotReady ? preview.title : hostname}
|
||||
</AttachmentTitle>
|
||||
<AttachmentDescription>
|
||||
{preview.snapshotReady
|
||||
? preview.provider || hostname
|
||||
: path && path !== "/"
|
||||
? path
|
||||
: preview.typeLabel}
|
||||
</AttachmentDescription>
|
||||
</AttachmentContent>
|
||||
{showHideControl ? (
|
||||
<AttachmentActions>
|
||||
<AttachmentAction
|
||||
aria-label="Hide all link previews"
|
||||
data-testid="composer-hide-link-previews"
|
||||
onClick={onHideAll}
|
||||
title="Hide previews"
|
||||
type="button"
|
||||
>
|
||||
<X />
|
||||
</AttachmentAction>
|
||||
</AttachmentActions>
|
||||
) : null}
|
||||
<AttachmentTrigger asChild>
|
||||
<a
|
||||
aria-label={`Open ${preview.title}`}
|
||||
href={preview.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="sr-only">Open {preview.title}</span>
|
||||
</a>
|
||||
</AttachmentTrigger>
|
||||
</Attachment>
|
||||
);
|
||||
}
|
||||
|
||||
function dataUrlBytes(dataUrl: string): Uint8Array | null {
|
||||
const match = /^data:([^;,]+);base64,([A-Za-z0-9+/=]+)$/.exec(dataUrl);
|
||||
@@ -33,11 +147,15 @@ async function uploadDataUrl(
|
||||
}
|
||||
|
||||
export function useComposerLinkPreviews(content: string) {
|
||||
const [suppressed, setSuppressed] = React.useState(false);
|
||||
const candidates = React.useMemo(
|
||||
() => extractSupportedLinkPreviews(content),
|
||||
[content],
|
||||
);
|
||||
const previews = useResolvedLinkPreviews(candidates);
|
||||
const previews = useResolvedLinkPreviews(suppressed ? [] : candidates);
|
||||
React.useEffect(() => {
|
||||
if (candidates.length === 0) setSuppressed(false);
|
||||
}, [candidates.length]);
|
||||
const [readyTags, setReadyTags] = React.useState<Record<string, string[]>>(
|
||||
{},
|
||||
);
|
||||
@@ -95,19 +213,28 @@ export function useComposerLinkPreviews(content: string) {
|
||||
}
|
||||
}, [previews, readyTags]);
|
||||
|
||||
readyTagsRef.current = candidates.flatMap((candidate) =>
|
||||
readyTags[candidate.href] ? [readyTags[candidate.href]] : [],
|
||||
);
|
||||
readyTagsRef.current = suppressed
|
||||
? [["link-preview", "none"]]
|
||||
: candidates.flatMap((candidate) =>
|
||||
readyTags[candidate.href] ? [readyTags[candidate.href]] : [],
|
||||
);
|
||||
const hideAll = React.useCallback(() => setSuppressed(true), []);
|
||||
const previewList = previews.length ? (
|
||||
<div
|
||||
className="message-markdown mb-2"
|
||||
className="mb-2"
|
||||
data-composer-link-previews=""
|
||||
data-ready-snapshot-count={readyTagsRef.current.length}
|
||||
>
|
||||
<LinkPreviewList
|
||||
ImageLightbox={LinkPreviewImageLightbox}
|
||||
previews={previews}
|
||||
/>
|
||||
<AttachmentGroup className="max-w-full flex-row flex-wrap items-start overflow-visible pb-0">
|
||||
{previews.map((preview, index) => (
|
||||
<ComposerLinkPreviewCard
|
||||
key={preview.href}
|
||||
onHideAll={hideAll}
|
||||
preview={preview}
|
||||
showHideControl={index === 0}
|
||||
/>
|
||||
))}
|
||||
</AttachmentGroup>
|
||||
</div>
|
||||
) : null;
|
||||
const getReadyTags = React.useCallback(() => readyTagsRef.current, []);
|
||||
|
||||
@@ -218,7 +218,8 @@ test.beforeEach(async ({ page }, testInfo) => {
|
||||
},
|
||||
linkPreviewMetadataDelayMs:
|
||||
testInfo.title.includes("style defaults") ||
|
||||
testInfo.title.includes("send does not wait")
|
||||
testInfo.title.includes("send does not wait") ||
|
||||
testInfo.title.includes("attachment-sized")
|
||||
? 1_500
|
||||
: undefined,
|
||||
}
|
||||
@@ -504,7 +505,41 @@ test("send does not wait for a pending link preview snapshot", async ({
|
||||
expect(linkPreviewTags ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
test("link preview image geometry stays stable while loading", async ({
|
||||
test("hiding composer previews suppresses the whole draft and emits the blanket marker", async ({
|
||||
page,
|
||||
}) => {
|
||||
const firstUrl = "https://github.com/block/buzz/pull/3246?hide=all";
|
||||
const secondUrl = "https://linear.app/acme/issue/ABC-123/hidden-too";
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await page.getByTestId("message-input").fill(firstUrl);
|
||||
await expect(page.locator("[data-composer-link-previews]")).toBeVisible();
|
||||
|
||||
await page.getByTestId("composer-hide-link-previews").click();
|
||||
await expect(page.locator("[data-composer-link-previews]")).toHaveCount(0);
|
||||
await page.getByTestId("message-input").fill(`${firstUrl} ${secondUrl}`);
|
||||
await expect(page.locator("[data-composer-link-previews]")).toHaveCount(0);
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
const row = page.getByTestId("message-row").last();
|
||||
await expect(row).toContainText(firstUrl);
|
||||
await expect(row).toContainText(secondUrl);
|
||||
await expect(row.locator("[data-link-preview]")).toHaveCount(0);
|
||||
const linkPreviewTags = await page.evaluate(() => {
|
||||
const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])]
|
||||
.reverse()
|
||||
.find((entry) => entry.command === "send_channel_message");
|
||||
return (
|
||||
call?.payload as { linkPreviewTags?: string[][] | null } | undefined
|
||||
)?.linkPreviewTags;
|
||||
});
|
||||
expect(linkPreviewTags).toEqual([["link-preview", "none"]]);
|
||||
|
||||
await page.getByTestId("message-input").fill(firstUrl);
|
||||
await expect(page.locator("[data-composer-link-previews]")).toBeVisible();
|
||||
});
|
||||
|
||||
test("composer link preview embeds stay attachment-sized while loading and ready", async ({
|
||||
page,
|
||||
}) => {
|
||||
const previewUrl = "https://github.com/block/buzz/pull/3246";
|
||||
@@ -520,32 +555,10 @@ test("link preview image geometry stays stable while loading", async ({
|
||||
const card = page
|
||||
.locator("[data-composer-link-previews]")
|
||||
.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 waitForAnimations(page);
|
||||
await card.screenshot({
|
||||
animations: "disabled",
|
||||
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/${width}-pending.png`,
|
||||
});
|
||||
}
|
||||
const pending = await card.evaluate((element) => ({
|
||||
const initial = await card.evaluate((element) => ({
|
||||
height: element.getBoundingClientRect().height,
|
||||
width: element.getBoundingClientRect().width,
|
||||
textLeft: element
|
||||
.querySelector('[data-slot="attachment-content"]')
|
||||
?.getBoundingClientRect().left,
|
||||
textInset: (() => {
|
||||
const content = element.querySelector("[data-link-preview-hostname]");
|
||||
const thumbnail = element.querySelector(
|
||||
"[data-link-preview-thumbnail]",
|
||||
);
|
||||
return content && thumbnail
|
||||
? content.getBoundingClientRect().left -
|
||||
thumbnail.getBoundingClientRect().right
|
||||
: undefined;
|
||||
})(),
|
||||
thumbnailHeight: element
|
||||
.querySelector("[data-link-preview-thumbnail]")
|
||||
?.getBoundingClientRect().height,
|
||||
@@ -553,26 +566,22 @@ test("link preview image geometry stays stable while loading", async ({
|
||||
.querySelector("[data-link-preview-thumbnail]")
|
||||
?.getBoundingClientRect().width,
|
||||
}));
|
||||
|
||||
await expect(card).toHaveAttribute("data-image-state", "image");
|
||||
await expect(
|
||||
card.locator("[data-link-preview-hostname-favicon]"),
|
||||
).toHaveAttribute("src", /data:image\/png;base64/);
|
||||
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`,
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/composer-${width}-loading.png`,
|
||||
});
|
||||
}
|
||||
const loaded = await card.evaluate((element) => ({
|
||||
description: element.querySelector('[data-slot="attachment-description"]')
|
||||
?.textContent,
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
card.evaluate((element) => element.getAttribute("data-state")),
|
||||
)
|
||||
.toBe("done");
|
||||
const ready = await card.evaluate((element) => ({
|
||||
height: element.getBoundingClientRect().height,
|
||||
titleClass: element.querySelector('[data-slot="attachment-title"]')
|
||||
?.className,
|
||||
textLeft: element
|
||||
.querySelector('[data-slot="attachment-content"]')
|
||||
?.getBoundingClientRect().left,
|
||||
width: element.getBoundingClientRect().width,
|
||||
thumbnailHeight: element
|
||||
.querySelector("[data-link-preview-thumbnail]")
|
||||
?.getBoundingClientRect().height,
|
||||
@@ -580,81 +589,38 @@ test("link preview image geometry stays stable while loading", async ({
|
||||
.querySelector("[data-link-preview-thumbnail]")
|
||||
?.getBoundingClientRect().width,
|
||||
}));
|
||||
if (process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR) {
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
path: `${process.env.BUZZ_LINK_PREVIEW_SCREENSHOTS_DIR}/composer-${width}-ready.png`,
|
||||
});
|
||||
}
|
||||
|
||||
expect(pending.width).toBe(width < 640 ? 345 : 384);
|
||||
expect(pending.height).toBe(84);
|
||||
expect(pending.textInset).toBe(8);
|
||||
expect(pending.thumbnailHeight).toBe(84);
|
||||
expect(loaded.height).toBe(pending.height);
|
||||
expect(loaded.textLeft).toBe(pending.textLeft);
|
||||
expect(loaded.thumbnailHeight).toBe(pending.thumbnailHeight);
|
||||
expect(loaded.thumbnailWidth).toBe(pending.thumbnailWidth);
|
||||
expect(loaded.thumbnailWidth).toBe(width < 640 ? 120 : 136);
|
||||
expect(loaded.titleClass).toContain("line-clamp-2");
|
||||
expect(loaded.description).toBe(
|
||||
"A polished, stable preview for shared links.",
|
||||
);
|
||||
expect(initial.height).toBe(55);
|
||||
expect(initial.width).toBe(320);
|
||||
expect(initial.thumbnailHeight).toBe(55);
|
||||
expect(initial.thumbnailWidth).toBe(55);
|
||||
expect(ready).toEqual(initial);
|
||||
}
|
||||
});
|
||||
|
||||
test("link preview no-image layout keeps compact height", async ({ page }) => {
|
||||
test("composer no-image link embeds keep the attachment footprint", async ({
|
||||
page,
|
||||
}) => {
|
||||
const previewUrl = "https://github.com/block/buzz/pull/3246?inline=none";
|
||||
|
||||
for (const width of [800, 420]) {
|
||||
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}&viewport=${width}`);
|
||||
const card = page
|
||||
.locator("[data-composer-link-previews]")
|
||||
.locator('[data-link-preview="github-pull-request"]');
|
||||
await expect(card).toHaveAttribute("data-image-state", "pending");
|
||||
await expect(card).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
card.evaluate((element) => element.getBoundingClientRect().height),
|
||||
)
|
||||
.toBe(84);
|
||||
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('[data-slot="attachment-media"]')).toHaveCount(0);
|
||||
const hostnameLink = card.locator("[data-link-preview-hostname]");
|
||||
await expect(hostnameLink).toHaveText("github.com");
|
||||
await expect(hostnameLink).toHaveAttribute(
|
||||
"href",
|
||||
`${previewUrl}&viewport=${width}`,
|
||||
);
|
||||
await expect(
|
||||
card.locator("[data-link-preview-hostname-favicon]"),
|
||||
).toHaveAttribute("src", /data:image\/png;base64/);
|
||||
await expect(
|
||||
card.locator('[data-slot="attachment-description"]'),
|
||||
).toHaveText("Open-source collaboration for the Buzz app.");
|
||||
await expect(card).toHaveCSS("border-left-width", "3px");
|
||||
const title = card.locator('[data-slot="attachment-title"]');
|
||||
await card.hover();
|
||||
await expect(hostnameLink).toHaveCSS("text-decoration-line", "underline");
|
||||
await expect(title).toHaveCSS("text-decoration-line", "underline");
|
||||
const resolved = await card.evaluate((element) => ({
|
||||
height: element.getBoundingClientRect().height,
|
||||
textLeft: element
|
||||
.querySelector('[data-slot="attachment-content"]')
|
||||
?.getBoundingClientRect().left,
|
||||
}));
|
||||
|
||||
expect(resolved.height).toBe(pending.height);
|
||||
expect(resolved.textLeft).toBeLessThan(pending.textLeft ?? 0);
|
||||
}
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
await page.getByTestId("message-input").fill(previewUrl);
|
||||
const card = page
|
||||
.locator("[data-composer-link-previews]")
|
||||
.locator('[data-link-preview="github-pull-request"]');
|
||||
await expect(card).toHaveAttribute("data-image-state", "none");
|
||||
await expect(card.locator("[data-link-preview-thumbnail]")).toBeVisible();
|
||||
await expect(card.locator('[data-slot="attachment-title"]')).toContainText(
|
||||
/github\.com|Buzz/,
|
||||
);
|
||||
await expect(card).toHaveCSS("height", "55px");
|
||||
});
|
||||
|
||||
test("mixed link preview image outcomes keep Compact and Rich fallbacks stable", async ({
|
||||
|
||||
Reference in New Issue
Block a user