meta-sovereign 0.53.1 → 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 +3 -1
- package/js/src/crm/reengage.js +18 -5
- package/js/src/server/local-certificate-check.js +82 -0
- package/js/src/server/local-tls.js +28 -22
- package/js/src/server/routes-events.js +1 -1
- package/js/src/sources/telegram-chat-info.js +6 -1
- package/js/src/sources/telegram-chat-url.js +46 -0
- 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 +262 -28
- 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-automation.js +8 -1
- package/js/src/web/chat-calls.js +6 -2
- package/js/src/web/chat-follow.js +22 -6
- 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-sidebar.js +6 -4
- package/js/src/web/chat-state.js +30 -16
- package/js/src/web/chat-view.js +61 -56
- 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-chat.js +4 -6
- package/js/src/web/locales/en.js +11 -0
- package/js/src/web/locales/hi-chat-more.js +8 -0
- package/js/src/web/locales/hi-chat.js +4 -5
- package/js/src/web/locales/hi.js +11 -0
- package/js/src/web/locales/ru-chat-more.js +9 -0
- package/js/src/web/locales/ru-chat.js +4 -5
- package/js/src/web/locales/ru.js +11 -0
- package/js/src/web/locales/zh-chat-more.js +8 -0
- package/js/src/web/locales/zh-chat.js +4 -4
- package/js/src/web/locales/zh.js +11 -0
- package/js/src/web/media-loading.js +176 -0
- package/js/src/web/navigation-counts.js +6 -4
- package/js/src/web/operator-model.js +15 -4
- package/js/src/web/operator-order.js +46 -0
- package/js/src/web/operator-prefs.js +59 -0
- package/js/src/web/operator-unread.js +8 -6
- package/js/src/web/operator-view.js +48 -15
- package/js/src/web/peer-avatar.js +14 -1
- package/js/src/web/person-panel.js +35 -34
- package/js/src/web/profile-stories.js +5 -2
- package/js/src/web/settings-view.js +38 -0
- package/js/src/web/spinner.js +7 -2
- package/js/src/web/stories-pref.js +5 -0
- package/js/src/web/stories.js +1 -1
- 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/js/src/web/chat-history-status.js +0 -76
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ The hosted app is the default UI: `serve` prints its address, <https://link-foun
|
|
|
61
61
|
|
|
62
62
|
On macOS, interactive `serve` now handles the full setup: it protects this installation’s TLS key with Secure Enclave, detects existing certificate trust, requests trust only when needed, and opens the GitHub Pages app. For a headless launch use `node js/bin/meta-sovereign.js serve --trust=true --open=true`. `--trust=false` and `--open=false` opt out. The Swift helper needs the macOS Command Line Tools; `--hardware-key=false` explicitly opts into a file key on unsupported Macs. Hardware protection applies to the key on disk; Node uses it in memory. See [local server setup and security](docs/LOCAL-SERVER-SECURITY.md) for the exact access rules and limits.
|
|
63
63
|
|
|
64
|
-
Open conversations load first
|
|
64
|
+
Open conversations load first. History loads automatically on scroll, with a compact indicator inside the message list while requesting or retrying; no history status panel or manual loading button is shown. Earlier history loads automatically on scroll in Chat and Operator. Message selection and menus work across the full row; unread cards use refreshed Telegram counters and retain cached data during connection failures. All / Private / Groups / Channels filters remain available inside every Telegram folder. Drag the vertical dividers to resize columns; widths are remembered. Calls is in the main menu after Chat, and Telegram account editing is under Profile. The default dark controls use white line icons. Buttons and action links have nonzero spacing and aligned control padding. Stories is off by default; Settings → Stories enables its display and requests. The JS server keeps its store under your OS user: `~/Library/Application Support/Meta Sovereign/store` on macOS, `%APPDATA%/Meta Sovereign/store` on Windows, and `${XDG_CONFIG_HOME:-~/.config}/Meta Sovereign/store` on Linux. Starting `serve` from an older checkout migrates its `.meta-sovereign` store once, preserving messages, contacts, sessions, media and certificate; a compatibility link preserves old paths. Restarts and a different working directory reuse the same store. `--store` or `META_SOVEREIGN_STORE_DIR` override it. See [chat readiness and data storage](docs/CHAT-READINESS.md).
|
|
65
65
|
|
|
66
66
|
## Connect the SPA to your server
|
|
67
67
|
|
|
@@ -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.
|
package/js/src/crm/reengage.js
CHANGED
|
@@ -22,6 +22,10 @@ import {
|
|
|
22
22
|
definedOnly,
|
|
23
23
|
defaultAddressRules,
|
|
24
24
|
} from './address-form.js';
|
|
25
|
+
import {
|
|
26
|
+
telegramChatUrl,
|
|
27
|
+
isTelegramWebUrl,
|
|
28
|
+
} from '../sources/telegram-chat-url.js';
|
|
25
29
|
import { addressFormOf } from './person-learn.js';
|
|
26
30
|
import {
|
|
27
31
|
batchStopReason,
|
|
@@ -300,12 +304,18 @@ export const templateFor = (templates, form) =>
|
|
|
300
304
|
* where the platform documents one; `null` elsewhere (copy the text).
|
|
301
305
|
*
|
|
302
306
|
* - e-mail: RFC 6068 `mailto:<addr>?body=` (line breaks as %0D%0A).
|
|
303
|
-
* - Telegram:
|
|
304
|
-
*
|
|
305
|
-
* pre-fill parameter for a user chat.
|
|
307
|
+
* - Telegram: documented username/phone links can prefill text; numeric-only
|
|
308
|
+
* peers open the specific chat in Telegram Web (sign-in may be required).
|
|
306
309
|
* - VK: `https://vk.com/im?sel=<peer>` opens the dialog with a user.
|
|
307
310
|
*/
|
|
308
|
-
export const prefillLinkFor = ({
|
|
311
|
+
export const prefillLinkFor = ({
|
|
312
|
+
source,
|
|
313
|
+
contact,
|
|
314
|
+
chat,
|
|
315
|
+
text,
|
|
316
|
+
username,
|
|
317
|
+
phone,
|
|
318
|
+
}) => {
|
|
309
319
|
const id = String(contact ?? '');
|
|
310
320
|
const peer = String(chat ?? id);
|
|
311
321
|
if (source === 'email' && /^[^\s@]+@[^\s@]+$/.test(id)) {
|
|
@@ -319,7 +329,10 @@ export const prefillLinkFor = ({ source, contact, chat, text }) => {
|
|
|
319
329
|
}
|
|
320
330
|
const tg = source === 'telegram' ? id.match(/^(?:user)?(\d+)$/) : null;
|
|
321
331
|
if (tg) {
|
|
322
|
-
|
|
332
|
+
const url = telegramChatUrl({ source, chat: tg[1], username, phone, text });
|
|
333
|
+
return url
|
|
334
|
+
? { url, prefillsText: Boolean(text) && !isTelegramWebUrl(url) }
|
|
335
|
+
: null;
|
|
323
336
|
}
|
|
324
337
|
return null;
|
|
325
338
|
};
|
|
@@ -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,10 +17,11 @@
|
|
|
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';
|
|
23
|
-
import
|
|
24
|
+
import http2 from 'node:http2';
|
|
24
25
|
import { DIR_MODE, tighten, writeFileDurable } from '../storage/durable.js';
|
|
25
26
|
import {
|
|
26
27
|
certificateFingerprint,
|
|
@@ -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) => {
|
|
@@ -265,8 +260,19 @@ export const listenHttpAndHttps = (
|
|
|
265
260
|
host = '127.0.0.1'
|
|
266
261
|
) =>
|
|
267
262
|
new Promise((resolve, reject) => {
|
|
268
|
-
|
|
269
|
-
|
|
263
|
+
// Multiplex API calls, media and event streams over HTTPS. HTTP/1's six
|
|
264
|
+
// browser connections can otherwise all wait behind long media downloads.
|
|
265
|
+
const secure = http2.createSecureServer(
|
|
266
|
+
{ key, cert, allowHTTP1: true },
|
|
267
|
+
(req, res) => {
|
|
268
|
+
// Keep the existing strict Host and Origin checks for HTTP/2 too.
|
|
269
|
+
req.headers.host ??= req.headers[':authority'];
|
|
270
|
+
httpServer.emit('request', req, res);
|
|
271
|
+
}
|
|
272
|
+
);
|
|
273
|
+
secure.requestTimeout = httpServer.requestTimeout;
|
|
274
|
+
secure.on('upgrade', (req, socket, head) =>
|
|
275
|
+
httpServer.emit('upgrade', req, socket, head)
|
|
270
276
|
);
|
|
271
277
|
// A browser that does not trust the certificate yet hangs up.
|
|
272
278
|
secure.on('tlsClientError', () => {});
|
|
@@ -69,7 +69,7 @@ export const handleEventRoutes = (store, req, res, p, ctx) => {
|
|
|
69
69
|
res.writeHead(200, {
|
|
70
70
|
'content-type': 'text/event-stream; charset=utf-8',
|
|
71
71
|
'cache-control': 'no-cache, no-transform',
|
|
72
|
-
connection: 'keep-alive',
|
|
72
|
+
...(req.httpVersionMajor === 2 ? {} : { connection: 'keep-alive' }),
|
|
73
73
|
'x-accel-buffering': 'no',
|
|
74
74
|
});
|
|
75
75
|
// Reconnect quickly after the server restarts.
|
|
@@ -377,6 +377,11 @@ const extrasOf = (user, full) => ({
|
|
|
377
377
|
const botRevenueOf = (user, full) =>
|
|
378
378
|
user.bot && full.canViewRevenue ? { canViewStarsRevenue: true } : {};
|
|
379
379
|
|
|
380
|
+
const contactReach = (user) => ({
|
|
381
|
+
username: user.username ?? null,
|
|
382
|
+
phone: user.phone ?? null,
|
|
383
|
+
});
|
|
384
|
+
|
|
380
385
|
const fetchUser = async (client, Api, helpers, inputPeer, peers, chat) => {
|
|
381
386
|
const storedPeer = inputPeer ?? (await peers.input(chat));
|
|
382
387
|
const id = inputUserOf(Api, helpers, storedPeer, chat);
|
|
@@ -389,7 +394,7 @@ const fetchUser = async (client, Api, helpers, inputPeer, peers, chat) => {
|
|
|
389
394
|
title: nameOf(user),
|
|
390
395
|
members: 1,
|
|
391
396
|
online,
|
|
392
|
-
|
|
397
|
+
...contactReach(user),
|
|
393
398
|
about: full.about ?? null,
|
|
394
399
|
photo: hasPhoto(user.photo),
|
|
395
400
|
pinned: full.pinnedMsgId ?? null,
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Normal Telegram clients ignore tg://user?id (a Bot API abstraction).
|
|
2
|
+
// Use documented username/phone/message links; numeric-only peers use Web A's
|
|
3
|
+
// signed-in chat route instead of launching a native app without a destination.
|
|
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
|
+
|
|
16
|
+
export const telegramChatUrl = ({
|
|
17
|
+
source,
|
|
18
|
+
chat,
|
|
19
|
+
username,
|
|
20
|
+
phone,
|
|
21
|
+
lastId,
|
|
22
|
+
text,
|
|
23
|
+
native = false,
|
|
24
|
+
} = {}) => {
|
|
25
|
+
if (source !== 'telegram' || !/^-?[1-9]\d*$/.test(String(chat))) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
const handle = String(username ?? '').replace(/^@/, '');
|
|
29
|
+
const number = String(phone ?? '').replace(/[\s()+-]/g, '');
|
|
30
|
+
const resolved = resolvedLink(handle, number, native);
|
|
31
|
+
if (resolved) {
|
|
32
|
+
return text
|
|
33
|
+
? `${resolved}${native ? '&' : '?'}text=${encodeURIComponent(text)}`
|
|
34
|
+
: resolved;
|
|
35
|
+
}
|
|
36
|
+
const id = String(chat);
|
|
37
|
+
if (/^-100\d+$/.test(id) && /^[1-9]\d*$/.test(String(lastId))) {
|
|
38
|
+
return native
|
|
39
|
+
? `tg://privatepost?channel=${id.slice(4)}&post=${lastId}`
|
|
40
|
+
: `https://t.me/c/${id.slice(4)}/${lastId}`;
|
|
41
|
+
}
|
|
42
|
+
return `https://web.telegram.org/a/#${id}`;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const isTelegramWebUrl = (url) =>
|
|
46
|
+
String(url ?? '').startsWith('https://web.telegram.org/a/#');
|
|
@@ -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);
|