Files
buzz/desktop/tests/e2e/composer-link-shortcut.spec.ts
Taylor Ho 1922d49cb2 feat(desktop): adding rich link previews to messages (#3818)
## 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 |
|---|---|
| ![Compact composer
loading](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/compact-composer-loading.png)
| ![Compact composer
ready](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/compact-composer-ready.png)
|

### Rich composer

| Loading | Ready |
|---|---|
| ![Rich composer
loading](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/rich-composer-loading.png)
| ![Rich composer
ready](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/rich-composer-ready.png)
|

### Responsive composer

| 800px loading | 800px ready |
|---|---|
| ![800px composer
loading](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/composer-800-loading.png)
| ![800px composer
ready](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/composer-800-ready.png)
|

| 420px loading | 420px ready |
|---|---|
| ![420px composer
loading](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/composer-420-loading.png)
| ![420px composer
ready](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/composer-420-ready.png)
|

### Recipient presentation

| Compact | Rich |
|---|---|
| ![Recipient
compact](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/recipient-compact.png)
| ![Recipient
rich](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/recipient-rich.png)
|

### Display-text Markdown link

| Composer | Recipient |
|---|---|
| ![Display-text link in
composer](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/display-text-composer.png)
| ![Display-text link with recipient
preview](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/display-text-recipient.png)
|

### Rich multiline description

![Rich preview preserving description
paragraphs](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/rich-multiline-recipient.png)

### Immediate dismissal

| Before × | Immediately after × |
|---|---|
| ![Preview before immediate
dismissal](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/dismissal-before.png)
| ![Composer immediately after preview
dismissal](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/3818/dismissal-after.png)
|

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
2026-08-07 10:56:08 -07:00

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();
});