Polish channel rows and split message media

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-18 16:47:12 +01:00
parent 95d44a25b9
commit 789329c9c2
11 changed files with 527 additions and 49 deletions

View File

@@ -51,3 +51,89 @@ test("text-only submissions remain one text event", () => {
{ content: "hello", kind: "text", outgoingTags: undefined },
]);
});
test("mixed link submissions put the link preview before remaining text", () => {
const previewTag = [
"link-preview",
"snapshot",
"1",
"https://example.com/launch",
];
const emojiTag = ["emoji", "ship", "https://relay.example/ship.png"];
const parts = buildOutgoingMessageParts({
body: "Launch notes https://example.com/launch are ready :ship:",
media: [],
nowSeconds: 100,
textTags: [previewTag, emojiTag],
});
assert.deepEqual(parts, [
{
content: "https://example.com/launch",
createdAt: 99,
kind: "link",
outgoingTags: [previewTag],
},
{
content: "Launch notes are ready :ship:",
kind: "text",
outgoingTags: [emojiTag],
},
]);
});
test("attachment, link, and prose events keep media-first ordering", () => {
const parts = buildOutgoingMessageParts({
body: "Context for https://example.com/launch",
media: [image],
nowSeconds: 100,
});
assert.equal(parts.length, 3);
assert.equal(parts[0].kind, "media");
assert.equal(parts[0].createdAt, 98);
assert.equal(parts[1].kind, "link");
assert.equal(parts[1].createdAt, 99);
assert.equal(parts[2].kind, "text");
assert.equal(parts[2].createdAt, undefined);
});
test("link-only submissions remain one link event", () => {
assert.deepEqual(
buildOutgoingMessageParts({
body: "https://example.com/launch",
media: [],
nowSeconds: 100,
}),
[
{
content: "https://example.com/launch",
kind: "link",
outgoingTags: undefined,
},
],
);
});
test("markdown link labels remain readable in the prose event", () => {
assert.deepEqual(
buildOutgoingMessageParts({
body: "Please review [the launch plan](https://example.com/launch).",
media: [],
nowSeconds: 100,
}),
[
{
content: "https://example.com/launch",
createdAt: 99,
kind: "link",
outgoingTags: undefined,
},
{
content: "Please review the launch plan.",
kind: "text",
outgoingTags: undefined,
},
],
);
});

View File

