mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
fix(desktop): remove Buzz entity link previews (#6512)
**Category:** improvement **User Impact:** Buzz-native project, repository, issue, and pull request links now appear once as compact inline chips, with their details available on hover. **Problem:** Buzz-native entity links rendered both an inline chip and a standalone preview card, repeating the same metadata and adding visual noise to conversations. **Solution:** Exclude Buzz-native links from the shared standalone-preview extractor while leaving entity parsing intact for chip tooltips and preserving external web previews and attachment cards. <details> <summary>File changes</summary> **desktop/src/shared/lib/linkPreview.ts** Stops Buzz-native preview candidates after parsing, including same-relay git clone URLs that normalize to repository entities, while allowing external URLs through the existing snapshot path. **desktop/src/shared/lib/linkPreview.test.mjs** Covers project, repository, issue, pull request, markdown-labeled, same-relay clone, and mixed external-link extraction behavior. **desktop/src/shared/ui/markdown/useMessageLinkPreviews.test.mjs** Confirms sent messages no longer merge a standalone Buzz entity card while external sender snapshots still render. </details> ## Reproduction steps 1. Open a desktop channel containing a `buzz://project`, `buzz://repo`, `buzz://issue`, or `buzz://pr` link. 2. Confirm the link renders as an inline entity chip without a second standalone Buzz card below the message. 3. Hover the chip and confirm its entity metadata remains available. 4. Post an external HTTPS link and confirm its web preview still renders. 5. Paste a same-relay `/git/<owner>/<repo>` clone URL and confirm it uses the repository chip without a duplicate card. ## Screenshots | Before | After | | --- | --- | | Inline chip plus redundant standalone Project card | Inline chip is now the sole presentation | |  |  | **After — rich metadata stays available on hover**  ## Verification At commit `3fa74cdd342ac1f6721b7d56a7f111af31e0e6e9`: - focused link-preview + Markdown unit suites — 119/119 passed - targeted registered smoke E2E — 8/8 passed, including labeled same-relay clone metadata, ordinary-link presentation, and in-app navigation - `cd desktop && pnpm exec tsc --noEmit` — passed - `git diff --check origin/main...HEAD` — passed - pre-push hooks — desktop check, TypeScript, and full desktop unit suite passed --------- Signed-off-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz> Co-authored-by: Mongo <5398c5fd039b963ce132b3e078e7c4af097dd997517bb5e14c2682fe68c25197@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -70,18 +70,18 @@ test("composer input versions retain only active hrefs while re-entry advances",
|
||||
nextHrefVersion: 0,
|
||||
};
|
||||
|
||||
input = updateComposerLinkPreviewInput(input, `see ${HREF}`);
|
||||
input = updateComposerLinkPreviewInput(input, `see ${HREF}`, null);
|
||||
const firstVersion = input.hrefVersions.get(HREF);
|
||||
assert.equal(input.hrefVersions.size, 1);
|
||||
|
||||
input = updateComposerLinkPreviewInput(input, `see ${secondHref}`);
|
||||
input = updateComposerLinkPreviewInput(input, `see ${secondHref}`, null);
|
||||
assert.deepEqual(
|
||||
[...input.hrefVersions.keys()],
|
||||
[secondHref],
|
||||
"departed href history is pruned instead of retained for the composer lifetime",
|
||||
);
|
||||
|
||||
input = updateComposerLinkPreviewInput(input, `see ${HREF}`);
|
||||
input = updateComposerLinkPreviewInput(input, `see ${HREF}`, null);
|
||||
assert.deepEqual([...input.hrefVersions.keys()], [HREF]);
|
||||
assert.ok(
|
||||
input.hrefVersions.get(HREF) > firstVersion,
|
||||
@@ -159,6 +159,7 @@ test("composer forces a refetch and drops the stale tag on a fast clear+re-paste
|
||||
nextHrefVersion: 0,
|
||||
},
|
||||
`see ${HREF}`,
|
||||
null,
|
||||
);
|
||||
const { result, rerender, unmount } = renderHook(
|
||||
({ content, hrefVersions }) =>
|
||||
@@ -183,10 +184,11 @@ test("composer forces a refetch and drops the stale tag on a fast clear+re-paste
|
||||
// href set equals the previous commit, but the update-boundary version has
|
||||
// advanced because the URL left and re-entered between those updates.
|
||||
await act(async () => {
|
||||
previewInput = updateComposerLinkPreviewInput(previewInput, "see ");
|
||||
previewInput = updateComposerLinkPreviewInput(previewInput, "see ", null);
|
||||
previewInput = updateComposerLinkPreviewInput(
|
||||
previewInput,
|
||||
`see ${HREF}`,
|
||||
null,
|
||||
);
|
||||
rerender(previewInput);
|
||||
});
|
||||
@@ -318,6 +320,7 @@ test("a removed blocked re-entry can later use metadata that resolved while abse
|
||||
nextHrefVersion: 0,
|
||||
},
|
||||
`see ${HREF}`,
|
||||
null,
|
||||
);
|
||||
const { result, rerender, unmount } = renderHook(
|
||||
({ content, hrefVersions }) =>
|
||||
@@ -330,10 +333,11 @@ test("a removed blocked re-entry can later use metadata that resolved while abse
|
||||
|
||||
// Re-enter the cached negative and wait until its forced refetch is in flight.
|
||||
await act(async () => {
|
||||
previewInput = updateComposerLinkPreviewInput(previewInput, "see ");
|
||||
previewInput = updateComposerLinkPreviewInput(previewInput, "see ", null);
|
||||
previewInput = updateComposerLinkPreviewInput(
|
||||
previewInput,
|
||||
`see ${HREF}`,
|
||||
null,
|
||||
);
|
||||
rerender(previewInput);
|
||||
});
|
||||
@@ -345,7 +349,7 @@ test("a removed blocked re-entry can later use metadata that resolved while abse
|
||||
// Remove the blocked href, then let its refetch populate healthy metadata
|
||||
// while no candidate is active.
|
||||
await act(async () => {
|
||||
previewInput = updateComposerLinkPreviewInput(previewInput, "see ");
|
||||
previewInput = updateComposerLinkPreviewInput(previewInput, "see ", null);
|
||||
rerender(previewInput);
|
||||
});
|
||||
await settle();
|
||||
@@ -359,6 +363,7 @@ test("a removed blocked re-entry can later use metadata that resolved while abse
|
||||
previewInput = updateComposerLinkPreviewInput(
|
||||
previewInput,
|
||||
`see ${HREF}`,
|
||||
null,
|
||||
);
|
||||
rerender(previewInput);
|
||||
});
|
||||
@@ -486,6 +491,7 @@ test("a stale in-flight upload cannot publish after the URL re-enters and a fres
|
||||
nextHrefVersion: 0,
|
||||
},
|
||||
`see ${HREF}`,
|
||||
null,
|
||||
);
|
||||
const { result, rerender, unmount } = renderHook(
|
||||
({ content, hrefVersions }) =>
|
||||
@@ -510,10 +516,11 @@ test("a stale in-flight upload cannot publish after the URL re-enters and a fres
|
||||
|
||||
// 2. Fast gesture: clear then re-paste the SAME URL inside the debounce.
|
||||
await act(async () => {
|
||||
previewInput = updateComposerLinkPreviewInput(previewInput, "see ");
|
||||
previewInput = updateComposerLinkPreviewInput(previewInput, "see ", null);
|
||||
previewInput = updateComposerLinkPreviewInput(
|
||||
previewInput,
|
||||
`see ${HREF}`,
|
||||
null,
|
||||
);
|
||||
rerender(previewInput);
|
||||
});
|
||||
@@ -750,3 +757,105 @@ test("an abandoned concurrent render cannot invalidate the committed snapshot ta
|
||||
ipcHandlers.clear();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Composer clone-URL classification ────────────────────────────────────────
|
||||
//
|
||||
// A same-relay `/git/<owner>/<repo>` clone URL is a Buzz repository entity: the
|
||||
// renderer normalizes it onto `buzz://repo` and shows it as an inline chip, not
|
||||
// a standalone card. The composer must reach the same verdict from the same
|
||||
// active relay origin — without it the URL is classified as an external
|
||||
// generic-link, enters snapshot fetching, and shows a card the sent message
|
||||
// then contradicts.
|
||||
|
||||
const CLONE_OWNER = "a".repeat(64);
|
||||
const RELAY_ORIGIN = "https://relay.example.com";
|
||||
const CLONE_HREF = `${RELAY_ORIGIN}/git/${CLONE_OWNER}/relay-tools.git`;
|
||||
|
||||
test("composer input classifies a same-relay clone URL as a Buzz entity", async () => {
|
||||
const { updateComposerLinkPreviewInput } = await import(
|
||||
"./useComposerLinkPreviews.tsx"
|
||||
);
|
||||
const empty = {
|
||||
content: "",
|
||||
hrefs: new Set(),
|
||||
hrefVersions: new Map(),
|
||||
nextHrefVersion: 0,
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
[
|
||||
...updateComposerLinkPreviewInput(
|
||||
empty,
|
||||
`clone ${CLONE_HREF}`,
|
||||
RELAY_ORIGIN,
|
||||
).hrefs,
|
||||
],
|
||||
[],
|
||||
"a same-relay clone URL is a chip-only entity, never a preview candidate",
|
||||
);
|
||||
// A different origin sharing the path shape stays an ordinary external link.
|
||||
assert.deepEqual(
|
||||
[
|
||||
...updateComposerLinkPreviewInput(
|
||||
empty,
|
||||
`clone ${CLONE_HREF}`,
|
||||
"https://evil.example.com",
|
||||
).hrefs,
|
||||
],
|
||||
[CLONE_HREF],
|
||||
);
|
||||
});
|
||||
|
||||
test("composer never fetches a snapshot for a same-relay clone URL", async () => {
|
||||
const { act, cleanup, renderHook } = await import("@testing-library/react");
|
||||
const { getCachedRelayOrigin } = await import("@/shared/lib/mediaUrl.ts");
|
||||
const { resetLinkPreviewMetadataCache } = await import(
|
||||
"@/shared/lib/useResolvedLinkPreviews.ts"
|
||||
);
|
||||
const { useComposerLinkPreviews } = await import(
|
||||
"./useComposerLinkPreviews.tsx"
|
||||
);
|
||||
|
||||
resetLinkPreviewMetadataCache();
|
||||
ipcHandlers.clear();
|
||||
ipcHandlers.set("get_relay_http_url", () => Promise.resolve(RELAY_ORIGIN));
|
||||
let fetchCalls = 0;
|
||||
ipcHandlers.set("fetch_link_preview_metadata", () => {
|
||||
fetchCalls += 1;
|
||||
return Promise.resolve(metadata());
|
||||
});
|
||||
|
||||
try {
|
||||
// The origin resolves asynchronously; the classification under test only
|
||||
// exists once it is known, so wait for the shared cache to publish it.
|
||||
const deadline = Date.now() + 5000;
|
||||
while (getCachedRelayOrigin() !== RELAY_ORIGIN && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
assert.equal(getCachedRelayOrigin(), RELAY_ORIGIN);
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useComposerLinkPreviews(`clone ${CLONE_HREF}`),
|
||||
);
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_WAIT_MS));
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
fetchCalls,
|
||||
0,
|
||||
"a Buzz repository entity must not enter external snapshot fetching",
|
||||
);
|
||||
assert.equal(result.current.previewList, null);
|
||||
assert.deepEqual(result.current.getReadyTags(), []);
|
||||
assert.deepEqual(result.current.getLiveCandidates(), []);
|
||||
assert.equal(result.current.hasPendingSnapshots, false);
|
||||
unmount();
|
||||
} finally {
|
||||
cleanup();
|
||||
ipcHandlers.clear();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
beginRelayOriginFetch,
|
||||
getCachedRelayOrigin,
|
||||
} from "@/shared/lib/mediaUrl";
|
||||
import { useRelayOrigin } from "@/shared/lib/useRelayOrigin";
|
||||
import {
|
||||
isBuzzEntityPreview,
|
||||
type ResolvedLinkPreview,
|
||||
@@ -199,9 +200,10 @@ export interface ComposerLinkPreviewInput {
|
||||
export function updateComposerLinkPreviewInput(
|
||||
current: ComposerLinkPreviewInput,
|
||||
content: string,
|
||||
relayOrigin: string | null,
|
||||
): ComposerLinkPreviewInput {
|
||||
const nextHrefs = new Set(
|
||||
extractSupportedLinkPreviews(content)
|
||||
extractSupportedLinkPreviews(content, relayOrigin)
|
||||
.filter((preview) =>
|
||||
preview.href.startsWith("buzz://")
|
||||
? true
|
||||
@@ -235,11 +237,28 @@ export function useComposerLinkPreviewInput() {
|
||||
hrefVersions: new Map(),
|
||||
nextHrefVersion: 0,
|
||||
}));
|
||||
// Read the origin through the store subscription so a paste that lands
|
||||
// before the async lookup resolves is reclassified — an href set frozen at
|
||||
// first-render time would keep a same-relay clone URL versioned as an
|
||||
// external candidate for the life of the composer.
|
||||
const relayOrigin = useRelayOrigin();
|
||||
const update = React.useCallback(
|
||||
(content: string) =>
|
||||
setInput((current) => updateComposerLinkPreviewInput(current, content)),
|
||||
[],
|
||||
setInput((current) =>
|
||||
updateComposerLinkPreviewInput(current, content, relayOrigin),
|
||||
),
|
||||
[relayOrigin],
|
||||
);
|
||||
// Re-classify already-entered content when the origin resolves or changes.
|
||||
// An empty draft has nothing to reclassify; returning `current` lets React
|
||||
// bail out of the mount-time pass instead of re-rendering the composer.
|
||||
React.useEffect(() => {
|
||||
setInput((current) =>
|
||||
current.content
|
||||
? updateComposerLinkPreviewInput(current, current.content, relayOrigin)
|
||||
: current,
|
||||
);
|
||||
}, [relayOrigin]);
|
||||
return [input, update] as const;
|
||||
}
|
||||
|
||||
@@ -273,16 +292,17 @@ export function useComposerLinkPreviews(
|
||||
);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [content]);
|
||||
const relayOrigin = useRelayOrigin();
|
||||
const extractCandidates = React.useCallback(
|
||||
(source: string) =>
|
||||
enabled
|
||||
? extractSupportedLinkPreviews(source).filter((preview) =>
|
||||
? extractSupportedLinkPreviews(source, relayOrigin).filter((preview) =>
|
||||
preview.href.startsWith("buzz://")
|
||||
? true
|
||||
: isValidLinkPreviewSnapshotCanonicalUrl(preview.href),
|
||||
)
|
||||
: [],
|
||||
[enabled],
|
||||
[enabled, relayOrigin],
|
||||
);
|
||||
const candidates = React.useMemo(
|
||||
() => extractCandidates(debounced),
|
||||
|
||||
@@ -662,12 +662,13 @@ export const PROJECT_WORK_ITEMS_STALE_TIME_MS = 2 * 60_000;
|
||||
export const PROJECT_ACTIVITY_STALE_TIME_MS = 2 * 60_000;
|
||||
export const PROJECT_LOCAL_REPOS_STALE_TIME_MS = 2 * 60_000;
|
||||
|
||||
export function useProjectsQuery() {
|
||||
export function useProjectsQuery(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: projectsQueryKey,
|
||||
queryFn: ({ signal }) => fetchProjects(undefined, signal),
|
||||
staleTime: PROJECTS_STALE_TIME_MS,
|
||||
gcTime: PROJECTS_GC_TIME_MS,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -236,30 +236,28 @@ test("parseSupportedLinkPreview rejects malformed buzz:// entity links", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews picks up buzz:// project links in prose", () => {
|
||||
test("extractSupportedLinkPreviews excludes Buzz entity links while keeping external links", () => {
|
||||
const entityLinks = [
|
||||
`buzz://project?owner=${BUZZ_OWNER}&d=buzz-world`,
|
||||
`buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world`,
|
||||
`buzz://issue?id=${BUZZ_EVENT_ID}&owner=${BUZZ_OWNER}&d=buzz-world`,
|
||||
`buzz://pr?id=${BUZZ_EVENT_ID}&owner=${BUZZ_OWNER}&d=buzz-world`,
|
||||
];
|
||||
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
`tracking here: buzz://project?owner=${BUZZ_OWNER}&d=buzz-world`,
|
||||
).map((preview) => [preview.kind, preview.typeLabel, preview.title]),
|
||||
[["buzz-project", "project", "buzz-world"]],
|
||||
`${entityLinks.join(" ")} https://example.com/story`,
|
||||
).map((preview) => preview.href),
|
||||
["https://example.com/story"],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews picks up buzz:// links in prose", () => {
|
||||
test("extractSupportedLinkPreviews excludes markdown-labeled Buzz entity links", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
`PR is up: buzz://pr?id=${BUZZ_EVENT_ID}&owner=${BUZZ_OWNER}&d=buzz-world — review please.`,
|
||||
).map((preview) => [preview.kind, preview.title]),
|
||||
[["buzz-pull-request", "buzz-world #c3b589fa"]],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews uses markdown labels for buzz:// links", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
`[Add header links](buzz://pr?id=${BUZZ_EVENT_ID}&owner=${BUZZ_OWNER}&d=buzz-world)`,
|
||||
).map((preview) => preview.title),
|
||||
["Add header links"],
|
||||
`[Project](buzz://project?owner=${BUZZ_OWNER}&d=buzz-world)`,
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -324,21 +322,13 @@ test("extractSupportedLinkPreviews returns unique supported links in order", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews picks up bare Buzz clone URLs in prose", () => {
|
||||
test("extractSupportedLinkPreviews excludes same-relay Buzz clone URLs", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
`master pushed; clone: https://buzz.block.builderlab.xyz/git/${BUZZ_OWNER}/buzz-world-galaxy and review please.`,
|
||||
"https://buzz.block.builderlab.xyz",
|
||||
),
|
||||
[
|
||||
{
|
||||
kind: "buzz-repository",
|
||||
href: `buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`,
|
||||
provider: "Buzz",
|
||||
title: "buzz-world-galaxy",
|
||||
typeLabel: "repo",
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
// Without a relay origin the URL is treated as an ordinary external link.
|
||||
assert.deepEqual(
|
||||
@@ -349,39 +339,13 @@ test("extractSupportedLinkPreviews picks up bare Buzz clone URLs in prose", () =
|
||||
);
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews uses markdown labels for Buzz repo links", () => {
|
||||
test("extractSupportedLinkPreviews excludes markdown-labeled Buzz clone URLs", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
`[Buzz World](https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy)`,
|
||||
"https://relay.example",
|
||||
).map((preview) => preview.title),
|
||||
["Buzz World"],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews dedupes clone URL variants of one repo", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
[
|
||||
`https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy`,
|
||||
`https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy.git`,
|
||||
].join(" "),
|
||||
"https://relay.example",
|
||||
).map((preview) => preview.href),
|
||||
[`buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`],
|
||||
);
|
||||
});
|
||||
|
||||
test("clone URLs and buzz://repo links for the same repo dedupe to one card", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
[
|
||||
`https://relay.example/git/${BUZZ_OWNER}/buzz-world-galaxy`,
|
||||
`buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`,
|
||||
].join(" "),
|
||||
"https://relay.example",
|
||||
).map((preview) => preview.href),
|
||||
[`buzz://repo?owner=${BUZZ_OWNER}&d=buzz-world-galaxy`],
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -665,7 +665,17 @@ export function extractSupportedLinkPreviews(
|
||||
const relayOrigin = activeRelayOrigin ?? null;
|
||||
for (const candidate of candidates) {
|
||||
const preview = parseSupportedLinkPreview(candidate.href, relayOrigin);
|
||||
if (!preview || seen.has(preview.href)) continue;
|
||||
// Buzz-native links render as inline entity chips. Their relay-backed
|
||||
// metadata is available from the chip on hover, so a second standalone
|
||||
// preview would duplicate the same entity presentation. This also covers
|
||||
// same-relay clone URLs, which parseSupportedLinkPreview normalizes to a
|
||||
// buzz://repo href. External web links continue through the snapshot path.
|
||||
if (
|
||||
!preview ||
|
||||
preview.href.startsWith("buzz://") ||
|
||||
seen.has(preview.href)
|
||||
)
|
||||
continue;
|
||||
|
||||
seen.add(preview.href);
|
||||
previews.push(
|
||||
|
||||
@@ -1243,17 +1243,21 @@ test("authored Buzz permalink labels remain ordinary links", () => {
|
||||
});
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
MarkdownRuntimeContext.Provider,
|
||||
{
|
||||
value: {
|
||||
channels: [{ id: channelId, name: "engineering" }],
|
||||
onOpenChannel: () => {},
|
||||
onOpenEntityLink: () => {},
|
||||
onOpenMessageLink: () => {},
|
||||
relayOrigin: null,
|
||||
QueryClientProvider,
|
||||
{ client: new QueryClient() },
|
||||
React.createElement(
|
||||
MarkdownRuntimeContext.Provider,
|
||||
{
|
||||
value: {
|
||||
channels: [{ id: channelId, name: "engineering" }],
|
||||
onOpenChannel: () => {},
|
||||
onOpenEntityLink: () => {},
|
||||
onOpenMessageLink: () => {},
|
||||
relayOrigin: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
markdown,
|
||||
markdown,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -55,12 +55,7 @@ import {
|
||||
MarkdownCodeBlock,
|
||||
SyntaxHighlightedCode,
|
||||
} from "./markdown/CodeBlock";
|
||||
import {
|
||||
EntityLinkAnchor,
|
||||
renderEntityLinkAnchor,
|
||||
useEntityCardOpenHandlers,
|
||||
useOpenEntityLink,
|
||||
} from "./markdown/entityLinks";
|
||||
import { EntityLinkAnchor, useOpenEntityLink } from "./markdown/entityLinks";
|
||||
import { ExternalLinkAnchor } from "./markdown/ExternalLinkAnchor";
|
||||
import { FileCard } from "./markdown/FileCard";
|
||||
import {
|
||||
@@ -1352,17 +1347,28 @@ export function createMarkdownComponents(
|
||||
// Malformed message deep links fall through to external handling.
|
||||
}
|
||||
|
||||
// `buzz://pr|issue|repo?…` entity links navigate in-app; malformed ones
|
||||
// fall through to the default anchor.
|
||||
const entityAnchor = renderEntityLinkAnchor({
|
||||
children,
|
||||
href,
|
||||
onOpenEntityLink,
|
||||
relayOrigin,
|
||||
interactive,
|
||||
asChip: label === href,
|
||||
});
|
||||
if (entityAnchor) return entityAnchor;
|
||||
// `buzz://pr|issue|repo|project?…` entity links navigate in-app;
|
||||
// malformed ones fall through to the default anchor. The provider-backed
|
||||
// component keeps metadata tooltips available for both raw chips and
|
||||
// authored Markdown labels.
|
||||
if (href) {
|
||||
const entityAnchor = React.createElement(
|
||||
EntityLinkAnchor,
|
||||
{
|
||||
href,
|
||||
onOpenEntityLink,
|
||||
relayOrigin,
|
||||
interactive,
|
||||
asChip: label === href,
|
||||
},
|
||||
children,
|
||||
);
|
||||
if (
|
||||
parseEntityLink(href).ok ||
|
||||
parseSupportedLinkPreview(href, relayOrigin)?.href.startsWith("buzz://")
|
||||
)
|
||||
return entityAnchor;
|
||||
}
|
||||
|
||||
const supportedLinkPreview = href
|
||||
? parseSupportedLinkPreview(href, relayOrigin)
|
||||
@@ -1824,11 +1830,6 @@ function MarkdownInner({
|
||||
processedContent = `${processedContent}\u200B`;
|
||||
}
|
||||
|
||||
const entityCardOpenHandlers = useEntityCardOpenHandlers(
|
||||
resolvedLinkPreviews,
|
||||
onOpenEntityLink,
|
||||
);
|
||||
|
||||
// When a config-nudge suppresses the prose (selectProseOrNudge returns
|
||||
// null), skip the parse entirely — it would be thrown away unrendered.
|
||||
const hasLeadingInlineContent = leadingInlineContent != null;
|
||||
@@ -1889,7 +1890,6 @@ function MarkdownInner({
|
||||
<LinkPreviewList
|
||||
ImageLightbox={LinkPreviewImageLightbox}
|
||||
key={messageId}
|
||||
onOpenByHref={entityCardOpenHandlers}
|
||||
onRemoveForEveryone={onRemoveLinkPreviewsForEveryone}
|
||||
previews={resolvedLinkPreviews}
|
||||
/>
|
||||
|
||||
@@ -9,10 +9,7 @@ import {
|
||||
parseEntityLink,
|
||||
type ParsedEntityLink,
|
||||
} from "@/shared/lib/entityLink";
|
||||
import {
|
||||
parseSupportedLinkPreview,
|
||||
type SupportedLinkPreview,
|
||||
} from "@/shared/lib/linkPreview";
|
||||
import { parseSupportedLinkPreview } from "@/shared/lib/linkPreview";
|
||||
|
||||
import {
|
||||
loadBuzzEntityMetadata,
|
||||
@@ -193,27 +190,6 @@ export function useOpenEntityLink(): (link: ParsedEntityLink) => void {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* In-app open handlers for `buzz://` entity preview cards, keyed by href.
|
||||
* External cards get no handler and keep their OS-opened anchor.
|
||||
*/
|
||||
export function useEntityCardOpenHandlers(
|
||||
previews: SupportedLinkPreview[],
|
||||
onOpenEntityLink: (link: ParsedEntityLink) => void,
|
||||
): Map<string, () => void> {
|
||||
return React.useMemo(() => {
|
||||
const handlers = new Map<string, () => void>();
|
||||
for (const preview of previews) {
|
||||
if (!isEntityLink(preview.href)) continue;
|
||||
const parsed = parseEntityLink(preview.href);
|
||||
if (parsed.ok) {
|
||||
handlers.set(preview.href, () => onOpenEntityLink(parsed.value));
|
||||
}
|
||||
}
|
||||
return handlers;
|
||||
}, [onOpenEntityLink, previews]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an anchor href to a canonical `buzz://` entity link, accepting
|
||||
* both the deep-link scheme directly and HTTPS relay clone URLs (which the
|
||||
@@ -253,7 +229,7 @@ export function EntityLinkAnchor({
|
||||
interactive?: boolean;
|
||||
asChip?: boolean;
|
||||
}): React.ReactElement | null {
|
||||
const { data: projects } = useProjectsQuery();
|
||||
const { data: projects } = useProjectsQuery(interactive);
|
||||
return renderEntityLinkAnchor({
|
||||
children,
|
||||
href,
|
||||
@@ -296,18 +272,38 @@ export function renderEntityLinkAnchor({
|
||||
if (!parsed.ok) return null;
|
||||
const presentation = entityLinkPresentation(parsed.value);
|
||||
|
||||
if (!asChip) {
|
||||
const inlineLink = (metadata?: LinkPreviewMetadata | null) => {
|
||||
const resolvedContext = metadata?.title.trim();
|
||||
const ariaLabel = resolvedContext
|
||||
? `${presentation.ariaLabel}: ${resolvedContext}`
|
||||
: presentation.ariaLabel;
|
||||
return (
|
||||
<BuzzInlineLink
|
||||
href={href}
|
||||
title={href}
|
||||
aria-label={presentation.ariaLabel}
|
||||
aria-label={ariaLabel}
|
||||
interactive={interactive}
|
||||
onOpenLink={() => onOpenEntityLink(parsed.value)}
|
||||
>
|
||||
{children}
|
||||
</BuzzInlineLink>
|
||||
);
|
||||
};
|
||||
|
||||
if (!asChip) {
|
||||
return interactive ? (
|
||||
<EntityMetadataTooltip
|
||||
fallback={presentation.label}
|
||||
footer={presentation.tooltipFooter}
|
||||
href={canonicalHref}
|
||||
link={parsed.value}
|
||||
projects={projects}
|
||||
>
|
||||
{inlineLink}
|
||||
</EntityMetadataTooltip>
|
||||
) : (
|
||||
inlineLink()
|
||||
);
|
||||
}
|
||||
|
||||
const chip = (metadata?: LinkPreviewMetadata | null) => {
|
||||
|
||||
@@ -27,7 +27,7 @@ function snapshotTag(href, title, siteName) {
|
||||
];
|
||||
}
|
||||
|
||||
test("relay-resolved Buzz entities beat conflicting sender snapshots in content order", () => {
|
||||
test("Buzz entity links do not create message preview cards", () => {
|
||||
const content = `${ENTITY_HREF} then ${EXTERNAL_HREF}`;
|
||||
const candidates = extractSupportedLinkPreviews(content, RELAY_ORIGIN);
|
||||
const snapshots = parseLinkPreviewSnapshots(
|
||||
@@ -38,22 +38,12 @@ test("relay-resolved Buzz entities beat conflicting sender snapshots in content
|
||||
content,
|
||||
RELAY_ORIGIN,
|
||||
);
|
||||
const entity = {
|
||||
...candidates[0],
|
||||
title: "Relay-authenticated PR title",
|
||||
imageState: "none",
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
mergeMessageLinkPreviews(candidates, snapshots, [entity]).map(
|
||||
mergeMessageLinkPreviews(candidates, snapshots).map(
|
||||
({ href, title, provider }) => ({ href, title, provider }),
|
||||
),
|
||||
[
|
||||
{
|
||||
href: ENTITY_HREF,
|
||||
title: "Relay-authenticated PR title",
|
||||
provider: "Buzz",
|
||||
},
|
||||
{
|
||||
href: EXTERNAL_HREF,
|
||||
title: "External story",
|
||||
|
||||
@@ -5,12 +5,7 @@ import {
|
||||
type SupportedLinkPreview,
|
||||
} from "@/shared/lib/linkPreview";
|
||||
import { parseLinkPreviewSnapshots } from "@/shared/lib/linkPreviewSnapshot";
|
||||
import {
|
||||
isBuzzEntityPreview,
|
||||
type ResolvedLinkPreview,
|
||||
useResolvedLinkPreviews,
|
||||
withEntityFallbacks,
|
||||
} from "@/shared/lib/useResolvedLinkPreviews";
|
||||
import type { ResolvedLinkPreview } from "@/shared/lib/useResolvedLinkPreviews";
|
||||
|
||||
/**
|
||||
* Resolve the link-preview cards for a rendered message.
|
||||
@@ -18,33 +13,19 @@ import {
|
||||
* External URLs render exclusively from sender-authored
|
||||
* `["link-preview","snapshot",…]` tags (`parseLinkPreviewSnapshots`) — the
|
||||
* privacy model shipped in the rich-link-previews work: recipients never
|
||||
* contact external sites.
|
||||
*
|
||||
* `buzz://pr|issue|repo` entity links (and relay clone URLs, which normalize
|
||||
* onto `buzz://repo`) are the exception and are rendered recipient-side:
|
||||
* their metadata comes from the community relay itself via the entity metadata
|
||||
* loader, so the sender-snapshot privacy model does not apply — and senders
|
||||
* (CLI, agents, mobile) attach no snapshot tags for them. Recognized entity
|
||||
* cards win
|
||||
* over conflicting sender snapshots, and cards retain their first-seen order in
|
||||
* the message.
|
||||
* contact external sites. Buzz-native entity links render as inline links or
|
||||
* chips with relay-backed metadata tooltips, not as standalone cards.
|
||||
*/
|
||||
export function mergeMessageLinkPreviews(
|
||||
candidates: SupportedLinkPreview[],
|
||||
snapshots: ResolvedLinkPreview[],
|
||||
entities: ResolvedLinkPreview[],
|
||||
): ResolvedLinkPreview[] {
|
||||
const snapshotsByHref = new Map(
|
||||
snapshots.map((preview) => [preview.href, preview]),
|
||||
);
|
||||
const entitiesByHref = new Map(
|
||||
entities.map((preview) => [preview.href, preview]),
|
||||
);
|
||||
|
||||
return candidates.flatMap((candidate) => {
|
||||
const preview = isBuzzEntityPreview(candidate)
|
||||
? entitiesByHref.get(candidate.href)
|
||||
: snapshotsByHref.get(candidate.href);
|
||||
const preview = snapshotsByHref.get(candidate.href);
|
||||
return preview ? [preview] : [];
|
||||
});
|
||||
}
|
||||
@@ -69,27 +50,12 @@ export function useMessageLinkPreviews({
|
||||
: [],
|
||||
[content, interactive, linkPreviewsSuppressed, relayOrigin],
|
||||
);
|
||||
const entityLinkPreviews = React.useMemo(
|
||||
() => linkPreviewCandidates.filter(isBuzzEntityPreview),
|
||||
[linkPreviewCandidates],
|
||||
);
|
||||
const relayResolvedEntityLinkPreviews =
|
||||
useResolvedLinkPreviews(entityLinkPreviews);
|
||||
const resolvedEntityLinkPreviews = React.useMemo(
|
||||
() =>
|
||||
withEntityFallbacks(entityLinkPreviews, relayResolvedEntityLinkPreviews),
|
||||
[entityLinkPreviews, relayResolvedEntityLinkPreviews],
|
||||
);
|
||||
return React.useMemo(() => {
|
||||
const snapshots =
|
||||
interactive && !linkPreviewsSuppressed
|
||||
? parseLinkPreviewSnapshots(linkPreviewTags, content, relayOrigin)
|
||||
: [];
|
||||
return mergeMessageLinkPreviews(
|
||||
linkPreviewCandidates,
|
||||
snapshots,
|
||||
resolvedEntityLinkPreviews,
|
||||
);
|
||||
return mergeMessageLinkPreviews(linkPreviewCandidates, snapshots);
|
||||
}, [
|
||||
content,
|
||||
interactive,
|
||||
@@ -97,6 +63,5 @@ export function useMessageLinkPreviews({
|
||||
linkPreviewTags,
|
||||
linkPreviewsSuppressed,
|
||||
relayOrigin,
|
||||
resolvedEntityLinkPreviews,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { waitForAnimations } from "../helpers/animations";
|
||||
import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge";
|
||||
import {
|
||||
installBridge,
|
||||
installMockBridge,
|
||||
TEST_IDENTITIES,
|
||||
} from "../helpers/bridge";
|
||||
|
||||
const SHOTS = "test-results/entity-link-recipient-cards";
|
||||
|
||||
// Regression coverage for buzz:// entity links posted WITHOUT sender
|
||||
// snapshot tags (CLI / agent senders): #3818 moved external-link previews to
|
||||
// sender-authored snapshots, which silently killed the recipient-side
|
||||
// buzz://pr|issue|repo cards from #4695. These cards resolve their titles
|
||||
// from the active relay itself, so they must render for recipients even when
|
||||
// the message carries no link-preview tags.
|
||||
// Regression coverage for Buzz-native entity links after standalone cards were
|
||||
// removed. Raw and authored entity links keep their inline navigation and
|
||||
// relay-backed metadata tooltips, while external sender snapshots still render.
|
||||
|
||||
const ALICE_PUBKEY = TEST_IDENTITIES.alice.pubkey;
|
||||
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
|
||||
@@ -20,8 +21,11 @@ const PR_SUBJECT = "Restore recipient-side entity cards";
|
||||
const ISSUE_ID = `f0${"1a2b".repeat(15)}ee`; // 64-hex event id
|
||||
const ISSUE_SUBJECT =
|
||||
"Smoke test: issue tracking on relay-tools with a deliberately long title";
|
||||
const EXTERNAL_HREF = "https://example.com/entity-chip-control";
|
||||
const RELAY_ORIGIN = "http://localhost:3000";
|
||||
const CLONE_HREF = `${RELAY_ORIGIN}/git/${ALICE_PUBKEY}/relay-tools.git`;
|
||||
|
||||
test("agent-style message with angle-bracket buzz:// links renders entity cards without snapshot tags", async ({
|
||||
test("agent-style Buzz links stay chip-only with metadata tooltips", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(
|
||||
@@ -75,7 +79,7 @@ test("agent-style message with angle-bracket buzz:// links renders entity cards
|
||||
// Simulate an agent/CLI sender: plain kind-9 message with angle-bracket
|
||||
// buzz:// URLs in a Markdown list and NO link-preview snapshot tags.
|
||||
await page.evaluate(
|
||||
({ prId, issueId, alicePubkey }) => {
|
||||
({ prId, issueId, alicePubkey, externalHref, cloneHref }) => {
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({
|
||||
channelName: "general",
|
||||
pubkey: alicePubkey,
|
||||
@@ -85,11 +89,35 @@ test("agent-style message with angle-bracket buzz:// links renders entity cards
|
||||
`- Pull request with enough leading context to wrap: <buzz://pr?id=${prId}&owner=${alicePubkey}&d=relay-tools>`,
|
||||
`- Issue: <buzz://issue?id=${issueId}&owner=${alicePubkey}&d=relay-tools>`,
|
||||
`- Repository: <buzz://repo?owner=${alicePubkey}&d=relay-tools>`,
|
||||
`- Labeled issue: [triage this issue](buzz://issue?id=${issueId}&owner=${alicePubkey}&d=relay-tools)`,
|
||||
`- Labeled clone: [clone relay-tools](${cloneHref})`,
|
||||
`- Missing repo: <buzz://repo?owner=${alicePubkey}&d=missing-repo>`,
|
||||
`- External control: <${externalHref}>`,
|
||||
].join("\n"),
|
||||
extraTags: [
|
||||
[
|
||||
"link-preview",
|
||||
"snapshot",
|
||||
"1",
|
||||
externalHref,
|
||||
"External preview survives",
|
||||
"Example",
|
||||
"Sender-authored external metadata",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
],
|
||||
],
|
||||
});
|
||||
},
|
||||
{ prId: PR_ID, issueId: ISSUE_ID, alicePubkey: ALICE_PUBKEY },
|
||||
{
|
||||
prId: PR_ID,
|
||||
issueId: ISSUE_ID,
|
||||
alicePubkey: ALICE_PUBKEY,
|
||||
externalHref: EXTERNAL_HREF,
|
||||
cloneHref: CLONE_HREF,
|
||||
},
|
||||
);
|
||||
|
||||
const row = page
|
||||
@@ -98,22 +126,10 @@ test("agent-style message with angle-bracket buzz:// links renders entity cards
|
||||
.last();
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
// The PR card resolves builder metadata from the signed root and repository.
|
||||
const prCard = row.locator('[data-link-preview="buzz-pull-request"]');
|
||||
await expect(prCard).toBeVisible();
|
||||
await expect(prCard).toContainText("relay-tools");
|
||||
await expect(prCard).toContainText(PR_SUBJECT);
|
||||
await expect(prCard).not.toContainText(
|
||||
"Open · fix/entity-cards → main · abc1230",
|
||||
);
|
||||
await expect(prCard).toHaveAttribute("data-image-state", "none");
|
||||
await expect(prCard.locator("[data-link-preview-thumbnail]")).toHaveCount(0);
|
||||
await expect(
|
||||
prCard.locator("[data-link-preview-hostname-buzz-mark]"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
prCard.locator("[data-link-preview-hostname-favicon]"),
|
||||
).toHaveCount(0);
|
||||
await expect(row.locator('[data-link-preview^="buzz-"]')).toHaveCount(0);
|
||||
const externalCard = row.locator('[data-link-preview="generic-link"]');
|
||||
await expect(externalCard).toBeVisible();
|
||||
await expect(externalCard).toContainText("External preview survives");
|
||||
const prChip = row.getByRole("button", {
|
||||
name: /Open pull request .* in repository relay-tools/,
|
||||
});
|
||||
@@ -158,9 +174,7 @@ test("agent-style message with angle-bracket buzz:// links renders entity cards
|
||||
expect(tooltipSemanticColors.actual).toEqual(tooltipSemanticColors.expected);
|
||||
await expect(prChip).toHaveText("relay-tools");
|
||||
|
||||
const issueChip = row.getByRole("button", {
|
||||
name: /Open issue .* in repository relay-tools/,
|
||||
});
|
||||
const issueChip = row.locator('[data-buzz-link-kind="issue"]');
|
||||
// The issue chip is the repository name alone — resolved metadata never
|
||||
// reaches the inline label, so it neither absorbs the title nor falls back
|
||||
// to the event hash.
|
||||
@@ -193,29 +207,21 @@ test("agent-style message with angle-bracket buzz:// links renders entity cards
|
||||
issueTooltip.locator('[data-buzz-tooltip-metadata-type=""]'),
|
||||
).toHaveText("Issue · relay-tools");
|
||||
|
||||
// The repository card uses its signed announcement metadata and remains
|
||||
// image-less.
|
||||
const repoCard = row
|
||||
.locator('[data-link-preview="buzz-repository"]')
|
||||
.filter({ hasText: "relay-tools" });
|
||||
await expect(repoCard).toBeVisible();
|
||||
await expect(repoCard).toContainText("relay-tools");
|
||||
await expect(repoCard).toContainText(
|
||||
"Operator tooling and admin CLI for relay deployments.",
|
||||
);
|
||||
await expect(repoCard).toContainText("active · default: main");
|
||||
await expect(repoCard).toHaveAttribute("data-image-state", "none");
|
||||
await expect(repoCard.locator("[data-link-preview-thumbnail]")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
const labeledIssue = row
|
||||
.getByRole("button", { name: /Open issue .* in repository relay-tools:/ })
|
||||
.filter({ hasText: "triage this issue" });
|
||||
await expect(labeledIssue).toBeVisible();
|
||||
await labeledIssue.hover();
|
||||
await expect(
|
||||
repoCard.locator("[data-link-preview-hostname-buzz-mark]"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
repoCard.locator("[data-link-preview-hostname-favicon]"),
|
||||
).toHaveCount(0);
|
||||
page
|
||||
.getByRole("tooltip")
|
||||
.locator('[data-buzz-tooltip-metadata-content=""]'),
|
||||
).toHaveText(ISSUE_SUBJECT);
|
||||
|
||||
// Repository metadata remains available from its inline chip.
|
||||
const repoChip = row.getByRole("button", {
|
||||
name: "Open repository relay-tools",
|
||||
exact: true,
|
||||
});
|
||||
await repoChip.hover();
|
||||
const repoTooltip = page.getByRole("tooltip");
|
||||
@@ -225,6 +231,23 @@ test("agent-style message with angle-bracket buzz:// links renders entity cards
|
||||
await expect(
|
||||
repoTooltip.locator('[data-buzz-tooltip-metadata-type=""]'),
|
||||
).toHaveText("Repository");
|
||||
|
||||
const labeledClone = row
|
||||
.getByRole("button", { name: "Open repository relay-tools" })
|
||||
.filter({ hasText: "clone relay-tools" });
|
||||
await expect(labeledClone).toBeVisible();
|
||||
await expect(labeledClone).toHaveClass(/underline/);
|
||||
await expect(labeledClone).not.toHaveClass(/wrapping-inline-chip/);
|
||||
await expect(labeledClone).toHaveAttribute("title", CLONE_HREF);
|
||||
await labeledClone.hover();
|
||||
const cloneTooltip = page.getByRole("tooltip");
|
||||
await expect(
|
||||
cloneTooltip.locator('[data-buzz-tooltip-metadata-content=""]'),
|
||||
).toContainText("Operator tooling and admin CLI for relay deployments.");
|
||||
await labeledClone.click();
|
||||
await expect(page.locator("[data-project-detail-screen]")).toBeVisible();
|
||||
await page.getByTestId("channel-general").click();
|
||||
|
||||
const missingRepoChip = row.getByRole("button", {
|
||||
name: "Open repository missing-repo",
|
||||
});
|
||||
@@ -257,16 +280,11 @@ test("agent-style message with angle-bracket buzz:// links renders entity cards
|
||||
await expect(missingRepoFooter).toHaveText("Repository");
|
||||
await expect(missingRepoFooter).toHaveClass(/text-secondary-foreground\/80/);
|
||||
await expect(missingRepoFooter).not.toHaveClass(/text-primary-foreground/);
|
||||
// Default typography is 14px; keep the image-less card compact while
|
||||
// allowing fractional line-height rounding across rendering platforms.
|
||||
expect(
|
||||
await repoCard.evaluate((card) => card.getBoundingClientRect().height),
|
||||
).toBeLessThan(90);
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
path: `${SHOTS}/01-recipient-entity-cards.png`,
|
||||
path: `${SHOTS}/01-recipient-entity-chips.png`,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -384,7 +402,7 @@ test("entity tooltip uses project context while relay metadata is delayed", asyn
|
||||
).toHaveText("buzz · The complete Buzz community platform.");
|
||||
});
|
||||
|
||||
test("desktop composer shows entity card and send is not blocked by missing snapshot", async ({
|
||||
test("desktop composer and sent message keep Buzz entities chip-only", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page);
|
||||
@@ -395,37 +413,98 @@ test("desktop composer shows entity card and send is not blocked by missing snap
|
||||
const repoLink = `buzz://repo?owner=${ALICE_PUBKEY}&d=relay-tools`;
|
||||
await page.getByTestId("message-input").fill(`Check out ${repoLink}`);
|
||||
|
||||
// buzz:// links never produce snapshot tags, so the composer card must
|
||||
// show as done (not stuck "processing") with zero ready snapshots.
|
||||
const composerCard = page
|
||||
.locator("[data-composer-link-previews]")
|
||||
.locator('[data-link-preview="buzz-repository"]');
|
||||
await expect(composerCard).toBeVisible();
|
||||
await expect(page.locator("[data-composer-link-previews]")).toHaveAttribute(
|
||||
"data-ready-snapshot-count",
|
||||
"0",
|
||||
);
|
||||
// Buzz-native links do not enter the standalone composer-preview surface.
|
||||
await expect(page.locator("[data-composer-link-previews]")).toHaveCount(0);
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
path: `${SHOTS}/02-composer-entity-card.png`,
|
||||
path: `${SHOTS}/02-composer-entity-chip-only.png`,
|
||||
});
|
||||
|
||||
await page.getByTestId("send-message").click();
|
||||
|
||||
const row = page.getByTestId("message-row").last();
|
||||
const repoCard = row.locator('[data-link-preview="buzz-repository"]');
|
||||
await expect(repoCard).toBeVisible();
|
||||
await expect(repoCard).toContainText("relay-tools");
|
||||
await expect(row.locator('[data-link-preview^="buzz-"]')).toHaveCount(0);
|
||||
const repoChip = row.getByRole("button", {
|
||||
name: "Open repository relay-tools",
|
||||
});
|
||||
await expect(repoChip).toBeVisible();
|
||||
await repoChip.hover();
|
||||
await expect(
|
||||
page
|
||||
.getByRole("tooltip")
|
||||
.locator('[data-buzz-tooltip-metadata-content=""]'),
|
||||
).toContainText("Operator tooling and admin CLI for relay deployments.");
|
||||
|
||||
await waitForAnimations(page);
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
path: `${SHOTS}/03-sent-entity-card.png`,
|
||||
path: `${SHOTS}/03-sent-entity-chip-tooltip.png`,
|
||||
});
|
||||
});
|
||||
|
||||
// The composer and the renderer must classify a same-relay clone URL
|
||||
// identically. Both read the active relay origin: without it the composer
|
||||
// treats the URL as an external link, shows a loading standalone card, and
|
||||
// enters sender-snapshot fetching — a preview the sent message then
|
||||
// contradicts by rendering the same URL as a repository chip.
|
||||
test("composer classifies a same-relay clone URL as a repository chip, not a card", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The default mock relay is http://localhost:3000, and generic external
|
||||
// previews are HTTPS-only — an http clone URL is unclassifiable either way,
|
||||
// so the mismatch cannot appear. Point the bridge at an https relay so the
|
||||
// composer's classification is the only variable.
|
||||
const relayHttpUrl = "https://relay.e2e.example";
|
||||
const cloneHref = `${relayHttpUrl}/git/${DEFAULT_MOCK_PUBKEY}/buzz.git`;
|
||||
await installBridge(page, {
|
||||
mode: "mock",
|
||||
relayHttpUrl,
|
||||
relayWsUrl: relayHttpUrl.replace(/^https/, "wss"),
|
||||
// Serve external metadata so a misclassified clone URL produces a
|
||||
// persistent standalone card rather than a card that loads and then
|
||||
// disappears on a null result — the assertions below would otherwise pass
|
||||
// by racing the loading window instead of by correct classification.
|
||||
mock: {
|
||||
linkPreviewMetadata: {
|
||||
title: "Misclassified as external",
|
||||
siteName: "relay.e2e.example",
|
||||
description: "Sender-snapshot metadata for an external link",
|
||||
imageDataUrl: null,
|
||||
imageDomain: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await page.getByTestId("channel-general").click();
|
||||
|
||||
await page.getByTestId("message-input").fill(`Clone it: ${cloneHref}`);
|
||||
// Past the composer's 350ms resolution debounce: a same-relay clone URL must
|
||||
// never reach the standalone preview surface, loading card included.
|
||||
await expect(page.locator("[data-composer-link-previews]")).toHaveCount(0);
|
||||
await page.waitForTimeout(600);
|
||||
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.locator("[data-link-preview]")).toHaveCount(0);
|
||||
const repoChip = row.getByRole("button", { name: "Open repository buzz" });
|
||||
await expect(repoChip).toBeVisible();
|
||||
await repoChip.hover();
|
||||
await expect(
|
||||
page
|
||||
.getByRole("tooltip")
|
||||
.locator('[data-buzz-tooltip-metadata-content=""]'),
|
||||
).toContainText("Relay, desktop, and mobile clients");
|
||||
|
||||
// The chip navigates in-app, proving the clone URL resolved onto the
|
||||
// canonical buzz://repo target rather than being handed to the OS.
|
||||
await repoChip.click();
|
||||
await expect(page.locator("[data-project-detail-screen]")).toBeVisible();
|
||||
});
|
||||
|
||||
test("reopening the same entity link reapplies its workspace state", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user