diff --git a/docs/gamecult/client-build-install.md b/docs/gamecult/client-build-install.md
index b2505fe1..33d3526f 100644
--- a/docs/gamecult/client-build-install.md
+++ b/docs/gamecult/client-build-install.md
@@ -47,10 +47,6 @@ 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
deleted file mode 100644
index 61a89445..00000000
--- a/scripts/buildSha.js
+++ /dev/null
@@ -1,56 +0,0 @@
-// 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
deleted file mode 100644
index 4a270867..00000000
--- a/scripts/buildSha.test.js
+++ /dev/null
@@ -1,170 +0,0 @@
-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 8c28b935..2dbaf1f1 100644
--- a/src/app/components/AccountDataEditor.tsx
+++ b/src/app/components/AccountDataEditor.tsx
@@ -16,7 +16,6 @@ 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';
@@ -295,7 +294,7 @@ export function AccountDataEditor({
-
+
diff --git a/src/app/components/BackupRestore.tsx b/src/app/components/BackupRestore.tsx
index 57a18674..068f437b 100644
--- a/src/app/components/BackupRestore.tsx
+++ b/src/app/components/BackupRestore.tsx
@@ -30,7 +30,6 @@ import {
} from '../hooks/useKeyBackup';
import { stopPropagation } from '../utils/keyboard';
import { useRestoreBackupOnVerification } from '../hooks/useRestoreBackupOnVerification';
-import { PhoneHit } from '../styles/PhoneHit.css';
type BackupStatusProps = {
enabled: boolean;
@@ -178,7 +177,6 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
)}
Device Verification
-
+
diff --git a/src/app/components/DeviceVerificationSetup.tsx b/src/app/components/DeviceVerificationSetup.tsx
index 68f42221..433fa6a1 100644
--- a/src/app/components/DeviceVerificationSetup.tsx
+++ b/src/app/components/DeviceVerificationSetup.tsx
@@ -25,7 +25,6 @@ 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
@@ -302,7 +301,7 @@ export const DeviceVerificationSetup = forwardRef
Setup Device Verification
-
+
@@ -337,7 +336,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 d0e5f715..9c7fd980 100644
--- a/src/app/components/Pdf-viewer/PdfViewer.tsx
+++ b/src/app/components/Pdf-viewer/PdfViewer.tsx
@@ -27,7 +27,6 @@ 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;
@@ -109,7 +108,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
-
+
@@ -118,7 +117,6 @@ 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 49e1d821..a2bdd1b8 100644
--- a/src/app/components/UIAFlowOverlay.tsx
+++ b/src/app/components/UIAFlowOverlay.tsx
@@ -13,7 +13,6 @@ import {
IconButton,
} from 'folds';
import FocusTrap from 'focus-trap-react';
-import { PhoneHit } from '../styles/PhoneHit.css';
export type UIAFlowOverlayProps = {
currentStep: number;
@@ -54,7 +53,6 @@ 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 3721b909..7d701c42 100644
--- a/src/app/components/editor/Toolbar.tsx
+++ b/src/app/components/editor/Toolbar.tsx
@@ -36,7 +36,6 @@ 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 (
@@ -73,7 +72,6 @@ export function MarkButton({ format, icon, tooltip }: MarkButtonProps) {
{(triggerRef) => (
{(triggerRef) => (
{(triggerRef) => (
handleMenuSelect(1)}
size="400"
@@ -182,7 +178,6 @@ export function HeadingBlockButton() {
>
{(triggerRef) => (
handleMenuSelect(2)}
size="400"
@@ -198,7 +193,6 @@ export function HeadingBlockButton() {
>
{(triggerRef) => (
handleMenuSelect(3)}
size="400"
@@ -214,7 +208,6 @@ 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 d2d5f197..de22b483 100644
--- a/src/app/components/emoji-board/components/Sidebar.tsx
+++ b/src/app/components/emoji-board/components/Sidebar.tsx
@@ -14,7 +14,6 @@ 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 (
@@ -69,7 +68,6 @@ function SidebarBtn({
>
{(ref) => (
(
Seen by
-
+
diff --git a/src/app/components/image-editor/ImageEditor.tsx b/src/app/components/image-editor/ImageEditor.tsx
index 0f072bb0..1ec474e8 100644
--- a/src/app/components/image-editor/ImageEditor.tsx
+++ b/src/app/components/image-editor/ImageEditor.tsx
@@ -2,7 +2,6 @@ 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;
@@ -25,7 +24,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 15b1cd08..ab81d503 100644
--- a/src/app/components/image-pack-view/ImagePackView.tsx
+++ b/src/app/components/image-pack-view/ImagePackView.tsx
@@ -1,7 +1,6 @@
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';
@@ -30,7 +29,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 4d2d7694..4956a7b6 100644
--- a/src/app/components/image-viewer/ImageViewer.tsx
+++ b/src/app/components/image-viewer/ImageViewer.tsx
@@ -7,7 +7,6 @@ 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;
@@ -34,7 +33,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
>
-
+
@@ -43,7 +42,6 @@ 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 a503fe13..ddac0576 100644
--- a/src/app/components/invite-user-prompt/InviteUserPrompt.tsx
+++ b/src/app/components/invite-user-prompt/InviteUserPrompt.tsx
@@ -42,7 +42,6 @@ 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,
@@ -174,7 +173,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 ffd5241c..50a89418 100644
--- a/src/app/components/join-address-prompt/JoinAddressPrompt.tsx
+++ b/src/app/components/join-address-prompt/JoinAddressPrompt.tsx
@@ -20,7 +20,6 @@ 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;
@@ -83,7 +82,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 832dd59d..217491e6 100644
--- a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx
+++ b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx
@@ -20,7 +20,6 @@ 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;
@@ -69,7 +68,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 6c75ef99..8709b942 100644
--- a/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx
+++ b/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx
@@ -20,7 +20,6 @@ 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;
@@ -69,7 +68,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 41cef5f4..2ffc9ec4 100644
--- a/src/app/components/message/FileHeader.tsx
+++ b/src/app/components/message/FileHeader.tsx
@@ -2,7 +2,6 @@ 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';
@@ -49,7 +48,6 @@ 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 08003f0e..f020080e 100644
--- a/src/app/components/message/Reaction.css.ts
+++ b/src/app/components/message/Reaction.css.ts
@@ -1,9 +1,6 @@
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();
@@ -28,14 +25,6 @@ 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 94132cea..478486a4 100644
--- a/src/app/components/message/content/AudioContent.tsx
+++ b/src/app/components/message/content/AudioContent.tsx
@@ -3,7 +3,6 @@ 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';
@@ -160,7 +159,6 @@ 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 39a40aa8..3d1a9c83 100644
--- a/src/app/components/message/layout/layout.css.ts
+++ b/src/app/components/message/layout/layout.css.ts
@@ -299,10 +299,7 @@ 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. 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.
+// negative margin takes the growth back out of the layout. Hit area only: no background, border or outline moves.
const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
export const Username = style({
overflow: 'hidden',
@@ -312,7 +309,7 @@ export const Username = style({
'button&': {
cursor: 'pointer',
'@media': {
- [SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11, minWidth: 44, textAlign: 'start' },
+ [SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11 },
},
},
'button&:hover, button&:focus-visible': {
diff --git a/src/app/components/password-input/PasswordInput.tsx b/src/app/components/password-input/PasswordInput.tsx
index 6aa0d4f3..4ffe0d83 100644
--- a/src/app/components/password-input/PasswordInput.tsx
+++ b/src/app/components/password-input/PasswordInput.tsx
@@ -1,7 +1,6 @@
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';
@@ -24,7 +23,6 @@ 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 0d091e7e..52128772 100644
--- a/src/app/components/room-topic-viewer/RoomTopicViewer.tsx
+++ b/src/app/components/room-topic-viewer/RoomTopicViewer.tsx
@@ -4,7 +4,6 @@ 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',
@@ -27,7 +26,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 e9e150b5..ec4ed0a5 100644
--- a/src/app/components/text-viewer/TextViewer.tsx
+++ b/src/app/components/text-viewer/TextViewer.tsx
@@ -5,7 +5,6 @@ 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'));
@@ -54,7 +53,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 a7ca2d06..0164cae6 100644
--- a/src/app/components/uia-stages/PasswordStage.tsx
+++ b/src/app/components/uia-stages/PasswordStage.tsx
@@ -4,7 +4,6 @@ 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,
@@ -46,7 +45,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 20decb6c..e3d6126a 100644
--- a/src/app/components/uia-stages/SSOStage.tsx
+++ b/src/app/components/uia-stages/SSOStage.tsx
@@ -1,7 +1,6 @@
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,
@@ -57,7 +56,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 d10fbaa6..b9bada71 100644
--- a/src/app/components/upload-card/CompactUploadCardRenderer.tsx
+++ b/src/app/components/upload-card/CompactUploadCardRenderer.tsx
@@ -6,7 +6,6 @@ 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;
@@ -64,7 +63,6 @@ export function CompactUploadCardRenderer({
)}
)}
(({ children, ...props }, ref) => {
<>
(({ children, ...props }, ref) => {
<>
Self Demotion
-
+
@@ -121,7 +120,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 a57a6b10..d100096b 100644
--- a/src/app/features/add-existing/AddExisting.tsx
+++ b/src/app/features/add-existing/AddExisting.tsx
@@ -54,7 +54,6 @@ 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,
@@ -202,7 +201,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 d63f8709..3cf38b1f 100644
--- a/src/app/features/call-status/CallControl.tsx
+++ b/src/app/features/call-status/CallControl.tsx
@@ -5,7 +5,6 @@ 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;
@@ -27,7 +26,6 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps
>
{(anchorRef) => (
{(anchorRef) => (
{(anchorRef) => (
{(anchorRef) => (
{(anchorRef) => (
{(anchorRef) => (
{(anchorRef) => (
{(anchorRef) => (
{(anchorRef) => (
void;
@@ -93,7 +92,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 a6e60a7b..f25ba7c3 100644
--- a/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx
+++ b/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx
@@ -22,7 +22,6 @@ 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;
@@ -119,7 +118,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 0f9f4034..0ea96901 100644
--- a/src/app/features/common-settings/developer-tools/StateEventEditor.tsx
+++ b/src/app/features/common-settings/developer-tools/StateEventEditor.tsx
@@ -31,7 +31,6 @@ 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;
@@ -276,7 +275,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 f7e901d5..ad8ffae6 100644
--- a/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx
+++ b/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx
@@ -4,7 +4,6 @@ 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;
@@ -30,7 +29,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 7be8de35..fdbe546e 100644
--- a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx
+++ b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx
@@ -40,7 +40,6 @@ 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[];
@@ -216,7 +215,6 @@ 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 2971d565..45a480aa 100644
--- a/src/app/features/common-settings/general/RoomUpgrade.tsx
+++ b/src/app/features/common-settings/general/RoomUpgrade.tsx
@@ -39,7 +39,6 @@ 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();
@@ -106,7 +105,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 a1dddb54..15cd1d08 100644
--- a/src/app/features/common-settings/members/Members.tsx
+++ b/src/app/features/common-settings/members/Members.tsx
@@ -56,7 +56,6 @@ 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,
@@ -162,7 +161,7 @@ export function Members({ requestClose }: MembersProps) {
-
+
@@ -278,7 +277,6 @@ 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 0684a48a..97305946 100644
--- a/src/app/features/common-settings/permissions/PowersEditor.tsx
+++ b/src/app/features/common-settings/permissions/PowersEditor.tsx
@@ -49,7 +49,6 @@ 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;
@@ -370,7 +369,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 9332f35c..d5d8c92d 100644
--- a/src/app/features/create-room/CreateRoomModal.tsx
+++ b/src/app/features/create-room/CreateRoomModal.tsx
@@ -24,7 +24,6 @@ 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;
@@ -64,7 +63,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
-
+
diff --git a/src/app/features/create-space/CreateSpaceModal.tsx b/src/app/features/create-space/CreateSpaceModal.tsx
index faa53b25..c1bc6898 100644
--- a/src/app/features/create-space/CreateSpaceModal.tsx
+++ b/src/app/features/create-space/CreateSpaceModal.tsx
@@ -23,7 +23,6 @@ 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;
@@ -62,7 +61,7 @@ function CreateSpaceModal({ state }: CreateSpaceModalProps) {
New Space
-
+
diff --git a/src/app/features/desk/Desk.tsx b/src/app/features/desk/Desk.tsx
index a09501e7..f1695716 100644
--- a/src/app/features/desk/Desk.tsx
+++ b/src/app/features/desk/Desk.tsx
@@ -1,7 +1,6 @@
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';
@@ -34,11 +33,7 @@ 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 43576cff..028cd560 100644
--- a/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx
+++ b/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx
@@ -10,7 +10,6 @@ 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({
@@ -39,7 +38,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 cdfa2d8a..1802e3b0 100644
--- a/src/app/features/lobby/HierarchyItemMenu.tsx
+++ b/src/app/features/lobby/HierarchyItemMenu.tsx
@@ -30,7 +30,6 @@ 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;
@@ -232,7 +231,6 @@ 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 ce248fe5..ed526c68 100644
--- a/src/app/features/lobby/LobbyHeader.tsx
+++ b/src/app/features/lobby/LobbyHeader.tsx
@@ -38,7 +38,6 @@ 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;
@@ -166,7 +165,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
{(onBack) => (
-
+
)}
@@ -241,7 +240,6 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
>
{(triggerRef) => (
useMemo(
@@ -194,7 +193,6 @@ 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 8d53ce7f..e8a349f0 100644
--- a/src/app/features/room-nav/RoomNavItem.tsx
+++ b/src/app/features/room-nav/RoomNavItem.tsx
@@ -66,7 +66,6 @@ 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;
@@ -230,7 +229,6 @@ function CallChatToggle() {
return (
setChat(!chat)}
aria-pressed={chat}
aria-label="Toggle Chat"
@@ -438,7 +436,6 @@ 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 e29fe2da..15157304 100644
--- a/src/app/features/room-settings/general/General.tsx
+++ b/src/app/features/room-settings/general/General.tsx
@@ -15,7 +15,6 @@ 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;
@@ -36,7 +35,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 d513dda9..7572a71b 100644
--- a/src/app/features/room-settings/permissions/Permissions.tsx
+++ b/src/app/features/room-settings/permissions/Permissions.tsx
@@ -9,7 +9,6 @@ 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;
@@ -46,7 +45,7 @@ export function Permissions({ requestClose }: PermissionsProps) {
-
+
diff --git a/src/app/features/room/CallChatView.tsx b/src/app/features/room/CallChatView.tsx
index fe40eb50..6c32f4a0 100644
--- a/src/app/features/room/CallChatView.tsx
+++ b/src/app/features/room/CallChatView.tsx
@@ -6,7 +6,6 @@ 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();
@@ -42,12 +41,7 @@ export function CallChatView() {
}
>
{(triggerRef) => (
-
+
)}
diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx
index 3162eff0..d9205076 100644
--- a/src/app/features/room/MembersDrawer.tsx
+++ b/src/app/features/room/MembersDrawer.tsx
@@ -59,7 +59,6 @@ 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;
@@ -88,7 +87,6 @@ function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) {
>
{(triggerRef) => (
setPeopleDrawer(false)}
@@ -361,7 +359,6 @@ 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 2d63cbf1..e92ae575 100644
--- a/src/app/features/room/RoomInput.tsx
+++ b/src/app/features/room/RoomInput.tsx
@@ -28,8 +28,7 @@ import {
config,
toRem,
} from 'folds';
-import { RoomOutbox } from './RoomOutbox';
-import { sendTracked } from '../../state/room/outboxSend';
+import { RoomOutbox, sendTracked } from './RoomOutbox';
import { clearDraft, loadDraft, saveDraft } from '../../state/room/composerStore';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -116,7 +115,6 @@ 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;
@@ -335,7 +333,7 @@ export const RoomInput = forwardRef(
});
handleCancelUpload(uploads);
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
- contents.forEach((content) => sendTracked(mx, roomId, content as Record));
+ contents.forEach((content) => mx.sendMessage(roomId, content as any));
};
const submit = useCallback(() => {
@@ -494,7 +492,11 @@ export const RoomInput = forwardRef(
await getImageUrlBlob(stickerUrl)
);
- sendTracked(mx, roomId, { body: label, url: mxc, info }, EventType.Sticker);
+ mx.sendEvent(roomId, EventType.Sticker, {
+ body: label,
+ url: mxc,
+ info,
+ });
};
return (
@@ -606,7 +608,6 @@ export const RoomInput = forwardRef(
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
setReplyDraft(undefined)}
variant="SurfaceVariant"
size="300"
@@ -640,7 +641,6 @@ export const RoomInput = forwardRef(
}
before={
pickFile('*')}
variant="SurfaceVariant"
size="300"
@@ -652,7 +652,6 @@ export const RoomInput = forwardRef(
after={
<>
(
>
{!hideStickerBtn && (
setEmojiBoardTab(EmojiBoardTab.Sticker)}
variant="SurfaceVariant"
@@ -709,7 +707,6 @@ export const RoomInput = forwardRef(
)}
(
)}
-
+
>
diff --git a/src/app/features/room/RoomOutbox.actions.test.tsx b/src/app/features/room/RoomOutbox.actions.test.tsx
deleted file mode 100644
index 0dbd4f5d..00000000
--- a/src/app/features/room/RoomOutbox.actions.test.tsx
+++ /dev/null
@@ -1,102 +0,0 @@
-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
deleted file mode 100644
index e4c258df..00000000
--- a/src/app/features/room/RoomOutbox.render.test.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-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 30d7c0bb..df22f9d4 100644
--- a/src/app/features/room/RoomOutbox.test.ts
+++ b/src/app/features/room/RoomOutbox.test.ts
@@ -1,28 +1,57 @@
-import { describe, expect, it } from 'vitest';
-import { SLOW_SEND_MS, outboxView } from './RoomOutbox';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { loadOutbox } from '../../state/room/composerStore';
+import { sendTracked } from './RoomOutbox';
-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 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();
});
- 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]);
+ 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('an entry older than this page load reads as interrupted', () => {
- expect(outboxView({ status: 'pending', ts: 5 }, 6, 10).label).toMatch(/Interrupted/);
+ 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');
});
});
diff --git a/src/app/features/room/RoomOutbox.tsx b/src/app/features/room/RoomOutbox.tsx
index 1e77df43..cf164472 100644
--- a/src/app/features/room/RoomOutbox.tsx
+++ b/src/app/features/room/RoomOutbox.tsx
@@ -3,44 +3,32 @@ 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';
-/** A send still unconfirmed after this long is offered Resend: the request may be hung. */
-export const SLOW_SEND_MS = 30_000;
+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))
+ );
-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 };
+/** 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 const useRoomOutbox = (userId: string, roomId: string): OutboxEntry[] => {
@@ -54,28 +42,19 @@ export const useRoomOutbox = (userId: string, roomId: string): OutboxEntry[] =>
return entries;
};
-/** 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;
-};
+/** Entries still 'pending' that predate this page load lost their promise in a reload: outcome unknown. */
+const pageStart = Date.now();
type RoomOutboxProps = { mx: MatrixClient; roomId: string };
/**
- * 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.
+ * 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.
*/
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 (
@@ -85,32 +64,40 @@ export function RoomOutbox({ mx, roomId }: RoomOutboxProps) {
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
{entries.map((entry) => {
- const view = outboxView(entry, now);
+ const failed = entry.status === 'failed';
+ const interrupted = !failed && entry.ts < pageStart;
+ const canResend = failed || interrupted;
return (
- {view.label}
+ {(() => {
+ if (failed) return `Not sent${entry.error ? `: ${entry.error}` : ''}`;
+ return interrupted ? 'Interrupted by a reload: may not have been sent' : 'Sending…';
+ })()}
{String(entry.content.body ?? '')}
- {view.canResend && (
+ {canResend && (
<>
- resendTracked(mx, entry)}>
- Resend
-
- removeOutbox(userId, entry.txnId)}
- >
- Discard
-
+ {
+ retryOutbox(userId, entry.txnId);
+ attempt(mx, userId, entry);
+ }}
+ >
+ Resend
+
+ removeOutbox(userId, entry.txnId)}>
+ Discard
+
>
)}
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index 9bd776f5..e9c13e2a 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -47,7 +47,6 @@ 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';
@@ -390,19 +389,6 @@ 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) => {
@@ -662,18 +648,6 @@ 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,
@@ -1672,7 +1646,6 @@ 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 3132c77a..8089b5ef 100644
--- a/src/app/features/room/RoomViewHeader.tsx
+++ b/src/app/features/room/RoomViewHeader.tsx
@@ -72,7 +72,6 @@ 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;
@@ -337,7 +336,6 @@ function CallButton() {
>
{(triggerRef) => (
{(onBack) => (
-
+
@@ -533,12 +531,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
}
>
{(triggerRef) => (
-
+
)}
@@ -555,7 +548,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
>
{(triggerRef) => (
{(triggerRef) => (
-
+
)}
@@ -644,7 +631,6 @@ 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 4b231856..223c6cf6 100644
--- a/src/app/features/room/jump-to-time/JumpToTime.tsx
+++ b/src/app/features/room/jump-to-time/JumpToTime.tsx
@@ -31,7 +31,6 @@ 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;
@@ -109,7 +108,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
deleted file mode 100644
index fa44ac9b..00000000
--- a/src/app/features/room/localEcho.test.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-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
deleted file mode 100644
index 4f827500..00000000
--- a/src/app/features/room/localEcho.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-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 a1bc17c9..4b3d436f 100644
--- a/src/app/features/room/message/Message.tsx
+++ b/src/app/features/room/message/Message.tsx
@@ -86,7 +86,6 @@ 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;
@@ -112,7 +111,7 @@ export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
{recentEmojis.map((emoji) => (
Delete Message
-
+
@@ -595,12 +589,7 @@ export const MessageReportItem = as<
Report Message
-
+
@@ -961,7 +950,6 @@ export const Message = as<'div', MessageProps>(
}
>
(
)}
(
{!isThreadedMessage && (
onReplyClick(ev, true)}
data-event-id={mEvent.getId()}
variant="SurfaceVariant"
@@ -996,7 +982,6 @@ export const Message = as<'div', MessageProps>(
)}
{canEditEvent(mx, mEvent) && onEditId && (
onEditId(mEvent.getId())}
variant="SurfaceVariant"
size="300"
@@ -1162,7 +1147,6 @@ 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 b1a674d9..6c686bc5 100644
--- a/src/app/features/room/reaction-viewer/ReactionViewer.tsx
+++ b/src/app/features/room/reaction-viewer/ReactionViewer.tsx
@@ -28,7 +28,6 @@ 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;
@@ -101,7 +100,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 25fe835f..9986849f 100644
--- a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx
+++ b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx
@@ -86,7 +86,6 @@ import {
useGetMemberPowerTag,
} from '../../../hooks/useMemberPowerTag';
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
-import { PhoneHit, PhoneHitSpread26 } from '../../../styles/PhoneHit.css';
type PinnedMessageProps = {
room: Room;
@@ -147,7 +146,6 @@ function PinnedMessage({
{canPinEvent && (
(
Pinned Messages
-
+
diff --git a/src/app/features/scene/SkyStateControl.tsx b/src/app/features/scene/SkyStateControl.tsx
index ee973822..a4313a8f 100644
--- a/src/app/features/scene/SkyStateControl.tsx
+++ b/src/app/features/scene/SkyStateControl.tsx
@@ -28,7 +28,6 @@ import {
setSceneStyle,
type ServedSceneStyle,
} from './sceneStyle';
-import { PhoneHit } from '../../styles/PhoneHit.css';
const STYLE_LABEL: Record = { terrain: 'Terrain', ridge: 'Ridges' };
@@ -150,7 +149,6 @@ 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 19407bdf..9ec74305 100644
--- a/src/app/features/settings/about/About.tsx
+++ b/src/app/features/settings/about/About.tsx
@@ -8,7 +8,6 @@ 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;
@@ -26,7 +25,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 77ee8671..c4b56e47 100644
--- a/src/app/features/settings/account/Account.tsx
+++ b/src/app/features/settings/account/Account.tsx
@@ -5,7 +5,6 @@ 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;
@@ -21,7 +20,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 791a407b..98db9459 100644
--- a/src/app/features/settings/account/IgnoredUserList.tsx
+++ b/src/app/features/settings/account/IgnoredUserList.tsx
@@ -8,7 +8,6 @@ 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();
@@ -68,7 +67,6 @@ function IgnoreUserInput({ userList }: { userList: string[] }) {
userId &&
!ignoring && (
Remove Avatar
- setAlertRemove(false)}
- radii="300"
- >
+ setAlertRemove(false)} radii="300">
@@ -279,7 +273,6 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
hasChanges &&
!changingDisplayName && (
void;
@@ -52,7 +51,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 c4912dc0..71b684f5 100644
--- a/src/app/features/settings/devices/DeviceTile.tsx
+++ b/src/app/features/settings/devices/DeviceTile.tsx
@@ -29,7 +29,6 @@ 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 (
@@ -289,7 +288,6 @@ export function DeviceTile({
-
+
diff --git a/src/app/features/settings/devices/Verification.tsx b/src/app/features/settings/devices/Verification.tsx
index c09dff55..6c7eab17 100644
--- a/src/app/features/settings/devices/Verification.tsx
+++ b/src/app/features/settings/devices/Verification.tsx
@@ -35,7 +35,6 @@ 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;
@@ -289,7 +288,6 @@ export function DeviceVerificationOptions() {
return (
<>
void;
@@ -31,7 +30,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 38647b13..a9288728 100644
--- a/src/app/features/settings/emojis-stickers/GlobalPacks.tsx
+++ b/src/app/features/settings/emojis-stickers/GlobalPacks.tsx
@@ -43,7 +43,6 @@ 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,
@@ -369,7 +368,6 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
{removed ? (
) : (
;
@@ -498,7 +497,6 @@ 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 b77cae21..7f84d607 100644
--- a/src/app/features/settings/notifications/KeywordMessages.tsx
+++ b/src/app/features/settings/notifications/KeywordMessages.tsx
@@ -15,7 +15,6 @@ 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,
@@ -76,7 +75,6 @@ function KeywordInput() {
keyword &&
!addingKeyword && (
+
{removing ? : }
);
diff --git a/src/app/features/settings/notifications/Notifications.tsx b/src/app/features/settings/notifications/Notifications.tsx
index 61af1a09..095a9bba 100644
--- a/src/app/features/settings/notifications/Notifications.tsx
+++ b/src/app/features/settings/notifications/Notifications.tsx
@@ -8,7 +8,6 @@ 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;
@@ -24,7 +23,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 1030972f..b5fefc93 100644
--- a/src/app/features/space-settings/SpaceSettings.tsx
+++ b/src/app/features/space-settings/SpaceSettings.tsx
@@ -17,7 +17,6 @@ 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;
@@ -118,7 +117,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 e99a4906..72040632 100644
--- a/src/app/features/space-settings/general/General.tsx
+++ b/src/app/features/space-settings/general/General.tsx
@@ -13,7 +13,6 @@ 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;
@@ -34,7 +33,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 d513dda9..7572a71b 100644
--- a/src/app/features/space-settings/permissions/Permissions.tsx
+++ b/src/app/features/space-settings/permissions/Permissions.tsx
@@ -9,7 +9,6 @@ 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;
@@ -46,7 +45,7 @@ export function Permissions({ requestClose }: PermissionsProps) {
-
+
diff --git a/src/app/pages/auth/ServerPicker.tsx b/src/app/pages/auth/ServerPicker.tsx
index 393cf34e..a2a78106 100644
--- a/src/app/pages/auth/ServerPicker.tsx
+++ b/src/app/pages/auth/ServerPicker.tsx
@@ -23,7 +23,6 @@ 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,
@@ -130,7 +129,6 @@ export function ServerPicker({
}
>
();
@@ -95,7 +94,6 @@ 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 6944c9f7..dae83166 100644
--- a/src/app/pages/client/explore/Explore.tsx
+++ b/src/app/pages/client/explore/Explore.tsx
@@ -37,7 +37,6 @@ 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();
@@ -97,12 +96,7 @@ 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 c28fad0a..f056cbb5 100644
--- a/src/app/pages/client/explore/Featured.tsx
+++ b/src/app/pages/client/explore/Featured.tsx
@@ -18,7 +18,6 @@ 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();
@@ -34,7 +33,7 @@ export function FeaturedRooms() {
{(onBack) => (
-
+
)}
diff --git a/src/app/pages/client/explore/Server.tsx b/src/app/pages/client/explore/Server.tsx
index 036c6488..48f267cc 100644
--- a/src/app/pages/client/explore/Server.tsx
+++ b/src/app/pages/client/explore/Server.tsx
@@ -45,7 +45,6 @@ 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(
@@ -500,7 +499,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 8e277889..fddd75aa 100644
--- a/src/app/pages/client/home/CreateRoom.tsx
+++ b/src/app/pages/client/home/CreateRoom.tsx
@@ -12,7 +12,6 @@ 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();
@@ -26,7 +25,7 @@ export function HomeCreateRoom() {
{(onBack) => (
-
+
)}
diff --git a/src/app/pages/client/home/Search.tsx b/src/app/pages/client/home/Search.tsx
index aa454d2c..d5ddfb77 100644
--- a/src/app/pages/client/home/Search.tsx
+++ b/src/app/pages/client/home/Search.tsx
@@ -5,7 +5,6 @@ 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);
@@ -20,7 +19,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 4d1580af..ca26bac3 100644
--- a/src/app/pages/client/inbox/Invites.tsx
+++ b/src/app/pages/client/inbox/Invites.tsx
@@ -63,7 +63,6 @@ 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;
@@ -746,7 +745,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 8fddba44..9592224f 100644
--- a/src/app/pages/client/inbox/Notifications.tsx
+++ b/src/app/pages/client/inbox/Notifications.tsx
@@ -97,7 +97,6 @@ import {
} from '../../../hooks/useMemberPowerTag';
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
-import { PhoneHit } from '../../../styles/PhoneHit.css';
type RoomNotificationsGroup = {
roomId: string;
@@ -643,7 +642,7 @@ export function Notifications() {
{screenSize === ScreenSize.Mobile && (
{(onBack) => (
-
+
)}
@@ -695,7 +694,6 @@ 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 a2849ab8..017262b5 100644
--- a/src/app/pages/client/space/Search.tsx
+++ b/src/app/pages/client/space/Search.tsx
@@ -11,7 +11,6 @@ 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();
@@ -35,7 +34,7 @@ export function SpaceSearch() {
{screenSize === ScreenSize.Mobile && (
{(onBack) => (
-
+
)}
diff --git a/src/app/phoneHit.call.test.ts b/src/app/phoneHit.call.test.ts
deleted file mode 100644
index 56971b05..00000000
--- a/src/app/phoneHit.call.test.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-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
deleted file mode 100644
index fdb2c4e0..00000000
--- a/src/app/phoneHit.commonSettings.test.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-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
deleted file mode 100644
index 741a5828..00000000
--- a/src/app/phoneHit.content.test.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-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
deleted file mode 100644
index a032d7d8..00000000
--- a/src/app/phoneHit.coverage.test.ts
+++ /dev/null
@@ -1,58 +0,0 @@
-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
deleted file mode 100644
index 8b3416a5..00000000
--- a/src/app/phoneHit.dialogs.test.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-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
deleted file mode 100644
index ef016b59..00000000
--- a/src/app/phoneHit.editor.test.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-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
deleted file mode 100644
index 97eb77b8..00000000
--- a/src/app/phoneHit.pages.test.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-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
deleted file mode 100644
index c98c2b09..00000000
--- a/src/app/phoneHit.roomSpace.test.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-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
deleted file mode 100644
index 37f1f3c2..00000000
--- a/src/app/phoneHit.settingsA.test.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-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
deleted file mode 100644
index 4d2e3dee..00000000
--- a/src/app/phoneHit.settingsB.test.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-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
deleted file mode 100644
index 3e579f4c..00000000
--- a/src/app/phoneHit.viewers.test.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-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
deleted file mode 100644
index a77a2e27..00000000
--- a/src/app/phoneHitTestKit.test.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-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
deleted file mode 100644
index d8449cba..00000000
--- a/src/app/phoneHitTestKit.ts
+++ /dev/null
@@ -1,251 +0,0 @@
-// 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 192f8414..996a7556 100644
--- a/src/app/phoneTapTargets.test.ts
+++ b/src/app/phoneTapTargets.test.ts
@@ -1,13 +1,45 @@
import { describe, expect, it } from 'vitest';
+import zlib from 'zlib';
+import { compile, processVanillaFile } from '@vanilla-extract/integration';
import { MOBILE_BREAKPOINT } from './hooks/useScreenSize';
-import {
- classLists,
- emitted,
- expectPhoneHitClasses,
- expectSizeTie,
- rulesFor,
- type SizeAllow,
-} from './phoneHitTestKit';
+
+// 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]));
+};
const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
const TAP = 44;
@@ -84,140 +116,3 @@ 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 2398a4c9..961131d9 100644
--- a/src/app/plugins/react-custom-html-parser.tsx
+++ b/src/app/plugins/react-custom-html-parser.tsx
@@ -22,7 +22,6 @@ 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,
@@ -283,7 +282,6 @@ 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 f5a0f35b..92bdcdfd 100644
--- a/src/app/state/room/composerStore.test.ts
+++ b/src/app/state/room/composerStore.test.ts
@@ -1,7 +1,6 @@
import { describe, expect, it } from 'vitest';
import { Descendant } from 'slate';
import {
- PAGE_ID,
clearDraft,
enqueueOutbox,
failOutbox,
@@ -78,7 +77,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, tab: PAGE_ID }]);
+ expect(after).toEqual([{ ...entry, status: 'pending', ts: 5 }]);
removeOutbox('@a:x', 't1', store);
expect(loadOutbox('@a:x', store)).toEqual([]);
expect(store.size()).toBe(0);
@@ -89,19 +88,11 @@ 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', 9, 't1', store);
+ retryOutbox('@a:x', '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 91933d72..1cbe54aa 100644
--- a/src/app/state/room/composerStore.ts
+++ b/src/app/state/room/composerStore.ts
@@ -15,18 +15,12 @@ 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 => {
@@ -96,8 +90,7 @@ 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 = (
@@ -144,7 +137,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, tab: PAGE_ID }], store);
+ saveOutbox(userId, [...rest, { ...entry, status: 'pending', ts: now }], store);
};
export const failOutbox = (
@@ -162,23 +155,16 @@ export const failOutbox = (
);
};
-/**
- * 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).
- */
+/** Retry in flight: failed goes back to pending so it is not shown as dead while it is sent. */
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, txnId: newTxnId, status: 'pending', error: undefined, ts: now, tab: PAGE_ID }
- : e
+ e.txnId === txnId ? { ...e, status: 'pending', error: undefined } : e
),
store
);
diff --git a/src/app/state/room/outboxSend.test.ts b/src/app/state/room/outboxSend.test.ts
deleted file mode 100644
index 8f1db8fd..00000000
--- a/src/app/state/room/outboxSend.test.ts
+++ /dev/null
@@ -1,132 +0,0 @@
-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
deleted file mode 100644
index 845e64a0..00000000
--- a/src/app/state/room/outboxSend.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-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 f3bf9e1c..5d8ec8c7 100644
--- a/src/app/state/room/roomInputDrafts.ts
+++ b/src/app/state/room/roomInputDrafts.ts
@@ -20,7 +20,9 @@ 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
deleted file mode 100644
index e971ecb3..00000000
--- a/src/app/styles/PhoneHit.css.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-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
deleted file mode 100644
index 95a8d966..00000000
--- a/src/client/initMatrix.test.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-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 871c186a..871a7625 100644
--- a/src/client/initMatrix.ts
+++ b/src/client/initMatrix.ts
@@ -1,19 +1,9 @@
-import {
- ClientEvent,
- createClient,
- MatrixClient,
- IndexedDBStore,
- IndexedDBCryptoStore,
- SyncState,
-} from 'matrix-js-sdk';
+import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } 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;
@@ -56,54 +46,11 @@ const startSync = (mx: MatrixClient) =>
lazyLoadMembers: true,
});
-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);
+ startResumeSync(mx, { restart: startSync });
};
+
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 e90ac6a8..eada97af 100644
--- a/src/client/resumeSync.test.ts
+++ b/src/client/resumeSync.test.ts
@@ -1,15 +1,25 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { MIN_HIDDEN_MS, RESUME_DEBOUNCE_MS, startResumeSync } from './resumeSync';
+import { HEALTH_CHECK_MS, MIN_HIDDEN_MS, RESUME_DEBOUNCE_MS, startResumeSync } from './resumeSync';
class Target extends EventTarget {
visibilityState: 'visible' | 'hidden' = 'visible';
}
-const setup = () => {
+const setup = (state: string | null = 'SYNCING') => {
const doc = new Target();
const win = new Target();
- const mx = { retryImmediately: vi.fn(() => true) };
- const stop = startResumeSync(mx, { doc: doc as never, win: win as never });
+ 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 hide = () => {
doc.visibilityState = 'hidden';
doc.dispatchEvent(new Event('visibilitychange'));
@@ -18,7 +28,7 @@ const setup = () => {
doc.visibilityState = 'visible';
doc.dispatchEvent(new Event('visibilitychange'));
};
- return { doc, win, mx, stop, hide, show };
+ return { doc, win, mx, restart, stop, hide, show };
};
const settle = () => vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS);
@@ -46,7 +56,7 @@ describe('startResumeSync', () => {
t.hide();
await vi.advanceTimersByTimeAsync(MIN_HIDDEN_MS - 1);
t.show();
- await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS);
+ await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS);
expect(t.mx.retryImmediately).not.toHaveBeenCalled();
});
@@ -58,6 +68,7 @@ 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);
@@ -72,14 +83,50 @@ 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();
+ const t = setup('ERROR');
t.win.dispatchEvent(new Event('online'));
+ await settle();
t.stop();
- await settle();
- expect(t.mx.retryImmediately).not.toHaveBeenCalled();
+ await vi.advanceTimersByTimeAsync(HEALTH_CHECK_MS);
+ expect(t.restart).not.toHaveBeenCalled();
t.win.dispatchEvent(new Event('online'));
await settle();
- expect(t.mx.retryImmediately).not.toHaveBeenCalled();
+ expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1);
});
});
diff --git a/src/client/resumeSync.ts b/src/client/resumeSync.ts
index a09f39f8..5f81547b 100644
--- a/src/client/resumeSync.ts
+++ b/src/client/resumeSync.ts
@@ -1,38 +1,60 @@
/** 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. 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.
+ * 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.
*/
-export const startResumeSync = (
- mx: SyncHost,
- { doc = document, win = window }: ResumeSyncOptions = {}
+export const startResumeSync = (
+ mx: Host,
+ { restart, 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) mx.retryImmediately();
+ if (disposed || restarting || healthCheck !== undefined) return;
+ mx.retryImmediately();
+ healthCheck = setTimeout(check, HEALTH_CHECK_MS);
}, RESUME_DEBOUNCE_MS);
};
@@ -56,6 +78,7 @@ 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
deleted file mode 100644
index ae1e5217..00000000
--- a/src/client/stallRecovery.test.ts
+++ /dev/null
@@ -1,148 +0,0 @@
-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
deleted file mode 100644
index 11b488e3..00000000
--- a/src/client/startClient.test.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-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
deleted file mode 100644
index 7d80f099..00000000
--- a/src/client/watchdog.test.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-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
deleted file mode 100644
index 6b97e8fc..00000000
--- a/src/client/watchdog.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-/**
- * 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 699c3148..e9b67550 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -10,7 +10,6 @@ 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: [
@@ -105,7 +104,6 @@ export default defineConfig({
}),
viteStaticCopy(copyFiles),
vanillaExtractPlugin(),
- buildShaPlugin(),
wasm(),
react(),
VitePWA({