@@ -1,15 +1,17 @@
import { buildOutgoingMessage, type ImetaMedia } from "./imetaMediaMarkdown";
import { splitOutgoingLinkContent } from "@/shared/lib/linkPreview";
export type OutgoingMessagePart = {
content: string;
createdAt?: number;
kind: "media" | "text";
kind: "link" | "media" | "text";
outgoingTags: string[][] | undefined;
};
/**
* Build the ordered events for one composer submission. Attachments remain
* together in a media-only event and precede any authored text event.
* together in a media-only event, authored links move to a link-only event,
* and both precede any remaining authored text event.
*/
export function buildOutgoingMessageParts({
body,
@@ -25,28 +27,45 @@ export function buildOutgoingMessageParts({
textTags?: string[][];
}): OutgoingMessagePart[] {
const parts: OutgoingMessagePart[] = [];
const hasText = body.trim().length > 0;
const { linkContent, textContent } = splitOutgoingLinkContent(body);
const linkTags = textTags.filter((tag) => tag[0] === "link-preview");
const remainingTextTags = textTags.filter((tag) => tag[0] !== "link-preview");
if (media.length > 0) {
const mediaMessage = buildOutgoingMessage("", media, spoileredMediaUrls);
parts.push({
content: mediaMessage.content,
// Relay events sort by whole-second timestamps and then event id. Give
// the media half of a mixed submission the immediately preceding second
// so every client renders it first without delaying either network send.
createdAt: hasText ? Math.max(0, nowSeconds - 1) : undefined,
kind: "media",
outgoingTags: mediaMessage.mediaTags,
});
}
if (hasText) {
if (linkContent) {
parts.push({
content: body,
kind: "text",
outgoingTags: textTags.length > 0 ? textTags : undefined,
content: linkContent,
kind: "link",
outgoingTags: linkTags.length > 0 ? linkTags : undefined,
});
}
return parts;
if (textContent) {
parts.push({
content: textContent,
kind: "text",
outgoingTags:
remainingTextTags.length > 0 ? remainingTextTags : undefined,
});
}
// Relay events sort by whole-second timestamps and then event id. Backdate
// every part except the final one by one additional second so attachment →
// link → prose order is stable without delaying any network send.
return parts.map((part, index) =>
index < parts.length - 1
? {
...part,
createdAt: Math.max(0, nowSeconds - (parts.length - 1 - index)),
}
: part,
);
}

View File

@@ -493,7 +493,15 @@ export function useMentionSendFlow({
);
const send = onSendRef.current;
let mediaMessageSent = false;
const persistCanceledDraft = (withoutMedia = false) => {
let linkMessageSent = false;
let remainingTextContent = draft.savedContent;
const persistCanceledDraft = ({
content = draft.savedContent,
withoutMedia = false,
}: {
content?: string;
withoutMedia?: boolean;
} = {}) => {
if (isSendCancelled() || !draft.recoveryDraftKey) return;
const existing = drafts.loadDraft(draft.recoveryDraftKey);
if (
@@ -510,16 +518,22 @@ export function useMentionSendFlow({
}
drafts.persistDraft(
draft.recoveryDraftKey,
draft.savedContent,
content,
draft.capturedChannelId ?? draft.recoveryDraftKey,
withoutMedia ? [] : draft.savedImeta,
withoutMedia ? [] : [...draft.savedSpoileredAttachmentUrls],
draft.savedMentionRefs,
);
};
const restoreComposerAfterFailure = (withoutMedia = false) => {
const restoreComposerAfterFailure = ({
content = draft.savedContent,
withoutMedia = false,
}: {
content?: string;
withoutMedia?: boolean;
} = {}) => {
if (isSendCancelled()) return;
persistCanceledDraft(withoutMedia);
persistCanceledDraft({ content, withoutMedia });
const canRestoreCurrentComposer =
isMountedRef.current &&
(draft.capturedChannelId === channelIdRef.current ||
@@ -535,9 +549,9 @@ export function useMentionSendFlow({
if (!canRestoreCurrentComposer) {
return;
}
setContent(draft.savedContent);
contentRef.current = draft.savedContent;
richText.setContent(draft.savedContent);
setContent(content);
contentRef.current = content;
richText.setContent(content);
setPendingImeta(withoutMedia ? [] : draft.savedImeta);
restoreQueuedAttachments(withoutMedia ? [] : draft.queuedAttachments);
mentions.restoreDraftMentionRefs(draft.savedMentionRefs);
@@ -564,6 +578,8 @@ export function useMentionSendFlow({
]),
textTags,
});
remainingTextContent =
parts.find((part) => part.kind === "text")?.content ?? "";
const revalidatedMentionPubkeys =
await mentions.revalidateMentionPubkeys(mentionPubkeys);
if (signal?.aborted || isSendCancelled()) return;
@@ -583,6 +599,7 @@ export function useMentionSendFlow({
draft.preparedLinkPreviews != null,
);
if (part.kind === "media") mediaMessageSent = true;
if (part.kind === "link") linkMessageSent = true;
if (signal?.aborted || isSendCancelled()) return;
}
if (signal?.aborted || isSendCancelled()) return;
@@ -610,7 +627,12 @@ export function useMentionSendFlow({
try {
await finishSend(uploaded, signal);
} catch {
restoreComposerAfterFailure(mediaMessageSent);
restoreComposerAfterFailure({
content: linkMessageSent
? remainingTextContent
: draft.savedContent,
withoutMedia: mediaMessageSent,
});
}
},
onError: (error) => {
@@ -620,7 +642,12 @@ export function useMentionSendFlow({
);
},
onCancel: () => {
restoreComposerAfterFailure(mediaMessageSent);
restoreComposerAfterFailure({
content: linkMessageSent
? remainingTextContent
: draft.savedContent,
withoutMedia: mediaMessageSent,
});
},
});
if (!uploadStarted) {
@@ -640,7 +667,12 @@ export function useMentionSendFlow({
try {
await finishSend([]);
} catch {
restoreComposerAfterFailure(mediaMessageSent);
restoreComposerAfterFailure({
content: linkMessageSent
? remainingTextContent
: draft.savedContent,
withoutMedia: mediaMessageSent,
});
}
}
} finally {

View File

@@ -306,7 +306,8 @@ export function ChannelMenuButton({
<SidebarMenuButton
className={cn(
"data-[active=true]:font-normal",
!isDm && "h-12 gap-2 p-1 pr-2 group-data-[collapsible=icon]:!p-0",
!isDm &&
"h-12 gap-2 rounded-2xl p-1 pr-2 group-data-[collapsible=icon]:!p-0 group-data-[collapsible=icon]:rounded-md",
isActive
? "group-hover/menu-item:bg-sidebar-active group-hover/menu-item:text-sidebar-active-foreground"
: "group-hover/menu-item:bg-sidebar-accent group-hover/menu-item:text-sidebar-foreground",
@@ -331,14 +332,13 @@ export function ChannelMenuButton({
<span
aria-hidden="true"
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-xl bg-sidebar-border/55 text-sidebar-foreground/65 transition-colors group-data-[collapsible=icon]:size-8 group-data-[collapsible=icon]:rounded-md group-data-[collapsible=icon]:bg-transparent",
isActive &&
"bg-sidebar-active-foreground/10 text-sidebar-active-foreground",
"flex size-10 shrink-0 items-center justify-center rounded-xl bg-background text-sidebar-foreground/65 transition-colors group-data-[collapsible=icon]:size-8 group-data-[collapsible=icon]:rounded-md group-data-[collapsible=icon]:bg-transparent",
isActive && "text-sidebar-active-foreground",
inactiveContentOpacity,
)}
data-testid={`channel-icon-container-${channel.name}`}
>
<SidebarChannelIcon channel={channel} className="size-6" />
<SidebarChannelIcon channel={channel} className="size-4" />
</span>
)}
{isDm ? (

View File

@@ -5,6 +5,7 @@ import {
extractSupportedLinkPreviews,
isSupportedLinkAutolinkLabel,
parseSupportedLinkPreview,
splitOutgoingLinkContent,
} from "./linkPreview.ts";
test("parseSupportedLinkPreview parses GitHub pull request URLs", () => {
@@ -559,3 +560,47 @@ test("extractSupportedLinkPreviews finds generic links and preserves exclusions"
],
);
});
test("splitOutgoingLinkContent separates visible links from prose", () => {
assert.deepEqual(
splitOutgoingLinkContent(
"Review [the plan](https://example.com/plan), then share <https://example.com/launch>.",
),
{
linkContent: "https://example.com/plan\n<https://example.com/launch>",
textContent: "Review the plan, then share.",
},
);
});
test("splitOutgoingLinkContent includes links without rich previews", () => {
assert.deepEqual(
splitOutgoingLinkContent(
"Source https://github.com/block/buzz/blob/main/README.md and http://example.com/docs",
),
{
linkContent:
"https://github.com/block/buzz/blob/main/README.md\nhttp://example.com/docs",
textContent: "Source and",
},
);
});
test("splitOutgoingLinkContent preserves hidden and image links", () => {
const content = [
"Visible https://example.com/visible",
"`https://example.com/code`",
"||https://example.com/spoiler||",
"![image](https://example.com/image.png)",
].join("\n");
assert.deepEqual(splitOutgoingLinkContent(content), {
linkContent: "https://example.com/visible",
textContent: [
"Visible",
"`https://example.com/code`",
"||https://example.com/spoiler||",
"![image](https://example.com/image.png)",
].join("\n"),
});
});

View File

@@ -52,6 +52,10 @@ const SUPPORTED_URL_RE =
/(^|[\s([{<>"'])(https:\/\/[^\s<>"'\]]+|https?:\/\/[^\s<>"'\]]+\/git\/[a-f0-9]{64}\/[^\s<>"'\]]+|buzz:\/\/(?:pr|issue|repo|project)\?[^\s<>"'\]]+|(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+)/gi;
const MARKDOWN_SUPPORTED_LINK_RE =
/!?\[([^\]\n]+)\]\((https:\/\/[^)\s<>"']+|https?:\/\/[^)\s<>"']+\/git\/[a-f0-9]{64}\/[^)\s<>"']+|buzz:\/\/(?:pr|issue|repo|project)\?[^)\s<>"']+|(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+)\)/gi;
const OUTGOING_URL_RE =
/(^|[\s([{<>"'])(https?:\/\/[^\s<>"'\]]+|buzz:\/\/(?:pr|issue|repo|project)\?[^\s<>"'\]]+)/gi;
const MARKDOWN_OUTGOING_LINK_RE =
/!?\[([^\]\n]+)\]\((https?:\/\/[^)\s<>"']+|buzz:\/\/(?:pr|issue|repo|project)\?[^)\s<>"']+)\)/gi;
const MAX_PREVIEWS = 8;
type HiddenRange = {
@@ -124,12 +128,17 @@ function collectCodeRanges(content: string): HiddenRange[] {
function collectMarkdownImageLinkRanges(content: string): HiddenRange[] {
const ranges: HiddenRange[] = [];
for (const match of content.matchAll(MARKDOWN_SUPPORTED_LINK_RE)) {
if (!match[0]?.startsWith("!")) continue;
ranges.push({
start: match.index ?? 0,
end: (match.index ?? 0) + match[0].length,
});
for (const expression of [
MARKDOWN_SUPPORTED_LINK_RE,
MARKDOWN_OUTGOING_LINK_RE,
]) {
for (const match of content.matchAll(expression)) {
if (!match[0]?.startsWith("!")) continue;
ranges.push({
start: match.index ?? 0,
end: (match.index ?? 0) + match[0].length,
});
}
}
return ranges;
@@ -618,30 +627,31 @@ function withTitle(
}
type LinkPreviewCandidate = {
end: number;
href: string;
index: number;
label?: string;
linkSource: string;
order: number;
replacement: string;
};
/** Extract supported link previews from message text, preserving first-seen order. */
export function extractSupportedLinkPreviews(
content: string,
activeRelayOrigin?: string | null,
): SupportedLinkPreview[] {
const previews: SupportedLinkPreview[] = [];
const seen = new Set<string>();
function collectLinkPreviewCandidates(content: string): LinkPreviewCandidate[] {
const searchable = stripHiddenLinkPreviewContent(content);
const candidates: LinkPreviewCandidate[] = [];
let order = 0;
for (const match of searchable.matchAll(MARKDOWN_SUPPORTED_LINK_RE)) {
if (match[0]?.startsWith("!")) continue;
const index = match.index ?? 0;
candidates.push({
end: index + match[0].length,
href: match[2],
index: match.index ?? 0,
index,
label: match[1],
linkSource: match[2],
order,
replacement: match[1],
});
order += 1;
}
@@ -650,18 +660,96 @@ export function extractSupportedLinkPreviews(
const prefix = match[1] ?? "";
const href = match[2];
if (!href) continue;
const trimmedHref = trimUrlCandidate(href);
let index = (match.index ?? 0) + prefix.length;
let end = index + trimmedHref.length;
if (content[index - 1] === "<" && content[end] === ">") {
index -= 1;
end += 1;
}
candidates.push({
href,
index: (match.index ?? 0) + prefix.length,
end,
href: trimmedHref,
index,
linkSource: content.slice(index, end),
order,
replacement: "",
});
order += 1;
}
candidates.sort((a, b) => a.index - b.index || a.order - b.order);
return candidates;
}
function collectOutgoingLinkCandidates(
content: string,
): LinkPreviewCandidate[] {
const searchable = stripHiddenLinkPreviewContent(content);
const candidates = collectLinkPreviewCandidates(content);
let order = candidates.length;
for (const match of searchable.matchAll(MARKDOWN_OUTGOING_LINK_RE)) {
if (match[0]?.startsWith("!")) continue;
const index = match.index ?? 0;
candidates.push({
end: index + match[0].length,
href: match[2],
index,
label: match[1],
linkSource: match[2],
order,
replacement: match[1],
});
order += 1;
}
for (const match of searchable.matchAll(OUTGOING_URL_RE)) {
const prefix = match[1] ?? "";
const href = match[2];
if (!href) continue;
const trimmedHref = trimUrlCandidate(href);
let index = (match.index ?? 0) + prefix.length;
let end = index + trimmedHref.length;
if (content[index - 1] === "<" && content[end] === ">") {
index -= 1;
end += 1;
}
candidates.push({
end,
href: trimmedHref,
index,
linkSource: content.slice(index, end),
order,
replacement: "",
});
order += 1;
}
candidates.sort((a, b) => a.index - b.index || a.order - b.order);
return candidates;
}
function isOutgoingLink(href: string): boolean {
if (isEntityLink(href)) return true;
try {
const parsed = new URL(href);
return parsed.protocol === "https:" || parsed.protocol === "http:";
} catch {
return false;
}
}
/** Extract supported link previews from message text, preserving first-seen order. */
export function extractSupportedLinkPreviews(
content: string,
activeRelayOrigin?: string | null,
): SupportedLinkPreview[] {
const previews: SupportedLinkPreview[] = [];
const seen = new Set<string>();
const relayOrigin = activeRelayOrigin ?? null;
for (const candidate of candidates) {
for (const candidate of collectLinkPreviewCandidates(content)) {
const preview = parseSupportedLinkPreview(candidate.href, relayOrigin);
if (!preview || seen.has(preview.href)) continue;
@@ -679,3 +767,64 @@ export function extractSupportedLinkPreviews(
return previews;
}
export type OutgoingLinkContentParts = {
linkContent: string;
textContent: string;
};
/**
* Lift web and Buzz entity links out of authored prose without disturbing links
* hidden inside code, spoilers, or image markdown. Markdown link labels remain
* in the prose so `Review [this PR](…)` becomes `Review this PR`.
*/
export function splitOutgoingLinkContent(
content: string,
activeRelayOrigin?: string | null,
): OutgoingLinkContentParts {
const relayOrigin = activeRelayOrigin ?? null;
const candidates: LinkPreviewCandidate[] = [];
for (const candidate of collectOutgoingLinkCandidates(content)) {
if (
!parseSupportedLinkPreview(candidate.href, relayOrigin) &&
!isOutgoingLink(candidate.href)
) {
continue;
}
const previousCandidate = candidates[candidates.length - 1];
if (!previousCandidate || candidate.index >= previousCandidate.end) {
candidates.push(candidate);
}
}
if (candidates.length === 0) {
return { linkContent: "", textContent: content };
}
let textContent = "";
let cursor = 0;
for (const candidate of candidates) {
textContent += content.slice(cursor, candidate.index);
textContent += candidate.replacement;
cursor = candidate.end;
if (!candidate.replacement) {
if (/[ \t]$/.test(textContent) && /^[ \t]/.test(content.slice(cursor))) {
cursor += 1;
}
if (
/[ \t]$/.test(textContent) &&
/^(?:\n|[,.;:!?])/.test(content.slice(cursor))
) {
textContent = textContent.replace(/[ \t]+$/, "");
}
}
}
textContent += content.slice(cursor);
return {
linkContent: candidates.map((candidate) => candidate.linkSource).join("\n"),
textContent: textContent.trim(),
};
}

View File

@@ -338,11 +338,15 @@
center / contain no-repeat;
}
.message-markdown.message-bubble-markdown .agent-mention-highlight {
padding-left: calc(var(--agent-icon-size) + var(--agent-icon-gap));
.message-markdown.message-bubble-markdown .inline-chip-with-icon {
display: inline-block;
padding-left: calc(
var(--inline-chip-icon-size) +
var(--inline-chip-icon-gap)
);
}
.message-markdown.message-bubble-markdown .agent-mention-highlight::before {
.message-markdown.message-bubble-markdown .inline-chip-with-icon::before {
left: 0;
}

View File

@@ -529,10 +529,18 @@ test("channel sidebar rows use a large icon tile and description", async ({
const iconContainer = page.getByTestId("channel-icon-container-general");
await expect(iconContainer).toBeVisible();
await expect(page.getByTestId("channel-general")).toHaveCSS(
"border-radius",
"16px",
);
await expect(iconContainer).toHaveCSS("width", "40px");
await expect(iconContainer).toHaveCSS("height", "40px");
await expect(iconContainer.locator("svg")).toHaveCSS("width", "24px");
await expect(iconContainer.locator("svg")).toHaveCSS("height", "24px");
const backgroundColor = await page
.locator("body")
.evaluate((element) => getComputedStyle(element).backgroundColor);
await expect(iconContainer).toHaveCSS("background-color", backgroundColor);
await expect(iconContainer.locator("svg")).toHaveCSS("width", "16px");
await expect(iconContainer.locator("svg")).toHaveCSS("height", "16px");
await expect(page.getByTestId("channel-subtitle-general")).toHaveText(
"General discussion for everyone",
);

View File

@@ -288,6 +288,7 @@ test("does not restore already-sent media when the text send fails", async ({
await page.evaluate(() => {
if (window.__BUZZ_E2E__?.mock) {
window.__BUZZ_E2E__.mock.sendMessageErrors = [
"",
"Mock accompanying text send failed.",
];
}

View File

@@ -606,6 +606,55 @@ test("direct messages use the same grouped message bubbles", async ({
await page.screenshot({ path: `${SHOTS}/direct-message-bubbles.png` });
});
test("bubble mentions keep their prefix icon beside the name", async ({
page,
}) => {
await seedMessageBubbles(page);
await installMockBridge(page);
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
const content = `Bubble mention @bob ${Date.now()}`;
const visibleContent = content.replace("@", "");
await page.evaluate(
({ bobPubkey, message }) => {
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
channelName: "general",
content: message,
mentionPubkeys: [bobPubkey],
});
},
{ bobPubkey: TEST_IDENTITIES.bob.pubkey, message: content },
);
const mention = page
.getByTestId("message-row")
.filter({ hasText: visibleContent })
.locator("[data-mention]");
await expect(mention).toHaveText("bob");
const geometry = await mention.evaluate((element) => {
const style = getComputedStyle(element);
const iconStyle = getComputedStyle(element, "::before");
const textNode = element.firstChild;
if (!textNode) throw new Error("Expected mention label text.");
const range = document.createRange();
range.selectNodeContents(textNode);
const elementBox = element.getBoundingClientRect();
return {
iconRight:
elementBox.left +
Number.parseFloat(iconStyle.left) +
Number.parseFloat(iconStyle.width),
paddingLeft: Number.parseFloat(style.paddingLeft),
textLeft: range.getBoundingClientRect().left,
};
});
expect(geometry.paddingLeft).toBeGreaterThan(0);
expect(geometry.iconRight).toBeLessThanOrEqual(geometry.textLeft + 0.5);
});
test("own-message bubbles align right and use the primary token", async ({
page,
}) => {

View File

@@ -1285,6 +1285,91 @@ test("rapid Enter presses on a ready link preview send exactly once", async ({
.toBe(1);
});
test("mixed link preview and prose send as separate link-first messages", async ({
page,
}) => {
const previewUrl = "https://github.com/block/buzz/pull/3246?split=1";
const prose = `Context for the shared link ${Date.now()}`;
await page.goto("/");
await page.getByTestId("channel-general").click();
const input = page.getByTestId("message-input");
await input.fill(`${prose} ${previewUrl}`);
await waitForReadyComposerSnapshots(page);
await page.getByTestId("send-message").click();
const rows = page.getByTestId("message-row");
const linkRow = rows
.filter({ has: page.locator("[data-link-preview]") })
.last();
const proseRow = rows.filter({ hasText: prose });
await expect(linkRow).toContainText(previewUrl);
await expect(linkRow).not.toContainText(prose);
await expect(proseRow).toBeVisible();
await expect(proseRow.locator("[data-link-preview]")).toHaveCount(0);
const [linkBox, proseBox] = await Promise.all([
linkRow.boundingBox(),
proseRow.boundingBox(),
]);
if (!linkBox || !proseBox) {
throw new Error("Expected separate link and prose message geometry.");
}
expect(linkBox.y).toBeLessThan(proseBox.y);
const calls = await page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? []).filter(
(entry) => entry.command === "send_channel_message",
),
);
expect(calls).toHaveLength(2);
expect(calls[0]?.payload).toMatchObject({
content: previewUrl,
mentionPubkeys: [],
});
expect(
(calls[0]?.payload as { linkPreviewTags?: string[][] }).linkPreviewTags,
).toHaveLength(1);
expect(calls[1]?.payload).toMatchObject({ content: prose });
expect(
(calls[1]?.payload as { linkPreviewTags?: string[][] }).linkPreviewTags ??
[],
).toEqual([]);
});
test("a failed prose send restores text without duplicating its sent link preview", async ({
page,
}) => {
const previewUrl = "https://github.com/block/buzz/pull/3246?retry=1";
const prose = `Retry only this link context ${Date.now()}`;
await page.goto("/");
await page.evaluate(() => {
if (window.__BUZZ_E2E__?.mock) {
window.__BUZZ_E2E__.mock.sendMessageErrors = [
"",
"Mock accompanying prose send failed.",
];
}
});
await page.getByTestId("channel-general").click();
const input = page.getByTestId("message-input");
await input.fill(`${prose} ${previewUrl}`);
await waitForReadyComposerSnapshots(page);
await page.getByTestId("send-message").click();
await expect(page.locator("[data-link-preview]")).toHaveCount(1);
await expect(input).toContainText(prose);
await expect(input).not.toContainText(previewUrl);
await expect(
page.getByTestId("message-row").filter({ hasText: prose }),
).toHaveCount(0);
await input.press("Enter");
await expect(
page.getByTestId("message-row").filter({ hasText: prose }),
).toBeVisible();
await expect(page.locator("[data-link-preview]")).toHaveCount(1);
});
test("pasting a link and immediately pressing Enter prepares it after submit", async ({
page,
}) => {