Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1455988a83 | ||
| 8ba407cd2a | |||
|
|
d5a0c1b4ff | ||
| ad9172a311 | |||
| 4e6b4f0c90 | |||
| 1a3eedf6cb | |||
| 9153ee945d | |||
| d5ad73fa5e | |||
| 4c16e51559 | |||
| f0c8dc2122 | |||
| d6a6e300a4 | |||
| 91993ace99 | |||
| 145e268894 | |||
| 8d25db2775 | |||
| 6c395d7aef | |||
| a7ac67683f | |||
| 8a52fa9353 | |||
| 32b95fb60b | |||
|
|
ff1a29dbec | ||
| 2c39f89fe4 | |||
| fd2626d366 | |||
| 09e85fad02 | |||
| b2a970f987 | |||
| 45939d544b | |||
| 0f7faa902c | |||
|
|
4eaa6f9c55 | ||
|
|
761d13fac5 | ||
|
|
d13cbc9b2e | ||
|
|
da05210db2 | ||
|
|
a0d047c139 | ||
|
|
84ee800e3f | ||
|
|
32dc46c21f | ||
|
|
200cf8fdb8 | ||
|
|
e209688de1 | ||
|
|
cd90a044ed | ||
|
|
0b40f4c66f | ||
|
|
b8bef8c175 | ||
|
|
351d1bc745 | ||
| 54ac1ba480 | |||
|
|
fe12d8a4b1 | ||
| b77efddcdc | |||
|
|
d917ccd3bb | ||
|
|
2b019a4302 | ||
|
|
eff894a89c | ||
|
|
c82193f0fb | ||
|
|
c45cdc7bd7 | ||
| 14ee4bee03 | |||
|
|
0c8474231e | ||
|
|
446306b1e7 | ||
|
|
fc92809eb9 | ||
| 2aa468c36a | |||
| 67f1267653 | |||
|
|
ae3760fcc7 | ||
|
|
bd2d980023 | ||
|
|
da9ef70c03 | ||
|
|
0e3115809e | ||
|
|
e04c7c074f | ||
|
|
fab6a04894 | ||
|
|
09b062d7be | ||
|
|
9d1d39922c | ||
|
|
76c1f56bd8 | ||
|
|
2a1f2c6b17 | ||
|
|
21ff167177 | ||
|
|
ee55c32214 | ||
|
|
856096e215 | ||
|
|
f7395f12e3 | ||
| 95f79a0517 | |||
|
|
917f9a0da2 | ||
|
|
46e96500e4 | ||
|
|
ccb7a8ebb6 | ||
| b6b1d522ab | |||
|
|
bb6391a70b | ||
|
|
69d752ff0b | ||
|
|
77edebff9c | ||
|
|
a92f6c4294 | ||
| 92a98664c9 | |||
|
|
a531976675 | ||
|
|
350ec987e0 | ||
|
|
7316c92ff3 | ||
| a8d39ee917 | |||
|
|
695a016d4f | ||
|
|
64a2656ad7 | ||
| f79613ca5f | |||
| 08bf515997 | |||
| 683d3b92f8 |
156 changed files with 4190 additions and 336 deletions
|
|
@ -28,6 +28,11 @@
|
|||
"fileZoneShare": {
|
||||
"enabled": false
|
||||
},
|
||||
"push": {
|
||||
"vapidPublicKey": "",
|
||||
"gatewayUrl": "",
|
||||
"appId": ""
|
||||
},
|
||||
"registration": {
|
||||
"enabled": false,
|
||||
"url": ""
|
||||
|
|
|
|||
|
|
@ -47,6 +47,10 @@ handoff states which config differs and the installer decides. From Yggdrasil th
|
|||
not resolve: pass `LIVE_CURL_ARGS="--resolve chat.gamecult.org:443:127.0.0.1"`.
|
||||
The folder, with the three pins (`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and
|
||||
the node and npm versions, is what the install request names.
|
||||
Every build stamps its commit into `index.html` (`scripts/buildSha.js`): `<meta name="build-sha" content="<40-hex>">`,
|
||||
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 `<sha>-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:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
{
|
||||
"name": "Ratatoskr",
|
||||
"short_name": "Ratatoskr",
|
||||
"id": "/",
|
||||
"description": "GameCult's Matrix client",
|
||||
"dir": "auto",
|
||||
"lang": "en-US",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"background_color": "#07111a",
|
||||
"theme_color": "#07111a",
|
||||
"icons": [
|
||||
|
|
@ -54,6 +55,12 @@
|
|||
"src": "./public/android/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./public/android/android-chrome-maskable-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
BIN
public/res/android/android-chrome-maskable-512x512.png
Normal file
BIN
public/res/android/android-chrome-maskable-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
56
scripts/buildSha.js
Normal file
56
scripts/buildSha.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// The commit a bundle was built from, stamped into its index.html as <meta name="build-sha">, 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 `<BUILD_SHA>-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' },
|
||||
],
|
||||
});
|
||||
170
scripts/buildSha.test.js
Normal file
170
scripts/buildSha.test.js
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { execFileSync } from 'child_process';
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { buildShaPlugin, gitIn, readBuildSha } from './buildSha';
|
||||
|
||||
const SHA = 'b6b1d522f1c0a9d3e2b4c5d6e7f8091a2b3c4d5e';
|
||||
const gitAt =
|
||||
(head, status = '') =>
|
||||
(cmd, ...args) =>
|
||||
cmd === 'rev-parse' ? (args[0] === '--show-prefix' ? '\n' : `${head}\n`) : status;
|
||||
|
||||
describe('readBuildSha', () => {
|
||||
it('reads the checkout it is built from', () => {
|
||||
expect(readBuildSha({}, gitAt(SHA))).toBe(SHA);
|
||||
});
|
||||
it('marks a tree with uncommitted changes, so a stamp never vouches for code it does not hold', () => {
|
||||
expect(readBuildSha({}, gitAt(SHA, ' M src/x.ts\n'))).toBe(`${SHA}-dirty`);
|
||||
});
|
||||
it('lets BUILD_SHA stand in where there is no git, and ignores a value that is not a full sha', () => {
|
||||
const noGit = () => {
|
||||
throw new Error('not a git repository');
|
||||
};
|
||||
expect(readBuildSha({ BUILD_SHA: SHA }, noGit)).toBe(SHA);
|
||||
expect(readBuildSha({ BUILD_SHA: SHA }, gitAt(SHA))).toBe(SHA);
|
||||
// a different HEAD is a mismatch, not a clean stamp of the explicit sha
|
||||
expect(readBuildSha({ BUILD_SHA: SHA }, gitAt('c'.repeat(40)))).toBe(`${SHA}-mismatch`);
|
||||
// a value that is not a lowercase full sha is ignored
|
||||
['b6b1d52', SHA.toUpperCase(), `${SHA}0`].forEach((v) =>
|
||||
expect(readBuildSha({ BUILD_SHA: v }, noGit)).toBe('unknown')
|
||||
);
|
||||
// git that answers but cannot say what the tree holds fails closed
|
||||
const brokenStatus = (cmd, ...args) => {
|
||||
if (cmd === 'status') throw new Error('index file smaller than expected');
|
||||
return args[0] === '--show-prefix' ? '\n' : `${SHA}\n`;
|
||||
};
|
||||
expect(readBuildSha({ BUILD_SHA: SHA }, brokenStatus)).toBe('unknown');
|
||||
expect(readBuildSha({}, brokenStatus)).toBe('unknown');
|
||||
expect(readBuildSha({}, gitAt('nope'))).toBe('unknown');
|
||||
// inside another repository's subfolder git does not govern: the explicit sha stands
|
||||
const nested = (cmd, ...args) => (args[0] === '--show-prefix' ? 'vendor/ratatoskr/\n' : SHA);
|
||||
expect(readBuildSha({ BUILD_SHA: SHA }, nested)).toBe(SHA);
|
||||
expect(readBuildSha({}, nested)).toBe('unknown');
|
||||
expect(readBuildSha({ BUILD_SHA: 'main' }, noGit)).toBe('unknown');
|
||||
});
|
||||
it("says 'unknown' rather than failing the build when git is absent", () => {
|
||||
expect(
|
||||
readBuildSha({}, () => {
|
||||
throw new Error('spawn git ENOENT');
|
||||
})
|
||||
).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('readBuildSha against a real repository', () => {
|
||||
const made = [];
|
||||
afterEach(() => {
|
||||
made.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true }));
|
||||
});
|
||||
const repo = () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'buildsha-'));
|
||||
made.push(dir);
|
||||
const g = (...a) => execFileSync('git', a, { cwd: dir, stdio: 'ignore' });
|
||||
g('init', '-q');
|
||||
mkdirSync(join(dir, 'src'));
|
||||
writeFileSync(join(dir, 'src', 'a.ts'), 'export {};\n');
|
||||
g('add', '.');
|
||||
g('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x');
|
||||
return dir;
|
||||
};
|
||||
it("stays clean with vite's temp config file present, and is dirty with a stray source file", () => {
|
||||
const dir = repo();
|
||||
const run = gitIn(dir);
|
||||
writeFileSync(join(dir, 'vite.config.js.timestamp-1700000000-abc123.mjs'), '');
|
||||
expect(readBuildSha({}, run)).toMatch(/^[0-9a-f]{40}$/);
|
||||
writeFileSync(join(dir, 'src', 'zz-stray.ts'), '');
|
||||
expect(readBuildSha({}, run)).toMatch(/^[0-9a-f]{40}-dirty$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readBuildSha dirty rules against a real repository', () => {
|
||||
const made2 = [];
|
||||
afterEach(() => {
|
||||
made2.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true }));
|
||||
});
|
||||
const TEMP = 'vite.config.js.timestamp-1700000000-abc123.mjs';
|
||||
const repo2 = (tracked = []) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'buildsha-'));
|
||||
made2.push(dir);
|
||||
const g = (...a) => execFileSync('git', a, { cwd: dir, stdio: 'ignore' });
|
||||
g('init', '-q');
|
||||
mkdirSync(join(dir, 'src'));
|
||||
writeFileSync(join(dir, 'src', 'a.ts'), 'export {};\n');
|
||||
tracked.forEach((f) => writeFileSync(join(dir, f), 'x\n'));
|
||||
g('add', '.');
|
||||
g('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x');
|
||||
return {
|
||||
dir,
|
||||
head: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim(),
|
||||
};
|
||||
};
|
||||
it('does not let an explicit BUILD_SHA mask a dirty tree', () => {
|
||||
const { dir, head } = repo2();
|
||||
const run = gitIn(dir);
|
||||
expect(readBuildSha({ BUILD_SHA: head }, run)).toBe(head);
|
||||
writeFileSync(join(dir, 'src', 'zz-stray.ts'), '');
|
||||
expect(readBuildSha({ BUILD_SHA: head }, run)).toBe(`${head}-dirty`);
|
||||
});
|
||||
it('lets git speak only for a tree whose root it is: an archive unpacked inside another repository keeps its explicit sha', () => {
|
||||
const { dir } = repo2();
|
||||
const inner = join(dir, 'vendor', 'ratatoskr');
|
||||
mkdirSync(inner, { recursive: true });
|
||||
writeFileSync(join(inner, 'x.ts'), '');
|
||||
expect(readBuildSha({ BUILD_SHA: SHA }, gitIn(inner))).toBe(SHA);
|
||||
expect(readBuildSha({}, gitIn(inner))).toBe('unknown');
|
||||
});
|
||||
it("notices an untracked .mjs that is not vite's temp file", () => {
|
||||
const { dir, head } = repo2();
|
||||
writeFileSync(join(dir, 'foo.mjs'), '');
|
||||
expect(readBuildSha({}, gitIn(dir))).toBe(`${head}-dirty`);
|
||||
rmSync(join(dir, 'foo.mjs'));
|
||||
// only vite's exact shape is its own: vite.config.<ext>.timestamp-<digits>-<hex>.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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from 'folds';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { Cursor } from '../plugins/text-area';
|
||||
import { PhoneHit } from '../styles/PhoneHit.css';
|
||||
import { syntaxErrorPosition } from '../utils/dom';
|
||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||
import { Page, PageHeader } from './page';
|
||||
|
|
@ -294,7 +295,7 @@ export function AccountDataEditor({
|
|||
</Chip>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<IconButton onClick={requestClose} variant="Surface" className={PhoneHit}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
} from '../hooks/useKeyBackup';
|
||||
import { stopPropagation } from '../utils/keyboard';
|
||||
import { useRestoreBackupOnVerification } from '../hooks/useRestoreBackupOnVerification';
|
||||
import { PhoneHit } from '../styles/PhoneHit.css';
|
||||
|
||||
type BackupStatusProps = {
|
||||
enabled: boolean;
|
||||
|
|
@ -177,6 +178,7 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
|
|||
<BackupSyncing count={remainingSession} />
|
||||
)}
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
aria-pressed={!!menuCords}
|
||||
size="300"
|
||||
variant="Surface"
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
} from '../hooks/useVerificationRequest';
|
||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||
import { ContainerColor } from '../styles/ContainerColor.css';
|
||||
import { PhoneHit } from '../styles/PhoneHit.css';
|
||||
|
||||
const DialogHeaderStyles: CSSProperties = {
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
|
|
@ -261,7 +262,7 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">Device Verification</Text>
|
||||
</Box>
|
||||
<IconButton size="300" radii="300" onClick={handleCancel}>
|
||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={handleCancel}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA';
|
|||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { useAlive } from '../hooks/useAlive';
|
||||
import { UseStateProvider } from './UseStateProvider';
|
||||
import { PhoneHit } from '../styles/PhoneHit.css';
|
||||
|
||||
type UIACallback<T> = (
|
||||
authDict: AuthDict | null
|
||||
|
|
@ -301,7 +302,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">Setup Device Verification</Text>
|
||||
</Box>
|
||||
<IconButton size="300" radii="300" onClick={onCancel}>
|
||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={onCancel}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
@ -336,7 +337,7 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">Reset Device Verification</Text>
|
||||
</Box>
|
||||
<IconButton size="300" radii="300" onClick={onCancel}>
|
||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={onCancel}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { AsyncStatus } from '../../hooks/useAsyncCallback';
|
|||
import { useZoom } from '../../hooks/useZoom';
|
||||
import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { PhoneHit, PhoneHitSpread24 } from '../../styles/PhoneHit.css';
|
||||
|
||||
export type PdfViewerProps = {
|
||||
name: string;
|
||||
|
|
@ -108,7 +109,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
|
|||
<Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}>
|
||||
<Header className={css.PdfViewerHeader} size="400">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<IconButton size="300" radii="300" onClick={requestClose}>
|
||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
|
||||
<Icon size="50" src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
<Text size="T300" truncate>
|
||||
|
|
@ -117,6 +118,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
|
|||
</Box>
|
||||
<Box shrink="No" alignItems="Center" gap="200">
|
||||
<IconButton
|
||||
className={PhoneHitSpread24}
|
||||
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
|
||||
outlined={zoom < 1}
|
||||
size="300"
|
||||
|
|
@ -130,6 +132,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
|
|||
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
||||
</Chip>
|
||||
<IconButton
|
||||
className={PhoneHitSpread24}
|
||||
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
|
||||
outlined={zoom > 1}
|
||||
size="300"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
IconButton,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { PhoneHit } from '../styles/PhoneHit.css';
|
||||
|
||||
export type UIAFlowOverlayProps = {
|
||||
currentStep: number;
|
||||
|
|
@ -53,6 +54,7 @@ export function UIAFlowOverlay({
|
|||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
ref={anchorRef}
|
||||
variant="Critical"
|
||||
size="300"
|
||||
|
|
|
|||
|
|
@ -1,84 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
config,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Line,
|
||||
Modal,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
} from 'folds';
|
||||
|
||||
import { CustomEditor, useEditor } from './Editor';
|
||||
import { Toolbar } from './Toolbar';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
|
||||
export function EditorPreview() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const editor = useEditor();
|
||||
const [toolbar, setToolbar] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IconButton variant="SurfaceVariant" onClick={() => setOpen(!open)}>
|
||||
<Icon src={Icons.BlockQuote} />
|
||||
</IconButton>
|
||||
<Overlay open={open} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setOpen(false),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal size="500">
|
||||
<div style={{ padding: config.space.S400 }}>
|
||||
<CustomEditor
|
||||
editor={editor}
|
||||
placeholder="Send a message..."
|
||||
before={
|
||||
<IconButton variant="SurfaceVariant" size="300" radii="300">
|
||||
<Icon src={Icons.PlusCircle} />
|
||||
</IconButton>
|
||||
}
|
||||
after={
|
||||
<>
|
||||
<IconButton
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={() => setToolbar(!toolbar)}
|
||||
aria-pressed={toolbar}
|
||||
>
|
||||
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
||||
</IconButton>
|
||||
<IconButton variant="SurfaceVariant" size="300" radii="300">
|
||||
<Icon src={Icons.Smile} />
|
||||
</IconButton>
|
||||
<IconButton variant="SurfaceVariant" size="300" radii="300">
|
||||
<Icon src={Icons.Send} />
|
||||
</IconButton>
|
||||
</>
|
||||
}
|
||||
bottom={
|
||||
toolbar && (
|
||||
<div>
|
||||
<Line variant="SurfaceVariant" size="300" />
|
||||
<Toolbar />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@ import { KeySymbol } from '../../utils/key-symbol';
|
|||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { PhoneHit, PhoneHitRoom36, PhoneHitSpread36 } from '../../styles/PhoneHit.css';
|
||||
|
||||
function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) {
|
||||
return (
|
||||
|
|
@ -72,6 +73,7 @@ export function MarkButton({ format, icon, tooltip }: MarkButtonProps) {
|
|||
<TooltipProvider tooltip={tooltip} delay={500}>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread36}
|
||||
ref={triggerRef}
|
||||
variant="SurfaceVariant"
|
||||
onClick={handleClick}
|
||||
|
|
@ -104,6 +106,7 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
|
|||
<TooltipProvider tooltip={tooltip} delay={500}>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread36}
|
||||
ref={triggerRef}
|
||||
variant="SurfaceVariant"
|
||||
onClick={handleClick}
|
||||
|
|
@ -163,6 +166,7 @@ export function HeadingBlockButton() {
|
|||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitRoom36}
|
||||
ref={triggerRef}
|
||||
onClick={() => handleMenuSelect(1)}
|
||||
size="400"
|
||||
|
|
@ -178,6 +182,7 @@ export function HeadingBlockButton() {
|
|||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitRoom36}
|
||||
ref={triggerRef}
|
||||
onClick={() => handleMenuSelect(2)}
|
||||
size="400"
|
||||
|
|
@ -193,6 +198,7 @@ export function HeadingBlockButton() {
|
|||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitRoom36}
|
||||
ref={triggerRef}
|
||||
onClick={() => handleMenuSelect(3)}
|
||||
size="400"
|
||||
|
|
@ -208,6 +214,7 @@ export function HeadingBlockButton() {
|
|||
}
|
||||
>
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
style={{ width: 'unset' }}
|
||||
variant="SurfaceVariant"
|
||||
onClick={handleMenuOpen}
|
||||
|
|
@ -239,6 +246,7 @@ export function ExitFormatting({ tooltip }: ExitFormattingProps) {
|
|||
<TooltipProvider tooltip={tooltip} delay={500}>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
ref={triggerRef}
|
||||
variant="SurfaceVariant"
|
||||
onClick={handleClick}
|
||||
|
|
@ -344,6 +352,7 @@ export function Toolbar() {
|
|||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
ref={triggerRef}
|
||||
variant="SurfaceVariant"
|
||||
onClick={() => setIsMarkdown(!isMarkdown)}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import * as css from './styles.css';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
export function Sidebar({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
|
|
@ -68,6 +69,7 @@ function SidebarBtn<T extends string>({
|
|||
>
|
||||
{(ref) => (
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
aria-pressed={active}
|
||||
aria-labelledby={`SidebarStackItem-${id}-label`}
|
||||
ref={ref}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import { getMouseEventCords } from '../../utils/dom';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
export type EventReadersProps = {
|
||||
room: Room;
|
||||
|
|
@ -52,7 +53,7 @@ export const EventReaders = as<'div', EventReadersProps>(
|
|||
<Box grow="Yes">
|
||||
<Text size="H3">Seen by</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={requestClose}>
|
||||
<IconButton className={PhoneHit} size="300" onClick={requestClose}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
import classNames from 'classnames';
|
||||
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
|
||||
import * as css from './ImageEditor.css';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
export type ImageEditorProps = {
|
||||
name: string;
|
||||
|
|
@ -24,7 +25,7 @@ export const ImageEditor = as<'div', ImageEditorProps>(
|
|||
>
|
||||
<Header className={css.ImageEditorHeader} size="400">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<IconButton size="300" radii="300" onClick={requestClose}>
|
||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
|
||||
<Icon size="50" src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
<Text size="T300" truncate>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import { Box, IconButton, Text, Icon, Icons, Scroll, Chip } from 'folds';
|
||||
import { PackAddress } from '../../plugins/custom-emoji';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
import { Page, PageHeader, PageContent } from '../page';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { RoomImagePack } from './RoomImagePack';
|
||||
|
|
@ -29,7 +30,7 @@ export function ImagePackView({ address, requestClose }: ImagePackViewProps) {
|
|||
</Chip>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<IconButton onClick={requestClose} variant="Surface" className={PhoneHit}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import * as css from './ImageViewer.css';
|
|||
import { useZoom } from '../../hooks/useZoom';
|
||||
import { usePan } from '../../hooks/usePan';
|
||||
import { downloadMedia } from '../../utils/matrix';
|
||||
import { PhoneHit, PhoneHitSpread24 } from '../../styles/PhoneHit.css';
|
||||
|
||||
export type ImageViewerProps = {
|
||||
alt: string;
|
||||
|
|
@ -33,7 +34,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||
>
|
||||
<Header className={css.ImageViewerHeader} size="400">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<IconButton size="300" radii="300" onClick={requestClose}>
|
||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
|
||||
<Icon size="50" src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
<Text size="T300" truncate>
|
||||
|
|
@ -42,6 +43,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||
</Box>
|
||||
<Box shrink="No" alignItems="Center" gap="200">
|
||||
<IconButton
|
||||
className={PhoneHitSpread24}
|
||||
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
|
||||
outlined={zoom < 1}
|
||||
size="300"
|
||||
|
|
@ -55,6 +57,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
|||
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
||||
</Chip>
|
||||
<IconButton
|
||||
className={PhoneHitSpread24}
|
||||
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
|
||||
outlined={zoom > 1}
|
||||
size="300"
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
|||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { BreakWord } from '../../styles/Text.css';
|
||||
import { useAlive } from '../../hooks/useAlive';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||
limit: 1000,
|
||||
|
|
@ -173,7 +174,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
|||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton size="300" radii="300" onClick={requestClose}>
|
||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { stopPropagation } from '../../utils/keyboard';
|
|||
import { isRoomAlias, isRoomId } from '../../utils/matrix';
|
||||
import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../../plugins/matrix-to';
|
||||
import { tryDecodeURIComponent } from '../../utils/dom';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
type JoinAddressProps = {
|
||||
onOpen: (roomIdOrAlias: string, via?: string[], eventId?: string) => void;
|
||||
|
|
@ -82,7 +83,7 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">Join with Address</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={onCancel} radii="300">
|
||||
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { MatrixError } from 'matrix-js-sdk';
|
|||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
type LeaveRoomPromptProps = {
|
||||
roomId: string;
|
||||
|
|
@ -68,7 +69,7 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">Leave Room</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={onCancel} radii="300">
|
||||
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { MatrixError } from 'matrix-js-sdk';
|
|||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
type LeaveSpacePromptProps = {
|
||||
roomId: string;
|
||||
|
|
@ -68,7 +69,7 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">Leave Space</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={onCancel} radii="300">
|
||||
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'f
|
|||
import React, { ReactNode, useCallback } from 'react';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import FileSaver from 'file-saver';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
import { mimeTypeToExt } from '../../utils/mimeTypes';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
|
|
@ -48,6 +49,7 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
|
|||
variant={hasError ? 'Critical' : 'SurfaceVariant'}
|
||||
size="300"
|
||||
radii="300"
|
||||
className={PhoneHit}
|
||||
>
|
||||
{downloading ? (
|
||||
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { createVar, style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
|
||||
import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize';
|
||||
import { PHONE_TAP } from '../../styles/PhoneHit.css';
|
||||
|
||||
const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
|
||||
const Container = createVar();
|
||||
const ContainerHover = createVar();
|
||||
const ContainerActive = createVar();
|
||||
|
|
@ -25,6 +28,14 @@ export const Reaction = style([
|
|||
selectors: {
|
||||
'button&': {
|
||||
cursor: 'pointer',
|
||||
// Phone tap target: the chip is 26 px tall, so its hit area grows 9 px above and below (an ::after) and the
|
||||
// same 9 px of margin opens the row, so the chips of one row and of the next never share a tap.
|
||||
'@media': { [PHONE]: { position: 'relative', marginBlock: (PHONE_TAP - 26) / 2 } },
|
||||
},
|
||||
'button&::after': {
|
||||
'@media': {
|
||||
[PHONE]: { content: '""', position: 'absolute', inset: `${-(PHONE_TAP - 26) / 2}px 0` },
|
||||
},
|
||||
},
|
||||
'&[aria-pressed=true]': {
|
||||
vars: {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import React, { ReactNode, useCallback, useRef, useState } from 'react';
|
|||
import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import { Range } from 'react-range';
|
||||
import { PhoneHitSpread24 } from '../../../styles/PhoneHit.css';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { IAudioInfo } from '../../../../types/matrix/common';
|
||||
|
|
@ -159,6 +160,7 @@ export function AudioContent({
|
|||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="Pill"
|
||||
className={PhoneHitSpread24}
|
||||
onClick={() => setMute(!mute)}
|
||||
aria-pressed={mute}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -299,7 +299,10 @@ export const BubbleLeftArrow = style({
|
|||
});
|
||||
|
||||
// Phone tap target: a sender name is 23 px tall, so the button grows 11 px above and below (44+ px to tap) and the
|
||||
// negative margin takes the growth back out of the layout. Hit area only: no background, border or outline moves.
|
||||
// negative margin takes the growth back out of the layout. Sideways it cannot borrow from the layout the
|
||||
// same way (a negative inline margin makes the button shrink inside its shrink-to-fit parent and clip the name), so
|
||||
// a name under 44 px is 44 px wide, text kept at the start: real growth of at most 16 px, in a row with room for it.
|
||||
// No background, border or outline moves.
|
||||
const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
|
||||
export const Username = style({
|
||||
overflow: 'hidden',
|
||||
|
|
@ -309,7 +312,7 @@ export const Username = style({
|
|||
'button&': {
|
||||
cursor: 'pointer',
|
||||
'@media': {
|
||||
[SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11 },
|
||||
[SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11, minWidth: 44, textAlign: 'start' },
|
||||
},
|
||||
},
|
||||
'button&:hover, button&:focus-visible': {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { ComponentProps, forwardRef } from 'react';
|
||||
import { Icon, IconButton, Input, config, Icons } from 'folds';
|
||||
import { UseStateProvider } from '../UseStateProvider';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
type PasswordInputProps = Omit<ComponentProps<typeof Input>, 'type' | 'size'> & {
|
||||
size: '400' | '500';
|
||||
|
|
@ -23,6 +24,7 @@ export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
|
|||
<>
|
||||
{after}
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
onClick={() => setVisible(!visible)}
|
||||
type="button"
|
||||
variant={visible ? 'Warning' : variant}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import classNames from 'classnames';
|
|||
import Linkify from 'linkify-react';
|
||||
import * as css from './style.css';
|
||||
import { LINKIFY_OPTS, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
export const RoomTopicViewer = as<
|
||||
'div',
|
||||
|
|
@ -26,7 +27,7 @@ export const RoomTopicViewer = as<
|
|||
{name}
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={requestClose} radii="300">
|
||||
<IconButton className={PhoneHit} size="300" onClick={requestClose} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, as } from 'fo
|
|||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import * as css from './TextViewer.css';
|
||||
import { copyToClipboard } from '../../utils/dom';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism'));
|
||||
|
||||
|
|
@ -53,7 +54,7 @@ export const TextViewer = as<'div', TextViewerProps>(
|
|||
>
|
||||
<Header className={css.TextViewerHeader} size="400">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<IconButton size="300" radii="300" onClick={requestClose}>
|
||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
|
||||
<Icon size="50" src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
<Text size="T300" truncate>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { AuthType } from 'matrix-js-sdk';
|
|||
import { StageComponentProps } from './types';
|
||||
import { ErrorCode } from '../../cs-errorcode';
|
||||
import { PasswordInput } from '../password-input';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
export function PasswordStage({
|
||||
stageData,
|
||||
|
|
@ -45,7 +46,7 @@ export function PasswordStage({
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">Account Password</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={onCancel} radii="300">
|
||||
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { StageComponentProps } from './types';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
export function SSOStage({
|
||||
ssoRedirectURL,
|
||||
|
|
@ -56,7 +57,7 @@ export function SSOStage({
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">SSO Login</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={onCancel} radii="300">
|
||||
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||
import { TUploadContent } from '../../utils/matrix';
|
||||
import { bytesToSize, getFileTypeIcon } from '../../utils/common';
|
||||
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
||||
import { PhoneHitSpread28 } from '../../styles/PhoneHit.css';
|
||||
|
||||
type CompactUploadCardRendererProps = {
|
||||
isEncrypted?: boolean;
|
||||
|
|
@ -63,6 +64,7 @@ export function CompactUploadCardRenderer({
|
|||
</Chip>
|
||||
)}
|
||||
<IconButton
|
||||
className={PhoneHitSpread28}
|
||||
onClick={removeUpload}
|
||||
aria-label="Cancel Upload"
|
||||
variant="SurfaceVariant"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
} from '../../state/room/roomInputDrafts';
|
||||
import { useObjectURL } from '../../hooks/useObjectURL';
|
||||
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
||||
import { PhoneHitSpread28 } from '../../styles/PhoneHit.css';
|
||||
|
||||
type PreviewImageProps = {
|
||||
fileItem: TUploadItem;
|
||||
|
|
@ -160,6 +161,7 @@ export function UploadCardRenderer({
|
|||
</Chip>
|
||||
)}
|
||||
<IconButton
|
||||
className={PhoneHitSpread28}
|
||||
onClick={removeUpload}
|
||||
aria-label="Cancel Upload"
|
||||
variant="SurfaceVariant"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { IPreviewUrlResponse } from 'matrix-js-sdk';
|
||||
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
|
||||
|
|
@ -10,6 +11,7 @@ import {
|
|||
useIntersectionObserver,
|
||||
} from '../../hooks/useIntersectionObserver';
|
||||
import * as css from './UrlPreviewCard.css';
|
||||
import { PhoneHitInPlace } from '../../styles/PhoneHit.css';
|
||||
import { tryDecodeURIComponent } from '../../utils/dom';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
|
|
@ -181,7 +183,10 @@ export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => {
|
|||
<>
|
||||
<div className={css.UrlPreviewHolderGradient({ position: 'Left' })} />
|
||||
<IconButton
|
||||
className={css.UrlPreviewHolderBtn({ position: 'Left' })}
|
||||
className={classNames(
|
||||
css.UrlPreviewHolderBtn({ position: 'Left' }),
|
||||
PhoneHitInPlace
|
||||
)}
|
||||
variant="Secondary"
|
||||
radii="Pill"
|
||||
size="300"
|
||||
|
|
@ -199,7 +204,10 @@ export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => {
|
|||
<>
|
||||
<div className={css.UrlPreviewHolderGradient({ position: 'Right' })} />
|
||||
<IconButton
|
||||
className={css.UrlPreviewHolderBtn({ position: 'Right' })}
|
||||
className={classNames(
|
||||
css.UrlPreviewHolderBtn({ position: 'Right' }),
|
||||
PhoneHitInPlace
|
||||
)}
|
||||
variant="Primary"
|
||||
radii="Pill"
|
||||
size="300"
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import { getPowerTagIconSrc, useGetMemberPowerTag } from '../../hooks/useMemberP
|
|||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
type SelfDemoteAlertProps = {
|
||||
power: number;
|
||||
|
|
@ -70,7 +71,7 @@ function SelfDemoteAlert({ power, onCancel, onChange }: SelfDemoteAlertProps) {
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">Self Demotion</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={onCancel} radii="300">
|
||||
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
@ -120,7 +121,7 @@ function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps)
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">Shared Power</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={onCancel} radii="300">
|
||||
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ import { StateEvent } from '../../../types/matrix/room';
|
|||
import { getViaServers } from '../../plugins/via-servers';
|
||||
import { rateLimitedActions } from '../../utils/matrix';
|
||||
import { useAlive } from '../../hooks/useAlive';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
const SEARCH_OPTS: UseAsyncSearchOptions = {
|
||||
limit: 500,
|
||||
|
|
@ -201,7 +202,7 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM
|
|||
<Text size="H4">Add Existing</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton size="300" radii="300" onClick={requestClose}>
|
||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { StatusDivider } from './components';
|
|||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { callEmbedAtom } from '../../state/callEmbed';
|
||||
import { PhoneHitSpread26 } from '../../styles/PhoneHit.css';
|
||||
|
||||
type MicrophoneButtonProps = {
|
||||
enabled: boolean;
|
||||
|
|
@ -26,6 +27,7 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps
|
|||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread26}
|
||||
ref={anchorRef}
|
||||
variant={enabled ? 'Surface' : 'Warning'}
|
||||
fill="Soft"
|
||||
|
|
@ -59,6 +61,7 @@ function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
|
|||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread26}
|
||||
ref={anchorRef}
|
||||
variant={enabled ? 'Surface' : 'Warning'}
|
||||
fill="Soft"
|
||||
|
|
@ -99,6 +102,7 @@ function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
|
|||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread26}
|
||||
ref={anchorRef}
|
||||
variant={enabled ? 'Success' : 'Surface'}
|
||||
fill="Soft"
|
||||
|
|
@ -136,6 +140,7 @@ function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonPro
|
|||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread26}
|
||||
ref={anchorRef}
|
||||
variant={enabled ? 'Success' : 'Surface'}
|
||||
fill="Soft"
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
SoundButton,
|
||||
VideoButton,
|
||||
} from './Controls';
|
||||
import { PhoneHitSpread40 } from '../../styles/PhoneHit.css';
|
||||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||||
import { useResizeObserver } from '../../hooks/useResizeObserver';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
|
|
@ -169,6 +170,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||
}
|
||||
>
|
||||
<IconButton
|
||||
className={PhoneHitSpread40}
|
||||
variant="Surface"
|
||||
fill="Soft"
|
||||
radii="400"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useAtom } from 'jotai';
|
|||
import * as css from './styles.css';
|
||||
import { callChatAtom } from '../../state/callEmbed';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { PhoneHitSpread40 } from '../../styles/PhoneHit.css';
|
||||
|
||||
export function ControlDivider() {
|
||||
return (
|
||||
|
|
@ -31,6 +32,7 @@ export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
|
|||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread40}
|
||||
ref={anchorRef}
|
||||
variant={enabled ? 'Surface' : 'Warning'}
|
||||
fill="Soft"
|
||||
|
|
@ -64,6 +66,7 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
|
|||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread40}
|
||||
ref={anchorRef}
|
||||
variant={enabled ? 'Surface' : 'Warning'}
|
||||
fill="Soft"
|
||||
|
|
@ -103,6 +106,7 @@ export function VideoButton({ enabled, onToggle }: VideoButtonProps) {
|
|||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread40}
|
||||
ref={anchorRef}
|
||||
variant={enabled ? 'Success' : 'Surface'}
|
||||
fill="Soft"
|
||||
|
|
@ -140,6 +144,7 @@ export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps)
|
|||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread40}
|
||||
ref={anchorRef}
|
||||
variant={enabled ? 'Success' : 'Surface'}
|
||||
fill="Soft"
|
||||
|
|
@ -170,6 +175,7 @@ export function ChatButton() {
|
|||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread40}
|
||||
ref={anchorRef}
|
||||
variant={chat ? 'Success' : 'Surface'}
|
||||
fill="Soft"
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
AccountDataSubmitCallback,
|
||||
} from '../../../components/AccountDataEditor';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
type DeveloperToolsProps = {
|
||||
requestClose: () => void;
|
||||
|
|
@ -92,7 +93,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
|||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
|
|||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { syntaxErrorPosition } from '../../../utils/dom';
|
||||
import { Cursor } from '../../../plugins/text-area';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
const EDITOR_INTENT_SPACE_COUNT = 2;
|
||||
|
||||
|
|
@ -118,7 +119,7 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro
|
|||
</Chip>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
|||
import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
const EDITOR_INTENT_SPACE_COUNT = 2;
|
||||
|
||||
|
|
@ -275,7 +276,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi
|
|||
</Chip>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
|
|||
import { ImagePack } from '../../../plugins/custom-emoji';
|
||||
import { ImagePackView } from '../../../components/image-pack-view';
|
||||
import { RoomPacks } from './RoomPacks';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
type EmojisStickersProps = {
|
||||
requestClose: () => void;
|
||||
|
|
@ -29,7 +30,7 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) {
|
|||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
|||
import { useAlive } from '../../../hooks/useAlive';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
type CreatePackTileProps = {
|
||||
packs: ImagePack[];
|
||||
|
|
@ -215,6 +216,7 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) {
|
|||
{canEdit &&
|
||||
(removed ? (
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
size="300"
|
||||
radii="Pill"
|
||||
variant="Critical"
|
||||
|
|
@ -225,6 +227,7 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) {
|
|||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
size="300"
|
||||
radii="Pill"
|
||||
variant="Secondary"
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { useRoom } from '../../../hooks/useRoom';
|
|||
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
const ROOM_ENC_ALGO = 'm.megolm.v1.aes-sha2';
|
||||
|
||||
|
|
@ -123,7 +124,12 @@ export function RoomEncryption({ permissions }: RoomEncryptionProps) {
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">Enable Encryption</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={() => setPrompt(false)} radii="300">
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
size="300"
|
||||
onClick={() => setPrompt(false)}
|
||||
radii="300"
|
||||
>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import { useAlive } from '../../../hooks/useAlive';
|
|||
import { creatorsSupported } from '../../../utils/matrix';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { BreakWord } from '../../../styles/Text.css';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
||||
const mx = useMatrixClient();
|
||||
|
|
@ -105,7 +106,7 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">{room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'}</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={requestClose} radii="300">
|
||||
<IconButton className={PhoneHit} size="300" onClick={requestClose} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import { useSpaceOptionally } from '../../../hooks/useSpace';
|
|||
import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../../hooks/useMemberPowerTag';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { getMouseEventCords } from '../../../utils/dom';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||
limit: 1000,
|
||||
|
|
@ -161,7 +162,7 @@ export function Members({ requestClose }: MembersProps) {
|
|||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
@ -277,6 +278,7 @@ export function Members({ requestClose }: MembersProps) {
|
|||
anchorRef={scrollTopAnchorRef}
|
||||
>
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
onClick={() => virtualizer.scrollToOffset(0)}
|
||||
variant="Surface"
|
||||
radii="Pill"
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import { useAlive } from '../../../hooks/useAlive';
|
|||
import { BetaNoticeBadge } from '../../../components/BetaNoticeBadge';
|
||||
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
||||
import { creatorsSupported } from '../../../utils/matrix';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
type EditPowerProps = {
|
||||
maxPower: number;
|
||||
|
|
@ -369,7 +370,7 @@ export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) {
|
|||
</Chip>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
import { CreateRoomModalState } from '../../state/createRoomModal';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { CreateRoomType } from '../../components/create-room/types';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
type CreateRoomModalProps = {
|
||||
state: CreateRoomModalState;
|
||||
|
|
@ -63,7 +64,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
|
|||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton size="300" radii="300" onClick={closeDialog}>
|
||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={closeDialog}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
} from '../../state/hooks/createSpaceModal';
|
||||
import { CreateSpaceModalState } from '../../state/createSpaceModal';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
type CreateSpaceModalProps = {
|
||||
state: CreateSpaceModalState;
|
||||
|
|
@ -61,7 +62,7 @@ function CreateSpaceModal({ state }: CreateSpaceModalProps) {
|
|||
<Text size="H4">New Space</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton size="300" radii="300" onClick={closeDialog}>
|
||||
<IconButton className={PhoneHit} size="300" radii="300" onClick={closeDialog}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
import { Page, PageContent, PageContentCenter, PageHeader } from '../../components/page';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { getHomePath } from '../../pages/pathUtils';
|
||||
|
|
@ -33,7 +34,11 @@ export function Desk() {
|
|||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Box grow="Yes" basis="No">
|
||||
{phone && (
|
||||
<IconButton aria-label="Back to rooms" onClick={() => navigate(getHomePath())}>
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
aria-label="Back to rooms"
|
||||
onClick={() => navigate(getHomePath())}
|
||||
>
|
||||
<Icon src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { BackRouteHandler } from '../../components/BackRouteHandler';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
type JoinBeforeNavigateProps = { roomIdOrAlias: string; eventId?: string; viaServers?: string[] };
|
||||
export function JoinBeforeNavigate({
|
||||
|
|
@ -38,7 +39,7 @@ export function JoinBeforeNavigate({
|
|||
{screenSize === ScreenSize.Mobile && (
|
||||
<BackRouteHandler>
|
||||
{(onBack) => (
|
||||
<IconButton onClick={onBack}>
|
||||
<IconButton className={PhoneHit} onClick={onBack}>
|
||||
<Icon src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import { IPowerLevels } from '../../hooks/usePowerLevels';
|
|||
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
|
||||
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
|
||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||
import { PhoneHitSpread24 } from '../../styles/PhoneHit.css';
|
||||
|
||||
type HierarchyItemWithParent = HierarchyItem & {
|
||||
parentId: string;
|
||||
|
|
@ -231,6 +232,7 @@ export function HierarchyItemMenu({
|
|||
return (
|
||||
<Box gap="200" alignItems="Center" shrink="No">
|
||||
<IconButton
|
||||
className={PhoneHitSpread24}
|
||||
onClick={handleOpenMenu}
|
||||
size="300"
|
||||
variant="SurfaceVariant"
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import { useGetRoom } from '../../hooks/useGetRoom';
|
|||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
|
||||
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
const useCanDropLobbyItem = (
|
||||
space: Room,
|
||||
|
|
@ -413,6 +414,7 @@ export function Lobby() {
|
|||
onVisibilityChange={setOnTop}
|
||||
>
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
onClick={() => virtualizer.scrollToOffset(0)}
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
|
|||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
type LobbyMenuProps = {
|
||||
powerLevels: IPowerLevels;
|
||||
|
|
@ -165,7 +166,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
|
|||
<Box shrink="No">
|
||||
<BackRouteHandler>
|
||||
{(onBack) => (
|
||||
<IconButton fill="None" onClick={onBack}>
|
||||
<IconButton className={PhoneHit} fill="None" onClick={onBack}>
|
||||
<Icon src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
@ -240,6 +241,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
|
|||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
fill="None"
|
||||
onClick={handleOpenMenu}
|
||||
ref={triggerRef}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { SearchResultGroup } from './SearchResultGroup';
|
|||
import { SearchInput } from './SearchInput';
|
||||
import { SearchFilters } from './SearchFilters';
|
||||
import { VirtualTile } from '../../components/virtualizer';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSearchParams =>
|
||||
useMemo(
|
||||
|
|
@ -193,6 +194,7 @@ export function MessageSearch({
|
|||
<Box direction="Column" gap="700">
|
||||
<ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}>
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
onClick={() => virtualizer.scrollToOffset(0)}
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
|
|
|
|||
72
src/app/features/push/PushNotification.tsx
Normal file
72
src/app/features/push/PushNotification.tsx
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Switch, Text, color } from 'folds';
|
||||
import { SettingTile } from '../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { getNotificationState, usePermissionState } from '../../hooks/usePermission';
|
||||
import type { ActivePushConfig } from './config';
|
||||
import { pushSessionToSW } from '../../../sw-session';
|
||||
import { disablePush, enablePush, pushIsOn } from './pusher';
|
||||
|
||||
export const pushSupported = (): boolean =>
|
||||
'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
|
||||
|
||||
const readyManager = async () => (await navigator.serviceWorker.ready).pushManager;
|
||||
|
||||
/** Background notifications through the installed app's service worker. The caller renders it only when the host's config fills the push block and the browser can. */
|
||||
export function PushNotification({ cfg }: { cfg: ActivePushConfig }) {
|
||||
const mx = useMatrixClient();
|
||||
const permission = usePermissionState('notifications', getNotificationState());
|
||||
const [on, setOn] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
readyManager()
|
||||
.then((m) => pushIsOn(mx, m, cfg))
|
||||
.then(setOn)
|
||||
.catch(() => undefined);
|
||||
}, [cfg, mx]);
|
||||
|
||||
const toggle = useCallback(
|
||||
async (enable: boolean) => {
|
||||
setBusy(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
if (enable) {
|
||||
if ((await window.Notification.requestPermission()) !== 'granted') {
|
||||
throw new Error('Notification permission was not granted.');
|
||||
}
|
||||
await enablePush(mx, await readyManager(), cfg, navigator.userAgent.slice(0, 64));
|
||||
} else {
|
||||
await disablePush(mx, await readyManager(), cfg);
|
||||
}
|
||||
// The worker keeps the token only while a subscription exists: tell it the new state.
|
||||
pushSessionToSW(mx.baseUrl, mx.getAccessToken() ?? undefined);
|
||||
setOn(enable);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Could not change background notifications.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[cfg, mx]
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title="Background Notifications"
|
||||
description={
|
||||
error || permission === 'denied' ? (
|
||||
<Text as="span" style={{ color: color.Critical.Main }} size="T200">
|
||||
{error ?? 'Notification permission is blocked. Allow it from the browser address bar.'}
|
||||
</Text>
|
||||
) : (
|
||||
<span>
|
||||
Notify when the app is closed. Installing the app to the home screen helps on iOS.
|
||||
</span>
|
||||
)
|
||||
}
|
||||
after={<Switch disabled={busy || permission === 'denied'} value={on} onChange={toggle} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
src/app/features/push/config.test.ts
Normal file
24
src/app/features/push/config.test.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { activePushConfig } from './config';
|
||||
|
||||
describe('activePushConfig', () => {
|
||||
it('is off unless all three keys are filled', () => {
|
||||
expect(activePushConfig(undefined)).toBeUndefined();
|
||||
expect(activePushConfig({})).toBeUndefined();
|
||||
expect(
|
||||
activePushConfig({ vapidPublicKey: 'k', gatewayUrl: 'https://g/_matrix/push/v1/notify' })
|
||||
).toBeUndefined();
|
||||
expect(activePushConfig({ vapidPublicKey: 'k', gatewayUrl: 'u', appId: 'a' })).toEqual({
|
||||
vapidPublicKey: 'k',
|
||||
gatewayUrl: 'u',
|
||||
appId: 'a',
|
||||
});
|
||||
});
|
||||
it('the shipped config.json carries the block empty, so push is off', () => {
|
||||
const cfg = JSON.parse(readFileSync(join(__dirname, '../../../../config.json'), 'utf8'));
|
||||
expect(cfg.push).toEqual({ vapidPublicKey: '', gatewayUrl: '', appId: '' });
|
||||
expect(activePushConfig(cfg.push)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
12
src/app/features/push/config.ts
Normal file
12
src/app/features/push/config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Web Push is off unless the host's config.json fills all three keys (ships empty). The public key is not a secret;
|
||||
// the gateway URL is the push gateway's notify endpoint the homeserver calls (`/_matrix/push/v1/notify`).
|
||||
export type PushConfig = {
|
||||
vapidPublicKey?: string;
|
||||
gatewayUrl?: string;
|
||||
appId?: string;
|
||||
};
|
||||
|
||||
export type ActivePushConfig = Required<PushConfig>;
|
||||
|
||||
export const activePushConfig = (c?: PushConfig): ActivePushConfig | undefined =>
|
||||
c?.vapidPublicKey && c.gatewayUrl && c.appId ? (c as ActivePushConfig) : undefined;
|
||||
29
src/app/features/push/manifest.test.ts
Normal file
29
src/app/features/push/manifest.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const ROOT = join(__dirname, '../../../..');
|
||||
const manifest = JSON.parse(readFileSync(join(ROOT, 'public/manifest.json'), 'utf8'));
|
||||
|
||||
describe('web app manifest', () => {
|
||||
it('has a stable identity and scope, and no orientation lock', () => {
|
||||
expect(manifest.id).toBe('/'); // what Chrome already derives from start_url "./" at the site root
|
||||
expect(manifest.scope).toBe('./');
|
||||
expect(manifest.start_url).toBe('./');
|
||||
expect(manifest.display).toBe('standalone');
|
||||
expect(manifest.orientation).toBeUndefined();
|
||||
});
|
||||
it('offers 192 and 512 icons for any use and a 512 maskable one, every file present', () => {
|
||||
const icons = manifest.icons as { src: string; sizes: string; purpose?: string }[];
|
||||
const any = icons.filter((i) => !i.purpose);
|
||||
expect(any.map((i) => i.sizes)).toEqual(expect.arrayContaining(['192x192', '512x512']));
|
||||
const maskable = icons.filter((i) => i.purpose === 'maskable');
|
||||
expect(maskable.map((i) => i.sizes)).toEqual(['512x512']);
|
||||
// the build copies public/res/android to public/android (vite.config.js)
|
||||
icons.forEach((i) =>
|
||||
expect(
|
||||
existsSync(join(ROOT, i.src.replace('./public/android/', 'public/res/android/')))
|
||||
).toBe(true)
|
||||
);
|
||||
});
|
||||
});
|
||||
108
src/app/features/push/payload.test.ts
Normal file
108
src/app/features/push/payload.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { describePush, GENERIC, parsePush } from './payload';
|
||||
import { matrixNotify, mockHomeserver, SESSION } from './testkit';
|
||||
|
||||
describe('parsePush', () => {
|
||||
it('reads the Matrix gateway body and the bare inner object', () => {
|
||||
const a = parsePush(matrixNotify('!r:x', '$e'));
|
||||
expect(a).toMatchObject({ roomId: '!r:x', eventId: '$e' });
|
||||
expect(parsePush({ room_id: '!r:x', event_id: '$e' })).toMatchObject({ roomId: '!r:x' });
|
||||
});
|
||||
it('rejects a body with no room id', () => {
|
||||
expect(parsePush({ notification: { event_id: '$e' } })).toBeUndefined();
|
||||
expect(parsePush(undefined)).toBeUndefined();
|
||||
expect(parsePush('x')).toBeUndefined();
|
||||
});
|
||||
it('carries text the gateway added', () => {
|
||||
expect(
|
||||
parsePush({
|
||||
notification: {
|
||||
room_id: '!r:x',
|
||||
room_name: 'Lobby',
|
||||
sender_display_name: 'Ann',
|
||||
content: { body: 'hi' },
|
||||
},
|
||||
})
|
||||
).toMatchObject({ roomName: 'Lobby', sender: 'Ann', text: 'hi' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('describePush', () => {
|
||||
const ids = { roomId: '!r:x', eventId: '$e' };
|
||||
const event = {
|
||||
type: 'm.room.message',
|
||||
sender: '@ann:x',
|
||||
content: { msgtype: 'm.text', body: 'hello there' },
|
||||
};
|
||||
|
||||
it('resolves ids through the homeserver with the stored token', async () => {
|
||||
const hs = mockHomeserver({
|
||||
token: 'tok-1',
|
||||
events: { $e: event },
|
||||
roomNames: { '!r:x': 'Lobby' },
|
||||
});
|
||||
expect(await describePush(ids, SESSION, hs.fetchFn)).toEqual({
|
||||
title: 'Lobby',
|
||||
body: 'ann: hello there',
|
||||
});
|
||||
expect(hs.seen).toHaveLength(2);
|
||||
expect(
|
||||
hs.seen.every(
|
||||
(r) => r.auth === 'Bearer tok-1' && r.url.startsWith('https://matrix.example/_matrix/')
|
||||
)
|
||||
).toBe(true);
|
||||
expect(hs.seen[0].url).toContain('/rooms/!r%3Ax/');
|
||||
});
|
||||
it('shows the generic notification with no session, and asks nobody', async () => {
|
||||
const hs = mockHomeserver({ token: 'tok-1' });
|
||||
expect(await describePush(ids, undefined, hs.fetchFn)).toEqual({
|
||||
title: 'Ratatoskr',
|
||||
body: 'New message',
|
||||
});
|
||||
expect(hs.seen).toHaveLength(0);
|
||||
});
|
||||
it('falls back when the token is refused, the network is down, or the event is encrypted', async () => {
|
||||
const stale = mockHomeserver({ token: 'other', events: { $e: event } });
|
||||
expect(await describePush(ids, SESSION, stale.fetchFn)).toEqual(GENERIC);
|
||||
const down = mockHomeserver({ token: 'tok-1', failAll: true });
|
||||
expect(await describePush(ids, SESSION, down.fetchFn)).toEqual(GENERIC);
|
||||
const enc = mockHomeserver({
|
||||
token: 'tok-1',
|
||||
events: {
|
||||
$e: {
|
||||
type: 'm.room.encrypted',
|
||||
sender: '@ann:x',
|
||||
content: { algorithm: 'm.megolm.v1.aes-sha2' },
|
||||
},
|
||||
},
|
||||
roomNames: { '!r:x': 'Lobby' },
|
||||
});
|
||||
expect(await describePush(ids, SESSION, enc.fetchFn)).toEqual({
|
||||
title: 'Lobby',
|
||||
body: 'New message',
|
||||
});
|
||||
});
|
||||
it('does not fetch what the push already carried', async () => {
|
||||
const hs = mockHomeserver({ token: 'tok-1' });
|
||||
const shown = await describePush(
|
||||
{ ...ids, roomName: 'R', sender: 'Bo', text: 'yo' },
|
||||
SESSION,
|
||||
hs.fetchFn
|
||||
);
|
||||
expect(shown).toEqual({ title: 'R', body: 'Bo: yo' });
|
||||
expect(hs.seen).toHaveLength(0);
|
||||
});
|
||||
it('clips a long body', async () => {
|
||||
const hs = mockHomeserver({
|
||||
token: 'tok-1',
|
||||
events: { $e: { ...event, content: { body: 'x'.repeat(500) } } },
|
||||
});
|
||||
const shown = await describePush(ids, SESSION, hs.fetchFn);
|
||||
expect(shown.body.length).toBeLessThanOrEqual(200);
|
||||
expect(shown.body.endsWith('…')).toBe(true);
|
||||
});
|
||||
it('shows the generic notification when the push names no room', async () => {
|
||||
const hs = mockHomeserver({ token: 'tok-1' });
|
||||
expect(await describePush(undefined, SESSION, hs.fetchFn)).toEqual(GENERIC);
|
||||
});
|
||||
});
|
||||
95
src/app/features/push/payload.ts
Normal file
95
src/app/features/push/payload.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import type { SessionRecord } from './sessionStore';
|
||||
|
||||
/** What a push names. The homeserver sends ids only (`push.include_content: false`); a gateway may add text. */
|
||||
export interface PushIds {
|
||||
roomId: string;
|
||||
eventId?: string;
|
||||
roomName?: string;
|
||||
sender?: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface Shown {
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined);
|
||||
|
||||
/** The Matrix push body is `{ notification: {...} }`; a gateway may forward the inner object. No room id: not ours to show. */
|
||||
export function parsePush(raw: unknown): PushIds | undefined {
|
||||
const top = (raw ?? {}) as Record<string, unknown>;
|
||||
const n = (top.notification ?? top) as Record<string, unknown>;
|
||||
const roomId = str(n.room_id);
|
||||
if (!roomId) return undefined;
|
||||
const content = (n.content ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
roomId,
|
||||
eventId: str(n.event_id),
|
||||
roomName: str(n.room_name),
|
||||
sender: str(n.sender_display_name) ?? str(n.sender),
|
||||
text: str(content.body),
|
||||
};
|
||||
}
|
||||
|
||||
export const GENERIC: Shown = { title: 'Ratatoskr', body: 'New message' };
|
||||
const MAX_BODY = 200;
|
||||
const clip = (s: string): string => (s.length > MAX_BODY ? `${s.slice(0, MAX_BODY - 1)}…` : s);
|
||||
const localpart = (userId: string): string => userId.replace(/^@/, '').split(':')[0];
|
||||
|
||||
type Fetch = (url: string, init: RequestInit) => Promise<Response>;
|
||||
|
||||
async function getJson(
|
||||
f: Fetch,
|
||||
session: SessionRecord,
|
||||
path: string,
|
||||
timeoutMs: number
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
try {
|
||||
const res = await f(`${session.baseUrl.replace(/\/+$/, '')}${path}`, {
|
||||
headers: { Authorization: `Bearer ${session.accessToken}` },
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
return res.ok ? ((await res.json()) as Record<string, unknown>) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Title and body for a push. Text the push carried wins. Otherwise, with a session, the event and the room's name are
|
||||
* fetched (clear-text rooms; an encrypted event shows the generic body). Every failure ends at the generic notification:
|
||||
* a push must always show something.
|
||||
*/
|
||||
export async function describePush(
|
||||
ids: PushIds | undefined,
|
||||
session: SessionRecord | undefined,
|
||||
f: Fetch,
|
||||
timeoutMs = 4000
|
||||
): Promise<Shown> {
|
||||
if (!ids) return GENERIC;
|
||||
let { roomName, sender, text } = ids;
|
||||
if (session && (!text || !roomName)) {
|
||||
const room = encodeURIComponent(ids.roomId);
|
||||
const [event, name] = await Promise.all([
|
||||
!text && ids.eventId
|
||||
? getJson(
|
||||
f,
|
||||
session,
|
||||
`/_matrix/client/v3/rooms/${room}/event/${encodeURIComponent(ids.eventId)}`,
|
||||
timeoutMs
|
||||
)
|
||||
: undefined,
|
||||
!roomName
|
||||
? getJson(f, session, `/_matrix/client/v3/rooms/${room}/state/m.room.name`, timeoutMs)
|
||||
: undefined,
|
||||
]);
|
||||
const content = (event?.content ?? {}) as Record<string, unknown>;
|
||||
if (event?.type === 'm.room.message') text = str(content.body);
|
||||
if (!sender && str(event?.sender)) sender = localpart(event?.sender as string);
|
||||
roomName = roomName ?? str(name?.name);
|
||||
}
|
||||
let { body } = GENERIC;
|
||||
if (text) body = sender ? `${sender}: ${text}` : text;
|
||||
return { title: roomName ?? GENERIC.title, body: clip(body) };
|
||||
}
|
||||
143
src/app/features/push/pusher.test.ts
Normal file
143
src/app/features/push/pusher.test.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { IPusherRequest } from 'matrix-js-sdk';
|
||||
import {
|
||||
disablePush,
|
||||
enablePush,
|
||||
PushManagerPort,
|
||||
pushIsOn,
|
||||
vapidKeyBytes,
|
||||
webPushPusher,
|
||||
} from './pusher';
|
||||
|
||||
const cfg = {
|
||||
vapidPublicKey: 'BAUD',
|
||||
gatewayUrl: 'https://matrix.example/_matrix/push/v1/notify',
|
||||
appId: 'org.gamecult.ratatoskr.web',
|
||||
};
|
||||
const key = (...b: number[]) => Uint8Array.from(b).buffer;
|
||||
|
||||
function subscription(unsubscribed: string[], serverKey?: ArrayBuffer): PushSubscription {
|
||||
return {
|
||||
options: { applicationServerKey: serverKey ?? vapidKeyBytes(cfg.vapidPublicKey).buffer },
|
||||
endpoint: 'https://push.example/send/abc',
|
||||
getKey: (n: string) => (n === 'p256dh' ? key(251, 255, 1) : key(250, 2)),
|
||||
unsubscribe: async () => {
|
||||
unsubscribed.push('x');
|
||||
return true;
|
||||
},
|
||||
} as unknown as PushSubscription;
|
||||
}
|
||||
|
||||
function world() {
|
||||
const calls: string[] = [];
|
||||
const pushers: IPusherRequest[] = [];
|
||||
let sub: PushSubscription | null = null;
|
||||
const unsub: string[] = [];
|
||||
const mx = {
|
||||
setPusher: async (p: IPusherRequest) => {
|
||||
calls.push(p.kind === null ? 'delete-pusher' : 'set-pusher');
|
||||
pushers.push(p);
|
||||
},
|
||||
// The account's pushers as the homeserver would list them: what setPusher last wrote, minus deletions.
|
||||
getPushers: async () => ({
|
||||
pushers: pushers
|
||||
.filter(
|
||||
(p, i) =>
|
||||
p.kind !== null &&
|
||||
!pushers.slice(i + 1).some((q) => q.kind === null && q.pushkey === p.pushkey)
|
||||
)
|
||||
.map((p) => ({ app_id: p.app_id, pushkey: p.pushkey })),
|
||||
}),
|
||||
};
|
||||
const push: PushManagerPort = {
|
||||
getSubscription: async () => sub,
|
||||
subscribe: async (o) => {
|
||||
calls.push(`subscribe:${o.userVisibleOnly}:${o.applicationServerKey.length}`);
|
||||
sub = subscription(unsub);
|
||||
return sub;
|
||||
},
|
||||
};
|
||||
const swapSub = (s: PushSubscription | null) => {
|
||||
sub = s;
|
||||
};
|
||||
return { mx, push, calls, pushers, unsub, swapSub };
|
||||
}
|
||||
|
||||
describe('web push pusher', () => {
|
||||
it('decodes the URL-safe VAPID key', () => {
|
||||
expect([...vapidKeyBytes('-_8')]).toEqual([251, 255]);
|
||||
});
|
||||
it('is the Sygnal webpush shape: app id, p256dh pushkey, subscription in data, ids only', () => {
|
||||
const p = webPushPusher(cfg, subscription([]), 'phone') as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
> & {
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(p).toMatchObject({
|
||||
kind: 'http',
|
||||
app_id: cfg.appId,
|
||||
pushkey: '-_8B',
|
||||
device_display_name: 'phone',
|
||||
append: false,
|
||||
});
|
||||
expect(p.data).toMatchObject({
|
||||
url: cfg.gatewayUrl,
|
||||
format: 'event_id_only',
|
||||
endpoint: 'https://push.example/send/abc',
|
||||
auth: '-gI',
|
||||
p256dh: '-_8B',
|
||||
});
|
||||
});
|
||||
it('enable subscribes with userVisibleOnly, then registers the pusher; a second enable reuses the subscription', async () => {
|
||||
const w = world();
|
||||
await enablePush(w.mx, w.push, cfg, 'phone');
|
||||
expect(w.calls).toEqual(['subscribe:true:3', 'set-pusher']);
|
||||
await enablePush(w.mx, w.push, cfg, 'phone');
|
||||
expect(w.calls).toEqual(['subscribe:true:3', 'set-pusher', 'set-pusher']);
|
||||
});
|
||||
it('disable deletes the pusher before the subscription, and does nothing when there is none', async () => {
|
||||
const w = world();
|
||||
await disablePush(w.mx, w.push, cfg);
|
||||
expect(w.calls).toEqual([]);
|
||||
await enablePush(w.mx, w.push, cfg, 'phone');
|
||||
await disablePush(w.mx, w.push, cfg);
|
||||
expect(w.calls.slice(-1)).toEqual(['delete-pusher']);
|
||||
expect(w.pushers.at(-1)).toMatchObject({ kind: null, app_id: cfg.appId, pushkey: '-_8B' });
|
||||
expect(w.unsub).toEqual(['x']);
|
||||
});
|
||||
it('a failed pusher write leaves the subscription alone', async () => {
|
||||
const w = world();
|
||||
await enablePush(w.mx, w.push, cfg, 'phone');
|
||||
w.mx.setPusher = async () => {
|
||||
throw new Error('homeserver said no');
|
||||
};
|
||||
await expect(disablePush(w.mx, w.push, cfg)).rejects.toThrow('homeserver said no');
|
||||
expect(w.unsub).toEqual([]);
|
||||
});
|
||||
it('the toggle is on only with a subscription and the pusher of this account for it', async () => {
|
||||
const w = world();
|
||||
expect(await pushIsOn(w.mx, w.push, cfg)).toBe(false);
|
||||
await enablePush(w.mx, w.push, cfg, 'phone');
|
||||
expect(await pushIsOn(w.mx, w.push, cfg)).toBe(true);
|
||||
// another account on the same browser: the subscription exists, its pusher does not
|
||||
const other = { ...w.mx, getPushers: async () => ({ pushers: [] }) };
|
||||
expect(await pushIsOn(other, w.push, cfg)).toBe(false);
|
||||
await disablePush(w.mx, w.push, cfg);
|
||||
expect(await pushIsOn(w.mx, w.push, cfg)).toBe(false);
|
||||
});
|
||||
it('a subscription made under another VAPID key is replaced, not reused', async () => {
|
||||
const w = world();
|
||||
const old = [] as string[];
|
||||
w.swapSub(subscription(old, Uint8Array.from([9, 9, 9]).buffer));
|
||||
await enablePush(w.mx, w.push, cfg, 'phone');
|
||||
expect(old).toEqual(['x']);
|
||||
expect(w.calls).toEqual(['subscribe:true:3', 'set-pusher']);
|
||||
});
|
||||
it('a failed pusher write after subscribing leaves the toggle off', async () => {
|
||||
const w = world();
|
||||
const fail = { ...w.mx, setPusher: async () => Promise.reject(new Error('no')) };
|
||||
await expect(enablePush(fail, w.push, cfg, 'phone')).rejects.toThrow('no');
|
||||
expect(await pushIsOn(w.mx, w.push, cfg)).toBe(false);
|
||||
});
|
||||
});
|
||||
107
src/app/features/push/pusher.ts
Normal file
107
src/app/features/push/pusher.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import type { IPusherRequest } from 'matrix-js-sdk';
|
||||
import type { ActivePushConfig } from './config';
|
||||
|
||||
/** What the page needs of the homeserver client and the browser's push manager, so tests supply fakes. */
|
||||
export interface PusherClient {
|
||||
setPusher(p: IPusherRequest): Promise<unknown>;
|
||||
getPushers(): Promise<{ pushers: { app_id: string; pushkey: string }[] }>;
|
||||
}
|
||||
export interface PushManagerPort {
|
||||
getSubscription(): Promise<PushSubscription | null>;
|
||||
subscribe(o: {
|
||||
userVisibleOnly: boolean;
|
||||
applicationServerKey: Uint8Array;
|
||||
}): Promise<PushSubscription>;
|
||||
}
|
||||
|
||||
export function vapidKeyBytes(b64url: string): Uint8Array {
|
||||
const pad = '='.repeat((4 - (b64url.length % 4)) % 4);
|
||||
const bin = atob((b64url + pad).replace(/-/g, '+').replace(/_/g, '/'));
|
||||
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
const b64url = (buf: ArrayBuffer | null): string =>
|
||||
btoa(String.fromCharCode(...new Uint8Array(buf ?? new ArrayBuffer(0))))
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
|
||||
/**
|
||||
* The Matrix pusher for a browser subscription (Sygnal's `webpush` shape: the subscription's keys ride in `data`; the
|
||||
* pushkey is the subscription's p256dh key, as Element Web does). `event_id_only` asks the homeserver for ids, which
|
||||
* the worker resolves itself.
|
||||
*/
|
||||
export function webPushPusher(
|
||||
cfg: ActivePushConfig,
|
||||
sub: PushSubscription,
|
||||
deviceName: string
|
||||
): IPusherRequest {
|
||||
const p256dh = b64url(sub.getKey('p256dh'));
|
||||
return {
|
||||
kind: 'http',
|
||||
app_id: cfg.appId,
|
||||
pushkey: p256dh,
|
||||
app_display_name: 'Ratatoskr',
|
||||
device_display_name: deviceName,
|
||||
lang: navigator.language || 'en',
|
||||
data: {
|
||||
url: cfg.gatewayUrl,
|
||||
format: 'event_id_only',
|
||||
endpoint: sub.endpoint,
|
||||
auth: b64url(sub.getKey('auth')),
|
||||
p256dh,
|
||||
},
|
||||
append: false,
|
||||
} as IPusherRequest;
|
||||
}
|
||||
|
||||
const sameKey = (sub: PushSubscription, vapid: Uint8Array): boolean => {
|
||||
const have = sub.options?.applicationServerKey;
|
||||
return !have || b64url(have) === b64url(vapid.buffer as ArrayBuffer);
|
||||
};
|
||||
|
||||
/** Subscribe (replacing a subscription made under another VAPID key), then register the pusher for this account. */
|
||||
export async function enablePush(
|
||||
mx: PusherClient,
|
||||
push: PushManagerPort,
|
||||
cfg: ActivePushConfig,
|
||||
deviceName: string
|
||||
): Promise<void> {
|
||||
const key = vapidKeyBytes(cfg.vapidPublicKey);
|
||||
let sub = await push.getSubscription();
|
||||
if (sub && !sameKey(sub, key)) {
|
||||
await sub.unsubscribe();
|
||||
sub = null;
|
||||
}
|
||||
sub = sub ?? (await push.subscribe({ userVisibleOnly: true, applicationServerKey: key }));
|
||||
await mx.setPusher(webPushPusher(cfg, sub, deviceName));
|
||||
}
|
||||
|
||||
/** On only when this browser holds a subscription AND this account has a pusher for it (the two live in different places). */
|
||||
export async function pushIsOn(
|
||||
mx: PusherClient,
|
||||
push: PushManagerPort,
|
||||
cfg: ActivePushConfig
|
||||
): Promise<boolean> {
|
||||
const sub = await push.getSubscription();
|
||||
if (!sub) return false;
|
||||
const pushkey = b64url(sub.getKey('p256dh'));
|
||||
const { pushers } = await mx.getPushers();
|
||||
return pushers.some((p) => p.app_id === cfg.appId && p.pushkey === pushkey);
|
||||
}
|
||||
|
||||
/** Delete this account's pusher, then the subscription. The order matters: a pusher with no subscription would keep failing at the gateway. */
|
||||
export async function disablePush(
|
||||
mx: PusherClient,
|
||||
push: PushManagerPort,
|
||||
cfg: ActivePushConfig
|
||||
): Promise<void> {
|
||||
const sub = await push.getSubscription();
|
||||
if (!sub) return;
|
||||
await mx.setPusher({
|
||||
kind: null,
|
||||
app_id: cfg.appId,
|
||||
pushkey: b64url(sub.getKey('p256dh')),
|
||||
} as unknown as IPusherRequest);
|
||||
await sub.unsubscribe();
|
||||
}
|
||||
111
src/app/features/push/sessionStore.test.ts
Normal file
111
src/app/features/push/sessionStore.test.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { idbPort, RecordPort, SessionRecord, sessionStore } from './sessionStore';
|
||||
|
||||
const memory = (): RecordPort & { held?: unknown } => {
|
||||
const m: RecordPort & { held?: unknown } = {
|
||||
async read() {
|
||||
return m.held;
|
||||
},
|
||||
async write(v) {
|
||||
m.held = v;
|
||||
},
|
||||
};
|
||||
return m;
|
||||
};
|
||||
|
||||
describe('sessionStore', () => {
|
||||
it('saves a valid session and loads it back', async () => {
|
||||
const s = sessionStore(memory());
|
||||
await s.save('https://hs/', 'tok');
|
||||
expect(await s.load()).toEqual({ baseUrl: 'https://hs/', accessToken: 'tok' });
|
||||
});
|
||||
it('deletes the record on logout, and on anything that is not two strings', async () => {
|
||||
const port = memory();
|
||||
const s = sessionStore(port);
|
||||
await s.save('https://hs/', 'tok');
|
||||
await s.save(undefined, undefined);
|
||||
expect(port.held).toBeUndefined();
|
||||
await s.save('https://hs/', 'tok');
|
||||
await s.save('https://hs/', 5);
|
||||
expect(await s.load()).toBeUndefined();
|
||||
});
|
||||
it('keeps the token only while a push subscription exists', async () => {
|
||||
const port = memory();
|
||||
const s = sessionStore(port);
|
||||
await s.saveIfSubscribed('https://hs/', 'tok', async () => false);
|
||||
expect(port.held).toBeUndefined();
|
||||
await s.saveIfSubscribed('https://hs/', 'tok', async () => true);
|
||||
expect(await s.load()).toEqual({ baseUrl: 'https://hs/', accessToken: 'tok' });
|
||||
await s.saveIfSubscribed('https://hs/', 'tok', async () => false);
|
||||
expect(port.held).toBeUndefined();
|
||||
await s.saveIfSubscribed('https://hs/', 'tok', async () => true);
|
||||
await s.saveIfSubscribed('https://hs/', 'tok', () =>
|
||||
Promise.reject(new Error('no push manager'))
|
||||
);
|
||||
expect(port.held).toBeUndefined();
|
||||
});
|
||||
it('ignores a record that is not a session', async () => {
|
||||
const port = memory();
|
||||
port.held = { baseUrl: 1 };
|
||||
expect(await sessionStore(port).load()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Just enough of IndexedDB for the port: one database, object stores of key -> value, async request callbacks.
|
||||
function fakeIndexedDB(): IDBFactory {
|
||||
const dbs = new Map<string, Map<string, Map<string, unknown>>>();
|
||||
const request = <T>(run: () => T) => {
|
||||
const r = {} as IDBRequest<T>;
|
||||
setTimeout(() => {
|
||||
(r as { result: T }).result = run();
|
||||
r.onsuccess?.(new Event('success'));
|
||||
}, 0);
|
||||
return r;
|
||||
};
|
||||
return {
|
||||
open(name: string) {
|
||||
const r = {} as IDBOpenDBRequest;
|
||||
setTimeout(() => {
|
||||
const fresh = !dbs.has(name);
|
||||
const stores = dbs.get(name) ?? new Map();
|
||||
dbs.set(name, stores);
|
||||
const db = {
|
||||
createObjectStore: (n: string) => stores.set(n, new Map()),
|
||||
close: () => undefined,
|
||||
transaction: (n: string) => {
|
||||
const tx = {} as IDBTransaction;
|
||||
const store = stores.get(n) as Map<string, unknown>;
|
||||
const api = {
|
||||
get: (k: string) => request(() => store.get(k)),
|
||||
put: (v: unknown, k: string) => {
|
||||
store.set(k, v);
|
||||
setTimeout(() => tx.oncomplete?.(new Event('complete')), 0);
|
||||
},
|
||||
delete: (k: string) => {
|
||||
store.delete(k);
|
||||
setTimeout(() => tx.oncomplete?.(new Event('complete')), 0);
|
||||
},
|
||||
};
|
||||
(tx as { objectStore: unknown }).objectStore = () => api;
|
||||
return tx;
|
||||
},
|
||||
} as unknown as IDBDatabase;
|
||||
(r as { result: IDBDatabase }).result = db;
|
||||
if (fresh) r.onupgradeneeded?.(new Event('upgradeneeded') as IDBVersionChangeEvent);
|
||||
r.onsuccess?.(new Event('success'));
|
||||
}, 0);
|
||||
return r;
|
||||
},
|
||||
} as unknown as IDBFactory;
|
||||
}
|
||||
|
||||
describe('idbPort', () => {
|
||||
it('round-trips the one record through IndexedDB and deletes it', async () => {
|
||||
const s = sessionStore(idbPort(fakeIndexedDB()));
|
||||
expect(await s.load()).toBeUndefined();
|
||||
await s.save('https://hs/', 'tok');
|
||||
expect(await s.load()).toEqual<SessionRecord>({ baseUrl: 'https://hs/', accessToken: 'tok' });
|
||||
await s.save(undefined, undefined);
|
||||
expect(await s.load()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
88
src/app/features/push/sessionStore.ts
Normal file
88
src/app/features/push/sessionStore.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// The one thing a push needs that a closed app cannot give: the access token, to fetch the event a push names.
|
||||
// Stored: `{ baseUrl, accessToken }`, one record, in this origin's IndexedDB `ratatoskr-sw` (not reachable by other
|
||||
// origins; the token is already in the page's own storage). Written when the page hands the worker its session
|
||||
// while this browser holds a push subscription; deleted when the page hands it none (logout) or the subscription is
|
||||
// gone. A browser that never turned push on stores nothing. Nothing else reads it but the push handler.
|
||||
export interface SessionRecord {
|
||||
baseUrl: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
/** The two operations the store needs from a key-value backend, so tests run without IndexedDB. */
|
||||
export interface RecordPort {
|
||||
read(): Promise<unknown>;
|
||||
write(value: SessionRecord | undefined): Promise<void>;
|
||||
}
|
||||
|
||||
const DB = 'ratatoskr-sw';
|
||||
const STORE = 'session';
|
||||
const KEY = 'session';
|
||||
|
||||
const done = <T>(req: IDBRequest<T>): Promise<T> =>
|
||||
new Promise((resolve, reject) => {
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
|
||||
export function idbPort(factory: IDBFactory): RecordPort {
|
||||
const open = (): Promise<IDBDatabase> => {
|
||||
const req = factory.open(DB, 1);
|
||||
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
||||
return done(req);
|
||||
};
|
||||
return {
|
||||
async read() {
|
||||
const db = await open();
|
||||
try {
|
||||
return await done(db.transaction(STORE).objectStore(STORE).get(KEY));
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
async write(value) {
|
||||
const db = await open();
|
||||
try {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
const store = tx.objectStore(STORE);
|
||||
const finished = new Promise<void>((resolve, reject) => {
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
if (value) store.put(value, KEY);
|
||||
else store.delete(KEY);
|
||||
await finished;
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function sessionStore(port: RecordPort) {
|
||||
return {
|
||||
/** A valid session replaces the record; anything else (logout) deletes it. */
|
||||
async save(baseUrl: unknown, accessToken: unknown): Promise<void> {
|
||||
await port.write(
|
||||
typeof baseUrl === 'string' && typeof accessToken === 'string' && baseUrl && accessToken
|
||||
? { baseUrl, accessToken }
|
||||
: undefined
|
||||
);
|
||||
},
|
||||
/** The token is kept only while this browser holds a push subscription; without one the record is deleted. */
|
||||
async saveIfSubscribed(
|
||||
baseUrl: unknown,
|
||||
accessToken: unknown,
|
||||
subscribed: () => Promise<boolean>
|
||||
): Promise<void> {
|
||||
const keep = await subscribed().catch(() => false);
|
||||
await this.save(keep ? baseUrl : undefined, keep ? accessToken : undefined);
|
||||
},
|
||||
async load(): Promise<SessionRecord | undefined> {
|
||||
const v = (await port.read()) as Partial<SessionRecord> | undefined;
|
||||
return v && typeof v.baseUrl === 'string' && typeof v.accessToken === 'string'
|
||||
? { baseUrl: v.baseUrl, accessToken: v.accessToken }
|
||||
: undefined;
|
||||
},
|
||||
};
|
||||
}
|
||||
41
src/app/features/push/testkit.ts
Normal file
41
src/app/features/push/testkit.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import type { SessionRecord } from './sessionStore';
|
||||
|
||||
/** A mock homeserver: answers the two reads the push handler makes, and records every request it saw. */
|
||||
export function mockHomeserver(opts: {
|
||||
token: string;
|
||||
events?: Record<string, unknown>;
|
||||
roomNames?: Record<string, string>;
|
||||
failAll?: boolean;
|
||||
}) {
|
||||
const seen: { url: string; auth: string | null }[] = [];
|
||||
const fetchFn = async (url: string, init: RequestInit): Promise<Response> => {
|
||||
const auth = new Headers(init.headers).get('Authorization');
|
||||
seen.push({ url, auth });
|
||||
if (opts.failAll) throw new Error('network down');
|
||||
if (auth !== `Bearer ${opts.token}`) return new Response('{}', { status: 401 });
|
||||
const ev = /\/rooms\/([^/]+)\/event\/([^/]+)$/.exec(url);
|
||||
if (ev) {
|
||||
const e = opts.events?.[decodeURIComponent(ev[2])];
|
||||
return e ? new Response(JSON.stringify(e)) : new Response('{}', { status: 404 });
|
||||
}
|
||||
const nm = /\/rooms\/([^/]+)\/state\/m\.room\.name$/.exec(url);
|
||||
if (nm) {
|
||||
const name = opts.roomNames?.[decodeURIComponent(nm[1])];
|
||||
return name ? new Response(JSON.stringify({ name })) : new Response('{}', { status: 404 });
|
||||
}
|
||||
return new Response('{}', { status: 404 });
|
||||
};
|
||||
return { fetchFn, seen };
|
||||
}
|
||||
|
||||
export const SESSION: SessionRecord = { baseUrl: 'https://matrix.example/', accessToken: 'tok-1' };
|
||||
|
||||
/** The body a homeserver posts to a push gateway for `format: event_id_only` (Matrix push gateway API). */
|
||||
export const matrixNotify = (roomId: string, eventId: string) => ({
|
||||
notification: {
|
||||
event_id: eventId,
|
||||
room_id: roomId,
|
||||
counts: { unread: 1 },
|
||||
devices: [{ app_id: 'org.gamecult.ratatoskr.web', pushkey: 'k', data: {} }],
|
||||
},
|
||||
});
|
||||
33
src/app/features/push/usePushOpen.ts
Normal file
33
src/app/features/push/usePushOpen.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { OPEN_ROOM, PUSH_EVENT_PARAM, PUSH_ROOM_PARAM } from './worker';
|
||||
|
||||
/** Opens the room a push notification named: from the worker's message (app open) or the start URL (app was closed). */
|
||||
export function usePushOpen(): void {
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const roomId = params.get(PUSH_ROOM_PARAM);
|
||||
if (roomId) {
|
||||
navigateRoom(roomId, params.get(PUSH_EVENT_PARAM) ?? undefined);
|
||||
params.delete(PUSH_ROOM_PARAM);
|
||||
params.delete(PUSH_EVENT_PARAM);
|
||||
const rest = params.toString();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`${window.location.pathname}${rest ? `?${rest}` : ''}${window.location.hash}`
|
||||
);
|
||||
}
|
||||
const onMessage = (ev: MessageEvent) => {
|
||||
if (ev.data?.type === OPEN_ROOM && typeof ev.data.roomId === 'string') {
|
||||
navigateRoom(
|
||||
ev.data.roomId,
|
||||
typeof ev.data.eventId === 'string' ? ev.data.eventId : undefined
|
||||
);
|
||||
}
|
||||
};
|
||||
navigator.serviceWorker?.addEventListener('message', onMessage);
|
||||
return () => navigator.serviceWorker?.removeEventListener('message', onMessage);
|
||||
}, [navigateRoom]);
|
||||
}
|
||||
108
src/app/features/push/worker.test.ts
Normal file
108
src/app/features/push/worker.test.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { handleClick, handlePush, OPEN_ROOM, WorkerPort } from './worker';
|
||||
import { matrixNotify, mockHomeserver, SESSION } from './testkit';
|
||||
|
||||
type Shown = { title: string; options: NotificationOptions & { renotify?: boolean } };
|
||||
|
||||
function port(over: Partial<WorkerPort> = {}) {
|
||||
const shown: Shown[] = [];
|
||||
const opened: string[] = [];
|
||||
const w: WorkerPort = {
|
||||
windows: async () => [],
|
||||
openWindow: async (u) => {
|
||||
opened.push(u);
|
||||
},
|
||||
show: async (title, options) => {
|
||||
shown.push({ title, options });
|
||||
},
|
||||
loadSession: async () => SESSION,
|
||||
fetch: mockHomeserver({ token: 'tok-1' }).fetchFn,
|
||||
scope: 'https://chat.example/',
|
||||
...over,
|
||||
};
|
||||
return { w, shown, opened };
|
||||
}
|
||||
|
||||
const msg = {
|
||||
type: 'm.room.message',
|
||||
sender: '@ann:x',
|
||||
content: { msgtype: 'm.text', body: 'hello' },
|
||||
};
|
||||
|
||||
describe('push against a mock gateway and homeserver', () => {
|
||||
it('a push the homeserver sent for event_id_only becomes a notification naming room, sender and text', async () => {
|
||||
const hs = mockHomeserver({
|
||||
token: 'tok-1',
|
||||
events: { $e: msg },
|
||||
roomNames: { '!r:x': 'Lobby' },
|
||||
});
|
||||
const { w, shown } = port({ fetch: hs.fetchFn });
|
||||
await handlePush(matrixNotify('!r:x', '$e'), w);
|
||||
expect(shown).toHaveLength(1);
|
||||
expect(shown[0].title).toBe('Lobby');
|
||||
expect(shown[0].options.body).toBe('ann: hello');
|
||||
expect(shown[0].options.tag).toBe('!r:x');
|
||||
expect(shown[0].options.renotify).toBe(true);
|
||||
expect(shown[0].options.data).toEqual({ roomId: '!r:x', eventId: '$e' });
|
||||
});
|
||||
it('with no stored session it still shows (a push must always show) and sends the token nowhere', async () => {
|
||||
const hs = mockHomeserver({ token: 'tok-1', events: { $e: msg } });
|
||||
const { w, shown } = port({ fetch: hs.fetchFn, loadSession: async () => undefined });
|
||||
await handlePush(matrixNotify('!r:x', '$e'), w);
|
||||
expect(shown[0]).toMatchObject({ title: 'Ratatoskr', options: { body: 'New message' } });
|
||||
expect(hs.seen).toHaveLength(0);
|
||||
});
|
||||
it('shows the generic notification for a body it cannot read', async () => {
|
||||
const { w, shown } = port();
|
||||
await handlePush(undefined, w);
|
||||
expect(shown[0].title).toBe('Ratatoskr');
|
||||
expect(shown[0].options.tag).toBeUndefined();
|
||||
expect(shown[0].options.data).toBeUndefined();
|
||||
});
|
||||
it('shows even when the window list cannot be read', async () => {
|
||||
const { w, shown } = port({ windows: () => Promise.reject(new Error('no clients')) });
|
||||
await handlePush(matrixNotify('!r:x', '$e'), w);
|
||||
expect(shown).toHaveLength(1);
|
||||
});
|
||||
it('shows nothing while a window is visible and focused, and shows when the window is hidden', async () => {
|
||||
const win = (visibilityState: string, focused: boolean) => ({
|
||||
visibilityState,
|
||||
focused,
|
||||
focus: async () => undefined,
|
||||
postMessage: () => undefined,
|
||||
});
|
||||
const a = port({ windows: async () => [win('visible', true)] });
|
||||
await handlePush(matrixNotify('!r:x', '$e'), a.w);
|
||||
expect(a.shown).toHaveLength(0);
|
||||
const b = port({ windows: async () => [win('hidden', false), win('visible', false)] });
|
||||
await handlePush(matrixNotify('!r:x', '$e'), b.w);
|
||||
expect(b.shown).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('notification click', () => {
|
||||
it('focuses the open window and tells it the room', async () => {
|
||||
const focus = vi.fn(async () => undefined);
|
||||
const postMessage = vi.fn();
|
||||
const { w, opened } = port({
|
||||
windows: async () => [{ visibilityState: 'hidden', focused: false, focus, postMessage }],
|
||||
});
|
||||
await handleClick({ roomId: '!r:x', eventId: '$e' }, w);
|
||||
expect(focus).toHaveBeenCalled();
|
||||
expect(postMessage).toHaveBeenCalledWith({ type: OPEN_ROOM, roomId: '!r:x', eventId: '$e' });
|
||||
expect(opened).toHaveLength(0);
|
||||
});
|
||||
it('with no window, starts the app with the room in the URL', async () => {
|
||||
const { w, opened } = port();
|
||||
await handleClick({ roomId: '!r:x', eventId: '$e' }, w);
|
||||
const u = new URL(opened[0]);
|
||||
expect(u.origin + u.pathname).toBe('https://chat.example/');
|
||||
expect(u.searchParams.get('pushRoom')).toBe('!r:x');
|
||||
expect(u.searchParams.get('pushEvent')).toBe('$e');
|
||||
});
|
||||
it('with no window and no target, just opens the app', async () => {
|
||||
const { w, opened } = port();
|
||||
await handleClick(undefined, w);
|
||||
expect(opened).toEqual(['https://chat.example/']);
|
||||
});
|
||||
});
|
||||
62
src/app/features/push/worker.ts
Normal file
62
src/app/features/push/worker.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { describePush, parsePush } from './payload';
|
||||
import type { SessionRecord } from './sessionStore';
|
||||
|
||||
/** The slice of the service worker's scope the handlers use, so they run in tests without a worker. */
|
||||
export interface WorkerPort {
|
||||
windows(): Promise<
|
||||
{
|
||||
visibilityState: string;
|
||||
focused: boolean;
|
||||
focus(): Promise<unknown>;
|
||||
postMessage(m: unknown): void;
|
||||
}[]
|
||||
>;
|
||||
openWindow(url: string): Promise<unknown>;
|
||||
show(title: string, options: NotificationOptions): Promise<void>;
|
||||
loadSession(): Promise<SessionRecord | undefined>;
|
||||
fetch(url: string, init: RequestInit): Promise<Response>;
|
||||
/** The worker's scope URL: where a cold click opens the app. */
|
||||
scope: string;
|
||||
}
|
||||
|
||||
export interface PushTarget {
|
||||
roomId: string;
|
||||
eventId?: string;
|
||||
}
|
||||
|
||||
/** A push shows a notification, unless the app is open and focused (browsers allow that, and the page notifies itself). */
|
||||
export async function handlePush(raw: unknown, w: WorkerPort): Promise<void> {
|
||||
const ids = parsePush(raw);
|
||||
const open = await w.windows().catch(() => []);
|
||||
if (open.some((c) => c.visibilityState === 'visible' && c.focused)) return;
|
||||
const shown = await describePush(ids, await w.loadSession(), w.fetch);
|
||||
await w.show(shown.title, {
|
||||
body: shown.body,
|
||||
// One notification per room, replaced (and re-alerted) by the next message there.
|
||||
tag: ids?.roomId,
|
||||
renotify: !!ids?.roomId,
|
||||
icon: './public/android/android-chrome-192x192.png',
|
||||
data: ids ? ({ roomId: ids.roomId, eventId: ids.eventId } satisfies PushTarget) : undefined,
|
||||
} as NotificationOptions);
|
||||
}
|
||||
|
||||
export const OPEN_ROOM = 'openRoom';
|
||||
export const PUSH_ROOM_PARAM = 'pushRoom';
|
||||
export const PUSH_EVENT_PARAM = 'pushEvent';
|
||||
|
||||
/** A click focuses an open window and tells it which room; with none open it starts the app with the room in the URL. */
|
||||
export async function handleClick(target: PushTarget | undefined, w: WorkerPort): Promise<void> {
|
||||
const windows = await w.windows();
|
||||
const open = windows[0];
|
||||
if (open) {
|
||||
await open.focus();
|
||||
if (target?.roomId) open.postMessage({ type: OPEN_ROOM, ...target });
|
||||
return;
|
||||
}
|
||||
const url = new URL(w.scope);
|
||||
if (target?.roomId) {
|
||||
url.searchParams.set(PUSH_ROOM_PARAM, target.roomId);
|
||||
if (target.eventId) url.searchParams.set(PUSH_EVENT_PARAM, target.eventId);
|
||||
}
|
||||
await w.openWindow(url.href);
|
||||
}
|
||||
|
|
@ -66,6 +66,7 @@ import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
|
|||
import { livekitSupport } from '../../hooks/useLivekitSupport';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
import { PhoneHitSpread24 } from '../../styles/PhoneHit.css';
|
||||
|
||||
type RoomNavItemMenuProps = {
|
||||
room: Room;
|
||||
|
|
@ -229,6 +230,7 @@ function CallChatToggle() {
|
|||
|
||||
return (
|
||||
<IconButton
|
||||
className={PhoneHitSpread24}
|
||||
onClick={() => setChat(!chat)}
|
||||
aria-pressed={chat}
|
||||
aria-label="Toggle Chat"
|
||||
|
|
@ -436,6 +438,7 @@ export function RoomNavItem({
|
|||
}
|
||||
>
|
||||
<IconButton
|
||||
className={PhoneHitSpread24}
|
||||
onClick={handleOpenMenu}
|
||||
aria-pressed={!!menuAnchor}
|
||||
aria-controls={`menu-${room.roomId}`}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { Permissions } from './permissions';
|
|||
import { RoomSettingsPage } from '../../state/roomSettings';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { DeveloperTools } from '../common-settings/developer-tools';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
type RoomSettingsMenuItem = {
|
||||
page: RoomSettingsPage;
|
||||
|
|
@ -117,7 +118,7 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
|
|||
</Box>
|
||||
<Box shrink="No">
|
||||
{screenSize === ScreenSize.Mobile && (
|
||||
<IconButton onClick={requestClose} variant="Background">
|
||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Background">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
} from '../../common-settings/general';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
type GeneralProps = {
|
||||
requestClose: () => void;
|
||||
|
|
@ -35,7 +36,7 @@ export function General({ requestClose }: GeneralProps) {
|
|||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { usePermissionGroups } from './usePermissionItems';
|
|||
import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
type PermissionsProps = {
|
||||
requestClose: () => void;
|
||||
|
|
@ -45,7 +46,7 @@ export function Permissions({ requestClose }: PermissionsProps) {
|
|||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Page, PageHeader } from '../../components/page';
|
|||
import { callChatAtom } from '../../state/callEmbed';
|
||||
import { RoomView } from './RoomView';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
export function CallChatView() {
|
||||
const { eventId } = useParams();
|
||||
|
|
@ -41,7 +42,12 @@ export function CallChatView() {
|
|||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton ref={triggerRef} variant="Surface" onClick={handleClose}>
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
ref={triggerRef}
|
||||
variant="Surface"
|
||||
onClick={handleClose}
|
||||
>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
|
|||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
type MemberDrawerHeaderProps = {
|
||||
room: Room;
|
||||
|
|
@ -87,6 +88,7 @@ function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) {
|
|||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
ref={triggerRef}
|
||||
variant="Background"
|
||||
onClick={() => setPeopleDrawer(false)}
|
||||
|
|
@ -359,6 +361,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
|||
|
||||
<ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}>
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
onClick={() => virtualizer.scrollToOffset(0)}
|
||||
variant="Surface"
|
||||
radii="Pill"
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ import {
|
|||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { RoomOutbox } from './RoomOutbox';
|
||||
import { sendTracked } from '../../state/room/outboxSend';
|
||||
import { clearDraft, loadDraft, saveDraft } from '../../state/room/composerStore';
|
||||
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import {
|
||||
|
|
@ -113,6 +116,7 @@ import { useTheme } from '../../hooks/useTheme';
|
|||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||
import { useComposingCheck } from '../../hooks/useComposingCheck';
|
||||
import { PhoneHitSpread24, PhoneHitSpread32 } from '../../styles/PhoneHit.css';
|
||||
|
||||
interface RoomInputProps {
|
||||
editor: Editor;
|
||||
|
|
@ -221,22 +225,66 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
useCallback((width) => setHideStickerBtn(width < 500), [])
|
||||
);
|
||||
|
||||
const userId = mx.getUserId() ?? '';
|
||||
const draftSaveTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
// The atom is only a cache: after a reload it is empty and the stored draft is the truth.
|
||||
useEffect(() => {
|
||||
if (msgDraft.length === 0) {
|
||||
const stored = loadDraft(userId, roomId);
|
||||
if (stored.length > 0) setMsgDraft(stored);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId, roomId]);
|
||||
|
||||
useEffect(() => {
|
||||
Transforms.insertFragment(editor, msgDraft);
|
||||
}, [editor, msgDraft]);
|
||||
|
||||
const persistDraft = useCallback(() => {
|
||||
clearTimeout(draftSaveTimer.current);
|
||||
draftSaveTimer.current = undefined;
|
||||
saveDraft(userId, roomId, editor.children);
|
||||
}, [userId, roomId, editor]);
|
||||
|
||||
// Write on input (debounced), and flush when the page is hidden or unloading, which is
|
||||
// all a mobile reload gives us.
|
||||
const handleEditorChange = useCallback(() => {
|
||||
clearTimeout(draftSaveTimer.current);
|
||||
draftSaveTimer.current = setTimeout(persistDraft, 400);
|
||||
}, [persistDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
const flush = () => {
|
||||
if (draftSaveTimer.current !== undefined) persistDraft();
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'hidden') flush();
|
||||
};
|
||||
window.addEventListener('pagehide', flush);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
return () => {
|
||||
window.removeEventListener('pagehide', flush);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
};
|
||||
}, [persistDraft]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearTimeout(draftSaveTimer.current);
|
||||
draftSaveTimer.current = undefined;
|
||||
if (!isEmptyEditor(editor)) {
|
||||
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
||||
setMsgDraft(parsedDraft);
|
||||
saveDraft(userId, roomId, parsedDraft);
|
||||
} else {
|
||||
setMsgDraft([]);
|
||||
clearDraft(userId, roomId);
|
||||
}
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
},
|
||||
[roomId, editor, setMsgDraft]
|
||||
[roomId, editor, setMsgDraft, userId]
|
||||
);
|
||||
|
||||
const handleFileMetadata = useCallback(
|
||||
|
|
@ -287,7 +335,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
});
|
||||
handleCancelUpload(uploads);
|
||||
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
||||
contents.forEach((content) => mx.sendMessage(roomId, content as any));
|
||||
contents.forEach((content) => sendTracked(mx, roomId, content as Record<string, unknown>));
|
||||
};
|
||||
|
||||
const submit = useCallback(() => {
|
||||
|
|
@ -366,12 +414,25 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
content['m.relates_to'].is_falling_back = false;
|
||||
}
|
||||
}
|
||||
mx.sendMessage(roomId, content as any);
|
||||
sendTracked(mx, roomId, content);
|
||||
clearTimeout(draftSaveTimer.current);
|
||||
draftSaveTimer.current = undefined;
|
||||
clearDraft(userId, roomId);
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
setReplyDraft(undefined);
|
||||
sendTypingStatus(false);
|
||||
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
|
||||
}, [
|
||||
mx,
|
||||
roomId,
|
||||
userId,
|
||||
editor,
|
||||
replyDraft,
|
||||
sendTypingStatus,
|
||||
setReplyDraft,
|
||||
isMarkdown,
|
||||
commands,
|
||||
]);
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||
(evt) => {
|
||||
|
|
@ -433,11 +494,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
await getImageUrlBlob(stickerUrl)
|
||||
);
|
||||
|
||||
mx.sendEvent(roomId, EventType.Sticker, {
|
||||
body: label,
|
||||
url: mxc,
|
||||
info,
|
||||
});
|
||||
sendTracked(mx, roomId, { body: label, url: mxc, info }, EventType.Sticker);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -537,47 +594,53 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
onPaste={handlePaste}
|
||||
onChange={handleEditorChange}
|
||||
top={
|
||||
replyDraft && (
|
||||
<div>
|
||||
<Box
|
||||
alignItems="Center"
|
||||
gap="300"
|
||||
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
|
||||
>
|
||||
<IconButton
|
||||
onClick={() => setReplyDraft(undefined)}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
<>
|
||||
<RoomOutbox mx={mx} roomId={roomId} />
|
||||
{replyDraft && (
|
||||
<div>
|
||||
<Box
|
||||
alignItems="Center"
|
||||
gap="300"
|
||||
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
|
||||
>
|
||||
<Icon src={Icons.Cross} size="50" />
|
||||
</IconButton>
|
||||
<Box direction="Row" gap="200" alignItems="Center">
|
||||
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
|
||||
<ReplyLayout
|
||||
userColor={replyUsernameColor}
|
||||
username={
|
||||
<Text size="T300" truncate>
|
||||
<b>
|
||||
{getMemberDisplayName(room, replyDraft.userId) ??
|
||||
getMxIdLocalPart(replyDraft.userId) ??
|
||||
replyDraft.userId}
|
||||
</b>
|
||||
</Text>
|
||||
}
|
||||
<IconButton
|
||||
className={PhoneHitSpread24}
|
||||
onClick={() => setReplyDraft(undefined)}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Text size="T300" truncate>
|
||||
{trimReplyFromBody(replyDraft.body)}
|
||||
</Text>
|
||||
</ReplyLayout>
|
||||
<Icon src={Icons.Cross} size="50" />
|
||||
</IconButton>
|
||||
<Box direction="Row" gap="200" alignItems="Center">
|
||||
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
|
||||
<ReplyLayout
|
||||
userColor={replyUsernameColor}
|
||||
username={
|
||||
<Text size="T300" truncate>
|
||||
<b>
|
||||
{getMemberDisplayName(room, replyDraft.userId) ??
|
||||
getMxIdLocalPart(replyDraft.userId) ??
|
||||
replyDraft.userId}
|
||||
</b>
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Text size="T300" truncate>
|
||||
{trimReplyFromBody(replyDraft.body)}
|
||||
</Text>
|
||||
</ReplyLayout>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</div>
|
||||
)
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
before={
|
||||
<IconButton
|
||||
className={PhoneHitSpread32}
|
||||
onClick={() => pickFile('*')}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
|
|
@ -589,6 +652,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
after={
|
||||
<>
|
||||
<IconButton
|
||||
className={PhoneHitSpread32}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
|
|
@ -631,6 +695,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
>
|
||||
{!hideStickerBtn && (
|
||||
<IconButton
|
||||
className={PhoneHitSpread32}
|
||||
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
|
||||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
|
||||
variant="SurfaceVariant"
|
||||
|
|
@ -644,6 +709,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
className={PhoneHitSpread32}
|
||||
ref={emojiBtnRef}
|
||||
aria-pressed={
|
||||
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
||||
|
|
@ -663,7 +729,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
</PopOut>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
|
||||
<IconButton
|
||||
className={PhoneHitSpread32}
|
||||
onClick={submit}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon src={Icons.Send} />
|
||||
</IconButton>
|
||||
</>
|
||||
|
|
|
|||
102
src/app/features/room/RoomOutbox.actions.test.tsx
Normal file
102
src/app/features/room/RoomOutbox.actions.test.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { enqueueOutbox, failOutbox, loadOutbox } from '../../state/room/composerStore';
|
||||
import { RoomOutbox } from './RoomOutbox';
|
||||
|
||||
// The component's hooks are held still (state is its initial value, effects do not run) so the element tree
|
||||
// it returns can be read and its click handlers called without a DOM.
|
||||
vi.mock('react', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react')>();
|
||||
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<string, string>();
|
||||
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>(() => {
|
||||
// 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' });
|
||||
});
|
||||
});
|
||||
64
src/app/features/room/RoomOutbox.render.test.tsx
Normal file
64
src/app/features/room/RoomOutbox.render.test.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { enqueueOutbox, failOutbox } from '../../state/room/composerStore';
|
||||
import { RoomOutbox } from './RoomOutbox';
|
||||
|
||||
// What the person sees under the timeline for sends the server has not confirmed.
|
||||
const text = (html: string) => html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ');
|
||||
|
||||
describe('RoomOutbox as rendered', () => {
|
||||
beforeEach(() => {
|
||||
const m = new Map<string, string>();
|
||||
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(<RoomOutbox mx={mx} roomId="!r:x" />)).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(<RoomOutbox mx={mx} roomId="!r:x" />);
|
||||
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(<RoomOutbox mx={mx} roomId="!r:x" />);
|
||||
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(<RoomOutbox mx={mx} roomId="!r:x" />);
|
||||
expect(text(html)).toContain('Interrupted by a reload');
|
||||
expect(text(html)).toContain('Resend');
|
||||
});
|
||||
});
|
||||
28
src/app/features/room/RoomOutbox.test.ts
Normal file
28
src/app/features/room/RoomOutbox.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { SLOW_SEND_MS, outboxView } 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,
|
||||
});
|
||||
});
|
||||
|
||||
it('offers Resend to a pending send at 30 s and not a millisecond before', () => {
|
||||
const at = (ms: number) => outboxView({ status: 'pending', ts: 100 }, 100 + ms, 0).canResend;
|
||||
expect([at(29_999), at(30_000)]).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('an entry older than this page load reads as interrupted', () => {
|
||||
expect(outboxView({ status: 'pending', ts: 5 }, 6, 10).label).toMatch(/Interrupted/);
|
||||
});
|
||||
});
|
||||
121
src/app/features/room/RoomOutbox.tsx
Normal file
121
src/app/features/room/RoomOutbox.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import { Box, Chip, Text, color, config } from 'folds';
|
||||
import {
|
||||
OutboxEntry,
|
||||
loadOutbox,
|
||||
removeOutbox,
|
||||
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;
|
||||
|
||||
export type OutboxView = { label: string; failed: boolean; canResend: boolean };
|
||||
|
||||
export const outboxView = (
|
||||
entry: Pick<OutboxEntry, 'status' | 'ts' | 'error'>,
|
||||
now: number,
|
||||
since: number = PAGE_START
|
||||
): OutboxView => {
|
||||
if (entry.status === 'failed') {
|
||||
return {
|
||||
label: `Not sent${entry.error ? `: ${entry.error}` : ''}`,
|
||||
failed: true,
|
||||
canResend: true,
|
||||
};
|
||||
}
|
||||
if (entry.ts < since) {
|
||||
return {
|
||||
label: 'Interrupted by a reload: may not have been sent',
|
||||
failed: false,
|
||||
canResend: true,
|
||||
};
|
||||
}
|
||||
if (now - entry.ts >= SLOW_SEND_MS) {
|
||||
return {
|
||||
label: 'Still sending: slow connection (Resend may duplicate it if it lands later)',
|
||||
failed: false,
|
||||
canResend: true,
|
||||
};
|
||||
}
|
||||
return { label: 'Sending…', failed: false, canResend: false };
|
||||
};
|
||||
|
||||
export const useRoomOutbox = (userId: string, roomId: string): OutboxEntry[] => {
|
||||
const read = () => loadOutbox(userId).filter((e) => e.roomId === roomId);
|
||||
const [entries, setEntries] = useState(read);
|
||||
useEffect(() => {
|
||||
setEntries(read());
|
||||
return subscribeOutbox(() => setEntries(read()));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId, roomId]);
|
||||
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;
|
||||
};
|
||||
|
||||
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.
|
||||
*/
|
||||
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 (
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
|
||||
>
|
||||
{entries.map((entry) => {
|
||||
const view = outboxView(entry, now);
|
||||
return (
|
||||
<Box key={entry.txnId} alignItems="Center" gap="200" data-outbox-status={entry.status}>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text
|
||||
size="T200"
|
||||
style={{ color: view.failed ? color.Critical.Main : color.Surface.OnContainer }}
|
||||
>
|
||||
{view.label}
|
||||
</Text>
|
||||
<Text size="T300" truncate>
|
||||
{String(entry.content.body ?? '')}
|
||||
</Text>
|
||||
</Box>
|
||||
{view.canResend && (
|
||||
<>
|
||||
<Chip variant="Surface" radii="Pill" onClick={() => resendTracked(mx, entry)}>
|
||||
<Text size="B300">Resend</Text>
|
||||
</Chip>
|
||||
<Chip
|
||||
variant="Surface"
|
||||
radii="Pill"
|
||||
onClick={() => removeOutbox(userId, entry.txnId)}
|
||||
>
|
||||
<Text size="B300">Discard</Text>
|
||||
</Chip>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -47,6 +47,7 @@ import {
|
|||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { isUnconfirmedLocalEcho } from './localEcho';
|
||||
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
||||
|
|
@ -389,6 +390,19 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void)
|
|||
}, [room, onArrive]);
|
||||
};
|
||||
|
||||
/** A local echo changes status (or becomes its remote echo) without a Timeline event: redraw. */
|
||||
const useLocalEchoRefresh = (room: Room, onUpdate: () => void) => {
|
||||
useEffect(() => {
|
||||
const handle: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (_evt, r) => {
|
||||
if (r.roomId === room.roomId) onUpdate();
|
||||
};
|
||||
room.on(RoomEvent.LocalEchoUpdated, handle);
|
||||
return () => {
|
||||
room.removeListener(RoomEvent.LocalEchoUpdated, handle);
|
||||
};
|
||||
}, [room, onUpdate]);
|
||||
};
|
||||
|
||||
const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => {
|
||||
useEffect(() => {
|
||||
const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r) => {
|
||||
|
|
@ -648,6 +662,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
)
|
||||
);
|
||||
|
||||
useLocalEchoRefresh(
|
||||
room,
|
||||
useCallback(() => {
|
||||
// the echo was hidden while unconfirmed, so its arrival scrolled to nothing: scroll now
|
||||
if (atBottomRef.current) {
|
||||
scrollToBottomRef.current.count += 1;
|
||||
scrollToBottomRef.current.smooth = true;
|
||||
}
|
||||
setTimeline((ct) => ({ ...ct }));
|
||||
}, [])
|
||||
);
|
||||
|
||||
const handleOpenEvent = useCallback(
|
||||
async (
|
||||
evtId: string,
|
||||
|
|
@ -1646,6 +1672,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
if (mEvent.isRedacted() && !showHiddenEvents) {
|
||||
return null;
|
||||
}
|
||||
if (isUnconfirmedLocalEcho(mEvent)) return null;
|
||||
|
||||
if (!newDivider && readUptoEventIdRef.current) {
|
||||
newDivider = prevEvent?.getId() === readUptoEventIdRef.current;
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ import { RoomSettingsPage } from '../../state/roomSettings';
|
|||
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
|
||||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
import { PhoneHitSpread40 } from '../../styles/PhoneHit.css';
|
||||
|
||||
type RoomMenuProps = {
|
||||
room: Room;
|
||||
|
|
@ -336,6 +337,7 @@ function CallButton() {
|
|||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread40}
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
ref={triggerRef}
|
||||
|
|
@ -455,7 +457,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||
<BackRouteHandler>
|
||||
{(onBack) => (
|
||||
<Box shrink="No" alignItems="Center">
|
||||
<IconButton fill="None" onClick={onBack}>
|
||||
<IconButton className={PhoneHitSpread40} fill="None" onClick={onBack}>
|
||||
<Icon src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
@ -531,7 +533,12 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton fill="None" ref={triggerRef} onClick={handleSearchClick}>
|
||||
<IconButton
|
||||
className={PhoneHitSpread40}
|
||||
fill="None"
|
||||
ref={triggerRef}
|
||||
onClick={handleSearchClick}
|
||||
>
|
||||
<Icon size="400" src={Icons.Search} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
@ -548,6 +555,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread40}
|
||||
fill="None"
|
||||
style={{ position: 'relative' }}
|
||||
onClick={handleOpenPinMenu}
|
||||
|
|
@ -612,7 +620,12 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton fill="None" ref={triggerRef} onClick={handleMemberToggle}>
|
||||
<IconButton
|
||||
className={PhoneHitSpread40}
|
||||
fill="None"
|
||||
ref={triggerRef}
|
||||
onClick={handleMemberToggle}
|
||||
>
|
||||
<Icon size="400" src={Icons.User} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
@ -631,6 +644,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
className={PhoneHitSpread40}
|
||||
fill="None"
|
||||
onClick={handleOpenMenu}
|
||||
ref={triggerRef}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { getMxIdLocalPart } from '../../utils/matrix';
|
|||
import * as css from './RoomViewTyping.css';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
export type RoomViewTypingProps = {
|
||||
room: Room;
|
||||
|
|
@ -111,7 +112,13 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
|
|||
</>
|
||||
)}
|
||||
</Text>
|
||||
<IconButton title="Drop Typing Status" size="300" radii="Pill" onClick={handleDropAll}>
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
title="Drop Typing Status"
|
||||
size="300"
|
||||
radii="Pill"
|
||||
onClick={handleDropAll}
|
||||
>
|
||||
<Icon size="50" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../
|
|||
import { DatePicker, TimePicker } from '../../../components/time-date';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
type JumpToTimeProps = {
|
||||
onCancel: () => void;
|
||||
|
|
@ -108,7 +109,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">Jump to Time</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={onCancel} radii="300">
|
||||
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
20
src/app/features/room/localEcho.test.ts
Normal file
20
src/app/features/room/localEcho.test.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { EventStatus } from 'matrix-js-sdk';
|
||||
import { isUnconfirmedLocalEcho } from './localEcho';
|
||||
|
||||
describe('isUnconfirmedLocalEcho', () => {
|
||||
it.each([
|
||||
EventStatus.ENCRYPTING,
|
||||
EventStatus.QUEUED,
|
||||
EventStatus.SENDING,
|
||||
EventStatus.NOT_SENT,
|
||||
EventStatus.CANCELLED,
|
||||
])('hides %s', (status) => {
|
||||
expect(isUnconfirmedLocalEcho({ status })).toBe(true);
|
||||
});
|
||||
|
||||
it('shows a server-confirmed event and one the server has accepted (SENT)', () => {
|
||||
expect(isUnconfirmedLocalEcho({ status: null })).toBe(false);
|
||||
expect(isUnconfirmedLocalEcho({ status: EventStatus.SENT })).toBe(false);
|
||||
});
|
||||
});
|
||||
10
src/app/features/room/localEcho.ts
Normal file
10
src/app/features/room/localEcho.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { EventStatus, MatrixEvent } from 'matrix-js-sdk';
|
||||
|
||||
/**
|
||||
* A local echo the server has not accepted: the SDK adds it to the live timeline the moment
|
||||
* sendMessage is called, marks it NOT_SENT on failure, and drops it on reload. Rendered like
|
||||
* any message it looks delivered and then vanishes. The outbox (RoomOutbox) is the one place
|
||||
* that shows unconfirmed sends, so the timeline hides these until the status is SENT.
|
||||
*/
|
||||
export const isUnconfirmedLocalEcho = (mEvent: Pick<MatrixEvent, 'status'>): boolean =>
|
||||
mEvent.status !== null && mEvent.status !== undefined && mEvent.status !== EventStatus.SENT;
|
||||
|
|
@ -86,6 +86,7 @@ import colorMXID from '../../../../util/colorMXID';
|
|||
import { useSkyInk } from '../../../hooks/useTheme';
|
||||
import { userColorInk } from '../../scene/skyInk';
|
||||
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
||||
import { PhoneHitRoom26, PhoneHitSpread32 } from '../../../styles/PhoneHit.css';
|
||||
|
||||
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
|
||||
|
||||
|
|
@ -111,7 +112,7 @@ export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
|
|||
{recentEmojis.map((emoji) => (
|
||||
<IconButton
|
||||
key={emoji.unicode}
|
||||
className={css.MessageQuickReaction}
|
||||
className={classNames(css.MessageQuickReaction, PhoneHitSpread32)}
|
||||
size="300"
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
|
|
@ -459,7 +460,12 @@ export const MessageDeleteItem = as<
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">Delete Message</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={handleClose} radii="300">
|
||||
<IconButton
|
||||
className={PhoneHitSpread32}
|
||||
size="300"
|
||||
onClick={handleClose}
|
||||
radii="300"
|
||||
>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
@ -589,7 +595,12 @@ export const MessageReportItem = as<
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">Report Message</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={handleClose} radii="300">
|
||||
<IconButton
|
||||
className={PhoneHitSpread32}
|
||||
size="300"
|
||||
onClick={handleClose}
|
||||
radii="300"
|
||||
>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
@ -950,6 +961,7 @@ export const Message = as<'div', MessageProps>(
|
|||
}
|
||||
>
|
||||
<IconButton
|
||||
className={PhoneHitRoom26}
|
||||
onClick={handleOpenEmojiBoard}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
|
|
@ -961,6 +973,7 @@ export const Message = as<'div', MessageProps>(
|
|||
</PopOut>
|
||||
)}
|
||||
<IconButton
|
||||
className={PhoneHitRoom26}
|
||||
onClick={onReplyClick}
|
||||
data-event-id={mEvent.getId()}
|
||||
variant="SurfaceVariant"
|
||||
|
|
@ -971,6 +984,7 @@ export const Message = as<'div', MessageProps>(
|
|||
</IconButton>
|
||||
{!isThreadedMessage && (
|
||||
<IconButton
|
||||
className={PhoneHitRoom26}
|
||||
onClick={(ev) => onReplyClick(ev, true)}
|
||||
data-event-id={mEvent.getId()}
|
||||
variant="SurfaceVariant"
|
||||
|
|
@ -982,6 +996,7 @@ export const Message = as<'div', MessageProps>(
|
|||
)}
|
||||
{canEditEvent(mx, mEvent) && onEditId && (
|
||||
<IconButton
|
||||
className={PhoneHitRoom26}
|
||||
onClick={() => onEditId(mEvent.getId())}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
|
|
@ -1147,6 +1162,7 @@ export const Message = as<'div', MessageProps>(
|
|||
}
|
||||
>
|
||||
<IconButton
|
||||
className={PhoneHitRoom26}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
|
|
@ -1320,6 +1336,7 @@ export const Event = as<'div', EventProps>(
|
|||
}
|
||||
>
|
||||
<IconButton
|
||||
className={PhoneHitRoom26}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
|||
import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room';
|
||||
import { mobileOrTablet } from '../../../utils/user-agent';
|
||||
import { useComposingCheck } from '../../../hooks/useComposingCheck';
|
||||
import { PhoneHitSpread32 } from '../../../styles/PhoneHit.css';
|
||||
|
||||
type MessageEditorProps = {
|
||||
roomId: string;
|
||||
|
|
@ -289,6 +290,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||
</Box>
|
||||
<Box gap="Inherit">
|
||||
<IconButton
|
||||
className={PhoneHitSpread32}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
|
|
@ -322,6 +324,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
|||
}
|
||||
>
|
||||
<IconButton
|
||||
className={PhoneHitSpread32}
|
||||
aria-pressed={anchor !== undefined}
|
||||
onClick={
|
||||
((evt) =>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
|||
import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile';
|
||||
import { useSpaceOptionally } from '../../../hooks/useSpace';
|
||||
import { getMouseEventCords } from '../../../utils/dom';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
export type ReactionViewerProps = {
|
||||
room: Room;
|
||||
|
|
@ -100,7 +101,7 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
|
|||
<Box grow="Yes">
|
||||
<Text size="H3" truncate>{`Reacted with :${selectedShortcode}:`}</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={requestClose}>
|
||||
<IconButton className={PhoneHit} size="300" onClick={requestClose}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ import {
|
|||
useGetMemberPowerTag,
|
||||
} from '../../../hooks/useMemberPowerTag';
|
||||
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
|
||||
import { PhoneHit, PhoneHitSpread26 } from '../../../styles/PhoneHit.css';
|
||||
|
||||
type PinnedMessageProps = {
|
||||
room: Room;
|
||||
|
|
@ -146,6 +147,7 @@ function PinnedMessage({
|
|||
</Chip>
|
||||
{canPinEvent && (
|
||||
<IconButton
|
||||
className={PhoneHitSpread26}
|
||||
data-event-id={eventId}
|
||||
variant="Secondary"
|
||||
size="300"
|
||||
|
|
@ -457,7 +459,7 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
|
|||
<Text size="H5">Pinned Messages</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton size="300" onClick={requestClose} radii="300">
|
||||
<IconButton className={PhoneHit} size="300" onClick={requestClose} radii="300">
|
||||
<Icon src={Icons.Cross} size="400" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import {
|
|||
setSceneStyle,
|
||||
type ServedSceneStyle,
|
||||
} from './sceneStyle';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
const STYLE_LABEL: Record<ServedSceneStyle, string> = { terrain: 'Terrain', ridge: 'Ridges' };
|
||||
|
||||
|
|
@ -149,6 +150,7 @@ export function SkyStateControl() {
|
|||
}
|
||||
>
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
variant="Background"
|
||||
aria-label={`Sky: ${LABEL[state]}`}
|
||||
title={`Sky: ${LABEL[state]}`}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import { About } from './about';
|
|||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { LogoutDialog } from '../../components/LogoutDialog';
|
||||
import { PhoneHit } from '../../styles/PhoneHit.css';
|
||||
|
||||
export enum SettingsPages {
|
||||
GeneralPage,
|
||||
|
|
@ -141,7 +142,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
|||
</Box>
|
||||
<Box shrink="No">
|
||||
{screenSize === ScreenSize.Mobile && (
|
||||
<IconButton onClick={requestClose} variant="Background">
|
||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Background">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { LogoMark } from '../../../components/logo/logoMark.css';
|
|||
import RatatoskrSVG from '../../../../../public/res/svg/ratatoskr.svg';
|
||||
import { clearCacheAndReload } from '../../../../client/initMatrix';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
type AboutProps = {
|
||||
requestClose: () => void;
|
||||
|
|
@ -25,7 +26,7 @@ export function About({ requestClose }: AboutProps) {
|
|||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { MatrixId } from './MatrixId';
|
|||
import { Profile } from './Profile';
|
||||
import { ContactInformation } from './ContactInfo';
|
||||
import { IgnoredUserList } from './IgnoredUserList';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
type AccountProps = {
|
||||
requestClose: () => void;
|
||||
|
|
@ -20,7 +21,7 @@ export function Account({ requestClose }: AccountProps) {
|
|||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
|||
import { isUserId } from '../../../utils/matrix';
|
||||
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
|
||||
import { useAlive } from '../../../hooks/useAlive';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
function IgnoreUserInput({ userList }: { userList: string[] }) {
|
||||
const mx = useMatrixClient();
|
||||
|
|
@ -67,6 +68,7 @@ function IgnoreUserInput({ userList }: { userList: string[] }) {
|
|||
userId &&
|
||||
!ignoring && (
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
type="reset"
|
||||
onClick={handleReset}
|
||||
size="300"
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import { ModalWide } from '../../../styles/Modal.css';
|
|||
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
||||
import { useCapabilities } from '../../../hooks/useCapabilities';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
type ProfileProps = {
|
||||
profile: UserProfile;
|
||||
|
|
@ -185,7 +186,12 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
|
|||
<Box grow="Yes">
|
||||
<Text size="H4">Remove Avatar</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={() => setAlertRemove(false)} radii="300">
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
size="300"
|
||||
onClick={() => setAlertRemove(false)}
|
||||
radii="300"
|
||||
>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
|
@ -273,6 +279,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
|||
hasChanges &&
|
||||
!changingDisplayName && (
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
type="reset"
|
||||
onClick={handleReset}
|
||||
size="300"
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
} from '../../../components/AccountDataEditor';
|
||||
import { copyToClipboard } from '../../../utils/dom';
|
||||
import { AccountData } from './AccountData';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
type DeveloperToolsProps = {
|
||||
requestClose: () => void;
|
||||
|
|
@ -51,7 +52,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
|||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton onClick={requestClose} variant="Surface">
|
||||
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { LogoutDialog } from '../../../components/LogoutDialog';
|
|||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { PhoneHit } from '../../../styles/PhoneHit.css';
|
||||
|
||||
export function DeviceTilePlaceholder() {
|
||||
return (
|
||||
|
|
@ -288,6 +289,7 @@ export function DeviceTile({
|
|||
<SettingTile
|
||||
before={
|
||||
<IconButton
|
||||
className={PhoneHit}
|
||||
variant={deleted ? 'Critical' : 'Secondary'}
|
||||
outlined={deleted}
|
||||
radii="300"
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue