mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
## Overview **Category:** improvement **User impact:** Link previews appear in the composer and travel as privacy-safe sender-authored snapshots, so recipients never contact the linked site merely by opening a conversation. **Problem:** Cold-cache link paste could freeze the composer before the URL painted; recipient-side unfurling leaked visits; invalid or unresolved preview work could interfere with sending or leave dead cards behind. **Solution:** Paint pasted links before starting cold resolver work, resolve only in the sender's composer, attach only complete validated snapshots at Send, and render authored snapshots without recipient fallback fetching. ## Behavior - **Cold paste stays responsive:** bare and angle-bracket URL paste paths commit the visible link before resolver work begins. - **Sender-only fetching:** metadata is resolved while composing; recipients render only the sender-authored snapshot. - **Send never waits:** pending, failed, invalid, and unsendable previews are omitted. They do not block or cancel the message. - **Terminal misses disappear:** failed, timed-out, or 404 resolver results remove the composer card while preserving visible link text. - **Display-text links work:** Markdown links such as `[review the pull request](…)` produce and send the same snapshots as bare URLs. - **Compact and Rich presentation:** Compact remains the default; Rich preserves source description line breaks and paragraphs. - **Immediate draft-wide dismissal:** clicking × immediately hides all previews for the draft, suppresses links pasted later, and emits only `["link-preview", "none"]`. No confirmation detour. Suppression resets after send or clearing the draft. - **Zero recipient fallback:** missing, stale, malformed, off-relay, unsupported, or suppressed snapshots remain ordinary visible links; recipients never regenerate them. ## Implementation - Resolve previews from deferred composer URL state so paste can paint first. - Upload finished preview media to the active community relay and snapshot only valid, sendable media references. - Atomically capture ready snapshots at submit time; never append a late preview after send. - Validate snapshot and suppression tags in desktop/native and relay ingestion, rejecting duplicate or mixed forms. - Render composer previews as stable 55px attachment cards at desktop and narrow widths. - Add deterministic E2E coverage for cold paste, ready/pending/failed/invalid previews, display-text links, multiline Rich descriptions, immediate dismissal, later-pasted links, and suppression reset. ## Validation Validated head: `9807ba8952f190e76153834abf8ab61dd40be5e2` - Push hooks passed: `check-push-org`, branch skew, desktop check, mobile tests, desktop tests, Rust tests, and desktop Tauri checks. - Focused screenshot E2E at the validated head: 5/5 passed across Compact/Rich composer and recipient states, 800px/420px geometry, display-text links, multiline descriptions, and immediate dismissal. - PR CI was triggered for this exact head and is currently running; completed checks are green at the time of this update. - Worktree is clean and both PR head and validated branch resolve to `9807ba895…`. ## Screenshots ### Compact composer | Loading | Ready | |---|---| |  |  | ### Rich composer | Loading | Ready | |---|---| |  |  | ### Responsive composer | 800px loading | 800px ready | |---|---| |  |  | | 420px loading | 420px ready | |---|---| |  |  | ### Recipient presentation | Compact | Rich | |---|---| |  |  | ### Display-text Markdown link | Composer | Recipient | |---|---| |  |  | ### Rich multiline description  ### Immediate dismissal | Before × | Immediately after × | |---|---| |  |  | --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
180 lines
6.7 KiB
TypeScript
180 lines
6.7 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import type { Page } from "@playwright/test";
|
|
|
|
import { installMockBridge } from "../helpers/bridge";
|
|
|
|
// ⌘K / Ctrl+K behaviour around the composer:
|
|
// - With composer text selected → open the link-edit dialog (the shortcut the
|
|
// formatting toolbar has always advertised on its link button).
|
|
// - With a caret inside an existing composer link → open the same dialog
|
|
// seeded with that link.
|
|
// - With an empty caret in the composer (no selection, no link) → fall
|
|
// through to the app-wide quick-search dialog.
|
|
// - With focus outside the composer → quick search, unchanged.
|
|
|
|
async function openGeneral(page: Page) {
|
|
await page.goto("/");
|
|
await page.getByTestId("channel-general").click();
|
|
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
|
}
|
|
|
|
test("⌘K with selected composer text opens the add-link dialog", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page);
|
|
await openGeneral(page);
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.click();
|
|
await input.pressSequentially("check out this link");
|
|
await page.keyboard.press("ControlOrMeta+a");
|
|
await page.keyboard.press("ControlOrMeta+k");
|
|
|
|
const dialog = page.getByRole("dialog", { name: "Add link" });
|
|
await expect(dialog).toBeVisible();
|
|
// Seeded with the selected text as the display value.
|
|
await expect(dialog.getByLabel("Display text")).toHaveValue(
|
|
"check out this link",
|
|
);
|
|
// Quick search must NOT have opened.
|
|
await expect(page.getByTestId("search-dialog-input")).toHaveCount(0);
|
|
});
|
|
|
|
test("⌘K with caret inside an existing link opens the edit-link dialog", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page);
|
|
await openGeneral(page);
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.click();
|
|
// Create a real link through the ⌘K flow first.
|
|
await input.pressSequentially("docs");
|
|
await page.keyboard.press("ControlOrMeta+a");
|
|
await page.keyboard.press("ControlOrMeta+k");
|
|
const addDialog = page.getByRole("dialog", { name: "Add link" });
|
|
await expect(addDialog).toBeVisible();
|
|
await addDialog.getByLabel("URL").fill("https://example.com");
|
|
await addDialog.getByRole("button", { name: "Save" }).click();
|
|
await expect(addDialog).toHaveCount(0);
|
|
await expect(input.locator('a[href="https://example.com"]')).toHaveText(
|
|
"docs",
|
|
);
|
|
|
|
// Click into the linked text to place the caret inside it. This must not
|
|
// surface contextual controls; editing stays accessible through ⌘K.
|
|
await input.locator('a[href="https://example.com"]').click();
|
|
await expect(page.getByRole("button", { name: "Edit link" })).toHaveCount(0);
|
|
await expect(page.getByRole("button", { name: "Unlink" })).toHaveCount(0);
|
|
await page.keyboard.press("ControlOrMeta+k");
|
|
|
|
const editDialog = page.getByRole("dialog", { name: "Edit link" });
|
|
await expect(editDialog).toBeVisible();
|
|
await expect(editDialog.getByLabel("URL")).toHaveValue("https://example.com");
|
|
await expect(page.getByTestId("search-dialog-input")).toHaveCount(0);
|
|
});
|
|
|
|
test("⌘K with an empty composer caret still opens quick search", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page);
|
|
await openGeneral(page);
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.click();
|
|
await page.keyboard.press("ControlOrMeta+k");
|
|
|
|
await expect(page.getByTestId("search-dialog-input")).toBeVisible();
|
|
await expect(page.getByRole("dialog", { name: "Add link" })).toHaveCount(0);
|
|
});
|
|
|
|
test("⌘K with unselected composer text (caret only) opens quick search", async ({
|
|
page,
|
|
}) => {
|
|
await installMockBridge(page);
|
|
await openGeneral(page);
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.click();
|
|
await input.pressSequentially("draft in progress");
|
|
await page.keyboard.press("ControlOrMeta+k");
|
|
|
|
await expect(page.getByTestId("search-dialog-input")).toBeVisible();
|
|
await expect(page.getByRole("dialog", { name: "Add link" })).toHaveCount(0);
|
|
});
|
|
|
|
test("macOS plain Ctrl+K still kill-lines in the composer", async ({
|
|
page,
|
|
}) => {
|
|
// Regression guard for the Emacs-style Ctrl-K binding: on macOS the
|
|
// primary-modifier check must reject Control so `macEmacsTextShortcuts`
|
|
// keeps kill-line, and neither the link dialog nor quick search may open.
|
|
test.skip(process.platform !== "darwin", "mac-only Emacs binding");
|
|
|
|
await installMockBridge(page);
|
|
await openGeneral(page);
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.click();
|
|
await input.pressSequentially("kill this line");
|
|
// Emacs Ctrl-A → start of line (ProseMirror handles this natively on mac;
|
|
// "Home" is not reliable in headless Chromium).
|
|
await page.keyboard.press("Control+a");
|
|
await page.keyboard.press("Control+k");
|
|
|
|
await expect(input).not.toContainText("kill this line");
|
|
await expect(page.getByRole("dialog", { name: "Add link" })).toHaveCount(0);
|
|
await expect(page.getByTestId("search-dialog-input")).toHaveCount(0);
|
|
});
|
|
|
|
test("macOS Ctrl+A, Ctrl+E, and Ctrl+K stay within hard-break lines", async ({
|
|
page,
|
|
}) => {
|
|
test.skip(process.platform !== "darwin", "mac-only Emacs bindings");
|
|
|
|
await page.addInitScript(() => {
|
|
Object.defineProperty(navigator, "platform", { value: "MacIntel" });
|
|
});
|
|
await installMockBridge(page);
|
|
await openGeneral(page);
|
|
|
|
const input = page.getByTestId("message-input");
|
|
await input.click();
|
|
await input.pressSequentially("first");
|
|
await input.press("Shift+Enter");
|
|
await input.pressSequentially("middle");
|
|
await input.press("Shift+Enter");
|
|
await input.pressSequentially("last");
|
|
await expect(input.locator("br")).toHaveCount(2);
|
|
|
|
// Start/end movement applies to the third visual line, not the whole editor.
|
|
await page.keyboard.press("Control+a");
|
|
await input.pressSequentially("[");
|
|
await page.keyboard.press("Control+e");
|
|
await input.pressSequentially("]");
|
|
await expect(input).toHaveText("firstmiddle[last]");
|
|
|
|
// Kill only to this line's end, preserving both preceding lines.
|
|
await page.keyboard.press("Control+a");
|
|
await page.keyboard.press("Control+k");
|
|
await expect(input).toHaveText("firstmiddle");
|
|
await expect(input.locator("br")).toHaveCount(3);
|
|
|
|
// At end-of-line, kill the newline and join with the following line.
|
|
await page.keyboard.press("Control+b");
|
|
await page.keyboard.press("Control+k");
|
|
await expect(input).toHaveText("firstmiddle");
|
|
await expect(input.locator("br")).toHaveCount(1);
|
|
});
|
|
|
|
test("⌘K outside the composer opens quick search", async ({ page }) => {
|
|
await installMockBridge(page);
|
|
await openGeneral(page);
|
|
|
|
// Focus is on the page body — not the composer.
|
|
await page.getByTestId("chat-title").click();
|
|
await page.keyboard.press("ControlOrMeta+k");
|
|
|
|
await expect(page.getByTestId("search-dialog-input")).toBeVisible();
|
|
});
|