Frontend - Feed: DOM-windowing via @tanstack/svelte-virtual (new VirtualFeed). The window virtualizer keeps the sticky header, pull-to-refresh, infinite-scroll sentinel and bottom nav working. List = dynamic measured heights keyed by upload id + anchorTo:start so an SSE prepend doesn't jump a scrolled reader; grid = measured square rows. Drops the content-visibility band-aid and the old FeedGrid. measureElement(null) on row unmount prevents ResizeObserver retention; stable option callbacks + guarded setOptions avoid O(n) re-measure on like/comment patches. - Global: viewport-fit=cover (activates safe-area insets), lang=de, PWA manifest + maskable icon + apple-touch metas, prefers-reduced-motion, safe-area-top headers. - Feed reactivity: like/comment SSE patch the single card in place; upload-processed debounced in-place merge; IntersectionObserver leak fixed; shared 60s clock. - CLS: list cards reserve the skeleton aspect box. - Destructive actions (promote/demote/unban, gallery release) routed through ConfirmSheet (host + admin). - Export OOM: streamed download via single-use ticket instead of res.blob(). - Shared primitives: IconButton (44px hit area), scrollLock action; UploadSheet a11y. - Polish: api timeout + non-JSON guard, focus-trap offsetParent fix, pull-to-refresh passive:false + guards, diashow keyboard a11y, Toaster assertive errors, dark-mode gaps, aria-pressed on like/chip state, CameraCapture mic-on-video + retry, ConfirmSheet spinner, upload beforeunload warning, emoji->SVG icons. Backend - social.rs: like/comment SSE broadcasts now carry the fresh count so feed clients patch one card in place instead of refetching page 1. - admin.rs / main.rs: supporting changes for the above. Verified: svelte-check 0 errors, frontend build clean, /feed SSR 200. Runtime feed scroll-feel validation still owed (documented in FOLLOWUPS.md). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
94 lines
4.0 KiB
TypeScript
94 lines
4.0 KiB
TypeScript
/**
|
|
* Phase 3 mobile — long-press gesture.
|
|
*
|
|
* The `longpress` action attaches to `<article>` in FeedListCard and to
|
|
* grid cells in VirtualFeed (grid mode). Holding for ≥ 500 ms fires `onlongpress`,
|
|
* which opens the ContextSheet bottom sheet via the feed page's
|
|
* `contextTarget` state.
|
|
*
|
|
* Setup needs at least one upload to render a card. We post via the
|
|
* Phase 2 upload-client helper, then drive the gesture with the touch
|
|
* helper.
|
|
*/
|
|
import { test, expect } from '../../fixtures/test';
|
|
import { uploadRaw, JPEG_MAGIC } from '../../helpers/upload-client';
|
|
import { longPress } from '../../helpers/touch';
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
const SAMPLE_JPG = join(process.cwd(), 'fixtures', 'media', 'sample.jpg');
|
|
|
|
async function seedUpload(token: string, caption = 'Longpress fixture') {
|
|
const body = readFileSync(SAMPLE_JPG);
|
|
const res = await uploadRaw(token, body, {
|
|
filename: 'lp.jpg',
|
|
contentType: 'image/jpeg',
|
|
caption,
|
|
});
|
|
if (res.status !== 201) {
|
|
const text = await res.text();
|
|
throw new Error(`Upload seed failed (${res.status}): ${text}`);
|
|
}
|
|
return res.json() as Promise<{ id: string }>;
|
|
}
|
|
|
|
test.describe('Mobile — long-press gesture', () => {
|
|
test('long-press on a FeedListCard opens the ContextSheet', async ({ page, guest, signIn }) => {
|
|
const g = await guest('Lp1');
|
|
await seedUpload(g.jwt, 'Hold to open');
|
|
await signIn(page, g);
|
|
await page.goto('/feed');
|
|
|
|
// Wait for at least one article to render. Caption text appears once feed loads.
|
|
const card = page.locator('article').filter({ hasText: g.displayName }).first();
|
|
await expect(card).toBeVisible({ timeout: 10_000 });
|
|
|
|
await longPress(page, card, 600);
|
|
|
|
// The ContextSheet renders a dialog with role="dialog" + aria-modal="true".
|
|
// Multiple sheets (UploadSheet, ContextSheet) may be in the DOM — match the
|
|
// one that actually has aria-modal=true (i.e. the open one).
|
|
// ContextSheet is always mounted (it just translates off-screen when closed).
|
|
// Match the OPEN state by the `translate-y-0` class the component applies
|
|
// when `open === true`.
|
|
const sheet = page.locator('[role="dialog"][aria-modal="true"].translate-y-0');
|
|
await expect(sheet).toBeVisible({ timeout: 2_000 });
|
|
await expect(sheet.getByRole('button', { name: /abbrechen/i })).toBeVisible();
|
|
});
|
|
|
|
test('a quick tap (< 500 ms) does NOT open the ContextSheet — only opens the lightbox', async ({ page, guest, signIn }) => {
|
|
const g = await guest('Lp2');
|
|
await seedUpload(g.jwt, 'Quick tap');
|
|
await signIn(page, g);
|
|
await page.goto('/feed');
|
|
|
|
const card = page.locator('article').filter({ hasText: g.displayName }).first();
|
|
await expect(card).toBeVisible({ timeout: 10_000 });
|
|
|
|
// Simulate a short press (200 ms — well under the 500 ms threshold).
|
|
await longPress(page, card, 200);
|
|
|
|
// Within 1 s, no aria-modal=true dialog should be open (the ContextSheet
|
|
// is "open" only when its aria-modal flag is true).
|
|
// The ContextSheet stays mounted but `translate-y-0` is only set when open.
|
|
await expect(page.locator('[role="dialog"][aria-modal="true"].translate-y-0')).toHaveCount(0, { timeout: 1_000 });
|
|
});
|
|
|
|
test('long-press suppresses the click that lands at pointerup (no double-open of lightbox)', async ({ page, guest, signIn }) => {
|
|
const g = await guest('Lp3');
|
|
await seedUpload(g.jwt, 'Suppress click');
|
|
await signIn(page, g);
|
|
await page.goto('/feed');
|
|
|
|
const card = page.locator('article').filter({ hasText: g.displayName }).first();
|
|
await expect(card).toBeVisible({ timeout: 10_000 });
|
|
|
|
await longPress(page, card, 700);
|
|
|
|
// The longpress action sets `suppressNextClick = true` — so the lightbox
|
|
// (separate role=dialog) should NOT appear in addition to the context sheet.
|
|
// Exactly one aria-modal=true dialog should be open: the context sheet.
|
|
await expect(page.locator('[role="dialog"][aria-modal="true"]')).toHaveCount(1, { timeout: 2_000 });
|
|
});
|
|
});
|