meta-sovereign 0.53.2 → 0.53.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/js/src/server/local-certificate-check.js +82 -0
- package/js/src/server/local-tls.js +14 -19
- package/js/src/sources/telegram-chat-url.js +20 -8
- package/js/src/storage/doublets-mirror.js +19 -3
- package/js/src/storage/dual-store.js +11 -1
- package/js/src/web/app-navigation.js +61 -0
- package/js/src/web/app-route.js +205 -0
- package/js/src/web/app.css +233 -5
- package/js/src/web/app.js +14 -39
- package/js/src/web/app.min.js +92 -92
- package/js/src/web/automations-view.js +2 -2
- package/js/src/web/chat-archive-media.js +12 -4
- package/js/src/web/chat-calls.js +6 -2
- package/js/src/web/chat-history-load.js +1 -0
- package/js/src/web/chat-invite.js +3 -5
- package/js/src/web/chat-media.js +160 -62
- package/js/src/web/chat-message.js +3 -1
- package/js/src/web/chat-model.js +8 -1
- package/js/src/web/chat-photo.js +84 -71
- package/js/src/web/chat-remote-media.js +5 -2
- package/js/src/web/chat-round.js +16 -1
- package/js/src/web/chat-state.js +30 -16
- package/js/src/web/chat-view.js +58 -45
- package/js/src/web/chat-web-apps.js +18 -12
- package/js/src/web/locales/en-chat-more.js +8 -0
- package/js/src/web/locales/en.js +3 -2
- package/js/src/web/locales/hi-chat-more.js +8 -0
- package/js/src/web/locales/hi.js +3 -2
- package/js/src/web/locales/ru-chat-more.js +9 -0
- package/js/src/web/locales/ru.js +3 -2
- package/js/src/web/locales/zh-chat-more.js +8 -0
- package/js/src/web/locales/zh.js +3 -2
- package/js/src/web/media-loading.js +176 -0
- package/js/src/web/operator-model.js +11 -4
- package/js/src/web/operator-order.js +46 -0
- package/js/src/web/operator-prefs.js +1 -0
- package/js/src/web/operator-unread.js +2 -2
- package/js/src/web/operator-view.js +46 -15
- package/js/src/web/peer-avatar.js +14 -1
- package/js/src/web/person-panel.js +15 -8
- package/js/src/web/spinner.js +7 -2
- package/js/src/web/views.js +12 -18
- package/js/src/web/web-app-loading.js +81 -0
- package/js/src/web/web-apps-model.js +20 -1
- package/package.json +8 -2
package/README.md
CHANGED
|
@@ -370,3 +370,5 @@ The CI/CD pipeline inherited from `js-ai-driven-development-pipeline-template` c
|
|
|
370
370
|
## License
|
|
371
371
|
|
|
372
372
|
[Unlicense](LICENSE) - Public Domain
|
|
373
|
+
|
|
374
|
+
Reloads and browser history preserve your section and conversation context; see [navigation](docs/NAVIGATION.md). [Mini App recovery](docs/MINI-APPS.md) documents SDK compatibility and external-site limitations.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Verify our ECDSA certificate using portable crypto primitives. Deno exposes
|
|
2
|
+
// X509Certificate but omits verify(), checkPrivateKey() and subjectAltName.
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const der = (data, start = 0) => {
|
|
6
|
+
const tag = data[start];
|
|
7
|
+
let length = data[start + 1];
|
|
8
|
+
let offset = start + 2;
|
|
9
|
+
if (length & 0x80) {
|
|
10
|
+
const count = length & 0x7f;
|
|
11
|
+
if (!count || count > 4) {
|
|
12
|
+
throw new Error('invalid DER length');
|
|
13
|
+
}
|
|
14
|
+
length = 0;
|
|
15
|
+
for (let n = 0; n < count; n++) {
|
|
16
|
+
length = length * 256 + data[offset++];
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const end = offset + length;
|
|
20
|
+
if (!Number.isInteger(end) || end > data.length) {
|
|
21
|
+
throw new Error('truncated DER');
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
tag,
|
|
25
|
+
end,
|
|
26
|
+
raw: data.subarray(start, end),
|
|
27
|
+
body: data.subarray(offset, end),
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
const children = (data) => {
|
|
31
|
+
const parts = [];
|
|
32
|
+
for (let offset = 0; offset < data.length;) {
|
|
33
|
+
const part = der(data, offset);
|
|
34
|
+
parts.push(part);
|
|
35
|
+
offset = part.end;
|
|
36
|
+
}
|
|
37
|
+
return parts;
|
|
38
|
+
};
|
|
39
|
+
const SAN_OID = Buffer.from('0603551d11', 'hex');
|
|
40
|
+
const ECDSA_SHA256 = Buffer.from('300a06082a8648ce3d040302', 'hex');
|
|
41
|
+
|
|
42
|
+
export const matchesLocalCertificate = (cert, key, { now, renewMs, san }) => {
|
|
43
|
+
try {
|
|
44
|
+
const x509 = new crypto.X509Certificate(cert);
|
|
45
|
+
if (
|
|
46
|
+
x509.ca ||
|
|
47
|
+
Date.parse(x509.validFrom) > now ||
|
|
48
|
+
Date.parse(x509.validTo) - now <= renewMs
|
|
49
|
+
) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
const encoded = String(cert).replace(/-----[^-]+-----|\s/g, '');
|
|
53
|
+
const root = der(Buffer.from(encoded, 'base64'));
|
|
54
|
+
const [tbs, algorithm, signature] = children(root.body);
|
|
55
|
+
if (!algorithm.raw.equals(ECDSA_SHA256) || signature.body[0] !== 0) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
const fields = children(tbs.body);
|
|
59
|
+
const extensions = children(
|
|
60
|
+
children(fields.find((field) => field.tag === 0xa3).body)[0].body
|
|
61
|
+
);
|
|
62
|
+
const names = extensions.filter((ext) =>
|
|
63
|
+
children(ext.body)[0].raw.equals(SAN_OID)
|
|
64
|
+
);
|
|
65
|
+
if (names.length !== 1 || !names[0].raw.equals(san)) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
const publicKey = crypto.createPublicKey(crypto.createPrivateKey(key));
|
|
69
|
+
const exportKey = (value) => value.export({ type: 'spki', format: 'der' });
|
|
70
|
+
return (
|
|
71
|
+
exportKey(publicKey).equals(exportKey(x509.publicKey)) &&
|
|
72
|
+
crypto.verify(
|
|
73
|
+
'sha256',
|
|
74
|
+
tbs.raw,
|
|
75
|
+
x509.publicKey,
|
|
76
|
+
signature.body.subarray(1)
|
|
77
|
+
)
|
|
78
|
+
);
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
};
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import crypto from 'node:crypto';
|
|
20
|
+
import { matchesLocalCertificate } from './local-certificate-check.js';
|
|
20
21
|
import { promises as fs } from 'node:fs';
|
|
21
22
|
import net from 'node:net';
|
|
22
23
|
import path from 'node:path';
|
|
@@ -155,25 +156,19 @@ export const makeLocalCertificate = ({
|
|
|
155
156
|
};
|
|
156
157
|
};
|
|
157
158
|
|
|
158
|
-
const usable = (cert, key, now) =>
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
x509.subjectAltName.includes('IP Address:::1'))
|
|
172
|
-
);
|
|
173
|
-
} catch {
|
|
174
|
-
return false;
|
|
175
|
-
}
|
|
176
|
-
};
|
|
159
|
+
const usable = (cert, key, now) =>
|
|
160
|
+
matchesLocalCertificate(cert, key, {
|
|
161
|
+
now,
|
|
162
|
+
renewMs: RENEW_DAYS * DAY_MS,
|
|
163
|
+
san: extension(
|
|
164
|
+
'2.5.29.17',
|
|
165
|
+
seq(
|
|
166
|
+
tlv(0x82, Buffer.from('localhost')),
|
|
167
|
+
tlv(0x87, Buffer.from([127, 0, 0, 1])),
|
|
168
|
+
tlv(0x87, LOOPBACK_V6)
|
|
169
|
+
)
|
|
170
|
+
),
|
|
171
|
+
});
|
|
177
172
|
|
|
178
173
|
/** Where the certificate of a store lives. */
|
|
179
174
|
export const localTlsPaths = (storeDir) => {
|
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
// Use documented username/phone/message links; numeric-only peers use Web A's
|
|
3
3
|
// signed-in chat route instead of launching a native app without a destination.
|
|
4
4
|
// References: core.telegram.org/api/links; Ajaxy/telegram-tt src/util/routing.ts.
|
|
5
|
+
const resolvedLink = (handle, number, native) =>
|
|
6
|
+
/^[a-zA-Z][a-zA-Z0-9_]{3,31}$/.test(handle)
|
|
7
|
+
? native
|
|
8
|
+
? `tg://resolve?domain=${handle}`
|
|
9
|
+
: `https://t.me/${handle}`
|
|
10
|
+
: /^[1-9]\d{6,14}$/.test(number)
|
|
11
|
+
? native
|
|
12
|
+
? `tg://resolve?phone=${number}`
|
|
13
|
+
: `https://t.me/+${number}`
|
|
14
|
+
: null;
|
|
15
|
+
|
|
5
16
|
export const telegramChatUrl = ({
|
|
6
17
|
source,
|
|
7
18
|
chat,
|
|
@@ -9,23 +20,24 @@ export const telegramChatUrl = ({
|
|
|
9
20
|
phone,
|
|
10
21
|
lastId,
|
|
11
22
|
text,
|
|
23
|
+
native = false,
|
|
12
24
|
} = {}) => {
|
|
13
25
|
if (source !== 'telegram' || !/^-?[1-9]\d*$/.test(String(chat))) {
|
|
14
26
|
return null;
|
|
15
27
|
}
|
|
16
28
|
const handle = String(username ?? '').replace(/^@/, '');
|
|
17
29
|
const number = String(phone ?? '').replace(/[\s()+-]/g, '');
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
?
|
|
22
|
-
:
|
|
23
|
-
if (native) {
|
|
24
|
-
return text ? `${native}?text=${encodeURIComponent(text)}` : native;
|
|
30
|
+
const resolved = resolvedLink(handle, number, native);
|
|
31
|
+
if (resolved) {
|
|
32
|
+
return text
|
|
33
|
+
? `${resolved}${native ? '&' : '?'}text=${encodeURIComponent(text)}`
|
|
34
|
+
: resolved;
|
|
25
35
|
}
|
|
26
36
|
const id = String(chat);
|
|
27
37
|
if (/^-100\d+$/.test(id) && /^[1-9]\d*$/.test(String(lastId))) {
|
|
28
|
-
return
|
|
38
|
+
return native
|
|
39
|
+
? `tg://privatepost?channel=${id.slice(4)}&post=${lastId}`
|
|
40
|
+
: `https://t.me/c/${id.slice(4)}/${lastId}`;
|
|
29
41
|
}
|
|
30
42
|
return `https://web.telegram.org/a/#${id}`;
|
|
31
43
|
};
|
|
@@ -42,6 +42,10 @@ export const snapshotInWorker = (from, to) =>
|
|
|
42
42
|
let result = null;
|
|
43
43
|
worker.once('message', (message) => {
|
|
44
44
|
result = message;
|
|
45
|
+
// The message follows the durable rename. Deno's worker compatibility
|
|
46
|
+
// layer can keep its port alive without emitting an exit event.
|
|
47
|
+
resolve(message);
|
|
48
|
+
Promise.resolve(worker.terminate()).catch(() => {});
|
|
45
49
|
});
|
|
46
50
|
worker.once('error', reject);
|
|
47
51
|
worker.once('exit', (code) => {
|
|
@@ -92,6 +96,7 @@ const createSnapshots = ({
|
|
|
92
96
|
let lastAt = 0;
|
|
93
97
|
let lastCost = 0;
|
|
94
98
|
let failures = 0;
|
|
99
|
+
let closed = false;
|
|
95
100
|
const once = async () => {
|
|
96
101
|
const started = clock.now();
|
|
97
102
|
try {
|
|
@@ -115,6 +120,9 @@ const createSnapshots = ({
|
|
|
115
120
|
};
|
|
116
121
|
let later = null;
|
|
117
122
|
const now = () => {
|
|
123
|
+
if (closed) {
|
|
124
|
+
return Promise.resolve(null);
|
|
125
|
+
}
|
|
118
126
|
clock.clearTimeout(timer);
|
|
119
127
|
timer = null;
|
|
120
128
|
if (queued) {
|
|
@@ -123,7 +131,7 @@ const createSnapshots = ({
|
|
|
123
131
|
if (!inFlight) {
|
|
124
132
|
inFlight = once().finally(() => {
|
|
125
133
|
inFlight = null;
|
|
126
|
-
if (wanted && !queued) {
|
|
134
|
+
if (wanted && !queued && !closed) {
|
|
127
135
|
wanted = false;
|
|
128
136
|
later();
|
|
129
137
|
}
|
|
@@ -139,7 +147,7 @@ const createSnapshots = ({
|
|
|
139
147
|
return queued;
|
|
140
148
|
};
|
|
141
149
|
later = () => {
|
|
142
|
-
if (timer) {
|
|
150
|
+
if (closed || timer) {
|
|
143
151
|
return;
|
|
144
152
|
}
|
|
145
153
|
if (inFlight || queued) {
|
|
@@ -157,7 +165,14 @@ const createSnapshots = ({
|
|
|
157
165
|
}, wait);
|
|
158
166
|
timer.unref?.();
|
|
159
167
|
};
|
|
160
|
-
|
|
168
|
+
const close = async () => {
|
|
169
|
+
closed = true;
|
|
170
|
+
wanted = false;
|
|
171
|
+
clock.clearTimeout(timer);
|
|
172
|
+
timer = null;
|
|
173
|
+
await (queued ?? inFlight);
|
|
174
|
+
};
|
|
175
|
+
return { now, later, close };
|
|
161
176
|
};
|
|
162
177
|
|
|
163
178
|
/**
|
|
@@ -230,6 +245,7 @@ export const createDoubletsMirror = async (
|
|
|
230
245
|
},
|
|
231
246
|
/** Write the snapshot now (after the log) and wait for it. */
|
|
232
247
|
flush: () => snapshots.now(),
|
|
248
|
+
close: () => snapshots.close(),
|
|
233
249
|
/** A snapshot older than the log is written again, in the background. */
|
|
234
250
|
async refresh() {
|
|
235
251
|
const behind = await stale();
|
|
@@ -245,7 +245,17 @@ export const openDualStore = async (
|
|
|
245
245
|
// What a backup streams, and the flush of just the log (R-Z16).
|
|
246
246
|
logFile: text.filePath,
|
|
247
247
|
flushLog: () => text.flush(),
|
|
248
|
-
close
|
|
248
|
+
async close() {
|
|
249
|
+
try {
|
|
250
|
+
await binary.close();
|
|
251
|
+
} finally {
|
|
252
|
+
try {
|
|
253
|
+
await text.flush();
|
|
254
|
+
} finally {
|
|
255
|
+
lock.release();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
},
|
|
249
259
|
};
|
|
250
260
|
} catch (error) {
|
|
251
261
|
lock.release();
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useSyncExternalStore } from 'react';
|
|
2
|
+
import { appNavigation } from './app-route.js';
|
|
3
|
+
|
|
4
|
+
// Wait for async section content to be tall enough before restoring its scroll.
|
|
5
|
+
// User input cancels restoration so it cannot pull the page away afterwards.
|
|
6
|
+
const restoreScroll = (scroll, done) => {
|
|
7
|
+
const [x, y] = scroll ?? [0, 0];
|
|
8
|
+
let frame;
|
|
9
|
+
let stopped = false;
|
|
10
|
+
const end = () => {
|
|
11
|
+
stopped = true;
|
|
12
|
+
globalThis.cancelAnimationFrame(frame);
|
|
13
|
+
done();
|
|
14
|
+
};
|
|
15
|
+
const until = Date.now() + 5000;
|
|
16
|
+
const apply = () => {
|
|
17
|
+
const height = globalThis.document.scrollingElement?.scrollHeight ?? 0;
|
|
18
|
+
if (height - globalThis.innerHeight >= y || Date.now() >= until) {
|
|
19
|
+
globalThis.scrollTo?.(x, y);
|
|
20
|
+
end();
|
|
21
|
+
} else if (!stopped) {
|
|
22
|
+
frame = globalThis.requestAnimationFrame(apply);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
const events = ['wheel', 'pointerdown', 'touchstart', 'keydown'];
|
|
26
|
+
events.forEach((event) =>
|
|
27
|
+
globalThis.addEventListener(event, end, { passive: true, once: true })
|
|
28
|
+
);
|
|
29
|
+
apply();
|
|
30
|
+
return () => {
|
|
31
|
+
end();
|
|
32
|
+
events.forEach((event) => globalThis.removeEventListener(event, end));
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const useAppNavigation = () => {
|
|
37
|
+
const navigation = appNavigation();
|
|
38
|
+
const route = useSyncExternalStore(
|
|
39
|
+
navigation.subscribe,
|
|
40
|
+
navigation.snapshot,
|
|
41
|
+
navigation.snapshot
|
|
42
|
+
);
|
|
43
|
+
useLayoutEffect(() => navigation.start(), [navigation]);
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const onNavigate = (event) => navigation.go(event.detail ?? {});
|
|
46
|
+
globalThis.addEventListener('meta-sovereign:navigate', onNavigate);
|
|
47
|
+
return () =>
|
|
48
|
+
globalThis.removeEventListener('meta-sovereign:navigate', onNavigate);
|
|
49
|
+
}, [navigation]);
|
|
50
|
+
useLayoutEffect(() => {
|
|
51
|
+
const previous = globalThis.history.scrollRestoration;
|
|
52
|
+
globalThis.history.scrollRestoration = 'manual';
|
|
53
|
+
navigation.restoring(true);
|
|
54
|
+
const stop = restoreScroll(route.scroll, () => navigation.restoring(false));
|
|
55
|
+
return () => {
|
|
56
|
+
stop();
|
|
57
|
+
globalThis.history.scrollRestoration = previous;
|
|
58
|
+
};
|
|
59
|
+
}, [route, navigation]);
|
|
60
|
+
return [route, navigation.go];
|
|
61
|
+
};
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// Section routes coexist with the original chat and provider deep links.
|
|
2
|
+
import { navItems } from './nav-items.js';
|
|
3
|
+
|
|
4
|
+
export const APP_ROUTE_KEY = 'metaSovereignNavigation';
|
|
5
|
+
const VIEWS = new Set(navItems.map(([view]) => view));
|
|
6
|
+
|
|
7
|
+
export const splitChatAddress = (key) => {
|
|
8
|
+
const match = /^([a-z0-9_-]+):(.+)$/i.exec(String(key ?? ''));
|
|
9
|
+
return match ? { source: match[1], chat: match[2] } : null;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const parseAppRoute = (hash = '') => {
|
|
13
|
+
const anchor = String(hash);
|
|
14
|
+
if (/^#conn-[a-z0-9-]+(?:\/manual)?$/i.test(anchor)) {
|
|
15
|
+
return { view: 'connections', anchor };
|
|
16
|
+
}
|
|
17
|
+
if (anchor.startsWith('#chat=')) {
|
|
18
|
+
try {
|
|
19
|
+
const chat = splitChatAddress(decodeURIComponent(anchor.slice(6)));
|
|
20
|
+
return chat ? { view: 'chat', anchor, chat } : null;
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const match = /^#\/?([a-z][a-z0-9-]*)(?:\?(.*))?$/.exec(anchor);
|
|
26
|
+
if (!match || !VIEWS.has(match[1])) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const view = match[1];
|
|
30
|
+
const chat = splitChatAddress(new URLSearchParams(match[2]).get('chat'));
|
|
31
|
+
return { view, anchor, ...(chat ? { chat } : {}) };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const sectionHash = (view) => `#/${view}`;
|
|
35
|
+
export const operatorHash = (key) =>
|
|
36
|
+
key ? `#/operator?chat=${encodeURIComponent(key)}` : sectionHash('operator');
|
|
37
|
+
export const chatHash = ({ source, chat }) =>
|
|
38
|
+
`#chat=${source}:${encodeURIComponent(chat)}`;
|
|
39
|
+
|
|
40
|
+
const remembered = (env) => {
|
|
41
|
+
try {
|
|
42
|
+
const value = JSON.parse(
|
|
43
|
+
env.sessionStorage?.getItem(APP_ROUTE_KEY) ?? 'null'
|
|
44
|
+
);
|
|
45
|
+
return value && typeof value === 'object' ? value : {};
|
|
46
|
+
} catch {
|
|
47
|
+
return {};
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const targetFor = (view, anchor, hashes) => {
|
|
52
|
+
const wanted = anchor === undefined ? hashes[view] : anchor;
|
|
53
|
+
const next = parseAppRoute(wanted);
|
|
54
|
+
return next?.view === view ? next : { view, anchor: sectionHash(view) };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const visit = (target, before, replace) => ({
|
|
58
|
+
...target,
|
|
59
|
+
previous: replace ? before.previous : before.anchor,
|
|
60
|
+
scroll: replace && before.view === target.view ? before.scroll : [0, 0],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** Browser history is the authority; session storage only remembers sections. */
|
|
64
|
+
export const createAppNavigation = (env = globalThis) => {
|
|
65
|
+
const memory = remembered(env);
|
|
66
|
+
const hashes = { ...memory.hashes };
|
|
67
|
+
const initial = parseAppRoute(env.location?.hash) ??
|
|
68
|
+
parseAppRoute(hashes[memory.view]) ?? { view: 'chat', anchor: '#/chat' };
|
|
69
|
+
let route = {
|
|
70
|
+
...initial,
|
|
71
|
+
previous: env.history?.state?.[APP_ROUTE_KEY]?.previous,
|
|
72
|
+
scroll: env.history?.state?.[APP_ROUTE_KEY]?.scroll ?? [0, 0],
|
|
73
|
+
};
|
|
74
|
+
let url = env.location?.hash;
|
|
75
|
+
let restoring = false;
|
|
76
|
+
const listeners = new Set();
|
|
77
|
+
const notify = () => listeners.forEach((listener) => listener());
|
|
78
|
+
const remember = () => {
|
|
79
|
+
hashes[route.view] = route.anchor;
|
|
80
|
+
try {
|
|
81
|
+
env.sessionStorage?.setItem(
|
|
82
|
+
APP_ROUTE_KEY,
|
|
83
|
+
JSON.stringify({ view: route.view, hashes })
|
|
84
|
+
);
|
|
85
|
+
} catch {
|
|
86
|
+
// The URL and history still work with browser storage disabled.
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const write = (next, replace) => {
|
|
90
|
+
const state = {
|
|
91
|
+
...env.history?.state,
|
|
92
|
+
[APP_ROUTE_KEY]: { ...next, scroll: next.scroll },
|
|
93
|
+
};
|
|
94
|
+
env.history?.[replace ? 'replaceState' : 'pushState']?.(
|
|
95
|
+
state,
|
|
96
|
+
'',
|
|
97
|
+
`${env.location?.pathname ?? ''}${env.location?.search ?? ''}${next.anchor}`
|
|
98
|
+
);
|
|
99
|
+
url = env.location?.hash;
|
|
100
|
+
};
|
|
101
|
+
const saveScroll = () => {
|
|
102
|
+
if (restoring) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
env.history?.replaceState?.(
|
|
106
|
+
{
|
|
107
|
+
...env.history.state,
|
|
108
|
+
[APP_ROUTE_KEY]: {
|
|
109
|
+
...route,
|
|
110
|
+
scroll: [env.scrollX ?? 0, env.scrollY ?? 0],
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
'',
|
|
114
|
+
env.location?.href
|
|
115
|
+
);
|
|
116
|
+
};
|
|
117
|
+
const accept = () => {
|
|
118
|
+
if (env.location?.hash === url) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const next = parseAppRoute(env.location?.hash);
|
|
122
|
+
url = env.location?.hash;
|
|
123
|
+
if (!next) {
|
|
124
|
+
return; // In-page anchors such as #root do not switch sections.
|
|
125
|
+
}
|
|
126
|
+
const kept = env.history?.state?.[APP_ROUTE_KEY];
|
|
127
|
+
route = {
|
|
128
|
+
...next,
|
|
129
|
+
previous: kept?.anchor === next.anchor ? kept.previous : route.anchor,
|
|
130
|
+
scroll: kept?.anchor === next.anchor ? kept.scroll : [0, 0],
|
|
131
|
+
};
|
|
132
|
+
write(route, true);
|
|
133
|
+
remember();
|
|
134
|
+
notify();
|
|
135
|
+
};
|
|
136
|
+
const go = ({ view, anchor } = {}, { replace = false } = {}) => {
|
|
137
|
+
if (!VIEWS.has(view)) {
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (anchor === undefined && route.view === view) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const target = targetFor(view, anchor, hashes);
|
|
144
|
+
if (
|
|
145
|
+
route.anchor === target.anchor &&
|
|
146
|
+
env.location?.hash === target.anchor
|
|
147
|
+
) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
saveScroll();
|
|
151
|
+
const oldURL = env.location?.href;
|
|
152
|
+
route = visit(target, route, replace);
|
|
153
|
+
write(route, replace);
|
|
154
|
+
remember();
|
|
155
|
+
notify();
|
|
156
|
+
// pushState does not emit hashchange; existing chat/provider listeners need it.
|
|
157
|
+
if (env.HashChangeEvent) {
|
|
158
|
+
env.dispatchEvent(
|
|
159
|
+
new env.HashChangeEvent('hashchange', {
|
|
160
|
+
oldURL,
|
|
161
|
+
newURL: env.location?.href,
|
|
162
|
+
})
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
return {
|
|
167
|
+
snapshot: () => route,
|
|
168
|
+
subscribe: (listener) => {
|
|
169
|
+
listeners.add(listener);
|
|
170
|
+
return () => listeners.delete(listener);
|
|
171
|
+
},
|
|
172
|
+
go,
|
|
173
|
+
restoring: (active) => {
|
|
174
|
+
restoring = active;
|
|
175
|
+
},
|
|
176
|
+
back: (detail) => {
|
|
177
|
+
const target = targetFor(detail.view, detail.anchor, hashes);
|
|
178
|
+
if (route.previous === target.anchor) {
|
|
179
|
+
env.history.back();
|
|
180
|
+
} else {
|
|
181
|
+
go(detail, { replace: true });
|
|
182
|
+
}
|
|
183
|
+
},
|
|
184
|
+
start: () => {
|
|
185
|
+
accept();
|
|
186
|
+
write(route, true);
|
|
187
|
+
remember();
|
|
188
|
+
env.addEventListener?.('popstate', accept);
|
|
189
|
+
env.addEventListener?.('hashchange', accept);
|
|
190
|
+
env.addEventListener?.('scroll', saveScroll, { passive: true });
|
|
191
|
+
return () => {
|
|
192
|
+
env.removeEventListener?.('popstate', accept);
|
|
193
|
+
env.removeEventListener?.('hashchange', accept);
|
|
194
|
+
env.removeEventListener?.('scroll', saveScroll);
|
|
195
|
+
};
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
let browserNavigation;
|
|
201
|
+
export const appNavigation = () =>
|
|
202
|
+
(browserNavigation ??= createAppNavigation());
|
|
203
|
+
export const navigateApp = (detail, options) =>
|
|
204
|
+
appNavigation().go(detail, options);
|
|
205
|
+
export const backInApp = (detail) => appNavigation().back(detail);
|