mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
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:
18 files changed
+1553
-3
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Vendored
+32
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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é.
|
||||
|
||||
Reference in new issue
Block a user