diff --git a/docs/gamecult/client-build-install.md b/docs/gamecult/client-build-install.md
index 33d3526f..b2505fe1 100644
--- a/docs/gamecult/client-build-install.md
+++ b/docs/gamecult/client-build-install.md
@@ -47,6 +47,10 @@ handoff states which config differs and the installer decides. From Yggdrasil th
not resolve: pass `LIVE_CURL_ARGS="--resolve chat.gamecult.org:443:127.0.0.1"`.
The folder, with the three pins (`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and
the node and npm versions, is what the install request names.
+Every build stamps its commit into `index.html` (`scripts/buildSha.js`): ``,
+with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. Where git governs the tree it decides: an explicit `BUILD_SHA` naming a different commit than HEAD stamps `-mismatch`, a git failure stamps `unknown`. A full-sha `BUILD_SHA` stands only for a tree git does not govern (a source archive) or cannot read (unusable git, for example a dubious-ownership refusal). The handoff
+build refuses a dist whose stamp differs from the sha it was asked for, and prints the stamp in its block.
+To read it from anywhere: `curl -s https://chat.gamecult.org/ | grep -o 'build-sha" content="[^"]*'`.
Before handing off:
diff --git a/scripts/buildSha.js b/scripts/buildSha.js
new file mode 100644
index 00000000..61a89445
--- /dev/null
+++ b/scripts/buildSha.js
@@ -0,0 +1,56 @@
+// The commit a bundle was built from, stamped into its index.html as , so a built
+// folder says what it is without a build log: `grep -o 'build-sha" content="[^"]*' dist/index.html`.
+// Where git governs the tree (REPO_DIR is a repository root) git decides: HEAD, suffixed -dirty when the tree has
+// uncommitted changes, or `-mismatch` when an explicit BUILD_SHA names a different commit, so a stamp
+// never vouches for code it does not hold; a git failure there is 'unknown'. Where git does not govern (a source
+// archive), a full-sha BUILD_SHA stands, else 'unknown'.
+import { execFileSync } from 'child_process';
+import { dirname, resolve } from 'path';
+import { fileURLToPath } from 'url';
+
+const REPO_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+
+// Vite writes vite.config.js.timestamp-*.mjs next to the config while it loads it; that file is the
+// build's own, not source, so an UNTRACKED one must not mark the tree dirty. A tracked file of that name,
+// or any other change, still does. Filtering status lines (not a pathspec) is what keeps it untracked-only.
+const VITE_TEMP = /^\?\? (?:.*\/)?vite\.config\.[a-z]+\.timestamp-\d+-[0-9a-f]+\.mjs$/;
+
+export const gitIn =
+ (cwd) =>
+ (...args) =>
+ execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
+
+const FULL_SHA = /^[0-9a-f]{40}$/;
+
+export const readBuildSha = (env = process.env, run = gitIn(REPO_DIR)) => {
+ const fromEnv = (env.BUILD_SHA ?? '').trim();
+ const explicit = FULL_SHA.test(fromEnv) ? fromEnv : null;
+ // Git governs this tree only when REPO_DIR is its root: a source archive unpacked inside some other
+ // repository has no git of its own, and that repository's HEAD says nothing about it.
+ let governed;
+ try {
+ governed = run('rev-parse', '--show-prefix').trim() === '';
+ } catch {
+ governed = false;
+ }
+ if (!governed) return explicit ?? 'unknown';
+ // Git governs and answers: from here a failure is 'unknown', never the bare explicit sha.
+ try {
+ const head = run('rev-parse', 'HEAD').trim();
+ if (!FULL_SHA.test(head)) return 'unknown';
+ if (explicit && explicit !== head) return `${explicit}-mismatch`;
+ const dirty = run('status', '--porcelain', '-uall', '--', '.')
+ .split('\n')
+ .some((l) => l.trim() !== '' && !VITE_TEMP.test(l));
+ return dirty ? `${head}-dirty` : head;
+ } catch {
+ return 'unknown';
+ }
+};
+
+export const buildShaPlugin = (sha = readBuildSha()) => ({
+ name: 'ratatoskr-build-sha',
+ transformIndexHtml: () => [
+ { tag: 'meta', attrs: { name: 'build-sha', content: sha }, injectTo: 'head-prepend' },
+ ],
+});
diff --git a/scripts/buildSha.test.js b/scripts/buildSha.test.js
new file mode 100644
index 00000000..4a270867
--- /dev/null
+++ b/scripts/buildSha.test.js
@@ -0,0 +1,170 @@
+import { execFileSync } from 'child_process';
+import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+import { afterEach, describe, expect, it } from 'vitest';
+import { buildShaPlugin, gitIn, readBuildSha } from './buildSha';
+
+const SHA = 'b6b1d522f1c0a9d3e2b4c5d6e7f8091a2b3c4d5e';
+const gitAt =
+ (head, status = '') =>
+ (cmd, ...args) =>
+ cmd === 'rev-parse' ? (args[0] === '--show-prefix' ? '\n' : `${head}\n`) : status;
+
+describe('readBuildSha', () => {
+ it('reads the checkout it is built from', () => {
+ expect(readBuildSha({}, gitAt(SHA))).toBe(SHA);
+ });
+ it('marks a tree with uncommitted changes, so a stamp never vouches for code it does not hold', () => {
+ expect(readBuildSha({}, gitAt(SHA, ' M src/x.ts\n'))).toBe(`${SHA}-dirty`);
+ });
+ it('lets BUILD_SHA stand in where there is no git, and ignores a value that is not a full sha', () => {
+ const noGit = () => {
+ throw new Error('not a git repository');
+ };
+ expect(readBuildSha({ BUILD_SHA: SHA }, noGit)).toBe(SHA);
+ expect(readBuildSha({ BUILD_SHA: SHA }, gitAt(SHA))).toBe(SHA);
+ // a different HEAD is a mismatch, not a clean stamp of the explicit sha
+ expect(readBuildSha({ BUILD_SHA: SHA }, gitAt('c'.repeat(40)))).toBe(`${SHA}-mismatch`);
+ // a value that is not a lowercase full sha is ignored
+ ['b6b1d52', SHA.toUpperCase(), `${SHA}0`].forEach((v) =>
+ expect(readBuildSha({ BUILD_SHA: v }, noGit)).toBe('unknown')
+ );
+ // git that answers but cannot say what the tree holds fails closed
+ const brokenStatus = (cmd, ...args) => {
+ if (cmd === 'status') throw new Error('index file smaller than expected');
+ return args[0] === '--show-prefix' ? '\n' : `${SHA}\n`;
+ };
+ expect(readBuildSha({ BUILD_SHA: SHA }, brokenStatus)).toBe('unknown');
+ expect(readBuildSha({}, brokenStatus)).toBe('unknown');
+ expect(readBuildSha({}, gitAt('nope'))).toBe('unknown');
+ // inside another repository's subfolder git does not govern: the explicit sha stands
+ const nested = (cmd, ...args) => (args[0] === '--show-prefix' ? 'vendor/ratatoskr/\n' : SHA);
+ expect(readBuildSha({ BUILD_SHA: SHA }, nested)).toBe(SHA);
+ expect(readBuildSha({}, nested)).toBe('unknown');
+ expect(readBuildSha({ BUILD_SHA: 'main' }, noGit)).toBe('unknown');
+ });
+ it("says 'unknown' rather than failing the build when git is absent", () => {
+ expect(
+ readBuildSha({}, () => {
+ throw new Error('spawn git ENOENT');
+ })
+ ).toBe('unknown');
+ });
+});
+
+describe('readBuildSha against a real repository', () => {
+ const made = [];
+ afterEach(() => {
+ made.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true }));
+ });
+ const repo = () => {
+ const dir = mkdtempSync(join(tmpdir(), 'buildsha-'));
+ made.push(dir);
+ const g = (...a) => execFileSync('git', a, { cwd: dir, stdio: 'ignore' });
+ g('init', '-q');
+ mkdirSync(join(dir, 'src'));
+ writeFileSync(join(dir, 'src', 'a.ts'), 'export {};\n');
+ g('add', '.');
+ g('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x');
+ return dir;
+ };
+ it("stays clean with vite's temp config file present, and is dirty with a stray source file", () => {
+ const dir = repo();
+ const run = gitIn(dir);
+ writeFileSync(join(dir, 'vite.config.js.timestamp-1700000000-abc123.mjs'), '');
+ expect(readBuildSha({}, run)).toMatch(/^[0-9a-f]{40}$/);
+ writeFileSync(join(dir, 'src', 'zz-stray.ts'), '');
+ expect(readBuildSha({}, run)).toMatch(/^[0-9a-f]{40}-dirty$/);
+ });
+});
+
+describe('readBuildSha dirty rules against a real repository', () => {
+ const made2 = [];
+ afterEach(() => {
+ made2.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true }));
+ });
+ const TEMP = 'vite.config.js.timestamp-1700000000-abc123.mjs';
+ const repo2 = (tracked = []) => {
+ const dir = mkdtempSync(join(tmpdir(), 'buildsha-'));
+ made2.push(dir);
+ const g = (...a) => execFileSync('git', a, { cwd: dir, stdio: 'ignore' });
+ g('init', '-q');
+ mkdirSync(join(dir, 'src'));
+ writeFileSync(join(dir, 'src', 'a.ts'), 'export {};\n');
+ tracked.forEach((f) => writeFileSync(join(dir, f), 'x\n'));
+ g('add', '.');
+ g('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x');
+ return {
+ dir,
+ head: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim(),
+ };
+ };
+ it('does not let an explicit BUILD_SHA mask a dirty tree', () => {
+ const { dir, head } = repo2();
+ const run = gitIn(dir);
+ expect(readBuildSha({ BUILD_SHA: head }, run)).toBe(head);
+ writeFileSync(join(dir, 'src', 'zz-stray.ts'), '');
+ expect(readBuildSha({ BUILD_SHA: head }, run)).toBe(`${head}-dirty`);
+ });
+ it('lets git speak only for a tree whose root it is: an archive unpacked inside another repository keeps its explicit sha', () => {
+ const { dir } = repo2();
+ const inner = join(dir, 'vendor', 'ratatoskr');
+ mkdirSync(inner, { recursive: true });
+ writeFileSync(join(inner, 'x.ts'), '');
+ expect(readBuildSha({ BUILD_SHA: SHA }, gitIn(inner))).toBe(SHA);
+ expect(readBuildSha({}, gitIn(inner))).toBe('unknown');
+ });
+ it("notices an untracked .mjs that is not vite's temp file", () => {
+ const { dir, head } = repo2();
+ writeFileSync(join(dir, 'foo.mjs'), '');
+ expect(readBuildSha({}, gitIn(dir))).toBe(`${head}-dirty`);
+ rmSync(join(dir, 'foo.mjs'));
+ // only vite's exact shape is its own: vite.config..timestamp--.mjs
+ [
+ 'vite.config.js.timestamp-1-ab.mjs.bak',
+ 'vite.config.js.timestamp-x-ab.mjs',
+ 'vite.config.js.timestamp-1-zz.mjs',
+ 'vite.config.a.b.timestamp-1-ab.mjs',
+ 'vite.config.js.timestamp-1-ab-c.mjs',
+ ].forEach((name) => {
+ writeFileSync(join(dir, name), '');
+ expect(readBuildSha({}, gitIn(dir)), name).toBe(`${head}-dirty`);
+ rmSync(join(dir, name));
+ });
+ expect(readBuildSha({}, gitIn(dir))).toBe(head);
+ });
+ it('treats a stray source file as dirty even beside the vite temp file, in the root or a subfolder', () => {
+ const { dir, head } = repo2();
+ const run = gitIn(dir);
+ writeFileSync(join(dir, TEMP), '');
+ writeFileSync(join(dir, 'src', TEMP), '');
+ expect(readBuildSha({}, run)).toBe(head);
+ writeFileSync(join(dir, 'src', 'zz-stray.ts'), '');
+ expect(readBuildSha({}, run)).toBe(`${head}-dirty`);
+ });
+ it('marks a tracked file of the temp name dirty once it changes', () => {
+ const { dir, head } = repo2([TEMP]);
+ const run = gitIn(dir);
+ expect(readBuildSha({}, run)).toBe(head);
+ writeFileSync(join(dir, TEMP), 'changed\n');
+ expect(readBuildSha({}, run)).toBe(`${head}-dirty`);
+ });
+ it('marks an untracked file in a new folder dirty (status lists files, not the folder)', () => {
+ const { dir, head } = repo2();
+ mkdirSync(join(dir, 'new'));
+ writeFileSync(join(dir, 'new', TEMP), '');
+ expect(readBuildSha({}, gitIn(dir))).toBe(head);
+ writeFileSync(join(dir, 'new', 'b.ts'), '');
+ expect(readBuildSha({}, gitIn(dir))).toBe(`${head}-dirty`);
+ });
+});
+
+describe('buildShaPlugin', () => {
+ it("hands vite a meta tag for the page's head carrying the sha", () => {
+ const plugin = buildShaPlugin(SHA);
+ expect(plugin.transformIndexHtml()).toEqual([
+ { tag: 'meta', attrs: { name: 'build-sha', content: SHA }, injectTo: 'head-prepend' },
+ ]);
+ });
+});
diff --git a/src/app/components/AccountDataEditor.tsx b/src/app/components/AccountDataEditor.tsx
index 2dbaf1f1..8c28b935 100644
--- a/src/app/components/AccountDataEditor.tsx
+++ b/src/app/components/AccountDataEditor.tsx
@@ -16,6 +16,7 @@ import {
} from 'folds';
import { MatrixError } from 'matrix-js-sdk';
import { Cursor } from '../plugins/text-area';
+import { PhoneHit } from '../styles/PhoneHit.css';
import { syntaxErrorPosition } from '../utils/dom';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { Page, PageHeader } from './page';
@@ -294,7 +295,7 @@ export function AccountDataEditor({
-
+
diff --git a/src/app/components/BackupRestore.tsx b/src/app/components/BackupRestore.tsx
index 068f437b..57a18674 100644
--- a/src/app/components/BackupRestore.tsx
+++ b/src/app/components/BackupRestore.tsx
@@ -30,6 +30,7 @@ import {
} from '../hooks/useKeyBackup';
import { stopPropagation } from '../utils/keyboard';
import { useRestoreBackupOnVerification } from '../hooks/useRestoreBackupOnVerification';
+import { PhoneHit } from '../styles/PhoneHit.css';
type BackupStatusProps = {
enabled: boolean;
@@ -177,6 +178,7 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
)}
Device Verification
-
+
diff --git a/src/app/components/DeviceVerificationSetup.tsx b/src/app/components/DeviceVerificationSetup.tsx
index 433fa6a1..68f42221 100644
--- a/src/app/components/DeviceVerificationSetup.tsx
+++ b/src/app/components/DeviceVerificationSetup.tsx
@@ -25,6 +25,7 @@ import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useAlive } from '../hooks/useAlive';
import { UseStateProvider } from './UseStateProvider';
+import { PhoneHit } from '../styles/PhoneHit.css';
type UIACallback = (
authDict: AuthDict | null
@@ -301,7 +302,7 @@ export const DeviceVerificationSetup = forwardRef
Setup Device Verification
-
+
@@ -336,7 +337,7 @@ export const DeviceVerificationReset = forwardRef
Reset Device Verification
-
+
diff --git a/src/app/components/Pdf-viewer/PdfViewer.tsx b/src/app/components/Pdf-viewer/PdfViewer.tsx
index 9c7fd980..d0e5f715 100644
--- a/src/app/components/Pdf-viewer/PdfViewer.tsx
+++ b/src/app/components/Pdf-viewer/PdfViewer.tsx
@@ -27,6 +27,7 @@ import { AsyncStatus } from '../../hooks/useAsyncCallback';
import { useZoom } from '../../hooks/useZoom';
import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist';
import { stopPropagation } from '../../utils/keyboard';
+import { PhoneHit, PhoneHitSpread24 } from '../../styles/PhoneHit.css';
export type PdfViewerProps = {
name: string;
@@ -108,7 +109,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
-
+
@@ -117,6 +118,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
(
{Math.round(zoom * 100)}%
1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom > 1}
size="300"
diff --git a/src/app/components/UIAFlowOverlay.tsx b/src/app/components/UIAFlowOverlay.tsx
index a2bdd1b8..49e1d821 100644
--- a/src/app/components/UIAFlowOverlay.tsx
+++ b/src/app/components/UIAFlowOverlay.tsx
@@ -13,6 +13,7 @@ import {
IconButton,
} from 'folds';
import FocusTrap from 'focus-trap-react';
+import { PhoneHit } from '../styles/PhoneHit.css';
export type UIAFlowOverlayProps = {
currentStep: number;
@@ -53,6 +54,7 @@ export function UIAFlowOverlay({
>
{(anchorRef) => (
- setOpen(!open)}>
-
-
- }>
-
- setOpen(false),
- clickOutsideDeactivates: true,
- escapeDeactivates: stopPropagation,
- }}
- >
-
-
-
-
-
- }
- after={
- <>
- setToolbar(!toolbar)}
- aria-pressed={toolbar}
- >
-
-
-
-
-
-
-
-
- >
- }
- bottom={
- toolbar && (
-
-
-
-
- )
- }
- />
-
-
-
-
-
- >
- );
-}
diff --git a/src/app/components/editor/Toolbar.tsx b/src/app/components/editor/Toolbar.tsx
index 7d701c42..3721b909 100644
--- a/src/app/components/editor/Toolbar.tsx
+++ b/src/app/components/editor/Toolbar.tsx
@@ -36,6 +36,7 @@ import { KeySymbol } from '../../utils/key-symbol';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { stopPropagation } from '../../utils/keyboard';
+import { PhoneHit, PhoneHitRoom36, PhoneHitSpread36 } from '../../styles/PhoneHit.css';
function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) {
return (
@@ -72,6 +73,7 @@ export function MarkButton({ format, icon, tooltip }: MarkButtonProps) {
{(triggerRef) => (
{(triggerRef) => (
{(triggerRef) => (
handleMenuSelect(1)}
size="400"
@@ -178,6 +182,7 @@ export function HeadingBlockButton() {
>
{(triggerRef) => (
handleMenuSelect(2)}
size="400"
@@ -193,6 +198,7 @@ export function HeadingBlockButton() {
>
{(triggerRef) => (
handleMenuSelect(3)}
size="400"
@@ -208,6 +214,7 @@ export function HeadingBlockButton() {
}
>
{(triggerRef) => (
{(triggerRef) => (
setIsMarkdown(!isMarkdown)}
diff --git a/src/app/components/emoji-board/components/Sidebar.tsx b/src/app/components/emoji-board/components/Sidebar.tsx
index de22b483..d2d5f197 100644
--- a/src/app/components/emoji-board/components/Sidebar.tsx
+++ b/src/app/components/emoji-board/components/Sidebar.tsx
@@ -14,6 +14,7 @@ import {
} from 'folds';
import classNames from 'classnames';
import * as css from './styles.css';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
export function Sidebar({ children }: { children: ReactNode }) {
return (
@@ -68,6 +69,7 @@ function SidebarBtn({
>
{(ref) => (
(
Seen by
-
+
diff --git a/src/app/components/image-editor/ImageEditor.tsx b/src/app/components/image-editor/ImageEditor.tsx
index 1ec474e8..0f072bb0 100644
--- a/src/app/components/image-editor/ImageEditor.tsx
+++ b/src/app/components/image-editor/ImageEditor.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
import * as css from './ImageEditor.css';
+import { PhoneHit } from '../../styles/PhoneHit.css';
export type ImageEditorProps = {
name: string;
@@ -24,7 +25,7 @@ export const ImageEditor = as<'div', ImageEditorProps>(
>
-
+
diff --git a/src/app/components/image-pack-view/ImagePackView.tsx b/src/app/components/image-pack-view/ImagePackView.tsx
index ab81d503..15b1cd08 100644
--- a/src/app/components/image-pack-view/ImagePackView.tsx
+++ b/src/app/components/image-pack-view/ImagePackView.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { Box, IconButton, Text, Icon, Icons, Scroll, Chip } from 'folds';
import { PackAddress } from '../../plugins/custom-emoji';
+import { PhoneHit } from '../../styles/PhoneHit.css';
import { Page, PageHeader, PageContent } from '../page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { RoomImagePack } from './RoomImagePack';
@@ -29,7 +30,7 @@ export function ImagePackView({ address, requestClose }: ImagePackViewProps) {
-
+
diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx
index 4956a7b6..4d2d7694 100644
--- a/src/app/components/image-viewer/ImageViewer.tsx
+++ b/src/app/components/image-viewer/ImageViewer.tsx
@@ -7,6 +7,7 @@ import * as css from './ImageViewer.css';
import { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan';
import { downloadMedia } from '../../utils/matrix';
+import { PhoneHit, PhoneHitSpread24 } from '../../styles/PhoneHit.css';
export type ImageViewerProps = {
alt: string;
@@ -33,7 +34,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
>
-
+
@@ -42,6 +43,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
(
{Math.round(zoom * 100)}%
1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom > 1}
size="300"
diff --git a/src/app/components/invite-user-prompt/InviteUserPrompt.tsx b/src/app/components/invite-user-prompt/InviteUserPrompt.tsx
index ddac0576..a503fe13 100644
--- a/src/app/components/invite-user-prompt/InviteUserPrompt.tsx
+++ b/src/app/components/invite-user-prompt/InviteUserPrompt.tsx
@@ -42,6 +42,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { BreakWord } from '../../styles/Text.css';
import { useAlive } from '../../hooks/useAlive';
+import { PhoneHit } from '../../styles/PhoneHit.css';
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000,
@@ -173,7 +174,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
-
+
diff --git a/src/app/components/join-address-prompt/JoinAddressPrompt.tsx b/src/app/components/join-address-prompt/JoinAddressPrompt.tsx
index 50a89418..ffd5241c 100644
--- a/src/app/components/join-address-prompt/JoinAddressPrompt.tsx
+++ b/src/app/components/join-address-prompt/JoinAddressPrompt.tsx
@@ -20,6 +20,7 @@ import { stopPropagation } from '../../utils/keyboard';
import { isRoomAlias, isRoomId } from '../../utils/matrix';
import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../../plugins/matrix-to';
import { tryDecodeURIComponent } from '../../utils/dom';
+import { PhoneHit } from '../../styles/PhoneHit.css';
type JoinAddressProps = {
onOpen: (roomIdOrAlias: string, via?: string[], eventId?: string) => void;
@@ -82,7 +83,7 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
Join with Address
-
+
diff --git a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx
index 217491e6..832dd59d 100644
--- a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx
+++ b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx
@@ -20,6 +20,7 @@ import { MatrixError } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { stopPropagation } from '../../utils/keyboard';
+import { PhoneHit } from '../../styles/PhoneHit.css';
type LeaveRoomPromptProps = {
roomId: string;
@@ -68,7 +69,7 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro
Leave Room
-
+
diff --git a/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx b/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx
index 8709b942..6c75ef99 100644
--- a/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx
+++ b/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx
@@ -20,6 +20,7 @@ import { MatrixError } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { stopPropagation } from '../../utils/keyboard';
+import { PhoneHit } from '../../styles/PhoneHit.css';
type LeaveSpacePromptProps = {
roomId: string;
@@ -68,7 +69,7 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
Leave Space
-
+
diff --git a/src/app/components/message/FileHeader.tsx b/src/app/components/message/FileHeader.tsx
index 2ffc9ec4..41cef5f4 100644
--- a/src/app/components/message/FileHeader.tsx
+++ b/src/app/components/message/FileHeader.tsx
@@ -2,6 +2,7 @@ import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'f
import React, { ReactNode, useCallback } from 'react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FileSaver from 'file-saver';
+import { PhoneHit } from '../../styles/PhoneHit.css';
import { mimeTypeToExt } from '../../utils/mimeTypes';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@@ -48,6 +49,7 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
variant={hasError ? 'Critical' : 'SurfaceVariant'}
size="300"
radii="300"
+ className={PhoneHit}
>
{downloading ? (
diff --git a/src/app/components/message/Reaction.css.ts b/src/app/components/message/Reaction.css.ts
index f020080e..08003f0e 100644
--- a/src/app/components/message/Reaction.css.ts
+++ b/src/app/components/message/Reaction.css.ts
@@ -1,6 +1,9 @@
import { createVar, style } from '@vanilla-extract/css';
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
+import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize';
+import { PHONE_TAP } from '../../styles/PhoneHit.css';
+const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
const Container = createVar();
const ContainerHover = createVar();
const ContainerActive = createVar();
@@ -25,6 +28,14 @@ export const Reaction = style([
selectors: {
'button&': {
cursor: 'pointer',
+ // Phone tap target: the chip is 26 px tall, so its hit area grows 9 px above and below (an ::after) and the
+ // same 9 px of margin opens the row, so the chips of one row and of the next never share a tap.
+ '@media': { [PHONE]: { position: 'relative', marginBlock: (PHONE_TAP - 26) / 2 } },
+ },
+ 'button&::after': {
+ '@media': {
+ [PHONE]: { content: '""', position: 'absolute', inset: `${-(PHONE_TAP - 26) / 2}px 0` },
+ },
},
'&[aria-pressed=true]': {
vars: {
diff --git a/src/app/components/message/content/AudioContent.tsx b/src/app/components/message/content/AudioContent.tsx
index 478486a4..94132cea 100644
--- a/src/app/components/message/content/AudioContent.tsx
+++ b/src/app/components/message/content/AudioContent.tsx
@@ -3,6 +3,7 @@ import React, { ReactNode, useCallback, useRef, useState } from 'react';
import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { Range } from 'react-range';
+import { PhoneHitSpread24 } from '../../../styles/PhoneHit.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { IAudioInfo } from '../../../../types/matrix/common';
@@ -159,6 +160,7 @@ export function AudioContent({
variant="SurfaceVariant"
size="300"
radii="Pill"
+ className={PhoneHitSpread24}
onClick={() => setMute(!mute)}
aria-pressed={mute}
>
diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts
index 3d1a9c83..39a40aa8 100644
--- a/src/app/components/message/layout/layout.css.ts
+++ b/src/app/components/message/layout/layout.css.ts
@@ -299,7 +299,10 @@ export const BubbleLeftArrow = style({
});
// Phone tap target: a sender name is 23 px tall, so the button grows 11 px above and below (44+ px to tap) and the
-// negative margin takes the growth back out of the layout. Hit area only: no background, border or outline moves.
+// negative margin takes the growth back out of the layout. Sideways it cannot borrow from the layout the
+// same way (a negative inline margin makes the button shrink inside its shrink-to-fit parent and clip the name), so
+// a name under 44 px is 44 px wide, text kept at the start: real growth of at most 16 px, in a row with room for it.
+// No background, border or outline moves.
const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
export const Username = style({
overflow: 'hidden',
@@ -309,7 +312,7 @@ export const Username = style({
'button&': {
cursor: 'pointer',
'@media': {
- [SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11 },
+ [SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11, minWidth: 44, textAlign: 'start' },
},
},
'button&:hover, button&:focus-visible': {
diff --git a/src/app/components/password-input/PasswordInput.tsx b/src/app/components/password-input/PasswordInput.tsx
index 4ffe0d83..6aa0d4f3 100644
--- a/src/app/components/password-input/PasswordInput.tsx
+++ b/src/app/components/password-input/PasswordInput.tsx
@@ -1,6 +1,7 @@
import React, { ComponentProps, forwardRef } from 'react';
import { Icon, IconButton, Input, config, Icons } from 'folds';
import { UseStateProvider } from '../UseStateProvider';
+import { PhoneHit } from '../../styles/PhoneHit.css';
type PasswordInputProps = Omit, 'type' | 'size'> & {
size: '400' | '500';
@@ -23,6 +24,7 @@ export const PasswordInput = forwardRef(
<>
{after}
setVisible(!visible)}
type="button"
variant={visible ? 'Warning' : variant}
diff --git a/src/app/components/room-topic-viewer/RoomTopicViewer.tsx b/src/app/components/room-topic-viewer/RoomTopicViewer.tsx
index 52128772..0d091e7e 100644
--- a/src/app/components/room-topic-viewer/RoomTopicViewer.tsx
+++ b/src/app/components/room-topic-viewer/RoomTopicViewer.tsx
@@ -4,6 +4,7 @@ import classNames from 'classnames';
import Linkify from 'linkify-react';
import * as css from './style.css';
import { LINKIFY_OPTS, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
+import { PhoneHit } from '../../styles/PhoneHit.css';
export const RoomTopicViewer = as<
'div',
@@ -26,7 +27,7 @@ export const RoomTopicViewer = as<
{name}
-
+
diff --git a/src/app/components/text-viewer/TextViewer.tsx b/src/app/components/text-viewer/TextViewer.tsx
index ec4ed0a5..e9e150b5 100644
--- a/src/app/components/text-viewer/TextViewer.tsx
+++ b/src/app/components/text-viewer/TextViewer.tsx
@@ -5,6 +5,7 @@ import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, as } from 'fo
import { ErrorBoundary } from 'react-error-boundary';
import * as css from './TextViewer.css';
import { copyToClipboard } from '../../utils/dom';
+import { PhoneHit } from '../../styles/PhoneHit.css';
const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism'));
@@ -53,7 +54,7 @@ export const TextViewer = as<'div', TextViewerProps>(
>
-
+
diff --git a/src/app/components/uia-stages/PasswordStage.tsx b/src/app/components/uia-stages/PasswordStage.tsx
index 0164cae6..a7ca2d06 100644
--- a/src/app/components/uia-stages/PasswordStage.tsx
+++ b/src/app/components/uia-stages/PasswordStage.tsx
@@ -4,6 +4,7 @@ import { AuthType } from 'matrix-js-sdk';
import { StageComponentProps } from './types';
import { ErrorCode } from '../../cs-errorcode';
import { PasswordInput } from '../password-input';
+import { PhoneHit } from '../../styles/PhoneHit.css';
export function PasswordStage({
stageData,
@@ -45,7 +46,7 @@ export function PasswordStage({
Account Password
-
+
diff --git a/src/app/components/uia-stages/SSOStage.tsx b/src/app/components/uia-stages/SSOStage.tsx
index e3d6126a..20decb6c 100644
--- a/src/app/components/uia-stages/SSOStage.tsx
+++ b/src/app/components/uia-stages/SSOStage.tsx
@@ -1,6 +1,7 @@
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
import React, { useCallback, useEffect, useState } from 'react';
import { StageComponentProps } from './types';
+import { PhoneHit } from '../../styles/PhoneHit.css';
export function SSOStage({
ssoRedirectURL,
@@ -56,7 +57,7 @@ export function SSOStage({
SSO Login
-
+
diff --git a/src/app/components/upload-card/CompactUploadCardRenderer.tsx b/src/app/components/upload-card/CompactUploadCardRenderer.tsx
index b9bada71..d10fbaa6 100644
--- a/src/app/components/upload-card/CompactUploadCardRenderer.tsx
+++ b/src/app/components/upload-card/CompactUploadCardRenderer.tsx
@@ -6,6 +6,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { TUploadContent } from '../../utils/matrix';
import { bytesToSize, getFileTypeIcon } from '../../utils/common';
import { useMediaConfig } from '../../hooks/useMediaConfig';
+import { PhoneHitSpread28 } from '../../styles/PhoneHit.css';
type CompactUploadCardRendererProps = {
isEncrypted?: boolean;
@@ -63,6 +64,7 @@ export function CompactUploadCardRenderer({
)}
)}
(({ children, ...props }, ref) => {
<>
(({ children, ...props }, ref) => {
<>
Self Demotion
-
+
@@ -120,7 +121,7 @@ function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps)
Shared Power
-
+
diff --git a/src/app/features/add-existing/AddExisting.tsx b/src/app/features/add-existing/AddExisting.tsx
index d100096b..a57a6b10 100644
--- a/src/app/features/add-existing/AddExisting.tsx
+++ b/src/app/features/add-existing/AddExisting.tsx
@@ -54,6 +54,7 @@ import { StateEvent } from '../../../types/matrix/room';
import { getViaServers } from '../../plugins/via-servers';
import { rateLimitedActions } from '../../utils/matrix';
import { useAlive } from '../../hooks/useAlive';
+import { PhoneHit } from '../../styles/PhoneHit.css';
const SEARCH_OPTS: UseAsyncSearchOptions = {
limit: 500,
@@ -201,7 +202,7 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM
Add Existing
-
+
diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx
index 3cf38b1f..d63f8709 100644
--- a/src/app/features/call-status/CallControl.tsx
+++ b/src/app/features/call-status/CallControl.tsx
@@ -5,6 +5,7 @@ import { StatusDivider } from './components';
import { CallEmbed, useCallControlState } from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { callEmbedAtom } from '../../state/callEmbed';
+import { PhoneHitSpread26 } from '../../styles/PhoneHit.css';
type MicrophoneButtonProps = {
enabled: boolean;
@@ -26,6 +27,7 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps
>
{(anchorRef) => (
{(anchorRef) => (
{(anchorRef) => (
{(anchorRef) => (
{(anchorRef) => (
{(anchorRef) => (
{(anchorRef) => (
{(anchorRef) => (
{(anchorRef) => (
void;
@@ -92,7 +93,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
-
+
diff --git a/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx b/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx
index f25ba7c3..a6e60a7b 100644
--- a/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx
+++ b/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx
@@ -22,6 +22,7 @@ import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { syntaxErrorPosition } from '../../../utils/dom';
import { Cursor } from '../../../plugins/text-area';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
const EDITOR_INTENT_SPACE_COUNT = 2;
@@ -118,7 +119,7 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro
-
+
diff --git a/src/app/features/common-settings/developer-tools/StateEventEditor.tsx b/src/app/features/common-settings/developer-tools/StateEventEditor.tsx
index 0ea96901..0f9f4034 100644
--- a/src/app/features/common-settings/developer-tools/StateEventEditor.tsx
+++ b/src/app/features/common-settings/developer-tools/StateEventEditor.tsx
@@ -31,6 +31,7 @@ import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
const EDITOR_INTENT_SPACE_COUNT = 2;
@@ -275,7 +276,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi
-
+
diff --git a/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx b/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx
index ad8ffae6..f7e901d5 100644
--- a/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx
+++ b/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx
@@ -4,6 +4,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
import { ImagePack } from '../../../plugins/custom-emoji';
import { ImagePackView } from '../../../components/image-pack-view';
import { RoomPacks } from './RoomPacks';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
type EmojisStickersProps = {
requestClose: () => void;
@@ -29,7 +30,7 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) {
-
+
diff --git a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx
index fdbe546e..7be8de35 100644
--- a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx
+++ b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx
@@ -40,6 +40,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useAlive } from '../../../hooks/useAlive';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
type CreatePackTileProps = {
packs: ImagePack[];
@@ -215,6 +216,7 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) {
{canEdit &&
(removed ? (
) : (
Enable Encryption
- setPrompt(false)} radii="300">
+ setPrompt(false)}
+ radii="300"
+ >
diff --git a/src/app/features/common-settings/general/RoomUpgrade.tsx b/src/app/features/common-settings/general/RoomUpgrade.tsx
index 45a480aa..2971d565 100644
--- a/src/app/features/common-settings/general/RoomUpgrade.tsx
+++ b/src/app/features/common-settings/general/RoomUpgrade.tsx
@@ -39,6 +39,7 @@ import { useAlive } from '../../../hooks/useAlive';
import { creatorsSupported } from '../../../utils/matrix';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { BreakWord } from '../../../styles/Text.css';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
const mx = useMatrixClient();
@@ -105,7 +106,7 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
{room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'}
-
+
diff --git a/src/app/features/common-settings/members/Members.tsx b/src/app/features/common-settings/members/Members.tsx
index 15cd1d08..a1dddb54 100644
--- a/src/app/features/common-settings/members/Members.tsx
+++ b/src/app/features/common-settings/members/Members.tsx
@@ -56,6 +56,7 @@ import { useSpaceOptionally } from '../../../hooks/useSpace';
import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../../hooks/useMemberPowerTag';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { getMouseEventCords } from '../../../utils/dom';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000,
@@ -161,7 +162,7 @@ export function Members({ requestClose }: MembersProps) {
-
+
@@ -277,6 +278,7 @@ export function Members({ requestClose }: MembersProps) {
anchorRef={scrollTopAnchorRef}
>
virtualizer.scrollToOffset(0)}
variant="Surface"
radii="Pill"
diff --git a/src/app/features/common-settings/permissions/PowersEditor.tsx b/src/app/features/common-settings/permissions/PowersEditor.tsx
index 97305946..0684a48a 100644
--- a/src/app/features/common-settings/permissions/PowersEditor.tsx
+++ b/src/app/features/common-settings/permissions/PowersEditor.tsx
@@ -49,6 +49,7 @@ import { useAlive } from '../../../hooks/useAlive';
import { BetaNoticeBadge } from '../../../components/BetaNoticeBadge';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { creatorsSupported } from '../../../utils/matrix';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
type EditPowerProps = {
maxPower: number;
@@ -369,7 +370,7 @@ export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) {
-
+
diff --git a/src/app/features/create-room/CreateRoomModal.tsx b/src/app/features/create-room/CreateRoomModal.tsx
index d5d8c92d..9332f35c 100644
--- a/src/app/features/create-room/CreateRoomModal.tsx
+++ b/src/app/features/create-room/CreateRoomModal.tsx
@@ -24,6 +24,7 @@ import {
import { CreateRoomModalState } from '../../state/createRoomModal';
import { stopPropagation } from '../../utils/keyboard';
import { CreateRoomType } from '../../components/create-room/types';
+import { PhoneHit } from '../../styles/PhoneHit.css';
type CreateRoomModalProps = {
state: CreateRoomModalState;
@@ -63,7 +64,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
-
+
diff --git a/src/app/features/create-space/CreateSpaceModal.tsx b/src/app/features/create-space/CreateSpaceModal.tsx
index c1bc6898..faa53b25 100644
--- a/src/app/features/create-space/CreateSpaceModal.tsx
+++ b/src/app/features/create-space/CreateSpaceModal.tsx
@@ -23,6 +23,7 @@ import {
} from '../../state/hooks/createSpaceModal';
import { CreateSpaceModalState } from '../../state/createSpaceModal';
import { stopPropagation } from '../../utils/keyboard';
+import { PhoneHit } from '../../styles/PhoneHit.css';
type CreateSpaceModalProps = {
state: CreateSpaceModalState;
@@ -61,7 +62,7 @@ function CreateSpaceModal({ state }: CreateSpaceModalProps) {
New Space
-
+
diff --git a/src/app/features/desk/Desk.tsx b/src/app/features/desk/Desk.tsx
index f1695716..a09501e7 100644
--- a/src/app/features/desk/Desk.tsx
+++ b/src/app/features/desk/Desk.tsx
@@ -1,6 +1,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
+import { PhoneHit } from '../../styles/PhoneHit.css';
import { Page, PageContent, PageContentCenter, PageHeader } from '../../components/page';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { getHomePath } from '../../pages/pathUtils';
@@ -33,7 +34,11 @@ export function Desk() {
{phone && (
- navigate(getHomePath())}>
+ navigate(getHomePath())}
+ >
)}
diff --git a/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx b/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx
index 028cd560..43576cff 100644
--- a/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx
+++ b/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx
@@ -10,6 +10,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { BackRouteHandler } from '../../components/BackRouteHandler';
+import { PhoneHit } from '../../styles/PhoneHit.css';
type JoinBeforeNavigateProps = { roomIdOrAlias: string; eventId?: string; viaServers?: string[] };
export function JoinBeforeNavigate({
@@ -38,7 +39,7 @@ export function JoinBeforeNavigate({
{screenSize === ScreenSize.Mobile && (
{(onBack) => (
-
+
)}
diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx
index 1802e3b0..cdfa2d8a 100644
--- a/src/app/features/lobby/HierarchyItemMenu.tsx
+++ b/src/app/features/lobby/HierarchyItemMenu.tsx
@@ -30,6 +30,7 @@ import { IPowerLevels } from '../../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
+import { PhoneHitSpread24 } from '../../styles/PhoneHit.css';
type HierarchyItemWithParent = HierarchyItem & {
parentId: string;
@@ -231,6 +232,7 @@ export function HierarchyItemMenu({
return (
virtualizer.scrollToOffset(0)}
variant="SurfaceVariant"
radii="Pill"
diff --git a/src/app/features/lobby/LobbyHeader.tsx b/src/app/features/lobby/LobbyHeader.tsx
index ed526c68..ce248fe5 100644
--- a/src/app/features/lobby/LobbyHeader.tsx
+++ b/src/app/features/lobby/LobbyHeader.tsx
@@ -38,6 +38,7 @@ import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
+import { PhoneHit } from '../../styles/PhoneHit.css';
type LobbyMenuProps = {
powerLevels: IPowerLevels;
@@ -165,7 +166,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
{(onBack) => (
-
+
)}
@@ -240,6 +241,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
>
{(triggerRef) => (
useMemo(
@@ -193,6 +194,7 @@ export function MessageSearch({
virtualizer.scrollToOffset(0)}
variant="SurfaceVariant"
radii="Pill"
diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx
index e8a349f0..8d53ce7f 100644
--- a/src/app/features/room-nav/RoomNavItem.tsx
+++ b/src/app/features/room-nav/RoomNavItem.tsx
@@ -66,6 +66,7 @@ import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
import { livekitSupport } from '../../hooks/useLivekitSupport';
import { StateEvent } from '../../../types/matrix/room';
import { webRTCSupported } from '../../utils/rtc';
+import { PhoneHitSpread24 } from '../../styles/PhoneHit.css';
type RoomNavItemMenuProps = {
room: Room;
@@ -229,6 +230,7 @@ function CallChatToggle() {
return (
setChat(!chat)}
aria-pressed={chat}
aria-label="Toggle Chat"
@@ -436,6 +438,7 @@ export function RoomNavItem({
}
>
{screenSize === ScreenSize.Mobile && (
-
+
)}
diff --git a/src/app/features/room-settings/general/General.tsx b/src/app/features/room-settings/general/General.tsx
index 15157304..e29fe2da 100644
--- a/src/app/features/room-settings/general/General.tsx
+++ b/src/app/features/room-settings/general/General.tsx
@@ -15,6 +15,7 @@ import {
} from '../../common-settings/general';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
type GeneralProps = {
requestClose: () => void;
@@ -35,7 +36,7 @@ export function General({ requestClose }: GeneralProps) {
-
+
diff --git a/src/app/features/room-settings/permissions/Permissions.tsx b/src/app/features/room-settings/permissions/Permissions.tsx
index 7572a71b..d513dda9 100644
--- a/src/app/features/room-settings/permissions/Permissions.tsx
+++ b/src/app/features/room-settings/permissions/Permissions.tsx
@@ -9,6 +9,7 @@ import { usePermissionGroups } from './usePermissionItems';
import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
type PermissionsProps = {
requestClose: () => void;
@@ -45,7 +46,7 @@ export function Permissions({ requestClose }: PermissionsProps) {
-
+
diff --git a/src/app/features/room/CallChatView.tsx b/src/app/features/room/CallChatView.tsx
index 6c32f4a0..fe40eb50 100644
--- a/src/app/features/room/CallChatView.tsx
+++ b/src/app/features/room/CallChatView.tsx
@@ -6,6 +6,7 @@ import { Page, PageHeader } from '../../components/page';
import { callChatAtom } from '../../state/callEmbed';
import { RoomView } from './RoomView';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
+import { PhoneHit } from '../../styles/PhoneHit.css';
export function CallChatView() {
const { eventId } = useParams();
@@ -41,7 +42,12 @@ export function CallChatView() {
}
>
{(triggerRef) => (
-
+
)}
diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx
index d9205076..3162eff0 100644
--- a/src/app/features/room/MembersDrawer.tsx
+++ b/src/app/features/room/MembersDrawer.tsx
@@ -59,6 +59,7 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { useRoomCreators } from '../../hooks/useRoomCreators';
+import { PhoneHit } from '../../styles/PhoneHit.css';
type MemberDrawerHeaderProps = {
room: Room;
@@ -87,6 +88,7 @@ function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) {
>
{(triggerRef) => (
setPeopleDrawer(false)}
@@ -359,6 +361,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
virtualizer.scrollToOffset(0)}
variant="Surface"
radii="Pill"
diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx
index e92ae575..2d63cbf1 100644
--- a/src/app/features/room/RoomInput.tsx
+++ b/src/app/features/room/RoomInput.tsx
@@ -28,7 +28,8 @@ import {
config,
toRem,
} from 'folds';
-import { RoomOutbox, sendTracked } from './RoomOutbox';
+import { RoomOutbox } from './RoomOutbox';
+import { sendTracked } from '../../state/room/outboxSend';
import { clearDraft, loadDraft, saveDraft } from '../../state/room/composerStore';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -115,6 +116,7 @@ import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useComposingCheck } from '../../hooks/useComposingCheck';
+import { PhoneHitSpread24, PhoneHitSpread32 } from '../../styles/PhoneHit.css';
interface RoomInputProps {
editor: Editor;
@@ -333,7 +335,7 @@ export const RoomInput = forwardRef(
});
handleCancelUpload(uploads);
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
- contents.forEach((content) => mx.sendMessage(roomId, content as any));
+ contents.forEach((content) => sendTracked(mx, roomId, content as Record));
};
const submit = useCallback(() => {
@@ -492,11 +494,7 @@ export const RoomInput = forwardRef(
await getImageUrlBlob(stickerUrl)
);
- mx.sendEvent(roomId, EventType.Sticker, {
- body: label,
- url: mxc,
- info,
- });
+ sendTracked(mx, roomId, { body: label, url: mxc, info }, EventType.Sticker);
};
return (
@@ -608,6 +606,7 @@ export const RoomInput = forwardRef(
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
setReplyDraft(undefined)}
variant="SurfaceVariant"
size="300"
@@ -641,6 +640,7 @@ export const RoomInput = forwardRef(
}
before={
pickFile('*')}
variant="SurfaceVariant"
size="300"
@@ -652,6 +652,7 @@ export const RoomInput = forwardRef(
after={
<>
(
>
{!hideStickerBtn && (
setEmojiBoardTab(EmojiBoardTab.Sticker)}
variant="SurfaceVariant"
@@ -707,6 +709,7 @@ export const RoomInput = forwardRef(
)}
(
)}
-
+
>
diff --git a/src/app/features/room/RoomOutbox.actions.test.tsx b/src/app/features/room/RoomOutbox.actions.test.tsx
new file mode 100644
index 00000000..0dbd4f5d
--- /dev/null
+++ b/src/app/features/room/RoomOutbox.actions.test.tsx
@@ -0,0 +1,102 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { enqueueOutbox, failOutbox, loadOutbox } from '../../state/room/composerStore';
+import { RoomOutbox } from './RoomOutbox';
+
+// The component's hooks are held still (state is its initial value, effects do not run) so the element tree
+// it returns can be read and its click handlers called without a DOM.
+vi.mock('react', async (importOriginal) => {
+ const actual = await importOriginal();
+ const useState = (init: unknown) => [
+ typeof init === 'function' ? (init as () => unknown)() : init,
+ () => undefined,
+ ];
+ return {
+ ...actual,
+ default: { ...actual, useState, useEffect: () => undefined },
+ useState,
+ useEffect: () => undefined,
+ };
+});
+
+type El = { props?: { children?: unknown; onClick?: () => void } };
+const flatten = (node: unknown): El[] => {
+ if (Array.isArray(node)) return node.flatMap(flatten);
+ if (node && typeof node === 'object') {
+ const el = node as El;
+ return [el, ...flatten(el.props?.children)];
+ }
+ return [];
+};
+const textOf = (node: unknown): string => {
+ if (typeof node === 'string' || typeof node === 'number') return String(node);
+ if (Array.isArray(node)) return node.map(textOf).join('');
+ if (node && typeof node === 'object') return textOf((node as El).props?.children);
+ return '';
+};
+/** The onClick of the chip whose label is `label`. */
+const clickOf = (tree: unknown, label: string) => {
+ const chip = flatten(tree).find((e) => e.props?.onClick && textOf(e) === label);
+ if (!chip?.props?.onClick) throw new Error(`no ${label} chip`);
+ return chip.props.onClick;
+};
+
+describe('RoomOutbox actions', () => {
+ beforeEach(() => {
+ const m = new Map();
+ vi.stubGlobal('localStorage', {
+ getItem: (k: string) => m.get(k) ?? null,
+ setItem: (k: string, v: string) => {
+ m.set(k, v);
+ },
+ removeItem: (k: string) => {
+ m.delete(k);
+ },
+ });
+ enqueueOutbox('@a:x', { txnId: 'keep', roomId: '!r:x', content: { body: 'stay' } });
+ enqueueOutbox('@a:x', { txnId: 'bad', roomId: '!r:x', content: { body: 'did not go' } });
+ failOutbox('@a:x', 'bad', 'ECONNRESET');
+ });
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ const mount = () => {
+ const mx = {
+ getUserId: () => '@a:x',
+ makeTxnId: () => 'txn-new',
+ getRoom: () => undefined,
+ sendEvent: vi.fn().mockResolvedValue({ event_id: '$e' }),
+ };
+ const tree = (RoomOutbox as unknown as (p: unknown) => unknown)({ mx, roomId: '!r:x' });
+ return { mx, tree };
+ };
+
+ it('Discard removes that message from the outbox and no other', () => {
+ const { mx, tree } = mount();
+ // both entries are shown; the failed one is the one with Discard
+ clickOf(tree, 'Discard')();
+ expect(loadOutbox('@a:x').map((e) => e.txnId)).toEqual(['keep']);
+ expect(mx.sendEvent).not.toHaveBeenCalled();
+ });
+
+ it('Resend sends that message again and does not discard it first', async () => {
+ const { mx, tree } = mount();
+ // the server has not answered yet: the resend is in flight
+ mx.sendEvent.mockReturnValue(
+ new Promise(() => {
+ // never settles
+ })
+ );
+ clickOf(tree, 'Resend')();
+ await Promise.resolve();
+ expect(mx.sendEvent).toHaveBeenCalledTimes(1);
+ expect(mx.sendEvent).toHaveBeenCalledWith(
+ '!r:x',
+ 'm.room.message',
+ { body: 'did not go' },
+ 'bad'
+ );
+ // still in the outbox, pending again under the same id, until the server confirms it
+ expect(loadOutbox('@a:x').find((e) => e.txnId === 'bad')).toMatchObject({ status: 'pending' });
+ });
+});
diff --git a/src/app/features/room/RoomOutbox.render.test.tsx b/src/app/features/room/RoomOutbox.render.test.tsx
new file mode 100644
index 00000000..e4c258df
--- /dev/null
+++ b/src/app/features/room/RoomOutbox.render.test.tsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { enqueueOutbox, failOutbox } from '../../state/room/composerStore';
+import { RoomOutbox } from './RoomOutbox';
+
+// What the person sees under the timeline for sends the server has not confirmed.
+const text = (html: string) => html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ');
+
+describe('RoomOutbox as rendered', () => {
+ beforeEach(() => {
+ const m = new Map();
+ vi.stubGlobal('localStorage', {
+ getItem: (k: string) => m.get(k) ?? null,
+ setItem: (k: string, v: string) => {
+ m.set(k, v);
+ },
+ removeItem: (k: string) => {
+ m.delete(k);
+ },
+ });
+ });
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ const mx = { getUserId: () => '@a:x' } as never;
+ const entry = (txnId: string, roomId: string, body: string) => ({
+ txnId,
+ roomId,
+ content: { body },
+ });
+
+ it('renders nothing when the room has no unconfirmed sends', () => {
+ enqueueOutbox('@a:x', entry('t1', '!other:x', 'elsewhere'));
+ expect(renderToStaticMarkup()).toBe('');
+ });
+
+ it('shows a fresh send as Sending with its text and no Resend', () => {
+ enqueueOutbox('@a:x', entry('t1', '!r:x', 'on its way'), Date.now() + 1000);
+ const html = renderToStaticMarkup();
+ expect(html).toContain('data-outbox-status="pending"');
+ expect(text(html)).toContain('Sending…');
+ expect(text(html)).toContain('on its way');
+ expect(text(html)).not.toContain('Resend');
+ });
+
+ it('shows a failed send with its error, Resend and Discard', () => {
+ enqueueOutbox('@a:x', entry('t2', '!r:x', 'did not go'));
+ failOutbox('@a:x', 't2', 'ECONNRESET');
+ const html = renderToStaticMarkup();
+ expect(html).toContain('data-outbox-status="failed"');
+ expect(text(html)).toContain('Not sent: ECONNRESET');
+ expect(text(html)).toContain('Resend');
+ expect(text(html)).toContain('Discard');
+ });
+
+ it('shows a send left over from before this page load as interrupted, resendable', () => {
+ enqueueOutbox('@a:x', entry('t3', '!r:x', 'lost to a reload'), 5);
+ const html = renderToStaticMarkup();
+ expect(text(html)).toContain('Interrupted by a reload');
+ expect(text(html)).toContain('Resend');
+ });
+});
diff --git a/src/app/features/room/RoomOutbox.test.ts b/src/app/features/room/RoomOutbox.test.ts
index df22f9d4..30d7c0bb 100644
--- a/src/app/features/room/RoomOutbox.test.ts
+++ b/src/app/features/room/RoomOutbox.test.ts
@@ -1,57 +1,28 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { loadOutbox } from '../../state/room/composerStore';
-import { sendTracked } from './RoomOutbox';
+import { describe, expect, it } from 'vitest';
+import { SLOW_SEND_MS, outboxView } from './RoomOutbox';
-const stubLocalStorage = () => {
- const m = new Map();
- vi.stubGlobal('localStorage', {
- getItem: (k: string) => m.get(k) ?? null,
- setItem: (k: string, v: string) => {
- m.set(k, v);
- },
- removeItem: (k: string) => {
- m.delete(k);
- },
- });
-};
-
-const fakeClient = (send: (...a: unknown[]) => Promise) =>
- ({ getUserId: () => '@a:x', makeTxnId: () => 'txn-1', sendMessage: send } as never);
-
-describe('sendTracked', () => {
- beforeEach(stubLocalStorage);
- afterEach(() => {
- vi.unstubAllGlobals();
+describe('outboxView', () => {
+ it('walks sending -> still sending (resendable) -> failed', () => {
+ expect(outboxView({ status: 'pending', ts: 100 }, 100, 0)).toMatchObject({
+ label: 'Sending…',
+ canResend: false,
+ });
+ expect(outboxView({ status: 'pending', ts: 100 }, 100 + SLOW_SEND_MS, 0)).toMatchObject({
+ label: expect.stringMatching(/^Still sending/),
+ canResend: true,
+ });
+ expect(outboxView({ status: 'failed', ts: 100, error: 'x' }, 100, 0)).toMatchObject({
+ failed: true,
+ canResend: true,
+ });
});
- const content = { msgtype: 'm.text', body: 'typed before reload' };
-
- it('holds the message in the outbox while the send is in flight, so a reload keeps it', async () => {
- let finish!: () => void;
- const mx = fakeClient(
- () =>
- new Promise((r) => {
- finish = () => r({ event_id: '$e' });
- })
- );
- const done = sendTracked(mx, '!r:x', content);
- expect(loadOutbox('@a:x')).toMatchObject([{ txnId: 'txn-1', status: 'pending', content }]);
- finish();
- await done;
- expect(loadOutbox('@a:x')).toEqual([]);
+ it('offers Resend to a pending send at 30 s and not a millisecond before', () => {
+ const at = (ms: number) => outboxView({ status: 'pending', ts: 100 }, 100 + ms, 0).canResend;
+ expect([at(29_999), at(30_000)]).toEqual([false, true]);
});
- it('a failed send stays, marked failed, and is not cleared', async () => {
- const mx = fakeClient(() => Promise.reject(new Error('ECONNRESET')));
- await sendTracked(mx, '!r:x', content);
- expect(loadOutbox('@a:x')).toMatchObject([
- { txnId: 'txn-1', status: 'failed', error: 'ECONNRESET', content },
- ]);
- });
-
- it('sends under the stored txn id, so a resend after an unknown outcome deduplicates', async () => {
- const send = vi.fn().mockResolvedValue({});
- await sendTracked(fakeClient(send), '!r:x', content);
- expect(send).toHaveBeenCalledWith('!r:x', content, 'txn-1');
+ it('an entry older than this page load reads as interrupted', () => {
+ expect(outboxView({ status: 'pending', ts: 5 }, 6, 10).label).toMatch(/Interrupted/);
});
});
diff --git a/src/app/features/room/RoomOutbox.tsx b/src/app/features/room/RoomOutbox.tsx
index cf164472..1e77df43 100644
--- a/src/app/features/room/RoomOutbox.tsx
+++ b/src/app/features/room/RoomOutbox.tsx
@@ -3,32 +3,44 @@ import { MatrixClient } from 'matrix-js-sdk';
import { Box, Chip, Text, color, config } from 'folds';
import {
OutboxEntry,
- enqueueOutbox,
- failOutbox,
loadOutbox,
removeOutbox,
- retryOutbox,
subscribeOutbox,
} from '../../state/room/composerStore';
+import { PAGE_START, resendTracked } from '../../state/room/outboxSend';
-const attempt = (mx: MatrixClient, userId: string, entry: OutboxEntry): Promise =>
- mx
- .sendMessage(entry.roomId, entry.content as never, entry.txnId)
- .then(() => removeOutbox(userId, entry.txnId))
- .catch((e: unknown) =>
- failOutbox(userId, entry.txnId, e instanceof Error ? e.message : String(e))
- );
+/** A send still unconfirmed after this long is offered Resend: the request may be hung. */
+export const SLOW_SEND_MS = 30_000;
-/** Send a message, keeping it in the durable outbox until the server confirms it. */
-export const sendTracked = (
- mx: MatrixClient,
- roomId: string,
- content: Record
-): Promise => {
- const userId = mx.getUserId() ?? '';
- const entry = { roomId, content, txnId: mx.makeTxnId() };
- enqueueOutbox(userId, entry);
- return attempt(mx, userId, { ...entry, status: 'pending', ts: Date.now() });
+export type OutboxView = { label: string; failed: boolean; canResend: boolean };
+
+export const outboxView = (
+ entry: Pick,
+ now: number,
+ since: number = PAGE_START
+): OutboxView => {
+ if (entry.status === 'failed') {
+ return {
+ label: `Not sent${entry.error ? `: ${entry.error}` : ''}`,
+ failed: true,
+ canResend: true,
+ };
+ }
+ if (entry.ts < since) {
+ return {
+ label: 'Interrupted by a reload: may not have been sent',
+ failed: false,
+ canResend: true,
+ };
+ }
+ if (now - entry.ts >= SLOW_SEND_MS) {
+ return {
+ label: 'Still sending: slow connection (Resend may duplicate it if it lands later)',
+ failed: false,
+ canResend: true,
+ };
+ }
+ return { label: 'Sending…', failed: false, canResend: false };
};
export const useRoomOutbox = (userId: string, roomId: string): OutboxEntry[] => {
@@ -42,19 +54,28 @@ export const useRoomOutbox = (userId: string, roomId: string): OutboxEntry[] =>
return entries;
};
-/** Entries still 'pending' that predate this page load lost their promise in a reload: outcome unknown. */
-const pageStart = Date.now();
+/** Re-render every few seconds while anything is waiting, so a slow send turns resendable. */
+const useNow = (active: boolean): number => {
+ const [now, setNow] = useState(Date.now());
+ useEffect(() => {
+ if (!active) return undefined;
+ setNow(Date.now());
+ const id = setInterval(() => setNow(Date.now()), 5000);
+ return () => clearInterval(id);
+ }, [active]);
+ return now;
+};
type RoomOutboxProps = { mx: MatrixClient; roomId: string };
/**
- * Messages this client sent that the server has not confirmed: still sending, or failed.
- * They also appear here after a reload, since the outbox is durable. Resending reuses the
- * original txn id, so a send that did land the first time is not duplicated.
+ * Messages this client sent that the server has not confirmed: sending, slow, interrupted by a
+ * reload, or failed, each with Resend where one makes sense and Discard.
*/
export function RoomOutbox({ mx, roomId }: RoomOutboxProps) {
const userId = mx.getUserId() ?? '';
const entries = useRoomOutbox(userId, roomId);
+ const now = useNow(entries.length > 0);
if (entries.length === 0) return null;
return (
@@ -64,40 +85,32 @@ export function RoomOutbox({ mx, roomId }: RoomOutboxProps) {
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
{entries.map((entry) => {
- const failed = entry.status === 'failed';
- const interrupted = !failed && entry.ts < pageStart;
- const canResend = failed || interrupted;
+ const view = outboxView(entry, now);
return (
- {(() => {
- if (failed) return `Not sent${entry.error ? `: ${entry.error}` : ''}`;
- return interrupted ? 'Interrupted by a reload: may not have been sent' : 'Sending…';
- })()}
+ {view.label}
{String(entry.content.body ?? '')}
- {canResend && (
+ {view.canResend && (
<>
- {
- retryOutbox(userId, entry.txnId);
- attempt(mx, userId, entry);
- }}
- >
- Resend
-
- removeOutbox(userId, entry.txnId)}>
- Discard
-
+ resendTracked(mx, entry)}>
+ Resend
+
+ removeOutbox(userId, entry.txnId)}
+ >
+ Discard
+
>
)}
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index e9c13e2a..9bd776f5 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -47,6 +47,7 @@ import {
import { isKeyHotkey } from 'is-hotkey';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { useTranslation } from 'react-i18next';
+import { isUnconfirmedLocalEcho } from './localEcho';
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
@@ -389,6 +390,19 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void)
}, [room, onArrive]);
};
+/** A local echo changes status (or becomes its remote echo) without a Timeline event: redraw. */
+const useLocalEchoRefresh = (room: Room, onUpdate: () => void) => {
+ useEffect(() => {
+ const handle: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (_evt, r) => {
+ if (r.roomId === room.roomId) onUpdate();
+ };
+ room.on(RoomEvent.LocalEchoUpdated, handle);
+ return () => {
+ room.removeListener(RoomEvent.LocalEchoUpdated, handle);
+ };
+ }, [room, onUpdate]);
+};
+
const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => {
useEffect(() => {
const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r) => {
@@ -648,6 +662,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
)
);
+ useLocalEchoRefresh(
+ room,
+ useCallback(() => {
+ // the echo was hidden while unconfirmed, so its arrival scrolled to nothing: scroll now
+ if (atBottomRef.current) {
+ scrollToBottomRef.current.count += 1;
+ scrollToBottomRef.current.smooth = true;
+ }
+ setTimeline((ct) => ({ ...ct }));
+ }, [])
+ );
+
const handleOpenEvent = useCallback(
async (
evtId: string,
@@ -1646,6 +1672,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (mEvent.isRedacted() && !showHiddenEvents) {
return null;
}
+ if (isUnconfirmedLocalEcho(mEvent)) return null;
if (!newDivider && readUptoEventIdRef.current) {
newDivider = prevEvent?.getId() === readUptoEventIdRef.current;
diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx
index 8089b5ef..3132c77a 100644
--- a/src/app/features/room/RoomViewHeader.tsx
+++ b/src/app/features/room/RoomViewHeader.tsx
@@ -72,6 +72,7 @@ import { RoomSettingsPage } from '../../state/roomSettings';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
+import { PhoneHitSpread40 } from '../../styles/PhoneHit.css';
type RoomMenuProps = {
room: Room;
@@ -336,6 +337,7 @@ function CallButton() {
>
{(triggerRef) => (
{(onBack) => (
-
+
@@ -531,7 +533,12 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
}
>
{(triggerRef) => (
-
+
)}
@@ -548,6 +555,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
>
{(triggerRef) => (
{(triggerRef) => (
-
+
)}
@@ -631,6 +644,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
>
{(triggerRef) => (
(
>
)}
-
+
diff --git a/src/app/features/room/jump-to-time/JumpToTime.tsx b/src/app/features/room/jump-to-time/JumpToTime.tsx
index 223c6cf6..4b231856 100644
--- a/src/app/features/room/jump-to-time/JumpToTime.tsx
+++ b/src/app/features/room/jump-to-time/JumpToTime.tsx
@@ -31,6 +31,7 @@ import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../
import { DatePicker, TimePicker } from '../../../components/time-date';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
type JumpToTimeProps = {
onCancel: () => void;
@@ -108,7 +109,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
Jump to Time
-
+
diff --git a/src/app/features/room/localEcho.test.ts b/src/app/features/room/localEcho.test.ts
new file mode 100644
index 00000000..fa44ac9b
--- /dev/null
+++ b/src/app/features/room/localEcho.test.ts
@@ -0,0 +1,20 @@
+import { describe, expect, it } from 'vitest';
+import { EventStatus } from 'matrix-js-sdk';
+import { isUnconfirmedLocalEcho } from './localEcho';
+
+describe('isUnconfirmedLocalEcho', () => {
+ it.each([
+ EventStatus.ENCRYPTING,
+ EventStatus.QUEUED,
+ EventStatus.SENDING,
+ EventStatus.NOT_SENT,
+ EventStatus.CANCELLED,
+ ])('hides %s', (status) => {
+ expect(isUnconfirmedLocalEcho({ status })).toBe(true);
+ });
+
+ it('shows a server-confirmed event and one the server has accepted (SENT)', () => {
+ expect(isUnconfirmedLocalEcho({ status: null })).toBe(false);
+ expect(isUnconfirmedLocalEcho({ status: EventStatus.SENT })).toBe(false);
+ });
+});
diff --git a/src/app/features/room/localEcho.ts b/src/app/features/room/localEcho.ts
new file mode 100644
index 00000000..4f827500
--- /dev/null
+++ b/src/app/features/room/localEcho.ts
@@ -0,0 +1,10 @@
+import { EventStatus, MatrixEvent } from 'matrix-js-sdk';
+
+/**
+ * A local echo the server has not accepted: the SDK adds it to the live timeline the moment
+ * sendMessage is called, marks it NOT_SENT on failure, and drops it on reload. Rendered like
+ * any message it looks delivered and then vanishes. The outbox (RoomOutbox) is the one place
+ * that shows unconfirmed sends, so the timeline hides these until the status is SENT.
+ */
+export const isUnconfirmedLocalEcho = (mEvent: Pick): boolean =>
+ mEvent.status !== null && mEvent.status !== undefined && mEvent.status !== EventStatus.SENT;
diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx
index 4b3d436f..a1bc17c9 100644
--- a/src/app/features/room/message/Message.tsx
+++ b/src/app/features/room/message/Message.tsx
@@ -86,6 +86,7 @@ import colorMXID from '../../../../util/colorMXID';
import { useSkyInk } from '../../../hooks/useTheme';
import { userColorInk } from '../../scene/skyInk';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
+import { PhoneHitRoom26, PhoneHitSpread32 } from '../../../styles/PhoneHit.css';
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
@@ -111,7 +112,7 @@ export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
{recentEmojis.map((emoji) => (
Delete Message
-
+
@@ -589,7 +595,12 @@ export const MessageReportItem = as<
Report Message
-
+
@@ -950,6 +961,7 @@ export const Message = as<'div', MessageProps>(
}
>
(
)}
(
{!isThreadedMessage && (
onReplyClick(ev, true)}
data-event-id={mEvent.getId()}
variant="SurfaceVariant"
@@ -982,6 +996,7 @@ export const Message = as<'div', MessageProps>(
)}
{canEditEvent(mx, mEvent) && onEditId && (
onEditId(mEvent.getId())}
variant="SurfaceVariant"
size="300"
@@ -1147,6 +1162,7 @@ export const Message = as<'div', MessageProps>(
}
>
(
}
>
(
(
}
>
diff --git a/src/app/features/room/reaction-viewer/ReactionViewer.tsx b/src/app/features/room/reaction-viewer/ReactionViewer.tsx
index 6c686bc5..b1a674d9 100644
--- a/src/app/features/room/reaction-viewer/ReactionViewer.tsx
+++ b/src/app/features/room/reaction-viewer/ReactionViewer.tsx
@@ -28,6 +28,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../../hooks/useSpace';
import { getMouseEventCords } from '../../../utils/dom';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
export type ReactionViewerProps = {
room: Room;
@@ -100,7 +101,7 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
{`Reacted with :${selectedShortcode}:`}
-
+
diff --git a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx
index 9986849f..25fe835f 100644
--- a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx
+++ b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx
@@ -86,6 +86,7 @@ import {
useGetMemberPowerTag,
} from '../../../hooks/useMemberPowerTag';
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
+import { PhoneHit, PhoneHitSpread26 } from '../../../styles/PhoneHit.css';
type PinnedMessageProps = {
room: Room;
@@ -146,6 +147,7 @@ function PinnedMessage({
{canPinEvent && (
(
Pinned Messages
-
+
diff --git a/src/app/features/scene/SkyStateControl.tsx b/src/app/features/scene/SkyStateControl.tsx
index a4313a8f..ee973822 100644
--- a/src/app/features/scene/SkyStateControl.tsx
+++ b/src/app/features/scene/SkyStateControl.tsx
@@ -28,6 +28,7 @@ import {
setSceneStyle,
type ServedSceneStyle,
} from './sceneStyle';
+import { PhoneHit } from '../../styles/PhoneHit.css';
const STYLE_LABEL: Record = { terrain: 'Terrain', ridge: 'Ridges' };
@@ -149,6 +150,7 @@ export function SkyStateControl() {
}
>
{screenSize === ScreenSize.Mobile && (
-
+
)}
diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx
index 9ec74305..19407bdf 100644
--- a/src/app/features/settings/about/About.tsx
+++ b/src/app/features/settings/about/About.tsx
@@ -8,6 +8,7 @@ import { LogoMark } from '../../../components/logo/logoMark.css';
import RatatoskrSVG from '../../../../../public/res/svg/ratatoskr.svg';
import { clearCacheAndReload } from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
type AboutProps = {
requestClose: () => void;
@@ -25,7 +26,7 @@ export function About({ requestClose }: AboutProps) {
-
+
diff --git a/src/app/features/settings/account/Account.tsx b/src/app/features/settings/account/Account.tsx
index c4b56e47..77ee8671 100644
--- a/src/app/features/settings/account/Account.tsx
+++ b/src/app/features/settings/account/Account.tsx
@@ -5,6 +5,7 @@ import { MatrixId } from './MatrixId';
import { Profile } from './Profile';
import { ContactInformation } from './ContactInfo';
import { IgnoredUserList } from './IgnoredUserList';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
type AccountProps = {
requestClose: () => void;
@@ -20,7 +21,7 @@ export function Account({ requestClose }: AccountProps) {
-
+
diff --git a/src/app/features/settings/account/IgnoredUserList.tsx b/src/app/features/settings/account/IgnoredUserList.tsx
index 98db9459..791a407b 100644
--- a/src/app/features/settings/account/IgnoredUserList.tsx
+++ b/src/app/features/settings/account/IgnoredUserList.tsx
@@ -8,6 +8,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { isUserId } from '../../../utils/matrix';
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
import { useAlive } from '../../../hooks/useAlive';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
function IgnoreUserInput({ userList }: { userList: string[] }) {
const mx = useMatrixClient();
@@ -67,6 +68,7 @@ function IgnoreUserInput({ userList }: { userList: string[] }) {
userId &&
!ignoring && (
Remove Avatar
- setAlertRemove(false)} radii="300">
+ setAlertRemove(false)}
+ radii="300"
+ >
@@ -273,6 +279,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
hasChanges &&
!changingDisplayName && (
void;
@@ -51,7 +52,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
-
+
diff --git a/src/app/features/settings/devices/DeviceTile.tsx b/src/app/features/settings/devices/DeviceTile.tsx
index 71b684f5..c4912dc0 100644
--- a/src/app/features/settings/devices/DeviceTile.tsx
+++ b/src/app/features/settings/devices/DeviceTile.tsx
@@ -29,6 +29,7 @@ import { LogoutDialog } from '../../../components/LogoutDialog';
import { stopPropagation } from '../../../utils/keyboard';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
export function DeviceTilePlaceholder() {
return (
@@ -288,6 +289,7 @@ export function DeviceTile({
-
+
diff --git a/src/app/features/settings/devices/Verification.tsx b/src/app/features/settings/devices/Verification.tsx
index 6c7eab17..c09dff55 100644
--- a/src/app/features/settings/devices/Verification.tsx
+++ b/src/app/features/settings/devices/Verification.tsx
@@ -35,6 +35,7 @@ import { stopPropagation } from '../../../utils/keyboard';
import { useAuthMetadata } from '../../../hooks/useAuthMetadata';
import { withSearchParam } from '../../../pages/pathUtils';
import { useAccountManagementActions } from '../../../hooks/useAccountManagement';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
type VerificationStatusBadgeProps = {
verificationStatus: VerificationStatus;
@@ -288,6 +289,7 @@ export function DeviceVerificationOptions() {
return (
<>
void;
@@ -30,7 +31,7 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) {
-
+
diff --git a/src/app/features/settings/emojis-stickers/GlobalPacks.tsx b/src/app/features/settings/emojis-stickers/GlobalPacks.tsx
index a9288728..38647b13 100644
--- a/src/app/features/settings/emojis-stickers/GlobalPacks.tsx
+++ b/src/app/features/settings/emojis-stickers/GlobalPacks.tsx
@@ -43,6 +43,7 @@ import { allRoomsAtom } from '../../../state/room-list/roomList';
import { AccountDataEvent } from '../../../../types/matrix/accountData';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { stopPropagation } from '../../../utils/keyboard';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
function GlobalPackSelector({
packs,
@@ -368,6 +369,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
{removed ? (
) : (
;
@@ -497,6 +498,7 @@ function DateHint({ hasChanges, handleReset }: DateHintProps) {
>
{hasChanges ? (
) : (
-
+
diff --git a/src/app/features/settings/notifications/KeywordMessages.tsx b/src/app/features/settings/notifications/KeywordMessages.tsx
index 7f84d607..b77cae21 100644
--- a/src/app/features/settings/notifications/KeywordMessages.tsx
+++ b/src/app/features/settings/notifications/KeywordMessages.tsx
@@ -15,6 +15,7 @@ import {
} from '../../../hooks/useNotificationMode';
import { NotificationModeSwitcher } from './NotificationModeSwitcher';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
const NOTIFY_MODE_OPS: NotificationModeOptions = {
highlight: true,
@@ -75,6 +76,7 @@ function KeywordInput() {
keyword &&
!addingKeyword && (
+
{removing ? : }
);
diff --git a/src/app/features/settings/notifications/Notifications.tsx b/src/app/features/settings/notifications/Notifications.tsx
index 095a9bba..61af1a09 100644
--- a/src/app/features/settings/notifications/Notifications.tsx
+++ b/src/app/features/settings/notifications/Notifications.tsx
@@ -8,6 +8,7 @@ import { KeywordMessagesNotifications } from './KeywordMessages';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
type NotificationsProps = {
requestClose: () => void;
@@ -23,7 +24,7 @@ export function Notifications({ requestClose }: NotificationsProps) {
-
+
diff --git a/src/app/features/space-settings/SpaceSettings.tsx b/src/app/features/space-settings/SpaceSettings.tsx
index b5fefc93..1030972f 100644
--- a/src/app/features/space-settings/SpaceSettings.tsx
+++ b/src/app/features/space-settings/SpaceSettings.tsx
@@ -17,6 +17,7 @@ import { Members } from '../common-settings/members';
import { DeveloperTools } from '../common-settings/developer-tools';
import { General } from './general';
import { Permissions } from './permissions';
+import { PhoneHit } from '../../styles/PhoneHit.css';
type SpaceSettingsMenuItem = {
page: SpaceSettingsPage;
@@ -117,7 +118,7 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps)
{screenSize === ScreenSize.Mobile && (
-
+
)}
diff --git a/src/app/features/space-settings/general/General.tsx b/src/app/features/space-settings/general/General.tsx
index 72040632..e99a4906 100644
--- a/src/app/features/space-settings/general/General.tsx
+++ b/src/app/features/space-settings/general/General.tsx
@@ -13,6 +13,7 @@ import {
} from '../../common-settings/general';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
type GeneralProps = {
requestClose: () => void;
@@ -33,7 +34,7 @@ export function General({ requestClose }: GeneralProps) {
-
+
diff --git a/src/app/features/space-settings/permissions/Permissions.tsx b/src/app/features/space-settings/permissions/Permissions.tsx
index 7572a71b..d513dda9 100644
--- a/src/app/features/space-settings/permissions/Permissions.tsx
+++ b/src/app/features/space-settings/permissions/Permissions.tsx
@@ -9,6 +9,7 @@ import { usePermissionGroups } from './usePermissionItems';
import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
type PermissionsProps = {
requestClose: () => void;
@@ -45,7 +46,7 @@ export function Permissions({ requestClose }: PermissionsProps) {
-
+
diff --git a/src/app/pages/auth/ServerPicker.tsx b/src/app/pages/auth/ServerPicker.tsx
index a2a78106..393cf34e 100644
--- a/src/app/pages/auth/ServerPicker.tsx
+++ b/src/app/pages/auth/ServerPicker.tsx
@@ -23,6 +23,7 @@ import FocusTrap from 'focus-trap-react';
import { useDebounce } from '../../hooks/useDebounce';
import { stopPropagation } from '../../utils/keyboard';
+import { PhoneHit } from '../../styles/PhoneHit.css';
export function ServerPicker({
server,
@@ -129,6 +130,7 @@ export function ServerPicker({
}
>
();
@@ -94,6 +95,7 @@ function UsernameHint({ server }: { server: string }) {
}
>
{(onBack) => (
-
+
)}
diff --git a/src/app/pages/client/explore/Explore.tsx b/src/app/pages/client/explore/Explore.tsx
index dae83166..6944c9f7 100644
--- a/src/app/pages/client/explore/Explore.tsx
+++ b/src/app/pages/client/explore/Explore.tsx
@@ -37,6 +37,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page';
import { stopPropagation } from '../../../utils/keyboard';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
export function AddServer() {
const mx = useMatrixClient();
@@ -96,7 +97,12 @@ export function AddServer() {
Add Server
- setDialog(false)} radii="300">
+ setDialog(false)}
+ radii="300"
+ >
diff --git a/src/app/pages/client/explore/Featured.tsx b/src/app/pages/client/explore/Featured.tsx
index f056cbb5..c28fad0a 100644
--- a/src/app/pages/client/explore/Featured.tsx
+++ b/src/app/pages/client/explore/Featured.tsx
@@ -18,6 +18,7 @@ import * as css from './style.css';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
export function FeaturedRooms() {
const { featuredCommunities } = useClientConfig();
@@ -33,7 +34,7 @@ export function FeaturedRooms() {
{(onBack) => (
-
+
)}
diff --git a/src/app/pages/client/explore/Server.tsx b/src/app/pages/client/explore/Server.tsx
index 48f267cc..036c6488 100644
--- a/src/app/pages/client/explore/Server.tsx
+++ b/src/app/pages/client/explore/Server.tsx
@@ -45,6 +45,7 @@ import { getMxIdServer } from '../../../utils/matrix';
import { stopPropagation } from '../../../utils/keyboard';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
const useServerSearchParams = (searchParams: URLSearchParams): ExploreServerPathSearchParams =>
useMemo(
@@ -499,7 +500,7 @@ export function PublicRooms() {
{screenSize === ScreenSize.Mobile && (
{(onBack) => (
-
+
)}
diff --git a/src/app/pages/client/home/CreateRoom.tsx b/src/app/pages/client/home/CreateRoom.tsx
index fddd75aa..8e277889 100644
--- a/src/app/pages/client/home/CreateRoom.tsx
+++ b/src/app/pages/client/home/CreateRoom.tsx
@@ -12,6 +12,7 @@ import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
import { CreateRoomForm } from '../../../features/create-room';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
export function HomeCreateRoom() {
const screenSize = useScreenSizeContext();
@@ -25,7 +26,7 @@ export function HomeCreateRoom() {
{(onBack) => (
-
+
)}
diff --git a/src/app/pages/client/home/Search.tsx b/src/app/pages/client/home/Search.tsx
index d5ddfb77..aa454d2c 100644
--- a/src/app/pages/client/home/Search.tsx
+++ b/src/app/pages/client/home/Search.tsx
@@ -5,6 +5,7 @@ import { MessageSearch } from '../../../features/message-search';
import { useHomeRooms } from './useHomeRooms';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
export function HomeSearch() {
const scrollRef = useRef(null);
@@ -19,7 +20,7 @@ export function HomeSearch() {
{screenSize === ScreenSize.Mobile && (
{(onBack) => (
-
+
)}
diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx
index ca26bac3..4d1580af 100644
--- a/src/app/pages/client/inbox/Invites.tsx
+++ b/src/app/pages/client/inbox/Invites.tsx
@@ -63,6 +63,7 @@ import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
import { useReportRoomSupported } from '../../../hooks/useReportRoomSupported';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
const COMPACT_CARD_WIDTH = 548;
@@ -745,7 +746,7 @@ export function Invites() {
{screenSize === ScreenSize.Mobile && (
{(onBack) => (
-
+
)}
diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx
index 9592224f..8fddba44 100644
--- a/src/app/pages/client/inbox/Notifications.tsx
+++ b/src/app/pages/client/inbox/Notifications.tsx
@@ -97,6 +97,7 @@ import {
} from '../../../hooks/useMemberPowerTag';
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
type RoomNotificationsGroup = {
roomId: string;
@@ -642,7 +643,7 @@ export function Notifications() {
{screenSize === ScreenSize.Mobile && (
{(onBack) => (
-
+
)}
@@ -694,6 +695,7 @@ export function Notifications() {
onVisibilityChange={handleScrollTopVisibility}
>
virtualizer.scrollToOffset(0)}
variant="SurfaceVariant"
radii="Pill"
diff --git a/src/app/pages/client/space/Search.tsx b/src/app/pages/client/space/Search.tsx
index 017262b5..a2849ab8 100644
--- a/src/app/pages/client/space/Search.tsx
+++ b/src/app/pages/client/space/Search.tsx
@@ -11,6 +11,7 @@ import { roomToParentsAtom } from '../../../state/room/roomToParents';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { BackRouteHandler } from '../../../components/BackRouteHandler';
+import { PhoneHit } from '../../../styles/PhoneHit.css';
export function SpaceSearch() {
const mx = useMatrixClient();
@@ -34,7 +35,7 @@ export function SpaceSearch() {
{screenSize === ScreenSize.Mobile && (
{(onBack) => (
-
+
)}
diff --git a/src/app/phoneHit.call.test.ts b/src/app/phoneHit.call.test.ts
new file mode 100644
index 00000000..56971b05
--- /dev/null
+++ b/src/app/phoneHit.call.test.ts
@@ -0,0 +1,15 @@
+import { describe, it } from 'vitest';
+import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit';
+
+// Every IconButton of the call bars carries the phone hit-area variant of its drawn size, in source order
+// (mic, sound, video, share, chat; the menu button; the call-status mic, sound, video, share).
+describe('icon buttons in the call controls', () => {
+ it.each([
+ ['features/call/Controls.tsx', Array(5).fill('PhoneHitSpread40')],
+ ['features/call/CallControls.tsx', ['PhoneHitSpread40']],
+ ['features/call-status/CallControl.tsx', Array(4).fill('PhoneHitSpread26')],
+ ])('%s', (file, want) => {
+ expectPhoneHitClasses(file as string, want as string[]);
+ expectSizeTie(file as string);
+ });
+});
diff --git a/src/app/phoneHit.commonSettings.test.ts b/src/app/phoneHit.commonSettings.test.ts
new file mode 100644
index 00000000..fdb2c4e0
--- /dev/null
+++ b/src/app/phoneHit.commonSettings.test.ts
@@ -0,0 +1,26 @@
+import { describe, it } from 'vitest';
+import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit';
+
+// Every IconButton under features/common-settings, in source order. Header close buttons are alone at the
+// header end (the title beside them is text), the dialog close buttons are alone in their Header, the
+// scroll-to-top button floats alone, and the RoomPacks pair is a ternary (only one renders): all PhoneHit.
+const files: [string, string[]][] = [
+ ['features/common-settings/members/Members.tsx', ['PhoneHit', 'PhoneHit']],
+ ['features/common-settings/developer-tools/SendRoomEvent.tsx', ['PhoneHit']],
+ ['features/common-settings/developer-tools/StateEventEditor.tsx', ['PhoneHit']],
+ ['features/common-settings/developer-tools/DevelopTools.tsx', ['PhoneHit']],
+ ['features/common-settings/permissions/PowersEditor.tsx', ['PhoneHit']],
+ ['features/common-settings/emojis-stickers/EmojisStickers.tsx', ['PhoneHit']],
+ ['features/common-settings/emojis-stickers/RoomPacks.tsx', ['PhoneHit', 'PhoneHit']],
+ ['features/common-settings/general/RoomEncryption.tsx', ['PhoneHit']],
+ ['features/common-settings/general/RoomUpgrade.tsx', ['PhoneHit']],
+];
+
+describe.each(files)('phone hit area: %s', (file, want) => {
+ it('carries the variant its drawn size and neighbours call for', () => {
+ expectPhoneHitClasses(file, want);
+ });
+ it('names a size that matches what it draws', () => {
+ expectSizeTie(file);
+ });
+});
diff --git a/src/app/phoneHit.content.test.ts b/src/app/phoneHit.content.test.ts
new file mode 100644
index 00000000..741a5828
--- /dev/null
+++ b/src/app/phoneHit.content.test.ts
@@ -0,0 +1,26 @@
+import { describe, it } from 'vitest';
+import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit';
+
+describe('icon buttons in message content and data editors', () => {
+ // The class each IconButton must carry, in source order.
+ const expected: Record = {
+ // mute toggle, 16 px icon + 8 padding = 24 px, 8 px from the volume slider (tappable)
+ 'components/message/content/AudioContent.tsx': ['PhoneHitSpread24'],
+ // download, alone at the end of the header row (26 px)
+ 'components/message/FileHeader.tsx': ['PhoneHit'],
+ // code block expand/collapse, 24 px, 8 px from the Copy chip
+ 'plugins/react-custom-html-parser.tsx': ['PhoneHitSpread24'],
+ // close at the header end, alone
+ 'components/AccountDataEditor.tsx': ['PhoneHit'],
+ 'components/image-pack-view/ImagePackView.tsx': ['PhoneHit'],
+ };
+
+ it.each(Object.entries(expected))(
+ '%s: each IconButton carries its own PhoneHit class',
+ (file, want) => expectPhoneHitClasses(file, want)
+ );
+
+ it.each(Object.keys(expected))('%s: a sized variant matches the drawn size', (file) =>
+ expectSizeTie(file)
+ );
+});
diff --git a/src/app/phoneHit.coverage.test.ts b/src/app/phoneHit.coverage.test.ts
new file mode 100644
index 00000000..a032d7d8
--- /dev/null
+++ b/src/app/phoneHit.coverage.test.ts
@@ -0,0 +1,58 @@
+import { readdirSync, readFileSync } from 'fs';
+import { describe, expect, it } from 'vitest';
+import { classLists, emitted, expectPhoneHitClasses, phoneHitClasses } from './phoneHitTestKit';
+
+// Every IconButton under src carries a PhoneHit class, or is named here with the reason it has none. The per-group
+// suites pin which variant a listed file's buttons carry; this one sees files no group lists, so a new bare
+// IconButton fails here instead of shipping with a sub-44px phone hit area.
+const EXEMPT: Record = {
+ 'features/estate/EstateNavigator.tsx': {
+ indices: [0],
+ why: 'Find a room sits in a css.Targets row that already gives it a 44 px phone target',
+ },
+ 'features/lobby/LobbyHeader.tsx': {
+ indices: [1],
+ why: 'Members button, hidden on mobile',
+ },
+};
+
+const tsxFiles = (dir: string): string[] =>
+ readdirSync(`${process.cwd()}/src/app/${dir}`, { withFileTypes: true }).flatMap((e) => {
+ if (e.isDirectory()) return tsxFiles(`${dir}${e.name}/`);
+ return e.name.endsWith('.tsx') ? [`${dir}${e.name}`] : [];
+ });
+
+describe('phone hit areas: every IconButton is covered or named', () => {
+ it('lists exactly the IconButtons without a PhoneHit class', () => {
+ const bare: Record = {};
+ tsxFiles('').forEach((file) => {
+ const src = readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8');
+ if (!src.includes(' (c === null ? [i] : []));
+ if (missing.length > 0) bare[file] = missing;
+ });
+ expect(bare).toEqual(
+ Object.fromEntries(Object.entries(EXEMPT).map(([f, e]) => [f, e.indices]))
+ );
+ });
+});
+
+// UrlPreviewCard's arrows are absolutely positioned by their own class; PhoneHit would set position: relative on them
+// and win or lose by stylesheet order, so they carry the variant that adds only the hit area.
+describe('phone hit area of a button positioned by its own class', () => {
+ it('the arrows carry PhoneHitInPlace', () => {
+ expectPhoneHitClasses('components/url-preview/UrlPreviewCard.tsx', [
+ 'PhoneHitInPlace',
+ 'PhoneHitInPlace',
+ ]);
+ });
+
+ it('PhoneHitInPlace draws the ::after and sets no position of its own', async () => {
+ const lists = await classLists('styles/PhoneHit.css.ts');
+ expect(lists.PhoneHitInPlace).toHaveLength(1);
+ const css = await emitted('styles/PhoneHit.css.ts');
+ const rules = [...css.matchAll(/^([^{}\n]*PhoneHitInPlace__[^{}\n]*) \{$/gm)].map((m) => m[1]);
+ expect(rules.length).toBeGreaterThan(0);
+ rules.forEach((sel) => expect(sel).toMatch(/::after$/));
+ });
+});
diff --git a/src/app/phoneHit.dialogs.test.ts b/src/app/phoneHit.dialogs.test.ts
new file mode 100644
index 00000000..8b3416a5
--- /dev/null
+++ b/src/app/phoneHit.dialogs.test.ts
@@ -0,0 +1,28 @@
+import { describe, it } from 'vitest';
+import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit';
+
+// Dialog and prompt close buttons: each is a 32 px button (24 px Icon + 8 px padding) alone at the end of a
+// header whose title is not tappable, so the plain PhoneHit; no neighbour to spread from, no clipping container.
+describe('icon buttons in dialogs and prompts', () => {
+ const expected: Record = {
+ 'features/create-space/CreateSpaceModal.tsx': ['PhoneHit'],
+ 'features/create-room/CreateRoomModal.tsx': ['PhoneHit'],
+ 'features/add-existing/AddExisting.tsx': ['PhoneHit'],
+ 'components/room-topic-viewer/RoomTopicViewer.tsx': ['PhoneHit'],
+ 'components/leave-room-prompt/LeaveRoomPrompt.tsx': ['PhoneHit'],
+ 'components/leave-space-prompt/LeaveSpacePrompt.tsx': ['PhoneHit'],
+ 'components/event-readers/EventReaders.tsx': ['PhoneHit'],
+ 'components/invite-user-prompt/InviteUserPrompt.tsx': ['PhoneHit'],
+ 'components/join-address-prompt/JoinAddressPrompt.tsx': ['PhoneHit'], // join cancel
+ 'components/user-profile/PowerChip.tsx': ['PhoneHit', 'PhoneHit'], // self demotion, shared power
+ };
+
+ it.each(Object.entries(expected))(
+ '%s: each IconButton carries its own PhoneHit class',
+ (file, want) => expectPhoneHitClasses(file, want)
+ );
+
+ it.each(Object.keys(expected))('%s: a sized variant matches the drawn size', (file) =>
+ expectSizeTie(file)
+ );
+});
diff --git a/src/app/phoneHit.editor.test.ts b/src/app/phoneHit.editor.test.ts
new file mode 100644
index 00000000..ef016b59
--- /dev/null
+++ b/src/app/phoneHit.editor.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it } from 'vitest';
+import { MOBILE_BREAKPOINT } from './hooks/useScreenSize';
+import {
+ classLists,
+ emitted,
+ expectPhoneHitClasses,
+ expectSizeTie,
+ iconButtonTags,
+ readSource,
+ rulesFor,
+ type SizeAllow,
+} from './phoneHitTestKit';
+
+describe('icon buttons in the editor, emoji board, upload cards and client root', () => {
+ // The class each IconButton must carry, in source order.
+ const expected: Record = {
+ 'components/editor/Toolbar.tsx': [
+ 'PhoneHitSpread36', // mark button: size 400 + 20 px icon, in a row 4 px apart
+ 'PhoneHitSpread36', // block button, same row
+ 'PhoneHitRoom36', // heading 1, inside the heading Menu (overflow hidden)
+ 'PhoneHitRoom36', // heading 2
+ 'PhoneHitRoom36', // heading 3
+ 'PhoneHit', // heading opener: two 20 px icons, 56 px wide, only its height is short
+ 'PhoneHit', // Exit chip: text label, wider than 44 px
+ 'PhoneHit', // markdown toggle, alone at the end of the toolbar
+ ],
+ // A column of 40 px buttons 4 px apart (the stack's gap="100", pinned below): there is no vertical variant, so only
+ // PhoneHit; the 44 px areas then touch with no overlap.
+ 'components/emoji-board/components/Sidebar.tsx': ['PhoneHit'],
+ // Both cards put a Retry chip beside the cancel button when the upload failed.
+ 'components/upload-card/UploadCardRenderer.tsx': ['PhoneHitSpread28'],
+ 'components/upload-card/CompactUploadCardRenderer.tsx': ['PhoneHitSpread28'],
+ 'pages/client/ClientRoot.tsx': ['PhoneHit'], // alone, absolute in the corner
+ };
+
+ const sizeAllow: SizeAllow = {};
+
+ it.each(Object.entries(expected))(
+ '%s: each IconButton carries its own PhoneHit class',
+ (file, want) => expectPhoneHitClasses(file, want)
+ );
+
+ it.each(Object.keys(expected))('%s: Spread and Room variants name the size they draw', (file) =>
+ expectSizeTie(file, sizeAllow)
+ );
+});
+
+/** The JSX element opened at `open` (index of its ``: nesting is counted, not guessed (no self-closing Box here). */
+const boxSpan = (src: string, open: number) => {
+ const marks = [...src.slice(open).matchAll(/])|<\/Box>/g)];
+ const depths = marks.map((m, i) =>
+ marks.slice(0, i + 1).reduce((d, x) => d + (x[0] === '' ? -1 : 1), 0)
+ );
+ const end = depths.indexOf(0);
+ if (end === -1) throw new Error('unbalanced ');
+ return [open, open + (marks[end].index ?? 0) + marks[end][0].length] as const;
+};
+
+describe('the estate navigator find button', () => {
+ // The only IconButton there needs no PhoneHit: every button in the navigator header is 44 px on a phone because
+ // the Targets class sets a min size, and the button sits inside the Box that carries that class.
+ it('is 44 px by the Targets class instead', async () => {
+ const src = readSource('features/estate/EstateNavigator.tsx');
+ const tags = iconButtonTags(src);
+ expect(tags).toHaveLength(1);
+ expect(tags[0]).not.toMatch(/PhoneHit/);
+
+ const header = src.indexOf('');
+ const boxStart = src.indexOf('', boxStart))).toMatch(/className=\{css\.Targets\}/);
+ const button = src.indexOf(' {
+ // PhoneHit makes the button position: relative on a phone; the inline style wins over a class, so the corner
+ // button stays absolute and its ::after anchors to it.
+ it('ClientRoot keeps its corner button absolutely positioned inline', () => {
+ const [tag] = iconButtonTags(readSource('pages/client/ClientRoot.tsx'));
+ expect(tag).toMatch(/style=\{\{[^}]*position:\s*'absolute'/);
+ });
+
+ // A 40 px button with a 44 px area, in a column 4 px apart: the areas touch and do not overlap. A smaller gap
+ // overlaps them, a larger one leaves a dead strip.
+ it('the emoji sidebar stack keeps its 4 px gap', () => {
+ const src = readSource('components/emoji-board/components/Sidebar.tsx');
+ const stack = src.slice(
+ src.indexOf('export const SidebarStack'),
+ src.indexOf('export function SidebarDivider')
+ );
+ expect(stack).toMatch(/direction="Column"/);
+ expect(stack).toMatch(/gap="100"/);
+ });
+});
diff --git a/src/app/phoneHit.pages.test.ts b/src/app/phoneHit.pages.test.ts
new file mode 100644
index 00000000..97eb77b8
--- /dev/null
+++ b/src/app/phoneHit.pages.test.ts
@@ -0,0 +1,29 @@
+import { describe, it } from 'vitest';
+import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit';
+
+// Page back buttons (and two lone buttons beside them). Every one is alone in its row, 40 px drawn (default
+// IconButton and Icon), so plain PhoneHit; the vertical stacks of the page have no variant.
+// null = deliberately without: LobbyHeader's Members button renders only when screenSize !== Mobile.
+const files: Record = {
+ 'features/lobby/LobbyHeader.tsx': ['PhoneHit', null, 'PhoneHit'],
+ 'features/join-before-navigate/JoinBeforeNavigate.tsx': ['PhoneHit'],
+ 'pages/client/space/Search.tsx': ['PhoneHit'],
+ 'pages/client/explore/Server.tsx': ['PhoneHit'],
+ 'pages/client/explore/Featured.tsx': ['PhoneHit'],
+ 'pages/client/home/CreateRoom.tsx': ['PhoneHit'],
+ 'pages/client/home/Search.tsx': ['PhoneHit'],
+ // the second is the 30 px scroll-to-top pill, floating alone at the top centre
+ 'pages/client/inbox/Notifications.tsx': ['PhoneHit', 'PhoneHit'],
+ 'pages/client/inbox/Invites.tsx': ['PhoneHit'],
+ 'pages/client/direct/DirectCreate.tsx': ['PhoneHit'],
+};
+
+describe.each(Object.entries(files))('phone hit area: %s', (file, want) => {
+ it('gives each IconButton its variant', () => {
+ expectPhoneHitClasses(file, want);
+ });
+ // plain PhoneHit has no size to tie; this fails when a sized variant appears whose number is not the drawn px
+ it('ties any sized variant to the drawn size', () => {
+ expectSizeTie(file);
+ });
+});
diff --git a/src/app/phoneHit.roomSpace.test.ts b/src/app/phoneHit.roomSpace.test.ts
new file mode 100644
index 00000000..c98c2b09
--- /dev/null
+++ b/src/app/phoneHit.roomSpace.test.ts
@@ -0,0 +1,28 @@
+import { describe, it } from 'vitest';
+import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit';
+
+describe('icon buttons in room settings, space settings, lobby, search and explore', () => {
+ // The class each IconButton must carry, in source order. Vertical stacks and lone buttons: the plain PhoneHit.
+ const expected: Record = {
+ 'features/room-settings/RoomSettings.tsx': ['PhoneHit'], // close, phone only, alone at the header end
+ 'features/room-settings/general/General.tsx': ['PhoneHit'], // close, 40 px
+ 'features/room-settings/permissions/Permissions.tsx': ['PhoneHit'],
+ 'features/space-settings/SpaceSettings.tsx': ['PhoneHit'],
+ 'features/space-settings/general/General.tsx': ['PhoneHit'],
+ 'features/space-settings/permissions/Permissions.tsx': ['PhoneHit'],
+ // 16 px icon + 8 padding = 24 px, 8-12 px from the Open / Add chips beside it in a hierarchy card
+ 'features/lobby/HierarchyItemMenu.tsx': ['PhoneHitSpread24'],
+ 'features/lobby/Lobby.tsx': ['PhoneHit'], // floating scroll-to-top, alone
+ 'features/message-search/MessageSearch.tsx': ['PhoneHit'], // floating scroll-to-top, alone
+ 'pages/client/explore/Explore.tsx': ['PhoneHit'], // Add Server dialog close
+ };
+
+ it.each(Object.entries(expected))(
+ '%s: each IconButton carries its own PhoneHit class',
+ (file, want) => expectPhoneHitClasses(file, want)
+ );
+
+ it.each(Object.keys(expected))('%s: a sized variant matches the drawn size', (file) =>
+ expectSizeTie(file)
+ );
+});
diff --git a/src/app/phoneHit.settingsA.test.ts b/src/app/phoneHit.settingsA.test.ts
new file mode 100644
index 00000000..37f1f3c2
--- /dev/null
+++ b/src/app/phoneHit.settingsA.test.ts
@@ -0,0 +1,29 @@
+import { describe, it } from 'vitest';
+import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit';
+
+describe('icon buttons in settings A', () => {
+ // The class each IconButton must carry, in source order. Every one is alone in its row (a header close next to
+ // non-tappable title text, an input slot, or a list tile beside a non-tappable Avatar), so the plain PhoneHit.
+ const expected: Record = {
+ 'features/settings/developer-tools/DevelopTools.tsx': ['PhoneHit'], // header close, 40 px
+ 'features/settings/notifications/Notifications.tsx': ['PhoneHit'], // header close, 40 px
+ 'features/settings/notifications/KeywordMessages.tsx': ['PhoneHit', 'PhoneHit'], // input reset, remove: 26 px, alone
+ 'features/settings/emojis-stickers/EmojisStickers.tsx': ['PhoneHit'], // header close, 40 px
+ 'features/settings/emojis-stickers/GlobalPacks.tsx': ['PhoneHit', 'PhoneHit'], // undo / remove, a ternary pair, 26 px
+ 'features/settings/Settings.tsx': ['PhoneHit'], // mobile-only header close, 40 px
+ 'features/settings/about/About.tsx': ['PhoneHit'], // header close, 40 px
+ 'features/settings/general/General.tsx': ['PhoneHit', 'PhoneHit', 'PhoneHit'], // reset / info (ternary), header close
+ 'features/settings/account/Account.tsx': ['PhoneHit'], // header close, 40 px
+ 'features/settings/devices/Devices.tsx': ['PhoneHit'], // header close, 40 px
+ };
+
+ it.each(Object.entries(expected))('%s carries the right hit-area class', (file, want) => {
+ expectPhoneHitClasses(file, want);
+ });
+
+ // Plain PhoneHit has no size to tie, so nothing is allow-listed; the check still fails an allow-list entry
+ // or a sized variant that disagrees with the drawn size if one is added later.
+ it.each(Object.keys(expected))('%s ties a sized variant to the drawn size', (file) => {
+ expectSizeTie(file);
+ });
+});
diff --git a/src/app/phoneHit.settingsB.test.ts b/src/app/phoneHit.settingsB.test.ts
new file mode 100644
index 00000000..4d2e3dee
--- /dev/null
+++ b/src/app/phoneHit.settingsB.test.ts
@@ -0,0 +1,21 @@
+import { describe, it } from 'vitest';
+import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit';
+
+// Each IconButton here is alone in its slot (a dialog header's close, an input's clear button, a tile's lone
+// menu or chevron button, its neighbours being text or a badge), so each takes PhoneHit. Every size is derivable
+// from the source, so nothing is allow-listed; plain PhoneHit has no number to tie.
+const files: [string, string[]][] = [
+ ['features/settings/account/Profile.tsx', ['PhoneHit', 'PhoneHit']],
+ ['features/settings/account/IgnoredUserList.tsx', ['PhoneHit']],
+ ['features/settings/devices/Verification.tsx', ['PhoneHit']],
+ ['features/settings/devices/DeviceTile.tsx', ['PhoneHit']],
+ ['pages/auth/ServerPicker.tsx', ['PhoneHit']],
+ ['components/BackupRestore.tsx', ['PhoneHit']],
+ ['components/DeviceVerification.tsx', ['PhoneHit']],
+ ['components/DeviceVerificationSetup.tsx', ['PhoneHit', 'PhoneHit']],
+];
+
+describe.each(files)('phone hit area: %s', (file, want) => {
+ it('gives each icon button its variant', () => expectPhoneHitClasses(file, want));
+ it('ties each sized variant to the size it draws', () => expectSizeTie(file));
+});
diff --git a/src/app/phoneHit.viewers.test.ts b/src/app/phoneHit.viewers.test.ts
new file mode 100644
index 00000000..3e579f4c
--- /dev/null
+++ b/src/app/phoneHit.viewers.test.ts
@@ -0,0 +1,27 @@
+import { describe, it } from 'vitest';
+import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit';
+
+// Viewer headers and the UIA stages. The back/close button sits beside a title that is not tappable, so it is a
+// plain PhoneHit; the zoom out/in buttons sit either side of a tappable zoom Chip, so they spread at their drawn
+// 24 px (IconButton size 300 around a 16 px Icon).
+// Not covered: the two scroll arrows of components/url-preview/UrlPreviewCard.tsx. They are position: absolute and
+// PhoneHit sets position: relative on phones, which would pull them into the flow; they need a variant that keeps
+// the position, which PhoneHit.css.ts does not have yet.
+const files: [string, string[]][] = [
+ ['components/uia-stages/PasswordStage.tsx', ['PhoneHit']],
+ ['components/uia-stages/SSOStage.tsx', ['PhoneHit']],
+ ['components/UIAFlowOverlay.tsx', ['PhoneHit']],
+ ['components/text-viewer/TextViewer.tsx', ['PhoneHit']],
+ ['components/image-editor/ImageEditor.tsx', ['PhoneHit']],
+ ['components/image-viewer/ImageViewer.tsx', ['PhoneHit', 'PhoneHitSpread24', 'PhoneHitSpread24']],
+ ['components/Pdf-viewer/PdfViewer.tsx', ['PhoneHit', 'PhoneHitSpread24', 'PhoneHitSpread24']],
+];
+
+describe.each(files)('phone hit area: %s', (file, want) => {
+ it('gives each icon button the variant of its surroundings', () => {
+ expectPhoneHitClasses(file, want);
+ });
+ it('ties each sized variant to the size the button draws', () => {
+ expectSizeTie(file);
+ });
+});
diff --git a/src/app/phoneHitTestKit.test.ts b/src/app/phoneHitTestKit.test.ts
new file mode 100644
index 00000000..a77a2e27
--- /dev/null
+++ b/src/app/phoneHitTestKit.test.ts
@@ -0,0 +1,154 @@
+import { describe, expect, it } from 'vitest';
+import { mkdtempSync, rmSync, writeFileSync } from 'fs';
+import { tmpdir } from 'os';
+import { relative } from 'path';
+import {
+ drawnPx,
+ expectPhoneHitClasses,
+ expectSizeTie,
+ iconButtons,
+ phoneHitOf,
+} from './phoneHitTestKit';
+
+describe('phone hit test kit', () => {
+ it('reads {
+ const src =
+ '';
+ expect(iconButtons(src).map((b) => b.tag)).toEqual([
+ '',
+ '',
+ ]);
+ });
+
+ it('derives a drawn size from literal size props, defaults being 400', () => {
+ expect(drawnPx('', '')).toEqual({ px: 24 });
+ expect(drawnPx('', '')).toEqual({ px: 40 });
+ });
+
+ it('reads {
+ expect(
+ drawnPx('', '')
+ ).toEqual({
+ px: 24,
+ });
+ });
+
+ it('calls a size undetermined when props are spread into the IconButton or the Icon', () => {
+ expect(drawnPx('', '')).toHaveProperty('undetermined');
+ expect(drawnPx('', '')).toHaveProperty(
+ 'undetermined'
+ );
+ expect(drawnPx('', '')).toHaveProperty(
+ 'undetermined'
+ );
+ expect(drawnPx('', '')).toHaveProperty('undetermined');
+ });
+
+ it('fails a file with no IconButton, which would otherwise pass vacuously', () => {
+ expect(() => expectSizeTie('styles/PhoneHit.css.ts')).toThrow(/no IconButton/);
+ });
+
+ describe('phoneHitOf reads the imported class off the AST, applied unconditionally', () => {
+ const tag = (expr: string) => ``;
+ const ok = (expr: string, name: string) => expect(phoneHitOf(tag(expr))).toBe(name);
+ const no = (expr: string) => expect(phoneHitOf(tag(expr))).toBeNull();
+
+ it('accepts the bare identifier and merges that always include it', () => {
+ ok('PhoneHitSpread26', 'PhoneHitSpread26');
+ ok('classNames(PhoneHitRoom36, css.other)', 'PhoneHitRoom36');
+ ok('clsx(css.a, { [css.b]: on }, PhoneHit)', 'PhoneHit');
+ ok('cx(PhoneHit)', 'PhoneHit');
+ ok('`${PhoneHit} ${css.other}`', 'PhoneHit');
+ ok('classNames(/* PhoneHit */ PhoneHit)', 'PhoneHit');
+ });
+
+ it('rejects the name as a string, a concatenation or a comment', () => {
+ no("'PhoneHitSpread26'");
+ no('`PhoneHitSpread26`');
+ no('"PhoneHitSpread26" + "x"');
+ no("'PhoneHit' + 'Spread26'");
+ no('/* PhoneHit */ undefined');
+ no('css.a // PhoneHit');
+ });
+
+ it('rejects a conditionally applied name', () => {
+ no('cond ? PhoneHitSpread26 : undefined');
+ no('cond && PhoneHitSpread26');
+ no('cond || PhoneHitSpread26');
+ no('maybe ?? PhoneHitSpread26');
+ no("classNames(cond ? PhoneHit : '', css.a)");
+ no('classNames(PhoneHit, cond && PhoneHit)');
+ });
+
+ it('rejects every other expression shape the name can hide in', () => {
+ no('classNames({ PhoneHit: on })');
+ no('clsx({ [PhoneHit]: on })');
+ no('`${`${PhoneHit}`}`');
+ no('typeof PhoneHit');
+ no("(PhoneHit = 'x')");
+ no('(PhoneHit, css.a)');
+ no('(PhoneHit)');
+ no('() => PhoneHit');
+ no('PhoneHit.foo');
+ no('other.PhoneHit');
+ no('classNames(css.a, [PhoneHit])');
+ no('wrap(PhoneHit)');
+ no('PhoneHitSpread26é');
+ no('`${css.a}`');
+ });
+
+ it('rejects a tag without className, with two, or with a non-expression one', () => {
+ expect(phoneHitOf('')).toBeNull();
+ expect(phoneHitOf('')).toBeNull();
+ expect(phoneHitOf('')).toBeNull();
+ expect(phoneHitOf('')).toBe('PhoneHit');
+ });
+
+ it('rejects a spread after className, which can override it, and accepts one before', () => {
+ expect(phoneHitOf('')).toBeNull();
+ expect(phoneHitOf('')).toBeNull();
+ expect(phoneHitOf('')).toBe('PhoneHit');
+ });
+
+ it('reads a className past braces inside strings', () => {
+ ok("classNames('{', PhoneHit)", 'PhoneHit');
+ });
+ });
+
+ describe('expectPhoneHitClasses', () => {
+ const check = (src: string, want: (string | null)[]) => {
+ const dir = mkdtempSync(`${tmpdir()}/phone-hit-kit-`);
+ try {
+ writeFileSync(`${dir}/Probe.tsx`, src);
+ expectPhoneHitClasses(relative(`${process.cwd()}/src/app`, `${dir}/Probe.tsx`), want);
+ } finally {
+ rmSync(dir, { recursive: true });
+ }
+ };
+ const imp = "import { PhoneHit, PhoneHitSpread26 } from '../../styles/PhoneHit.css';\n";
+ const src =
+ imp +
+ 'const A = () => (<>>);';
+
+ it('pins each button in order, null meaning no class', () => {
+ check(src, ['PhoneHit', 'PhoneHitSpread26', null]);
+ expect(() => check(src, ['PhoneHit', 'PhoneHit', null])).toThrow();
+ expect(() => check(src, ['PhoneHit', 'PhoneHitSpread26', 'PhoneHit'])).toThrow();
+ expect(() => check(src, ['PhoneHit', 'PhoneHitSpread26'])).toThrow();
+ });
+
+ it('fails a name that is not read from PhoneHit.css or is applied conditionally', () => {
+ const local = "const PhoneHit = 'x';\n";
+ expect(() => check(local, ['PhoneHit'])).toThrow();
+ expect(() => check(local, [null])).not.toThrow();
+ const cond = imp + '';
+ expect(() => check(cond, ['PhoneHit'])).toThrow();
+ const aliased =
+ "import { Other as PhoneHit } from '../../styles/PhoneHit.css';\n";
+ expect(() => check(aliased, ['PhoneHit'])).toThrow();
+ const wrongModule =
+ "import { PhoneHit } from './other';\n";
+ expect(() => check(wrongModule, ['PhoneHit'])).toThrow();
+ });
+ });
+});
diff --git a/src/app/phoneHitTestKit.ts b/src/app/phoneHitTestKit.ts
new file mode 100644
index 00000000..d8449cba
--- /dev/null
+++ b/src/app/phoneHitTestKit.ts
@@ -0,0 +1,251 @@
+// Shared helpers for the phone hit-area tests (phoneTapTargets.test.ts and one phoneHit..test.ts per later
+// group of IconButtons). Not a test file: it registers nothing, the test files call it.
+import { readFileSync } from 'fs';
+import { expect } from 'vitest';
+import ts from 'typescript';
+import zlib from 'zlib';
+import { compile, processVanillaFile } from '@vanilla-extract/integration';
+
+// The CSS a .css.ts file emits, read back from vanilla-extract's own compile (as pages/auth/footerBand.test.ts does;
+// vanilla-extract gzips the inlined source only when it is large, so both forms are read).
+const compiled = async (file: string) => {
+ const filePath = `${process.cwd()}/src/app/${file}`;
+ const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' });
+ return processVanillaFile({ source, filePath, identOption: 'debug' });
+};
+
+/** The class list each export of a .css.ts file resolves to (a composed class lists the classes it composes). */
+export const classLists = async (file: string): Promise> =>
+ Object.fromEntries(
+ [...(await compiled(file)).matchAll(/^export var (\w+) = '([^']*)';$/gm)].map((m) => [
+ m[1],
+ m[2].split(' '),
+ ])
+ );
+
+export const emitted = async (file: string) => {
+ const code = await compiled(file);
+ // A file with recipes inlines one source per rule group; read them all.
+ return [...code.matchAll(/source=#?([A-Za-z0-9+/=]+)/g)]
+ .map((m) => {
+ const bytes = Buffer.from(m[1], 'base64');
+ return (bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes).toString();
+ })
+ .join('\n');
+};
+
+export type Decls = Record;
+const parse = (body: string): Decls =>
+ Object.fromEntries(
+ body
+ .split('\n')
+ .map((l) => l.trim().replace(/;$/, ''))
+ .filter((l) => /^[a-z-]+: -?\d+px$/.test(l))
+ .map((l) => [l.split(':')[0], Number(l.split(': ')[1].replace('px', ''))])
+ );
+
+/** Pixel declarations of the rules whose selector matches, inside or outside a media query. */
+export const rulesFor = (css: string, selector: RegExp, media: string | undefined): Decls[] => {
+ const scope = media
+ ? [...css.matchAll(/@media ([^{]+) \{\n([\s\S]*?\n)\}(?:\n|$)/g)]
+ .filter((m) => m[1] === media)
+ .map((m) => m[2])
+ .join('\n')
+ : css.replace(/@media [^{]+ \{\n[\s\S]*?\n\}(?:\n|$)/g, '');
+ return [...scope.matchAll(/^\s*([^{}\n]+) \{\n([^}]*)\}/gm)]
+ .filter((m) => selector.test(m[1]))
+ .map((m) => parse(m[2]));
+};
+
+/** The source of a file under src/app. */
+export const readSource = (file: string) =>
+ readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8');
+
+/** The end of a JSX opening tag starting at `from`: its closing > outside any {} expression. */
+const tagEnd = (src: string, from: number) => {
+ let depth = 0;
+ let j = from;
+ for (; j < src.length; j += 1) {
+ if (src[j] === '{') depth += 1;
+ else if (src[j] === '}') depth -= 1;
+ else if (src[j] === '>' && depth === 0) break;
+ }
+ return j;
+};
+
+const tsx = (src: string) =>
+ ts.createSourceFile('source.tsx', src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
+
+type Opening = ts.JsxOpeningElement | ts.JsxSelfClosingElement;
+
+/** Every IconButton element (not IconButtonX) of a parsed source, in source order. */
+const elements = (sf: ts.SourceFile) => {
+ const found: Opening[] = [];
+ const walk = (n: ts.Node) => {
+ if (
+ (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) &&
+ ts.isIdentifier(n.tagName) &&
+ n.tagName.text === 'IconButton'
+ )
+ found.push(n);
+ ts.forEachChild(n, walk);
+ };
+ walk(sf);
+ return found;
+};
+
+const bodyOf = (el: Opening) =>
+ ts.isJsxOpeningElement(el) && ts.isJsxElement(el.parent)
+ ? el.getSourceFile().text.slice(el.end, el.parent.closingElement.getStart())
+ : '';
+
+/** Every
+ elements(tsx(src)).map((el) => ({ tag: el.getText(), body: bodyOf(el) }));
+
+export const iconButtonTags = (src: string) => iconButtons(src).map((b) => b.tag);
+
+/** Local names the source imports from the PhoneHit.css module (as written, not aliased). */
+const importedHits = (sf: ts.SourceFile) =>
+ new Set(
+ sf.statements.flatMap((st) => {
+ const nb = ts.isImportDeclaration(st) ? st.importClause?.namedBindings : undefined;
+ return nb &&
+ ts.isNamedImports(nb) &&
+ ts.isStringLiteral((st as ts.ImportDeclaration).moduleSpecifier) &&
+ /^(\.\.\/)+styles\/PhoneHit\.css$/.test(
+ ((st as ts.ImportDeclaration).moduleSpecifier as ts.StringLiteral).text
+ )
+ ? nb.elements.filter((e) => !e.propertyName).map((e) => e.name.text)
+ : [];
+ })
+ );
+
+const MERGERS = new Set(['classNames', 'clsx', 'cx']);
+
+/** The PhoneHit* class an IconButton carries, or null. className must appear exactly once, with no props spread
+ * after it (a spread can override it at runtime), and its expression must be: the bare identifier; a classNames /
+ * clsx / cx call with it as a direct argument; or a template with it as a direct span. The identifier must occur
+ * nowhere else in the expression, so a key, a member, a closure, a conditional or a comma can never carry it. When
+ * `imported` is given, the identifier must also be a binding imported from the PhoneHit.css module. */
+const hitOf = (el: Opening, imported?: Set): string | null => {
+ const props = el.attributes.properties;
+ const at = props.flatMap((p, i) =>
+ ts.isJsxAttribute(p) && ts.isIdentifier(p.name) && p.name.text === 'className' ? [i] : []
+ );
+ if (at.length !== 1 || props.slice(at[0] + 1).some(ts.isJsxSpreadAttribute)) return null;
+ const attr = props[at[0]] as ts.JsxAttribute;
+ const expr =
+ attr.initializer && ts.isJsxExpression(attr.initializer) && attr.initializer.expression;
+ if (!expr) return null;
+ const isHit = (n: ts.Node): n is ts.Identifier =>
+ ts.isIdentifier(n) && /^PhoneHit\w*$/.test(n.text) && (!imported || imported.has(n.text));
+ let direct: ts.Node[] = [];
+ if (ts.isIdentifier(expr)) direct = [expr];
+ else if (
+ ts.isCallExpression(expr) &&
+ ts.isIdentifier(expr.expression) &&
+ MERGERS.has(expr.expression.text)
+ )
+ direct = [...expr.arguments];
+ else if (ts.isTemplateExpression(expr)) direct = expr.templateSpans.map((sp) => sp.expression);
+ const hits = direct.filter(isHit);
+ if (hits.length !== 1) return null;
+ let occurrences = 0;
+ const count = (n: ts.Node) => {
+ if (ts.isIdentifier(n) && n.text === hits[0].text) occurrences += 1;
+ ts.forEachChild(n, count);
+ };
+ count(expr);
+ return occurrences === 1 ? hits[0].text : null;
+};
+
+/** The PhoneHit* class of one tag's text (see hitOf), without the import-binding check. */
+export const phoneHitOf = (tag: string) => {
+ const els = elements(tsx(tag.endsWith('/>') ? tag : `${tag}`));
+ return els.length === 1 ? hitOf(els[0]) : null;
+};
+
+/** The PhoneHit* class of each IconButton of a source, in source order; null when it has none or the name is not
+ * the one imported from PhoneHit.css. */
+export const phoneHitClasses = (src: string) => {
+ const sf = tsx(src);
+ const imported = importedHits(sf);
+ return elements(sf).map((el) => hitOf(el, imported));
+};
+
+/** The class each IconButton must carry, in source order (null = must carry none): the variant is chosen by the
+ * button's drawn size and whether a clipping container or neighbours surround it, so a swapped variant is a wrong
+ * hit area. The name must also be the one PhoneHit.css exports, not a local of the same name. */
+export const expectPhoneHitClasses = (file: string, want: (string | null)[]) =>
+ expect(phoneHitClasses(readSource(file))).toEqual(want);
+
+// IconButton `size` -> padding on each side, Icon `size` -> px (folds 2.7.2). Both default to 400.
+const BUTTON_PAD: Record = { '300': 4, '400': 8, '500': 12, '600': 16 };
+const ICON_PX: Record = {
+ '50': 16,
+ '100': 18,
+ '200': 20,
+ '300': 22,
+ '400': 24,
+ '500': 28,
+};
+
+/** The size prop of a tag: its literal value, the default when absent, or undefined when it is an expression. */
+const sizeProp = (tag: string) => {
+ // props spread in may carry a size of their own
+ if (tag.includes('{...')) return undefined;
+ const m = tag.match(/\ssize=(?:"(\w+)"|\{([^}]*)\})/);
+ if (!m) return '400';
+ return m[1] ?? m[2].match(/^'(\w+)'$/)?.[1];
+};
+
+/** The px an IconButton draws, from its size prop and the Icon inside; undefined with a reason when the source
+ * does not say (a variable size, no Icon, Icons of different sizes). */
+export const drawnPx = (tag: string, body: string): { px: number } | { undetermined: string } => {
+ const pad = BUTTON_PAD[sizeProp(tag) ?? ''];
+ if (pad === undefined)
+ return { undetermined: 'size of the IconButton is not a literal (or props are spread in)' };
+ const icons = [...body.matchAll(//])/g)].map((m) => {
+ const t = body.slice(m.index, tagEnd(body, m.index ?? 0) + 1);
+ return ICON_PX[sizeProp(t) ?? ''];
+ });
+ if (icons.length === 0) return { undetermined: 'no inside' };
+ if (icons.some((p) => p === undefined))
+ return { undetermined: 'size of an Icon is not a literal (or props are spread in)' };
+ if (new Set(icons).size > 1) return { undetermined: 'Icons of different sizes inside' };
+ return { px: icons[0] + 2 * pad };
+};
+
+/** Per file, per IconButton index (source order): why its drawn size cannot be derived from the source. */
+export type SizeAllow = Record>;
+
+/** A button carrying PhoneHitSpread or PhoneHitRoom draws N px. Plain PhoneHit has no number to tie. An
+ * undetermined size passes only with an entry in `allow`, and an entry for a size that can be derived fails. */
+export const expectSizeTie = (file: string, allow: SizeAllow = {}) => {
+ const allowed = allow[file] ?? {};
+ const seen = new Set();
+ const src = readSource(file);
+ const buttons = iconButtons(src);
+ const classes = phoneHitClasses(src);
+ expect(buttons.length, `${file} has no IconButton`).toBeGreaterThan(0);
+ buttons.forEach(({ tag, body }, i) => {
+ const variant = classes[i]?.match(/^PhoneHit(?:Spread|Room)(\d+)$/);
+ if (!variant) {
+ expect(allowed[i], `${file} #${i} is allow-listed but has no sized variant`).toBeUndefined();
+ return;
+ }
+ const drawn = drawnPx(tag, body);
+ if ('undetermined' in drawn) {
+ expect(
+ allowed[i],
+ `${file} #${i}: ${drawn.undetermined}; add it to the allow-list`
+ ).toBeDefined();
+ seen.add(i);
+ return;
+ }
+ expect(allowed[i], `${file} #${i} is allow-listed but its size is derivable`).toBeUndefined();
+ expect(Number(variant[1]), `${file} #${i} draws ${drawn.px} px`).toBe(drawn.px);
+ });
+ expect(Object.keys(allowed).map(Number).sort()).toEqual([...seen].sort());
+};
diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts
index 996a7556..192f8414 100644
--- a/src/app/phoneTapTargets.test.ts
+++ b/src/app/phoneTapTargets.test.ts
@@ -1,45 +1,13 @@
import { describe, expect, it } from 'vitest';
-import zlib from 'zlib';
-import { compile, processVanillaFile } from '@vanilla-extract/integration';
import { MOBILE_BREAKPOINT } from './hooks/useScreenSize';
-
-// The CSS a .css.ts file emits, read back from vanilla-extract's own compile (as pages/auth/footerBand.test.ts does;
-// vanilla-extract gzips the inlined source only when it is large, so both forms are read).
-const emitted = async (file: string) => {
- const filePath = `${process.cwd()}/src/app/${file}`;
- const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' });
- const code = await processVanillaFile({ source, filePath, identOption: 'debug' });
- // A file with recipes inlines one source per rule group; read them all.
- return [...code.matchAll(/source=#?([A-Za-z0-9+/=]+)/g)]
- .map((m) => {
- const bytes = Buffer.from(m[1], 'base64');
- return (bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes).toString();
- })
- .join('\n');
-};
-
-type Decls = Record;
-const parse = (body: string): Decls =>
- Object.fromEntries(
- body
- .split('\n')
- .map((l) => l.trim().replace(/;$/, ''))
- .filter((l) => /^[a-z-]+: -?\d+px$/.test(l))
- .map((l) => [l.split(':')[0], Number(l.split(': ')[1].replace('px', ''))])
- );
-
-/** Pixel declarations of the rules whose selector matches, inside or outside a media query. */
-const rulesFor = (css: string, selector: RegExp, media: string | undefined): Decls[] => {
- const scope = media
- ? [...css.matchAll(/@media ([^{]+) \{\n([\s\S]*?\n)\}(?:\n|$)/g)]
- .filter((m) => m[1] === media)
- .map((m) => m[2])
- .join('\n')
- : css.replace(/@media [^{]+ \{\n[\s\S]*?\n\}(?:\n|$)/g, '');
- return [...scope.matchAll(/^\s*([^{}\n]+) \{\n([^}]*)\}/gm)]
- .filter((m) => selector.test(m[1]))
- .map((m) => parse(m[2]));
-};
+import {
+ classLists,
+ emitted,
+ expectPhoneHitClasses,
+ expectSizeTie,
+ rulesFor,
+ type SizeAllow,
+} from './phoneHitTestKit';
const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
const TAP = 44;
@@ -116,3 +84,140 @@ describe('phone navigator rows', () => {
expect(phone.match(/min-height: 2\.75rem/g)?.length).toBeGreaterThanOrEqual(2);
});
});
+
+describe('phone icon-button hit areas', () => {
+ const stripPhone = (css: string) => css.replace(/@media [^{]+ \{\n[\s\S]*?\n\}(?:\n|$)/g, '');
+
+ it('draw a 44 x 44 box around the button on a phone and nothing on any other screen', async () => {
+ const css = await emitted('styles/PhoneHit.css.ts');
+ const phone = [...css.matchAll(/@media ([^{]+) \{\n([\s\S]*?\n)\}(?:\n|$)/g)];
+ expect(phone.map((m) => m[1])).toEqual([PHONE]);
+ expect(css).toMatch(/::after \{[^}]*width: max\(100%, 44px\);[^}]*height: max\(100%, 44px\);/);
+ // no content, no box: the pseudo-element is not rendered at all
+ expect(css).toMatch(/::after \{[^}]*content: "";/);
+ expect(css).toMatch(/::after \{[^}]*position: absolute;/);
+ // centred on the button: top and left at half, pulled back by half its own size
+ expect(css).toMatch(/::after \{[^}]*top: 50%;/);
+ expect(css).toMatch(/::after \{[^}]*left: 50%;/);
+ expect(css).toMatch(/::after \{[^}]*transform: translate\(-50%, -50%\);/);
+ // the box anchors to its own button, receives the tap, and sits above nothing it should not
+ expect(css).toMatch(/@media [^{]+ \{\n\s*\.PhoneHit_PhoneHit__\w+ \{\n\s*position: relative;/);
+ // PhoneHit and PhoneHitInPlace each draw it; the Spread and Room classes compose PhoneHit
+ const afters = [...css.matchAll(/::after \{\n([^}]*)\}/g)];
+ expect(afters).toHaveLength(2);
+ const afterDecls = afters.flatMap((m) =>
+ m[1]
+ .split('\n')
+ .map((l) => l.trim().split(':')[0])
+ .filter(Boolean)
+ );
+ expect([...new Set(afterDecls)].sort()).toEqual([
+ 'content',
+ 'height',
+ 'left',
+ 'position',
+ 'top',
+ 'transform',
+ 'width',
+ ]);
+ // nothing on a phone moves a button off its own anchor: only relative (the button) and absolute (the boxes)
+ expect([...css.matchAll(/position: (\w+);/g)].map((m) => m[1]).sort()).toEqual([
+ 'absolute',
+ 'absolute',
+ 'relative',
+ ]);
+ expect(stripPhone(css).trim()).toBe('');
+ });
+
+ it('open a row up by half the shortfall, so neighbouring hit areas meet and do not overlap', async () => {
+ const css = await emitted('styles/PhoneHit.css.ts');
+ const margins = (selector: RegExp) =>
+ rulesFor(css, selector, PHONE).map((d) => d['margin-inline']);
+ // 32 px composer buttons, 40 px header buttons, 26 px message-option buttons (which also open vertically,
+ // the menu around them clips a hit area drawn outside its box)
+ // each class owns its own margin: (44 - drawn size) / 2
+ const own = (size: number) => margins(new RegExp(`PhoneHit_PhoneHitSpread${size}__`));
+ expect([24, 26, 28, 32, 36, 40].map(own)).toEqual([[10], [9], [8], [6], [4], [2]]);
+ // the Room variants open the block axis by the same amount, on top of their Spread class
+ const room = (size: number) =>
+ rulesFor(css, new RegExp(`PhoneHit_PhoneHitRoom${size}__`), PHONE);
+ expect([room(26), room(36)]).toEqual([[{ 'margin-block': 9 }], [{ 'margin-block': 4 }]]);
+ });
+
+ it('compose a Room variant from the Spread class of the same size, which composes PhoneHit', async () => {
+ const lists = await classLists('styles/PhoneHit.css.ts');
+ const named = (list: string[], name: string) => list.filter((c) => c.includes(`_${name}__`));
+ [26, 36].forEach((size) => {
+ const room = lists[`PhoneHitRoom${size}`];
+ expect(
+ named(room, `PhoneHitSpread${size}`),
+ `Room${size} composes Spread${size}`
+ ).toHaveLength(1);
+ expect(named(room, 'PhoneHit'), `Room${size} composes PhoneHit`).toHaveLength(1);
+ });
+ expect(lists.PhoneHitRoom36.some((c) => c.includes('_PhoneHitSpread26__'))).toBe(false);
+ });
+
+ it("make a message's reaction chip 44 px to tap by opening 9 px above and below it", async () => {
+ const css = await emitted('components/message/Reaction.css.ts');
+ const [chip] = rulesFor(css, /^button\.Reaction_Reaction__\w+$/, PHONE);
+ expect(chip['margin-block']).toBe((TAP - 26) / 2);
+ expect(css).toMatch(/::after \{[^}]*inset: -9px 0;/);
+ rulesFor(css, /^button\.Reaction_Reaction__\w+$/, undefined).forEach((d) =>
+ expect(Object.keys(d)).not.toContain('margin-block')
+ );
+ });
+
+ it('give a sender name a 44 px wide button too, text kept at its start', async () => {
+ const css = await emitted('components/message/layout/layout.css.ts');
+ const [phone] = rulesFor(css, /^button\.layout_Username__/, PHONE);
+ expect(phone['min-width']).toBe(TAP);
+ expect(css).toMatch(/text-align: start;/);
+ });
+});
+
+describe('icon buttons in the room view', () => {
+ // The class each IconButton must carry, in source order: the variant is chosen by the button's drawn size
+ // and whether a clipping container or neighbours surround it, so a swapped variant is a wrong hit area.
+ const expected: Record = {
+ 'features/room/message/Message.tsx': [
+ 'PhoneHitSpread32', // quick-reaction emoji buttons
+ 'PhoneHitSpread32', // delete dialog close
+ 'PhoneHitSpread32', // report dialog close
+ ...Array(6).fill('PhoneHitRoom26'), // message options bar, inside a clipping menu
+ ],
+ 'features/room/RoomInput.tsx': ['PhoneHitSpread24', ...Array(5).fill('PhoneHitSpread32')], // reply cancel is a 16 px icon, the rest 24 px
+ 'features/room/RoomViewHeader.tsx': Array(6).fill('PhoneHitSpread40'),
+ // The chat path's other buttons. Two neighbours in a row: Spread32. One on its own: the plain PhoneHit.
+ 'features/room/message/MessageEditor.tsx': ['PhoneHitSpread32', 'PhoneHitSpread32'], // toolbar, emoji
+ 'features/room-nav/RoomNavItem.tsx': ['PhoneHitSpread24', 'PhoneHitSpread24'], // chat toggle, options: 16 px icon + 8 padding = 24 px, 0 gap
+ 'features/room/MembersDrawer.tsx': ['PhoneHit', 'PhoneHit'], // close, scroll to top
+ 'features/room/reaction-viewer/ReactionViewer.tsx': ['PhoneHit'],
+ 'features/room/room-pin-menu/RoomPinMenu.tsx': ['PhoneHitSpread26', 'PhoneHit'], // unpin (26 px, 8 px from the Open chip), close
+ 'features/room/RoomViewTyping.tsx': ['PhoneHit'],
+ 'features/room/CallChatView.tsx': ['PhoneHit'],
+ 'features/room/jump-to-time/JumpToTime.tsx': ['PhoneHit'],
+ // Buttons that were fixed with #125 and had no test.
+ 'components/password-input/PasswordInput.tsx': ['PhoneHit'],
+ 'features/desk/Desk.tsx': ['PhoneHit'],
+ 'features/scene/SkyStateControl.tsx': ['PhoneHit'],
+ 'pages/auth/login/PasswordLoginForm.tsx': ['PhoneHit'],
+ };
+
+ // IconButtons whose drawn size the source does not say (file -> index in source order -> why). Everything else
+ // is derived from the size props, so a Spread32 on a 24 px button fails.
+ const sizeAllow: SizeAllow = {
+ 'features/room/message/Message.tsx': {
+ 0: 'quick-reaction button wraps an emoji , no Icon; 32 px by its CSS class',
+ },
+ };
+
+ it.each(Object.entries(expected))(
+ '%s: each IconButton carries its own PhoneHit class',
+ (file, want) => expectPhoneHitClasses(file, want)
+ );
+
+ it.each(Object.keys(expected))('%s: each sized variant equals the px its button draws', (file) =>
+ expectSizeTie(file, sizeAllow)
+ );
+});
diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx
index 961131d9..2398a4c9 100644
--- a/src/app/plugins/react-custom-html-parser.tsx
+++ b/src/app/plugins/react-custom-html-parser.tsx
@@ -22,6 +22,7 @@ import Linkify from 'linkify-react';
import { ErrorBoundary } from 'react-error-boundary';
import { ChildNode } from 'domhandler';
import * as css from '../styles/CustomHtml.css';
+import { PhoneHitSpread24 } from '../styles/PhoneHit.css';
import {
getMxIdLocalPart,
getCanonicalAliasRoomId,
@@ -282,6 +283,7 @@ export function CodeBlock({
variant="SurfaceVariant"
outlined
radii="300"
+ className={PhoneHitSpread24}
onClick={toggleExpand}
aria-label={expanded ? 'Collapse' : 'Expand'}
>
diff --git a/src/app/state/room/composerStore.test.ts b/src/app/state/room/composerStore.test.ts
index 92bdcdfd..f5a0f35b 100644
--- a/src/app/state/room/composerStore.test.ts
+++ b/src/app/state/room/composerStore.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { Descendant } from 'slate';
import {
+ PAGE_ID,
clearDraft,
enqueueOutbox,
failOutbox,
@@ -77,7 +78,7 @@ describe('outbox', () => {
const store = memoryStore();
enqueueOutbox('@a:x', entry, 5, store);
const after = loadOutbox('@a:x', store);
- expect(after).toEqual([{ ...entry, status: 'pending', ts: 5 }]);
+ expect(after).toEqual([{ ...entry, status: 'pending', ts: 5, tab: PAGE_ID }]);
removeOutbox('@a:x', 't1', store);
expect(loadOutbox('@a:x', store)).toEqual([]);
expect(store.size()).toBe(0);
@@ -88,11 +89,19 @@ describe('outbox', () => {
enqueueOutbox('@a:x', entry, 5, store);
failOutbox('@a:x', 't1', 'offline', store);
expect(loadOutbox('@a:x', store)[0]).toMatchObject({ status: 'failed', error: 'offline' });
- retryOutbox('@a:x', 't1', store);
+ retryOutbox('@a:x', 't1', 9, 't1', store);
expect(loadOutbox('@a:x', store)[0]).toMatchObject({ status: 'pending', txnId: 't1' });
expect(loadOutbox('@a:x', store)[0].error).toBeUndefined();
});
+ it('a resend can rekey the entry and restarts its clock', () => {
+ const store = memoryStore();
+ enqueueOutbox('@a:x', entry, 5, store);
+ failOutbox('@a:x', 't1', 'offline', store);
+ retryOutbox('@a:x', 't1', 50, 't2', store);
+ expect(loadOutbox('@a:x', store)).toMatchObject([{ txnId: 't2', status: 'pending', ts: 50 }]);
+ });
+
it('re-enqueueing the same txn id does not duplicate it; other accounts are separate', () => {
const store = memoryStore();
enqueueOutbox('@a:x', entry, 5, store);
diff --git a/src/app/state/room/composerStore.ts b/src/app/state/room/composerStore.ts
index 1cbe54aa..91933d72 100644
--- a/src/app/state/room/composerStore.ts
+++ b/src/app/state/room/composerStore.ts
@@ -15,12 +15,18 @@ export type OutboxStatus = 'pending' | 'failed';
export type OutboxEntry = {
txnId: string;
roomId: string;
+ /** Event type; absent means m.room.message. */
+ type?: string;
content: Record;
status: OutboxStatus;
error?: string;
ts: number;
+ /** The page that last sent it: only that page's watchdog may judge it stalled. */
+ tab?: string;
};
+export const PAGE_ID = Math.random().toString(36).slice(2);
+
type Store = Pick;
const defaultStore = (): Store | undefined => {
@@ -90,7 +96,8 @@ export const saveDraft = (
removeKey(store, draftKey(userId, roomId));
return;
}
- if (!writeJson(store, draftKey(userId, roomId), draft)) removeKey(store, draftKey(userId, roomId));
+ if (!writeJson(store, draftKey(userId, roomId), draft))
+ removeKey(store, draftKey(userId, roomId));
};
export const clearDraft = (
@@ -137,7 +144,7 @@ export const enqueueOutbox = (
store: Store | undefined = defaultStore()
): void => {
const rest = loadOutbox(userId, store).filter((e) => e.txnId !== entry.txnId);
- saveOutbox(userId, [...rest, { ...entry, status: 'pending', ts: now }], store);
+ saveOutbox(userId, [...rest, { ...entry, status: 'pending', ts: now, tab: PAGE_ID }], store);
};
export const failOutbox = (
@@ -155,16 +162,23 @@ export const failOutbox = (
);
};
-/** Retry in flight: failed goes back to pending so it is not shown as dead while it is sent. */
+/**
+ * A resend begins: the entry is pending again from `now`, under `newTxnId` when the SDK still
+ * holds the failed attempt's echo under the old one (it refuses to add a second echo for a txn id).
+ */
export const retryOutbox = (
userId: string,
txnId: string,
+ now: number = Date.now(),
+ newTxnId: string = txnId,
store: Store | undefined = defaultStore()
): void => {
saveOutbox(
userId,
loadOutbox(userId, store).map((e) =>
- e.txnId === txnId ? { ...e, status: 'pending', error: undefined } : e
+ e.txnId === txnId
+ ? { ...e, txnId: newTxnId, status: 'pending', error: undefined, ts: now, tab: PAGE_ID }
+ : e
),
store
);
diff --git a/src/app/state/room/outboxSend.test.ts b/src/app/state/room/outboxSend.test.ts
new file mode 100644
index 00000000..8f1db8fd
--- /dev/null
+++ b/src/app/state/room/outboxSend.test.ts
@@ -0,0 +1,132 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { enqueueOutbox, failOutbox, loadOutbox } from './composerStore';
+import { resendInterrupted, resendTracked, sendTracked } from './outboxSend';
+
+const stubLocalStorage = () => {
+ const m = new Map();
+ vi.stubGlobal('localStorage', {
+ getItem: (k: string) => m.get(k) ?? null,
+ setItem: (k: string, v: string) => {
+ m.set(k, v);
+ },
+ removeItem: (k: string) => {
+ m.delete(k);
+ },
+ });
+};
+
+const fakeClient = (send: (...a: unknown[]) => Promise) =>
+ ({ getUserId: () => '@a:x', makeTxnId: () => 'txn-1', sendEvent: send } as never);
+
+describe('sendTracked', () => {
+ beforeEach(stubLocalStorage);
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ const content = { msgtype: 'm.text', body: 'typed before reload' };
+
+ it('holds the message in the outbox while the send is in flight, so a reload keeps it', async () => {
+ let finish!: () => void;
+ const mx = fakeClient(
+ () =>
+ new Promise((r) => {
+ finish = () => r({ event_id: '$e' });
+ })
+ );
+ const done = sendTracked(mx, '!r:x', content);
+ expect(loadOutbox('@a:x')).toMatchObject([{ txnId: 'txn-1', status: 'pending', content }]);
+ finish();
+ await done;
+ expect(loadOutbox('@a:x')).toEqual([]);
+ });
+
+ it('a failed send stays, marked failed, and is not cleared', async () => {
+ const mx = fakeClient(() => Promise.reject(new Error('ECONNRESET')));
+ await sendTracked(mx, '!r:x', content);
+ expect(loadOutbox('@a:x')).toMatchObject([
+ { txnId: 'txn-1', status: 'failed', error: 'ECONNRESET', content },
+ ]);
+ });
+
+ it('sends under the stored txn id, so a resend after an unknown outcome deduplicates', async () => {
+ const send = vi.fn().mockResolvedValue({});
+ await sendTracked(fakeClient(send), '!r:x', content);
+ expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', content, 'txn-1');
+ });
+});
+
+describe('resendTracked', () => {
+ beforeEach(stubLocalStorage);
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+ const entry = {
+ txnId: 'txn-old',
+ roomId: '!r:x',
+ content: { msgtype: 'm.text', body: 'b' },
+ status: 'failed' as const,
+ ts: 1,
+ };
+
+ it('uses a fresh txn id while the SDK still holds the failed echo (it would throw on the old one)', async () => {
+ const send = vi.fn().mockResolvedValue({});
+ const mx = {
+ getUserId: () => '@a:x',
+ makeTxnId: () => 'txn-new',
+ sendEvent: send,
+ getRoom: () => ({ getEventForTxnId: () => ({}) }),
+ } as never;
+ await resendTracked(mx, entry);
+ expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', entry.content, 'txn-new');
+ });
+
+ it('reuses the txn id after a reload, when no echo exists, so the server deduplicates', async () => {
+ const send = vi.fn().mockResolvedValue({});
+ const mx = {
+ getUserId: () => '@a:x',
+ makeTxnId: () => 'txn-new',
+ sendEvent: send,
+ getRoom: () => ({ getEventForTxnId: () => undefined }),
+ } as never;
+ await resendTracked(mx, entry);
+ expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', entry.content, 'txn-old');
+ });
+
+ it('a synchronous throw from the SDK marks the entry failed instead of escaping', async () => {
+ const mx = {
+ getUserId: () => '@a:x',
+ makeTxnId: () => 'txn-new',
+ sendEvent: () => {
+ throw new Error('addPendingEvent called on an event with known txnId');
+ },
+ getRoom: () => undefined,
+ } as never;
+ await sendTracked(mx, '!r:x', { body: 'x' });
+ expect(loadOutbox('@a:x')[0]).toMatchObject({ status: 'failed' });
+ });
+});
+
+describe('resendInterrupted', () => {
+ beforeEach(stubLocalStorage);
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('resends pending entries older than the page under their own txn id, and leaves failed ones', async () => {
+ const send = vi.fn().mockResolvedValue({});
+ const mx = {
+ getUserId: () => '@a:x',
+ makeTxnId: () => 'fresh',
+ sendEvent: send,
+ getRoom: () => undefined,
+ } as never;
+ enqueueOutbox('@a:x', { txnId: 'old', roomId: '!r:x', content: { body: 'o' } }, 5);
+ enqueueOutbox('@a:x', { txnId: 'bad', roomId: '!r:x', content: { body: 'b' } }, 5);
+ failOutbox('@a:x', 'bad', 'nope');
+ enqueueOutbox('@a:x', { txnId: 'new', roomId: '!r:x', content: { body: 'n' } }, 500);
+ await resendInterrupted(mx, 100);
+ expect(send).toHaveBeenCalledTimes(1);
+ expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', { body: 'o' }, 'old');
+ });
+});
diff --git a/src/app/state/room/outboxSend.ts b/src/app/state/room/outboxSend.ts
new file mode 100644
index 00000000..845e64a0
--- /dev/null
+++ b/src/app/state/room/outboxSend.ts
@@ -0,0 +1,73 @@
+import { MatrixClient } from 'matrix-js-sdk';
+import {
+ OutboxEntry,
+ enqueueOutbox,
+ failOutbox,
+ loadOutbox,
+ removeOutbox,
+ retryOutbox,
+} from './composerStore';
+
+/** Entries still 'pending' that predate this page load lost their promise in a reload: outcome unknown. */
+export const PAGE_START = Date.now();
+
+const attempt = async (mx: MatrixClient, userId: string, entry: OutboxEntry): Promise => {
+ try {
+ await mx.sendEvent(
+ entry.roomId,
+ (entry.type ?? 'm.room.message') as never,
+ entry.content as never,
+ entry.txnId
+ );
+ removeOutbox(userId, entry.txnId);
+ } catch (e) {
+ // sendEvent can also throw synchronously (e.g. a txn id the SDK already holds)
+ failOutbox(userId, entry.txnId, e instanceof Error ? e.message : String(e));
+ }
+};
+
+/**
+ * Send, keeping the message in the durable outbox until the server confirms it. The timeline
+ * hides the SDK's local echo until then, so this is the only place an unconfirmed send shows.
+ */
+export const sendTracked = (
+ mx: MatrixClient,
+ roomId: string,
+ content: Record,
+ type?: string
+): Promise => {
+ const userId = mx.getUserId() ?? '';
+ const entry = { roomId, content, txnId: mx.makeTxnId(), ...(type ? { type } : {}) };
+ enqueueOutbox(userId, entry);
+ return attempt(mx, userId, { ...entry, status: 'pending', ts: Date.now() });
+};
+
+/**
+ * Resend. An echo still held by the SDK under the old txn id (a failure in this session) makes
+ * it refuse the same id, so that attempt is abandoned under a fresh id. After a reload the echo
+ * is gone and the old id is reused, so a send that did land is deduplicated by the server.
+ */
+export const resendTracked = (mx: MatrixClient, entry: OutboxEntry): Promise => {
+ const userId = mx.getUserId() ?? '';
+ const held = mx.getRoom(entry.roomId)?.getEventForTxnId(entry.txnId);
+ const txnId = held ? mx.makeTxnId() : entry.txnId;
+ retryOutbox(userId, entry.txnId, Date.now(), txnId);
+ return attempt(mx, userId, { ...entry, txnId, status: 'pending', ts: Date.now() });
+};
+
+/**
+ * After a reload, send again every entry the reload interrupted. They have no SDK echo, so each
+ * keeps its txn id and the server deduplicates one that did land. Failed entries are left for
+ * the user: they failed with an answer, these are only unknown.
+ */
+export const resendInterrupted = (
+ mx: MatrixClient,
+ since: number = PAGE_START
+): Promise => {
+ const userId = mx.getUserId() ?? '';
+ return Promise.all(
+ loadOutbox(userId)
+ .filter((e) => e.status === 'pending' && e.ts < since)
+ .map((e) => resendTracked(mx, e))
+ );
+};
diff --git a/src/app/state/room/roomInputDrafts.ts b/src/app/state/room/roomInputDrafts.ts
index 5d8ec8c7..f3bf9e1c 100644
--- a/src/app/state/room/roomInputDrafts.ts
+++ b/src/app/state/room/roomInputDrafts.ts
@@ -20,9 +20,7 @@ export type TUploadItem = {
export type TUploadListAtom = ReturnType>;
-export const roomIdToUploadItemsAtomFamily = atomFamily(
- createListAtom
-);
+export const roomIdToUploadItemsAtomFamily = atomFamily(createListAtom);
export const roomUploadAtomFamily = createUploadAtomFamily();
diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts
new file mode 100644
index 00000000..e971ecb3
--- /dev/null
+++ b/src/app/styles/PhoneHit.css.ts
@@ -0,0 +1,68 @@
+import { style } from '@vanilla-extract/css';
+import { MOBILE_BREAKPOINT } from '../hooks/useScreenSize';
+
+// Phone tap target for an icon button: on a phone the button's hit area is 44 x 44 px centred on the drawn
+// button (a ::after the button's own click reaches), so nothing drawn changes size. A button with neighbours
+// 44 px closer than that would take their taps, so the `Spread` classes also give it horizontal margin of half
+// the shortfall: the row grows by that, on phones only, and the hit areas meet instead of overlapping.
+// Vertical room is cheaper than horizontal on a phone, so only the horizontal axis pays.
+export const PHONE_TAP = 44;
+const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
+
+const hitArea = {
+ '@media': {
+ [PHONE]: {
+ content: '""',
+ position: 'absolute',
+ top: '50%',
+ left: '50%',
+ width: `max(100%, ${PHONE_TAP}px)`,
+ height: `max(100%, ${PHONE_TAP}px)`,
+ transform: 'translate(-50%, -50%)',
+ },
+ },
+} as const;
+
+/** An icon button on its own: the hit area grows, nothing else moves. */
+export const PhoneHit = style({
+ '@media': { [PHONE]: { position: 'relative' } },
+ selectors: { '&::after': hitArea },
+});
+
+/** For a button that is already positioned (absolute or fixed) by its own class: PhoneHit's `position: relative`
+ * would fight that class by stylesheet order, and the positioned button already contains its ::after. */
+export const PhoneHitInPlace = style({ selectors: { '&::after': hitArea } });
+
+const spreads = new Map();
+
+/** An icon button in a row, `visual` px wide: the row opens up so its 44 px hit area meets its neighbours'. */
+const spread = (visual: number) => {
+ const cls = style(
+ [PhoneHit, { '@media': { [PHONE]: { marginInline: (PHONE_TAP - visual) / 2 } } }],
+ `PhoneHitSpread${visual}`
+ );
+ spreads.set(visual, cls);
+ return cls;
+};
+
+export const PhoneHitSpread24 = spread(24);
+export const PhoneHitSpread26 = spread(26);
+export const PhoneHitSpread28 = spread(28);
+export const PhoneHitSpread32 = spread(32);
+export const PhoneHitSpread36 = spread(36);
+export const PhoneHitSpread40 = spread(40);
+
+/** For a button in a clipping container (a Menu's overflow hidden eats a hit area drawn outside the box): the row
+ * opens up on both axes, so the 44 px sits inside the container. It is the Spread class of the same size plus the
+ * block margin, so the two axes cannot disagree about the size. */
+const room = (visual: number) => {
+ const spreadClass = spreads.get(visual);
+ if (!spreadClass) throw new Error(`PhoneHitRoom${visual} needs a PhoneHitSpread${visual}`);
+ return style(
+ [spreadClass, { '@media': { [PHONE]: { marginBlock: (PHONE_TAP - visual) / 2 } } }],
+ `PhoneHitRoom${visual}`
+ );
+};
+
+export const PhoneHitRoom26 = room(26);
+export const PhoneHitRoom36 = room(36);
diff --git a/src/client/initMatrix.test.ts b/src/client/initMatrix.test.ts
new file mode 100644
index 00000000..95a8d966
--- /dev/null
+++ b/src/client/initMatrix.test.ts
@@ -0,0 +1,64 @@
+import { describe, expect, it, vi } from 'vitest';
+import { PAGE_ID, enqueueOutbox, failOutbox } from '../app/state/room/composerStore';
+import { RECOVER_RELOAD_MIN_GAP_MS, ownPendingAges, reloadToRecoverSync } from './initMatrix';
+
+const memory = () => {
+ const m = new Map();
+ return {
+ getItem: (k: string) => m.get(k) ?? null,
+ setItem: (k: string, v: string) => {
+ m.set(k, v);
+ },
+ };
+};
+
+describe('reloadToRecoverSync', () => {
+ it('reloads once, then refuses inside the gap so a down server cannot loop it', () => {
+ const store = memory();
+ const reload = vi.fn();
+ expect(reloadToRecoverSync(1_000_000, () => store, reload)).toBe(true);
+ expect(
+ reloadToRecoverSync(1_000_000 + RECOVER_RELOAD_MIN_GAP_MS - 1, () => store, reload)
+ ).toBe(false);
+ expect(reloadToRecoverSync(1_000_000 + RECOVER_RELOAD_MIN_GAP_MS, () => store, reload)).toBe(
+ true
+ );
+ expect(reload).toHaveBeenCalledTimes(2);
+ });
+});
+
+describe('reloadToRecoverSync with unusable storage', () => {
+ it('refuses instead of throwing when sessionStorage throws', () => {
+ const reload = vi.fn();
+ const denied = () => {
+ throw new Error('SecurityError');
+ };
+ expect(reloadToRecoverSync(1, denied, reload)).toBe(false);
+ expect(reload).not.toHaveBeenCalled();
+ });
+});
+
+describe('ownPendingAges', () => {
+ it("counts only this page's pending sends, not other pages' or failed ones", () => {
+ const m = new Map();
+ vi.stubGlobal('localStorage', {
+ getItem: (k: string) => m.get(k) ?? null,
+ setItem: (k: string, v: string) => {
+ m.set(k, v);
+ },
+ removeItem: (k: string) => {
+ m.delete(k);
+ },
+ });
+ const e = (txnId: string) => ({ txnId, roomId: '!r:x', content: {} });
+ enqueueOutbox('@a:x', e('mine'), 1000);
+ enqueueOutbox('@a:x', e('failed'), 1000);
+ failOutbox('@a:x', 'failed', 'x');
+ enqueueOutbox('@a:x', e('theirs'), 1000);
+ const entries = JSON.parse(m.get('ratatoskr.outbox.@a:x') as string);
+ entries.find((x: { txnId: string }) => x.txnId === 'theirs').tab = `${PAGE_ID}-other`;
+ m.set('ratatoskr.outbox.@a:x', JSON.stringify(entries));
+ expect(ownPendingAges('@a:x', 1500)).toEqual([500]);
+ vi.unstubAllGlobals();
+ });
+});
diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts
index 871a7625..871c186a 100644
--- a/src/client/initMatrix.ts
+++ b/src/client/initMatrix.ts
@@ -1,9 +1,19 @@
-import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from 'matrix-js-sdk';
+import {
+ ClientEvent,
+ createClient,
+ MatrixClient,
+ IndexedDBStore,
+ IndexedDBCryptoStore,
+ SyncState,
+} from 'matrix-js-sdk';
import { cryptoCallbacks } from './secretStorageKeys';
import { clearNavToActivePathStore } from '../app/state/navToActivePath';
import { pushSessionToSW } from '../sw-session';
import { startResumeSync } from './resumeSync';
+import { startWatchdog } from './watchdog';
+import { PAGE_ID, loadOutbox } from '../app/state/room/composerStore';
+import { resendInterrupted } from '../app/state/room/outboxSend';
type Session = {
baseUrl: string;
@@ -46,11 +56,54 @@ const startSync = (mx: MatrixClient) =>
lazyLoadMembers: true,
});
-export const startClient = async (mx: MatrixClient) => {
- await startSync(mx);
- startResumeSync(mx, { restart: startSync });
+const RECOVER_RELOAD_KEY = 'ratatoskr.recoverReloadAt';
+export const RECOVER_RELOAD_MIN_GAP_MS = 60_000;
+
+/** Reload to bring a dead connection back, at most once a minute, so a server that is down cannot loop us. */
+export const reloadToRecoverSync = (
+ now: number = Date.now(),
+ getStore: () => Pick = () => sessionStorage,
+ reload: () => void = () => window.location.reload()
+): boolean => {
+ try {
+ const store = getStore();
+ const last = Number(store.getItem(RECOVER_RELOAD_KEY) ?? 0);
+ if (now - last < RECOVER_RELOAD_MIN_GAP_MS) return false;
+ store.setItem(RECOVER_RELOAD_KEY, String(now));
+ } catch {
+ return false;
+ }
+ reload();
+ return true;
};
+/** Ages in ms of the sends this page made that are still pending: the only ones it may judge stalled. */
+export const ownPendingAges = (userId: string, now: number = Date.now()): number[] =>
+ loadOutbox(userId)
+ .filter((e) => e.status === 'pending' && e.tab === PAGE_ID)
+ .map((e) => now - e.ts);
+
+const started = new WeakSet();
+
+export const startClient = async (mx: MatrixClient) => {
+ await startSync(mx);
+ if (started.has(mx)) return;
+ started.add(mx);
+ startResumeSync(mx);
+ startWatchdog({
+ pendingAges: () => ownPendingAges(mx.getUserId() ?? ''),
+ isVisible: () => document.visibilityState === 'visible',
+ isOnline: () => navigator.onLine,
+ recover: () => reloadToRecoverSync(),
+ });
+ // What a reload interrupted is sent again once sync is up.
+ const onSync = (state: SyncState) => {
+ if (state !== SyncState.Prepared && state !== SyncState.Syncing) return;
+ mx.off(ClientEvent.Sync, onSync);
+ resendInterrupted(mx);
+ };
+ mx.on(ClientEvent.Sync, onSync);
+};
export const clearCacheAndReload = async (mx: MatrixClient) => {
mx.stopClient();
clearNavToActivePathStore(mx.getSafeUserId());
diff --git a/src/client/resumeSync.test.ts b/src/client/resumeSync.test.ts
index eada97af..e90ac6a8 100644
--- a/src/client/resumeSync.test.ts
+++ b/src/client/resumeSync.test.ts
@@ -1,25 +1,15 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { HEALTH_CHECK_MS, MIN_HIDDEN_MS, RESUME_DEBOUNCE_MS, startResumeSync } from './resumeSync';
+import { MIN_HIDDEN_MS, RESUME_DEBOUNCE_MS, startResumeSync } from './resumeSync';
class Target extends EventTarget {
visibilityState: 'visible' | 'hidden' = 'visible';
}
-const setup = (state: string | null = 'SYNCING') => {
+const setup = () => {
const doc = new Target();
const win = new Target();
- const mx = {
- state,
- getSyncState: () => mx.state,
- retryImmediately: vi.fn(() => true),
- stopClient: vi.fn(),
- };
- const restart = vi.fn(async () => {});
- const stop = startResumeSync(mx, {
- restart,
- doc: doc as never,
- win: win as never,
- });
+ const mx = { retryImmediately: vi.fn(() => true) };
+ const stop = startResumeSync(mx, { doc: doc as never, win: win as never });
const hide = () => {
doc.visibilityState = 'hidden';
doc.dispatchEvent(new Event('visibilitychange'));
@@ -28,7 +18,7 @@ const setup = (state: string | null = 'SYNCING') => {
doc.visibilityState = 'visible';
doc.dispatchEvent(new Event('visibilitychange'));
};
- return { doc, win, mx, restart, stop, hide, show };
+ return { doc, win, mx, stop, hide, show };
};
const settle = () => vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS);
@@ -56,7 +46,7 @@ describe('startResumeSync', () => {
t.hide();
await vi.advanceTimersByTimeAsync(MIN_HIDDEN_MS - 1);
t.show();
- await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS);
+ await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS);
expect(t.mx.retryImmediately).not.toHaveBeenCalled();
});
@@ -68,7 +58,6 @@ describe('startResumeSync', () => {
t.win.dispatchEvent(Object.assign(new Event('pageshow'), { persisted: true }));
await settle();
expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1);
- await vi.advanceTimersByTimeAsync(HEALTH_CHECK_MS);
t.win.dispatchEvent(new Event('online'));
await settle();
expect(t.mx.retryImmediately).toHaveBeenCalledTimes(2);
@@ -83,50 +72,14 @@ describe('startResumeSync', () => {
expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1);
});
- it('does not restart when sync is healthy after the check window', async () => {
- for (const state of ['PREPARED', 'SYNCING', 'CATCHUP']) {
- const t = setup(state);
- t.win.dispatchEvent(new Event('online'));
- await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS);
- expect(t.restart).not.toHaveBeenCalled();
- t.stop();
- }
- });
-
- it('does one stop/start when sync is still unhealthy, never overlapping', async () => {
- const t = setup('RECONNECTING');
- let release!: () => void;
- t.restart.mockImplementation(
- () =>
- new Promise((r) => {
- release = r;
- })
- );
- t.win.dispatchEvent(new Event('online'));
- await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS);
- expect(t.mx.stopClient).toHaveBeenCalledTimes(1);
- expect(t.restart).toHaveBeenCalledTimes(1);
- // a trigger while the restart is in flight does nothing
- t.win.dispatchEvent(new Event('online'));
- await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS);
- expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1);
- expect(t.restart).toHaveBeenCalledTimes(1);
- release();
- await vi.advanceTimersByTimeAsync(0);
- t.win.dispatchEvent(new Event('online'));
- await settle();
- expect(t.mx.retryImmediately).toHaveBeenCalledTimes(2);
- });
-
it('removes listeners and cancels pending work on dispose', async () => {
- const t = setup('ERROR');
+ const t = setup();
t.win.dispatchEvent(new Event('online'));
- await settle();
t.stop();
- await vi.advanceTimersByTimeAsync(HEALTH_CHECK_MS);
- expect(t.restart).not.toHaveBeenCalled();
+ await settle();
+ expect(t.mx.retryImmediately).not.toHaveBeenCalled();
t.win.dispatchEvent(new Event('online'));
await settle();
- expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1);
+ expect(t.mx.retryImmediately).not.toHaveBeenCalled();
});
});
diff --git a/src/client/resumeSync.ts b/src/client/resumeSync.ts
index 5f81547b..a09f39f8 100644
--- a/src/client/resumeSync.ts
+++ b/src/client/resumeSync.ts
@@ -1,60 +1,38 @@
/** The slice of MatrixClient this module drives; a MatrixClient satisfies it. */
export type SyncHost = {
- getSyncState(): string | null;
retryImmediately(): boolean;
- stopClient(): void;
};
-// matrix-js-sdk SyncState values for a live sync loop.
-const HEALTHY: ReadonlySet = new Set(['PREPARED', 'SYNCING', 'CATCHUP']);
-
export const RESUME_DEBOUNCE_MS = 250;
export const MIN_HIDDEN_MS = 5000;
-export const HEALTH_CHECK_MS = 3000;
-type ResumeSyncOptions = {
+type ResumeSyncOptions = {
/** Event sources and visibility; default to the browser globals. */
doc?: Pick;
win?: Pick;
- /** Restart the client after stopClient; must resolve once startClient has been issued. */
- restart: (mx: Host) => Promise;
};
/**
* A backgrounded tab or PWA leaves the sync long-poll dead until its 30 s timeout.
* On return (visible after a real absence, pageshow from bfcache, online) nudge the
- * poll with retryImmediately; if sync is still unhealthy after HEALTH_CHECK_MS, do one
- * stop/start. Returns a disposer that removes every listener and timer.
+ * poll with retryImmediately. It never stops and restarts the client: stopClient() closes
+ * the rust crypto's OlmMachine for good and startClient() does not reopen it. A connection
+ * that stays dead is the watchdog's business. Returns a disposer that removes every
+ * listener and timer.
*/
-export const startResumeSync = (
- mx: Host,
- { restart, doc = document, win = window }: ResumeSyncOptions
+export const startResumeSync = (
+ mx: SyncHost,
+ { doc = document, win = window }: ResumeSyncOptions = {}
): (() => void) => {
let hiddenAt: number | undefined;
let debounce: ReturnType | undefined;
- let healthCheck: ReturnType | undefined;
- let restarting = false;
let disposed = false;
- const check = async () => {
- healthCheck = undefined;
- if (disposed || restarting || HEALTHY.has(mx.getSyncState())) return;
- restarting = true;
- try {
- mx.stopClient();
- await restart(mx);
- } finally {
- restarting = false;
- }
- };
-
const resume = () => {
if (debounce !== undefined) clearTimeout(debounce);
debounce = setTimeout(() => {
debounce = undefined;
- if (disposed || restarting || healthCheck !== undefined) return;
- mx.retryImmediately();
- healthCheck = setTimeout(check, HEALTH_CHECK_MS);
+ if (!disposed) mx.retryImmediately();
}, RESUME_DEBOUNCE_MS);
};
@@ -78,7 +56,6 @@ export const startResumeSync = (
return () => {
disposed = true;
if (debounce !== undefined) clearTimeout(debounce);
- if (healthCheck !== undefined) clearTimeout(healthCheck);
doc.removeEventListener('visibilitychange', onVisibility);
win.removeEventListener('pageshow', onPageShow);
win.removeEventListener('online', resume);
diff --git a/src/client/stallRecovery.test.ts b/src/client/stallRecovery.test.ts
new file mode 100644
index 00000000..ae1e5217
--- /dev/null
+++ b/src/client/stallRecovery.test.ts
@@ -0,0 +1,148 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { PAGE_ID, loadOutbox } from '../app/state/room/composerStore';
+import { resendInterrupted, sendTracked } from '../app/state/room/outboxSend';
+import { RECOVER_RELOAD_MIN_GAP_MS, ownPendingAges, reloadToRecoverSync } from './initMatrix';
+import { SEND_STALL_MS, WATCHDOG_INTERVAL_MS, startWatchdog } from './watchdog';
+
+// The dead-connection story from the user's side: a send hangs, the page notices and reloads
+// itself once, and the message goes out again from the outbox under its own txn id.
+const memoryStore = (m = new Map()) => ({
+ getItem: (k: string) => m.get(k) ?? null,
+ setItem: (k: string, v: string) => {
+ m.set(k, v);
+ },
+ removeItem: (k: string) => {
+ m.delete(k);
+ },
+});
+
+const OUTBOX_KEY = 'ratatoskr.outbox.@a:x';
+
+describe('a send that hangs on a dead connection', () => {
+ let storage: Map;
+ beforeEach(() => {
+ vi.useFakeTimers();
+ storage = new Map();
+ vi.stubGlobal('localStorage', memoryStore(storage));
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ });
+
+ // Every send gets its own txn id, as the SDK's makeTxnId gives; a client that hands out one
+ // constant id could not tell a resend of the right message from a resend of the wrong one.
+ const hungClient = () => {
+ let n = 0;
+ return {
+ getUserId: () => '@a:x',
+ makeTxnId: () => {
+ n += 1;
+ return `txn-${n}`;
+ },
+ sendEvent: vi.fn(
+ () =>
+ new Promise(() => {
+ // never settles: the request is hung
+ })
+ ),
+ getRoom: () => undefined,
+ };
+ };
+ const watch = (env: { visible: boolean; online: boolean }) => {
+ const reload = vi.fn();
+ const session = memoryStore();
+ const stop = startWatchdog({
+ pendingAges: () => ownPendingAges('@a:x'),
+ isVisible: () => env.visible,
+ isOnline: () => env.online,
+ recover: () => reloadToRecoverSync(Date.now(), () => session, reload),
+ });
+ return { reload, stop };
+ };
+ const stalled = () => vi.advanceTimersByTime(SEND_STALL_MS + 2 * WATCHDOG_INTERVAL_MS);
+
+ it('is kept in the outbox, reloads the page once after the stall, and each message is resent under its own txn id', async () => {
+ const mx = hungClient();
+ const first = { msgtype: 'm.text', body: 'hello from a dead connection' };
+ const second = { msgtype: 'm.text', body: 'and a second one' };
+ const { reload, stop } = watch({ visible: true, online: true });
+
+ sendTracked(mx as never, '!r:x', first);
+ sendTracked(mx as never, '!q:x', second);
+ expect(loadOutbox('@a:x')).toMatchObject([
+ { txnId: 'txn-1', status: 'pending' },
+ { txnId: 'txn-2', status: 'pending' },
+ ]);
+
+ vi.advanceTimersByTime(SEND_STALL_MS - WATCHDOG_INTERVAL_MS);
+ expect(reload).not.toHaveBeenCalled();
+ vi.advanceTimersByTime(2 * WATCHDOG_INTERVAL_MS);
+ expect(reload).toHaveBeenCalledTimes(1);
+ // the stall persists across the reload guard window: no reload loop
+ vi.advanceTimersByTime(RECOVER_RELOAD_MIN_GAP_MS - 2 * WATCHDOG_INTERVAL_MS);
+ expect(reload).toHaveBeenCalledTimes(1);
+ stop();
+
+ // the reloaded page: the entries are older than the page, the network works again
+ const send = vi.fn().mockResolvedValue({ event_id: '$e' });
+ const reloaded = { ...mx, sendEvent: send };
+ await resendInterrupted(reloaded as never, Date.now() + 1);
+ expect(send).toHaveBeenCalledTimes(2);
+ expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', first, 'txn-1');
+ expect(send).toHaveBeenCalledWith('!q:x', 'm.room.message', second, 'txn-2');
+ expect(loadOutbox('@a:x')).toEqual([]);
+ });
+
+ it('resends under a fresh txn id when the SDK still holds an echo for the old one', async () => {
+ const mx = hungClient();
+ const content = { msgtype: 'm.text', body: 'echo still held' };
+ sendTracked(mx as never, '!r:x', content);
+ expect(loadOutbox('@a:x')).toMatchObject([{ txnId: 'txn-1' }]);
+
+ const send = vi.fn().mockResolvedValue({ event_id: '$e' });
+ const held = {
+ ...mx,
+ makeTxnId: () => 'txn-fresh',
+ sendEvent: send,
+ getRoom: () => ({ getEventForTxnId: (id: string) => (id === 'txn-1' ? {} : undefined) }),
+ };
+ await resendInterrupted(held as never, Date.now() + 1);
+ expect(send).toHaveBeenCalledTimes(1);
+ expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', content, 'txn-fresh');
+ expect(loadOutbox('@a:x')).toEqual([]);
+ });
+
+ it.each([
+ ['hidden', { visible: false, online: true }],
+ ['offline', { visible: true, online: false }],
+ ])('does not reload a stalled page while it is %s, and does once it is back', (_name, env) => {
+ const mx = hungClient();
+ const state = { ...env };
+ const { reload, stop } = watch(state);
+ sendTracked(mx as never, '!r:x', { body: 'x' });
+ stalled();
+ vi.advanceTimersByTime(10 * SEND_STALL_MS);
+ expect(reload).not.toHaveBeenCalled();
+ state.visible = true;
+ state.online = true;
+ vi.advanceTimersByTime(2 * WATCHDOG_INTERVAL_MS);
+ expect(reload).toHaveBeenCalledTimes(1);
+ stop();
+ });
+
+ it('does not reload for a send that belongs to another tab, or one that already failed', () => {
+ const mx = hungClient();
+ const { reload, stop } = watch({ visible: true, online: true });
+ sendTracked(mx as never, '!r:x', { body: 'theirs' });
+ sendTracked(mx as never, '!r:x', { body: 'failed' });
+ const entries = JSON.parse(storage.get(OUTBOX_KEY) as string);
+ entries[0].tab = `${PAGE_ID}-other`;
+ entries[1].status = 'failed';
+ storage.set(OUTBOX_KEY, JSON.stringify(entries));
+ stalled();
+ vi.advanceTimersByTime(10 * SEND_STALL_MS);
+ expect(reload).not.toHaveBeenCalled();
+ stop();
+ });
+});
diff --git a/src/client/startClient.test.ts b/src/client/startClient.test.ts
new file mode 100644
index 00000000..11b488e3
--- /dev/null
+++ b/src/client/startClient.test.ts
@@ -0,0 +1,115 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { enqueueOutbox } from '../app/state/room/composerStore';
+import { PAGE_START } from '../app/state/room/outboxSend';
+import { startClient } from './initMatrix';
+import { startResumeSync } from './resumeSync';
+import { startWatchdog } from './watchdog';
+
+vi.mock('./resumeSync', () => ({ startResumeSync: vi.fn() }));
+vi.mock('./watchdog', async (importOriginal) => ({
+ ...(await importOriginal()),
+ startWatchdog: vi.fn(() => () => undefined),
+}));
+
+const memoryStore = (m = new Map()) => ({
+ getItem: (k: string) => m.get(k) ?? null,
+ setItem: (k: string, v: string) => {
+ m.set(k, v);
+ },
+ removeItem: (k: string) => {
+ m.delete(k);
+ },
+});
+
+// startClient is the one place the dead-connection pieces are wired to the page: what the watchdog reads,
+// when it may fire, what it does, and what runs after the reload. These tests call it and exercise that wiring.
+describe('startClient wiring', () => {
+ let handlers: Array<(state: string) => void>;
+ let mx: Record & {
+ sendEvent: ReturnType;
+ off: ReturnType;
+ };
+ beforeEach(() => {
+ vi.clearAllMocks();
+ handlers = [];
+ vi.stubGlobal('localStorage', memoryStore());
+ mx = {
+ startClient: vi.fn().mockResolvedValue(undefined),
+ getUserId: () => '@a:x',
+ makeTxnId: () => 'txn-new',
+ getRoom: () => undefined,
+ sendEvent: vi.fn().mockResolvedValue({ event_id: '$e' }),
+ on: (_ev: string, h: (state: string) => void) => {
+ handlers.push(h);
+ },
+ off: vi.fn(),
+ };
+ });
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ const watchdogOptions = () =>
+ (startWatchdog as unknown as ReturnType).mock.calls[0][0] as Parameters<
+ typeof startWatchdog
+ >[0];
+
+ it('starts sync, the resume nudge and the watchdog once per client', async () => {
+ await startClient(mx as never);
+ await startClient(mx as never);
+ expect(mx.startClient).toHaveBeenCalledTimes(2);
+ expect(startResumeSync).toHaveBeenCalledTimes(1);
+ expect(startResumeSync).toHaveBeenCalledWith(mx);
+ expect(startWatchdog).toHaveBeenCalledTimes(1);
+ });
+
+ it("feeds the watchdog this page's pending sends for this user", async () => {
+ await startClient(mx as never);
+ const { pendingAges } = watchdogOptions();
+ expect(pendingAges()).toEqual([]);
+ enqueueOutbox('@a:x', { txnId: 't', roomId: '!r:x', content: {} }, Date.now() - 7000);
+ enqueueOutbox('@b:x', { txnId: 'u', roomId: '!r:x', content: {} }, Date.now() - 1000);
+ const ages = pendingAges();
+ expect(ages).toHaveLength(1);
+ expect(ages[0]).toBeGreaterThanOrEqual(7000);
+ });
+
+ it('reads visibility and connectivity from the page', async () => {
+ const doc = { visibilityState: 'visible' };
+ const nav = { onLine: true };
+ vi.stubGlobal('document', doc);
+ vi.stubGlobal('navigator', nav);
+ await startClient(mx as never);
+ const { isVisible, isOnline } = watchdogOptions();
+ expect([isVisible(), isOnline()]).toEqual([true, true]);
+ doc.visibilityState = 'hidden';
+ nav.onLine = false;
+ expect([isVisible(), isOnline()]).toEqual([false, false]);
+ });
+
+ it('recovers by a guarded reload, at most once a minute', async () => {
+ const reload = vi.fn();
+ vi.stubGlobal('window', { location: { reload } });
+ vi.stubGlobal('sessionStorage', memoryStore());
+ await startClient(mx as never);
+ const { recover } = watchdogOptions();
+ recover();
+ recover();
+ expect(reload).toHaveBeenCalledTimes(1);
+ });
+
+ it('resends what a reload interrupted once sync is up, and only once', async () => {
+ enqueueOutbox(
+ '@a:x',
+ { txnId: 'old', roomId: '!r:x', content: { body: 'x' } },
+ PAGE_START - 5000
+ );
+ await startClient(mx as never);
+ expect(handlers).toHaveLength(1);
+ handlers[0]('CONNECTING');
+ expect(mx.sendEvent).not.toHaveBeenCalled();
+ handlers[0]('SYNCING');
+ expect(mx.off).toHaveBeenCalledWith('sync', handlers[0]);
+ expect(mx.sendEvent).toHaveBeenCalledWith('!r:x', 'm.room.message', { body: 'x' }, 'old');
+ });
+});
diff --git a/src/client/watchdog.test.ts b/src/client/watchdog.test.ts
new file mode 100644
index 00000000..7d80f099
--- /dev/null
+++ b/src/client/watchdog.test.ts
@@ -0,0 +1,60 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { SEND_STALL_MS, WATCHDOG_INTERVAL_MS, startWatchdog } from './watchdog';
+
+const setup = (over: Partial[0]> = {}) => {
+ const recover = vi.fn();
+ const stop = startWatchdog({
+ pendingAges: () => [],
+ isVisible: () => true,
+ isOnline: () => true,
+ recover,
+ ...over,
+ });
+ return { recover, stop };
+};
+const tick = (n: number) => vi.advanceTimersByTime(WATCHDOG_INTERVAL_MS * n);
+
+describe('watchdog', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('stays quiet while nothing is pending', () => {
+ const t = setup();
+ tick(100);
+ expect(t.recover).not.toHaveBeenCalled();
+ t.stop();
+ });
+
+ it('recovers when a send has been pending past the stall threshold, not before', () => {
+ let age = 0;
+ const t = setup({ pendingAges: () => [age] });
+ age = SEND_STALL_MS - 1;
+ tick(1);
+ expect(t.recover).not.toHaveBeenCalled();
+ age = SEND_STALL_MS;
+ tick(1);
+ expect(t.recover).toHaveBeenCalledTimes(1);
+ t.stop();
+ });
+
+ it('does nothing while the page is hidden or offline', () => {
+ const hidden = setup({ isVisible: () => false, pendingAges: () => [1e9] });
+ const offline = setup({ isOnline: () => false, pendingAges: () => [1e9] });
+ tick(50);
+ expect(hidden.recover).not.toHaveBeenCalled();
+ expect(offline.recover).not.toHaveBeenCalled();
+ hidden.stop();
+ offline.stop();
+ });
+
+ it('stop() ends it', () => {
+ const t = setup({ pendingAges: () => [1e9] });
+ t.stop();
+ tick(10);
+ expect(t.recover).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/client/watchdog.ts b/src/client/watchdog.ts
new file mode 100644
index 00000000..6b97e8fc
--- /dev/null
+++ b/src/client/watchdog.ts
@@ -0,0 +1,37 @@
+/**
+ * The page can go on looking alive while its connection is dead: sync polls are cut by their
+ * own timeout and js-sdk retries them, but a send has no timeout at all, so on a half-open
+ * connection it hangs SENDING and no request reaches the server. A send pending past
+ * `stallMs` is the sign, and `recover` (a guarded reload) the answer; the outbox then sends
+ * again what the reload interrupted. Sync state is not watched: ERROR is also what offline
+ * looks like, and js-sdk recovers from that itself.
+ * Nothing fires while the page is hidden (a frozen tab is not a dead connection) or offline
+ * (a reload there only blanks the page).
+ */
+export type WatchdogOptions = {
+ /** Ages in ms of this page's sends still pending. */
+ pendingAges: () => number[];
+ isVisible: () => boolean;
+ isOnline: () => boolean;
+ recover: () => void;
+ intervalMs?: number;
+ stallMs?: number;
+};
+
+export const WATCHDOG_INTERVAL_MS = 5000;
+export const SEND_STALL_MS = 20_000;
+
+export const startWatchdog = ({
+ pendingAges,
+ isVisible,
+ isOnline,
+ recover,
+ intervalMs = WATCHDOG_INTERVAL_MS,
+ stallMs = SEND_STALL_MS,
+}: WatchdogOptions): (() => void) => {
+ const id = setInterval(() => {
+ if (!isVisible() || !isOnline()) return;
+ if (pendingAges().some((age) => age >= stallMs)) recover();
+ }, intervalMs);
+ return () => clearInterval(id);
+};
diff --git a/vite.config.js b/vite.config.js
index e9b67550..699c3148 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -10,6 +10,7 @@ import { VitePWA } from 'vite-plugin-pwa';
import fs from 'fs';
import path from 'path';
import buildConfig from './build.config';
+import { buildShaPlugin } from './scripts/buildSha';
const copyFiles = {
targets: [
@@ -104,6 +105,7 @@ export default defineConfig({
}),
viteStaticCopy(copyFiles),
vanillaExtractPlugin(),
+ buildShaPlugin(),
wasm(),
react(),
VitePWA({