mirror of
https://github.com/block/buzz.git
synced 2026-11-14 05:12:27 +00:00
Polish channel rows and split message media
Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
@@ -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,
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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||",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
assert.deepEqual(splitOutgoingLinkContent(content), {
|
||||
linkContent: "https://example.com/visible",
|
||||
textContent: [
|
||||
"Visible",
|
||||
"`https://example.com/code`",
|
||||
"||https://example.com/spoiler||",
|
||||
"",
|
||||
].join("\n"),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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.",
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user