Commit 0e50e0d137af
tarball11 files changed+559-3
@@ -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;@@ -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; }@@ -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>@@ -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; }@@ -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);@@ -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
+}@@ -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_SLOP33
+ ) {}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
+});@@ -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
+}@@ -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;@@ -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
+});@@ -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
+});