Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ All notable changes to Allthing are recorded here. The format follows [Keep a Ch

### Fixed

- A video sound badge handles Space release explicitly after touch input, retaining focus and playback without depending on a synthesized native click. Held-key repeats do not toggle sound, and Enter retains its native button action.
- Changing presence visibility or session while a heartbeat is in flight queues one refresh with the latest state, so the update is not lost behind the older response.
- Write buttons retain their dialog accessibility attributes during permission discovery and key approval. A follow confirmed on the first hand-off check preserves its new follower count when the reader was known not to follow beforehand.
- Leaving a page closes its popovers immediately, disposing nested previews as the kept feed detaches; Back does not restore fading panels while closing timers are delayed.
- Leaving Home reuses observed card heights when their exact current sizes still match, reducing layout reads before a post opens while preserving Back geometry and the existing fallback for late size changes or unsupported browsers.
Expand All @@ -36,6 +38,8 @@ All notable changes to Allthing are recorded here. The format follows [Keep a Ch

### Development

- Live sort checks wait for the interactive app; deep Back checks use Past week so posts aging out of a short range cannot masquerade as lost retained cards.
- Profile website checks retain bounded browser errors and failed-request diagnostics when the app fails to leave its boot shell.
- Post-opening performance checks retain bounded, sanitized hover, resource, frame, long-task and animation-frame diagnostics for each attempt, including failures. The click-to-post measurement, prefetch wait, sample count and strict 100 ms limit are unchanged.

- Pin the current Miniflare development dependency's Undici to 7.30.1, fixing its [EventSource memory](https://github.com/advisories/GHSA-4qv8-2p9j-2685), [integrity-check](https://github.com/advisories/GHSA-f72v-6w45-q472) and [content-encoding](https://github.com/advisories/GHSA-xjj6-f33q-929c) advisories. The version-scoped override leaves Wrangler, Miniflare and workerd unchanged; Wrangler 4.152.0 also still pins an affected version.
Expand Down
31 changes: 29 additions & 2 deletions crates/allthing-web/src/components/player.rs
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,7 @@ pub fn HlsVideo(
// so it decides, and changes at once rather than when the element's
// volumechange event comes back (a queued event could leave a quick
// second press reading a stale state).
let toggle_sound = move |_: ev::MouseEvent| {
let toggle_sound = move || {
let Some(video) = video_ref.get_untracked() else { return };
let sound = muted.get_untracked();
set_sound(sound);
Expand All @@ -589,6 +589,11 @@ pub fn HlsVideo(
show_sound();
}
};
// After touch input, a browser can deliver Space down/up to this
// focused badge without synthesizing its native click. Handle that
// release explicitly, cancelling the default to avoid two toggles in
// browsers that do synthesize it. Enter keeps the button's native action.
let sound_key_down = StoredValue::new(false);

// Media events are queued. A pause/ended event from an earlier state
// can arrive after play has resumed; it must not hide active controls.
Expand Down Expand Up @@ -675,7 +680,29 @@ pub fn HlsVideo(
>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5.5v13l11-6.5z"/></svg>
</button>
<button class="video-sound" type="button" on:click=toggle_sound>
<button
class="video-sound"
type="button"
on:click=move |_| toggle_sound()
on:keydown=move |event: ev::KeyboardEvent| {
if event.key() == " " {
event.prevent_default();
if !event.repeat() {
sound_key_down.set_value(true);
}
}
}
on:keyup=move |event: ev::KeyboardEvent| {
if event.key() == " " {
event.prevent_default();
if sound_key_down.get_value() {
sound_key_down.set_value(false);
toggle_sound();
}
}
}
on:blur=move |_| sound_key_down.set_value(false)
>
{move || if muted.get() { "Sound off · tap for sound" } else { "Sound on · tap to mute" }}
</button>
</Show>
Expand Down
8 changes: 8 additions & 0 deletions crates/allthing-web/src/components/presence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ thread_local! {
static JITTER: Cell<f64> = const { Cell::new(0.0) };
/// A beat of this tab's is on its way.
static BEATING: Cell<bool> = const { Cell::new(false) };
/// A reader changed visibility or session while a beat was in flight.
/// Coalesce changes, then send the latest state after that beat finishes.
static REFRESH_PENDING: Cell<bool> = const { Cell::new(false) };
/// The first beat's wait is over.
static STARTED: Cell<bool> = const { Cell::new(false) };
}
Expand Down Expand Up @@ -176,6 +179,9 @@ impl Presence {
/// again when the turn comes. Nothing while the tab is hidden, offline
/// or idle: the listeners look again.
fn tick(self, now_please: bool) {
if now_please {
REFRESH_PENDING.set(true);
}
clear_timer();
let now = js_sys::Date::now();
let kept = read();
Expand All @@ -198,7 +204,9 @@ impl Presence {
let at = kept.at as f64;
let last = (kept.at > 0).then_some(at);
let due = should_beat(now, last, LAST_INPUT.get(), visible, online, next_ms);
let now_please = REFRESH_PENDING.get();
if (due || now_please) && visible && online {
REFRESH_PENDING.set(false);
self.beat(kept, now, now_please);
return;
}
Expand Down
12 changes: 10 additions & 2 deletions e2e/allthing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ test.describe("front page", () => {
// This live read checks feed navigation without recording a presence beat.
await page.route("**/api/v1/presence", (route) => route.fulfill({ status: 404, json: { error: "Read-only test" } }));
await page.goto(at("/"));
await appCame(page);
await expect(firstCard(page)).toBeVisible();
const latestRead = PAGES ? null : page.waitForResponse((response) =>
new URL(response.url()).pathname === "/api/v1/latest" && response.request().method() === "GET",
Expand Down Expand Up @@ -898,11 +899,18 @@ test.describe("threads and navigation", () => {
await new Promise((done) => setTimeout(done, 2500));
await route.continue();
});
await page.goto(at("/"));
// Keep this geometry test independent of posts aging out of a short
// time window while the slow-navigation and hold checks are running.
await page.goto(at("/?t=week"));
await appCame(page);
const home = page.url();
await expect(page.getByRole("combobox", { name: "Time range", exact: true })).toHaveValue("week");
await expect(firstCard(page)).toBeVisible();
// Several pages deep: more than one server page of trending casts.
for (let i = 0; i < 40 && (await page.locator(".cast-card").count()) < atLeast + 10; i++) {
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
const more = page.getByRole("button", { name: "Load more posts", exact: true });
if (await more.isVisible() && await more.isEnabled()) await more.click();
await page.waitForTimeout(600);
}
const loaded = await page.locator(".cast-card").count();
Expand Down Expand Up @@ -937,7 +945,7 @@ test.describe("threads and navigation", () => {
await openAnchor();
await page.waitForTimeout(500);
await page.goBack();
await expect(page).toHaveURL(new RegExp(`${at("/")}$`));
await expect(page).toHaveURL(home);
// Where things are, for a failure's message: the anchor card's top and
// place in the list, the scroll offset and how much of the page exists.
const where = () =>
Expand Down
22 changes: 22 additions & 0 deletions e2e/presence.fixture.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,28 @@ const kept = (page: Page | BrowserContext, answer: Answer, at = Date.now()) =>
test.describe("in front of the Worker", () => {
test.skip(PAGES, "the static copy has no Worker to beat to");

test("a visibility change during an in-flight beat refreshes after its answer", async ({ page }) => {
await signIn(page, me);
await kept(page, { online: 2, readers: [ann], next: 240 }, 0);
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
const { beats } = await serve(page, () => ok(2, [ann]), gate);
await page.goto("/");
const chip = chipOf(page);
await expect(chip).toBeVisible();
await expect.poll(() => beats.length).toBe(1);
await chip.click();
const show = panelOf(page).getByRole("switch", { name: "Show me here" });
await show.click();
await expect(show).toHaveAttribute("aria-checked", "false");
expect(beats[0].body.hidden).toBe(false);
release();
await expect.poll(() => beats.length).toBe(2);
expect(beats[1].body).toEqual({ ...beats[0].body, hidden: true, refresh: true });
await expect(panelOf(page).locator(".presence-readers a")).toHaveCount(1);
await expect(panelOf(page).getByRole("link", { name: "@ann", exact: true })).toBeVisible();
});

for (const width of [1280, 921, 920, 390]) {
test(`the chip comes after the page's own call and moves nothing at width ${width}`, async ({ browser, page: desktop }, info) => {
const { page, close } = await opened(browser, desktop, info, width);
Expand Down
40 changes: 38 additions & 2 deletions e2e/profile-website.fixture.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import { expect, test, type Browser, type Locator, type Page, type TestInfo } from "@playwright/test";
import { expect, test, type Browser, type Locator, type Page, type Request, type TestInfo } from "@playwright/test";
import { NOW } from "./fixture-time";

/*
Expand All @@ -21,6 +21,19 @@ const REL = "noopener noreferrer nofollow ugc";
const longHref = `https://www.example.com/${"a-rather-long-path/".repeat(10)}end/`;
const longLabel = longHref.replace("https://www.", "").replace(/\/$/, "");

// A profile stuck at the boot shell needs its original browser failure,
// rather than only the later missing-name assertion. Keep diagnostics bounded
// and omit URL queries, which can contain account or authentication data.
const bootDiagnostics = new Map<TestInfo, { rows: string[]; pending: Map<Request, string> }>();
test.afterEach(async ({}, info) => {
const diagnostics = bootDiagnostics.get(info);
bootDiagnostics.delete(info);
if (diagnostics && info.status !== info.expectedStatus) {
const rows = [...diagnostics.rows, ...[...diagnostics.pending.values()].map((url) => `still pending: ${url}`)];
await info.attach("profile-boot-diagnostics", { body: rows.join("\n"), contentType: "text/plain" });
}
});

type Website = { href: string; label: string };
// What each profile's payload says of the account's website: as Allthing's
// build makes it, or, cleared and hostile, as another server might send it.
Expand Down Expand Up @@ -84,7 +97,30 @@ async function at(browser: Browser, info: TestInfo, width: number) {
const context = await browser.newContext({
baseURL: info.project.use.baseURL, viewport: { width, height: phone ? 844 : 900 }, isMobile: phone, hasTouch: phone,
});
return { context, page: await context.newPage() };
const page = await context.newPage();
const rows: string[] = [];
const pending = new Map<Request, string>();
bootDiagnostics.set(info, { rows, pending });
const record = (row: string) => {
if (rows.length < 40) rows.push(row.replace(/https?:\/\/[^\s)]+/g, (raw) => {
try {
const url = new URL(raw);
return `${url.origin}${url.pathname}`;
} catch { return "[invalid URL]"; }
}).slice(0, 4000));
};
page.on("pageerror", (error) => record(`pageerror: ${error.stack ?? error.message}`));
page.on("request", (request) => {
const url = new URL(request.url());
if (pending.size < 40 && /\.(?:wasm|js)$/.test(url.pathname)) pending.set(request, `${url.origin}${url.pathname}`);
});
page.on("requestfinished", (request) => pending.delete(request));
page.on("requestfailed", (request) => {
pending.delete(request);
const url = new URL(request.url());
record(`requestfailed: ${url.origin}${url.pathname} ${request.failure()?.errorText}`);
});
return { context, page };
}

async function open(page: Page, name: string) {
Expand Down
17 changes: 16 additions & 1 deletion e2e/video-sound-focus.fixture.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ for (const width of [1280, 390]) {
await expect.poll(() => video.evaluate((v) => !v.muted && !v.paused)).toBe(true);
expect(await page.evaluate(() => localStorage.getItem("allthing.sound"))).toBe("on");
// Hold Space on the pointer-focused control until its keydown has
// been processed, then release it for the native button activation.
// been processed, then release it to activate once.
// Do not refocus: the same node must keep focus throughout.
const pointerFocused = await enabled.elementHandle();
await page.keyboard.down("Space");
Expand All @@ -103,6 +103,10 @@ for (const width of [1280, 390]) {
expect(await enabled.evaluate((button, original) => button === original, pointerFocused)).toBe(true);
await expect(enabled).toHaveAttribute("data-press", "down");
await expect.poll(() => video.evaluate((v) => !v.muted && !v.paused)).toBe(true);
// Repeated keydown must not toggle while Space remains held.
await page.keyboard.down("Space");
await expect(enabled).toBeFocused();
await expect.poll(() => video.evaluate((v) => !v.muted && !v.paused)).toBe(true);
} finally {
await page.keyboard.up("Space");
}
Expand All @@ -111,6 +115,17 @@ for (const width of [1280, 390]) {
expect(await page.evaluate(() => localStorage.getItem("allthing.sound"))).toBeNull();
expect(await page.evaluate(() => window.scrollY)).toBe(before);
expect(await video.getAttribute("src")).toBe(source);
// Enter retains the native button action, and another Space release
// toggles once on the same pointer-focused node.
await page.keyboard.press("Enter");
await expect(enabled).toBeFocused();
await expect.poll(() => video.evaluate((v) => !v.muted && !v.paused)).toBe(true);
await page.keyboard.press("Space");
await expect(sound).toBeFocused();
await expect.poll(() => video.evaluate((v) => v.muted && !v.paused)).toBe(true);
expect(await page.evaluate(() => localStorage.getItem("allthing.sound"))).toBeNull();
expect(await page.evaluate(() => window.scrollY)).toBe(before);
expect(await video.getAttribute("src")).toBe(source);
failed = false;
} finally {
if (failed) {
Expand Down
Loading