diff --git a/Makefile.toml b/Makefile.toml index dfa6bad30..180a4aeb2 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -250,7 +250,11 @@ target/native/x86_64-unknown-linux-gnu/${BUILD_PROFILE}/web-container-tool sign description = "Build the Dioxus UI with example data" env = { UI_FEATURES = "example-data" } # build-chat-delegate is required because UI includes delegate WASM via include_bytes! -dependencies = ["build-chat-delegate"] +# build-tailwind is required because ui/assets/styles.css is gitignored (it is a +# build product). Without it `dx build` fails on a clean tree with +# "Asset at /assets/styles.css doesn't exist" — CI only gets away with omitting +# it here because build.yml runs `npm run build:css` as an explicit step. +dependencies = ["build-chat-delegate", "build-tailwind"] command = "dx" args = ["build", "--${BUILD_PROFILE}", "--features", "${UI_FEATURES}"] cwd = "./ui" @@ -259,7 +263,8 @@ cwd = "./ui" description = "Build the Dioxus UI without Freenet sync" env = { UI_FEATURES = "no-sync" } # build-chat-delegate is required because UI includes delegate WASM via include_bytes! -dependencies = ["build-chat-delegate"] +# build-tailwind is required — see the note on build-ui-example. +dependencies = ["build-chat-delegate", "build-tailwind"] command = "dx" args = ["build", "--${BUILD_PROFILE}", "--features", "${UI_FEATURES}"] cwd = "./ui" @@ -268,7 +273,10 @@ cwd = "./ui" description = "Build the Dioxus UI with example data and no Freenet sync" env = { UI_FEATURES = "example-data,no-sync" } # build-chat-delegate is required because UI includes delegate WASM via include_bytes! -dependencies = ["build-chat-delegate"] +# build-tailwind is required — see the note on build-ui-example. This is the +# task AGENTS.md tells you to run before the Playwright suite, so it has to +# work from a clean checkout. +dependencies = ["build-chat-delegate", "build-tailwind"] command = "dx" args = ["build", "--${BUILD_PROFILE}", "--features", "${UI_FEATURES}"] cwd = "./ui" @@ -787,7 +795,9 @@ dependencies = ["build-ui"] description = "Development build" env = { UI_FEATURES = "" } # build-chat-delegate is required because UI includes delegate WASM via include_bytes! -dependencies = ["build-chat-delegate"] +# build-tailwind is required — see the note on build-ui-example. (dev-example +# already had it; this task was the odd one out.) +dependencies = ["build-chat-delegate", "build-tailwind"] command = "dx" args = ["serve"] cwd = "./ui" diff --git a/ui/Cargo.toml b/ui/Cargo.toml index 6080ea703..e514460f1 100644 --- a/ui/Cargo.toml +++ b/ui/Cargo.toml @@ -28,7 +28,7 @@ rand.workspace = true getrandom = { version = "0.2.15", features = ["js", "wasm-bindgen", "js-sys"], default-features = false } # UI Framework -dioxus = { version = "0.7.3", features = ["web"] } +dioxus = { version = "0.7.9", features = ["web"] } # Only `fa_solid_icons` are referenced in the UI (verified by grep). Dropping # the brands/regular feature sets trims their icon modules from the build. dioxus-free-icons = { version = "0.10.0", features = ["font-awesome-solid"] } diff --git a/ui/src/components/room_list/edit_room_modal.rs b/ui/src/components/room_list/edit_room_modal.rs index 3d6fe6d68..9a4556ae3 100644 --- a/ui/src/components/room_list/edit_room_modal.rs +++ b/ui/src/components/room_list/edit_room_modal.rs @@ -4,6 +4,8 @@ use crate::components::app::{CURRENT_ROOM, EDIT_ROOM_MODAL, ROOMS}; use crate::util::ecies::{seal_for_room, unseal_bytes_with_secrets}; use dioxus::logger::tracing::{error, info, warn}; use dioxus::prelude::*; +use dioxus_free_icons::icons::fa_solid_icons::FaCopy; +use dioxus_free_icons::Icon; use freenet_scaffold::ComposableState; use river_core::room_state::configuration::{AuthorizedConfigurationV1, Configuration}; use river_core::room_state::privacy::{PrivacyMode, RoomDisplayMetadata}; @@ -150,12 +152,24 @@ pub fn EditRoomModal() -> Element { title: "Ed25519 public key (Curve25519 elliptic curve)", "Room Public Key" } - input { - r#type: "text", - readonly: true, - title: "Ed25519 public key (Curve25519 elliptic curve)", - class: "w-full px-3 py-2 bg-surface border border-border rounded-lg text-text-muted text-sm font-mono cursor-text select-all", - value: "{bs58::encode(room_data.owner_vk.as_bytes()).into_string()}" + div { + class: "flex items-center gap-2", + input { + r#type: "text", + readonly: true, + "data-testid": "room-public-key-input", + title: "Ed25519 public key (Curve25519 elliptic curve)", + // `select-text`, NOT `select-all` — see the note on + // `CopyButton` below. `user-select: all` makes this + // field completely unselectable in Firefox. + class: "flex-1 min-w-0 px-3 py-2 bg-surface border border-border rounded-lg text-text-muted text-sm font-mono cursor-text select-text", + value: "{bs58::encode(room_data.owner_vk.as_bytes()).into_string()}" + } + CopyButton { + value: bs58::encode(room_data.owner_vk.as_bytes()).into_string(), + testid: "room-public-key-copy-button", + label: "Copy room public key", + } } } // Contract ID @@ -165,11 +179,21 @@ pub fn EditRoomModal() -> Element { class: "block text-sm font-medium text-text-muted mb-1", "Contract ID" } - input { - r#type: "text", - readonly: true, - class: "w-full px-3 py-2 bg-surface border border-border rounded-lg text-text-muted text-sm font-mono cursor-text select-all", - value: "{room_data.contract_key.id()}" + div { + class: "flex items-center gap-2", + input { + r#type: "text", + readonly: true, + "data-testid": "contract-id-input", + // `select-text`, NOT `select-all` — see `CopyButton`. + class: "flex-1 min-w-0 px-3 py-2 bg-surface border border-border rounded-lg text-text-muted text-sm font-mono cursor-text select-text", + value: "{room_data.contract_key.id()}" + } + CopyButton { + value: room_data.contract_key.id().to_string(), + testid: "contract-id-copy-button", + label: "Copy contract ID", + } } } @@ -193,7 +217,9 @@ pub fn EditRoomModal() -> Element { input { r#type: "text", readonly: true, - class: "flex-1 px-3 py-2 bg-surface border border-border rounded-lg text-text-muted text-sm font-mono cursor-text select-all", + "data-testid": "secret-version-input", + // `select-text`, NOT `select-all` — see `CopyButton`. + class: "flex-1 min-w-0 px-3 py-2 bg-surface border border-border rounded-lg text-text-muted text-sm font-mono cursor-text select-text", value: "{secret_version}" } if is_owner { @@ -358,6 +384,56 @@ pub fn EditRoomModal() -> Element { } } +/// Copy-to-clipboard button for the read-only key fields above. +/// +/// # Why those inputs are `select-text` and must never be `select-all` +/// +/// The Room Public Key / Contract ID / Secret Version inputs used Tailwind's +/// `select-all` (`user-select: all`), which made them impossible to select or +/// copy by hand in Firefox: click-drag selected nothing, double-click selected +/// nothing (freenet/river#537). Firefox parses the declaration — the computed +/// value really is `all` — but selecting inside an `` under it yields a +/// zero-length selection, so `Ctrl+C` copies nothing. Chromium and WebKit +/// instead select the whole value, which is why this looked Firefox-specific. +/// +/// Measured with a standalone repro driven by Playwright (characters selected +/// by a click-drag across the field, then by a double-click): +/// +/// | input rule | Firefox | Chromium | WebKit | +/// |---------------------------|---------|----------|--------| +/// | `user-select: all` | **0** | 44 | 44 | +/// | `user-select: text` | 44 | 44 | 44 | +/// +/// So the fields declare `select-text` explicitly rather than relying on the +/// `auto` default. Do NOT "restore" `select-all` — it re-breaks Firefox. +/// Pinned by `ui/tests/room-info-key-selection.spec.ts`, which runs against +/// Firefox in CI. +/// +/// This is a child component rather than inline markup because the "Copied!" +/// feedback needs `use_signal`, and the fields render inside an `if let` +/// branch where a hook call would be conditional. +#[component] +fn CopyButton(value: String, testid: String, label: String) -> Element { + let mut copied = use_signal(|| false); + let value_for_clipboard = value.clone(); + + rsx! { + button { + r#type: "button", + "data-testid": "{testid}", + "aria-label": "{label}", + title: "{label}", + class: "flex-shrink-0 px-3 py-2 bg-surface hover:bg-surface-hover border border-border rounded-lg text-text-muted hover:text-text text-sm transition-colors flex items-center gap-1.5", + onclick: move |_| { + crate::util::copy_to_clipboard(&value_for_clipboard); + copied.set(true); + }, + Icon { icon: FaCopy, width: 12, height: 12 } + span { if *copied.read() { "Copied!" } else { "Copy" } } + } + } +} + #[component] fn RoomDescriptionField(config: Configuration, is_owner: bool) -> Element { let initial_desc = { diff --git a/ui/tests/room-info-key-selection.spec.ts b/ui/tests/room-info-key-selection.spec.ts new file mode 100644 index 000000000..1cd601892 --- /dev/null +++ b/ui/tests/room-info-key-selection.spec.ts @@ -0,0 +1,372 @@ +import { test, expect, Page, Locator } from "@playwright/test"; + +// Regression test for freenet/river#537: the Room Public Key and Contract ID +// in the room-details panel could not be selected or copied in Firefox. +// +// The fields carried Tailwind's `select-all` (`user-select: all`). Firefox +// parses that — the computed value really is `all` — but selecting inside an +// `` under it produces a ZERO-length selection, so click-drag and +// double-click both did nothing and Ctrl+C copied nothing. Chromium and WebKit +// select the whole value under the same rule, which is why the bug looked +// Firefox-specific. The fix declares `user-select: text` explicitly. +// +// This spec runs on every project in playwright.config.ts, so the Firefox +// project is the actual gate; chromium/webkit assert the fix did not regress +// the browsers that already worked. + +const ROOM_NAME = "Public Discussion Room"; + +// Playwright's WebKit build will not deliver a keyboard copy to a READONLY +// input. Isolated with a standalone page containing nothing but two inputs, +// one `readonly` and one editable — no River code involved: +// +// engine | readonly Ctrl+A | readonly mouse-select | readonly Ctrl+C | editable +// firefox | selects | selects | copy fires | all work +// chromium | selects | selects | copy fires | all work +// webkit | selects NOTHING | selects | NO copy event | all work +// +// So on WebKit only the KEYBOARD half is unobservable; mouse selection — the +// behaviour this spec exists to protect — works and is asserted on every +// project including webkit and mobile-safari. These fields were already +// `readonly` before the fix, so this is not a regression, and it is a property +// of the harness rather than of the app. Firefox is the browser from the bug +// report, so the Ctrl+C acceptance criterion is still genuinely gated. +const WEBKIT_KEYBOARD_COPY_SKIP = + "Playwright's WebKit does not deliver a keyboard copy to a readonly input (harness limitation, verified against a bare input outside River); mouse selection is still asserted on webkit."; + +async function waitForApp(page: Page) { + await page.waitForSelector(".app-root", { timeout: 30_000 }); + await expect(page.locator("aside, .app-root button")).not.toHaveCount(0); +} + +async function openRoomDetails(page: Page) { + const vp = page.viewportSize(); + if (vp && vp.width < 1024) { + await page.setViewportSize({ width: 1280, height: vp.height }); + } + + // Scope to the room list — once a room is selected the header button carries + // the room name as its accessible name too, which would be a second match. + const roomBtn = page.getByTestId("room-list").getByRole("button", { name: ROOM_NAME }); + await expect(roomBtn).toBeVisible({ timeout: 10_000 }); + await roomBtn.click(); + await expect(page.getByRole("heading", { name: ROOM_NAME })).toBeVisible({ timeout: 5_000 }); + + // The (i) affordance in the room header opens the room-details modal. + await page.getByTitle("Room details").click(); + await expect(page.getByTestId("edit-room-modal")).toBeVisible({ timeout: 5_000 }); +} + +/** + * Capture what the app actually writes to the clipboard. + * + * `crate::util::copy_to_clipboard` goes through `document.execCommand('copy')` + * on a throwaway off-screen textarea (so it works inside the gateway's + * sandboxed iframe), which is not readable via the Clipboard API. Patching + * `execCommand` lets us assert the COPIED TEXT rather than just the button's + * label — a button wired to the wrong field would still say "Copied!". + */ +async function captureClipboardWrites(page: Page) { + await page.evaluate(() => { + const w = window as unknown as { __clip: string[] }; + w.__clip = []; + const orig = document.execCommand.bind(document); + document.execCommand = function (cmd: string, ...rest: unknown[]) { + if (cmd === "copy") { + const active = document.activeElement as HTMLTextAreaElement | null; + let copied = active && "value" in active ? active.value : ""; + if (!copied) { + // `select()` is not guaranteed to focus, so fall back to the + // helper's signature: an off-screen fixed-position textarea. + const ta = Array.from(document.querySelectorAll("textarea")).find( + (t) => t.style.left === "-9999px" + ); + copied = ta?.value ?? ""; + } + w.__clip.push(copied); + } + return (orig as (c: string, ...r: unknown[]) => boolean)(cmd, ...rest); + } as typeof document.execCommand; + }); +} + +function clipboardWrites(page: Page) { + return page.evaluate(() => (window as unknown as { __clip: string[] }).__clip); +} + +/** Text the browser would put on the clipboard for `Ctrl+C` on this input. */ +function selectedText(input: Locator) { + return input.evaluate((el: HTMLInputElement) => + el.value.substring(el.selectionStart ?? 0, el.selectionEnd ?? 0) + ); +} + +async function clearSelection(input: Locator) { + await input.evaluate((el: HTMLInputElement) => el.setSelectionRange(0, 0)); +} + +async function dragAcross(page: Page, input: Locator) { + const box = await input.boundingBox(); + if (!box) throw new Error("input has no bounding box"); + const y = box.y + box.height / 2; + await page.mouse.move(box.x + 8, y); + await page.mouse.down(); + await page.mouse.move(box.x + box.width - 12, y, { steps: 12 }); + await page.mouse.up(); +} + +for (const field of [ + { testid: "room-public-key-input", label: "room public key" }, + { testid: "contract-id-input", label: "contract ID" }, +]) { + test.describe(`room-details ${field.label} is selectable`, () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + test(`click-drag selects the ${field.label}`, async ({ page }) => { + await page.goto("/"); + await waitForApp(page); + await openRoomDetails(page); + + const input = page.getByTestId(field.testid); + await expect(input).toBeVisible(); + const value = await input.inputValue(); + expect(value.length).toBeGreaterThan(0); + + await dragAcross(page, input); + + // Before the fix this was "" on Firefox. + const selected = await selectedText(input); + expect(selected.length).toBeGreaterThan(0); + // The drag starts/ends a few px inside the field, so on a value wider + // than the box it is a partial selection; either way it must be a real, + // contiguous run of the value. + expect(value).toContain(selected); + }); + + test(`double-click selects the ${field.label}`, async ({ page }) => { + await page.goto("/"); + await waitForApp(page); + await openRoomDetails(page); + + const input = page.getByTestId(field.testid); + const value = await input.inputValue(); + + await clearSelection(input); + await input.dblclick(); + + const selected = await selectedText(input); + expect(selected.length).toBeGreaterThan(0); + expect(value).toContain(selected); + }); + + test(`Ctrl+C copies a mouse selection of the ${field.label}`, async ({ page, browserName }) => { + test.skip(browserName === "webkit", WEBKIT_KEYBOARD_COPY_SKIP); + await page.goto("/"); + await waitForApp(page); + await openRoomDetails(page); + + const input = page.getByTestId(field.testid); + const value = await input.inputValue(); + + // Record what the browser actually hands to the clipboard. Reading the + // system clipboard needs per-browser permissions that Firefox does not + // grant in Playwright, so observe the copy event instead: its default + // payload is the field's current selection. + await input.evaluate((el: HTMLInputElement) => { + (window as unknown as { __copied?: string }).__copied = undefined; + el.addEventListener("copy", () => { + (window as unknown as { __copied?: string }).__copied = el.value.substring( + el.selectionStart ?? 0, + el.selectionEnd ?? 0 + ); + }); + }); + + // Drive the selection with the MOUSE, which is what the bug broke. + // Ctrl+A is deliberately not used here: keyboard select-all still works + // under `user-select: all`, so a Ctrl+A-driven copy passes even on the + // broken build and would make this test non-discriminating. + await clearSelection(input); + await input.dblclick(); + const selected = await selectedText(input); + expect(selected.length).toBeGreaterThan(0); + + await page.keyboard.press("ControlOrMeta+c"); + await expect + .poll(() => page.evaluate(() => (window as unknown as { __copied?: string }).__copied), { + timeout: 2_000, + }) + .toBe(selected); + expect(value).toContain(selected); + }); + + test(`Ctrl+A then Ctrl+C copies the whole ${field.label}`, async ({ page, browserName }) => { + test.skip(browserName === "webkit", WEBKIT_KEYBOARD_COPY_SKIP); + await page.goto("/"); + await waitForApp(page); + await openRoomDetails(page); + + const input = page.getByTestId(field.testid); + const value = await input.inputValue(); + + await input.evaluate((el: HTMLInputElement) => { + (window as unknown as { __copied?: string }).__copied = undefined; + el.addEventListener("copy", () => { + (window as unknown as { __copied?: string }).__copied = el.value.substring( + el.selectionStart ?? 0, + el.selectionEnd ?? 0 + ); + }); + }); + + await input.click(); + await page.keyboard.press("ControlOrMeta+a"); + expect(await selectedText(input)).toBe(value); + + await page.keyboard.press("ControlOrMeta+c"); + await expect + .poll(() => page.evaluate(() => (window as unknown as { __copied?: string }).__copied), { + timeout: 2_000, + }) + .toBe(value); + }); + + test(`selecting the ${field.label} does not close the modal`, async ({ page }) => { + await page.goto("/"); + await waitForApp(page); + await openRoomDetails(page); + + const input = page.getByTestId(field.testid); + await dragAcross(page, input); + await input.dblclick(); + + // Acceptance criterion: no surrounding click/modal/drag behaviour regresses. + await expect(page.getByTestId("edit-room-modal")).toBeVisible(); + }); + }); +} + +test.describe("room-details copy buttons", () => { + test.use({ viewport: { width: 1280, height: 800 } }); + + for (const field of [ + { + input: "room-public-key-input", + button: "room-public-key-copy-button", + other: "contract-id-input", + }, + { + input: "contract-id-input", + button: "contract-id-copy-button", + other: "room-public-key-input", + }, + ]) { + test(`${field.button} confirms the copy`, async ({ page }) => { + await page.goto("/"); + await waitForApp(page); + await openRoomDetails(page); + + const value = await page.getByTestId(field.input).inputValue(); + expect(value.length).toBeGreaterThan(0); + + const button = page.getByTestId(field.button); + await expect(button).toBeVisible(); + await expect(button).toHaveText(/Copy/); + + await button.click(); + await expect(button).toHaveText(/Copied!/, { timeout: 2_000 }); + }); + + test(`${field.button} copies THAT field's value, not another`, async ({ page }) => { + await page.goto("/"); + await waitForApp(page); + await openRoomDetails(page); + + const mine = await page.getByTestId(field.input).inputValue(); + const other = await page.getByTestId(field.other).inputValue(); + expect(mine.length).toBeGreaterThan(0); + expect(mine).not.toBe(other); + + await captureClipboardWrites(page); + await page.getByTestId(field.button).click(); + + // Asserting the button says "Copied!" is NOT enough: it would say that + // just the same if the two buttons' values were swapped. + await expect.poll(() => clipboardWrites(page), { timeout: 2_000 }).toEqual([mine]); + }); + } + + test("the copy feedback resets when the panel is closed and reopened", async ({ page }) => { + await page.goto("/"); + await waitForApp(page); + await openRoomDetails(page); + + const button = page.getByTestId("room-public-key-copy-button"); + await button.click(); + await expect(button).toHaveText(/Copied!/, { timeout: 2_000 }); + + // Same contract the Export Identity copy button holds + // (copy-clipboard-feedback.spec.ts): reopening must not show a stale + // "Copied!" from a previous visit. + // + // The waits below are deliberately generous. The panel is a `fixed + // inset-0` overlay, so the room-header (i) button only becomes actionable + // again once it has fully unmounted, and under the full suite's parallel + // load (five projects, each booting a WASM app) that settle can outrun a + // 5s budget on an otherwise-healthy run. This is contention, not a + // correctness question: an instrumented 25-cycle open/copy/close/reopen + // loop showed a stale label 0 times in both WebKit and Firefox. So wait + // for the reopen affordance explicitly instead of racing it. + await page.getByTestId("edit-room-close-button").click(); + await expect(page.getByTestId("edit-room-modal")).toHaveCount(0, { timeout: 15_000 }); + + const reopen = page.getByTitle("Room details"); + await expect(reopen).toBeVisible({ timeout: 15_000 }); + await reopen.click(); + await expect(page.getByTestId("edit-room-modal")).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId("room-public-key-copy-button")).toHaveText(/^Copy$/, { + timeout: 15_000, + }); + }); + + test("the room-details panel does not overflow horizontally", async ({ page }) => { + await page.goto("/"); + await waitForApp(page); + await openRoomDetails(page); + + // The copy buttons put the inputs in a flex row; `flex-1 min-w-0` must keep + // a long base58 value from widening the modal. + const modal = page.getByTestId("edit-room-modal"); + const overflow = await modal.evaluate( + (el) => el.scrollWidth - el.clientWidth + ); + expect(overflow).toBeLessThanOrEqual(1); + }); + + test("the panel still fits, with both copy buttons, at 320px", async ({ page }) => { + await page.goto("/"); + await waitForApp(page); + await openRoomDetails(page); + + // The narrow viewport is the whole point of this check: adding a button + // beside each value is exactly what could overflow a small screen, and + // `openRoomDetails` widens to desktop, so the other tests never see it. + // 320px is the smallest width responsive-layout.spec.ts covers. + await page.setViewportSize({ width: 320, height: 800 }); + const modal = page.getByTestId("edit-room-modal"); + await expect(modal).toBeVisible(); + + for (const testid of ["room-public-key-copy-button", "contract-id-copy-button"]) { + await expect(page.getByTestId(testid)).toBeVisible(); + } + + const overflow = await modal.evaluate((el) => el.scrollWidth - el.clientWidth); + expect(overflow).toBeLessThanOrEqual(1); + + // The modal itself must not be pushed outside the viewport either. + const docOverflow = await page.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth + ); + expect(docOverflow).toBeLessThanOrEqual(1); + }); +});