From 55d008a2e8a555b31371f8b765d5e992b2534999 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:39:39 +0100 Subject: [PATCH 01/26] fix: let the user cancel a sign-in that hangs --- src/api/jmap-client.ts | 7 +- src/lib/__tests__/server-discovery.test.ts | 12 ++ src/lib/__tests__/totp-login.test.ts | 17 +++ src/lib/server-discovery.ts | 44 +++--- src/lib/sign-in-abort.ts | 73 ++++++++++ src/lib/totp-login.ts | 47 ++++--- src/screens/LoginScreen.tsx | 126 +++++++++++++----- src/screens/login/SigningInStep.tsx | 28 +++- .../login/__tests__/login-back.test.ts | 32 +++++ src/screens/login/login-back.ts | 26 ++++ .../__tests__/auth-store-cancel.test.ts | 101 ++++++++++++++ .../__tests__/auth-store-pairing.test.ts | 2 +- src/stores/auth-store.ts | 111 ++++++++++----- 13 files changed, 521 insertions(+), 105 deletions(-) create mode 100644 src/lib/sign-in-abort.ts create mode 100644 src/screens/login/__tests__/login-back.test.ts create mode 100644 src/screens/login/login-back.ts create mode 100644 src/stores/__tests__/auth-store-cancel.test.ts diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index bdf27a16..880f93d9 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -279,11 +279,13 @@ export class JMAPClient { username: string, password: string, totp?: string, + /** Cancels the token requests of a TOTP sign-in (the sign-in screen's Cancel). */ + signal?: AbortSignal, ): Promise { const baseUrl = serverUrl.replace(/\/+$/, ''); const epoch = this.detachSession(); if (totp) { - return this.connectWithTotp(baseUrl, username, password, totp, epoch); + return this.connectWithTotp(baseUrl, username, password, totp, epoch, signal); } const credentials: StoredCredentials = { serverUrl: baseUrl, username, password }; this.credentials = credentials; @@ -314,11 +316,12 @@ export class JMAPClient { password: string, totp: string, epoch: number, + signal?: AbortSignal, ): Promise { const { exchangePasswordForTokens, LoginEndpointMissingError } = await import('../lib/totp-login'); let tokens: OAuthTokens; try { - tokens = await exchangePasswordForTokens(baseUrl, username, password, totp); + tokens = await exchangePasswordForTokens(baseUrl, username, password, totp, { signal }); } catch (err) { if (err instanceof LoginEndpointMissingError) { this.assertCurrentSession(epoch); diff --git a/src/lib/__tests__/server-discovery.test.ts b/src/lib/__tests__/server-discovery.test.ts index 72e78853..c2b99c59 100644 --- a/src/lib/__tests__/server-discovery.test.ts +++ b/src/lib/__tests__/server-discovery.test.ts @@ -196,4 +196,16 @@ describe('discoverServerForEmail', () => { await expect(discoverServerForEmail('ada')).resolves.toBeNull(); expect(mockSecureFetch).not.toHaveBeenCalled(); }); + + it('stops searching as soon as the sign-in is cancelled (U-133)', async () => { + // Every host hangs; only the cancel can end the search before the deadline. + mockSecureFetch.mockImplementation(() => new Promise(() => undefined)); + const controller = new AbortController(); + const started = Date.now(); + const search = discoverServerForEmail('ada@example.com', { timeoutMs: 60_000, signal: controller.signal }); + setTimeout(() => controller.abort(), 20); + await expect(search).resolves.toBeNull(); + expect(Date.now() - started).toBeLessThan(5_000); + expect(mockSecureFetch.mock.calls[0][1].signal.aborted).toBe(true); + }); }); diff --git a/src/lib/__tests__/totp-login.test.ts b/src/lib/__tests__/totp-login.test.ts index 05147eb4..a1f4ff9a 100644 --- a/src/lib/__tests__/totp-login.test.ts +++ b/src/lib/__tests__/totp-login.test.ts @@ -101,6 +101,23 @@ describe('exchangePasswordForTokens', () => { expect(calls.some((c) => c.url.endsWith('/auth/register'))).toBe(false); }); + it('cancels the client check and the login requests with the sign-in screen (U-133)', async () => { + const signals: Array = []; + fetchMock.mockImplementation((_url: string, init?: RequestInit) => { + signals.push(init?.signal ?? undefined); + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener('abort', () => reject(new TypeError('Aborted'))); + }); + }); + const controller = new AbortController(); + const pending = exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456', { signal: controller.signal }); + await vi.waitFor(() => expect(signals.length).toBe(1)); + controller.abort(); + // A cancel, not "server unreachable". + await expect(pending).rejects.toMatchObject({ name: 'SignInCancelledError' }); + expect(signals[0]?.aborted).toBe(true); + }); + it('registers its own client on Stalwart 1.0 and signs in with it', async () => { const calls = fakeServer('1.0'); const tokens = await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456'); diff --git a/src/lib/server-discovery.ts b/src/lib/server-discovery.ts index e21da1a9..c38f31bf 100644 --- a/src/lib/server-discovery.ts +++ b/src/lib/server-discovery.ts @@ -8,6 +8,7 @@ // slow or hostile network must never be worse than typing it in. import { secureFetch } from './client-cert'; +import { signalWithTimeout } from './sign-in-abort'; export const DISCOVERY_TIMEOUT_MS = 2500; @@ -96,18 +97,21 @@ export function serverCandidates(domain: string): string[] { return [`https://${domain}`, `https://mail.${domain}`, `https://webmail.${domain}`]; } -function withTimeout(promise: Promise, ms: number, fallback: T): Promise { +function withTimeout(promise: Promise, ms: number, fallback: T, signal?: AbortSignal): Promise { return new Promise((resolve) => { - const timer = setTimeout(() => resolve(fallback), ms); - promise - .then((value) => { - clearTimeout(timer); - resolve(value); - }) - .catch(() => { - clearTimeout(timer); - resolve(fallback); - }); + if (signal?.aborted) { + resolve(fallback); + return; + } + const finish = (value: T) => { + clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + resolve(value); + }; + const onAbort = () => finish(fallback); + const timer = setTimeout(() => finish(fallback), ms); + signal?.addEventListener('abort', onAbort, { once: true }); + promise.then(finish).catch(() => finish(fallback)); }); } @@ -128,15 +132,16 @@ function looksLikeSession(body: unknown): boolean { export async function probeJmapServer( baseUrl: string, timeoutMs: number = DISCOVERY_TIMEOUT_MS, + signal?: AbortSignal, ): Promise { - const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; - const abortTimer = setTimeout(() => controller?.abort(), timeoutMs); + // Gives up at the deadline, or when the user cancels the sign-in. + const deadline = signalWithTimeout(signal, timeoutMs); const request = (async () => { try { const response = await secureFetch(`${baseUrl}/.well-known/jmap`, { headers: { Accept: 'application/json' }, - signal: controller?.signal, + signal: deadline.signal, }); if (response.status === 401) return true; if (response.status !== 200) return false; @@ -149,9 +154,9 @@ export async function probeJmapServer( try { // The abort signal doesn't reach the native client-certificate path, so // race the whole thing rather than trusting the request to give up. - return await withTimeout(request, timeoutMs, false); + return await withTimeout(request, timeoutMs, false, signal); } finally { - clearTimeout(abortTimer); + deadline.done(); } } @@ -159,6 +164,8 @@ export interface DiscoveryOptions { /** Servers of already-registered accounts, trusted without a probe. */ knownServerUrls?: string[]; timeoutMs?: number; + /** The sign-in screen's Cancel: discovery stops and finds nothing. */ + signal?: AbortSignal; } /** @@ -170,7 +177,7 @@ export async function discoverServerForEmail( options: DiscoveryOptions = {}, ): Promise { const domain = emailDomain(email); - if (!domain) return null; + if (!domain || options.signal?.aborted) return null; // A server we're already signed in to for this domain needs no probe, and // covers the common case of adding a second account on the same host. @@ -186,8 +193,9 @@ export async function discoverServerForEmail( const timeoutMs = options.timeoutMs ?? DISCOVERY_TIMEOUT_MS; const candidates = serverCandidates(domain); const results = await Promise.all( - candidates.map(async (candidate) => ((await probeJmapServer(candidate, timeoutMs)) ? candidate : null)), + candidates.map(async (candidate) => ((await probeJmapServer(candidate, timeoutMs, options.signal)) ? candidate : null)), ); + if (options.signal?.aborted) return null; // `find` keeps candidate order, so a bare domain wins over `mail.` even when // both answer. diff --git a/src/lib/sign-in-abort.ts b/src/lib/sign-in-abort.ts new file mode 100644 index 00000000..5078c98b --- /dev/null +++ b/src/lib/sign-in-abort.ts @@ -0,0 +1,73 @@ +// Cancelling a sign-in (U-133). The sign-in screen used to show "Signing in" +// with no way out until every request gave up on its own, and Android Back +// was swallowed meanwhile. The screen now holds an AbortController per +// attempt; its signal goes into discovery, the client check, the /api/auth +// and token requests, and the auth store, which drops a sign-in whose signal +// fired before it registered the account. + +export class SignInCancelledError extends Error { + constructor() { + super('Sign-in cancelled'); + this.name = 'SignInCancelledError'; + } +} + +export function isSignInCancelled(err: unknown): boolean { + return err instanceof Error && err.name === 'SignInCancelledError'; +} + +export function throwIfCancelled(signal: AbortSignal | undefined): void { + if (signal?.aborted) throw new SignInCancelledError(); +} + +/** + * `promise`, or a `SignInCancelledError` as soon as `signal` fires. The work + * behind the promise goes on; callers that need to undo what it does once it + * finishes keep their own reference to it. + */ +export function abortable(promise: Promise, signal: AbortSignal | undefined): Promise { + if (!signal) return promise; + if (signal.aborted) { + promise.catch(() => undefined); + return Promise.reject(new SignInCancelledError()); + } + return new Promise((resolve, reject) => { + const onAbort = () => reject(new SignInCancelledError()); + signal.addEventListener('abort', onAbort, { once: true }); + promise.then( + (value) => { + signal.removeEventListener('abort', onAbort); + resolve(value); + }, + (err) => { + signal.removeEventListener('abort', onAbort); + reject(signal.aborted ? new SignInCancelledError() : err); + }, + ); + }); +} + +/** + * One signal that fires when `signal` does or after `ms`, whichever comes + * first (AbortSignal.any and AbortSignal.timeout aren't in every engine the + * app runs on). Call `done` when the request has finished. + */ +export function signalWithTimeout( + signal: AbortSignal | undefined, + ms?: number, +): { signal?: AbortSignal; done: () => void } { + if (typeof AbortController === 'undefined') return { signal, done: () => undefined }; + if (!signal && ms === undefined) return { done: () => undefined }; + const controller = new AbortController(); + const abort = () => controller.abort(); + const timer = ms !== undefined ? setTimeout(abort, ms) : undefined; + if (signal?.aborted) controller.abort(); + else signal?.addEventListener('abort', abort, { once: true }); + return { + signal: controller.signal, + done: () => { + if (timer !== undefined) clearTimeout(timer); + signal?.removeEventListener('abort', abort); + }, + }; +} diff --git a/src/lib/totp-login.ts b/src/lib/totp-login.ts index 2c00a39e..289d3a3e 100644 --- a/src/lib/totp-login.ts +++ b/src/lib/totp-login.ts @@ -29,6 +29,7 @@ import { sha256Hex } from './sha256'; import { base64UrlEncode, randomBytes, randomHex } from './random'; import { HANDOFF_REDIRECT_URI, type OAuthTokens } from './oauth'; import { NetworkError } from '../api/jmap-client'; +import { signalWithTimeout, throwIfCancelled } from './sign-in-abort'; // Stalwart 0.16's built-in OAuth accepts any client id; the webmail's public // one keeps refresh behaviour identical to the browser-handoff bundle. @@ -134,13 +135,6 @@ function rememberClient(base: string, client: LoginClient): void { loginClients.set(base, client); } -function withTimeout(ms: number): { signal?: AbortSignal; done: () => void } { - if (typeof AbortController === 'undefined') return { done: () => undefined }; - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), ms); - return { signal: controller.signal, done: () => clearTimeout(timer) }; -} - // The password and the PKCE verifier must not follow a redirect to another // host. The requests go out with fetchWithoutForeignRedirect, which hands such // a redirect back unfollowed; where the platform followed it anyway, the @@ -163,8 +157,12 @@ function assertSameServer(requested: string, response: Response): void { * names, which may be on the public address it advertises rather than the one * the user typed; the id is sealed with the server's key, so it serves both. */ -async function registerPublicClient(base: string, url = `${base}/auth/register`): Promise { - const t = withTimeout(CLIENT_CHECK_TIMEOUT_MS); +async function registerPublicClient( + base: string, + url = `${base}/auth/register`, + signal?: AbortSignal, +): Promise { + const t = signalWithTimeout(signal, CLIENT_CHECK_TIMEOUT_MS); let response: Response; try { response = await fetchWithoutForeignRedirect(url, { @@ -182,6 +180,7 @@ async function registerPublicClient(base: string, url = `${base}/auth/register`) timeoutMs: CLIENT_CHECK_TIMEOUT_MS, }); } catch (err) { + throwIfCancelled(signal); throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable'); } finally { t.done(); @@ -207,6 +206,7 @@ async function checkClient( base: string, clientId: string, redirectUri: string, + signal?: AbortSignal, ): Promise<'known' | 'unknown' | 'none' | 'busy'> { const query = new URLSearchParams({ response_type: 'code', @@ -217,7 +217,7 @@ async function checkClient( code_challenge_method: 'S256', }); const url = `${base}/api/auth/client?${query.toString()}`; - const t = withTimeout(CLIENT_CHECK_TIMEOUT_MS); + const t = signalWithTimeout(signal, CLIENT_CHECK_TIMEOUT_MS); let response: Response; try { response = await fetchWithoutForeignRedirect(url, { @@ -226,6 +226,7 @@ async function checkClient( timeoutMs: CLIENT_CHECK_TIMEOUT_MS, }); } catch (err) { + throwIfCancelled(signal); throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable'); } finally { t.done(); @@ -253,14 +254,15 @@ async function checkClient( */ export async function resolveLoginClient( serverUrl: string, - opts?: { registrationEndpoint?: string }, + opts?: { registrationEndpoint?: string; signal?: AbortSignal }, ): Promise { const base = trimBase(serverUrl); const cached = loginClients.get(base); if (cached) return cached; const fallback: LoginClient = { clientId: DEFAULT_CLIENT_ID, redirectUri: HANDOFF_REDIRECT_URI }; + const signal = opts?.signal; - const own = await checkClient(base, MOBILE_CLIENT_ID, REGISTERED_REDIRECT_URI); + const own = await checkClient(base, MOBILE_CLIENT_ID, REGISTERED_REDIRECT_URI, signal); if (own === 'busy') return fallback; if (own === 'known') { const client = { clientId: MOBILE_CLIENT_ID, redirectUri: REGISTERED_REDIRECT_URI }; @@ -268,11 +270,11 @@ export async function resolveLoginClient( return client; } if (own === 'unknown') { - const shared = await checkClient(base, DEFAULT_CLIENT_ID, HANDOFF_REDIRECT_URI); + const shared = await checkClient(base, DEFAULT_CLIENT_ID, HANDOFF_REDIRECT_URI, signal); if (shared === 'busy') return fallback; if (shared === 'unknown') { const endpoint = opts?.registrationEndpoint; - return registerPublicClient(base, endpoint && /^https?:\/\//i.test(endpoint) ? endpoint : undefined); + return registerPublicClient(base, endpoint && /^https?:\/\//i.test(endpoint) ? endpoint : undefined, signal); } } rememberClient(base, fallback); @@ -386,6 +388,7 @@ async function requestLoginCode( totp: string | undefined, client: LoginClient, challenge: string, + signal?: AbortSignal, ): Promise { const state = randomHex(16); const url = `${base}/api/auth`; @@ -403,8 +406,10 @@ async function requestLoginCode( codeChallenge: challenge, state, })), + signal, }); } catch (err) { + throwIfCancelled(signal); throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable'); } assertSameServer(url, loginResponse); @@ -434,26 +439,28 @@ export async function exchangePasswordForTokens( username: string, password: string, totp?: string, - opts?: { clientId?: string; redirectUri?: string }, + opts?: { clientId?: string; redirectUri?: string; signal?: AbortSignal }, ): Promise { const base = trimBase(serverUrl); const verifier = generateCodeVerifier(); const challenge = generateCodeChallenge(verifier); + const signal = opts?.signal; + throwIfCancelled(signal); let client: LoginClient = opts?.clientId ? { clientId: opts.clientId, redirectUri: opts.redirectUri ?? HANDOFF_REDIRECT_URI } - : await resolveLoginClient(base); - let outcome = await requestLoginCode(base, username, password, totp, client, challenge); + : await resolveLoginClient(base, { signal }); + let outcome = await requestLoginCode(base, username, password, totp, client, challenge, signal); // A remembered client the server no longer knows (another server now // answers at the address, or its key changed), or the default taken while // the client check failed: ask again, once. 1.0 checks the client before // the credentials, so this costs no failed sign-in. if (outcome.kind === 'rejected' && outcome.error === 'invalid_client' && !opts?.clientId) { loginClients.delete(base); - const fresh = await resolveLoginClient(base); + const fresh = await resolveLoginClient(base, { signal }); if (fresh.clientId !== client.clientId) { client = fresh; - outcome = await requestLoginCode(base, username, password, totp, client, challenge); + outcome = await requestLoginCode(base, username, password, totp, client, challenge, signal); } } @@ -486,8 +493,10 @@ export async function exchangePasswordForTokens( method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body: tokenParams.toString(), + signal, }); } catch (err) { + throwIfCancelled(signal); throw new NetworkError(err instanceof Error ? err.message : 'Server unreachable'); } assertSameServer(tokenUrl, tokenResponse); diff --git a/src/screens/LoginScreen.tsx b/src/screens/LoginScreen.tsx index edc927aa..822ca07e 100644 --- a/src/screens/LoginScreen.tsx +++ b/src/screens/LoginScreen.tsx @@ -33,6 +33,8 @@ import ServerStep from './login/ServerStep'; import ConfirmStep from './login/ConfirmStep'; import PasswordStep from './login/PasswordStep'; import SigningInStep, { type SigningInPhase } from './login/SigningInStep'; +import { loginBackAction } from './login/login-back'; +import { isSignInCancelled } from '../lib/sign-in-abort'; interface LoginScreenProps { onLogin?: () => void; @@ -117,6 +119,33 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo // (in add mode the server fields still name the signed-in account's host). const [pairingWebmailUrl, setPairingWebmailUrl] = React.useState(null); + // One controller per sign-in or server search (U-133). Cancel and Back + // abort it; a flow whose controller is no longer the current one drops + // whatever its awaits bring back, so a late answer neither signs in nor + // shows an error over the form the user went back to. + const attemptRef = React.useRef(null); + const beginAttempt = React.useCallback((): AbortController => { + attemptRef.current?.abort(); + const controller = new AbortController(); + attemptRef.current = controller; + return controller; + }, []); + const isLive = (attempt: AbortController) => attemptRef.current === attempt && !attempt.signal.aborted; + const endAttempt = React.useCallback((attempt: AbortController): boolean => { + if (attemptRef.current !== attempt) return false; + attemptRef.current = null; + return true; + }, []); + const cancelSignIn = React.useCallback(() => { + attemptRef.current?.abort(); + attemptRef.current = null; + setBusy(null); + setSearching(false); + setPairingWebmailUrl(null); + setNotice(null); + }, []); + React.useEffect(() => () => attemptRef.current?.abort(), []); + // Addresses typed before on this device (last 5) plus the registry's, so a // returning user taps instead of retyping. Never contains passwords. const [recentEmails, setRecentEmails] = React.useState([]); @@ -216,27 +245,36 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo // out of the flow entirely from the first step. React.useEffect(() => { const subscription = BackHandler.addEventListener('hardwareBackPress', () => { - if (busy || searching) return true; - if (scannerVisible) { - setScannerVisible(false); - return true; + const action = loginBackAction({ + busy: !!busy, + searching, + scannerVisible, + pasteVisible, + historyLength: history.length, + canLeave: isAddMode && !!onCancel, + }); + switch (action) { + case 'cancel': + cancelSignIn(); + return true; + case 'close-scanner': + setScannerVisible(false); + return true; + case 'close-paste': + setPasteVisible(false); + return true; + case 'step-back': + goBack(); + return true; + case 'leave': + onCancel?.(); + return true; + default: + return false; } - if (pasteVisible) { - setPasteVisible(false); - return true; - } - if (history.length > 0) { - goBack(); - return true; - } - if (isAddMode && onCancel) { - onCancel(); - return true; - } - return false; }); return () => subscription.remove(); - }, [busy, searching, scannerVisible, pasteVisible, history.length, goBack, isAddMode, onCancel]); + }, [busy, searching, scannerVisible, pasteVisible, history.length, goBack, isAddMode, onCancel, cancelSignIn]); // ── flows ────────────────────────────────────────────── @@ -279,21 +317,24 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo const runHandoff = React.useCallback( async (target: string) => { setNotice(null); + const attempt = beginAttempt(); setBusy('browser'); const wasAuthenticated = useAuthStore.getState().isAuthenticated; try { - await loginViaWebmail(target, { addAccount: isAddMode }); + await loginViaWebmail(target, { addAccount: isAddMode, signal: attempt.signal }); + if (!isLive(attempt)) return; // loginViaWebmail resolves quietly when the user closes the browser — // that's a cancellation, not a failure, so leave them where they were. finishIfSignedIn(wasAuthenticated); } catch (err) { + if (!isLive(attempt) || isSignInCancelled(err)) return; if (continueWithSecondFactor(err, target)) return; setNotice(describeLoginError(err, { serverUrl: target, t })); } finally { - setBusy(null); + if (endAttempt(attempt)) setBusy(null); } }, - [continueWithSecondFactor, finishIfSignedIn, isAddMode, loginViaWebmail, t], + [beginAttempt, continueWithSecondFactor, endAttempt, finishIfSignedIn, isAddMode, loginViaWebmail, t], ); const handleEmailContinue = React.useCallback(async () => { @@ -307,10 +348,12 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo } setNotice(null); + const attempt = beginAttempt(); setSearching(true); rememberEmail(value); try { - const found = await discoverServerForEmail(value, { knownServerUrls }); + const found = await discoverServerForEmail(value, { knownServerUrls, signal: attempt.signal }); + if (!isLive(attempt)) return; if (found) { setServerUrl(found); setServerInput(found); @@ -322,9 +365,9 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo goTo('server'); } } finally { - setSearching(false); + if (endAttempt(attempt)) setSearching(false); } - }, [email, goTo, knownServerUrls, rememberEmail, t]); + }, [beginAttempt, email, endAttempt, goTo, knownServerUrls, rememberEmail, t]); const handleServerContinue = React.useCallback(() => { const normalized = normalizeServerUrl(serverInput); @@ -353,15 +396,18 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo return; } setNotice(null); + const attempt = beginAttempt(); setBusy('connecting'); const wasAuthenticated = useAuthStore.getState().isAuthenticated; try { - await loginWithToken(target, accessToken, { addAccount: isAddMode }); + await loginWithToken(target, accessToken, { addAccount: isAddMode, signal: attempt.signal }); + if (!isLive(attempt)) return; finishIfSignedIn(wasAuthenticated); } catch (err) { + if (!isLive(attempt) || isSignInCancelled(err)) return; setNotice(describeLoginError(err, { serverUrl: target, t })); } finally { - setBusy(null); + if (endAttempt(attempt)) setBusy(null); } return; } @@ -377,15 +423,19 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo } setNotice(null); + const attempt = beginAttempt(); setBusy('connecting'); const wasAuthenticated = useAuthStore.getState().isAuthenticated; try { await login(target, email.trim(), password, { addAccount: isAddMode, totp: totpRequired ? totp.trim() : undefined, + signal: attempt.signal, }); + if (!isLive(attempt)) return; finishIfSignedIn(wasAuthenticated); } catch (err) { + if (!isLive(attempt) || isSignInCancelled(err)) return; if (err instanceof Error && err.name === 'TotpRequiredError') { setTotpRequired(true); setNotice(describeLoginError(err, { serverUrl: target, t })); @@ -393,9 +443,9 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo } setNotice(describeLoginError(err, { serverUrl: target, t })); } finally { - setBusy(null); + if (endAttempt(attempt)) setBusy(null); } - }, [accessToken, email, finishIfSignedIn, isAddMode, login, loginWithToken, password, serverInput, serverUrl, tokenMode, totp, totpRequired, t]); + }, [accessToken, beginAttempt, email, endAttempt, finishIfSignedIn, isAddMode, login, loginWithToken, password, serverInput, serverUrl, tokenMode, totp, totpRequired, t]); // A sign-in code, however it arrived: scanned, pasted, or a tapped // `bulwarkmail://` link. @@ -415,23 +465,28 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo // Cross-device pairing code: redeem it, no browser needed. setNotice(null); + const attempt = beginAttempt(); setPairingWebmailUrl(payload.webmailUrl); setBusy('pairing'); const wasAuthenticated = useAuthStore.getState().isAuthenticated; try { - await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode }); + await loginViaPairing(payload.webmailUrl, payload.code, { addAccount: isAddMode, signal: attempt.signal }); + if (!isLive(attempt)) return; finishIfSignedIn(wasAuthenticated); } catch (err) { + if (!isLive(attempt) || isSignInCancelled(err)) return; if (continueWithSecondFactor(err, payload.webmailUrl)) return; // The error names its own host: the webmail for a failed redeem, the // mail server when connecting afterwards failed. setNotice(describeLoginError(err, { serverUrl: payload.webmailUrl, t })); } finally { - setBusy(null); - setPairingWebmailUrl(null); + if (endAttempt(attempt)) { + setBusy(null); + setPairingWebmailUrl(null); + } } }, - [continueWithSecondFactor, finishIfSignedIn, isAddMode, loginViaPairing, runHandoff, t], + [beginAttempt, continueWithSecondFactor, endAttempt, finishIfSignedIn, isAddMode, loginViaPairing, runHandoff, t], ); const handleScanned = React.useCallback( @@ -544,6 +599,7 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo phase={busy} serverUrl={pairing ? pairingWebmailUrl : serverUrl || knownServerUrl} email={pairing || tokenMode ? null : email.trim() || null} + onCancel={cancelSignIn} /> ); } @@ -565,7 +621,11 @@ export default function LoginScreen({ onLogin, isAddMode = false, onCancel }: Lo const canGoBack = history.length > 0; const shellProps = { - onBack: canGoBack ? goBack : undefined, + // Back during the server search stops it as well (U-133). + onBack: canGoBack ? () => { + if (searching) cancelSignIn(); + goBack(); + } : undefined, onClose: isAddMode && onCancel ? onCancel : undefined, }; diff --git a/src/screens/login/SigningInStep.tsx b/src/screens/login/SigningInStep.tsx index e7a68227..30e5e417 100644 --- a/src/screens/login/SigningInStep.tsx +++ b/src/screens/login/SigningInStep.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, StyleSheet, ActivityIndicator, Image } from 'react-native'; +import { View, Text, StyleSheet, ActivityIndicator, Image, Pressable } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../../theme/tokens'; import { useColors, useResolvedTheme } from '../../theme/colors'; @@ -16,6 +16,8 @@ interface SigningInStepProps { phase: SigningInPhase; serverUrl: string | null; email: string | null; + /** Stops the sign-in and goes back to the step it started from (U-133). */ + onCancel?: () => void; } function hostOf(url: string | null): string { @@ -32,7 +34,7 @@ function initialsOf(email: string): string { * current stage makes a slow first connection legible instead of a bare * spinner that could mean anything. */ -export default function SigningInStep({ phase, serverUrl, email }: SigningInStepProps) { +export default function SigningInStep({ phase, serverUrl, email, onCancel }: SigningInStepProps) { const c = useColors(); const theme = useResolvedTheme(); const styles = React.useMemo(() => makeStyles(c), [c]); @@ -68,6 +70,18 @@ export default function SigningInStep({ phase, serverUrl, email }: SigningInStep {copy[phase].detail} + + {onCancel ? ( + [styles.cancel, pressed && styles.cancelPressed]} + accessibilityRole="button" + accessibilityLabel={t('login.mobile.cancel_sign_in', 'Cancel sign-in')} + > + {t('common.cancel', 'Cancel')} + + ) : null} ); @@ -98,5 +112,15 @@ function makeStyles(c: ThemePalette) { title: { ...typography.h2, color: c.text, textAlign: 'center' }, detail: { ...typography.body, color: c.textSecondary, textAlign: 'center' }, spinner: { marginTop: spacing.xl }, + cancel: { + marginTop: spacing.lg, + minHeight: componentSizes.buttonLg, + paddingHorizontal: spacing.lg, + alignItems: 'center', + justifyContent: 'center', + borderRadius: radius.md, + }, + cancelPressed: { opacity: 0.6 }, + cancelText: { ...typography.bodyMedium, color: c.primary }, }); } diff --git a/src/screens/login/__tests__/login-back.test.ts b/src/screens/login/__tests__/login-back.test.ts new file mode 100644 index 00000000..a65a890b --- /dev/null +++ b/src/screens/login/__tests__/login-back.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { loginBackAction, type LoginBackState } from '../login-back'; + +const idle: LoginBackState = { + busy: false, + searching: false, + scannerVisible: false, + pasteVisible: false, + historyLength: 2, + canLeave: true, +}; + +describe('Back on the sign-in screen', () => { + it('cancels a sign-in under way instead of swallowing the key (U-133)', () => { + expect(loginBackAction({ ...idle, busy: true })).toBe('cancel'); + }); + + it('cancels the server search', () => { + expect(loginBackAction({ ...idle, searching: true })).toBe('cancel'); + }); + + it('closes the scanner, then the paste dialog, then goes a step back', () => { + expect(loginBackAction({ ...idle, scannerVisible: true })).toBe('close-scanner'); + expect(loginBackAction({ ...idle, pasteVisible: true })).toBe('close-paste'); + expect(loginBackAction(idle)).toBe('step-back'); + }); + + it('leaves add-account from the first step, and lets the system have the key otherwise', () => { + expect(loginBackAction({ ...idle, historyLength: 0 })).toBe('leave'); + expect(loginBackAction({ ...idle, historyLength: 0, canLeave: false })).toBe('exit'); + }); +}); diff --git a/src/screens/login/login-back.ts b/src/screens/login/login-back.ts new file mode 100644 index 00000000..3c32b76d --- /dev/null +++ b/src/screens/login/login-back.ts @@ -0,0 +1,26 @@ +// What Android Back (and the on-screen back) does on the sign-in screen. +// While a sign-in or the server search runs, Back cancels it and returns to +// the step it started from (U-133); it used to be swallowed, which left a +// hung "Signing in" with no way out but killing the app. + +export type LoginBackAction = 'cancel' | 'close-scanner' | 'close-paste' | 'step-back' | 'leave' | 'exit'; + +export interface LoginBackState { + busy: boolean; + searching: boolean; + scannerVisible: boolean; + pasteVisible: boolean; + /** Steps behind the current one. */ + historyLength: number; + /** Add-account mode with a way to close the screen. */ + canLeave: boolean; +} + +export function loginBackAction(state: LoginBackState): LoginBackAction { + if (state.busy || state.searching) return 'cancel'; + if (state.scannerVisible) return 'close-scanner'; + if (state.pasteVisible) return 'close-paste'; + if (state.historyLength > 0) return 'step-back'; + if (state.canLeave) return 'leave'; + return 'exit'; +} diff --git a/src/stores/__tests__/auth-store-cancel.test.ts b/src/stores/__tests__/auth-store-cancel.test.ts new file mode 100644 index 00000000..c391a872 --- /dev/null +++ b/src/stores/__tests__/auth-store-cancel.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// U-133: a sign-in that hangs had no way out. The sign-in screen now hands +// the store an AbortSignal; once it fires, the sign-in rejects at once, the +// client goes back to what it served before, and nothing of the account the +// sign-in was adding stays behind, even when the connect finishes later. + +let finishConnect: ((value: unknown) => void) | null = null; + +vi.mock('../../api/jmap-client', () => ({ + jmapClient: { + connect: vi.fn(() => new Promise((resolve) => { finishConnect = resolve; })), + snapshot: vi.fn(() => ({ session: 'previous', credentials: null, accountId: 'prev' })), + restoreSnapshot: vi.fn(), + reset: vi.fn(), + clearAccountCredentials: vi.fn(async () => undefined), + onAuthFailure: vi.fn(() => () => undefined), + onTokenRefresh: vi.fn(() => () => undefined), + }, + AuthenticationError: class AuthenticationError extends Error {}, + InvalidTokenError: class InvalidTokenError extends Error {}, + NetworkError: class NetworkError extends Error {}, + SessionSupersededError: class SessionSupersededError extends Error {}, +})); + +vi.mock('../../lib/push-notifications', () => ({ + teardownPushNotifications: vi.fn(async () => undefined), + teardownPushNotificationsForAccount: vi.fn(async () => undefined), +})); + +import { jmapClient } from '../../api/jmap-client'; +import { useAuthStore } from '../auth-store'; +import { useAccountStore } from '../account-store'; + +const SERVER = 'https://mail.example.com'; + +beforeEach(() => { + vi.clearAllMocks(); + finishConnect = null; + useAccountStore.setState({ accounts: [], activeAccountId: null }); + useAuthStore.setState({ isAuthenticated: false, isLoading: false, error: null }); +}); + +async function connectStarted(): Promise { + await vi.waitFor(() => expect(finishConnect).not.toBeNull()); +} + +describe('cancelling a sign-in', () => { + it('rejects at once while the server has not answered, and keeps no error', async () => { + const controller = new AbortController(); + const pending = useAuthStore.getState().login(SERVER, 'ada@example.com', 'pw', { signal: controller.signal }); + await connectStarted(); + + controller.abort(); + await expect(pending).rejects.toMatchObject({ name: 'SignInCancelledError' }); + + expect(useAuthStore.getState()).toMatchObject({ isLoading: false, error: null, isAuthenticated: false }); + // The client must not go on serving the half-connected account. + expect(jmapClient.reset).toHaveBeenCalled(); + expect(useAccountStore.getState().accounts).toEqual([]); + }); + + it('drops the credentials a connect stores after the user cancelled', async () => { + const controller = new AbortController(); + const pending = useAuthStore.getState().login(SERVER, 'ada@example.com', 'pw', { signal: controller.signal }); + await connectStarted(); + controller.abort(); + await expect(pending).rejects.toMatchObject({ name: 'SignInCancelledError' }); + + finishConnect!({ apiUrl: `${SERVER}/jmap/` }); + await vi.waitFor(() => { + expect(jmapClient.clearAccountCredentials).toHaveBeenCalledWith('ada@example.com@mail.example.com'); + }); + expect(useAccountStore.getState().accounts).toEqual([]); + expect(useAuthStore.getState().isAuthenticated).toBe(false); + }); + + it('puts the account in use back when adding one is cancelled', async () => { + useAuthStore.setState({ isAuthenticated: true }); + const controller = new AbortController(); + const pending = useAuthStore.getState().login(SERVER, 'bob@example.com', 'pw', { + addAccount: true, + signal: controller.signal, + }); + await connectStarted(); + controller.abort(); + await expect(pending).rejects.toMatchObject({ name: 'SignInCancelledError' }); + + expect(jmapClient.restoreSnapshot).toHaveBeenCalledWith({ session: 'previous', credentials: null, accountId: 'prev' }); + expect(useAuthStore.getState().isAuthenticated).toBe(true); + }); + + it('does not start when the signal fired before', async () => { + const controller = new AbortController(); + controller.abort(); + await expect( + useAuthStore.getState().loginWithToken(SERVER, 'tok', { signal: controller.signal }), + ).rejects.toMatchObject({ name: 'SignInCancelledError' }); + expect(useAuthStore.getState().error).toBeNull(); + }); +}); diff --git a/src/stores/__tests__/auth-store-pairing.test.ts b/src/stores/__tests__/auth-store-pairing.test.ts index f0dfa343..c0b6f7cb 100644 --- a/src/stores/__tests__/auth-store-pairing.test.ts +++ b/src/stores/__tests__/auth-store-pairing.test.ts @@ -132,7 +132,7 @@ describe('loginViaPairing with an app password', () => { await useAuthStore.getState().loginViaPairing(WEBMAIL, newCode()); - expect(connect).toHaveBeenCalledWith(SERVER, 'ada@example.com', 'app-pass-123', undefined); + expect(connect).toHaveBeenCalledWith(SERVER, 'ada@example.com', 'app-pass-123', undefined, undefined); expect(connectWithOAuth).not.toHaveBeenCalled(); expect(useAuthStore.getState()).toMatchObject({ isAuthenticated: true, username: 'ada@example.com', serverUrl: SERVER }); }); diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index 7e683bbe..31717643 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -41,6 +41,7 @@ import { import { reconcileServerFingerprint } from '../lib/server-change'; import { deviceSyncSignedIn, releaseDeviceSyncBeforeSignOut } from '../device-sync/app/lifecycle'; import { perfMark } from '../lib/perf-marks'; +import { abortable, isSignInCancelled, throwIfCancelled } from '../lib/sign-in-abort'; // Persist middleware hydrates asynchronously on cold start. Without this // guard, restoreSession() can read the account-store before AsyncStorage has @@ -97,14 +98,14 @@ export interface AuthState { serverUrl: string, username: string, password: string, - opts?: { addAccount?: boolean; totp?: string }, + opts?: SignInOptions & { totp?: string }, ) => Promise; /** Access-token (Bearer) sign-in with a token the user pasted, e.g. a Fastmail API token. */ - loginWithToken: (serverUrl: string, token: string, opts?: { addAccount?: boolean }) => Promise; - loginViaWebmail: (webmailUrl: string, opts?: { addAccount?: boolean }) => Promise; + loginWithToken: (serverUrl: string, token: string, opts?: SignInOptions) => Promise; + loginViaWebmail: (webmailUrl: string, opts?: SignInOptions) => Promise; /** OAuth/OIDC (PKCE) straight against the mail server's authorization server. */ - loginViaOAuth: (serverUrl: string, opts?: { addAccount?: boolean }) => Promise; - loginViaPairing: (webmailUrl: string, code: string, opts?: { addAccount?: boolean }) => Promise; + loginViaOAuth: (serverUrl: string, opts?: SignInOptions) => Promise; + loginViaPairing: (webmailUrl: string, code: string, opts?: SignInOptions) => Promise; logout: () => Promise; logoutAll: () => Promise; switchAccount: (accountId: string) => Promise; @@ -259,6 +260,16 @@ async function switchesSettled(): Promise { while (pendingSwitch) await pendingSwitch.settled; } +/** + * `signal` is the sign-in screen's Cancel (U-133): once it fires, the sign-in + * rejects with SignInCancelledError, puts the connection in use back and + * keeps nothing of the account it was adding. + */ +export interface SignInOptions { + addAccount?: boolean; + signal?: AbortSignal; +} + interface SignedIn { session: JMAPSession; serverUrl: string; @@ -276,12 +287,14 @@ interface SignedIn { async function signIn( set: (partial: Partial) => void, get: () => AuthState, - opts: { addAccount?: boolean } | undefined, + opts: SignInOptions | undefined, connect: () => Promise, ): Promise { signInsUnderWay += 1; let saved: typeof signInSaved = null; + const signal = opts?.signal; try { + throwIfCancelled(signal); // A switch under way lands first, so there is one account to put back. await switchesSettled(); const isCurrent = beginAccountChange(); @@ -304,7 +317,23 @@ async function signIn( let wasRegistered = true; const accountStore = useAccountStore.getState(); try { - signedIn = await connect(); + const connecting = connect(); + if (signal) { + // Cancelled while connecting: the connect goes on in the background + // until its next session check (putting the connection back below + // supersedes it). Should it get as far as storing credentials for + // the account anyway, they go too. + connecting.then( + (late) => { + if (!signal.aborted || signedIn) return; + if (!useAccountStore.getState().getAccountById(late.accountId)) { + void jmapClient.clearAccountCredentials(late.accountId).catch(() => undefined); + } + }, + () => undefined, + ); + } + signedIn = await abortable(connecting, signal); if (!isCurrent()) throw new SessionSupersededError(); wasRegistered = !!accountStore.getAccountById(signedIn.accountId); // Contacts/calendar are still single-bucket, so wipe those now that @@ -315,6 +344,7 @@ async function signIn( } await checkServerChange(signedIn.accountId, signedIn.session); if (!isCurrent()) throw new SessionSupersededError(); + throwIfCancelled(signal); accountStore.addAccount({ serverUrl: signedIn.serverUrl, username: signedIn.username, @@ -325,7 +355,8 @@ async function signIn( hasError: false, }); } catch (err) { - putBack(!!signedIn); + // A cancelled connect may already hold the client: take it back. + putBack(!!signedIn || isSignInCancelled(err)); // connect() stored credentials for an account the registry never took // (the account limit, or a newer account change): none stay behind. if (signedIn && !wasRegistered && !useAccountStore.getState().getAccountById(signedIn.accountId)) { @@ -394,7 +425,7 @@ function assertRoomForAccount(accountId?: string): void { // Adding an account to a full registry: refuse before a sign-in code is // spent or a browser sign-in started, since what they buy can't be kept. -function refuseAddWhenFull(set: (partial: Partial) => void, opts?: { addAccount?: boolean }): void { +function refuseAddWhenFull(set: (partial: Partial) => void, opts?: SignInOptions): void { if (!opts?.addAccount) return; try { assertRoomForAccount(); @@ -463,7 +494,7 @@ async function completeOAuthHandoff( set: (partial: Partial) => void, get: () => AuthState, result: Extract, - opts?: { addAccount?: boolean }, + opts?: SignInOptions, ): Promise { await signIn(set, get, opts, async () => { const { session, username, accountId } = await jmapClient.connectWithOAuth(result.serverUrl, result.tokens); @@ -471,6 +502,23 @@ async function completeOAuthHandoff( }); } +// After an await of a sign-in step that takes no signal: stop when the user +// cancelled meanwhile. +function cancelledSignIn(set: (partial: Partial) => void, opts?: SignInOptions): void { + if (!opts?.signal?.aborted) return; + set({ isLoading: false, error: null }); + throwIfCancelled(opts.signal); +} + +function oauthSignInMessage(err: unknown): string | null { + if (isSignInCancelled(err)) return null; + return err instanceof AuthenticationError + ? 'Authentication rejected by server' + : err instanceof Error + ? err.message + : 'OAuth sign-in failed'; +} + function applyConnectedState( set: (partial: Partial) => void, session: JMAPSession, @@ -514,12 +562,16 @@ export const useAuthStore = create((set, get) => ({ // Adding an additional account keeps the live connection until the // new sign-in succeeded, so a typo doesn't kill the current session. await signIn(set, get, opts, async () => { - const session = await jmapClient.connect(serverUrl, username, password, opts?.totp); + const session = await jmapClient.connect(serverUrl, username, password, opts?.totp, opts?.signal); const base = serverUrl.replace(/\/+$/, ''); return { session, serverUrl: base, username, accountId: generateAccountId(username, base) }; }); set({ pendingTotpLogin: null }); } catch (err) { + if (isSignInCancelled(err)) { + set({ isLoading: false, error: null }); + throw err; + } if (err instanceof Error && err.name === 'TotpRequiredError') { // Keep what the user (or the webmail hand-off) supplied so the code // step doesn't make them retype the password. @@ -550,7 +602,7 @@ export const useAuthStore = create((set, get) => ({ }); set({ pendingTotpLogin: null }); } catch (err) { - set({ isLoading: false, error: err instanceof Error ? err.message : 'Connection failed' }); + set({ isLoading: false, error: isSignInCancelled(err) ? null : err instanceof Error ? err.message : 'Connection failed' }); throw err; } }, @@ -562,8 +614,11 @@ export const useAuthStore = create((set, get) => ({ // served there. Opening `/login?mobile_redirect_uri=…` on a bare Stalwart // lands on a 404 or the admin page, so check first and fall back to the // server's own OAuth (PKCE) when the webmail is missing. - if (!(await probeWebmail(webmailUrl))) { + const hasWebmail = await probeWebmail(webmailUrl); + cancelledSignIn(set, opts); + if (!hasWebmail) { const metadata = await discoverOAuthMetadata(webmailUrl); + cancelledSignIn(set, opts); if (metadata) { await get().loginViaOAuth(webmailUrl, opts); return; @@ -575,12 +630,14 @@ export const useAuthStore = create((set, get) => ({ let result; try { result = await runWebmailHandoff(webmailUrl, { addAccount: opts?.addAccount }); + cancelledSignIn(set, opts); } catch (err) { if (err instanceof HandoffCancelledError) { // User closed the browser tab — quiet exit, no error banner. set({ isLoading: false, error: null }); return; } + if (isSignInCancelled(err)) throw err; const message = err instanceof Error ? err.message : 'Sign-in failed'; set({ isLoading: false, error: message }); throw err; @@ -600,13 +657,7 @@ export const useAuthStore = create((set, get) => ({ try { await completeOAuthHandoff(set, get, result, opts); } catch (err) { - const message = - err instanceof AuthenticationError - ? 'Authentication rejected by server' - : err instanceof Error - ? err.message - : 'OAuth sign-in failed'; - set({ isLoading: false, error: message }); + set({ isLoading: false, error: oauthSignInMessage(err) }); throw err; } }, @@ -620,11 +671,13 @@ export const useAuthStore = create((set, get) => ({ const metadata = await discoverOAuthMetadata(base); if (!metadata) throw new HandoffError('This server does not offer OAuth sign-in'); tokens = await loginWithPkce(base, metadata, { addAccount: opts?.addAccount }); + cancelledSignIn(set, opts); } catch (err) { if (err instanceof HandoffCancelledError) { set({ isLoading: false, error: null }); return; } + if (isSignInCancelled(err)) throw err; const message = err instanceof Error ? err.message : 'Sign-in failed'; set({ isLoading: false, error: message }); throw err; @@ -632,13 +685,7 @@ export const useAuthStore = create((set, get) => ({ try { await completeOAuthHandoff(set, get, { flow: 'oauth', serverUrl: base, tokens }, opts); } catch (err) { - const message = - err instanceof AuthenticationError - ? 'Authentication rejected by server' - : err instanceof Error - ? err.message - : 'OAuth sign-in failed'; - set({ isLoading: false, error: message }); + set({ isLoading: false, error: oauthSignInMessage(err) }); throw err; } }, @@ -660,13 +707,13 @@ export const useAuthStore = create((set, get) => ({ set({ isLoading: true, error: null }); let result: HandoffResult; try { - result = await redeemPairingCode(webmailUrl, code); + result = await abortable(redeemPairingCode(webmailUrl, code), opts?.signal); } catch (err) { // Not redeemed here: the server is the judge of a retry (it answers // "used" if the request did get through). pairingCodesSeen.delete(code); const message = err instanceof Error ? err.message : 'Pairing failed'; - set({ isLoading: false, error: message }); + set({ isLoading: false, error: isSignInCancelled(err) ? null : message }); throw err; } @@ -677,6 +724,10 @@ export const useAuthStore = create((set, get) => ({ // keeps for that step), and the account limit, which a new code won't fix. const serverHost = hostOfUrl(result.serverUrl); const connectFailed = (err: unknown): unknown => { + if (isSignInCancelled(err)) { + set({ isLoading: false, error: null }); + return err; + } if (err instanceof Error && (err.name === 'TotpRequiredError' || err instanceof AccountLimitError)) { // `login` has settled the store already; the OAuth path has not. if (get().isLoading) set({ isLoading: false, error: err.message }); @@ -700,7 +751,7 @@ export const useAuthStore = create((set, get) => ({ // password): the normal password sign-in, which also keeps the live // account when adding another one fails. try { - await get().login(result.serverUrl, result.username, result.password, { addAccount: opts?.addAccount }); + await get().login(result.serverUrl, result.username, result.password, { addAccount: opts?.addAccount, signal: opts?.signal }); } catch (err) { throw connectFailed(err); } From 115229958330d525ec730946c7e9298bb4d960e7 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:42:24 +0100 Subject: [PATCH 02/26] fix: raise grey text to 4.5:1 contrast in every built-in theme --- src/theme/__tests__/standard-theme.test.ts | 7 +- src/theme/__tests__/theme-contrast.test.ts | 66 +++++++++++++++++++ src/theme/builtin-themes.ts | 75 +++++++++++----------- src/theme/tokens.ts | 24 ++++--- 4 files changed, 123 insertions(+), 49 deletions(-) create mode 100644 src/theme/__tests__/theme-contrast.test.ts diff --git a/src/theme/__tests__/standard-theme.test.ts b/src/theme/__tests__/standard-theme.test.ts index 5f71ea12..e1543204 100644 --- a/src/theme/__tests__/standard-theme.test.ts +++ b/src/theme/__tests__/standard-theme.test.ts @@ -2,14 +2,15 @@ import { describe, it, expect } from 'vitest'; import { DARK_COLORS, LIGHT_COLORS } from '../tokens'; // The standard theme's roles from branding/APP.md ("Tokens used here"), -// which follow the webmail's app/globals.css. +// which follow the webmail's app/globals.css (muted-foreground raised to +// 4.5:1 in 9f6c7b99c). describe('standard theme tokens match the webmail', () => { it('light', () => { expect(LIGHT_COLORS.background).toBe('#ffffff'); expect(LIGHT_COLORS.text).toBe('#0f172a'); expect(LIGHT_COLORS.popover).toBe('#ffffff'); expect(LIGHT_COLORS.border).toBe('#e2e8f0'); - expect(LIGHT_COLORS.mutedForeground).toBe('#64748b'); + expect(LIGHT_COLORS.mutedForeground).toBe('#59687c'); }); it('dark: white primary with near-black text on neutral greys', () => { @@ -19,7 +20,7 @@ describe('standard theme tokens match the webmail', () => { expect(DARK_COLORS.primaryForeground).toBe('#171717'); expect(DARK_COLORS.popover).toBe('#1c1c1c'); expect(DARK_COLORS.border).toBe('rgba(128, 128, 128, 0.3)'); - expect(DARK_COLORS.mutedForeground).toBe('#a3a3a3'); + expect(DARK_COLORS.mutedForeground).toBe('#adadad'); expect(DARK_COLORS.muted).toBe('#262626'); }); }); diff --git a/src/theme/__tests__/theme-contrast.test.ts b/src/theme/__tests__/theme-contrast.test.ts new file mode 100644 index 00000000..f56bc0c8 --- /dev/null +++ b/src/theme/__tests__/theme-contrast.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { DARK_COLORS, LIGHT_COLORS, type ThemePalette } from '../tokens'; +import { BUILTIN_THEMES } from '../builtin-themes'; + +// Text contrast of every built-in theme, light and dark (WCAG 1.4.3, webmail +// 9f6c7b99c and lib/__tests__/theme-contrast.test.ts). Grey text used to sit +// at 2.6:1 on white (textMuted #94a3b8) and 3.2:1 on dark surfaces (#737373 +// on #262626); it carries dates, previews, hints and placeholders all over +// the app. + +type Rgb = [number, number, number]; + +function parse(color: string): { rgb: Rgb; a: number } { + const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color); + if (hex) { + const v = hex[1].length === 3 ? hex[1].split('').map((ch) => ch + ch).join('') : hex[1]; + return { rgb: [0, 2, 4].map((i) => parseInt(v.slice(i, i + 2), 16)) as Rgb, a: 1 }; + } + const rgba = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+)\s*)?\)$/i.exec(color); + if (!rgba) throw new Error(`unparsed colour ${color}`); + return { rgb: [Number(rgba[1]), Number(rgba[2]), Number(rgba[3])], a: rgba[4] === undefined ? 1 : Number(rgba[4]) }; +} + +/** Opaque rgb of `color`, laid over `under` when it has alpha. */ +function solid(color: string, under: Rgb): Rgb { + const c = parse(color); + return c.rgb.map((v, i) => v * c.a + under[i] * (1 - c.a)) as Rgb; +} + +function luminance([r, g, b]: Rgb): number { + const [lr, lg, lb] = [r, g, b].map((v) => { + const c = v / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb; +} + +function ratio(fg: string, ground: string, page: string): number { + const bg = solid(ground, solid(page, [255, 255, 255])); + const l1 = luminance(solid(fg, bg)); + const l2 = luminance(bg); + return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05); +} + +const palettes: Array<[string, ThemePalette]> = [ + ['default light', LIGHT_COLORS], + ['default dark', DARK_COLORS], + ...BUILTIN_THEMES.flatMap((theme): Array<[string, ThemePalette]> => [ + [`${theme.name} light`, { ...LIGHT_COLORS, ...theme.light } as ThemePalette], + [`${theme.name} dark`, { ...DARK_COLORS, ...theme.dark } as ThemePalette], + ]), +]; + +const TEXT = ['text', 'textSecondary', 'textMuted', 'mutedForeground'] as const; +// The grounds text is drawn on: the page, cards, sheets and popovers, and +// the grey fills of rows, chips and inputs. +const GROUNDS = ['background', 'surface', 'card', 'popover', 'secondary', 'muted'] as const; + +describe.each(palettes)('%s', (_name, palette) => { + it.each(TEXT)('%s reaches 4.5:1 on every ground', (fg) => { + for (const ground of GROUNDS) { + expect(ratio(palette[fg], palette[ground], palette.background), `${fg} ${palette[fg]} on ${ground} ${palette[ground]}`) + .toBeGreaterThanOrEqual(4.5); + } + }); +}); diff --git a/src/theme/builtin-themes.ts b/src/theme/builtin-themes.ts index c6fdbbcb..6b72bb05 100644 --- a/src/theme/builtin-themes.ts +++ b/src/theme/builtin-themes.ts @@ -2,6 +2,9 @@ // scripts in the parity work (2026-08-29). Each theme overrides the subset of // ThemePalette that the CSS defines; everything else keeps the base palette. // Regenerate rather than hand-edit when the webmail themes change. +// The text tokens (foreground, card/popover foreground, muted-foreground) +// were taken again after the webmail raised them to 4.5:1 (9f6c7b99c); +// src/theme/__tests__/theme-contrast.test.ts checks them. // // Mapping (webmail --color-* -> ThemePalette): primary -> primary, // primaryDark, textLink, navActive; ring -> primaryLight, borderFocus; @@ -47,8 +50,8 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [ card: '#ffffff', cardForeground: '#09090b', text: '#09090b', - textSecondary: '#71717a', - textMuted: '#71717a', + textSecondary: '#66666e', + textMuted: '#66666e', textInverse: '#ffffff', textLink: '#3b82f6', border: '#e4e4e7', @@ -63,14 +66,14 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [ info: '#3b82f6', infoForeground: '#ffffff', unread: '#3b82f6', - read: '#71717a', + read: '#66666e', navActive: '#3b82f6', - navInactive: '#71717a', + navInactive: '#66666e', navBadge: '#ef4444', secondary: '#f4f4f5', secondaryForeground: '#18181b', muted: '#f4f4f5', - mutedForeground: '#71717a', + mutedForeground: '#66666e', accent: '#dbeafe', accentForeground: '#1d4ed8', selection: '#dbeafe', @@ -257,8 +260,8 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [ card: '#eff1f5', cardForeground: '#4c4f69', text: '#4c4f69', - textSecondary: '#6c6f85', - textMuted: '#6c6f85', + textSecondary: '#5c5f77', + textMuted: '#5c5f77', textInverse: '#eff1f5', textLink: '#8839ef', border: '#ccd0da', @@ -273,14 +276,14 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [ info: '#1e66f5', infoForeground: '#eff1f5', unread: '#8839ef', - read: '#6c6f85', + read: '#5c5f77', navActive: '#8839ef', - navInactive: '#6c6f85', + navInactive: '#5c5f77', navBadge: '#d20f39', secondary: '#e6e9ef', secondaryForeground: '#4c4f69', muted: '#dce0e8', - mutedForeground: '#6c6f85', + mutedForeground: '#5c5f77', accent: '#8839ef', accentForeground: '#eff1f5', selection: '#dce0e8', @@ -307,8 +310,8 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [ card: '#313244', cardForeground: '#cdd6f4', text: '#cdd6f4', - textSecondary: '#a6adc8', - textMuted: '#a6adc8', + textSecondary: '#b3b9d0', + textMuted: '#b3b9d0', textInverse: '#1e1e2e', textLink: '#cba6f7', border: '#45475a', @@ -323,14 +326,14 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [ info: '#89b4fa', infoForeground: '#1e1e2e', unread: '#cba6f7', - read: '#a6adc8', + read: '#b3b9d0', navActive: '#cba6f7', - navInactive: '#a6adc8', + navInactive: '#b3b9d0', navBadge: '#f38ba8', secondary: '#313244', secondaryForeground: '#cdd6f4', muted: '#313244', - mutedForeground: '#a6adc8', + mutedForeground: '#b3b9d0', accent: '#45475a', accentForeground: '#cba6f7', selection: 'rgba(203, 166, 247, 0.2)', @@ -360,10 +363,10 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [ surfaceHover: '#268bd2', surfaceActive: '#eee8d5', card: '#fdf6e3', - cardForeground: '#657b83', - text: '#657b83', - textSecondary: '#93a1a1', - textMuted: '#93a1a1', + cardForeground: '#073642', + text: '#073642', + textSecondary: '#4f6369', + textMuted: '#4f6369', textInverse: '#fdf6e3', textLink: '#268bd2', border: '#eee8d5', @@ -378,20 +381,20 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [ info: '#268bd2', infoForeground: '#fdf6e3', unread: '#268bd2', - read: '#93a1a1', + read: '#4f6369', navActive: '#268bd2', - navInactive: '#93a1a1', + navInactive: '#4f6369', navBadge: '#dc322f', secondary: '#eee8d5', secondaryForeground: '#586e75', muted: '#eee8d5', - mutedForeground: '#93a1a1', + mutedForeground: '#4f6369', accent: '#268bd2', accentForeground: '#fdf6e3', selection: '#eee8d5', selectionForeground: '#268bd2', popover: '#fdf6e3', - popoverForeground: '#657b83', + popoverForeground: '#073642', chart1: '#268bd2', chart2: '#859900', chart3: '#b58900', @@ -410,10 +413,10 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [ surfaceHover: '#073642', surfaceActive: '#073642', card: '#073642', - cardForeground: '#93a1a1', - text: '#839496', - textSecondary: '#586e75', - textMuted: '#586e75', + cardForeground: '#aab7b7', + text: '#aab7b7', + textSecondary: '#9aa7a7', + textMuted: '#9aa7a7', textInverse: '#002b36', textLink: '#268bd2', border: '#073642', @@ -428,20 +431,20 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [ info: '#268bd2', infoForeground: '#002b36', unread: '#268bd2', - read: '#586e75', + read: '#9aa7a7', navActive: '#268bd2', - navInactive: '#586e75', + navInactive: '#9aa7a7', navBadge: '#dc322f', secondary: '#073642', secondaryForeground: '#93a1a1', muted: '#073642', - mutedForeground: '#586e75', + mutedForeground: '#9aa7a7', accent: '#073642', accentForeground: '#268bd2', selection: 'rgba(38, 139, 210, 0.2)', selectionForeground: '#268bd2', popover: '#073642', - popoverForeground: '#93a1a1', + popoverForeground: '#aab7b7', chart1: '#268bd2', chart2: '#859900', chart3: '#b58900', @@ -467,8 +470,8 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [ card: '#ffffff', cardForeground: '#27353a', text: '#27353a', - textSecondary: '#737677', - textMuted: '#737677', + textSecondary: '#646768', + textMuted: '#646768', textInverse: '#ffffff', textLink: '#37beff', border: '#dddddd', @@ -483,14 +486,14 @@ export const BUILTIN_THEMES: BuiltinTheme[] = [ info: '#37beff', infoForeground: '#ffffff', unread: '#ffd452', - read: '#737677', + read: '#646768', navActive: '#37beff', - navInactive: '#737677', + navInactive: '#646768', navBadge: '#ff5552', secondary: '#f4f4f4', secondaryForeground: '#27353a', muted: '#f4f4f4', - mutedForeground: '#737677', + mutedForeground: '#646768', accent: '#e6f6ff', accentForeground: '#0070a8', selection: '#ebf8ff', diff --git a/src/theme/tokens.ts b/src/theme/tokens.ts index 5b146b88..2ae71a10 100644 --- a/src/theme/tokens.ts +++ b/src/theme/tokens.ts @@ -136,8 +136,10 @@ export const DARK_COLORS: ThemePalette = { cardForeground: '#fafafa', text: '#fafafa', - textSecondary: '#a3a3a3', - textMuted: '#737373', + // The webmail's dark muted-foreground (9f6c7b99c): 4.5:1 or more on the + // background and on the #262626 surfaces. #737373 was 3.2:1 on those. + textSecondary: '#adadad', + textMuted: '#adadad', textInverse: '#0a0a0a', // Links stay blue (webmail: info) so they read as links on a white-primary // theme. @@ -161,7 +163,7 @@ export const DARK_COLORS: ThemePalette = { infoForeground: '#ffffff', unread: '#60a5fa', - read: '#a3a3a3', + read: '#adadad', starred: '#fbbf24', flagged: '#ef4444', draft: '#a78bfa', @@ -179,13 +181,13 @@ export const DARK_COLORS: ThemePalette = { tags: TAGS, navActive: '#fafafa', - navInactive: '#a3a3a3', + navInactive: '#adadad', navBadge: '#ef4444', secondary: '#262626', secondaryForeground: '#fafafa', muted: '#262626', - mutedForeground: '#a3a3a3', + mutedForeground: '#adadad', accent: '#1e3a8a', accentForeground: '#dbeafe', @@ -232,8 +234,10 @@ export const LIGHT_COLORS: ThemePalette = { cardForeground: '#0f172a', text: '#0f172a', - textSecondary: '#64748b', - textMuted: '#94a3b8', + // The webmail's light muted-foreground (9f6c7b99c): 4.5:1 or more on white + // and on the slate fills. #94a3b8 was 2.6:1 on white. + textSecondary: '#59687c', + textMuted: '#59687c', textInverse: '#ffffff', textLink: '#1d4ed8', @@ -255,7 +259,7 @@ export const LIGHT_COLORS: ThemePalette = { infoForeground: '#ffffff', unread: '#3b82f6', - read: '#64748b', + read: '#59687c', starred: '#eab308', flagged: '#ef4444', draft: '#a855f7', @@ -273,13 +277,13 @@ export const LIGHT_COLORS: ThemePalette = { tags: LIGHT_TAGS, navActive: '#3b82f6', - navInactive: '#64748b', + navInactive: '#59687c', navBadge: '#ef4444', secondary: '#f8fafc', secondaryForeground: '#0f172a', muted: '#f1f5f9', - mutedForeground: '#64748b', + mutedForeground: '#59687c', accent: '#dbeafe', accentForeground: '#1e40af', From ff5a8c91d64f0bb75be9b097c9c6af3c25bf597f Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 03:45:35 +0100 Subject: [PATCH 03/26] fix: give the text fields in settings and sign-in a name for screen readers --- src/components/Input.tsx | 2 + src/components/__tests__/field-names.test.ts | 74 +++++++++++++++++++ src/components/input-a11y.ts | 26 +++++++ .../settings/AccountSecuritySettings.tsx | 26 +++---- src/components/settings/ComposingSettings.tsx | 2 +- src/components/settings/ContactsSettings.tsx | 3 +- .../settings/ContentSendersSettings.tsx | 2 +- src/components/settings/DownloadsSettings.tsx | 2 + src/components/settings/FolderSettings.tsx | 2 +- src/components/settings/IdentitySettings.tsx | 12 +-- src/components/settings/KeywordSettings.tsx | 2 +- src/components/settings/ReadingSettings.tsx | 2 +- .../settings/SidebarAppsSettings.tsx | 4 +- src/components/settings/TemplateSettings.tsx | 9 ++- src/screens/login/EmailStep.tsx | 1 + 15 files changed, 138 insertions(+), 31 deletions(-) create mode 100644 src/components/__tests__/field-names.test.ts create mode 100644 src/components/input-a11y.ts diff --git a/src/components/Input.tsx b/src/components/Input.tsx index ce3e8b93..ddcf9583 100644 --- a/src/components/Input.tsx +++ b/src/components/Input.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { TextInput as RNTextInput, View, Text, StyleSheet, ViewStyle, TextInputProps } from 'react-native'; import { spacing, radius, typography, componentSizes, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; +import { inputAccessibility } from './input-a11y'; interface InputProps extends TextInputProps { label?: string; @@ -48,6 +49,7 @@ export default function Input({ { setFocused(true); props.onFocus?.(e); }} diff --git a/src/components/__tests__/field-names.test.ts b/src/components/__tests__/field-names.test.ts new file mode 100644 index 00000000..81e4b6bd --- /dev/null +++ b/src/components/__tests__/field-names.test.ts @@ -0,0 +1,74 @@ +// Text fields need a name a screen reader can say (webmail ef13556a0, +// dcc21acd0, 4e2affe40, f73643bbb). The password, code and new-password +// fields under Security had none: no label prop, no placeholder, no +// accessibilityLabel. Settings and the sign-in steps are scanned so that a +// bare field doesn't creep back in; Input itself names its field from its +// label, then its placeholder. +import { describe, expect, it } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { inputAccessibility } from '../input-a11y'; + +const SRC = join(__dirname, '..', '..'); + +function elementEnd(source: string, from: number): number { + let depth = 0; + for (let i = from; i < source.length; i++) { + const ch = source[i]; + if (ch === '{') depth += 1; + else if (ch === '}') depth -= 1; + else if (ch === '/' && depth === 0 && source[i + 1] === '>') return i; + } + return source.length; +} + +/** `file:line` of every TextInput or Input element without a name. */ +function unnamedFields(file: string): string[] { + const source = readFileSync(join(SRC, file), 'utf8'); + const out: string[] = []; + for (const m of source.matchAll(/<(TextInput|Input)\b/g)) { + const element = source.slice(m.index!, elementEnd(source, m.index! + m[0].length)); + if (/\baccessibilityLabel=/.test(element)) continue; + if (m[1] === 'Input' && /\blabel=/.test(element)) continue; + out.push(`${file}:${source.slice(0, m.index!).split('\n').length}`); + } + return out; +} + +const SETTINGS = readdirSync(join(SRC, 'components', 'settings')) + .filter((name) => name.endsWith('.tsx')) + // The calendar pass owns the auto-reply editor. + .filter((name) => name !== 'VacationSettings.tsx') + .map((name) => `components/settings/${name}`); + +const FILES = [ + ...SETTINGS, + 'screens/SettingsScreen.tsx', + 'screens/login/EmailStep.tsx', + 'screens/login/PasswordStep.tsx', + 'screens/login/ServerStep.tsx', +]; + +describe('field names', () => { + it('every field in Settings and the sign-in steps has a name', () => { + expect(FILES.flatMap(unnamedFields)).toEqual([]); + }); +}); + +describe('inputAccessibility', () => { + it('names the field after its label', () => { + expect(inputAccessibility({ label: 'Folder name', placeholder: 'Receipts' })).toEqual({ accessibilityLabel: 'Folder name' }); + }); + + it('falls back to the placeholder, and lets an explicit name win', () => { + expect(inputAccessibility({ placeholder: 'Search' })).toEqual({ accessibilityLabel: 'Search' }); + expect(inputAccessibility({ label: 'Name', accessibilityLabel: 'Display name' })).toEqual({ accessibilityLabel: 'Display name' }); + }); + + it('reads the error as the hint', () => { + expect(inputAccessibility({ label: 'Email', error: 'Not an address' })).toEqual({ + accessibilityLabel: 'Email', + accessibilityHint: 'Not an address', + }); + }); +}); diff --git a/src/components/input-a11y.ts b/src/components/input-a11y.ts new file mode 100644 index 00000000..22d5dd13 --- /dev/null +++ b/src/components/input-a11y.ts @@ -0,0 +1,26 @@ +// The name a screen reader gives a text field (webmail components/ui/field.tsx, +// ef13556a0, dcc21acd0, 4e2affe40). Input drew its label as a Text of its +// own, not tied to the field, so TalkBack and VoiceOver announced an +// unnamed "edit box"; a placeholder alone stops being read once the field +// has a value. The label names the field, then the placeholder; an error +// is read as the hint so it is heard on focus. + +export interface InputAccessibilityProps { + label?: string; + error?: string; + placeholder?: string; + accessibilityLabel?: string; + accessibilityHint?: string; +} + +export function inputAccessibility(props: InputAccessibilityProps): { + accessibilityLabel?: string; + accessibilityHint?: string; +} { + const name = props.accessibilityLabel || props.label || props.placeholder || undefined; + const hint = props.error || props.accessibilityHint || undefined; + return { + ...(name ? { accessibilityLabel: name } : {}), + ...(hint ? { accessibilityHint: hint } : {}), + }; +} diff --git a/src/components/settings/AccountSecuritySettings.tsx b/src/components/settings/AccountSecuritySettings.tsx index dc964670..88ee9544 100644 --- a/src/components/settings/AccountSecuritySettings.tsx +++ b/src/components/settings/AccountSecuritySettings.tsx @@ -178,7 +178,7 @@ function PasswordChangeSection({ otpEnabled }: { otpEnabled: boolean }) { {t('settings.security.password.current', "Current password")} - + setShowCurrent((v) => !v)} accessibilityRole="button" accessibilityLabel={t('settings.security.password.toggle_visibility', "Show or hide password")}> {showCurrent ? : } @@ -188,14 +188,14 @@ function PasswordChangeSection({ otpEnabled }: { otpEnabled: boolean }) { {otpEnabled && ( {t('settings.security.two_factor.code', "Verification code")} - + )} {t('settings.security.password.new', "New password")} - + setShowNew((v) => !v)} accessibilityRole="button" accessibilityLabel={t('settings.security.password.toggle_visibility', "Show or hide password")}> {showNew ? : } @@ -204,7 +204,7 @@ function PasswordChangeSection({ otpEnabled }: { otpEnabled: boolean }) { {t('settings.security.password.confirm', "Confirm password")} - + {error && {error}} @@ -252,7 +252,7 @@ function DisplayNameSection({ initial, onSaved }: { initial: string; onSaved: (n return ( - + @@ -356,11 +356,11 @@ function TotpSection({ enabled, onChanged }: { enabled: boolean; onChanged: () = {t('settings.security.password.current', "Current password")} - + {t('settings.security.two_factor.code', "Verification code")} - + {error ? {error} : null} @@ -375,8 +375,8 @@ function TotpSection({ enabled, onChanged }: { enabled: boolean; onChanged: () = {disableOpen && ( {t('settings.security.two_factor.disable_hint_code', "Enter your password and a code from your authenticator app to turn off two-factor authentication.")} - - + + {error ? {error} : null} + + ))} + + + + + update('toastDuration', Number(v))} + options={TOAST_DURATION_CHOICES.map((value) => ({ value: String(value), label: durationLabel(value) }))} + /> + + + update('sendConfirmation', v)} + accessibilityLabel={t('settings.accessibility.confirm_send.label', 'Confirm before sending')} + /> + + + update('confirmDelete', v)} + accessibilityLabel={t('settings.accessibility.confirm_delete.label', 'Confirm before deleting')} + /> + + + update('confirmArchive', v)} + accessibilityLabel={t('settings.accessibility.confirm_archive.label', 'Confirm before archiving')} + /> + + + + + ); +} diff --git a/src/components/settings/AppearanceSettings.tsx b/src/components/settings/AppearanceSettings.tsx index 64506bd2..afb958c7 100644 --- a/src/components/settings/AppearanceSettings.tsx +++ b/src/components/settings/AppearanceSettings.tsx @@ -3,8 +3,9 @@ import { View, Text, StyleSheet } from 'react-native'; import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; -import { useSettingsStore, type ThemeMode, type FontSize, type Density } from '../../stores/settings-store'; +import { useSettingsStore, type ThemeMode, type Density } from '../../stores/settings-store'; import { useLocaleStore } from '../../stores/locale-store'; +import { setPendingSettingsTab } from '../../navigation/pending-settings-tab'; type Theme = ThemeMode; @@ -85,14 +86,10 @@ export function AppearanceSettings() { const theme = useSettingsStore((s) => s.theme); const t = useLocaleStore((s) => s.t); const setTheme = useSettingsStore((s) => s.setTheme); - const fontSize = useSettingsStore((s) => s.fontSize); - const setFontSize = useSettingsStore((s) => s.setFontSize); const density = useSettingsStore((s) => s.density); const setDensity = useSettingsStore((s) => s.setDensity); const showToolbarLabels = useSettingsStore((s) => s.showToolbarLabels); const setShowToolbarLabels = useSettingsStore((s) => s.setShowToolbarLabels); - const animationsEnabled = useSettingsStore((s) => s.animationsEnabled); - const setAnimationsEnabled = useSettingsStore((s) => s.setAnimationsEnabled); const senderFavicons = useSettingsStore((s) => s.senderFavicons); const setSenderFavicons = useSettingsStore((s) => s.setSenderFavicons); const hydrated = useSettingsStore((s) => s.hydrated); @@ -119,17 +116,13 @@ export function AppearanceSettings() { /> - - setFontSize(v as FontSize)} - options={[ - { value: 'small', label: t('settings.appearance.font_size.small', "Small") }, - { value: 'medium', label: t('settings.appearance.font_size.medium', "Medium") }, - { value: 'large', label: t('settings.appearance.font_size.large', "Large") }, - ]} - /> - + {/* Text size and motion moved to Settings > Accessibility. */} + setPendingSettingsTab('accessibility')} + link="page" + /> @@ -151,10 +144,6 @@ export function AppearanceSettings() { - - - - Accessibility (webmail 1.19, 8008393e5, 0731d24f6): presets that +// set several options at once and can be undone, and text size from 87.5% +// to 200% (the app had 92/100/112%). + +function store(initial: Partial) { + const state: Partial = { ...initial }; + const update = (key: K, value: PersistedSettings[K]) => { + state[key] = value; + }; + return { state, update }; +} + +describe('accessibility presets', () => { + it('changes only what differs, and Undo puts it back', () => { + const { state, update } = store({ fontSize: 'medium', showToolbarLabels: true, animationsEnabled: true }); + const changes = presetChanges('low-vision', state); + expect(changes).toEqual([{ key: 'fontSize', from: 'medium', to: 'xx-large' }]); + + applyPresetChanges(changes, update); + expect(state.fontSize).toBe('xx-large'); + undoPresetChanges(changes, state, update); + expect(state.fontSize).toBe('medium'); + }); + + it('keeps a value the user changed again after applying', () => { + const { state, update } = store({ animationsEnabled: true }); + const changes = presetChanges('motion', state); + applyPresetChanges(changes, update); + expect(state.animationsEnabled).toBe(false); + state.animationsEnabled = true; + state.fontSize = 'huge'; + undoPresetChanges(changes, state, update); + expect(state.animationsEnabled).toBe(true); + }); + + it('turns off conversation grouping for screen readers', () => { + expect(presetChanges('screen-reader', { disableThreading: false })).toEqual([ + { key: 'disableThreading', from: false, to: true }, + ]); + expect(presetChanges('screen-reader', { disableThreading: true })).toEqual([]); + }); +}); + +describe('text size', () => { + it('runs from 87.5% to 200%', () => { + expect(FONT_SCALE.small).toBe(0.875); + expect(FONT_SCALE.huge).toBe(2); + expect(Object.keys(FONT_SCALE)).toHaveLength(7); + }); + + it('keeps the larger sizes and the notification duration when the settings are read back', () => { + const merged = mergeWithDefaults({ fontSize: 'huge', toastDuration: 20 } as Partial); + expect(merged.fontSize).toBe('huge'); + expect(merged.toastDuration).toBe(20); + expect(mergeWithDefaults({ toastDuration: 7 } as Partial).toastDuration).toBe(-1); + }); +}); diff --git a/src/lib/__tests__/settings-search.test.ts b/src/lib/__tests__/settings-search.test.ts index 1b31344f..5623cac1 100644 --- a/src/lib/__tests__/settings-search.test.ts +++ b/src/lib/__tests__/settings-search.test.ts @@ -80,6 +80,7 @@ describe('settings search index (English catalog)', () => { account: 'AccountSettings.tsx', language: 'LanguageSettings.tsx', notifications: 'NotificationSettings.tsx', + accessibility: 'AccessibilitySettings.tsx', appearance: 'AppearanceSettings.tsx', layout: 'LayoutSettings.tsx', reading: 'ReadingSettings.tsx', @@ -113,7 +114,10 @@ describe('settings search index (English catalog)', () => { } it('lists settings where the native app shows them', () => { - expect(labelsFor('appearance')).toContain('Font Size'); + // Text size moved to Accessibility (webmail 1.19), with the presets. + expect(labelsFor('accessibility')).toContain('Text size'); + expect(labelsFor('accessibility')).toContain('Low vision'); + expect(labelsFor('appearance')).not.toContain('Font Size'); expect(labelsFor('layout')).toContain('Message list order'); expect(labelsFor('notifications')).toContain('Parse email invitations'); expect(labelsFor('calendar')).toContain('Show week numbers'); @@ -142,9 +146,9 @@ describe('settings search index (English catalog)', () => { describe('buildSettingsSearchIndex', () => { it('translates labels through t() for the current locale', () => { const index = buildSettingsSearchIndex(en, (key) => translate('de', key)); - const fontSize = translate('de', 'settings.appearance.font_size.label'); - expect(index.subResults.appearance?.map((r) => r.label)).toContain(fontSize); - expect(index.haystacks.appearance).toContain(fontSize.toLowerCase()); + const textSize = translate('de', 'settings.accessibility.text_size.label'); + expect(index.subResults.accessibility?.map((r) => r.label)).toContain(textSize); + expect(index.haystacks.accessibility).toContain(textSize.toLowerCase()); }); it('adds extra sub-results to the pane and its haystack', () => { @@ -178,11 +182,11 @@ describe('matching', () => { }); it('returns the matching settings of a pane, by label or description', () => { - expect(subResultsForQuery(index, 'appearance', 'font')).toEqual([ - { label: 'Font Size', description: 'Adjust text size for better readability' }, + expect(subResultsForQuery(index, 'accessibility', 'text size')).toEqual([ + { label: 'Text size', description: "Scales the app's text on top of your phone's text size. 100% is your phone's size." }, ]); - expect(subResultsForQuery(index, 'appearance', 'readability').map((r) => r.label)).toEqual(['Font Size']); - expect(subResultsForQuery(index, 'appearance', '')).toEqual([]); + expect(subResultsForQuery(index, 'accessibility', 'archived').map((r) => r.label)).toEqual(['Confirm before archiving']); + expect(subResultsForQuery(index, 'accessibility', '')).toEqual([]); }); it('caps the settings listed under one pane', () => { diff --git a/src/lib/__tests__/toast-duration.test.ts b/src/lib/__tests__/toast-duration.test.ts new file mode 100644 index 00000000..ba551616 --- /dev/null +++ b/src/lib/__tests__/toast-duration.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useSettingsStore } from '../../stores/settings-store'; +import { toast, useToastStore } from '../../stores/toast-store'; +import { setScreenReaderOnForTests } from '../screen-reader'; +import { effectiveToastDuration, lastingToastDuration, userToastSeconds } from '../toast-duration'; + +// Toasts and the Undo bar left after a fixed 5 s, errors after 10 s, which +// is short for someone reaching the Undo button with a screen reader +// (webmail lib/toast-duration.ts, WCAG 2.2.1). + +beforeEach(() => { + setScreenReaderOnForTests(false); + useSettingsStore.setState({ toastDuration: -1 }); + useToastStore.getState().clearToasts(); +}); + +describe('notification duration', () => { + it('is 5 s by default and 20 s while a screen reader is on', () => { + expect(userToastSeconds()).toBe(5); + setScreenReaderOnForTests(true); + expect(userToastSeconds()).toBe(20); + expect(lastingToastDuration(5000)).toBe(20_000); + }); + + it('follows the setting, never going below the time the caller picked', () => { + useSettingsStore.setState({ toastDuration: 60 }); + expect(lastingToastDuration(5000)).toBe(60_000); + useSettingsStore.setState({ toastDuration: 5 }); + expect(lastingToastDuration(10_000)).toBe(10_000); + }); + + it('keeps toasts until they are closed when set to 0', () => { + useSettingsStore.setState({ toastDuration: 0 }); + expect(lastingToastDuration(5000)).toBe(0); + }); + + it('lengthens toasts with an action and errors, not plain notices', () => { + useSettingsStore.setState({ toastDuration: 20 }); + expect(effectiveToastDuration({ type: 'success', action: { label: 'Undo' } })).toBe(20_000); + expect(effectiveToastDuration({ type: 'error' })).toBe(20_000); + expect(effectiveToastDuration({ type: 'success' })).toBe(5000); + }); + + it('is what the toast store gives a new toast', () => { + setScreenReaderOnForTests(true); + toast.success('Archived', { action: { label: 'Undo', onPress: () => undefined } }); + toast.error('Could not move'); + toast.info('Copied'); + expect(useToastStore.getState().toasts.map((t) => t.duration)).toEqual([20_000, 20_000, 5000]); + }); +}); diff --git a/src/lib/accessibility-presets.ts b/src/lib/accessibility-presets.ts new file mode 100644 index 00000000..ca63da8a --- /dev/null +++ b/src/lib/accessibility-presets.ts @@ -0,0 +1,66 @@ +// Presets on Settings > Accessibility (webmail lib/accessibility-presets.ts, +// 8008393e5, 0731d24f6). A preset is a list of values applied once: +// afterwards they are ordinary settings the user can change, and presets can +// be combined. Nothing remembers which preset was used. Applying one can be +// undone, which puts back exactly the values it changed. +// +// The phone takes the presets whose settings it has: screen reader (no +// conversation grouping), low vision (150% text, labels under the toolbar +// icons) and motion (no animations). The webmail's others need settings the +// app doesn't have (reading fonts, spacing, focus outlines, hover, sounds). + +import type { PersistedSettings } from '../stores/settings-store'; + +export type AccessibilityPresetId = 'screen-reader' | 'low-vision' | 'motion'; + +export const ACCESSIBILITY_PRESET_IDS: readonly AccessibilityPresetId[] = ['screen-reader', 'low-vision', 'motion']; + +type PresetValues = Partial; + +export const ACCESSIBILITY_PRESETS: Record = { + // The webmail's: no conversation grouping (its reading-pane and announce + // options have no phone counterpart). + 'screen-reader': { + disableThreading: true, + }, + 'low-vision': { + fontSize: 'xx-large', + showToolbarLabels: true, + }, + motion: { + animationsEnabled: false, + }, +}; + +export interface PresetChange { + key: keyof PersistedSettings; + from: unknown; + to: unknown; +} + +/** The changes a preset would make to `state`: only values that differ. */ +export function presetChanges(id: AccessibilityPresetId, state: Partial): PresetChange[] { + const changes: PresetChange[] = []; + for (const [key, to] of Object.entries(ACCESSIBILITY_PRESETS[id]) as Array<[keyof PersistedSettings, unknown]>) { + if (!(key in state) || state[key] === to) continue; + changes.push({ key, from: state[key], to }); + } + return changes; +} + +type Update = (key: K, value: PersistedSettings[K]) => void; + +/** Applies a preset's changes; returns them so they can be undone. */ +export function applyPresetChanges(changes: PresetChange[], update: Update): void { + for (const change of changes) update(change.key, change.to as never); +} + +/** + * Puts back what a preset changed. A setting the user changed again since + * keeps the newer value. + */ +export function undoPresetChanges(changes: PresetChange[], state: Partial, update: Update): void { + for (const change of changes) { + if (state[change.key] === change.to) update(change.key, change.from as never); + } +} diff --git a/src/lib/screen-reader.ts b/src/lib/screen-reader.ts new file mode 100644 index 00000000..f95a59b3 --- /dev/null +++ b/src/lib/screen-reader.ts @@ -0,0 +1,31 @@ +// Whether TalkBack or VoiceOver is on, readable without a hook (the toast +// store decides a toast's duration when it is queued). Starts watching on +// first use; until the first answer arrives it reads as off. + +import { AccessibilityInfo } from 'react-native'; + +let screenReaderOn = false; +let watching = false; + +function watch(): void { + if (watching) return; + watching = true; + const info = AccessibilityInfo as typeof AccessibilityInfo | undefined; + try { + void info?.isScreenReaderEnabled?.().then((on) => { screenReaderOn = on; }).catch(() => undefined); + info?.addEventListener?.('screenReaderChanged', (on: boolean) => { screenReaderOn = on; }); + } catch { + // No accessibility bridge (tests, a headless task): stays off. + } +} + +export function isScreenReaderOn(): boolean { + watch(); + return screenReaderOn; +} + +/** Test hook. */ +export function setScreenReaderOnForTests(on: boolean): void { + watching = true; + screenReaderOn = on; +} diff --git a/src/lib/settings-search.ts b/src/lib/settings-search.ts index 47901d50..a8927529 100644 --- a/src/lib/settings-search.ts +++ b/src/lib/settings-search.ts @@ -14,7 +14,7 @@ import { supportsDeviceSync } from './platform-capabilities'; export type SettingsTabId = - | 'account' | 'language' | 'notifications' + | 'account' | 'language' | 'notifications' | 'accessibility' | 'appearance' | 'layout' | 'reading' | 'composing' | 'identities' | 'vacation' | 'filters' | 'templates' | 'folders' | 'keywords' | 'downloads' @@ -57,12 +57,22 @@ export const SETTINGS_SEARCH_PATHS: Record = { 'settings.notifications.calendar.enabled', 'settings.notifications.calendar.invitation_parsing', ], + accessibility: [ + 'settings.accessibility.presets.screen-reader', + 'settings.accessibility.presets.low-vision', + 'settings.accessibility.presets.motion', + 'settings.accessibility.text_size', + 'settings.accessibility.reduce_motion', + 'settings.accessibility.toast_duration', + 'settings.accessibility.confirm_send', + 'settings.accessibility.confirm_delete', + 'settings.accessibility.confirm_archive', + ], appearance: [ 'settings.appearance.theme', - 'settings.appearance.font_size', + 'settings.appearance.accessibility_link', 'settings.appearance.list_density', 'settings.appearance.toolbar_labels', - 'settings.appearance.animations', // The leaf: the catalog's subtree also has the webmail's refresh button. 'settings.advanced.sender_favicons.label', ], @@ -248,6 +258,7 @@ export const SETTINGS_SEARCH_KEYWORDS: Record = { account: 'profile email password user signin signout reorder switch default multi-account shared storage quota', language: 'locale region date time format translation', notifications: 'alert push badge unifiedpush relay device reminder', + accessibility: 'a11y screen reader talkback voiceover low vision text size font bigger larger motion animation preset undo timeout duration confirm', appearance: 'theme dark light font size text color animation density', layout: 'swipe gesture quick actions toolbar order sort unified folder count avatar', reading: 'mark read preview thread conversation archive delete attachment open monospace mono font plain text receipt', diff --git a/src/lib/settings-summary.ts b/src/lib/settings-summary.ts index 49d88687..cd7fe42c 100644 --- a/src/lib/settings-summary.ts +++ b/src/lib/settings-summary.ts @@ -33,6 +33,7 @@ const WEEKDAY: Record<0 | 1 | 6, [string, string]> = { }; const STATIC: Partial> = { + accessibility: ['settings.summary.accessibility', 'Text size, motion, confirmations'], layout: ['settings.summary.layout', 'Quick actions, swipes, list order'], reading: ['settings.summary.reading', 'Read status, delete, attachments'], composing: ['settings.summary.composing', 'Signature, send delay, drafts'], diff --git a/src/lib/toast-duration.ts b/src/lib/toast-duration.ts new file mode 100644 index 00000000..ab5ec647 --- /dev/null +++ b/src/lib/toast-duration.ts @@ -0,0 +1,55 @@ +// How long toasts and the Undo bar stay (webmail lib/toast-duration.ts, +// 8d2ebccf0, WCAG 2.2.1). One setting, Settings > Accessibility > +// Notification duration, covers toasts with an action, errors and the Undo +// bar: 5 to 60 seconds, or until closed. Plain notices keep their own short +// time. The app's default is automatic: 5 s, or 20 s while TalkBack or +// VoiceOver is on, since reaching an Undo button by swiping takes longer +// than tapping it. + +import { useSettingsStore } from '../stores/settings-store'; +import { isScreenReaderOn } from './screen-reader'; + +/** How long a toast stays when nothing else says so. */ +export const DEFAULT_TOAST_MS = 5000; +/** Errors stay longer than other toasts. */ +export const DEFAULT_ERROR_TOAST_MS = 10000; +/** The automatic duration while a screen reader is on, in seconds. */ +export const SCREEN_READER_TOAST_SECONDS = 20; +/** The setting's values: -1 automatic, 0 until closed, else seconds. */ +export const TOAST_DURATION_CHOICES = [-1, 5, 10, 20, 60, 0] as const; + +/** The setting in seconds; 0 keeps toasts until they are closed. */ +export function userToastSeconds(): number { + let stored = -1; + try { + const value = useSettingsStore.getState().toastDuration; + if (typeof value === 'number') stored = value; + } catch { + // A store that isn't there yet: automatic. + } + if (stored >= 0) return stored; + return isScreenReaderOn() ? SCREEN_READER_TOAST_SECONDS : DEFAULT_TOAST_MS / 1000; +} + +/** + * How long something that must not be missed stays: a toast with an action, + * an error, the Undo bar. Never shorter than `baseMs`, the time the caller + * picked; 0 when it stays until it is closed. + */ +export function lastingToastDuration(baseMs: number): number { + const seconds = userToastSeconds(); + if (seconds === 0 || baseMs <= 0) return 0; + return Math.max(baseMs, seconds * 1000); +} + +/** The duration a new toast gets. A plain notice keeps its own time. */ +export function effectiveToastDuration(toast: { + type: string; + duration?: number; + action?: unknown; +}): number { + const base = toast.duration ?? (toast.type === 'error' ? DEFAULT_ERROR_TOAST_MS : DEFAULT_TOAST_MS); + if (base <= 0) return base; + const lasting = toast.type === 'error' || !!toast.action; + return lasting ? lastingToastDuration(base) : base; +} diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 488fe5d3..3232fae5 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -4,7 +4,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { useLayout } from '../lib/use-layout'; import { confirmSignOut } from '../lib/sign-out-confirm'; import { - ArrowLeft, LogOut, Search, X, ChevronRight, + Accessibility, ArrowLeft, LogOut, Search, X, ChevronRight, Palette, User, Shield, UserPen, Palmtree, Calendar, Filter, FileText, FolderOpen, Tags, HardDrive, BookUser, PanelLeftClose, Bell, RefreshCw, @@ -30,6 +30,7 @@ import Badge from '../components/Badge'; import SenderAvatar from '../components/SenderAvatar'; import { UpdatesSettings } from '../components/settings/UpdatesSettings'; import { AppearanceSettings } from '../components/settings/AppearanceSettings'; +import { AccessibilitySettings } from '../components/settings/AccessibilitySettings'; import { CalendarSettings } from '../components/settings/CalendarSettings'; import { ContactsSettings } from '../components/settings/ContactsSettings'; import { FilesSettings } from '../components/settings/FilesSettings'; @@ -99,6 +100,7 @@ const TABS: TabDef[] = [ { id: 'account', label: 'Account', icon: User, group: 'general', implemented: true }, { id: 'language', label: 'Language', icon: Languages, group: 'general', implemented: true }, { id: 'notifications', label: 'Notifications', icon: Bell, group: 'general', implemented: true }, + { id: 'accessibility', label: 'Accessibility', icon: Accessibility, group: 'general', implemented: true }, // Appearance { id: 'appearance', label: 'Appearance', icon: Palette, group: 'appearance', implemented: true }, @@ -150,6 +152,7 @@ const TAB_COMPONENTS: Partial>> = { account: AccountSettings, language: LanguageSettings, notifications: NotificationSettings, + accessibility: AccessibilitySettings, appearance: AppearanceSettings, layout: LayoutSettings, reading: ReadingSettings, diff --git a/src/stores/settings-store.ts b/src/stores/settings-store.ts index bb0ffb33..29ab8a10 100644 --- a/src/stores/settings-store.ts +++ b/src/stores/settings-store.ts @@ -8,7 +8,9 @@ import { EMPTY_NOTIFICATION_MUTES, normalizeNotificationMutes, type Notification export type ExternalContentPolicy = 'allow' | 'block' | 'ask'; export type ThemeMode = 'light' | 'dark' | 'system'; -export type FontSize = 'small' | 'medium' | 'large'; +// Text size, 87.5% to 200% (webmail lib/accessibility-settings.ts FONT_SIZES). +export type FontSize = 'small' | 'medium' | 'large' | 'x-large' | 'xx-large' | 'xxx-large' | 'huge'; +export const FONT_SIZES: readonly FontSize[] = ['small', 'medium', 'large', 'x-large', 'xx-large', 'xxx-large', 'huge']; export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable'; export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent'; export type MailAttachmentAction = 'preview' | 'download'; @@ -124,7 +126,7 @@ export interface SidebarApp { showOnMobile: boolean; } -interface PersistedSettings { +export interface PersistedSettings { // Privacy & content externalContentPolicy: ExternalContentPolicy; trustedSenders: string[]; @@ -309,6 +311,12 @@ interface PersistedSettings { // Hard cap on the on-disk body cache, in megabytes. When a sync pushes the // cache past this, the oldest messages are evicted to fit. offlineCacheMaxMB: number; + + // platform: Settings > Accessibility. How long toasts with an action + // (Undo) and errors stay, in seconds (webmail toastDuration, 5 to 60, 0 = + // until closed); -1 = automatic: 5 s, or 20 s while a screen reader is + // on (lib/toast-duration). + toastDuration: number; } const DEFAULT_PERSISTED: PersistedSettings = { @@ -463,6 +471,9 @@ const DEFAULT_PERSISTED: PersistedSettings = { offlineCacheEnabled: false, offlineCacheDays: 7, offlineCacheMaxMB: 50, + + // platform: + toastDuration: -1, }; export interface SettingsState extends PersistedSettings { @@ -576,7 +587,7 @@ const VALIDATORS: Partial boolea messageListOrderScope: oneOf(['inbox', 'all']), // Levels are sanitized by the consumer (lib/message-list-order sanitizeSortLevels). messageListOrder: (v) => Array.isArray(v), - fontSize: oneOf(['small', 'medium', 'large']), + fontSize: oneOf(FONT_SIZES), density: oneOf(['extra-compact', 'compact', 'regular', 'comfortable']), attachmentReminderKeywords: stringArray, // Same set the webmail accepts (stores/settings-store.ts importSettings). @@ -612,6 +623,8 @@ const VALIDATORS: Partial boolea exportSpaceReplacement: oneOf(['keep', 'underscore', 'dash']), offlineCacheDays: intBetween(1, 3650), offlineCacheMaxMB: intBetween(1, 100000), + // platform: + toastDuration: oneOf([-1, 0, 5, 10, 20, 60]), sidebarApps: (v) => Array.isArray(v) && v.every((a) => a && typeof a === 'object' && typeof (a as SidebarApp).id === 'string' diff --git a/src/stores/toast-store.ts b/src/stores/toast-store.ts index 53384a37..f2d2a47b 100644 --- a/src/stores/toast-store.ts +++ b/src/stores/toast-store.ts @@ -1,5 +1,6 @@ import { create } from 'zustand'; import { announce, toastAnnouncement } from '../lib/announce'; +import { effectiveToastDuration } from '../lib/toast-duration'; // Lightweight port of the webmail's stores/toast-store.ts: a queue of typed // toasts with an optional action, rendered by components/ToastHost. Use it @@ -20,6 +21,8 @@ export interface Toast { message?: string; action?: ToastAction; // ms; errors default to 10 s like the webmail, everything else to 5 s. + // Errors and toasts with an action follow the "Notification duration" + // setting (lib/toast-duration). 0 stays until it is swiped away. duration: number; createdAt: number; } @@ -42,7 +45,7 @@ export const useToastStore = create((set) => ({ ...toast, id, createdAt: Date.now(), - duration: toast.duration ?? (toast.type === 'error' ? 10_000 : 5_000), + duration: effectiveToastDuration(toast), }; // Keep the queue short: a burst of errors should not stack up forever. set((state) => ({ toasts: [...state.toasts.slice(-2), entry] })); diff --git a/src/theme/dynamic.ts b/src/theme/dynamic.ts index 705da87e..8dd1933d 100644 --- a/src/theme/dynamic.ts +++ b/src/theme/dynamic.ts @@ -5,16 +5,22 @@ import { useEffect, useMemo, useState } from 'react'; import { AccessibilityInfo } from 'react-native'; -import { useSettingsStore } from '../stores/settings-store'; +import { useSettingsStore, type FontSize } from '../stores/settings-store'; import { typography as baseTypography, spacing as baseSpacing } from './tokens'; -export type FontScale = 'small' | 'medium' | 'large'; +export type FontScale = FontSize; export type DensityKind = 'extra-compact' | 'compact' | 'regular' | 'comfortable'; -const FONT_SCALE: Record = { - small: 0.92, +// The webmail's text sizes (lib/accessibility-settings.ts FONT_SIZE_SCALE), +// 87.5% to 200%, on top of the system font scale RN applies to Text anyway. +export const FONT_SCALE: Record = { + small: 0.875, medium: 1, - large: 1.12, + large: 1.125, + 'x-large': 1.25, + 'xx-large': 1.5, + 'xxx-large': 1.75, + huge: 2, }; // How tall an email-list-style row should be. Mirrors the webmail From 9bd4674c9bfd37f0a28c76f974d9812b9e2bb177 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:15:56 +0100 Subject: [PATCH 12/26] chore: harvest the platform strings --- locales/rn/en.json | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/locales/rn/en.json b/locales/rn/en.json index 8875ce75..6a46fc99 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -622,6 +622,7 @@ "camera_allow": "Allow camera access", "camera_disabled": "Camera access is disabled. Enable it in Settings to scan a sign-in QR code.", "camera_needed": "Bulwark Mail needs camera access to scan a sign-in QR code.", + "cancel_sign_in": "Cancel sign-in", "choose_subtitle": "Choose how you'd like to sign in.", "confirm_check": "Check this looks right", "confirm_found": "Found your mail server", @@ -811,6 +812,31 @@ "up_to_date": "Up to date", "updates": "Updates" }, + "accessibility": { + "presets": { + "applied_mobile": "{name} changed {count, plural, one {# setting} other {# settings}}", + "apply": "Apply", + "apply_named": "Apply {name}", + "low-vision": { + "description_mobile": "Text at 150% and labels under the toolbar icons." + }, + "motion": { + "description_mobile": "No animations or sliding transitions." + }, + "screen-reader": { + "description_mobile": "No conversation grouping, so every message is a row of its own." + } + }, + "reduce_motion": { + "description_mobile": "Turns off sliding panels and transitions. The app also does this when reduced motion is on in your phone's settings." + }, + "text_size": { + "description_mobile": "Scales the app's text on top of your phone's text size. 100% is your phone's size." + }, + "toast_duration": { + "automatic": "Automatic (longer with a screen reader)" + } + }, "account": { "accounts": { "description_mobile": "Reorder how accounts appear in the account switcher, pick the default, or sign one out.", @@ -837,6 +863,9 @@ } }, "appearance": { + "accessibility_link": { + "description_mobile": "Text size, reduced motion and the accessibility presets are on the Accessibility page." + }, "description_mobile": "Customize how the app looks", "language": { "restart_for_direction": "Restart the app to apply the new text direction.", @@ -1379,6 +1408,7 @@ "mobile_explainer": "S/MIME is not available in the mobile app yet. Manage keys and certificates in the webmail; signed messages are shown without verification here." }, "summary": { + "accessibility": "Text size, motion, confirmations", "composing": "Signature, send delay, drafts", "contacts": "Address books, sorting", "content_senders": "Remote images, trusted senders", @@ -1432,6 +1462,7 @@ "description_mobile": "Reusable snippets for common replies. Tap one to edit; long-press to delete.", "export_failed": "Export failed", "import_failed": "Import failed", + "import_json_label": "Templates as JSON", "imported": "Imported", "imported_count": "{count, plural, one {# template} other {# templates}} added.", "is_html": "Body is HTML", From b205c13a6e992cc77b219ae5189798a47dcc109c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 04:18:10 +0100 Subject: [PATCH 13/26] fix: show the two-factor code prompt as a hint, not a red error (bulwarkmail/webmail#1422) --- src/lib/__tests__/login-errors.test.ts | 8 ++++++++ src/lib/login-errors.ts | 7 +++++++ src/screens/login/LoginNotice.tsx | 15 +++++++++++++-- src/screens/login/PasswordStep.tsx | 4 ++-- 4 files changed, 30 insertions(+), 4 deletions(-) diff --git a/src/lib/__tests__/login-errors.test.ts b/src/lib/__tests__/login-errors.test.ts index 0059d62d..287849e1 100644 --- a/src/lib/__tests__/login-errors.test.ts +++ b/src/lib/__tests__/login-errors.test.ts @@ -16,6 +16,14 @@ describe('describeLoginError', () => { expect(copy.detail).toMatch(/app password/i); }); + it('shows the two-factor prompt as a next step, not as an error (bulwarkmail/webmail#1422)', () => { + expect(describeLoginError(named('TotpRequiredError', 'MFA code required')).tone).toBe('info'); + expect(describeLoginError(named('Error', 'Session discovery failed: 402 Payment Required')).tone).toBe('info'); + // A wrong code still is one. + expect(describeLoginError(named('TotpLoginError', 'Invalid username, password or code')).tone).not.toBe('info'); + expect(describeLoginError(named('AuthenticationError', 'Invalid credentials')).tone).not.toBe('info'); + }); + it('blames a refused access token on the token, not on a password', () => { for (const err of [named('InvalidTokenError', 'Invalid access token'), 'Invalid access token']) { const copy = describeLoginError(err); diff --git a/src/lib/login-errors.ts b/src/lib/login-errors.ts index a1b18470..fb85c738 100644 --- a/src/lib/login-errors.ts +++ b/src/lib/login-errors.ts @@ -10,6 +10,11 @@ import { MAX_ACCOUNTS } from './account-utils'; export interface LoginErrorCopy { title: string; detail?: string; + /** + * `info` for a next step rather than a failure: the server asking for the + * two-factor code (webmail #1422). Absent means an error. + */ + tone?: 'error' | 'info'; } type Translate = (key: string, fallback?: string, params?: Record) => string; @@ -123,6 +128,7 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {} return { title: t('login.mobile.err_totp_title', 'Enter your two-factor code'), detail: t('login.mobile.err_totp_detail', 'This account is protected with two-factor sign-in. Type the 6-digit code from your authenticator app.'), + tone: 'info', }; } @@ -157,6 +163,7 @@ export function describeLoginError(err: unknown, context: LoginErrorContext = {} return { title: t('login.mobile.err_totp_title', 'Enter your two-factor code'), detail: t('login.mobile.err_totp_detail', 'This account is protected with two-factor sign-in. Type the 6-digit code from your authenticator app.'), + tone: 'info', }; } diff --git a/src/screens/login/LoginNotice.tsx b/src/screens/login/LoginNotice.tsx index 05dc51f1..5b7acbe7 100644 --- a/src/screens/login/LoginNotice.tsx +++ b/src/screens/login/LoginNotice.tsx @@ -3,25 +3,36 @@ import { View, Text, StyleSheet } from 'react-native'; import { AlertTriangle, Lock } from '../../components/icons'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; +import { announce } from '../../lib/announce'; interface LoginNoticeProps { title: string; detail?: string; tone?: 'error' | 'info'; + /** + * Read out when it appears, in either tone (a status, not a static hint): + * the code prompt is info now (webmail #1422) but still news. + */ + live?: boolean; } /** Inline banner for a sign-in failure, or a reassurance about where a * password ends up. Errors lead with what went wrong, then what to do. */ -export default function LoginNotice({ title, detail, tone = 'error' }: LoginNoticeProps) { +export default function LoginNotice({ title, detail, tone = 'error', live }: LoginNoticeProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const isError = tone === 'error'; const Icon = isError ? AlertTriangle : Lock; + const isLive = live ?? isError; + // iOS reads no live regions. + React.useEffect(() => { + if (isLive) announce([title, detail].filter(Boolean).join('. '), { assertive: isError }); + }, [isLive, isError, title, detail]); return ( diff --git a/src/screens/login/PasswordStep.tsx b/src/screens/login/PasswordStep.tsx index 3bd6b8fe..39cba046 100644 --- a/src/screens/login/PasswordStep.tsx +++ b/src/screens/login/PasswordStep.tsx @@ -18,7 +18,7 @@ interface PasswordStepProps { totpRequired?: boolean; onChangeTotp?: (value: string) => void; onSubmit: () => void; - notice?: { title: string; detail?: string } | null; + notice?: { title: string; detail?: string; tone?: 'error' | 'info' } | null; /** Access-token sign-in in place of username and password. */ tokenMode?: boolean; token?: string; @@ -148,7 +148,7 @@ export default function PasswordStep({ )} - {notice ? : null} + {notice ? : null} +