Pull the mail list down to sync on a phone (#163)

Pulling the mail list down from its top, with a finger below md, runs the topbar's sync. A shared touch-gesture module decides what a touch is and who owns it, for the row swipes and long-press to come. Mouse, trackpad and wider windows are unchanged.

Takes over #115.

Co-authored-by: Flouss <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-15 20:29:01 +02:00
1 parent b940bd3a31
commit 550dd9cf89
18 files changed
+1553 -3

No files matched your search

+9
View File
@@ -8,6 +8,15 @@ The published image tags: `latest` follows the most recent release below,
## Unreleased
**Pull the mail list down to sync it, on a phone.** Reaching for the button at the top of the screen
is no longer the only way: pulling the mail list down from its top now syncs it, through the same
sync as that button. Drag past the *Release to sync* indicator and let go, and the sync icon spins
while the run lasts. It is for a finger on a phone-width screen only. A mouse, a trackpad and wider
windows behave as before, a list scrolled down scrolls as it always did, and a pull while the
sidebar is open does nothing. Reduced motion removes the indicator's slide. No schema change, no
deployment change.
[#163](https://github.com/karatektus/pl_mail/pull/163), by @Flouss.
**An installed plMail no longer says "New mail" when nothing arrived.** The notification on a phone
or browser was sent for every change to a message — opening a conversation, marking it read,
starring, archiving, a spam move, a copy of something you sent — and again a few minutes later when
@@ -0,0 +1,343 @@
import { Controller } from "@hotwired/stimulus";
import { motionIsOff } from "../../motion.js";
import { Intent, claimSequence, createSequence, releaseSequence } from "../../touch_gesture.js";
/**
* Pull the mail list down from its top to sync, on a phone.
*
* The list is the only thing that moves, and it does not move: the indicator is
* what slides down with the finger, so no row is touched while a pull is in
* progress. Release past the threshold runs the topbar's sync button, through its
* own controller, and the indicator rests in view with a spinning icon while that
* button reports a run in progress, whichever control started it.
*
* Mounted on #message-list, outside the list frame, so it survives a frame swap.
* Touches are read on that element, and the rows scroller is found at touchstart
* with closest(), because a frame visit replaces the scroller itself.
*
* Touch only, below md (invariant 1). Under a mouse nothing here runs.
*
* ┌─────────────────┬─────────────────────────────────────────────────────────┐
* │ Targets │ indicator (the slide with the finger), icon (spins) │
* ├─────────────────┼─────────────────────────────────────────────────────────┤
* │ Reaches │ the topbar's mail--sync-now controller, by lookup, to │
* │ │ call run(): its running guard and toast, not a click │
* ├─────────────────┼─────────────────────────────────────────────────────────┤
* │ Listens for │ mail--sync-now:started / :stopped on the document │
* ├─────────────────┼─────────────────────────────────────────────────────────┤
* │ Routes │ none: the sync is the sync button's own POST │
* └─────────────────┴─────────────────────────────────────────────────────────┘
*
* Constants, all derived from the owner-confirmed defaults:
* PULL_RESISTANCE = 0.5. The indicator shows finger travel x 0.5.
* RELEASE_DISPLAYED_PX = 70. Letting go past it syncs. Finger travel that
* reaches it is 70 / 0.5 = 140px. So a finger of 180px releases (displayed
* 90) and one of 120px does not (displayed 60).
* SYNC_RESTING_PX = 48. Where the indicator rests while a run is open.
*
* Thresholds are measured on finger displacement from touchstart, never on the
* rendered translation, so easing does not move them.
*
* A pull ends in one of three ways, and only the first can sync:
* - touchend, of the finger that was pulling: release past the threshold syncs;
* - touchcancel (the system took the touch), or a second finger landing during
* the pull: the pull is ABORTED. The indicator returns to idle, the claim is
* released, and nothing syncs;
* - no touchend or touchcancel ever arrives: the element the finger was on was
* removed mid-pull, so events stop reaching this element. The next one-finger
* touchstart abandons the stale pull the same way an abort does, and nothing syncs.
*
* Touch moves are listened to with passive: false only while a sequence might be
* a pull: the listener is added at touchstart when the list is at its top, and
* removed as soon as the sequence is decided against a pull, or when it ends. A
* touchmove that is part of a pull is cancelable, and preventDefault is called
* only then, so a scroll that is not a pull is left to the browser.
*
* Reduced motion: the indicator's slide is removed under motionIsOff(), so a
* release snaps back rather than animating. Thresholds are unchanged.
*
* What is NOT here: the row-swipe and sheet gestures (later changes). They share
* the arbiter in touch_gesture.js, and this controller claims its sequence through
* claimSequence("pull"), so a claimed or open-surface sequence is never pulled.
*/
/** Displayed distance per pixel of finger travel. A pull resists by half. */
const PULL_RESISTANCE = 0.5;
/** Displayed distance at which letting go starts a sync. */
const RELEASE_DISPLAYED_PX = 70;
/**
* Where the indicator rests while a run is open, measured from the top of the list.
* The pill is 25px tall (6px padding each side around a 13px line), so its top is
* 48 - 25 = 23px below the list's top: in view, clear of the edge, and below the
* topbar's own line. Resting at 0 would leave it hidden, which is what this fixes.
*/
const SYNC_RESTING_PX = 48;
/** The list's scroller, as the list frame draws it. */
const ROWS = '[data-list-region="rows"]';
/** The topbar's sync button. Its controller's run() is the sync, not a click on the button. */
const SYNC_BUTTON = "[data-controller~='mail--sync-now']";
/** Below md: 48rem, the mobile_search_controller's query. */
const MOBILE = "(width < 48rem)";
const STATE = Object.freeze({
IDLE: "idle",
PULLING: "pulling",
RELEASE: "release",
SYNCING: "syncing",
});
/**
* Where the indicator rests, as a transform. Hidden when idle; at the pull's own
* distance while pulling; at SYNC_RESTING_PX while a run is open, so the spinner is
* in view for as long as the sync is.
*/
function restingTransform(state, displayed) {
if (state === STATE.IDLE) {
return "";
}
const travel = state === STATE.SYNCING ? SYNC_RESTING_PX : displayed;
return `translateY(calc(-100% + ${travel}px))`;
}
export default class extends Controller {
static targets = ["indicator", "icon"];
connect() {
this._syncing = false;
this._pulling = false;
this._claimed = false;
this._sequence = null;
this._rows = null;
this._displayed = 0;
this._onStart = this._touchStart.bind(this);
this._onMove = this._touchMove.bind(this);
this._onEnd = this._touchEnd.bind(this);
this._onCancel = this._touchCancel.bind(this);
this._onStarted = () => this._setSyncing(true);
this._onStopped = () => this._setSyncing(false);
this.element.addEventListener("touchstart", this._onStart, { passive: true });
this.element.addEventListener("touchend", this._onEnd, { passive: true });
this.element.addEventListener("touchcancel", this._onCancel, { passive: true });
document.addEventListener("mail--sync-now:started", this._onStarted);
document.addEventListener("mail--sync-now:stopped", this._onStopped);
this._render();
}
disconnect() {
this._abort();
this.element.removeEventListener("touchstart", this._onStart);
this.element.removeEventListener("touchend", this._onEnd);
this.element.removeEventListener("touchcancel", this._onCancel);
document.removeEventListener("mail--sync-now:started", this._onStarted);
document.removeEventListener("mail--sync-now:stopped", this._onStopped);
}
// ── Private ───────────────────────────────────────────────────────────
/**
* A sequence may become a pull only if it starts on the rows scroller, while
* the list is at its very top. The scrollTop test is in one place, here, so
* the decision and the arming cannot disagree about it.
*/
_mayStart() {
return this._rows !== null && this._rows.scrollTop === 0;
}
_touchStart(event) {
// A second finger, or more, ends any pull in progress. It is never a pull, and
// whatever the first finger does afterwards cannot sync it.
if (event.touches.length !== 1) {
this._abort();
return;
}
// A one-finger touchstart begins a new sequence, so whatever this controller still
// holds is stale: its touchend or touchcancel may never reach us, because the
// element the finger was on was removed mid-gesture. Abandon it, with no sync.
this._abort();
this._sequence = null;
this._rows = null;
if (window.matchMedia(MOBILE).matches !== true) {
return;
}
const rows = event.target.closest?.(ROWS);
if (!rows || !this.element.contains(rows)) {
return;
}
const touch = event.touches[0];
this._rows = rows;
this._origin = { x: touch.clientX, y: touch.clientY, at: event.timeStamp };
this._sequence = createSequence();
if (this._mayStart()) {
// Armed for this sequence only. See the class docblock on why this is
// added here and not permanently.
this.element.addEventListener("touchmove", this._onMove, { passive: false });
}
}
_touchMove(event) {
if (this._sequence === null || event.touches.length !== 1) {
return;
}
const touch = event.touches[0];
const dx = touch.clientX - this._origin.x;
const dy = touch.clientY - this._origin.y;
if (this._pulling === false) {
const intent = this._sequence.move(dx, dy, event.timeStamp - this._origin.at);
if (intent === Intent.UNDECIDED) {
return;
}
// Decided, and not a pull: a vertical move downward, from the top, with
// nothing else claiming the sequence. Anything else is left to the browser.
if (intent !== Intent.VERTICAL || dy <= 0 || this._mayStart() === false || claimSequence("pull") === false) {
this._disarm();
return;
}
this._claimed = true;
this._pulling = true;
}
if (event.cancelable) {
event.preventDefault();
}
this._displayed = Math.max(0, dy * PULL_RESISTANCE);
this._render();
}
/**
* touchend: the finger lifted. Only a pull this controller holds can sync, and
* only past the threshold. A touchend for a sequence that never claimed releases
* nothing: the claim, if any, belongs to someone else.
*/
_touchEnd() {
this._disarm();
if (this._claimed === false) {
return;
}
// The rows scroller was replaced during the pull (a frame swap, or a refresh that
// rebuilt the list): the pull is no longer over the list it started on, so it aborts.
if (this._rows.isConnected === false) {
this._abort();
return;
}
const released = this._displayed >= RELEASE_DISPLAYED_PX;
this._release();
if (released) {
this._syncNow();
}
this._displayed = 0;
this._render();
}
/** touchcancel: the system took the touch. The pull is aborted, and nothing syncs. */
_touchCancel() {
this._abort();
}
/**
* The button's own run(): its running guard and its toast decide, so a pull
* during a sync is refused exactly as a click would be. A disabled button is
* not a guard here, because nothing clicks it.
*/
_syncNow() {
const button = document.querySelector(SYNC_BUTTON);
if (button === null) {
return;
}
this.application.getControllerForElementAndIdentifier(button, "mail--sync-now")?.run();
}
/** Stop listening for this sequence's moves, and forget it. */
_disarm() {
this.element.removeEventListener("touchmove", this._onMove);
this._sequence = null;
}
/** Give back this controller's claim, if it holds one. Never another consumer's. */
_release() {
if (this._claimed) {
releaseSequence("pull");
this._claimed = false;
}
this._pulling = false;
}
/** A pull that did not end in a release: listeners off, claim released, indicator home. No sync. */
_abort() {
this._disarm();
this._release();
this._displayed = 0;
this._render();
}
_setSyncing(on) {
this._syncing = on;
this._render();
}
_state() {
if (this._syncing) {
return STATE.SYNCING;
}
if (this._pulling) {
return this._displayed >= RELEASE_DISPLAYED_PX ? STATE.RELEASE : STATE.PULLING;
}
return STATE.IDLE;
}
_render() {
if (this.hasIndicatorTarget === false) {
return;
}
const state = this._state();
const indicator = this.indicatorTarget;
indicator.dataset.state = state;
indicator.style.transform = restingTransform(state, this._displayed);
// Under reduced motion the slide back is a jump, not an animation.
indicator.style.transition = motionIsOff() ? "none" : "";
if (this.hasIconTarget) {
this.iconTarget.classList.toggle("fa-spin", state === STATE.SYNCING);
}
}
}
@@ -35,6 +35,8 @@ export default class extends Controller {
this._running = true;
this._spin(true);
// Said here, after the guard, so a click that is refused says nothing.
this.dispatch("started");
let response = null;
@@ -124,10 +126,16 @@ export default class extends Controller {
this._schedule();
}
/**
* The one way a run ends, error paths included, so "stopped" is said once for
* every "started". Other controllers (pull to refresh) show the spinning state
* from these two events, whichever control started the run.
*/
_stop() {
clearTimeout(this._poll);
this._running = false;
this._spin(false);
this.dispatch("stopped");
}
_spin(on) {
@@ -17,6 +17,7 @@
// it back on the burger, and the burger's aria-expanded says which it is.
import { Controller } from "@hotwired/stimulus";
import { registerSurface, unregisterSurface } from "../../touch_gesture.js";
const DESKTOP = "(min-width: 768px)";
const RAIL_KEY = "plmail:sidebarRail";
@@ -28,6 +29,10 @@ export default class extends Controller {
connect() {
this._onKeydown = this._handleKeydown.bind(this);
// An open drawer is a surface no touch gesture may start over. Read from
// its own inert target, so it cannot disagree with what is on screen.
registerSurface("drawer", () => this.hasDrawerTarget && this.drawerTarget.inert === false);
// A drawer left open while rotating an iPad to landscape would otherwise
// stay stuck over the desktop layout, backdrop and all.
this._desktop = window.matchMedia(DESKTOP);
@@ -43,6 +48,7 @@ export default class extends Controller {
}
disconnect() {
unregisterSurface("drawer");
document.removeEventListener("keydown", this._onKeydown);
this._desktop.removeEventListener("change", this._onBreakpoint);
this._desktop.removeEventListener("change", this._onExpandedChange);
+43
View File
@@ -5638,3 +5638,46 @@ html[data-dnd-active] [data-dnd-ghost] {
outline: 2px solid rgb(var(--rgb-accent) / 0.7);
outline-offset: -2px;
}
/* ── Pull to refresh ───────────────────────────────────────────────────────
The indicator above a phone's mail list. pull_to_refresh_controller.js
moves it with the finger by an inline transform, so the idle position below
is the one a CSS rule owns: out of sight, above the top of the list. It is
removed from the flow (absolute), so the list does not move while it is
shown. Its slide back is a transition, which the controller removes under
reduced motion.
Unlayered, like the rules above. */
.mail-pull-indicator {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: 2;
width: fit-content;
margin-inline: auto;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.875rem;
border-radius: 9999px;
background: rgb(var(--rgb-raised));
color: rgb(var(--rgb-ink-muted));
font-size: 0.8125rem;
pointer-events: none;
transform: translateY(-100%);
transition: transform 200ms ease-out;
}
.mail-pull-indicator [data-label-for] {
display: none;
}
.mail-pull-indicator[data-state="pulling"] [data-label-for="pulling"],
.mail-pull-indicator[data-state="release"] [data-label-for="release"] {
display: inline;
}
.mail-pull-indicator[data-state="release"] {
color: var(--accent-text);
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Types for assets/touch_gesture.js, so the specs that import it are checked.
*
* A declaration file rather than `allowJs`, for the reason pdf_geometry.d.ts
* gives: the controllers are plain JavaScript and are exercised in the browser.
*/
/** The decision a sequence has reached. */
export type IntentValue = "undecided" | "hold" | "vertical" | "left" | "right";
export const DIRECTION_LOCK_PX: number;
export const HOLD_MS: number;
export const HOLD_SLOP_PX: number;
export const Intent: {
readonly UNDECIDED: "undecided";
readonly HOLD: "hold";
readonly VERTICAL: "vertical";
readonly LEFT: "left";
readonly RIGHT: "right";
};
export interface Sequence {
move(dx: number, dy: number, elapsedMs: number): IntentValue;
}
export function createSequence(): Sequence;
export function registerSurface(name: string, isOpen: () => boolean): void;
export function unregisterSurface(name: string): void;
export function openSurface(): string | null;
export function claimSequence(kind: string): boolean;
export function releaseSequence(kind: string): boolean;
+169
View File
@@ -0,0 +1,169 @@
/*
* touch_gesture.js — who owns a touch sequence.
*
* Several controllers read the same touches: the pull-to-refresh gesture on the
* list, and (in later changes) row swipes and the long-press sheet. Left to each
* of them, a touch that starts a swipe would also scroll the list, pull it, and
* open a hold. This module is the one place that decides what a touch IS.
*
* Three things live here, and nothing else:
*
* 1. The decision. A sequence is measured from its touchstart. Once its reach
* (max of |dx| and |dy|) is DIRECTION_LOCK_PX, it is decided: vertical or
* horizontal. A tie counts as vertical, so the scroll wins a diagonal that is
* exactly even. Holding still for HOLD_MS, with no movement past
* HOLD_SLOP_PX, decides a hold instead. Once decided, it stays decided.
* Movement past the slop cancels a future hold for the rest of the sequence,
* so a hold cannot be started by a finger that wandered and then rested.
*
* 2. The claim. One consumer owns a sequence. claimSequence() refuses a second
* claimant until its holder calls releaseSequence(kind) at touchend or touchcancel.
*
* 3. The open surface. A surface (the sidebar drawer, and later an open row or
* the action sheet) registers a predicate that reads its own state, so
* nothing here mirrors a DOM flag and goes stale. While any surface reports
* open, claimSequence() refuses: no gesture starts over an open surface.
*
* What it does NOT do: read the DOM, time anything, or see a touch event. The
* caller passes in displacement and elapsed milliseconds, which keeps every rule
* above testable in Node (see tests/e2e/touch-gesture-arbiter.spec.ts). A hold
* with no movement at all is only decided when the caller asks again after the
* time has passed, so a caller that wants the hold to fire while the finger is
* still must arrange its own timer and call move() from it.
*
* Measured on displacement from touchstart, never on the rendered translation,
* so the rules do not depend on any easing applied to what the user sees.
*/
/** Movement, in CSS pixels, after which a sequence is decided as vertical or horizontal. */
export const DIRECTION_LOCK_PX = 10;
/** Time, in milliseconds, a finger must rest before the sequence is a hold. */
export const HOLD_MS = 450;
/**
* Movement, in CSS pixels, a hold tolerates. Movement strictly past this cancels
* the hold for the rest of the sequence. Exactly this much does not.
*/
export const HOLD_SLOP_PX = 8;
/** What a sequence has been decided to be, or that it is still undecided. */
export const Intent = Object.freeze({
UNDECIDED: "undecided",
HOLD: "hold",
VERTICAL: "vertical",
LEFT: "left",
RIGHT: "right",
});
/** Open surfaces: name -> predicate. A surface is open while its predicate returns true. */
const surfaces = new Map();
/** Who owns the current sequence, or null when nobody does. */
let claimedBy = null;
/**
* A new per-sequence holder. Call move() on every sample with the displacement
* from touchstart and the time since it, and act on the returned Intent.
*
* @returns {{ move(dx: number, dy: number, elapsedMs: number): string }}
*/
export function createSequence() {
let intent = Intent.UNDECIDED;
let holdCancelled = false;
return {
move(dx, dy, elapsedMs) {
// Sticky: once decided, a later sample cannot change the answer.
if (intent !== Intent.UNDECIDED) {
return intent;
}
const reach = Math.max(Math.abs(dx), Math.abs(dy));
if (reach > HOLD_SLOP_PX) {
holdCancelled = true;
}
if (reach >= DIRECTION_LOCK_PX) {
if (Math.abs(dy) >= Math.abs(dx)) {
intent = Intent.VERTICAL;
} else {
intent = dx < 0 ? Intent.LEFT : Intent.RIGHT;
}
return intent;
}
if (!holdCancelled && elapsedMs >= HOLD_MS) {
intent = Intent.HOLD;
}
return intent;
},
};
}
/**
* Register a surface. `isOpen` is read every time a claim is asked for, so it
* must describe the surface's real state (a drawer reads its own `inert`), not a
* copy of it.
*
* @param {string} name
* @param {() => boolean} isOpen
*/
export function registerSurface(name, isOpen) {
surfaces.set(name, isOpen);
}
/** Remove a surface, on disconnect. */
export function unregisterSurface(name) {
surfaces.delete(name);
}
/** The name of the first registered surface that is open, or null. */
export function openSurface() {
for (const [name, isOpen] of surfaces) {
if (isOpen()) {
return name;
}
}
return null;
}
/**
* Claim the current sequence for `kind`. Refused while another consumer holds it
* and while any surface is open. The refusal is silent: the caller does nothing,
* and the touch stays with whatever the browser would do with it.
*
* @param {string} kind "pull", later "swipe" or "hold"
* @returns {boolean} true when the caller now owns the sequence
*/
export function claimSequence(kind) {
if (claimedBy !== null || openSurface() !== null) {
return false;
}
claimedBy = kind;
return true;
}
/**
* Release the claim, if `kind` holds it. A caller that does not hold the claim
* releases nothing and is told so: a touchend from a sequence that never claimed
* must not free the claim of a consumer that is still mid-gesture.
*
* @param {string} kind the consumer releasing, which must be the one that claimed
* @returns {boolean} true when the claim was held by `kind` and is now released
*/
export function releaseSequence(kind) {
if (claimedBy !== kind) {
return false;
}
claimedBy = null;
return true;
}
+13 -1
View File
@@ -1,4 +1,4 @@
<!-- translated-from: features/mail.md sha1:aafa725dd0343a8e15a1cb0e8f4ad13942ae2b05 -->
<!-- translated-from: features/mail.md sha1:1aee1ecf391427b1e6c35fd4f36b0f59ba68da1e -->
# Mail
@@ -684,6 +684,12 @@ und als sonst nichts — wo die Nachricht danach landet, ist Sache des Anbieters
**Aktualisieren** in der Werkzeugleiste reiht für jedes deiner aktiven Konten eine
Synchronisierung ein und dreht sich, bis diese Aufträge abgearbeitet sind.
Auf dem Telefon ist dieselbe Synchronisierung ein Ziehen: Zieh die Liste vom oberen Rand nach
unten, über den Hinweis *Loslassen zum Synchronisieren* hinaus, und lass los. Der Synchronisieren-
Knopf oben auf dem Bildschirm dreht sich, solange er läuft, und der Hinweis dreht sich mit. Ohne
Berührung ist der Knopf der Ersatz: ein benannter Knopf für Tastatur und Bildschirmleser, und die
Meldung zur Synchronisierung sagt, wann ein Lauf beginnt und endet.
### Tastenkürzel
Die einzelnen Tasten, die Gmail benutzt, funktionieren auch hier. Mit **?** bekommst du überall die
@@ -1304,3 +1310,9 @@ keine Zeit. Auf dem Radar wird sie fünfzehn Minuten nach Eingang der Mail angeb
dann, damit ein Code von letzter Woche nicht lesbar dort stehen bleibt; die Karte über der Mail
selbst behält ihn. plMail erfindet keine zehn Minuten, also kann die Karte dir nicht sagen, ob der
Code noch geht — das kann nur die Mail, aus der er stammt.
**Ziehen zum Aktualisieren gibt es nur mit dem Finger, und nur ganz oben in der Liste.** Maus,
Trackpad und jedes Fenster ab der Tablet-Breite ziehen nie. Ein Ziehen beginnt nur, wenn die Liste
ganz oben steht, und nicht bei offener Seitenleiste; von weiter unten scrollt dieselbe Bewegung nur.
Reduzierte Bewegung nimmt dem Hinweis das Gleiten und sonst nichts, der Auslösepunkt bleibt gleich.
+11
View File
@@ -620,6 +620,11 @@ and nothing else — where the message lands afterwards is the provider's busine
**Refresh** in the toolbar queues a sync for every active account you own and spins until those
jobs have drained.
On a phone the same sync is a pull: drag the list down from its top, past the indicator's
*Release to sync*, and let go. The sync button at the top of the screen spins while it runs, and
the indicator spins with it. Without touch, the sync button is the equivalent: it is a named button
for a keyboard or a screen reader, and the sync toast says when the run starts and when it ends.
### Keyboard shortcuts
The single keys Gmail uses work here too. Press **?** anywhere for the full list, or the
@@ -1123,6 +1128,12 @@ the search box says how far it has got, and until it says nothing at all, "meani
from this view" — the dialog is a shortcut to the same operation, across every account, nested labels
included.
**Pull to refresh is for a finger at the top of the list, and only there.** A mouse, a trackpad and
any window at the tablet breakpoint or wider never pull. A pull starts only while the list is
scrolled to its very top, and not while the sidebar is open: from further down, the same drag just
scrolls. Reduced motion removes the indicator's slide and nothing else, so the release point is the
same.
**A signature image has to be small to travel with the mail.** A picture in a *signature* is stored
inside it and sent as an inline part, up to 256 KB each and 768 KB per signature; see
[Signature](#signature). Anything larger, an SVG, or an image that could not be fetched stays a
+15 -2
View File
@@ -300,9 +300,14 @@ csrf token. Both measured: see App\Twig\ListFragmentGlobal.
row so fifty rows are one report, and here rather than
inside the frame so it survives a fragment swap and
watches for one. #}
{# One data-controller attribute: a second one is dropped by the parser. The
pull gesture is outside the frame, so a refresh cannot take it away, and
it reaches the topbar's sync button by lookup, not by DOM position. #}
{% if app.user %}
data-controller="mail--new-marker"
data-controller="mail--new-marker mail--pull-to-refresh"
data-mail--new-marker-url-value="{{ path('app_mail_threads_listed') }}"
{% else %}
data-controller="mail--pull-to-refresh"
{% endif %}
{# @container, so the rows below switch to their stacked
layout on the LIST's width. They used to switch on the
@@ -310,8 +315,16 @@ csrf token. Both measured: see App\Twig\ListFragmentGlobal.
the calendar pane is docked: a 1440px window with a
wide pane leaves the list narrower than a phone, and
every row stayed on one line and truncated to nothing. #}
class="@container flex flex-col flex-1 overflow-hidden {% block list_hidden %}{% endblock %}"
class="@container relative flex flex-col flex-1 overflow-hidden {% block list_hidden %}{% endblock %}"
>
{# The pull to refresh indicator. Here, beside the frame and not inside it, so a
refresh never replaces it. Its words are the visual states only: the sync
toast is what announces the run, so this stays out of the accessibility tree. #}
<div data-mail--pull-to-refresh-target="indicator" data-state="idle" aria-hidden="true" class="mail-pull-indicator">
<i class="fa-solid fa-rotate" data-mail--pull-to-refresh-target="icon" aria-hidden="true"></i>
<span data-label-for="pulling">{{ 'pull_to_refresh.pull'|trans }}</span>
<span data-label-for="release">{{ 'pull_to_refresh.release'|trans }}</span>
</div>
{{ block('list_frame') }}
</div>
+539
View File
@@ -0,0 +1,539 @@
import { test, expect, devices, type Page } from "./support/test";
import { Finger, recordTouchMoves, touchMoveCancelable } from "./support/touch";
import { settled } from "./support/motion";
import { seed } from "./support/config";
/**
* Pull to refresh on a phone, driven by real touches.
*
* Derivations (k is the resistance, owner default 0.5):
* displayed distance = finger dy from touchstart x k
* release point = displayed 70px, so finger travel 70 / 0.5 = 140px
* PULL_PAST = 180px finger -> displayed 90 >= 70: release, one sync
* PULL_SHORT = 120px finger -> displayed 60 < 70: no release, no sync
* A test that moves the constants must re-derive these two numbers.
*
* The sync endpoints are answered by the spec, not by the server. The question is
* whether the gesture asks for a sync, and how many, not what the sync does; a
* real run would change the test account's mail. The answers keep the topbar
* button running until a test says otherwise, so the running guard can be tested.
*/
test.use({ ...devices["Pixel 5"] });
// The base seed has four inbox threads, which cannot overflow a phone's list. Sixty
// messages overflow it, so the scroll case has something to scroll. --messages adds
// to the bulk mailbox rather than replacing it, so each test starts from a clear one
// and the last test leaves none behind for the next spec on this worker.
test.beforeEach(() => {
seed("seed-bulk --clear", "seed-bulk --messages=60");
});
test.afterAll(() => {
seed("seed-bulk --clear");
});
const PULL_PAST = 180;
const PULL_SHORT = 120;
const ROWS = '[data-list-region="rows"]';
const INDICATOR = '[data-mail--pull-to-refresh-target="indicator"]';
const ICON = '[data-mail--pull-to-refresh-target="icon"]';
const BURGER = '[data-ui--sidebar-drawer-target="burger"]';
const isSync = (url: URL) => url.pathname === "/mail/sync";
const isStatus = (url: URL) => url.pathname === "/mail/sync/status";
/**
* Answer the sync endpoints, and count the POSTs that reach them.
*
* `pending` is what the status endpoint reports: 0 lets the button finish, 1
* keeps it running for the length of the test.
*/
async function mockSync(page: Page, { pending = 0 } = {}): Promise<{ posts: number; pending: number }> {
// `pending` is read on every status answer, so a test can end a run it started by
// setting it to 0 while the run is open.
const counter = { posts: 0, pending };
await page.route(isSync, async (route) => {
if (route.request().method() === "POST") {
counter.posts++;
}
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ dispatched: 1 }) });
});
await page.route(isStatus, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ pending: counter.pending }) }),
);
return counter;
}
/** The list's rows scroller, the surface a pull starts on. */
async function rowsStart(page: Page): Promise<{ x: number; y: number }> {
const box = await page.locator(ROWS).boundingBox();
expect(box, "the rows scroller is rendered").not.toBeNull();
return { x: box!.x + box!.width / 2, y: box!.y + 20 };
}
async function openInbox(page: Page): Promise<void> {
await page.goto("/mail/inbox");
await settled(page);
}
test.describe("pull to refresh on a phone", () => {
test("a pull past the release point syncs once, and reads release before the finger lifts", async ({ page }) => {
const sync = await mockSync(page);
await openInbox(page);
const start = await rowsStart(page);
const finger = await Finger.down(page, start.x, start.y);
await finger.move(0, PULL_PAST);
await expect(page.locator(INDICATOR)).toHaveAttribute("data-state", "release");
await finger.up();
await expect.poll(() => sync.posts).toBe(1);
await page.waitForTimeout(300);
expect(sync.posts, "exactly one sync for one pull").toBe(1);
});
test("a pull short of the release point tracks the finger but does not sync", async ({ page }) => {
const sync = await mockSync(page);
await openInbox(page);
const start = await rowsStart(page);
const finger = await Finger.down(page, start.x, start.y);
await finger.move(0, PULL_SHORT);
// Presence companion: the indicator followed the finger. A test that only
// checked for no POST would pass for a gesture that never started.
await expect(page.locator(INDICATOR)).toHaveAttribute("data-state", "pulling");
await finger.up();
await page.waitForTimeout(500);
expect(sync.posts).toBe(0);
});
test("a pull from a list that is scrolled down scrolls the list and does not sync", async ({ page }) => {
const sync = await mockSync(page);
await openInbox(page);
const rows = page.locator(ROWS);
// Presence precondition: the list has to be able to scroll to 300, or the
// assertion below would read 0 for a list that never moved.
await rows.evaluate((el) => {
el.scrollTop = 300;
});
expect(await rows.evaluate((el) => el.scrollTop)).toBe(300);
const start = await rowsStart(page);
const finger = await Finger.down(page, start.x, start.y);
await finger.move(0, PULL_PAST);
await finger.up();
await page.waitForTimeout(300);
expect(sync.posts).toBe(0);
expect(await rows.evaluate((el) => el.scrollTop), "the finger scrolled the list").toBeLessThan(300);
});
test("a pull while the drawer is open does not sync, and the same pull does once it is closed", async ({ page }) => {
const sync = await mockSync(page);
await openInbox(page);
await page.locator(BURGER).click();
await expect(page.locator("#sidebar-drawer")).not.toHaveAttribute("inert", "");
// Where the drawer is open, the backdrop is what a touch lands on, so the
// list never sees the touch at all. That is what the browser does, and it
// is recorded here: the open-surface check in the gesture module is covered
// by its node-side spec, not by this one.
const start = await rowsStart(page);
const hit = await page.evaluate(({ x, y }) => {
const element = document.elementFromPoint(x, y);
return element?.closest("[data-ui--sidebar-drawer-target]")?.getAttribute("data-ui--sidebar-drawer-target") ?? null;
}, start);
expect(hit, "the open drawer's backdrop takes the touch").toBe("backdrop");
const finger = await Finger.down(page, start.x, start.y);
await finger.move(0, PULL_PAST);
await finger.up();
await page.waitForTimeout(300);
expect(sync.posts).toBe(0);
// Presence companion: close the drawer, and the same pull syncs.
await page.keyboard.press("Escape");
await expect(page.locator("#sidebar-drawer")).toHaveAttribute("inert", "");
const again = await rowsStart(page);
const second = await Finger.down(page, again.x, again.y);
await second.move(0, PULL_PAST);
await second.up();
await expect.poll(() => sync.posts).toBe(1);
});
test("a horizontal drag at the top does not sync, and a vertical pull after it does", async ({ page }) => {
const sync = await mockSync(page);
await openInbox(page);
const start = await rowsStart(page);
// The drag runs 140px down as well (the release travel, 70 / 0.5), so a pull
// that ignored the arbiter would release and sync. The arbiter's answer is
// horizontal, because 180 > 140. Only that decision stops it.
const sideways = await Finger.down(page, start.x, start.y);
await sideways.move(PULL_PAST, 140);
await sideways.up();
await page.waitForTimeout(300);
expect(sync.posts).toBe(0);
// Presence companion, in the same test: the list still takes a vertical pull.
const down = await Finger.down(page, start.x, start.y);
await down.move(0, PULL_PAST);
await down.up();
await expect.poll(() => sync.posts).toBe(1);
});
test("every touchmove during a pull is cancelable, and the pull still syncs once", async ({ page }) => {
const sync = await mockSync(page);
await openInbox(page);
await recordTouchMoves(page);
const start = await rowsStart(page);
const finger = await Finger.down(page, start.x, start.y);
// Two small moves (4px in all), then the rest. Chromium delivers no touchmove
// for movement inside its touch slop, so those two make no event; the events
// recorded are the 8 moves of the rest, each one past the lock. The count is
// the number of moves made after the slop, derived from the steps: 8. The
// assertion is on delivered events, not on the 10 moves made.
await finger.move(0, 4, 2);
await finger.move(0, PULL_PAST - 4, 8);
await finger.up();
await expect.poll(() => sync.posts).toBe(1);
const cancelable = await touchMoveCancelable(page);
expect(cancelable).toHaveLength(8);
expect(cancelable.every((value) => value === true), `cancelable per move: ${cancelable}`).toBe(true);
});
test("while a sync runs after a release the indicator rests in view and spins, and slides away when it stops", async ({ page }) => {
const sync = await mockSync(page, { pending: 1 });
await openInbox(page);
const start = await rowsStart(page);
const finger = await Finger.down(page, start.x, start.y);
await finger.move(0, PULL_PAST);
await finger.up();
const indicator = page.locator(INDICATOR);
const list = page.locator("#message-list");
await expect.poll(() => sync.posts).toBe(1);
await expect(indicator).toHaveAttribute("data-state", "syncing");
// Derivation: the pill is 25px tall in the layout (6px padding each side around a 13px line),
// and the rest offset is 48px, so its top is 48 - 25 = 23px below the list's top.
// A pill that rests where the hidden one sits would have its bottom at the list's
// top, and fail the first check.
// Wait for the indicator's own transition to finish: the slide from the release point
// to the rest point is 200ms, and a box read mid-slide proves nothing about where it
// rests. Only the indicator's own animations count: the spinning icon is a child, and
// the spin runs for as long as the sync does, so the page as a whole never settles.
await expect
.poll(() => indicator.evaluate((el) => el.getAnimations().length), { timeout: 2_000 })
.toBe(0);
const listBox = await list.boundingBox();
const box = await indicator.boundingBox();
const viewport = page.viewportSize();
expect(listBox).not.toBeNull();
expect(box).not.toBeNull();
expect(box!.y, "the indicator rests below the top of the list").toBeGreaterThanOrEqual(listBox!.y);
expect(box!.y + box!.height, "the indicator is in the viewport").toBeLessThanOrEqual(viewport!.height);
await expect(page.locator(ICON)).toHaveClass(/fa-spin/);
// End the run. The stopped event takes the indicator back out of sight, above the
// list's top, once its slide has finished.
sync.pending = 0;
await expect(indicator).toHaveAttribute("data-state", "idle", { timeout: 15_000 });
await expect
.poll(async () => {
const hidden = await indicator.boundingBox();
const top = await list.boundingBox();
return hidden!.y + hidden!.height <= top!.y + 1;
}, { timeout: 5_000 })
.toBe(true);
});
test("a touch cancelled mid-pull aborts the pull: no sync, and the indicator returns to idle", async ({ page }) => {
const sync = await mockSync(page);
await openInbox(page);
const start = await rowsStart(page);
const cancelled = await Finger.down(page, start.x, start.y);
await cancelled.move(0, PULL_PAST);
await expect(page.locator(INDICATOR)).toHaveAttribute("data-state", "release");
await cancelled.cancel();
await expect(page.locator(INDICATOR)).toHaveAttribute("data-state", "idle");
await page.waitForTimeout(500);
expect(sync.posts, "a cancelled touch syncs nothing").toBe(0);
// Presence companion, in the same test: a pull that ends with touchend syncs.
const ended = await Finger.down(page, start.x, start.y);
await ended.move(0, PULL_PAST);
await ended.up();
await expect.poll(() => sync.posts).toBe(1);
});
test("a second finger during a pull aborts it: both fingers up syncs nothing", async ({ page }) => {
const sync = await mockSync(page);
await openInbox(page);
const start = await rowsStart(page);
const finger = await Finger.down(page, start.x, start.y);
await finger.move(0, PULL_PAST);
await expect(page.locator(INDICATOR)).toHaveAttribute("data-state", "release");
// The second finger lands 100px to the right of the first, which is still on screen.
await finger.addFinger(start.x + 100, start.y + PULL_PAST);
await expect(page.locator(INDICATOR)).toHaveAttribute("data-state", "idle");
await finger.up();
await page.waitForTimeout(500);
expect(sync.posts, "a two-finger touch syncs nothing").toBe(0);
// Presence companion: a later single-finger pull still syncs.
const later = await Finger.down(page, start.x, start.y);
await later.move(0, PULL_PAST);
await later.up();
await expect.poll(() => sync.posts).toBe(1);
});
test("a pull whose rows are replaced under the finger leaves no claim behind: a later touch syncs nothing, and the next pull syncs once", async ({ page }) => {
const sync = await mockSync(page);
await openInbox(page);
// Record the element the first finger lands on, so the test can show it is the
// one that gets detached. A touch whose target stays in the document would
// still reach the controller, and the test would pass without the fault.
await page.evaluate(() => {
document.addEventListener(
"touchstart",
(event) => {
(window as unknown as { __origin: Element | null }).__origin = event.target as Element;
},
{ capture: true, passive: true },
);
});
const start = await rowsStart(page);
const stale = await Finger.down(page, start.x, start.y);
await stale.move(0, PULL_PAST);
await expect(page.locator(INDICATOR)).toHaveAttribute("data-state", "release");
// Presence: the touch landed on a row inside the rows region, not on the region itself.
// Replacing the region's children detaches that row, and the region stays in place.
const detached = await page.evaluate(() => {
const origin = (window as unknown as { __origin: Element | null }).__origin;
const rows = document.querySelector('[data-list-region="rows"]');
if (origin === null || rows === null || origin === rows || !rows.contains(origin)) {
return "the touch did not land on a row";
}
rows.innerHTML = rows.innerHTML;
return origin.isConnected ? "the touch target is still attached" : "detached";
});
expect(detached).toBe("detached");
await stale.up();
// A tap that does not move: no pull, no sync, on any branch. It has to be a tap
// with no touchmove, because a touchmove during a stale pull resets the displayed
// distance to the finger's travel (0 here) and would hide the fault. The tap's
// synthesised click is swallowed, so the row does not open and the list stays put.
await page.evaluate(() => {
window.addEventListener(
"click",
(event) => {
event.preventDefault();
event.stopImmediatePropagation();
},
{ capture: true },
);
});
const fresh = await Finger.down(page, start.x, start.y);
await fresh.up();
await page.waitForTimeout(500);
expect(sync.posts, "a touch after a lost pull syncs nothing").toBe(0);
// Presence companion: a fresh pull from the top still syncs, exactly once.
const pull = await Finger.down(page, start.x, start.y);
await pull.move(0, PULL_PAST);
await pull.up();
await expect.poll(() => sync.posts).toBe(1);
await page.waitForTimeout(300);
expect(sync.posts, "exactly one sync for the fresh pull").toBe(1);
});
test("a second pull while the sync is still running does not sync again", async ({ page }) => {
const sync = await mockSync(page, { pending: 1 });
await openInbox(page);
const start = await rowsStart(page);
const first = await Finger.down(page, start.x, start.y);
await first.move(0, PULL_PAST);
await first.up();
// Presence companion: the first pull started the sync.
await expect.poll(() => sync.posts).toBe(1);
await expect(page.locator(INDICATOR)).toHaveAttribute("data-state", "syncing");
const second = await Finger.down(page, start.x, start.y);
await second.move(0, PULL_PAST);
await second.up();
await page.waitForTimeout(500);
expect(sync.posts, "the running guard holds the second pull").toBe(1);
});
test("a touch drag on a desktop-width window does nothing, and the same drag syncs on a phone width", async ({ page }) => {
const sync = await mockSync(page);
await page.setViewportSize({ width: 1280, height: 800 });
await openInbox(page);
const start = await rowsStart(page);
const finger = await Finger.down(page, start.x, start.y);
await finger.move(0, PULL_PAST);
await finger.up();
await page.waitForTimeout(300);
expect(sync.posts, "no pull at md and up").toBe(0);
// Presence companion: the same touch, narrowed to a phone width, syncs.
await page.setViewportSize({ width: 393, height: 727 });
await openInbox(page);
const phone = await rowsStart(page);
const again = await Finger.down(page, phone.x, phone.y);
await again.move(0, PULL_PAST);
await again.up();
await expect.poll(() => sync.posts).toBe(1);
});
test("a mouse drag does nothing on a desktop", async ({ page }) => {
const sync = await mockSync(page);
await page.setViewportSize({ width: 1280, height: 800 });
await openInbox(page);
const start = await rowsStart(page);
// Presence companion: count the mouse moves the page receives, so "no sync"
// means the drag was delivered and ignored, not never sent.
await page.evaluate(() => {
const win = window as unknown as { __mouseMoves: number };
win.__mouseMoves = 0;
window.addEventListener("mousemove", () => win.__mouseMoves++);
});
await page.mouse.move(start.x, start.y);
await page.mouse.down();
await page.mouse.move(start.x, start.y + PULL_PAST, { steps: 6 });
await page.mouse.up();
await page.waitForTimeout(300);
expect(await page.evaluate(() => (window as unknown as { __mouseMoves: number }).__mouseMoves)).toBeGreaterThan(0);
expect(sync.posts).toBe(0);
});
test("with reduced motion the indicator jumps back, and without it the indicator slides", async ({ page }) => {
await mockSync(page);
await page.emulateMedia({ reducedMotion: "no-preference" });
await openInbox(page);
const start = await rowsStart(page);
const first = await Finger.down(page, start.x, start.y);
await first.move(0, PULL_PAST);
await first.up();
// Presence companion: the slide takes time when motion is allowed.
await expect(page.locator(INDICATOR)).toHaveAttribute("data-state", "idle");
const slide = await page.locator(INDICATOR).evaluate((el) => getComputedStyle(el).transitionDuration);
expect(slide).not.toBe("0s");
await page.emulateMedia({ reducedMotion: "reduce" });
await openInbox(page);
const again = await rowsStart(page);
const second = await Finger.down(page, again.x, again.y);
await second.move(0, PULL_PAST);
await second.up();
await expect(page.locator(INDICATOR)).toHaveAttribute("data-state", "idle");
expect(await page.locator(INDICATOR).evaluate((el) => getComputedStyle(el).transitionDuration)).toBe("0s");
});
});
+108
View File
@@ -0,0 +1,108 @@
import type { CDPSession, Page } from "@playwright/test";
/**
* One finger on a touch screen, driven through the DevTools protocol.
*
* `page.mouse` cannot stand in for it. A mouse drag fires mouse events and never
* touch events, and the gesture code reads touch events, so a mouse test would
* pass for a gesture that was never exercised. And a synthetic `new TouchEvent`
* dispatched from the page skips what the browser itself decides about a touch:
* whether touch-action lets it pan, whether a touchmove is cancelable, whether
* a native scroll starts. So the touch goes through `Input.dispatchTouchEvent`,
* as a finger would, and the browser decides the rest.
*
* Chromium only, as the suite is. The page needs a touch-capable context
* (`hasTouch`, which the Pixel 5 device sets).
*
* The finger stays down between calls, so a test can look at the screen while
* the touch is still in progress, which is where a pull shows its release state.
*/
export class Finger {
private x: number;
private y: number;
private constructor(
private readonly cdp: CDPSession,
x: number,
y: number,
) {
this.x = x;
this.y = y;
}
/** Put a finger down at (x, y). */
static async down(page: Page, x: number, y: number): Promise<Finger> {
const cdp = await page.context().newCDPSession(page);
await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });
return new Finger(cdp, x, y);
}
/**
* Move by (dx, dy) from where the finger is, in `steps` touchmove events of
* equal size. The steps are real events, so a test can count them.
*/
async move(dx: number, dy: number, steps = 6): Promise<void> {
for (let i = 1; i <= steps; i++) {
await this.cdp.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: this.x + (dx * i) / steps, y: this.y + (dy * i) / steps }],
});
}
this.x += dx;
this.y += dy;
}
/**
* Put a second finger down at (x, y), beside the first, which stays where it is.
* One touchstart carries every point that is down, as the browser sends it.
*/
async addFinger(x: number, y: number): Promise<void> {
await this.cdp.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [
{ x: this.x, y: this.y },
{ x, y },
],
});
}
/** Lift the finger, and detach the session. */
async up(): Promise<void> {
await this.cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
await this.cdp.detach();
}
/**
* The system takes the touch: a call, a notification, a gesture of its own. The
* browser sends touchcancel, never touchend, so the page must not treat it as a
* finger that lifted. CDP's touchCancel is the browser's own event for this.
*/
async cancel(): Promise<void> {
await this.cdp.send("Input.dispatchTouchEvent", { type: "touchCancel", touchPoints: [] });
await this.cdp.detach();
}
}
/**
* Record whether each touchmove on the page is cancelable.
*
* The listener is passive, so it observes the events without changing what the
* browser does with them: a blocking recorder would make the whole sequence
* cancelable and hide the very thing being measured.
*/
export async function recordTouchMoves(page: Page): Promise<void> {
await page.evaluate(() => {
const win = window as unknown as { __touchMoves: boolean[] };
win.__touchMoves = [];
document.addEventListener("touchmove", (event) => win.__touchMoves.push(event.cancelable), { passive: true });
});
}
/** The `cancelable` flag of every touchmove recorded since recordTouchMoves(). */
export async function touchMoveCancelable(page: Page): Promise<boolean[]> {
return page.evaluate(() => (window as unknown as { __touchMoves: boolean[] }).__touchMoves);
}
+87
View File
@@ -0,0 +1,87 @@
import { test, expect, type Page } from "./support/test";
/**
* The topbar sync button's lifecycle, as the rest of the page can see it.
*
* The button announces when a run starts and when it stops, so a gesture (pull to
* refresh) can show the same spinning state whatever started the run. This tests
* the button alone, at desktop width: the gesture is tested in pull-to-refresh.spec.ts.
*
* Clock arithmetic (sync_now_controller.js defaults): the first status check runs
* pollInterval = 1500ms after the POST resolves, and each check that reads zero
* pending adds one to a run of zeroes. The run is confirmed on the third zero
* (confirmations = 3), so the earliest stop is the third poll, at 3 x 1500 = 4500ms
* of clock time after the POST resolved. After the second poll it must not have
* stopped: 2 x 1500 = 3000ms, with two zeroes, which is short of three.
*
* The clock is stepped 1500ms at a time, and each step waits for the status request
* it causes, because a check only schedules the next one after its own fetch
* resolves. One runFor(4500) would not give three checks.
*/
test.use({ viewport: { width: 1280, height: 800 } });
const BUTTON = '[data-controller~="mail--sync-now"]';
const ICON = `${BUTTON} [data-mail--sync-now-target="icon"]`;
const POLL_MS = 1500;
const isSync = (url: URL) => url.pathname === "/mail/sync";
const isStatus = (url: URL) => url.pathname === "/mail/sync/status";
/** The lifecycle events the page has heard, in order, from the document. */
async function heard(page: Page): Promise<string[]> {
return page.evaluate(() => (window as unknown as { __syncEvents: string[] }).__syncEvents);
}
test("the button announces a started run on the click, and a stopped run on the third idle poll", async ({ page }) => {
await page.clock.install();
await page.route(isSync, (route) => {
if (route.request().method() === "POST") {
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ dispatched: 1 }) });
}
return route.continue();
});
await page.route(isStatus, (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ pending: 0 }) }),
);
await page.goto("/mail/inbox");
await page.evaluate(() => {
const win = window as unknown as { __syncEvents: string[] };
win.__syncEvents = [];
document.addEventListener("mail--sync-now:started", () => win.__syncEvents.push("started"));
document.addEventListener("mail--sync-now:stopped", () => win.__syncEvents.push("stopped"));
});
const posted = page.waitForResponse((response) => isSync(new URL(response.url())) && response.request().method() === "POST");
await page.locator(BUTTON).click();
await posted;
// Presence: started has fired, on the click, before any poll.
expect(await heard(page)).toEqual(["started"]);
await expect(page.locator(ICON)).toHaveClass(/fa-spin/);
// Poll 1 and poll 2: each reads zero, and neither is enough.
for (let poll = 1; poll <= 2; poll++) {
const checked = page.waitForResponse((response) => isStatus(new URL(response.url())));
await page.clock.runFor(POLL_MS);
await checked;
}
expect(await heard(page), "not stopped after two zeroes").toEqual(["started"]);
await expect(page.locator(ICON), "still spinning after two zeroes").toHaveClass(/fa-spin/);
// Poll 3: the third zero, and the run is over.
const third = page.waitForResponse((response) => isStatus(new URL(response.url())));
await page.clock.runFor(POLL_MS);
await third;
await expect.poll(() => heard(page)).toEqual(["started", "stopped"]);
await expect(page.locator(ICON)).not.toHaveClass(/fa-spin/);
});
+158
View File
@@ -0,0 +1,158 @@
import { test, expect } from "@playwright/test";
import {
DIRECTION_LOCK_PX,
HOLD_MS,
HOLD_SLOP_PX,
Intent,
claimSequence,
createSequence,
registerSurface,
releaseSequence,
unregisterSurface,
} from "../../assets/touch_gesture.js";
/**
* The arbiter's decisions, on their own.
*
* No browser: the module is pure decisions plus two pieces of module-level state
* (the open surface, and the sequence's claim), and this file imports it directly,
* so a failure names the row rather than a screenshot. The browser specs prove
* the controllers ask it; this proves what it answers.
*
* Every expected value below is derived from the three constants, never fitted to
* what the module returns:
* - DIRECTION_LOCK_PX = 10: a sequence is decided once max(|dx|, |dy|) >= 10.
* Ties (|dx| == |dy|) count as vertical.
* - HOLD_MS = 450: a hold needs elapsed >= 450 with no movement past HOLD_SLOP_PX.
* - HOLD_SLOP_PX = 8: movement is "past 8px" when max(|dx|, |dy|) > 8, so 8 is not
* past it. A hold cancelled by movement stays cancelled for the sequence.
* Rows that are not in the plan's own table are marked "added" with the reason.
*/
const { UNDECIDED, HOLD, VERTICAL, LEFT, RIGHT } = Intent;
test.describe("touch gesture arbiter: decisions", () => {
test("the constants are the owner-confirmed defaults", () => {
// Pinned so a changed constant is a deliberate edit to this file as well.
expect(DIRECTION_LOCK_PX).toBe(10);
expect(HOLD_MS).toBe(450);
expect(HOLD_SLOP_PX).toBe(8);
});
test("(6,0,100) is undecided: 6 is under the 10px lock", () => {
expect(createSequence().move(6, 0, 100)).toBe(UNDECIDED);
});
test("(11,3,100) is horizontal to the right: 11 >= 10 and |dx| > |dy|", () => {
expect(createSequence().move(11, 3, 100)).toBe(RIGHT);
});
test("(3,11,100) is vertical: 11 >= 10 and |dy| > |dx|", () => {
expect(createSequence().move(3, 11, 100)).toBe(VERTICAL);
});
test("(-11,2,100) is horizontal to the left: the sign of dx decides the side", () => {
expect(createSequence().move(-11, 2, 100)).toBe(LEFT);
});
test("(8,8,100) is undecided: max is 8, under the 10px lock (Euclidean would be 11.3)", () => {
expect(createSequence().move(8, 8, 100)).toBe(UNDECIDED);
});
test("(10,10,100) is vertical: the tie rule, and 10 >= 10 locks", () => {
expect(createSequence().move(10, 10, 100)).toBe(VERTICAL);
});
test("(0,0,500) is a hold: 500 >= 450 with no movement", () => {
expect(createSequence().move(0, 0, 500)).toBe(HOLD);
});
test("(0,0,450) is a hold: the boundary, 450 >= 450", () => {
expect(createSequence().move(0, 0, 450)).toBe(HOLD);
});
test("(0,0,449) is undecided: one millisecond short of the hold", () => {
expect(createSequence().move(0, 0, 449)).toBe(UNDECIDED);
});
test("(7,0,500) is a hold: 7 <= 8, so still within the slop", () => {
expect(createSequence().move(7, 0, 500)).toBe(HOLD);
});
test("(8,0,500) is a hold: added, the slop boundary, 8 is not past 8", () => {
expect(createSequence().move(8, 0, 500)).toBe(HOLD);
});
test("(6,6,500) is a hold: added, max is 6 and Euclidean would be 8.5, which is past 8", () => {
expect(createSequence().move(6, 6, 500)).toBe(HOLD);
});
test("(9,0,500) is not a hold and still undecided: 8 < 9 < 10, the band", () => {
expect(createSequence().move(9, 0, 500)).toBe(UNDECIDED);
});
test("(9,0,200) then (9,0,500): the hold is cancelled for the rest of the sequence", () => {
const sequence = createSequence();
expect(sequence.move(9, 0, 200)).toBe(UNDECIDED);
expect(sequence.move(9, 0, 500)).toBe(UNDECIDED);
});
test("a decided hold stays a hold: a later (20,0) is not a late swipe", () => {
const sequence = createSequence();
expect(sequence.move(0, 0, 500)).toBe(HOLD);
expect(sequence.move(20, 0, 600)).toBe(HOLD);
});
test("a decided vertical stays vertical: a later horizontal move does not change it", () => {
const sequence = createSequence();
expect(sequence.move(0, 11, 100)).toBe(VERTICAL);
expect(sequence.move(30, 0, 200)).toBe(VERTICAL);
});
});
test.describe("touch gesture arbiter: claims and open surfaces", () => {
test.afterEach(() => {
releaseSequence("pull");
releaseSequence("swipe");
unregisterSurface("test-surface");
});
test("a claimed sequence rejects a second claimant; releasing it lets the next one claim", () => {
expect(claimSequence("pull")).toBe(true);
expect(claimSequence("swipe")).toBe(false);
expect(releaseSequence("pull")).toBe(true);
// Presence companion: the rejection above is only meaningful if a released
// sequence is claimable again.
expect(claimSequence("swipe")).toBe(true);
});
test("a release by someone who does not hold the claim releases nothing", () => {
expect(claimSequence("pull")).toBe(true);
// A touchend from a sequence that never claimed must not free another consumer's claim.
expect(releaseSequence("swipe")).toBe(false);
expect(claimSequence("swipe")).toBe(false);
// Presence companion: the owner's own release does free it.
expect(releaseSequence("pull")).toBe(true);
expect(claimSequence("swipe")).toBe(true);
});
test("an open surface blocks a pull claim; closing it lets the pull claim", () => {
let open = true;
registerSurface("test-surface", () => open);
expect(claimSequence("pull")).toBe(false);
// Presence companion: the same claim succeeds once the surface reports closed.
open = false;
expect(claimSequence("pull")).toBe(true);
});
});
+3
View File
@@ -2609,6 +2609,9 @@ sync_now:
still_running: 'Die Synchronisierung läuft im Hintergrund weiter.'
complete: 'Synchronisierung abgeschlossen.'
session_expired: 'Deine Sitzung ist abgelaufen – lade die Seite neu, um dich wieder anzumelden.'
pull_to_refresh:
pull: 'Zum Synchronisieren nach unten ziehen'
release: 'Loslassen zum Synchronisieren'
search:
empty:
or_keyword: oder tippe einfach ein Stichwort.
+3
View File
@@ -2623,6 +2623,9 @@ sync_now:
still_running: 'Sync is still running in the background.'
complete: 'Sync complete.'
session_expired: 'Your session has expired — reload the page to sign in again.'
pull_to_refresh:
pull: 'Pull down to sync'
release: 'Release to sync'
search:
empty:
or_keyword: or just type a keyword.
+3
View File
@@ -2483,6 +2483,9 @@ sync_now:
still_running: 'Still haulin'' below decks.'
complete: 'All hands aboard — sync complete.'
session_expired: 'Yer watch has ended — reload the page to sign in again.'
pull_to_refresh:
pull: 'Heave down to haul in the post'
release: 'Let go, and haul in the post'
search:
empty:
or_keyword: or just bellow a keyword.
+3
View File
@@ -2636,6 +2636,9 @@ sync_now:
still_running: 'La synchronisation se poursuit en arrière-plan.'
complete: 'Synchronisation terminée.'
session_expired: 'Votre session a expiré — rechargez la page pour vous reconnecter.'
pull_to_refresh:
pull: 'Tirez vers le bas pour synchroniser'
release: 'Relâchez pour synchroniser'
search:
empty:
or_keyword: ou tapez simplement un mot-clé.