atagen

malice

public
Star0
Watch0

Commit 0e50e0d137af

tarball

atagen <boss@atagen.co>2026-10-26 05:50 UTC

11 files changed+559-3
web: support touch screens

iOS never fires contextmenu on long-press, so room, space, user and sticker menus were unreachable on iPhone. A shared long-press action now opens them, the back gesture closes the topmost layer instead of leaving the app, and the layout follows the iOS keyboard through visualViewport.
Modified file: web/src/lib/Composer.svelte+6 additions−1 deletions
@@ -1,5 +1,6 @@
11 <script lang="ts">
2- import { tick } from 'svelte';
2+ import { onDestroy, tick } from 'svelte';
3+ import { backStack, instanceLayerId } from './back-stack';
34 import ComposerText from './ComposerText.svelte';
45 import EmojiText from './EmojiText.svelte';
56 import MxcAvatar from './MxcAvatar.svelte';
@@ -47,6 +48,10 @@
4748 let fileInput: HTMLInputElement | null = null;
4849 let composing = false;
4950 let stickersOpen = false;
51+ const stickersLayer = instanceLayerId('sticker-picker');
52+ const dismissStickers = () => (stickersOpen = false);
53+ $: backStack.sync(stickersLayer, stickersOpen, dismissStickers);
54+ onDestroy(() => backStack.sync(stickersLayer, false, dismissStickers));
5055
5156 let mentionQuery: MentionQuery | null = null;
5257 let mentionIndex = 0;
Modified file: web/src/lib/PackManager.svelte+1 additions−1 deletions
@@ -382,7 +382,7 @@
382382 {/if}
383383
384384 <style>
385- .packs { width: min(820px, calc(100vw - 32px)); max-height: calc(100vh - 64px); display: flex; flex-direction: column; }
385+ .packs { width: min(820px, calc(100vw - 32px)); max-height: calc(100dvh - 64px); display: flex; flex-direction: column; }
386386 .layout { display: flex; gap: 14px; min-height: 0; flex: 1; }
387387 .pack-list { flex: none; width: 210px; display: flex; flex-direction: column; gap: 2px; overflow-y: auto; }
388388 h4 { margin: 10px 6px 4px; font-size: 12px; color: var(--muted); font-weight: 650; }
Modified file: web/src/lib/PinButton.svelte+7 additions
@@ -42,4 +42,11 @@
4242 .pin-btn.pinned { opacity: 1; color: var(--accent); }
4343 .pin-btn:hover { background: var(--hover); color: var(--text); }
4444 :global(.room-item:hover) .pin-btn, :global(.room-item:focus-within) .pin-btn, .pin-btn:focus-visible { opacity: 1; }
45+ @media (hover: none) {
46+ :global(.room-item:has(.room-row.active)) .pin-btn { opacity: 1; }
47+ }
48+ @media (pointer: coarse) {
49+ .pin-btn { position: relative; }
50+ .pin-btn::after { content: ''; position: absolute; inset: -8px -4px; }
51+ }
4552 </style>
Modified file: web/src/lib/RoomThreads.svelte+2 additions
@@ -64,6 +64,8 @@
6464 .thread-preview { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
6565 .thread-meta { flex: none; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
6666 .thread-item:hover :global(.pin-btn), .thread-item :global(.pin-btn.pinned) { opacity: 1; }
67+ @media (hover: none) { .thread-item:has(.active) :global(.pin-btn) { opacity: 1; } }
68+ @media (pointer: coarse) { .thread-row { padding: 9px 6px; } }
6769 .thread-unread { flex: none; min-width: 16px; padding: 0 4px; border-radius: var(--r-sm); background: var(--panel-2); color: var(--text); font-size: 10.5px; font-weight: 600; text-align: center; font-variant-numeric: tabular-nums; }
6870 .thread-unread.mention { background: var(--accent); color: var(--on-accent); }
6971 .thread-more:disabled { color: var(--muted); cursor: default; }
Modified file: web/src/lib/StickerPicker.svelte+3 additions−1 deletions
@@ -5,6 +5,7 @@
55 import EmojiText from './EmojiText.svelte';
66 import { packLabel, stickersOf } from './packs';
77 import type { ImagePack, PackImage } from './types';
8+ import { longpress } from './longpress';
89
910 export let packs: ImagePack[];
1011 export let roomName: (roomId: string) => string;
@@ -90,6 +91,7 @@
9091 aria-label={image.body ?? image.shortcode}
9192 on:click={() => onSend(g.pack, image)}
9293 on:contextmenu|preventDefault={(ev) => onClone(g.pack, image, ev.clientX, ev.clientY)}
94+ use:longpress={(ev) => onClone(g.pack, image, ev.clientX, ev.clientY)}
9395 >
9496 <img src={messageMediaUrl(image.media_id, 'Thumbnail')} alt="" loading="lazy" />
9597 </button>
@@ -116,7 +118,7 @@
116118 gap: 6px;
117119 width: 360px;
118120 height: auto;
119- max-height: min(420px, calc(100vh - 120px));
121+ max-height: min(420px, calc(100dvh - 120px));
120122 max-width: calc(100vw - 16px);
121123 padding: 8px;
122124 background: var(--panel);
Added file: web/src/lib/back-stack.ts+129 additions
@@ -0,0 +1,129 @@
1+const KEY = 'backLayer';
2+const SETTLE_MS = 400;
3+
4+export interface HistoryLike {
5+ state: unknown;
6+ pushState(state: unknown, unused: string, url?: string): void;
7+ replaceState(state: unknown, unused: string, url?: string): void;
8+ back(): void;
9+}
10+
11+interface Layer {
12+ id: string;
13+ close: (id: string) => void;
14+}
15+
16+function layerOf(state: unknown): string | undefined {
17+ if (state && typeof state === 'object' && KEY in state) return String((state as Record<string, unknown>)[KEY]);
18+ return undefined;
19+}
20+
21+function plain(state: unknown): Record<string, unknown> {
22+ const { [KEY]: _drop, ...rest } = (state && typeof state === 'object' ? state : {}) as Record<string, unknown>;
23+ return rest;
24+}
25+
26+export class BackStack {
27+ private stack: Layer[] = [];
28+ private queue: Array<() => void> = [];
29+ private waiting = false;
30+ private timer: ReturnType<typeof setTimeout> | null = null;
31+
32+ constructor(private readonly history: HistoryLike) {}
33+
34+ /** A reload can land on a layer entry whose layer no longer exists. */
35+ init(): void {
36+ if (layerOf(this.history.state) !== undefined) this.history.replaceState(plain(this.history.state), '');
37+ }
38+
39+ get depth(): number {
40+ return this.stack.length;
41+ }
42+
43+ sync(id: string, open: boolean, close: (id: string) => void): void {
44+ const index = this.stack.findIndex((l) => l.id === id);
45+ if (open && index < 0) {
46+ this.stack.push({ id, close });
47+ this.run(() => this.history.pushState({ ...plain(this.history.state), [KEY]: id }, ''));
48+ } else if (open) {
49+ this.stack[index].close = close;
50+ } else if (index >= 0) {
51+ const top = index === this.stack.length - 1;
52+ this.stack.splice(index, 1);
53+ if (top) this.run(() => this.rewind());
54+ }
55+ }
56+
57+ /** Records a route, replacing the layer entry on top when layers are being dismissed by the navigation. */
58+ route(state: unknown, url: string): void {
59+ this.run(() => {
60+ if (this.stack.length > 0 && layerOf(this.history.state) !== undefined) {
61+ this.stack = [];
62+ this.history.replaceState(state, '', url);
63+ } else {
64+ this.history.pushState(state, '', url);
65+ }
66+ });
67+ }
68+
69+ /** Returns true when the popstate belonged to the layer stack, false when it is a plain route change. */
70+ pop(state: unknown): boolean {
71+ if (this.waiting) {
72+ this.settle();
73+ return true;
74+ }
75+ const id = layerOf(state);
76+ if (id === undefined) {
77+ if (this.stack.length === 0) return false;
78+ this.closeAbove(-1);
79+ return true;
80+ }
81+ const index = this.stack.findIndex((l) => l.id === id);
82+ if (index < 0) {
83+ this.history.back();
84+ return true;
85+ }
86+ this.closeAbove(index);
87+ return true;
88+ }
89+
90+ private closeAbove(index: number): void {
91+ const closing = this.stack.splice(index + 1);
92+ for (const layer of closing.reverse()) layer.close(layer.id);
93+ }
94+
95+ private rewind(): void {
96+ if (layerOf(this.history.state) === undefined) return;
97+ this.waiting = true;
98+ this.timer = setTimeout(() => this.settle(), SETTLE_MS);
99+ this.history.back();
100+ }
101+
102+ private settle(): void {
103+ this.waiting = false;
104+ if (this.timer !== null) clearTimeout(this.timer);
105+ this.timer = null;
106+ const next = this.queue.shift();
107+ if (next) this.run(next);
108+ }
109+
110+ private run(op: () => void): void {
111+ if (this.waiting) {
112+ this.queue.push(op);
113+ return;
114+ }
115+ op();
116+ if (!this.waiting) {
117+ const next = this.queue.shift();
118+ if (next) this.run(next);
119+ }
120+ }
121+}
122+
123+export const backStack = new BackStack(typeof window === 'undefined' ? ({} as HistoryLike) : window.history);
124+
125+let instances = 0;
126+
127+export function instanceLayerId(prefix: string): string {
128+ return `${prefix}-${++instances}`;
129+}
Added file: web/src/lib/longpress.ts+162 additions
@@ -0,0 +1,162 @@
1+import { readable, type Readable } from 'svelte/store';
2+
3+export const LONG_PRESS_MS = 450;
4+export const LONG_PRESS_SLOP = 8;
5+
6+export interface PressEvent {
7+ clientX: number;
8+ clientY: number;
9+ currentTarget: EventTarget | null;
10+}
11+
12+export interface Timers {
13+ set(fn: () => void, ms: number): unknown;
14+ clear(handle: unknown): void;
15+}
16+
17+const realTimers: Timers = {
18+ set: (fn, ms) => setTimeout(fn, ms),
19+ clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>)
20+};
21+
22+export class PressTracker {
23+ fired = false;
24+ private handle: unknown = null;
25+ private x = 0;
26+ private y = 0;
27+
28+ constructor(
29+ private readonly onFire: (x: number, y: number) => void,
30+ private readonly timers: Timers = realTimers,
31+ private readonly delay = LONG_PRESS_MS,
32+ private readonly slop = LONG_PRESS_SLOP
33+ ) {}
34+
35+ get pending(): boolean {
36+ return this.handle !== null;
37+ }
38+
39+ start(x: number, y: number): void {
40+ this.cancel();
41+ this.fired = false;
42+ this.x = x;
43+ this.y = y;
44+ this.handle = this.timers.set(() => {
45+ this.handle = null;
46+ this.fired = true;
47+ this.onFire(this.x, this.y);
48+ }, this.delay);
49+ }
50+
51+ move(x: number, y: number): void {
52+ if (this.handle === null) return;
53+ if (Math.hypot(x - this.x, y - this.y) > this.slop) this.cancel();
54+ }
55+
56+ flush(): void {
57+ if (this.handle === null) return;
58+ this.cancel();
59+ this.fired = true;
60+ this.onFire(this.x, this.y);
61+ }
62+
63+ cancel(): void {
64+ if (this.handle === null) return;
65+ this.timers.clear(this.handle);
66+ this.handle = null;
67+ }
68+}
69+
70+type Handler = ((ev: PressEvent) => void) | null | undefined;
71+
72+interface Options {
73+ on: Handler;
74+ allowSelection?: boolean;
75+ ignore?: string;
76+}
77+
78+type Param = Handler | Options;
79+
80+function split(param: Param): Options {
81+ return typeof param === 'function' || !param ? { on: param } : param;
82+}
83+
84+export function longpress(node: HTMLElement, param: Param) {
85+ let opts = split(param);
86+ let current = opts.on;
87+ let pointerId = -1;
88+
89+ const tracker = new PressTracker((x, y) => {
90+ navigator.vibrate?.(10);
91+ current?.({ clientX: x, clientY: y, currentTarget: node });
92+ });
93+
94+ const apply = () => {
95+ node.style.setProperty('-webkit-touch-callout', 'none');
96+ if (!opts.allowSelection) node.style.setProperty('user-select', 'none');
97+ };
98+ apply();
99+
100+ function down(e: PointerEvent) {
101+ if (!current || e.pointerType === 'mouse' || !e.isPrimary) return;
102+ if (opts.ignore && (e.target as Element).closest(opts.ignore)) return;
103+ pointerId = e.pointerId;
104+ tracker.start(e.clientX, e.clientY);
105+ }
106+ function move(e: PointerEvent) {
107+ if (e.pointerId === pointerId) tracker.move(e.clientX, e.clientY);
108+ }
109+ function end(e: PointerEvent) {
110+ if (e.pointerId !== pointerId) return;
111+ tracker.cancel();
112+ if (tracker.fired) setTimeout(() => (tracker.fired = false), 400);
113+ }
114+ function click(e: MouseEvent) {
115+ if (!tracker.fired) return;
116+ tracker.fired = false;
117+ e.preventDefault();
118+ e.stopImmediatePropagation();
119+ }
120+ function contextmenu(e: Event) {
121+ if (!tracker.fired && !tracker.pending) return;
122+ e.preventDefault();
123+ e.stopImmediatePropagation();
124+ tracker.flush();
125+ }
126+ const cancel = () => tracker.cancel();
127+
128+ node.addEventListener('pointerdown', down);
129+ node.addEventListener('pointermove', move);
130+ node.addEventListener('pointerup', end);
131+ node.addEventListener('pointercancel', end);
132+ node.addEventListener('click', click, true);
133+ node.addEventListener('contextmenu', contextmenu, true);
134+ window.addEventListener('scroll', cancel, true);
135+
136+ return {
137+ update(next: Param) {
138+ opts = split(next);
139+ current = opts.on;
140+ apply();
141+ },
142+ destroy() {
143+ tracker.cancel();
144+ node.removeEventListener('pointerdown', down);
145+ node.removeEventListener('pointermove', move);
146+ node.removeEventListener('pointerup', end);
147+ node.removeEventListener('pointercancel', end);
148+ node.removeEventListener('click', click, true);
149+ node.removeEventListener('contextmenu', contextmenu, true);
150+ window.removeEventListener('scroll', cancel, true);
151+ }
152+ };
153+}
154+
155+export const finePointer: Readable<boolean> = readable(true, (set) => {
156+ if (typeof window === 'undefined') return;
157+ const query = window.matchMedia('(pointer: fine)');
158+ set(query.matches);
159+ const update = () => set(query.matches);
160+ query.addEventListener('change', update);
161+ return () => query.removeEventListener('change', update);
162+});
Added file: web/src/lib/viewport.ts+25 additions
@@ -0,0 +1,25 @@
1+export function trackKeyboardInset(): () => void {
2+ const visual = window.visualViewport;
3+ if (!visual) return () => {};
4+ const root = document.documentElement;
5+ const update = () => {
6+ const inset = Math.max(0, Math.round(window.innerHeight - visual.height - visual.offsetTop));
7+ if (inset > 0) {
8+ root.style.setProperty('--kb-inset', `${inset}px`);
9+ root.classList.add('kb-open');
10+ if (window.scrollY !== 0) window.scrollTo(0, 0);
11+ } else {
12+ root.style.removeProperty('--kb-inset');
13+ root.classList.remove('kb-open');
14+ }
15+ };
16+ visual.addEventListener('resize', update);
17+ visual.addEventListener('scroll', update);
18+ update();
19+ return () => {
20+ visual.removeEventListener('resize', update);
21+ visual.removeEventListener('scroll', update);
22+ root.style.removeProperty('--kb-inset');
23+ root.classList.remove('kb-open');
24+ };
25+}
Modified file: web/src/main.ts+2 additions
@@ -2,10 +2,12 @@ import './app.css';
22 import { mount } from 'svelte';
33 import App from './App.svelte';
44 import { registerServiceWorker } from './lib/sw-register';
5+import { trackKeyboardInset } from './lib/viewport';
56
67 const target = document.getElementById('app');
78 if (!target) throw new Error('#app element missing');
89 registerServiceWorker();
10+trackKeyboardInset();
911 const app = mount(App, { target });
1012
1113 export default app;
Added file: web/test/back-stack.test.ts+133 additions
@@ -0,0 +1,133 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { BackStack, type HistoryLike } from '../src/lib/back-stack.ts';
4+
5+class FakeHistory implements HistoryLike {
6+ entries: unknown[] = [{ roomId: 'a' }];
7+ index = 0;
8+ onPop: (state: unknown) => void = () => undefined;
9+ get state(): unknown {
10+ return this.entries[this.index];
11+ }
12+ pushState(state: unknown): void {
13+ this.entries.splice(this.index + 1, Infinity, state);
14+ this.index++;
15+ }
16+ replaceState(state: unknown): void {
17+ this.entries[this.index] = state;
18+ }
19+ back(): void {
20+ if (this.index === 0) return;
21+ this.index--;
22+ queueMicrotask(() => this.onPop(this.state));
23+ }
24+}
25+
26+function setup() {
27+ const history = new FakeHistory();
28+ const stack = new BackStack(history);
29+ const routed: unknown[] = [];
30+ history.onPop = (state) => {
31+ if (!stack.pop(state)) routed.push(state);
32+ };
33+ return { history, stack, routed };
34+}
35+
36+const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
37+
38+test('opening a layer adds one entry and back closes it', async () => {
39+ const { history, stack, routed } = setup();
40+ let open = true;
41+ stack.sync('card', true, () => (open = false));
42+ assert.equal(history.entries.length, 2);
43+ history.back();
44+ await tick();
45+ assert.equal(open, false);
46+ assert.equal(history.index, 0);
47+ assert.deepEqual(routed, []);
48+});
49+
50+test('closing through the UI pops its entry and swallows the popstate', async () => {
51+ const { history, stack, routed } = setup();
52+ stack.sync('card', true, () => undefined);
53+ stack.sync('card', false, () => undefined);
54+ await tick();
55+ assert.equal(history.index, 0);
56+ assert.deepEqual(routed, []);
57+ assert.equal(stack.depth, 0);
58+});
59+
60+test('back closes only the topmost of several layers', async () => {
61+ const { history, stack } = setup();
62+ const closed: string[] = [];
63+ stack.sync('drawer', true, () => closed.push('drawer'));
64+ stack.sync('card', true, () => closed.push('card'));
65+ history.back();
66+ await tick();
67+ assert.deepEqual(closed, ['card']);
68+ assert.equal(stack.depth, 1);
69+});
70+
71+test('a route recorded under an open layer replaces its entry', async () => {
72+ const { history, stack, routed } = setup();
73+ stack.sync('drawer', true, () => undefined);
74+ stack.route({ roomId: 'b' }, '/?room=b');
75+ stack.sync('drawer', false, () => undefined);
76+ await tick();
77+ assert.deepEqual(history.entries, [{ roomId: 'a' }, { roomId: 'b' }]);
78+ history.back();
79+ await tick();
80+ assert.deepEqual(routed, [{ roomId: 'a' }]);
81+});
82+
83+test('closing a layer then pressing back does not skip a room', async () => {
84+ const { history, stack, routed } = setup();
85+ stack.route({ roomId: 'b' }, '/?room=b');
86+ stack.sync('card', true, () => undefined);
87+ stack.sync('card', false, () => undefined);
88+ await tick();
89+ assert.deepEqual(history.state, { roomId: 'b' });
90+ history.back();
91+ await tick();
92+ assert.deepEqual(routed, [{ roomId: 'a' }]);
93+});
94+
95+test('a route recorded right after a UI close waits for the pop', async () => {
96+ const { history, stack } = setup();
97+ stack.sync('menu', true, () => undefined);
98+ stack.sync('menu', false, () => undefined);
99+ stack.route({ roomId: 'b' }, '/?room=b');
100+ await tick();
101+ assert.deepEqual(history.entries, [{ roomId: 'a' }, { roomId: 'b' }]);
102+});
103+
104+test('a layer closed under another leaves a stale entry that back skips', async () => {
105+ const { history, stack, routed } = setup();
106+ let dialog = true;
107+ stack.sync('menu', true, () => undefined);
108+ stack.sync('dialog', true, () => (dialog = false));
109+ stack.sync('menu', false, () => undefined);
110+ history.back();
111+ await tick();
112+ assert.equal(dialog, false);
113+ assert.equal(history.index, 0);
114+ assert.equal(routed.length, 0);
115+});
116+
117+test('init strips a layer marker left behind by a reload', () => {
118+ const { history, stack } = setup();
119+ history.entries = [{ roomId: 'a' }, { roomId: 'a', backLayer: 'card' }];
120+ history.index = 1;
121+ stack.init();
122+ assert.deepEqual(history.state, { roomId: 'a' });
123+});
124+
125+test('a settle timeout releases a pop whose popstate never arrives', async () => {
126+ const { history, stack } = setup();
127+ stack.sync('x', true, () => undefined);
128+ history.back = () => undefined;
129+ stack.sync('x', false, () => undefined);
130+ stack.route({ roomId: 'b' }, '/?room=b');
131+ await new Promise((resolve) => setTimeout(resolve, 500));
132+ assert.deepEqual(history.state, { roomId: 'b' });
133+});
Added file: web/test/longpress.test.ts+89 additions
@@ -0,0 +1,89 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { PressTracker, type Timers } from '../src/lib/longpress.ts';
4+
5+function clock() {
6+ let now = 0;
7+ let next = 1;
8+ const queue = new Map<number, { at: number; fn: () => void }>();
9+ const timers: Timers = {
10+ set(fn, ms) {
11+ const id = next++;
12+ queue.set(id, { at: now + ms, fn });
13+ return id;
14+ },
15+ clear(handle) {
16+ queue.delete(handle as number);
17+ }
18+ };
19+ const advance = (ms: number) => {
20+ now += ms;
21+ for (const [id, t] of [...queue]) {
22+ if (t.at <= now) {
23+ queue.delete(id);
24+ t.fn();
25+ }
26+ }
27+ };
28+ return { timers, advance };
29+}
30+
31+function setup() {
32+ const c = clock();
33+ const fires: Array<[number, number]> = [];
34+ const tracker = new PressTracker((x, y) => fires.push([x, y]), c.timers);
35+ return { ...c, tracker, fires };
36+}
37+
38+test('fires with the press origin after the delay', () => {
39+ const { tracker, advance, fires } = setup();
40+ tracker.start(10, 20);
41+ advance(449);
42+ assert.deepEqual(fires, []);
43+ advance(1);
44+ assert.deepEqual(fires, [[10, 20]]);
45+ assert.equal(tracker.fired, true);
46+});
47+
48+test('release before the delay cancels', () => {
49+ const { tracker, advance, fires } = setup();
50+ tracker.start(0, 0);
51+ advance(200);
52+ tracker.cancel();
53+ advance(1000);
54+ assert.deepEqual(fires, []);
55+ assert.equal(tracker.fired, false);
56+});
57+
58+test('small jitter is tolerated, a drag cancels', () => {
59+ const a = setup();
60+ a.tracker.start(0, 0);
61+ a.tracker.move(4, 4);
62+ a.advance(450);
63+ assert.equal(a.fires.length, 1);
64+ const b = setup();
65+ b.tracker.start(0, 0);
66+ b.tracker.move(9, 0);
67+ b.advance(450);
68+ assert.equal(b.fires.length, 0);
69+});
70+
71+test('a new press clears the fired flag', () => {
72+ const { tracker, advance } = setup();
73+ tracker.start(0, 0);
74+ advance(450);
75+ tracker.start(5, 5);
76+ assert.equal(tracker.fired, false);
77+ assert.equal(tracker.pending, true);
78+});
79+
80+test('flush fires a pending press once and ignores an idle tracker', () => {
81+ const { tracker, advance, fires } = setup();
82+ tracker.flush();
83+ assert.equal(fires.length, 0);
84+ tracker.start(3, 4);
85+ tracker.flush();
86+ advance(1000);
87+ assert.deepEqual(fires, [[3, 4]]);
88+ assert.equal(tracker.fired, true);
89+});