Compare commits

..
156 changed files with 336 additions and 4190 deletions

View file

@ -28,11 +28,6 @@
"fileZoneShare": { "fileZoneShare": {
"enabled": false "enabled": false
}, },
"push": {
"vapidPublicKey": "",
"gatewayUrl": "",
"appId": ""
},
"registration": { "registration": {
"enabled": false, "enabled": false,
"url": "" "url": ""

View file

@ -47,10 +47,6 @@ handoff states which config differs and the installer decides. From Yggdrasil th
not resolve: pass `LIVE_CURL_ARGS="--resolve chat.gamecult.org:443:127.0.0.1"`. 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 folder, with the three pins (`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and
the node and npm versions, is what the install request names. 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: Before handing off:

View file

@ -1,13 +1,12 @@
{ {
"name": "Ratatoskr", "name": "Ratatoskr",
"short_name": "Ratatoskr", "short_name": "Ratatoskr",
"id": "/",
"description": "GameCult's Matrix client", "description": "GameCult's Matrix client",
"dir": "auto", "dir": "auto",
"lang": "en-US", "lang": "en-US",
"display": "standalone", "display": "standalone",
"orientation": "portrait",
"start_url": "./", "start_url": "./",
"scope": "./",
"background_color": "#07111a", "background_color": "#07111a",
"theme_color": "#07111a", "theme_color": "#07111a",
"icons": [ "icons": [
@ -55,12 +54,6 @@
"src": "./public/android/android-chrome-512x512.png", "src": "./public/android/android-chrome-512x512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "type": "image/png"
},
{
"src": "./public/android/android-chrome-maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
} }
] ]
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View file

@ -1,56 +0,0 @@
// 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' },
],
});

View file

@ -1,170 +0,0 @@
import { execFileSync } from 'child_process';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { buildShaPlugin, gitIn, readBuildSha } from './buildSha';
const SHA = 'b6b1d522f1c0a9d3e2b4c5d6e7f8091a2b3c4d5e';
const gitAt =
(head, status = '') =>
(cmd, ...args) =>
cmd === 'rev-parse' ? (args[0] === '--show-prefix' ? '\n' : `${head}\n`) : status;
describe('readBuildSha', () => {
it('reads the checkout it is built from', () => {
expect(readBuildSha({}, gitAt(SHA))).toBe(SHA);
});
it('marks a tree with uncommitted changes, so a stamp never vouches for code it does not hold', () => {
expect(readBuildSha({}, gitAt(SHA, ' M src/x.ts\n'))).toBe(`${SHA}-dirty`);
});
it('lets BUILD_SHA stand in where there is no git, and ignores a value that is not a full sha', () => {
const noGit = () => {
throw new Error('not a git repository');
};
expect(readBuildSha({ BUILD_SHA: SHA }, noGit)).toBe(SHA);
expect(readBuildSha({ BUILD_SHA: SHA }, gitAt(SHA))).toBe(SHA);
// a different HEAD is a mismatch, not a clean stamp of the explicit sha
expect(readBuildSha({ BUILD_SHA: SHA }, gitAt('c'.repeat(40)))).toBe(`${SHA}-mismatch`);
// a value that is not a lowercase full sha is ignored
['b6b1d52', SHA.toUpperCase(), `${SHA}0`].forEach((v) =>
expect(readBuildSha({ BUILD_SHA: v }, noGit)).toBe('unknown')
);
// git that answers but cannot say what the tree holds fails closed
const brokenStatus = (cmd, ...args) => {
if (cmd === 'status') throw new Error('index file smaller than expected');
return args[0] === '--show-prefix' ? '\n' : `${SHA}\n`;
};
expect(readBuildSha({ BUILD_SHA: SHA }, brokenStatus)).toBe('unknown');
expect(readBuildSha({}, brokenStatus)).toBe('unknown');
expect(readBuildSha({}, gitAt('nope'))).toBe('unknown');
// inside another repository's subfolder git does not govern: the explicit sha stands
const nested = (cmd, ...args) => (args[0] === '--show-prefix' ? 'vendor/ratatoskr/\n' : SHA);
expect(readBuildSha({ BUILD_SHA: SHA }, nested)).toBe(SHA);
expect(readBuildSha({}, nested)).toBe('unknown');
expect(readBuildSha({ BUILD_SHA: 'main' }, noGit)).toBe('unknown');
});
it("says 'unknown' rather than failing the build when git is absent", () => {
expect(
readBuildSha({}, () => {
throw new Error('spawn git ENOENT');
})
).toBe('unknown');
});
});
describe('readBuildSha against a real repository', () => {
const made = [];
afterEach(() => {
made.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true }));
});
const repo = () => {
const dir = mkdtempSync(join(tmpdir(), 'buildsha-'));
made.push(dir);
const g = (...a) => execFileSync('git', a, { cwd: dir, stdio: 'ignore' });
g('init', '-q');
mkdirSync(join(dir, 'src'));
writeFileSync(join(dir, 'src', 'a.ts'), 'export {};\n');
g('add', '.');
g('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x');
return dir;
};
it("stays clean with vite's temp config file present, and is dirty with a stray source file", () => {
const dir = repo();
const run = gitIn(dir);
writeFileSync(join(dir, 'vite.config.js.timestamp-1700000000-abc123.mjs'), '');
expect(readBuildSha({}, run)).toMatch(/^[0-9a-f]{40}$/);
writeFileSync(join(dir, 'src', 'zz-stray.ts'), '');
expect(readBuildSha({}, run)).toMatch(/^[0-9a-f]{40}-dirty$/);
});
});
describe('readBuildSha dirty rules against a real repository', () => {
const made2 = [];
afterEach(() => {
made2.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true }));
});
const TEMP = 'vite.config.js.timestamp-1700000000-abc123.mjs';
const repo2 = (tracked = []) => {
const dir = mkdtempSync(join(tmpdir(), 'buildsha-'));
made2.push(dir);
const g = (...a) => execFileSync('git', a, { cwd: dir, stdio: 'ignore' });
g('init', '-q');
mkdirSync(join(dir, 'src'));
writeFileSync(join(dir, 'src', 'a.ts'), 'export {};\n');
tracked.forEach((f) => writeFileSync(join(dir, f), 'x\n'));
g('add', '.');
g('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x');
return {
dir,
head: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim(),
};
};
it('does not let an explicit BUILD_SHA mask a dirty tree', () => {
const { dir, head } = repo2();
const run = gitIn(dir);
expect(readBuildSha({ BUILD_SHA: head }, run)).toBe(head);
writeFileSync(join(dir, 'src', 'zz-stray.ts'), '');
expect(readBuildSha({ BUILD_SHA: head }, run)).toBe(`${head}-dirty`);
});
it('lets git speak only for a tree whose root it is: an archive unpacked inside another repository keeps its explicit sha', () => {
const { dir } = repo2();
const inner = join(dir, 'vendor', 'ratatoskr');
mkdirSync(inner, { recursive: true });
writeFileSync(join(inner, 'x.ts'), '');
expect(readBuildSha({ BUILD_SHA: SHA }, gitIn(inner))).toBe(SHA);
expect(readBuildSha({}, gitIn(inner))).toBe('unknown');
});
it("notices an untracked .mjs that is not vite's temp file", () => {
const { dir, head } = repo2();
writeFileSync(join(dir, 'foo.mjs'), '');
expect(readBuildSha({}, gitIn(dir))).toBe(`${head}-dirty`);
rmSync(join(dir, 'foo.mjs'));
// only vite's exact shape is its own: vite.config.<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' },
]);
});
});

View file

@ -16,7 +16,6 @@ import {
} from 'folds'; } from 'folds';
import { MatrixError } from 'matrix-js-sdk'; import { MatrixError } from 'matrix-js-sdk';
import { Cursor } from '../plugins/text-area'; import { Cursor } from '../plugins/text-area';
import { PhoneHit } from '../styles/PhoneHit.css';
import { syntaxErrorPosition } from '../utils/dom'; import { syntaxErrorPosition } from '../utils/dom';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { Page, PageHeader } from './page'; import { Page, PageHeader } from './page';
@ -295,7 +294,7 @@ export function AccountDataEditor({
</Chip> </Chip>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface" className={PhoneHit}> <IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

@ -30,7 +30,6 @@ import {
} from '../hooks/useKeyBackup'; } from '../hooks/useKeyBackup';
import { stopPropagation } from '../utils/keyboard'; import { stopPropagation } from '../utils/keyboard';
import { useRestoreBackupOnVerification } from '../hooks/useRestoreBackupOnVerification'; import { useRestoreBackupOnVerification } from '../hooks/useRestoreBackupOnVerification';
import { PhoneHit } from '../styles/PhoneHit.css';
type BackupStatusProps = { type BackupStatusProps = {
enabled: boolean; enabled: boolean;
@ -178,7 +177,6 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
<BackupSyncing count={remainingSession} /> <BackupSyncing count={remainingSession} />
)} )}
<IconButton <IconButton
className={PhoneHit}
aria-pressed={!!menuCords} aria-pressed={!!menuCords}
size="300" size="300"
variant="Surface" variant="Surface"

View file

@ -30,7 +30,6 @@ import {
} from '../hooks/useVerificationRequest'; } from '../hooks/useVerificationRequest';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { ContainerColor } from '../styles/ContainerColor.css'; import { ContainerColor } from '../styles/ContainerColor.css';
import { PhoneHit } from '../styles/PhoneHit.css';
const DialogHeaderStyles: CSSProperties = { const DialogHeaderStyles: CSSProperties = {
padding: `0 ${config.space.S200} 0 ${config.space.S400}`, padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
@ -262,7 +261,7 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">Device Verification</Text> <Text size="H4">Device Verification</Text>
</Box> </Box>
<IconButton className={PhoneHit} size="300" radii="300" onClick={handleCancel}> <IconButton size="300" radii="300" onClick={handleCancel}>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>

View file

@ -25,7 +25,6 @@ import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA';
import { useMatrixClient } from '../hooks/useMatrixClient'; import { useMatrixClient } from '../hooks/useMatrixClient';
import { useAlive } from '../hooks/useAlive'; import { useAlive } from '../hooks/useAlive';
import { UseStateProvider } from './UseStateProvider'; import { UseStateProvider } from './UseStateProvider';
import { PhoneHit } from '../styles/PhoneHit.css';
type UIACallback<T> = ( type UIACallback<T> = (
authDict: AuthDict | null authDict: AuthDict | null
@ -302,7 +301,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">Setup Device Verification</Text> <Text size="H4">Setup Device Verification</Text>
</Box> </Box>
<IconButton className={PhoneHit} size="300" radii="300" onClick={onCancel}> <IconButton size="300" radii="300" onClick={onCancel}>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
@ -337,7 +336,7 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">Reset Device Verification</Text> <Text size="H4">Reset Device Verification</Text>
</Box> </Box>
<IconButton className={PhoneHit} size="300" radii="300" onClick={onCancel}> <IconButton size="300" radii="300" onClick={onCancel}>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>

View file

@ -27,7 +27,6 @@ import { AsyncStatus } from '../../hooks/useAsyncCallback';
import { useZoom } from '../../hooks/useZoom'; import { useZoom } from '../../hooks/useZoom';
import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist'; import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { PhoneHit, PhoneHitSpread24 } from '../../styles/PhoneHit.css';
export type PdfViewerProps = { export type PdfViewerProps = {
name: string; name: string;
@ -109,7 +108,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
<Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}> <Box className={classNames(css.PdfViewer, className)} direction="Column" {...props} ref={ref}>
<Header className={css.PdfViewerHeader} size="400"> <Header className={css.PdfViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200"> <Box grow="Yes" alignItems="Center" gap="200">
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}> <IconButton size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} /> <Icon size="50" src={Icons.ArrowLeft} />
</IconButton> </IconButton>
<Text size="T300" truncate> <Text size="T300" truncate>
@ -118,7 +117,6 @@ export const PdfViewer = as<'div', PdfViewerProps>(
</Box> </Box>
<Box shrink="No" alignItems="Center" gap="200"> <Box shrink="No" alignItems="Center" gap="200">
<IconButton <IconButton
className={PhoneHitSpread24}
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'} variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom < 1} outlined={zoom < 1}
size="300" size="300"
@ -132,7 +130,6 @@ export const PdfViewer = as<'div', PdfViewerProps>(
<Text size="B300">{Math.round(zoom * 100)}%</Text> <Text size="B300">{Math.round(zoom * 100)}%</Text>
</Chip> </Chip>
<IconButton <IconButton
className={PhoneHitSpread24}
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'} variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom > 1} outlined={zoom > 1}
size="300" size="300"

View file

@ -13,7 +13,6 @@ import {
IconButton, IconButton,
} from 'folds'; } from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { PhoneHit } from '../styles/PhoneHit.css';
export type UIAFlowOverlayProps = { export type UIAFlowOverlayProps = {
currentStep: number; currentStep: number;
@ -54,7 +53,6 @@ export function UIAFlowOverlay({
> >
{(anchorRef) => ( {(anchorRef) => (
<IconButton <IconButton
className={PhoneHit}
ref={anchorRef} ref={anchorRef}
variant="Critical" variant="Critical"
size="300" size="300"

View file

@ -0,0 +1,84 @@
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>
</>
);
}

View file

@ -36,7 +36,6 @@ import { KeySymbol } from '../../utils/key-symbol';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { PhoneHit, PhoneHitRoom36, PhoneHitSpread36 } from '../../styles/PhoneHit.css';
function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) { function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) {
return ( return (
@ -73,7 +72,6 @@ export function MarkButton({ format, icon, tooltip }: MarkButtonProps) {
<TooltipProvider tooltip={tooltip} delay={500}> <TooltipProvider tooltip={tooltip} delay={500}>
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton
className={PhoneHitSpread36}
ref={triggerRef} ref={triggerRef}
variant="SurfaceVariant" variant="SurfaceVariant"
onClick={handleClick} onClick={handleClick}
@ -106,7 +104,6 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
<TooltipProvider tooltip={tooltip} delay={500}> <TooltipProvider tooltip={tooltip} delay={500}>
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton
className={PhoneHitSpread36}
ref={triggerRef} ref={triggerRef}
variant="SurfaceVariant" variant="SurfaceVariant"
onClick={handleClick} onClick={handleClick}
@ -166,7 +163,6 @@ export function HeadingBlockButton() {
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton
className={PhoneHitRoom36}
ref={triggerRef} ref={triggerRef}
onClick={() => handleMenuSelect(1)} onClick={() => handleMenuSelect(1)}
size="400" size="400"
@ -182,7 +178,6 @@ export function HeadingBlockButton() {
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton
className={PhoneHitRoom36}
ref={triggerRef} ref={triggerRef}
onClick={() => handleMenuSelect(2)} onClick={() => handleMenuSelect(2)}
size="400" size="400"
@ -198,7 +193,6 @@ export function HeadingBlockButton() {
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton
className={PhoneHitRoom36}
ref={triggerRef} ref={triggerRef}
onClick={() => handleMenuSelect(3)} onClick={() => handleMenuSelect(3)}
size="400" size="400"
@ -214,7 +208,6 @@ export function HeadingBlockButton() {
} }
> >
<IconButton <IconButton
className={PhoneHit}
style={{ width: 'unset' }} style={{ width: 'unset' }}
variant="SurfaceVariant" variant="SurfaceVariant"
onClick={handleMenuOpen} onClick={handleMenuOpen}
@ -246,7 +239,6 @@ export function ExitFormatting({ tooltip }: ExitFormattingProps) {
<TooltipProvider tooltip={tooltip} delay={500}> <TooltipProvider tooltip={tooltip} delay={500}>
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton
className={PhoneHit}
ref={triggerRef} ref={triggerRef}
variant="SurfaceVariant" variant="SurfaceVariant"
onClick={handleClick} onClick={handleClick}
@ -352,7 +344,6 @@ export function Toolbar() {
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton
className={PhoneHit}
ref={triggerRef} ref={triggerRef}
variant="SurfaceVariant" variant="SurfaceVariant"
onClick={() => setIsMarkdown(!isMarkdown)} onClick={() => setIsMarkdown(!isMarkdown)}

View file

@ -14,7 +14,6 @@ import {
} from 'folds'; } from 'folds';
import classNames from 'classnames'; import classNames from 'classnames';
import * as css from './styles.css'; import * as css from './styles.css';
import { PhoneHit } from '../../../styles/PhoneHit.css';
export function Sidebar({ children }: { children: ReactNode }) { export function Sidebar({ children }: { children: ReactNode }) {
return ( return (
@ -69,7 +68,6 @@ function SidebarBtn<T extends string>({
> >
{(ref) => ( {(ref) => (
<IconButton <IconButton
className={PhoneHit}
aria-pressed={active} aria-pressed={active}
aria-labelledby={`SidebarStackItem-${id}-label`} aria-labelledby={`SidebarStackItem-${id}-label`}
ref={ref} ref={ref}

View file

@ -24,7 +24,6 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace'; import { useSpaceOptionally } from '../../hooks/useSpace';
import { getMouseEventCords } from '../../utils/dom'; import { getMouseEventCords } from '../../utils/dom';
import { PhoneHit } from '../../styles/PhoneHit.css';
export type EventReadersProps = { export type EventReadersProps = {
room: Room; room: Room;
@ -53,7 +52,7 @@ export const EventReaders = as<'div', EventReadersProps>(
<Box grow="Yes"> <Box grow="Yes">
<Text size="H3">Seen by</Text> <Text size="H3">Seen by</Text>
</Box> </Box>
<IconButton className={PhoneHit} size="300" onClick={requestClose}> <IconButton size="300" onClick={requestClose}>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>

View file

@ -2,7 +2,6 @@ import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds'; import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
import * as css from './ImageEditor.css'; import * as css from './ImageEditor.css';
import { PhoneHit } from '../../styles/PhoneHit.css';
export type ImageEditorProps = { export type ImageEditorProps = {
name: string; name: string;
@ -25,7 +24,7 @@ export const ImageEditor = as<'div', ImageEditorProps>(
> >
<Header className={css.ImageEditorHeader} size="400"> <Header className={css.ImageEditorHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200"> <Box grow="Yes" alignItems="Center" gap="200">
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}> <IconButton size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} /> <Icon size="50" src={Icons.ArrowLeft} />
</IconButton> </IconButton>
<Text size="T300" truncate> <Text size="T300" truncate>

View file

@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import { Box, IconButton, Text, Icon, Icons, Scroll, Chip } from 'folds'; import { Box, IconButton, Text, Icon, Icons, Scroll, Chip } from 'folds';
import { PackAddress } from '../../plugins/custom-emoji'; import { PackAddress } from '../../plugins/custom-emoji';
import { PhoneHit } from '../../styles/PhoneHit.css';
import { Page, PageHeader, PageContent } from '../page'; import { Page, PageHeader, PageContent } from '../page';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { RoomImagePack } from './RoomImagePack'; import { RoomImagePack } from './RoomImagePack';
@ -30,7 +29,7 @@ export function ImagePackView({ address, requestClose }: ImagePackViewProps) {
</Chip> </Chip>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton onClick={requestClose} variant="Surface" className={PhoneHit}> <IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

@ -7,7 +7,6 @@ import * as css from './ImageViewer.css';
import { useZoom } from '../../hooks/useZoom'; import { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan'; import { usePan } from '../../hooks/usePan';
import { downloadMedia } from '../../utils/matrix'; import { downloadMedia } from '../../utils/matrix';
import { PhoneHit, PhoneHitSpread24 } from '../../styles/PhoneHit.css';
export type ImageViewerProps = { export type ImageViewerProps = {
alt: string; alt: string;
@ -34,7 +33,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
> >
<Header className={css.ImageViewerHeader} size="400"> <Header className={css.ImageViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200"> <Box grow="Yes" alignItems="Center" gap="200">
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}> <IconButton size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} /> <Icon size="50" src={Icons.ArrowLeft} />
</IconButton> </IconButton>
<Text size="T300" truncate> <Text size="T300" truncate>
@ -43,7 +42,6 @@ export const ImageViewer = as<'div', ImageViewerProps>(
</Box> </Box>
<Box shrink="No" alignItems="Center" gap="200"> <Box shrink="No" alignItems="Center" gap="200">
<IconButton <IconButton
className={PhoneHitSpread24}
variant={zoom < 1 ? 'Success' : 'SurfaceVariant'} variant={zoom < 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom < 1} outlined={zoom < 1}
size="300" size="300"
@ -57,7 +55,6 @@ export const ImageViewer = as<'div', ImageViewerProps>(
<Text size="B300">{Math.round(zoom * 100)}%</Text> <Text size="B300">{Math.round(zoom * 100)}%</Text>
</Chip> </Chip>
<IconButton <IconButton
className={PhoneHitSpread24}
variant={zoom > 1 ? 'Success' : 'SurfaceVariant'} variant={zoom > 1 ? 'Success' : 'SurfaceVariant'}
outlined={zoom > 1} outlined={zoom > 1}
size="300" size="300"

View file

@ -42,7 +42,6 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { BreakWord } from '../../styles/Text.css'; import { BreakWord } from '../../styles/Text.css';
import { useAlive } from '../../hooks/useAlive'; import { useAlive } from '../../hooks/useAlive';
import { PhoneHit } from '../../styles/PhoneHit.css';
const SEARCH_OPTIONS: UseAsyncSearchOptions = { const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000, limit: 1000,
@ -174,7 +173,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}> <IconButton size="300" radii="300" onClick={requestClose}>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

@ -20,7 +20,6 @@ import { stopPropagation } from '../../utils/keyboard';
import { isRoomAlias, isRoomId } from '../../utils/matrix'; import { isRoomAlias, isRoomId } from '../../utils/matrix';
import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../../plugins/matrix-to'; import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../../plugins/matrix-to';
import { tryDecodeURIComponent } from '../../utils/dom'; import { tryDecodeURIComponent } from '../../utils/dom';
import { PhoneHit } from '../../styles/PhoneHit.css';
type JoinAddressProps = { type JoinAddressProps = {
onOpen: (roomIdOrAlias: string, via?: string[], eventId?: string) => void; onOpen: (roomIdOrAlias: string, via?: string[], eventId?: string) => void;
@ -83,7 +82,7 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) {
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">Join with Address</Text> <Text size="H4">Join with Address</Text>
</Box> </Box>
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300"> <IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>

View file

@ -20,7 +20,6 @@ import { MatrixError } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { PhoneHit } from '../../styles/PhoneHit.css';
type LeaveRoomPromptProps = { type LeaveRoomPromptProps = {
roomId: string; roomId: string;
@ -69,7 +68,7 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">Leave Room</Text> <Text size="H4">Leave Room</Text>
</Box> </Box>
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300"> <IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>

View file

@ -20,7 +20,6 @@ import { MatrixError } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { PhoneHit } from '../../styles/PhoneHit.css';
type LeaveSpacePromptProps = { type LeaveSpacePromptProps = {
roomId: string; roomId: string;
@ -69,7 +68,7 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">Leave Space</Text> <Text size="H4">Leave Space</Text>
</Box> </Box>
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300"> <IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>

View file

@ -2,7 +2,6 @@ import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'f
import React, { ReactNode, useCallback } from 'react'; import React, { ReactNode, useCallback } from 'react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FileSaver from 'file-saver'; import FileSaver from 'file-saver';
import { PhoneHit } from '../../styles/PhoneHit.css';
import { mimeTypeToExt } from '../../utils/mimeTypes'; import { mimeTypeToExt } from '../../utils/mimeTypes';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@ -49,7 +48,6 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
variant={hasError ? 'Critical' : 'SurfaceVariant'} variant={hasError ? 'Critical' : 'SurfaceVariant'}
size="300" size="300"
radii="300" radii="300"
className={PhoneHit}
> >
{downloading ? ( {downloading ? (
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} /> <Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />

View file

@ -1,9 +1,6 @@
import { createVar, style } from '@vanilla-extract/css'; import { createVar, style } from '@vanilla-extract/css';
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds'; 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 Container = createVar();
const ContainerHover = createVar(); const ContainerHover = createVar();
const ContainerActive = createVar(); const ContainerActive = createVar();
@ -28,14 +25,6 @@ export const Reaction = style([
selectors: { selectors: {
'button&': { 'button&': {
cursor: 'pointer', 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]': { '&[aria-pressed=true]': {
vars: { vars: {

View file

@ -3,7 +3,6 @@ import React, { ReactNode, useCallback, useRef, useState } from 'react';
import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds'; import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { Range } from 'react-range'; import { Range } from 'react-range';
import { PhoneHitSpread24 } from '../../../styles/PhoneHit.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { IAudioInfo } from '../../../../types/matrix/common'; import { IAudioInfo } from '../../../../types/matrix/common';
@ -160,7 +159,6 @@ export function AudioContent({
variant="SurfaceVariant" variant="SurfaceVariant"
size="300" size="300"
radii="Pill" radii="Pill"
className={PhoneHitSpread24}
onClick={() => setMute(!mute)} onClick={() => setMute(!mute)}
aria-pressed={mute} aria-pressed={mute}
> >

View file

@ -299,10 +299,7 @@ export const BubbleLeftArrow = style({
}); });
// Phone tap target: a sender name is 23 px tall, so the button grows 11 px above and below (44+ px to tap) and the // Phone tap target: a sender name is 23 px tall, so the button grows 11 px above and below (44+ px to tap) and the
// negative margin takes the growth back out of the layout. Sideways it cannot borrow from the layout the // negative margin takes the growth back out of the layout. Hit area only: no background, border or outline moves.
// 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)`; const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`;
export const Username = style({ export const Username = style({
overflow: 'hidden', overflow: 'hidden',
@ -312,7 +309,7 @@ export const Username = style({
'button&': { 'button&': {
cursor: 'pointer', cursor: 'pointer',
'@media': { '@media': {
[SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11, minWidth: 44, textAlign: 'start' }, [SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11 },
}, },
}, },
'button&:hover, button&:focus-visible': { 'button&:hover, button&:focus-visible': {

View file

@ -1,7 +1,6 @@
import React, { ComponentProps, forwardRef } from 'react'; import React, { ComponentProps, forwardRef } from 'react';
import { Icon, IconButton, Input, config, Icons } from 'folds'; import { Icon, IconButton, Input, config, Icons } from 'folds';
import { UseStateProvider } from '../UseStateProvider'; import { UseStateProvider } from '../UseStateProvider';
import { PhoneHit } from '../../styles/PhoneHit.css';
type PasswordInputProps = Omit<ComponentProps<typeof Input>, 'type' | 'size'> & { type PasswordInputProps = Omit<ComponentProps<typeof Input>, 'type' | 'size'> & {
size: '400' | '500'; size: '400' | '500';
@ -24,7 +23,6 @@ export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
<> <>
{after} {after}
<IconButton <IconButton
className={PhoneHit}
onClick={() => setVisible(!visible)} onClick={() => setVisible(!visible)}
type="button" type="button"
variant={visible ? 'Warning' : variant} variant={visible ? 'Warning' : variant}

View file

@ -4,7 +4,6 @@ import classNames from 'classnames';
import Linkify from 'linkify-react'; import Linkify from 'linkify-react';
import * as css from './style.css'; import * as css from './style.css';
import { LINKIFY_OPTS, scaleSystemEmoji } from '../../plugins/react-custom-html-parser'; import { LINKIFY_OPTS, scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
import { PhoneHit } from '../../styles/PhoneHit.css';
export const RoomTopicViewer = as< export const RoomTopicViewer = as<
'div', 'div',
@ -27,7 +26,7 @@ export const RoomTopicViewer = as<
{name} {name}
</Text> </Text>
</Box> </Box>
<IconButton className={PhoneHit} size="300" onClick={requestClose} radii="300"> <IconButton size="300" onClick={requestClose} radii="300">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>

View file

@ -5,7 +5,6 @@ import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, as } from 'fo
import { ErrorBoundary } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary';
import * as css from './TextViewer.css'; import * as css from './TextViewer.css';
import { copyToClipboard } from '../../utils/dom'; import { copyToClipboard } from '../../utils/dom';
import { PhoneHit } from '../../styles/PhoneHit.css';
const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism')); const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism'));
@ -54,7 +53,7 @@ export const TextViewer = as<'div', TextViewerProps>(
> >
<Header className={css.TextViewerHeader} size="400"> <Header className={css.TextViewerHeader} size="400">
<Box grow="Yes" alignItems="Center" gap="200"> <Box grow="Yes" alignItems="Center" gap="200">
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}> <IconButton size="300" radii="300" onClick={requestClose}>
<Icon size="50" src={Icons.ArrowLeft} /> <Icon size="50" src={Icons.ArrowLeft} />
</IconButton> </IconButton>
<Text size="T300" truncate> <Text size="T300" truncate>

View file

@ -4,7 +4,6 @@ import { AuthType } from 'matrix-js-sdk';
import { StageComponentProps } from './types'; import { StageComponentProps } from './types';
import { ErrorCode } from '../../cs-errorcode'; import { ErrorCode } from '../../cs-errorcode';
import { PasswordInput } from '../password-input'; import { PasswordInput } from '../password-input';
import { PhoneHit } from '../../styles/PhoneHit.css';
export function PasswordStage({ export function PasswordStage({
stageData, stageData,
@ -46,7 +45,7 @@ export function PasswordStage({
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">Account Password</Text> <Text size="H4">Account Password</Text>
</Box> </Box>
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300"> <IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>

View file

@ -1,7 +1,6 @@
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds'; import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { StageComponentProps } from './types'; import { StageComponentProps } from './types';
import { PhoneHit } from '../../styles/PhoneHit.css';
export function SSOStage({ export function SSOStage({
ssoRedirectURL, ssoRedirectURL,
@ -57,7 +56,7 @@ export function SSOStage({
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">SSO Login</Text> <Text size="H4">SSO Login</Text>
</Box> </Box>
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300"> <IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>

View file

@ -6,7 +6,6 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { TUploadContent } from '../../utils/matrix'; import { TUploadContent } from '../../utils/matrix';
import { bytesToSize, getFileTypeIcon } from '../../utils/common'; import { bytesToSize, getFileTypeIcon } from '../../utils/common';
import { useMediaConfig } from '../../hooks/useMediaConfig'; import { useMediaConfig } from '../../hooks/useMediaConfig';
import { PhoneHitSpread28 } from '../../styles/PhoneHit.css';
type CompactUploadCardRendererProps = { type CompactUploadCardRendererProps = {
isEncrypted?: boolean; isEncrypted?: boolean;
@ -64,7 +63,6 @@ export function CompactUploadCardRenderer({
</Chip> </Chip>
)} )}
<IconButton <IconButton
className={PhoneHitSpread28}
onClick={removeUpload} onClick={removeUpload}
aria-label="Cancel Upload" aria-label="Cancel Upload"
variant="SurfaceVariant" variant="SurfaceVariant"

View file

@ -12,7 +12,6 @@ import {
} from '../../state/room/roomInputDrafts'; } from '../../state/room/roomInputDrafts';
import { useObjectURL } from '../../hooks/useObjectURL'; import { useObjectURL } from '../../hooks/useObjectURL';
import { useMediaConfig } from '../../hooks/useMediaConfig'; import { useMediaConfig } from '../../hooks/useMediaConfig';
import { PhoneHitSpread28 } from '../../styles/PhoneHit.css';
type PreviewImageProps = { type PreviewImageProps = {
fileItem: TUploadItem; fileItem: TUploadItem;
@ -161,7 +160,6 @@ export function UploadCardRenderer({
</Chip> </Chip>
)} )}
<IconButton <IconButton
className={PhoneHitSpread28}
onClick={removeUpload} onClick={removeUpload}
aria-label="Cancel Upload" aria-label="Cancel Upload"
variant="SurfaceVariant" variant="SurfaceVariant"

View file

@ -1,4 +1,3 @@
import classNames from 'classnames';
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { IPreviewUrlResponse } from 'matrix-js-sdk'; import { IPreviewUrlResponse } from 'matrix-js-sdk';
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds'; import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
@ -11,7 +10,6 @@ import {
useIntersectionObserver, useIntersectionObserver,
} from '../../hooks/useIntersectionObserver'; } from '../../hooks/useIntersectionObserver';
import * as css from './UrlPreviewCard.css'; import * as css from './UrlPreviewCard.css';
import { PhoneHitInPlace } from '../../styles/PhoneHit.css';
import { tryDecodeURIComponent } from '../../utils/dom'; import { tryDecodeURIComponent } from '../../utils/dom';
import { mxcUrlToHttp } from '../../utils/matrix'; import { mxcUrlToHttp } from '../../utils/matrix';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@ -183,10 +181,7 @@ export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => {
<> <>
<div className={css.UrlPreviewHolderGradient({ position: 'Left' })} /> <div className={css.UrlPreviewHolderGradient({ position: 'Left' })} />
<IconButton <IconButton
className={classNames( className={css.UrlPreviewHolderBtn({ position: 'Left' })}
css.UrlPreviewHolderBtn({ position: 'Left' }),
PhoneHitInPlace
)}
variant="Secondary" variant="Secondary"
radii="Pill" radii="Pill"
size="300" size="300"
@ -204,10 +199,7 @@ export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => {
<> <>
<div className={css.UrlPreviewHolderGradient({ position: 'Right' })} /> <div className={css.UrlPreviewHolderGradient({ position: 'Right' })} />
<IconButton <IconButton
className={classNames( className={css.UrlPreviewHolderBtn({ position: 'Right' })}
css.UrlPreviewHolderBtn({ position: 'Right' }),
PhoneHitInPlace
)}
variant="Primary" variant="Primary"
radii="Pill" radii="Pill"
size="300" size="300"

View file

@ -43,7 +43,6 @@ import { getPowerTagIconSrc, useGetMemberPowerTag } from '../../hooks/useMemberP
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare'; import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare';
import { PhoneHit } from '../../styles/PhoneHit.css';
type SelfDemoteAlertProps = { type SelfDemoteAlertProps = {
power: number; power: number;
@ -71,7 +70,7 @@ function SelfDemoteAlert({ power, onCancel, onChange }: SelfDemoteAlertProps) {
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">Self Demotion</Text> <Text size="H4">Self Demotion</Text>
</Box> </Box>
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300"> <IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
@ -121,7 +120,7 @@ function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps)
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">Shared Power</Text> <Text size="H4">Shared Power</Text>
</Box> </Box>
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300"> <IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>

View file

@ -54,7 +54,6 @@ import { StateEvent } from '../../../types/matrix/room';
import { getViaServers } from '../../plugins/via-servers'; import { getViaServers } from '../../plugins/via-servers';
import { rateLimitedActions } from '../../utils/matrix'; import { rateLimitedActions } from '../../utils/matrix';
import { useAlive } from '../../hooks/useAlive'; import { useAlive } from '../../hooks/useAlive';
import { PhoneHit } from '../../styles/PhoneHit.css';
const SEARCH_OPTS: UseAsyncSearchOptions = { const SEARCH_OPTS: UseAsyncSearchOptions = {
limit: 500, limit: 500,
@ -202,7 +201,7 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM
<Text size="H4">Add Existing</Text> <Text size="H4">Add Existing</Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton className={PhoneHit} size="300" radii="300" onClick={requestClose}> <IconButton size="300" radii="300" onClick={requestClose}>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

@ -5,7 +5,6 @@ import { StatusDivider } from './components';
import { CallEmbed, useCallControlState } from '../../plugins/call'; import { CallEmbed, useCallControlState } from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { callEmbedAtom } from '../../state/callEmbed'; import { callEmbedAtom } from '../../state/callEmbed';
import { PhoneHitSpread26 } from '../../styles/PhoneHit.css';
type MicrophoneButtonProps = { type MicrophoneButtonProps = {
enabled: boolean; enabled: boolean;
@ -27,7 +26,6 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps
> >
{(anchorRef) => ( {(anchorRef) => (
<IconButton <IconButton
className={PhoneHitSpread26}
ref={anchorRef} ref={anchorRef}
variant={enabled ? 'Surface' : 'Warning'} variant={enabled ? 'Surface' : 'Warning'}
fill="Soft" fill="Soft"
@ -61,7 +59,6 @@ function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
> >
{(anchorRef) => ( {(anchorRef) => (
<IconButton <IconButton
className={PhoneHitSpread26}
ref={anchorRef} ref={anchorRef}
variant={enabled ? 'Surface' : 'Warning'} variant={enabled ? 'Surface' : 'Warning'}
fill="Soft" fill="Soft"
@ -102,7 +99,6 @@ function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
> >
{(anchorRef) => ( {(anchorRef) => (
<IconButton <IconButton
className={PhoneHitSpread26}
ref={anchorRef} ref={anchorRef}
variant={enabled ? 'Success' : 'Surface'} variant={enabled ? 'Success' : 'Surface'}
fill="Soft" fill="Soft"
@ -140,7 +136,6 @@ function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonPro
> >
{(anchorRef) => ( {(anchorRef) => (
<IconButton <IconButton
className={PhoneHitSpread26}
ref={anchorRef} ref={anchorRef}
variant={enabled ? 'Success' : 'Surface'} variant={enabled ? 'Success' : 'Surface'}
fill="Soft" fill="Soft"

View file

@ -25,7 +25,6 @@ import {
SoundButton, SoundButton,
VideoButton, VideoButton,
} from './Controls'; } from './Controls';
import { PhoneHitSpread40 } from '../../styles/PhoneHit.css';
import { CallEmbed, useCallControlState } from '../../plugins/call'; import { CallEmbed, useCallControlState } from '../../plugins/call';
import { useResizeObserver } from '../../hooks/useResizeObserver'; import { useResizeObserver } from '../../hooks/useResizeObserver';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
@ -170,7 +169,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
} }
> >
<IconButton <IconButton
className={PhoneHitSpread40}
variant="Surface" variant="Surface"
fill="Soft" fill="Soft"
radii="400" radii="400"

View file

@ -4,7 +4,6 @@ import { useAtom } from 'jotai';
import * as css from './styles.css'; import * as css from './styles.css';
import { callChatAtom } from '../../state/callEmbed'; import { callChatAtom } from '../../state/callEmbed';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { PhoneHitSpread40 } from '../../styles/PhoneHit.css';
export function ControlDivider() { export function ControlDivider() {
return ( return (
@ -32,7 +31,6 @@ export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
> >
{(anchorRef) => ( {(anchorRef) => (
<IconButton <IconButton
className={PhoneHitSpread40}
ref={anchorRef} ref={anchorRef}
variant={enabled ? 'Surface' : 'Warning'} variant={enabled ? 'Surface' : 'Warning'}
fill="Soft" fill="Soft"
@ -66,7 +64,6 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
> >
{(anchorRef) => ( {(anchorRef) => (
<IconButton <IconButton
className={PhoneHitSpread40}
ref={anchorRef} ref={anchorRef}
variant={enabled ? 'Surface' : 'Warning'} variant={enabled ? 'Surface' : 'Warning'}
fill="Soft" fill="Soft"
@ -106,7 +103,6 @@ export function VideoButton({ enabled, onToggle }: VideoButtonProps) {
> >
{(anchorRef) => ( {(anchorRef) => (
<IconButton <IconButton
className={PhoneHitSpread40}
ref={anchorRef} ref={anchorRef}
variant={enabled ? 'Success' : 'Surface'} variant={enabled ? 'Success' : 'Surface'}
fill="Soft" fill="Soft"
@ -144,7 +140,6 @@ export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps)
> >
{(anchorRef) => ( {(anchorRef) => (
<IconButton <IconButton
className={PhoneHitSpread40}
ref={anchorRef} ref={anchorRef}
variant={enabled ? 'Success' : 'Surface'} variant={enabled ? 'Success' : 'Surface'}
fill="Soft" fill="Soft"
@ -175,7 +170,6 @@ export function ChatButton() {
> >
{(anchorRef) => ( {(anchorRef) => (
<IconButton <IconButton
className={PhoneHitSpread40}
ref={anchorRef} ref={anchorRef}
variant={chat ? 'Success' : 'Surface'} variant={chat ? 'Success' : 'Surface'}
fill="Soft" fill="Soft"

View file

@ -30,7 +30,6 @@ import {
AccountDataSubmitCallback, AccountDataSubmitCallback,
} from '../../../components/AccountDataEditor'; } from '../../../components/AccountDataEditor';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type DeveloperToolsProps = { type DeveloperToolsProps = {
requestClose: () => void; requestClose: () => void;
@ -93,7 +92,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

@ -22,7 +22,6 @@ import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { syntaxErrorPosition } from '../../../utils/dom'; import { syntaxErrorPosition } from '../../../utils/dom';
import { Cursor } from '../../../plugins/text-area'; import { Cursor } from '../../../plugins/text-area';
import { PhoneHit } from '../../../styles/PhoneHit.css';
const EDITOR_INTENT_SPACE_COUNT = 2; const EDITOR_INTENT_SPACE_COUNT = 2;
@ -119,7 +118,7 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro
</Chip> </Chip>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

@ -31,7 +31,6 @@ import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor'; import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { PhoneHit } from '../../../styles/PhoneHit.css';
const EDITOR_INTENT_SPACE_COUNT = 2; const EDITOR_INTENT_SPACE_COUNT = 2;
@ -276,7 +275,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi
</Chip> </Chip>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

@ -4,7 +4,6 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
import { ImagePack } from '../../../plugins/custom-emoji'; import { ImagePack } from '../../../plugins/custom-emoji';
import { ImagePackView } from '../../../components/image-pack-view'; import { ImagePackView } from '../../../components/image-pack-view';
import { RoomPacks } from './RoomPacks'; import { RoomPacks } from './RoomPacks';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type EmojisStickersProps = { type EmojisStickersProps = {
requestClose: () => void; requestClose: () => void;
@ -30,7 +29,7 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

@ -40,7 +40,6 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useAlive } from '../../../hooks/useAlive'; import { useAlive } from '../../../hooks/useAlive';
import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type CreatePackTileProps = { type CreatePackTileProps = {
packs: ImagePack[]; packs: ImagePack[];
@ -216,7 +215,6 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) {
{canEdit && {canEdit &&
(removed ? ( (removed ? (
<IconButton <IconButton
className={PhoneHit}
size="300" size="300"
radii="Pill" radii="Pill"
variant="Critical" variant="Critical"
@ -227,7 +225,6 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) {
</IconButton> </IconButton>
) : ( ) : (
<IconButton <IconButton
className={PhoneHit}
size="300" size="300"
radii="Pill" radii="Pill"
variant="Secondary" variant="Secondary"

View file

@ -28,7 +28,6 @@ import { useRoom } from '../../../hooks/useRoom';
import { useStateEvent } from '../../../hooks/useStateEvent'; import { useStateEvent } from '../../../hooks/useStateEvent';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions'; import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
import { PhoneHit } from '../../../styles/PhoneHit.css';
const ROOM_ENC_ALGO = 'm.megolm.v1.aes-sha2'; const ROOM_ENC_ALGO = 'm.megolm.v1.aes-sha2';
@ -124,12 +123,7 @@ export function RoomEncryption({ permissions }: RoomEncryptionProps) {
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">Enable Encryption</Text> <Text size="H4">Enable Encryption</Text>
</Box> </Box>
<IconButton <IconButton size="300" onClick={() => setPrompt(false)} radii="300">
className={PhoneHit}
size="300"
onClick={() => setPrompt(false)}
radii="300"
>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>

View file

@ -39,7 +39,6 @@ import { useAlive } from '../../../hooks/useAlive';
import { creatorsSupported } from '../../../utils/matrix'; import { creatorsSupported } from '../../../utils/matrix';
import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { BreakWord } from '../../../styles/Text.css'; import { BreakWord } from '../../../styles/Text.css';
import { PhoneHit } from '../../../styles/PhoneHit.css';
function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) { function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
const mx = useMatrixClient(); const mx = useMatrixClient();
@ -106,7 +105,7 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">{room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'}</Text> <Text size="H4">{room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'}</Text>
</Box> </Box>
<IconButton className={PhoneHit} size="300" onClick={requestClose} radii="300"> <IconButton size="300" onClick={requestClose} radii="300">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>

View file

@ -56,7 +56,6 @@ import { useSpaceOptionally } from '../../../hooks/useSpace';
import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../../hooks/useMemberPowerTag'; import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../../hooks/useMemberPowerTag';
import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { getMouseEventCords } from '../../../utils/dom'; import { getMouseEventCords } from '../../../utils/dom';
import { PhoneHit } from '../../../styles/PhoneHit.css';
const SEARCH_OPTIONS: UseAsyncSearchOptions = { const SEARCH_OPTIONS: UseAsyncSearchOptions = {
limit: 1000, limit: 1000,
@ -162,7 +161,7 @@ export function Members({ requestClose }: MembersProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>
@ -278,7 +277,6 @@ export function Members({ requestClose }: MembersProps) {
anchorRef={scrollTopAnchorRef} anchorRef={scrollTopAnchorRef}
> >
<IconButton <IconButton
className={PhoneHit}
onClick={() => virtualizer.scrollToOffset(0)} onClick={() => virtualizer.scrollToOffset(0)}
variant="Surface" variant="Surface"
radii="Pill" radii="Pill"

View file

@ -49,7 +49,6 @@ import { useAlive } from '../../../hooks/useAlive';
import { BetaNoticeBadge } from '../../../components/BetaNoticeBadge'; import { BetaNoticeBadge } from '../../../components/BetaNoticeBadge';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag'; import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { creatorsSupported } from '../../../utils/matrix'; import { creatorsSupported } from '../../../utils/matrix';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type EditPowerProps = { type EditPowerProps = {
maxPower: number; maxPower: number;
@ -370,7 +369,7 @@ export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) {
</Chip> </Chip>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

@ -24,7 +24,6 @@ import {
import { CreateRoomModalState } from '../../state/createRoomModal'; import { CreateRoomModalState } from '../../state/createRoomModal';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { CreateRoomType } from '../../components/create-room/types'; import { CreateRoomType } from '../../components/create-room/types';
import { PhoneHit } from '../../styles/PhoneHit.css';
type CreateRoomModalProps = { type CreateRoomModalProps = {
state: CreateRoomModalState; state: CreateRoomModalState;
@ -64,7 +63,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton className={PhoneHit} size="300" radii="300" onClick={closeDialog}> <IconButton size="300" radii="300" onClick={closeDialog}>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

@ -23,7 +23,6 @@ import {
} from '../../state/hooks/createSpaceModal'; } from '../../state/hooks/createSpaceModal';
import { CreateSpaceModalState } from '../../state/createSpaceModal'; import { CreateSpaceModalState } from '../../state/createSpaceModal';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { PhoneHit } from '../../styles/PhoneHit.css';
type CreateSpaceModalProps = { type CreateSpaceModalProps = {
state: CreateSpaceModalState; state: CreateSpaceModalState;
@ -62,7 +61,7 @@ function CreateSpaceModal({ state }: CreateSpaceModalProps) {
<Text size="H4">New Space</Text> <Text size="H4">New Space</Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton className={PhoneHit} size="300" radii="300" onClick={closeDialog}> <IconButton size="300" radii="300" onClick={closeDialog}>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

@ -1,7 +1,6 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds'; import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
import { PhoneHit } from '../../styles/PhoneHit.css';
import { Page, PageContent, PageContentCenter, PageHeader } from '../../components/page'; import { Page, PageContent, PageContentCenter, PageHeader } from '../../components/page';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { getHomePath } from '../../pages/pathUtils'; import { getHomePath } from '../../pages/pathUtils';
@ -34,11 +33,7 @@ export function Desk() {
<Box grow="Yes" alignItems="Center" gap="200"> <Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes" basis="No"> <Box grow="Yes" basis="No">
{phone && ( {phone && (
<IconButton <IconButton aria-label="Back to rooms" onClick={() => navigate(getHomePath())}>
className={PhoneHit}
aria-label="Back to rooms"
onClick={() => navigate(getHomePath())}
>
<Icon src={Icons.ArrowLeft} /> <Icon src={Icons.ArrowLeft} />
</IconButton> </IconButton>
)} )}

View file

@ -10,7 +10,6 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { allRoomsAtom } from '../../state/room-list/roomList'; import { allRoomsAtom } from '../../state/room-list/roomList';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { BackRouteHandler } from '../../components/BackRouteHandler'; import { BackRouteHandler } from '../../components/BackRouteHandler';
import { PhoneHit } from '../../styles/PhoneHit.css';
type JoinBeforeNavigateProps = { roomIdOrAlias: string; eventId?: string; viaServers?: string[] }; type JoinBeforeNavigateProps = { roomIdOrAlias: string; eventId?: string; viaServers?: string[] };
export function JoinBeforeNavigate({ export function JoinBeforeNavigate({
@ -39,7 +38,7 @@ export function JoinBeforeNavigate({
{screenSize === ScreenSize.Mobile && ( {screenSize === ScreenSize.Mobile && (
<BackRouteHandler> <BackRouteHandler>
{(onBack) => ( {(onBack) => (
<IconButton className={PhoneHit} onClick={onBack}> <IconButton onClick={onBack}>
<Icon src={Icons.ArrowLeft} /> <Icon src={Icons.ArrowLeft} />
</IconButton> </IconButton>
)} )}

View file

@ -30,7 +30,6 @@ import { IPowerLevels } from '../../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators'; import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions'; import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt'; import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { PhoneHitSpread24 } from '../../styles/PhoneHit.css';
type HierarchyItemWithParent = HierarchyItem & { type HierarchyItemWithParent = HierarchyItem & {
parentId: string; parentId: string;
@ -232,7 +231,6 @@ export function HierarchyItemMenu({
return ( return (
<Box gap="200" alignItems="Center" shrink="No"> <Box gap="200" alignItems="Center" shrink="No">
<IconButton <IconButton
className={PhoneHitSpread24}
onClick={handleOpenMenu} onClick={handleOpenMenu}
size="300" size="300"
variant="SurfaceVariant" variant="SurfaceVariant"

View file

@ -48,7 +48,6 @@ import { useGetRoom } from '../../hooks/useGetRoom';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions'; import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions';
import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators'; import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators';
import { PhoneHit } from '../../styles/PhoneHit.css';
const useCanDropLobbyItem = ( const useCanDropLobbyItem = (
space: Room, space: Room,
@ -414,7 +413,6 @@ export function Lobby() {
onVisibilityChange={setOnTop} onVisibilityChange={setOnTop}
> >
<IconButton <IconButton
className={PhoneHit}
onClick={() => virtualizer.scrollToOffset(0)} onClick={() => virtualizer.scrollToOffset(0)}
variant="SurfaceVariant" variant="SurfaceVariant"
radii="Pill" radii="Pill"

View file

@ -38,7 +38,6 @@ import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt'; import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { PhoneHit } from '../../styles/PhoneHit.css';
type LobbyMenuProps = { type LobbyMenuProps = {
powerLevels: IPowerLevels; powerLevels: IPowerLevels;
@ -166,7 +165,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
<Box shrink="No"> <Box shrink="No">
<BackRouteHandler> <BackRouteHandler>
{(onBack) => ( {(onBack) => (
<IconButton className={PhoneHit} fill="None" onClick={onBack}> <IconButton fill="None" onClick={onBack}>
<Icon src={Icons.ArrowLeft} /> <Icon src={Icons.ArrowLeft} />
</IconButton> </IconButton>
)} )}
@ -241,7 +240,6 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton
className={PhoneHit}
fill="None" fill="None"
onClick={handleOpenMenu} onClick={handleOpenMenu}
ref={triggerRef} ref={triggerRef}

View file

@ -23,7 +23,6 @@ import { SearchResultGroup } from './SearchResultGroup';
import { SearchInput } from './SearchInput'; import { SearchInput } from './SearchInput';
import { SearchFilters } from './SearchFilters'; import { SearchFilters } from './SearchFilters';
import { VirtualTile } from '../../components/virtualizer'; import { VirtualTile } from '../../components/virtualizer';
import { PhoneHit } from '../../styles/PhoneHit.css';
const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSearchParams => const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSearchParams =>
useMemo( useMemo(
@ -194,7 +193,6 @@ export function MessageSearch({
<Box direction="Column" gap="700"> <Box direction="Column" gap="700">
<ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}> <ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}>
<IconButton <IconButton
className={PhoneHit}
onClick={() => virtualizer.scrollToOffset(0)} onClick={() => virtualizer.scrollToOffset(0)}
variant="SurfaceVariant" variant="SurfaceVariant"
radii="Pill" radii="Pill"

View file

@ -1,72 +0,0 @@
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} />}
/>
);
}

View file

@ -1,24 +0,0 @@
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();
});
});

View file

@ -1,12 +0,0 @@
// 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;

View file

@ -1,29 +0,0 @@
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)
);
});
});

View file

@ -1,108 +0,0 @@
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);
});
});

View file

@ -1,95 +0,0 @@
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) };
}

View file

@ -1,143 +0,0 @@
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);
});
});

View file

@ -1,107 +0,0 @@
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();
}

View file

@ -1,111 +0,0 @@
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();
});
});

View file

@ -1,88 +0,0 @@
// 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;
},
};
}

View file

@ -1,41 +0,0 @@
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: {} }],
},
});

View file

@ -1,33 +0,0 @@
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]);
}

View file

@ -1,108 +0,0 @@
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/']);
});
});

View file

@ -1,62 +0,0 @@
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);
}

View file

@ -66,7 +66,6 @@ import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
import { livekitSupport } from '../../hooks/useLivekitSupport'; import { livekitSupport } from '../../hooks/useLivekitSupport';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
import { webRTCSupported } from '../../utils/rtc'; import { webRTCSupported } from '../../utils/rtc';
import { PhoneHitSpread24 } from '../../styles/PhoneHit.css';
type RoomNavItemMenuProps = { type RoomNavItemMenuProps = {
room: Room; room: Room;
@ -230,7 +229,6 @@ function CallChatToggle() {
return ( return (
<IconButton <IconButton
className={PhoneHitSpread24}
onClick={() => setChat(!chat)} onClick={() => setChat(!chat)}
aria-pressed={chat} aria-pressed={chat}
aria-label="Toggle Chat" aria-label="Toggle Chat"
@ -438,7 +436,6 @@ export function RoomNavItem({
} }
> >
<IconButton <IconButton
className={PhoneHitSpread24}
onClick={handleOpenMenu} onClick={handleOpenMenu}
aria-pressed={!!menuAnchor} aria-pressed={!!menuAnchor}
aria-controls={`menu-${room.roomId}`} aria-controls={`menu-${room.roomId}`}

View file

@ -17,7 +17,6 @@ import { Permissions } from './permissions';
import { RoomSettingsPage } from '../../state/roomSettings'; import { RoomSettingsPage } from '../../state/roomSettings';
import { useRoom } from '../../hooks/useRoom'; import { useRoom } from '../../hooks/useRoom';
import { DeveloperTools } from '../common-settings/developer-tools'; import { DeveloperTools } from '../common-settings/developer-tools';
import { PhoneHit } from '../../styles/PhoneHit.css';
type RoomSettingsMenuItem = { type RoomSettingsMenuItem = {
page: RoomSettingsPage; page: RoomSettingsPage;
@ -118,7 +117,7 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
{screenSize === ScreenSize.Mobile && ( {screenSize === ScreenSize.Mobile && (
<IconButton className={PhoneHit} onClick={requestClose} variant="Background"> <IconButton onClick={requestClose} variant="Background">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
)} )}

View file

@ -15,7 +15,6 @@ import {
} from '../../common-settings/general'; } from '../../common-settings/general';
import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type GeneralProps = { type GeneralProps = {
requestClose: () => void; requestClose: () => void;
@ -36,7 +35,7 @@ export function General({ requestClose }: GeneralProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

@ -9,7 +9,6 @@ import { usePermissionGroups } from './usePermissionItems';
import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions'; import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions';
import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type PermissionsProps = { type PermissionsProps = {
requestClose: () => void; requestClose: () => void;
@ -46,7 +45,7 @@ export function Permissions({ requestClose }: PermissionsProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

@ -6,7 +6,6 @@ import { Page, PageHeader } from '../../components/page';
import { callChatAtom } from '../../state/callEmbed'; import { callChatAtom } from '../../state/callEmbed';
import { RoomView } from './RoomView'; import { RoomView } from './RoomView';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { PhoneHit } from '../../styles/PhoneHit.css';
export function CallChatView() { export function CallChatView() {
const { eventId } = useParams(); const { eventId } = useParams();
@ -42,12 +41,7 @@ export function CallChatView() {
} }
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton ref={triggerRef} variant="Surface" onClick={handleClose}>
className={PhoneHit}
ref={triggerRef}
variant="Surface"
onClick={handleClose}
>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
)} )}

View file

@ -59,7 +59,6 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
import { ContainerColor } from '../../styles/ContainerColor.css'; import { ContainerColor } from '../../styles/ContainerColor.css';
import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag'; import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { PhoneHit } from '../../styles/PhoneHit.css';
type MemberDrawerHeaderProps = { type MemberDrawerHeaderProps = {
room: Room; room: Room;
@ -88,7 +87,6 @@ function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) {
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton
className={PhoneHit}
ref={triggerRef} ref={triggerRef}
variant="Background" variant="Background"
onClick={() => setPeopleDrawer(false)} onClick={() => setPeopleDrawer(false)}
@ -361,7 +359,6 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
<ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}> <ScrollTopContainer scrollRef={scrollRef} anchorRef={scrollTopAnchorRef}>
<IconButton <IconButton
className={PhoneHit}
onClick={() => virtualizer.scrollToOffset(0)} onClick={() => virtualizer.scrollToOffset(0)}
variant="Surface" variant="Surface"
radii="Pill" radii="Pill"

View file

@ -28,9 +28,6 @@ import {
config, config,
toRem, toRem,
} from 'folds'; } 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 { useMatrixClient } from '../../hooks/useMatrixClient';
import { import {
@ -116,7 +113,6 @@ import { useTheme } from '../../hooks/useTheme';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useComposingCheck } from '../../hooks/useComposingCheck'; import { useComposingCheck } from '../../hooks/useComposingCheck';
import { PhoneHitSpread24, PhoneHitSpread32 } from '../../styles/PhoneHit.css';
interface RoomInputProps { interface RoomInputProps {
editor: Editor; editor: Editor;
@ -225,66 +221,22 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
useCallback((width) => setHideStickerBtn(width < 500), []) 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(() => { useEffect(() => {
Transforms.insertFragment(editor, msgDraft); Transforms.insertFragment(editor, msgDraft);
}, [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( useEffect(
() => () => { () => () => {
clearTimeout(draftSaveTimer.current);
draftSaveTimer.current = undefined;
if (!isEmptyEditor(editor)) { if (!isEmptyEditor(editor)) {
const parsedDraft = JSON.parse(JSON.stringify(editor.children)); const parsedDraft = JSON.parse(JSON.stringify(editor.children));
setMsgDraft(parsedDraft); setMsgDraft(parsedDraft);
saveDraft(userId, roomId, parsedDraft);
} else { } else {
setMsgDraft([]); setMsgDraft([]);
clearDraft(userId, roomId);
} }
resetEditor(editor); resetEditor(editor);
resetEditorHistory(editor); resetEditorHistory(editor);
}, },
[roomId, editor, setMsgDraft, userId] [roomId, editor, setMsgDraft]
); );
const handleFileMetadata = useCallback( const handleFileMetadata = useCallback(
@ -335,7 +287,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}); });
handleCancelUpload(uploads); handleCancelUpload(uploads);
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
contents.forEach((content) => sendTracked(mx, roomId, content as Record<string, unknown>)); contents.forEach((content) => mx.sendMessage(roomId, content as any));
}; };
const submit = useCallback(() => { const submit = useCallback(() => {
@ -414,25 +366,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
content['m.relates_to'].is_falling_back = false; content['m.relates_to'].is_falling_back = false;
} }
} }
sendTracked(mx, roomId, content); mx.sendMessage(roomId, content as any);
clearTimeout(draftSaveTimer.current);
draftSaveTimer.current = undefined;
clearDraft(userId, roomId);
resetEditor(editor); resetEditor(editor);
resetEditorHistory(editor); resetEditorHistory(editor);
setReplyDraft(undefined); setReplyDraft(undefined);
sendTypingStatus(false); sendTypingStatus(false);
}, [ }, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
mx,
roomId,
userId,
editor,
replyDraft,
sendTypingStatus,
setReplyDraft,
isMarkdown,
commands,
]);
const handleKeyDown: KeyboardEventHandler = useCallback( const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => { (evt) => {
@ -494,7 +433,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
await getImageUrlBlob(stickerUrl) await getImageUrlBlob(stickerUrl)
); );
sendTracked(mx, roomId, { body: label, url: mxc, info }, EventType.Sticker); mx.sendEvent(roomId, EventType.Sticker, {
body: label,
url: mxc,
info,
});
}; };
return ( return (
@ -594,53 +537,47 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp} onKeyUp={handleKeyUp}
onPaste={handlePaste} onPaste={handlePaste}
onChange={handleEditorChange}
top={ top={
<> replyDraft && (
<RoomOutbox mx={mx} roomId={roomId} /> <div>
{replyDraft && ( <Box
<div> alignItems="Center"
<Box gap="300"
alignItems="Center" style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
gap="300" >
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }} <IconButton
onClick={() => setReplyDraft(undefined)}
variant="SurfaceVariant"
size="300"
radii="300"
> >
<IconButton <Icon src={Icons.Cross} size="50" />
className={PhoneHitSpread24} </IconButton>
onClick={() => setReplyDraft(undefined)} <Box direction="Row" gap="200" alignItems="Center">
variant="SurfaceVariant" {replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
size="300" <ReplyLayout
radii="300" userColor={replyUsernameColor}
> username={
<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> <Text size="T300" truncate>
{trimReplyFromBody(replyDraft.body)} <b>
{getMemberDisplayName(room, replyDraft.userId) ??
getMxIdLocalPart(replyDraft.userId) ??
replyDraft.userId}
</b>
</Text> </Text>
</ReplyLayout> }
</Box> >
<Text size="T300" truncate>
{trimReplyFromBody(replyDraft.body)}
</Text>
</ReplyLayout>
</Box> </Box>
</div> </Box>
)} </div>
</> )
} }
before={ before={
<IconButton <IconButton
className={PhoneHitSpread32}
onClick={() => pickFile('*')} onClick={() => pickFile('*')}
variant="SurfaceVariant" variant="SurfaceVariant"
size="300" size="300"
@ -652,7 +589,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
after={ after={
<> <>
<IconButton <IconButton
className={PhoneHitSpread32}
variant="SurfaceVariant" variant="SurfaceVariant"
size="300" size="300"
radii="300" radii="300"
@ -695,7 +631,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
> >
{!hideStickerBtn && ( {!hideStickerBtn && (
<IconButton <IconButton
className={PhoneHitSpread32}
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker} aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)} onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
variant="SurfaceVariant" variant="SurfaceVariant"
@ -709,7 +644,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</IconButton> </IconButton>
)} )}
<IconButton <IconButton
className={PhoneHitSpread32}
ref={emojiBtnRef} ref={emojiBtnRef}
aria-pressed={ aria-pressed={
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
@ -729,13 +663,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</PopOut> </PopOut>
)} )}
</UseStateProvider> </UseStateProvider>
<IconButton <IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
className={PhoneHitSpread32}
onClick={submit}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.Send} /> <Icon src={Icons.Send} />
</IconButton> </IconButton>
</> </>

View file

@ -1,102 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { enqueueOutbox, failOutbox, loadOutbox } from '../../state/room/composerStore';
import { RoomOutbox } from './RoomOutbox';
// The component's hooks are held still (state is its initial value, effects do not run) so the element tree
// it returns can be read and its click handlers called without a DOM.
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<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' });
});
});

View file

@ -1,64 +0,0 @@
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { enqueueOutbox, failOutbox } from '../../state/room/composerStore';
import { RoomOutbox } from './RoomOutbox';
// What the person sees under the timeline for sends the server has not confirmed.
const text = (html: string) => html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ');
describe('RoomOutbox as rendered', () => {
beforeEach(() => {
const m = new Map<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');
});
});

View file

@ -1,28 +0,0 @@
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/);
});
});

View file

@ -1,121 +0,0 @@
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>
);
}

View file

@ -47,7 +47,6 @@ import {
import { isKeyHotkey } from 'is-hotkey'; import { isKeyHotkey } from 'is-hotkey';
import { Opts as LinkifyOpts } from 'linkifyjs'; import { Opts as LinkifyOpts } from 'linkifyjs';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { isUnconfirmedLocalEcho } from './localEcho';
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix'; import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator'; import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
@ -390,19 +389,6 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void)
}, [room, onArrive]); }, [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) => { const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => {
useEffect(() => { useEffect(() => {
const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r) => { const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r) => {
@ -662,18 +648,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
) )
); );
useLocalEchoRefresh(
room,
useCallback(() => {
// the echo was hidden while unconfirmed, so its arrival scrolled to nothing: scroll now
if (atBottomRef.current) {
scrollToBottomRef.current.count += 1;
scrollToBottomRef.current.smooth = true;
}
setTimeline((ct) => ({ ...ct }));
}, [])
);
const handleOpenEvent = useCallback( const handleOpenEvent = useCallback(
async ( async (
evtId: string, evtId: string,
@ -1672,7 +1646,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
if (mEvent.isRedacted() && !showHiddenEvents) { if (mEvent.isRedacted() && !showHiddenEvents) {
return null; return null;
} }
if (isUnconfirmedLocalEcho(mEvent)) return null;
if (!newDivider && readUptoEventIdRef.current) { if (!newDivider && readUptoEventIdRef.current) {
newDivider = prevEvent?.getId() === readUptoEventIdRef.current; newDivider = prevEvent?.getId() === readUptoEventIdRef.current;

View file

@ -72,7 +72,6 @@ import { RoomSettingsPage } from '../../state/roomSettings';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed'; import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { useLivekitSupport } from '../../hooks/useLivekitSupport'; import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc'; import { webRTCSupported } from '../../utils/rtc';
import { PhoneHitSpread40 } from '../../styles/PhoneHit.css';
type RoomMenuProps = { type RoomMenuProps = {
room: Room; room: Room;
@ -337,7 +336,6 @@ function CallButton() {
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton
className={PhoneHitSpread40}
variant="Surface" variant="Surface"
fill="None" fill="None"
ref={triggerRef} ref={triggerRef}
@ -457,7 +455,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
<BackRouteHandler> <BackRouteHandler>
{(onBack) => ( {(onBack) => (
<Box shrink="No" alignItems="Center"> <Box shrink="No" alignItems="Center">
<IconButton className={PhoneHitSpread40} fill="None" onClick={onBack}> <IconButton fill="None" onClick={onBack}>
<Icon src={Icons.ArrowLeft} /> <Icon src={Icons.ArrowLeft} />
</IconButton> </IconButton>
</Box> </Box>
@ -533,12 +531,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
} }
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton fill="None" ref={triggerRef} onClick={handleSearchClick}>
className={PhoneHitSpread40}
fill="None"
ref={triggerRef}
onClick={handleSearchClick}
>
<Icon size="400" src={Icons.Search} /> <Icon size="400" src={Icons.Search} />
</IconButton> </IconButton>
)} )}
@ -555,7 +548,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton
className={PhoneHitSpread40}
fill="None" fill="None"
style={{ position: 'relative' }} style={{ position: 'relative' }}
onClick={handleOpenPinMenu} onClick={handleOpenPinMenu}
@ -620,12 +612,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
} }
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton fill="None" ref={triggerRef} onClick={handleMemberToggle}>
className={PhoneHitSpread40}
fill="None"
ref={triggerRef}
onClick={handleMemberToggle}
>
<Icon size="400" src={Icons.User} /> <Icon size="400" src={Icons.User} />
</IconButton> </IconButton>
)} )}
@ -644,7 +631,6 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
> >
{(triggerRef) => ( {(triggerRef) => (
<IconButton <IconButton
className={PhoneHitSpread40}
fill="None" fill="None"
onClick={handleOpenMenu} onClick={handleOpenMenu}
ref={triggerRef} ref={triggerRef}

View file

@ -10,7 +10,6 @@ import { getMxIdLocalPart } from '../../utils/matrix';
import * as css from './RoomViewTyping.css'; import * as css from './RoomViewTyping.css';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers'; import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
import { PhoneHit } from '../../styles/PhoneHit.css';
export type RoomViewTypingProps = { export type RoomViewTypingProps = {
room: Room; room: Room;
@ -112,13 +111,7 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>(
</> </>
)} )}
</Text> </Text>
<IconButton <IconButton title="Drop Typing Status" size="300" radii="Pill" onClick={handleDropAll}>
className={PhoneHit}
title="Drop Typing Status"
size="300"
radii="Pill"
onClick={handleDropAll}
>
<Icon size="50" src={Icons.Cross} /> <Icon size="50" src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

@ -31,7 +31,6 @@ import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../
import { DatePicker, TimePicker } from '../../../components/time-date'; import { DatePicker, TimePicker } from '../../../components/time-date';
import { useSetting } from '../../../state/hooks/settings'; import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings'; import { settingsAtom } from '../../../state/settings';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type JumpToTimeProps = { type JumpToTimeProps = {
onCancel: () => void; onCancel: () => void;
@ -109,7 +108,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">Jump to Time</Text> <Text size="H4">Jump to Time</Text>
</Box> </Box>
<IconButton className={PhoneHit} size="300" onClick={onCancel} radii="300"> <IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>

View file

@ -1,20 +0,0 @@
import { describe, expect, it } from 'vitest';
import { EventStatus } from 'matrix-js-sdk';
import { isUnconfirmedLocalEcho } from './localEcho';
describe('isUnconfirmedLocalEcho', () => {
it.each([
EventStatus.ENCRYPTING,
EventStatus.QUEUED,
EventStatus.SENDING,
EventStatus.NOT_SENT,
EventStatus.CANCELLED,
])('hides %s', (status) => {
expect(isUnconfirmedLocalEcho({ status })).toBe(true);
});
it('shows a server-confirmed event and one the server has accepted (SENT)', () => {
expect(isUnconfirmedLocalEcho({ status: null })).toBe(false);
expect(isUnconfirmedLocalEcho({ status: EventStatus.SENT })).toBe(false);
});
});

View file

@ -1,10 +0,0 @@
import { EventStatus, MatrixEvent } from 'matrix-js-sdk';
/**
* A local echo the server has not accepted: the SDK adds it to the live timeline the moment
* sendMessage is called, marks it NOT_SENT on failure, and drops it on reload. Rendered like
* any message it looks delivered and then vanishes. The outbox (RoomOutbox) is the one place
* that shows unconfirmed sends, so the timeline hides these until the status is SENT.
*/
export const isUnconfirmedLocalEcho = (mEvent: Pick<MatrixEvent, 'status'>): boolean =>
mEvent.status !== null && mEvent.status !== undefined && mEvent.status !== EventStatus.SENT;

View file

@ -86,7 +86,6 @@ import colorMXID from '../../../../util/colorMXID';
import { useSkyInk } from '../../../hooks/useTheme'; import { useSkyInk } from '../../../hooks/useTheme';
import { userColorInk } from '../../scene/skyInk'; import { userColorInk } from '../../scene/skyInk';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag'; import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { PhoneHitRoom26, PhoneHitSpread32 } from '../../../styles/PhoneHit.css';
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
@ -112,7 +111,7 @@ export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>(
{recentEmojis.map((emoji) => ( {recentEmojis.map((emoji) => (
<IconButton <IconButton
key={emoji.unicode} key={emoji.unicode}
className={classNames(css.MessageQuickReaction, PhoneHitSpread32)} className={css.MessageQuickReaction}
size="300" size="300"
variant="SurfaceVariant" variant="SurfaceVariant"
radii="Pill" radii="Pill"
@ -460,12 +459,7 @@ export const MessageDeleteItem = as<
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">Delete Message</Text> <Text size="H4">Delete Message</Text>
</Box> </Box>
<IconButton <IconButton size="300" onClick={handleClose} radii="300">
className={PhoneHitSpread32}
size="300"
onClick={handleClose}
radii="300"
>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
@ -595,12 +589,7 @@ export const MessageReportItem = as<
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">Report Message</Text> <Text size="H4">Report Message</Text>
</Box> </Box>
<IconButton <IconButton size="300" onClick={handleClose} radii="300">
className={PhoneHitSpread32}
size="300"
onClick={handleClose}
radii="300"
>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
@ -961,7 +950,6 @@ export const Message = as<'div', MessageProps>(
} }
> >
<IconButton <IconButton
className={PhoneHitRoom26}
onClick={handleOpenEmojiBoard} onClick={handleOpenEmojiBoard}
variant="SurfaceVariant" variant="SurfaceVariant"
size="300" size="300"
@ -973,7 +961,6 @@ export const Message = as<'div', MessageProps>(
</PopOut> </PopOut>
)} )}
<IconButton <IconButton
className={PhoneHitRoom26}
onClick={onReplyClick} onClick={onReplyClick}
data-event-id={mEvent.getId()} data-event-id={mEvent.getId()}
variant="SurfaceVariant" variant="SurfaceVariant"
@ -984,7 +971,6 @@ export const Message = as<'div', MessageProps>(
</IconButton> </IconButton>
{!isThreadedMessage && ( {!isThreadedMessage && (
<IconButton <IconButton
className={PhoneHitRoom26}
onClick={(ev) => onReplyClick(ev, true)} onClick={(ev) => onReplyClick(ev, true)}
data-event-id={mEvent.getId()} data-event-id={mEvent.getId()}
variant="SurfaceVariant" variant="SurfaceVariant"
@ -996,7 +982,6 @@ export const Message = as<'div', MessageProps>(
)} )}
{canEditEvent(mx, mEvent) && onEditId && ( {canEditEvent(mx, mEvent) && onEditId && (
<IconButton <IconButton
className={PhoneHitRoom26}
onClick={() => onEditId(mEvent.getId())} onClick={() => onEditId(mEvent.getId())}
variant="SurfaceVariant" variant="SurfaceVariant"
size="300" size="300"
@ -1162,7 +1147,6 @@ export const Message = as<'div', MessageProps>(
} }
> >
<IconButton <IconButton
className={PhoneHitRoom26}
variant="SurfaceVariant" variant="SurfaceVariant"
size="300" size="300"
radii="300" radii="300"
@ -1336,7 +1320,6 @@ export const Event = as<'div', EventProps>(
} }
> >
<IconButton <IconButton
className={PhoneHitRoom26}
variant="SurfaceVariant" variant="SurfaceVariant"
size="300" size="300"
radii="300" radii="300"

View file

@ -54,7 +54,6 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room'; import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room';
import { mobileOrTablet } from '../../../utils/user-agent'; import { mobileOrTablet } from '../../../utils/user-agent';
import { useComposingCheck } from '../../../hooks/useComposingCheck'; import { useComposingCheck } from '../../../hooks/useComposingCheck';
import { PhoneHitSpread32 } from '../../../styles/PhoneHit.css';
type MessageEditorProps = { type MessageEditorProps = {
roomId: string; roomId: string;
@ -290,7 +289,6 @@ export const MessageEditor = as<'div', MessageEditorProps>(
</Box> </Box>
<Box gap="Inherit"> <Box gap="Inherit">
<IconButton <IconButton
className={PhoneHitSpread32}
variant="SurfaceVariant" variant="SurfaceVariant"
size="300" size="300"
radii="300" radii="300"
@ -324,7 +322,6 @@ export const MessageEditor = as<'div', MessageEditorProps>(
} }
> >
<IconButton <IconButton
className={PhoneHitSpread32}
aria-pressed={anchor !== undefined} aria-pressed={anchor !== undefined}
onClick={ onClick={
((evt) => ((evt) =>

View file

@ -28,7 +28,6 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile'; import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../../hooks/useSpace'; import { useSpaceOptionally } from '../../../hooks/useSpace';
import { getMouseEventCords } from '../../../utils/dom'; import { getMouseEventCords } from '../../../utils/dom';
import { PhoneHit } from '../../../styles/PhoneHit.css';
export type ReactionViewerProps = { export type ReactionViewerProps = {
room: Room; room: Room;
@ -101,7 +100,7 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
<Box grow="Yes"> <Box grow="Yes">
<Text size="H3" truncate>{`Reacted with :${selectedShortcode}:`}</Text> <Text size="H3" truncate>{`Reacted with :${selectedShortcode}:`}</Text>
</Box> </Box>
<IconButton className={PhoneHit} size="300" onClick={requestClose}> <IconButton size="300" onClick={requestClose}>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>

View file

@ -86,7 +86,6 @@ import {
useGetMemberPowerTag, useGetMemberPowerTag,
} from '../../../hooks/useMemberPowerTag'; } from '../../../hooks/useMemberPowerTag';
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag'; import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { PhoneHit, PhoneHitSpread26 } from '../../../styles/PhoneHit.css';
type PinnedMessageProps = { type PinnedMessageProps = {
room: Room; room: Room;
@ -147,7 +146,6 @@ function PinnedMessage({
</Chip> </Chip>
{canPinEvent && ( {canPinEvent && (
<IconButton <IconButton
className={PhoneHitSpread26}
data-event-id={eventId} data-event-id={eventId}
variant="Secondary" variant="Secondary"
size="300" size="300"
@ -459,7 +457,7 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
<Text size="H5">Pinned Messages</Text> <Text size="H5">Pinned Messages</Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton className={PhoneHit} size="300" onClick={requestClose} radii="300"> <IconButton size="300" onClick={requestClose} radii="300">
<Icon src={Icons.Cross} size="400" /> <Icon src={Icons.Cross} size="400" />
</IconButton> </IconButton>
</Box> </Box>

View file

@ -28,7 +28,6 @@ import {
setSceneStyle, setSceneStyle,
type ServedSceneStyle, type ServedSceneStyle,
} from './sceneStyle'; } from './sceneStyle';
import { PhoneHit } from '../../styles/PhoneHit.css';
const STYLE_LABEL: Record<ServedSceneStyle, string> = { terrain: 'Terrain', ridge: 'Ridges' }; const STYLE_LABEL: Record<ServedSceneStyle, string> = { terrain: 'Terrain', ridge: 'Ridges' };
@ -150,7 +149,6 @@ export function SkyStateControl() {
} }
> >
<IconButton <IconButton
className={PhoneHit}
variant="Background" variant="Background"
aria-label={`Sky: ${LABEL[state]}`} aria-label={`Sky: ${LABEL[state]}`}
title={`Sky: ${LABEL[state]}`} title={`Sky: ${LABEL[state]}`}

View file

@ -33,7 +33,6 @@ import { About } from './about';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { LogoutDialog } from '../../components/LogoutDialog'; import { LogoutDialog } from '../../components/LogoutDialog';
import { PhoneHit } from '../../styles/PhoneHit.css';
export enum SettingsPages { export enum SettingsPages {
GeneralPage, GeneralPage,
@ -142,7 +141,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
{screenSize === ScreenSize.Mobile && ( {screenSize === ScreenSize.Mobile && (
<IconButton className={PhoneHit} onClick={requestClose} variant="Background"> <IconButton onClick={requestClose} variant="Background">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
)} )}

View file

@ -8,7 +8,6 @@ import { LogoMark } from '../../../components/logo/logoMark.css';
import RatatoskrSVG from '../../../../../public/res/svg/ratatoskr.svg'; import RatatoskrSVG from '../../../../../public/res/svg/ratatoskr.svg';
import { clearCacheAndReload } from '../../../../client/initMatrix'; import { clearCacheAndReload } from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type AboutProps = { type AboutProps = {
requestClose: () => void; requestClose: () => void;
@ -26,7 +25,7 @@ export function About({ requestClose }: AboutProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

@ -5,7 +5,6 @@ import { MatrixId } from './MatrixId';
import { Profile } from './Profile'; import { Profile } from './Profile';
import { ContactInformation } from './ContactInfo'; import { ContactInformation } from './ContactInfo';
import { IgnoredUserList } from './IgnoredUserList'; import { IgnoredUserList } from './IgnoredUserList';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type AccountProps = { type AccountProps = {
requestClose: () => void; requestClose: () => void;
@ -21,7 +20,7 @@ export function Account({ requestClose }: AccountProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

@ -8,7 +8,6 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { isUserId } from '../../../utils/matrix'; import { isUserId } from '../../../utils/matrix';
import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers'; import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers';
import { useAlive } from '../../../hooks/useAlive'; import { useAlive } from '../../../hooks/useAlive';
import { PhoneHit } from '../../../styles/PhoneHit.css';
function IgnoreUserInput({ userList }: { userList: string[] }) { function IgnoreUserInput({ userList }: { userList: string[] }) {
const mx = useMatrixClient(); const mx = useMatrixClient();
@ -68,7 +67,6 @@ function IgnoreUserInput({ userList }: { userList: string[] }) {
userId && userId &&
!ignoring && ( !ignoring && (
<IconButton <IconButton
className={PhoneHit}
type="reset" type="reset"
onClick={handleReset} onClick={handleReset}
size="300" size="300"

View file

@ -43,7 +43,6 @@ import { ModalWide } from '../../../styles/Modal.css';
import { createUploadAtom, UploadSuccess } from '../../../state/upload'; import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { CompactUploadCardRenderer } from '../../../components/upload-card'; import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useCapabilities } from '../../../hooks/useCapabilities'; import { useCapabilities } from '../../../hooks/useCapabilities';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type ProfileProps = { type ProfileProps = {
profile: UserProfile; profile: UserProfile;
@ -186,12 +185,7 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
<Box grow="Yes"> <Box grow="Yes">
<Text size="H4">Remove Avatar</Text> <Text size="H4">Remove Avatar</Text>
</Box> </Box>
<IconButton <IconButton size="300" onClick={() => setAlertRemove(false)} radii="300">
className={PhoneHit}
size="300"
onClick={() => setAlertRemove(false)}
radii="300"
>
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Header> </Header>
@ -279,7 +273,6 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
hasChanges && hasChanges &&
!changingDisplayName && ( !changingDisplayName && (
<IconButton <IconButton
className={PhoneHit}
type="reset" type="reset"
onClick={handleReset} onClick={handleReset}
size="300" size="300"

View file

@ -13,7 +13,6 @@ import {
} from '../../../components/AccountDataEditor'; } from '../../../components/AccountDataEditor';
import { copyToClipboard } from '../../../utils/dom'; import { copyToClipboard } from '../../../utils/dom';
import { AccountData } from './AccountData'; import { AccountData } from './AccountData';
import { PhoneHit } from '../../../styles/PhoneHit.css';
type DeveloperToolsProps = { type DeveloperToolsProps = {
requestClose: () => void; requestClose: () => void;
@ -52,7 +51,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
</Text> </Text>
</Box> </Box>
<Box shrink="No"> <Box shrink="No">
<IconButton className={PhoneHit} onClick={requestClose} variant="Surface"> <IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} /> <Icon src={Icons.Cross} />
</IconButton> </IconButton>
</Box> </Box>

View file

@ -29,7 +29,6 @@ import { LogoutDialog } from '../../../components/LogoutDialog';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
import { useSetting } from '../../../state/hooks/settings'; import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings'; import { settingsAtom } from '../../../state/settings';
import { PhoneHit } from '../../../styles/PhoneHit.css';
export function DeviceTilePlaceholder() { export function DeviceTilePlaceholder() {
return ( return (
@ -289,7 +288,6 @@ export function DeviceTile({
<SettingTile <SettingTile
before={ before={
<IconButton <IconButton
className={PhoneHit}
variant={deleted ? 'Critical' : 'Secondary'} variant={deleted ? 'Critical' : 'Secondary'}
outlined={deleted} outlined={deleted}
radii="300" radii="300"

Some files were not shown because too many files have changed in this diff Show more