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.
Files changed (62) hide show
  1. package/README.md +3 -1
  2. package/js/src/crm/reengage.js +18 -5
  3. package/js/src/server/local-certificate-check.js +82 -0
  4. package/js/src/server/local-tls.js +28 -22
  5. package/js/src/server/routes-events.js +1 -1
  6. package/js/src/sources/telegram-chat-info.js +6 -1
  7. package/js/src/sources/telegram-chat-url.js +46 -0
  8. package/js/src/storage/doublets-mirror.js +19 -3
  9. package/js/src/storage/dual-store.js +11 -1
  10. package/js/src/web/app-navigation.js +61 -0
  11. package/js/src/web/app-route.js +205 -0
  12. package/js/src/web/app.css +262 -28
  13. package/js/src/web/app.js +14 -39
  14. package/js/src/web/app.min.js +92 -92
  15. package/js/src/web/automations-view.js +2 -2
  16. package/js/src/web/chat-archive-media.js +12 -4
  17. package/js/src/web/chat-automation.js +8 -1
  18. package/js/src/web/chat-calls.js +6 -2
  19. package/js/src/web/chat-follow.js +22 -6
  20. package/js/src/web/chat-history-load.js +1 -0
  21. package/js/src/web/chat-invite.js +3 -5
  22. package/js/src/web/chat-media.js +160 -62
  23. package/js/src/web/chat-message.js +3 -1
  24. package/js/src/web/chat-model.js +8 -1
  25. package/js/src/web/chat-photo.js +84 -71
  26. package/js/src/web/chat-remote-media.js +5 -2
  27. package/js/src/web/chat-round.js +16 -1
  28. package/js/src/web/chat-sidebar.js +6 -4
  29. package/js/src/web/chat-state.js +30 -16
  30. package/js/src/web/chat-view.js +61 -56
  31. package/js/src/web/chat-web-apps.js +18 -12
  32. package/js/src/web/locales/en-chat-more.js +8 -0
  33. package/js/src/web/locales/en-chat.js +4 -6
  34. package/js/src/web/locales/en.js +11 -0
  35. package/js/src/web/locales/hi-chat-more.js +8 -0
  36. package/js/src/web/locales/hi-chat.js +4 -5
  37. package/js/src/web/locales/hi.js +11 -0
  38. package/js/src/web/locales/ru-chat-more.js +9 -0
  39. package/js/src/web/locales/ru-chat.js +4 -5
  40. package/js/src/web/locales/ru.js +11 -0
  41. package/js/src/web/locales/zh-chat-more.js +8 -0
  42. package/js/src/web/locales/zh-chat.js +4 -4
  43. package/js/src/web/locales/zh.js +11 -0
  44. package/js/src/web/media-loading.js +176 -0
  45. package/js/src/web/navigation-counts.js +6 -4
  46. package/js/src/web/operator-model.js +15 -4
  47. package/js/src/web/operator-order.js +46 -0
  48. package/js/src/web/operator-prefs.js +59 -0
  49. package/js/src/web/operator-unread.js +8 -6
  50. package/js/src/web/operator-view.js +48 -15
  51. package/js/src/web/peer-avatar.js +14 -1
  52. package/js/src/web/person-panel.js +35 -34
  53. package/js/src/web/profile-stories.js +5 -2
  54. package/js/src/web/settings-view.js +38 -0
  55. package/js/src/web/spinner.js +7 -2
  56. package/js/src/web/stories-pref.js +5 -0
  57. package/js/src/web/stories.js +1 -1
  58. package/js/src/web/views.js +12 -18
  59. package/js/src/web/web-app-loading.js +81 -0
  60. package/js/src/web/web-apps-model.js +20 -1
  61. package/package.json +8 -2
  62. 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 and show loading or retry status until their history is ready. 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. 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).
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.
@@ -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: `tg://user?id=<id>` opens the chat with that user (Bot
304
- * API docs); the text still has to be pasted, because Telegram has no
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 = ({ source, contact, chat, text }) => {
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
- return { url: `tg://user?id=${tg[1]}`, prefillsText: false };
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 tls from 'node:tls';
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
- try {
160
- const x509 = new crypto.X509Certificate(cert);
161
- return (
162
- !x509.ca &&
163
- x509.verify(x509.publicKey) &&
164
- x509.checkPrivateKey(crypto.createPrivateKey(key)) &&
165
- Date.parse(x509.validFrom) <= now &&
166
- Date.parse(x509.validTo) - now > RENEW_DAYS * DAY_MS &&
167
- x509.subjectAltName?.split(', ').length === 3 &&
168
- x509.subjectAltName.includes('DNS:localhost') &&
169
- x509.subjectAltName.includes('IP Address:127.0.0.1') &&
170
- (x509.subjectAltName.includes('IP Address:0:0:0:0:0:0:0:1') ||
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
- const secure = tls.createServer({ key, cert }, (socket) =>
269
- httpServer.emit('connection', socket)
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
- username: user.username ?? null,
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
- return { now, later };
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: lock.release,
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);