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 ## 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 **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, 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 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._running = true;
this._spin(true); this._spin(true);
// Said here, after the guard, so a click that is refused says nothing.
this.dispatch("started");
let response = null; let response = null;
@@ -124,10 +126,16 @@ export default class extends Controller {
this._schedule(); 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() { _stop() {
clearTimeout(this._poll); clearTimeout(this._poll);
this._running = false; this._running = false;
this._spin(false); this._spin(false);
this.dispatch("stopped");
} }
_spin(on) { _spin(on) {
@@ -17,6 +17,7 @@
// it back on the burger, and the burger's aria-expanded says which it is. // it back on the burger, and the burger's aria-expanded says which it is.
import { Controller } from "@hotwired/stimulus"; import { Controller } from "@hotwired/stimulus";
import { registerSurface, unregisterSurface } from "../../touch_gesture.js";
const DESKTOP = "(min-width: 768px)"; const DESKTOP = "(min-width: 768px)";
const RAIL_KEY = "plmail:sidebarRail"; const RAIL_KEY = "plmail:sidebarRail";
@@ -28,6 +29,10 @@ export default class extends Controller {
connect() { connect() {
this._onKeydown = this._handleKeydown.bind(this); 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 // A drawer left open while rotating an iPad to landscape would otherwise
// stay stuck over the desktop layout, backdrop and all. // stay stuck over the desktop layout, backdrop and all.
this._desktop = window.matchMedia(DESKTOP); this._desktop = window.matchMedia(DESKTOP);
@@ -43,6 +48,7 @@ export default class extends Controller {
} }
disconnect() { disconnect() {
unregisterSurface("drawer");
document.removeEventListener("keydown", this._onKeydown); document.removeEventListener("keydown", this._onKeydown);
this._desktop.removeEventListener("change", this._onBreakpoint); this._desktop.removeEventListener("change", this._onBreakpoint);
this._desktop.removeEventListener("change", this._onExpandedChange); 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: 2px solid rgb(var(--rgb-accent) / 0.7);
outline-offset: -2px; 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 # 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 **Aktualisieren** in der Werkzeugleiste reiht für jedes deiner aktiven Konten eine
Synchronisierung ein und dreht sich, bis diese Aufträge abgearbeitet sind. 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 ### Tastenkürzel
Die einzelnen Tasten, die Gmail benutzt, funktionieren auch hier. Mit **?** bekommst du überall die 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 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 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. 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 **Refresh** in the toolbar queues a sync for every active account you own and spins until those
jobs have drained. 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 ### Keyboard shortcuts
The single keys Gmail uses work here too. Press **?** anywhere for the full list, or the 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 from this view" — the dialog is a shortcut to the same operation, across every account, nested labels
included. 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 **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 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 [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 row so fifty rows are one report, and here rather than
inside the frame so it survives a fragment swap and inside the frame so it survives a fragment swap and
watches for one. #} 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 %} {% 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') }}" data-mail--new-marker-url-value="{{ path('app_mail_threads_listed') }}"
{% else %}
data-controller="mail--pull-to-refresh"
{% endif %} {% endif %}
{# @container, so the rows below switch to their stacked {# @container, so the rows below switch to their stacked
layout on the LIST's width. They used to switch on the 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 the calendar pane is docked: a 1440px window with a
wide pane leaves the list narrower than a phone, and wide pane leaves the list narrower than a phone, and
every row stayed on one line and truncated to nothing. #} 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') }} {{ block('list_frame') }}
</div> </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.' still_running: 'Die Synchronisierung läuft im Hintergrund weiter.'
complete: 'Synchronisierung abgeschlossen.' complete: 'Synchronisierung abgeschlossen.'
session_expired: 'Deine Sitzung ist abgelaufen – lade die Seite neu, um dich wieder anzumelden.' 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: search:
empty: empty:
or_keyword: oder tippe einfach ein Stichwort. 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.' still_running: 'Sync is still running in the background.'
complete: 'Sync complete.' complete: 'Sync complete.'
session_expired: 'Your session has expired — reload the page to sign in again.' 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: search:
empty: empty:
or_keyword: or just type a keyword. or_keyword: or just type a keyword.
+3
View File
@@ -2483,6 +2483,9 @@ sync_now:
still_running: 'Still haulin'' below decks.' still_running: 'Still haulin'' below decks.'
complete: 'All hands aboard — sync complete.' complete: 'All hands aboard — sync complete.'
session_expired: 'Yer watch has ended — reload the page to sign in again.' 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: search:
empty: empty:
or_keyword: or just bellow a keyword. 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.' still_running: 'La synchronisation se poursuit en arrière-plan.'
complete: 'Synchronisation terminée.' complete: 'Synchronisation terminée.'
session_expired: 'Votre session a expiré — rechargez la page pour vous reconnecter.' 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: search:
empty: empty:
or_keyword: ou tapez simplement un mot-clé. or_keyword: ou tapez simplement un mot-clé.