@chisquare-tech/yan-ngx-apps-messages 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,1357 @@
1
+ import * as i0 from '@angular/core';
2
+ import { InjectionToken, inject, Injectable, NgZone, signal, computed } from '@angular/core';
3
+ import { HttpClient, HttpErrorResponse } from '@angular/common/http';
4
+ import { firstValueFrom } from 'rxjs';
5
+
6
+ /** Mounted at `messages`. `to/:userId` opens (or starts) the direct conversation with someone, then replaces itself. */
7
+ const MESSAGES_ROUTES = [
8
+ { path: '', pathMatch: 'full', loadComponent: () => import('./chisquare-tech-yan-ngx-apps-messages-messages.page-L0fDp0gv.mjs').then((m) => m.MessagesPage) },
9
+ { path: 'requests', loadComponent: () => import('./chisquare-tech-yan-ngx-apps-messages-messages.page-L0fDp0gv.mjs').then((m) => m.MessagesPage) },
10
+ { path: 'to/:userId', loadComponent: () => import('./chisquare-tech-yan-ngx-apps-messages-start.page-gJ1cJDfy.mjs').then((m) => m.StartPage) },
11
+ { path: ':id', loadComponent: () => import('./chisquare-tech-yan-ngx-apps-messages-messages.page-L0fDp0gv.mjs').then((m) => m.MessagesPage) },
12
+ ];
13
+
14
+ // The `dm` channel of Yan's one WebSocket (/ws, cloud/server/realtime): auth with an access token in the first frame
15
+ // (never in the URL), subscribe, answer pings, and on a drop reconnect with backoff and resume from the last seq.
16
+ // Frames carry ids, never content.
17
+ class DmSocket {
18
+ constructor(o) {
19
+ this.ws = null;
20
+ this.attempt = 0;
21
+ this.timer = null;
22
+ this.closed = false;
23
+ this.o = o;
24
+ }
25
+ start() {
26
+ this.closed = false;
27
+ void this.connect();
28
+ }
29
+ stop() {
30
+ this.closed = true;
31
+ if (this.timer)
32
+ clearTimeout(this.timer);
33
+ this.ws?.close();
34
+ this.ws = null;
35
+ }
36
+ async connect() {
37
+ if (this.closed)
38
+ return;
39
+ const token = await this.o.token().catch(() => null);
40
+ if (!token || this.closed)
41
+ return this.retry();
42
+ let ws;
43
+ try {
44
+ ws = new WebSocket(this.o.url());
45
+ }
46
+ catch {
47
+ return this.retry();
48
+ }
49
+ this.ws = ws;
50
+ ws.onopen = () => ws.send(JSON.stringify({ v: 1, t: 'auth', token, device: this.o.device() }));
51
+ ws.onmessage = (e) => {
52
+ let f;
53
+ try {
54
+ f = JSON.parse(String(e.data));
55
+ }
56
+ catch {
57
+ return;
58
+ }
59
+ if (f.t === 'ping')
60
+ return ws.send(JSON.stringify({ v: 1, t: 'pong' }));
61
+ if (f.t === 'ready') {
62
+ const since = this.seq;
63
+ return ws.send(JSON.stringify(since === undefined ? { v: 1, t: 'sub', ch: 'dm' } : { v: 1, t: 'sub', ch: 'dm', since }));
64
+ }
65
+ if (f.ch !== 'dm')
66
+ return;
67
+ if (f.t === 'subscribed') {
68
+ this.attempt = 0;
69
+ const cur = f.seq ?? 0;
70
+ if (this.seq === undefined)
71
+ this.seq = cur; // first time: from now on
72
+ else if (cur < this.seq) {
73
+ // the server restarted and its numbering with it: what we missed can't be replayed
74
+ this.seq = cur;
75
+ this.o.onResync();
76
+ }
77
+ // otherwise the frames after our seq are replayed next
78
+ return;
79
+ }
80
+ if (f.t === 'resync') {
81
+ this.seq = f.seq;
82
+ return this.o.onResync();
83
+ }
84
+ if (f.t === 'error')
85
+ return;
86
+ if (typeof f.seq === 'number') {
87
+ if (this.seq !== undefined && f.seq > this.seq + 1)
88
+ this.o.onResync();
89
+ this.seq = Math.max(this.seq ?? 0, f.seq);
90
+ }
91
+ this.o.onEvent({ t: f.t, id: f.id, d: f.d });
92
+ };
93
+ ws.onclose = () => {
94
+ if (this.ws === ws)
95
+ this.ws = null;
96
+ this.retry();
97
+ };
98
+ }
99
+ retry() {
100
+ if (this.closed || this.timer)
101
+ return;
102
+ const delay = Math.min(30_000, 1000 * 2 ** this.attempt) * (0.5 + Math.random() / 2);
103
+ this.attempt = Math.min(this.attempt + 1, 6);
104
+ this.timer = setTimeout(() => {
105
+ this.timer = null;
106
+ void this.connect();
107
+ }, delay);
108
+ }
109
+ }
110
+
111
+ // End-to-end encryption for Yan's DMs (PLAN P1.7, TRD §5.8; owner decision 2026-11-06: Olm/Megolm via vodozemac,
112
+ // @chisquare-tech/yan-e2ee). Framework-free, so the same code runs in the browser and in node tests: the wasm module,
113
+ // storage, the server API and the lock are all passed in.
114
+ //
115
+ // The model is Matrix's:
116
+ // - Each device has an Olm account. It publishes signed identity keys, signed one-time keys and a fallback key.
117
+ // - Each device sending to a conversation has a Megolm group session per epoch (the server bumps the epoch on every
118
+ // membership change), rotated after ROTATE_AFTER messages. Its key goes to every member device over Olm, through
119
+ // that device's to-device inbox, before the first message that device should read.
120
+ // - Messages are Megolm ciphertext. The server stores and relays what it can't read.
121
+ //
122
+ // Envelopes (version 1). The Android and iOS clients must produce exactly these:
123
+ // - to-device body: JSON {"v":1,"type":0|1,"body":"<olm ciphertext>"} (type 0 is a pre-key message)
124
+ // - its plaintext: {"t":"yan.room_key","conversationId","epoch","sessionId","sessionKey",
125
+ // "sender":{"userId","deviceId","ed25519"},"recipient":{"userId","deviceId","ed25519"}}
126
+ // - a message's Megolm plaintext: {"v":1,"conversationId","kind","text"}
127
+ // A room key is accepted only when it names this device as the recipient and its sender matches the device that
128
+ // sent it (its keys as the server published them, signed by itself). A message is shown only when its session's
129
+ // conversation and sender match the message's own.
130
+ const OLM = 'm.olm.v1.curve25519-aes-sha2';
131
+ const MEGOLM = 'm.megolm.v1.aes-sha2';
132
+ /** One-time keys a device keeps on the server; topped up when it falls below half. */
133
+ const KEY_TARGET = 50;
134
+ /** Messages per group session before a new one (Matrix's default is 100 too). */
135
+ const ROTATE_AFTER = 100;
136
+ /** A lock for one process. */
137
+ function localLock() {
138
+ let tail = Promise.resolve();
139
+ return (fn) => {
140
+ const run = tail.then(fn, fn);
141
+ tail = run.catch(() => undefined);
142
+ return run;
143
+ };
144
+ }
145
+ // ---- pure rules (also used by tests) ----
146
+ const deviceSigningText = (userId, deviceId, curve, ed) => `yan.device.v1|${userId}|${deviceId}|${curve}|${ed}`;
147
+ const keySigningText = (userId, deviceId, keyId, key, fallback) => `yan.${fallback ? 'fallback' : 'otk'}.v1|${userId}|${deviceId}|${keyId}|${key}`;
148
+ /** A room key that arrived from `from` is for `me` and really from that device. */
149
+ function roomKeyProblem(k, me, from) {
150
+ const r = k;
151
+ if (!r || r.t !== 'yan.room_key')
152
+ return 'not a room key';
153
+ if (typeof r.conversationId !== 'string' || typeof r.sessionId !== 'string' || typeof r.sessionKey !== 'string')
154
+ return 'malformed';
155
+ if (!same(r.recipient, me))
156
+ return 'meant for another device';
157
+ if (!same(r.sender, from))
158
+ return 'sender mismatch';
159
+ return null;
160
+ }
161
+ const same = (a, b) => !!a && a.userId === b.userId && a.deviceId === b.deviceId && a.ed25519 === b.ed25519;
162
+ /** Keys to generate so the server holds about KEY_TARGET: none until it falls below half. */
163
+ function keysToGenerate(onServer, target = KEY_TARGET) {
164
+ return onServer >= target / 2 ? 0 : target - onServer;
165
+ }
166
+ const MAX_OLM_PER_PEER = 5;
167
+ /** One person's encryption on one device. */
168
+ class E2eeMachine {
169
+ constructor(o) {
170
+ /** devices as the server published them and we checked them, per user, for this page's life */
171
+ this.deviceCache = new Map();
172
+ /** sessionId → message index → message id: a reused index is a replay */
173
+ this.seen = new Map();
174
+ this.w = o.wasm;
175
+ this.api = o.transport;
176
+ this.store = o.store;
177
+ this.key = o.pickleKey;
178
+ this.lock = o.lock ?? localLock();
179
+ this.log = o.log ?? (() => undefined);
180
+ this.userId = o.userId;
181
+ this.deviceName = o.deviceName;
182
+ this.newId = o.newDeviceId ?? randomDeviceId;
183
+ }
184
+ /** Loads this device's account, or makes and publishes a new one; then makes sure the server has its keys. */
185
+ static async open(o) {
186
+ const m = new E2eeMachine(o);
187
+ await m.lock(() => m.boot());
188
+ return m;
189
+ }
190
+ get deviceId() {
191
+ return this.dev.deviceId;
192
+ }
193
+ get ed25519() {
194
+ return this.ed;
195
+ }
196
+ async boot() {
197
+ const raw = await this.store.get('device');
198
+ this.dev = raw ? JSON.parse(raw) : this.fresh();
199
+ for (let attempt = 0;; attempt++) {
200
+ const acct = this.account();
201
+ this.ed = acct.ed25519;
202
+ this.curve = acct.curve25519;
203
+ if (!this.dev.registered) {
204
+ const ok = await this.api.registerDevice(this.dev.deviceId, {
205
+ curve25519: this.curve,
206
+ ed25519: this.ed,
207
+ signature: acct.sign(deviceSigningText(this.userId, this.dev.deviceId, this.curve, this.ed)),
208
+ name: this.deviceName,
209
+ });
210
+ if (!ok) {
211
+ acct.free();
212
+ if (attempt > 0)
213
+ throw new Error('the server refused this device');
214
+ this.log('device refused; starting a new one');
215
+ await this.wipe();
216
+ this.dev = this.fresh();
217
+ continue;
218
+ }
219
+ this.dev.registered = true;
220
+ await this.saveDevice(acct);
221
+ }
222
+ acct.free();
223
+ const count = await this.api.keyCount(this.dev.deviceId);
224
+ if (!count) {
225
+ if (attempt > 0)
226
+ throw new Error('the server forgot this device');
227
+ this.log('device unknown to the server; starting a new one');
228
+ await this.wipe();
229
+ this.dev = this.fresh();
230
+ continue;
231
+ }
232
+ await this.topUp(count);
233
+ break;
234
+ }
235
+ }
236
+ fresh() {
237
+ const acct = new this.w.Account();
238
+ const rec = { deviceId: this.newId(), account: acct.pickle(this.key), registered: false };
239
+ acct.free();
240
+ return rec;
241
+ }
242
+ async wipe() {
243
+ await this.store.del('device');
244
+ await this.store.del('inboxAfter');
245
+ }
246
+ account() {
247
+ return this.w.Account.from_pickle(this.dev.account, this.key);
248
+ }
249
+ async saveDevice(acct) {
250
+ this.dev.account = acct.pickle(this.key);
251
+ await this.store.set('device', JSON.stringify(this.dev));
252
+ }
253
+ /** Publishes one-time keys when the server is running low, and a fallback key when it has none. */
254
+ async topUp(count) {
255
+ const c = count ?? (await this.api.keyCount(this.dev.deviceId));
256
+ if (!c)
257
+ return;
258
+ const n = keysToGenerate(c.oneTimeKeys);
259
+ if (!n && c.fallback)
260
+ return;
261
+ const acct = this.account();
262
+ try {
263
+ if (n)
264
+ acct.generate_one_time_keys(Math.min(n, acct.max_one_time_keys()));
265
+ if (!c.fallback)
266
+ acct.generate_fallback_key();
267
+ const sign = (fallback) => ([keyId, key]) => ({
268
+ keyId,
269
+ key,
270
+ signature: acct.sign(keySigningText(this.userId, this.dev.deviceId, keyId, key, fallback)),
271
+ });
272
+ const otks = Object.entries(acct.one_time_keys()).map(sign(false));
273
+ const [fallbackKey] = Object.entries(acct.fallback_key()).map(sign(true));
274
+ if (!otks.length && !fallbackKey)
275
+ return;
276
+ // saved before upload: if the upload is lost, the same keys go next time (the server ignores repeats)
277
+ await this.saveDevice(acct);
278
+ await this.api.uploadKeys(this.dev.deviceId, { oneTimeKeys: otks, fallbackKey });
279
+ acct.mark_keys_as_published();
280
+ await this.saveDevice(acct);
281
+ }
282
+ finally {
283
+ acct.free();
284
+ }
285
+ }
286
+ // ---- devices ----
287
+ /** A user's devices whose keys are signed by themselves and match what this device saw before (trust on first use). */
288
+ devicesOf(userId) {
289
+ let p = this.deviceCache.get(userId);
290
+ if (!p) {
291
+ p = this.loadDevices(userId);
292
+ this.deviceCache.set(userId, p);
293
+ p.catch(() => this.deviceCache.delete(userId));
294
+ }
295
+ return p;
296
+ }
297
+ async loadDevices(userId) {
298
+ const out = [];
299
+ for (const d of await this.api.devices(userId)) {
300
+ if (d.userId !== userId)
301
+ continue;
302
+ if (!this.w.verify_ed25519(d.ed25519, deviceSigningText(userId, d.deviceId, d.curve25519, d.ed25519), d.signature)) {
303
+ this.log(`device ${userId}/${d.deviceId}: bad signature`);
304
+ continue;
305
+ }
306
+ const pinKey = `pin:${userId}|${d.deviceId}`;
307
+ const pinned = await this.store.get(pinKey);
308
+ const now = `${d.curve25519}|${d.ed25519}`;
309
+ if (pinned && pinned !== now) {
310
+ this.log(`device ${userId}/${d.deviceId}: keys changed`);
311
+ continue;
312
+ }
313
+ if (!pinned)
314
+ await this.store.set(pinKey, now);
315
+ out.push(d);
316
+ }
317
+ return out;
318
+ }
319
+ /** Forget cached device lists (someone signed in on a new device, or a membership changed). */
320
+ forgetDevices(userId) {
321
+ if (userId)
322
+ this.deviceCache.delete(userId);
323
+ else
324
+ this.deviceCache.clear();
325
+ }
326
+ // ---- Olm sessions ----
327
+ async olmSessions(curve) {
328
+ const raw = await this.store.get(`olm:${curve}`);
329
+ return raw ? JSON.parse(raw) : { sessions: [] };
330
+ }
331
+ async saveOlm(curve, s, rec) {
332
+ const r = rec ?? (await this.olmSessions(curve));
333
+ const id = s.session_id;
334
+ const rest = r.sessions.filter((x) => x.id !== id);
335
+ r.sessions = [{ id, pickle: s.pickle(this.key) }, ...rest].slice(0, MAX_OLM_PER_PEER);
336
+ await this.store.set(`olm:${curve}`, JSON.stringify(r));
337
+ }
338
+ /** Olm sessions to each device, opening new ones from claimed one-time keys where there are none yet. */
339
+ async olmTo(devices) {
340
+ const out = new Map();
341
+ const need = [];
342
+ for (const d of devices) {
343
+ const rec = await this.olmSessions(d.curve25519);
344
+ if (rec.sessions.length)
345
+ out.set(d.curve25519, this.w.Session.from_pickle(rec.sessions[0].pickle, this.key));
346
+ else
347
+ need.push(d);
348
+ }
349
+ if (!need.length)
350
+ return out;
351
+ const claimed = await this.api.claim(need.map((d) => ({ userId: d.userId, deviceId: d.deviceId })));
352
+ const acct = this.account();
353
+ try {
354
+ for (const k of claimed) {
355
+ const d = need.find((x) => x.userId === k.userId && x.deviceId === k.deviceId);
356
+ if (!d)
357
+ continue;
358
+ if (!this.w.verify_ed25519(d.ed25519, keySigningText(d.userId, d.deviceId, k.keyId, k.key, !!k.fallback), k.signature)) {
359
+ this.log(`claimed key for ${d.userId}/${d.deviceId}: bad signature`);
360
+ continue;
361
+ }
362
+ const s = acct.create_outbound_session(d.curve25519, k.key);
363
+ await this.saveOlm(d.curve25519, s);
364
+ out.set(d.curve25519, s);
365
+ }
366
+ }
367
+ finally {
368
+ acct.free();
369
+ }
370
+ return out;
371
+ }
372
+ // ---- sending ----
373
+ /** Encrypts a message for a conversation, first sending the group session's key to every device that lacks it. */
374
+ encrypt(conv, plain) {
375
+ return this.lock(async () => {
376
+ const outKey = `out:${conv.id}`;
377
+ const raw = await this.store.get(outKey);
378
+ let rec = raw ? JSON.parse(raw) : null;
379
+ if (!rec || rec.epoch !== conv.epoch || rec.sent >= ROTATE_AFTER)
380
+ rec = await this.newOutbound(conv);
381
+ const group = this.w.GroupSession.from_pickle(rec.pickle, this.key);
382
+ try {
383
+ await this.share(conv, group, rec);
384
+ const ciphertext = group.encrypt(JSON.stringify({ v: 1, conversationId: conv.id, kind: plain.kind, text: plain.text }));
385
+ rec.sent++;
386
+ rec.pickle = group.pickle(this.key);
387
+ await this.store.set(outKey, JSON.stringify(rec));
388
+ return { senderDevice: this.dev.deviceId, algorithm: MEGOLM, sessionId: rec.sessionId, ciphertext };
389
+ }
390
+ finally {
391
+ group.free();
392
+ }
393
+ });
394
+ }
395
+ async newOutbound(conv) {
396
+ const g = new this.w.GroupSession();
397
+ // our own copy, so this device reads what it sent
398
+ const ib = new this.w.InboundGroupSession(g.session_key);
399
+ const mine = { pickle: ib.pickle(this.key), conversationId: conv.id, senderUserId: this.userId, senderDeviceId: this.dev.deviceId, firstIndex: ib.first_known_index };
400
+ await this.store.set(`in:${g.session_id}`, JSON.stringify(mine));
401
+ const rec = { epoch: conv.epoch, sessionId: g.session_id, pickle: g.pickle(this.key), sent: 0, shared: [] };
402
+ ib.free();
403
+ g.free();
404
+ return rec;
405
+ }
406
+ async share(conv, group, rec) {
407
+ const readers = [...new Set(conv.members.filter((m) => m.state === 'active' || m.state === 'request').map((m) => m.userId))];
408
+ if (!readers.includes(this.userId))
409
+ readers.push(this.userId); // our other devices
410
+ const targets = [];
411
+ for (const u of readers) {
412
+ for (const d of await this.devicesOf(u)) {
413
+ if (u === this.userId && d.deviceId === this.dev.deviceId)
414
+ continue;
415
+ if (!rec.shared.includes(`${u}|${d.deviceId}|${d.curve25519}`))
416
+ targets.push(d);
417
+ }
418
+ }
419
+ if (!targets.length)
420
+ return;
421
+ const sessions = await this.olmTo(targets);
422
+ const sessionKey = group.session_key; // from the current index: a new reader can't open what came before
423
+ const messages = [];
424
+ const done = [];
425
+ for (const d of targets) {
426
+ const s = sessions.get(d.curve25519);
427
+ if (!s)
428
+ continue; // no keys to claim: that device reads nothing until it publishes some
429
+ const k = {
430
+ t: 'yan.room_key',
431
+ conversationId: conv.id,
432
+ epoch: conv.epoch,
433
+ sessionId: rec.sessionId,
434
+ sessionKey,
435
+ sender: { userId: this.userId, deviceId: this.dev.deviceId, ed25519: this.ed },
436
+ recipient: { userId: d.userId, deviceId: d.deviceId, ed25519: d.ed25519 },
437
+ };
438
+ const sealed = s.encrypt(JSON.stringify(k));
439
+ await this.saveOlm(d.curve25519, s);
440
+ s.free();
441
+ messages.push({ userId: d.userId, deviceId: d.deviceId, type: OLM, body: JSON.stringify({ v: 1, type: sealed.message_type, body: sealed.body }) });
442
+ done.push(`${d.userId}|${d.deviceId}|${d.curve25519}`);
443
+ }
444
+ for (let i = 0; i < messages.length; i += 200)
445
+ await this.api.sendToDevice(this.dev.deviceId, messages.slice(i, i + 200));
446
+ rec.shared.push(...done);
447
+ }
448
+ // ---- receiving ----
449
+ /** Reads this device's to-device inbox: room keys from other devices. Answers how many new keys arrived. */
450
+ sync() {
451
+ return this.lock(() => this.syncLocked());
452
+ }
453
+ async syncLocked() {
454
+ let after = Number((await this.store.get('inboxAfter')) ?? 0);
455
+ let added = 0;
456
+ let usedOneTimeKey = false;
457
+ for (;;) {
458
+ const items = await this.api.inbox(this.dev.deviceId, after);
459
+ if (!items.length)
460
+ break;
461
+ for (const it of items) {
462
+ try {
463
+ const r = await this.receive(it);
464
+ added += r.added;
465
+ usedOneTimeKey ||= r.newSession;
466
+ }
467
+ catch (e) {
468
+ this.log(`to-device ${it.id} from ${it.senderUserId}/${it.senderDeviceId}: ${e.message}`);
469
+ }
470
+ after = Math.max(after, it.id);
471
+ }
472
+ await this.store.set('inboxAfter', String(after));
473
+ await this.api.ackInbox(this.dev.deviceId, after);
474
+ }
475
+ if (usedOneTimeKey)
476
+ await this.topUp();
477
+ return added;
478
+ }
479
+ async receive(it) {
480
+ if (it.type !== OLM)
481
+ return { added: 0, newSession: false };
482
+ const env = JSON.parse(it.body);
483
+ if (env.v !== 1)
484
+ throw new Error('unknown envelope');
485
+ const find = async () => (await this.devicesOf(it.senderUserId)).find((d) => d.deviceId === it.senderDeviceId);
486
+ let from = await find();
487
+ if (!from) {
488
+ // a device that signed in after we last looked
489
+ this.forgetDevices(it.senderUserId);
490
+ from = await find();
491
+ }
492
+ if (!from)
493
+ throw new Error('unknown sender device');
494
+ let plaintext = null;
495
+ let newSession = false;
496
+ const rec = await this.olmSessions(from.curve25519);
497
+ for (const s of rec.sessions) {
498
+ const sess = this.w.Session.from_pickle(s.pickle, this.key);
499
+ try {
500
+ plaintext = sess.decrypt(env.type, env.body);
501
+ await this.saveOlm(from.curve25519, sess, rec);
502
+ break;
503
+ }
504
+ catch {
505
+ // not this session
506
+ }
507
+ finally {
508
+ sess.free();
509
+ }
510
+ }
511
+ if (plaintext === null) {
512
+ if (env.type !== 0)
513
+ throw new Error('no session for this message');
514
+ const acct = this.account();
515
+ try {
516
+ const inbound = acct.create_inbound_session(from.curve25519, env.type, env.body);
517
+ plaintext = inbound.plaintext;
518
+ const sess = inbound.take_session();
519
+ await this.saveDevice(acct); // its one-time key is spent
520
+ await this.saveOlm(from.curve25519, sess, rec);
521
+ sess.free();
522
+ newSession = true;
523
+ }
524
+ finally {
525
+ acct.free();
526
+ }
527
+ }
528
+ const k = JSON.parse(plaintext);
529
+ const problem = roomKeyProblem(k, { userId: this.userId, deviceId: this.dev.deviceId, ed25519: this.ed }, { userId: from.userId, deviceId: from.deviceId, ed25519: from.ed25519 });
530
+ if (problem)
531
+ throw new Error(problem);
532
+ const ib = new this.w.InboundGroupSession(k.sessionKey);
533
+ try {
534
+ if (ib.session_id !== k.sessionId)
535
+ throw new Error('session id mismatch');
536
+ const existing = await this.store.get(`in:${k.sessionId}`);
537
+ if (existing) {
538
+ const e = JSON.parse(existing);
539
+ if (e.senderUserId !== from.userId || e.senderDeviceId !== from.deviceId || e.conversationId !== k.conversationId)
540
+ throw new Error('session claimed by another sender');
541
+ if (e.firstIndex <= ib.first_known_index)
542
+ return { added: 0, newSession };
543
+ }
544
+ const r = { pickle: ib.pickle(this.key), conversationId: k.conversationId, senderUserId: from.userId, senderDeviceId: from.deviceId, firstIndex: ib.first_known_index };
545
+ await this.store.set(`in:${k.sessionId}`, JSON.stringify(r));
546
+ return { added: 1, newSession };
547
+ }
548
+ finally {
549
+ ib.free();
550
+ }
551
+ }
552
+ /** Decrypts a message; when its key hasn't arrived, reads the inbox once and tries again. */
553
+ decrypt(msg) {
554
+ return this.lock(async () => {
555
+ let raw = await this.store.get(`in:${msg.sessionId}`);
556
+ if (!raw) {
557
+ await this.syncLocked();
558
+ raw = await this.store.get(`in:${msg.sessionId}`);
559
+ }
560
+ if (!raw)
561
+ return { ok: false, reason: 'missing-key' };
562
+ const rec = JSON.parse(raw);
563
+ if (msg.algorithm !== MEGOLM || rec.conversationId !== msg.conversationId || rec.senderUserId !== msg.senderId || rec.senderDeviceId !== msg.senderDeviceId) {
564
+ return { ok: false, reason: 'invalid' };
565
+ }
566
+ const ib = this.w.InboundGroupSession.from_pickle(rec.pickle, this.key);
567
+ try {
568
+ const d = ib.decrypt(msg.ciphertext);
569
+ const seen = this.seen.get(msg.sessionId) ?? new Map();
570
+ this.seen.set(msg.sessionId, seen);
571
+ const prior = seen.get(d.message_index);
572
+ if (prior && prior !== msg.id)
573
+ return { ok: false, reason: 'invalid' };
574
+ seen.set(d.message_index, msg.id);
575
+ const p = JSON.parse(d.plaintext);
576
+ if (p.v !== 1 || p.conversationId !== msg.conversationId || typeof p.text !== 'string')
577
+ return { ok: false, reason: 'invalid' };
578
+ return { ok: true, kind: p.kind || 'text', text: p.text };
579
+ }
580
+ catch {
581
+ return { ok: false, reason: 'invalid' };
582
+ }
583
+ finally {
584
+ ib.free();
585
+ }
586
+ });
587
+ }
588
+ }
589
+ /** web_ + 16 random hex characters. */
590
+ function randomDeviceId() {
591
+ const b = new Uint8Array(8);
592
+ crypto.getRandomValues(b);
593
+ return 'web_' + [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
594
+ }
595
+
596
+ // The browser side of the encryption's storage: one IndexedDB database, keys prefixed by the person's id (several
597
+ // people can use one browser). Every pickle is already encrypted by vodozemac with a 32-byte pickle key; that key is
598
+ // stored wrapped by a non-extractable AES-GCM key, so its bytes never sit in storage in the clear.
599
+ const DB = 'yan-e2ee';
600
+ const KV = 'kv';
601
+ const KEYS = 'keys';
602
+ let dbp = null;
603
+ function db() {
604
+ dbp ??= new Promise((resolve, reject) => {
605
+ const r = indexedDB.open(DB, 1);
606
+ r.onupgradeneeded = () => {
607
+ r.result.createObjectStore(KV);
608
+ r.result.createObjectStore(KEYS);
609
+ };
610
+ r.onsuccess = () => resolve(r.result);
611
+ r.onerror = () => reject(r.error);
612
+ });
613
+ dbp.catch(() => (dbp = null));
614
+ return dbp;
615
+ }
616
+ function req(store, mode, fn) {
617
+ return db().then((d) => new Promise((resolve, reject) => {
618
+ const tx = d.transaction(store, mode);
619
+ const r = fn(tx.objectStore(store));
620
+ tx.oncomplete = () => resolve(r.result);
621
+ tx.onerror = () => reject(tx.error);
622
+ tx.onabort = () => reject(tx.error);
623
+ }));
624
+ }
625
+ /** One person's encryption storage. */
626
+ function idbStore(userId) {
627
+ const k = (key) => `${userId}/${key}`;
628
+ return {
629
+ get: (key) => req(KV, 'readonly', (s) => s.get(k(key))),
630
+ set: (key, value) => req(KV, 'readwrite', (s) => s.put(value, k(key))),
631
+ del: (key) => req(KV, 'readwrite', (s) => s.delete(k(key))),
632
+ };
633
+ }
634
+ /** This person's pickle key on this browser, made on first use. */
635
+ async function pickleKey(userId) {
636
+ const have = await req(KEYS, 'readonly', (s) => s.get(userId));
637
+ if (have)
638
+ return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: have.iv }, have.wrapper, have.sealed));
639
+ const raw = crypto.getRandomValues(new Uint8Array(32));
640
+ const wrapper = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
641
+ const iv = crypto.getRandomValues(new Uint8Array(12));
642
+ const sealed = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, wrapper, raw);
643
+ await req(KEYS, 'readwrite', (s) => s.put({ wrapper, iv, sealed }, userId));
644
+ return raw;
645
+ }
646
+ /** Everything this browser holds for a person (signing out of messaging here). */
647
+ async function forget(userId) {
648
+ const d = await db();
649
+ await new Promise((resolve, reject) => {
650
+ const tx = d.transaction([KV, KEYS], 'readwrite');
651
+ tx.objectStore(KV).delete(IDBKeyRange.bound(`${userId}/`, `${userId}/￿`));
652
+ tx.objectStore(KEYS).delete(userId);
653
+ tx.oncomplete = () => resolve();
654
+ tx.onerror = () => reject(tx.error);
655
+ });
656
+ }
657
+ /** One operation at a time across every tab of this person (a ratchet must never fork); in-tab only without Web Locks. */
658
+ function tabLock(userId, fallback) {
659
+ const locks = navigator.locks;
660
+ if (!locks)
661
+ return fallback;
662
+ return (fn) => locks.request(`yan-e2ee:${userId}`, fn);
663
+ }
664
+
665
+ // Pure display rules for Messages (prototype Messages screen, PRD DM-1–8). No Angular, so node tests run them.
666
+ /** How a person is shown: display name, else @username, else "Someone". */
667
+ function personName(p) {
668
+ if (!p)
669
+ return 'Someone';
670
+ return p.displayName || (p.username ? '@' + p.username : 'Someone');
671
+ }
672
+ /** The other people in a conversation who are still in it. */
673
+ function others(c, me) {
674
+ return c.members.filter((m) => m.userId !== me && (m.state === 'active' || m.state === 'request'));
675
+ }
676
+ /** A direct conversation is named after the other person; a group by its title, else its members. */
677
+ function conversationName(c, me, people) {
678
+ if (c.kind === 'group' && c.title)
679
+ return c.title;
680
+ const names = others(c, me).map((m) => personName(people[m.userId]));
681
+ if (!names.length)
682
+ return c.kind === 'group' ? 'Group' : 'Just you';
683
+ if (c.kind === 'direct')
684
+ return names[0];
685
+ return names.length <= 3 ? names.join(', ') : `${names.slice(0, 2).join(', ')} and ${names.length - 2} others`;
686
+ }
687
+ /** The prototype's kind eyebrow. */
688
+ function kindLabel(c) {
689
+ return c.kind === 'group' ? 'Group' : 'Direct';
690
+ }
691
+ /** Every DM and group is end-to-end encrypted (the email and community rows, which aren't, come with P2). */
692
+ const E2EE_LABEL = { cls: 'yan-security--e2ee', icon: 'shield-lock', text: 'End-to-end encrypted' };
693
+ /** The Requests row's second line. */
694
+ function requestsLine(count, firstFrom) {
695
+ if (!count)
696
+ return 'No requests';
697
+ return `${count} waiting · ${firstFrom ?? 'Someone'}`;
698
+ }
699
+ /** Who will see your messages under Requests, until they accept (DM-3). */
700
+ function pendingFor(c, me, people) {
701
+ const waiting = c.members.filter((m) => m.userId !== me && m.state === 'request');
702
+ if (!waiting.length)
703
+ return null;
704
+ if (c.kind === 'direct')
705
+ return `${personName(people[waiting[0].userId])} isn't in your network yet. Your messages go to their Requests until they accept.`;
706
+ return waiting.length === 1
707
+ ? `${personName(people[waiting[0].userId])} hasn't accepted yet and sees this under Requests.`
708
+ : `${waiting.length} people haven't accepted yet and see this under Requests.`;
709
+ }
710
+ /** Days left on a request (DM-3: 7), for "Expires in 3 days". */
711
+ function expiresIn(until, now = Date.now()) {
712
+ if (!until)
713
+ return '';
714
+ const ms = new Date(until).getTime() - now;
715
+ if (ms <= 0)
716
+ return 'Expired';
717
+ const h = Math.ceil(ms / 3_600_000);
718
+ if (h < 24)
719
+ return `Expires in ${h} hour${h === 1 ? '' : 's'}`;
720
+ const d = Math.ceil(h / 24);
721
+ return `Expires in ${d} day${d === 1 ? '' : 's'}`;
722
+ }
723
+ /**
724
+ * "Seen" under your last message, when everyone else has read up to it or later. `order` is the thread's message
725
+ * ids, oldest first.
726
+ */
727
+ function seen(c, me, order, lastMine) {
728
+ if (!lastMine)
729
+ return false;
730
+ const at = order.indexOf(lastMine);
731
+ if (at < 0)
732
+ return false;
733
+ const rest = others(c, me).filter((m) => m.state === 'active');
734
+ if (!rest.length)
735
+ return false;
736
+ return rest.every((m) => !!m.lastReadMessageId && order.indexOf(m.lastReadMessageId) >= at);
737
+ }
738
+ /** What a message that can't be shown says instead. */
739
+ function unreadableText(reason) {
740
+ return reason === 'missing-key' ? "Can't be read on this device" : "Couldn't be verified, so it isn't shown";
741
+ }
742
+ /** A preview line for the list: the text, else a word for the kind. */
743
+ function previewText(kind, text) {
744
+ if (text)
745
+ return text;
746
+ const words = { image: 'Photo', video: 'Video', file: 'File', voice: 'Voice message', link: 'Link', reaction: 'Reaction' };
747
+ return words[kind] ?? 'Message';
748
+ }
749
+
750
+ const MESSAGES_HOST = new InjectionToken('MESSAGES_HOST', {
751
+ providedIn: 'root',
752
+ factory: () => ({
753
+ userId: () => null,
754
+ accessToken: async () => null,
755
+ requestSignIn: () => undefined,
756
+ wsUrl: () => location.origin.replace(/^http/, 'ws') + '/ws',
757
+ deviceName: () => 'Web',
758
+ }),
759
+ });
760
+ /** Host-provided: base URL of the messaging API. */
761
+ const MESSAGES_API_BASE = new InjectionToken('MESSAGES_API_BASE', {
762
+ providedIn: 'root',
763
+ factory: () => '/api/apps/messaging',
764
+ });
765
+ /** Host-provided: base URL of the social API (names for people known only by id). */
766
+ const MESSAGES_SOCIAL_BASE = new InjectionToken('MESSAGES_SOCIAL_BASE', {
767
+ providedIn: 'root',
768
+ factory: () => '/api/apps/social',
769
+ });
770
+ /** Host-provided: where the encryption module (yan_e2ee_bg.wasm from @chisquare-tech/yan-e2ee) is served. */
771
+ const MESSAGES_E2EE_WASM = new InjectionToken('MESSAGES_E2EE_WASM', {
772
+ providedIn: 'root',
773
+ factory: () => '/e2ee/yan_e2ee_bg.wasm',
774
+ });
775
+
776
+ /** yan-gox-apps/libs/messaging over HTTP: the conversation API, and the device-key API the encryption needs. */
777
+ class MessagingApi {
778
+ constructor() {
779
+ this.http = inject(HttpClient);
780
+ this.base = inject(MESSAGES_API_BASE);
781
+ this.social = inject(MESSAGES_SOCIAL_BASE);
782
+ }
783
+ async get(path, params) {
784
+ const p = {};
785
+ for (const [k, v] of Object.entries(params ?? {}))
786
+ if (v !== undefined && v !== '')
787
+ p[k] = String(v);
788
+ return (await firstValueFrom(this.http.get(this.base + path, { params: p }))).data;
789
+ }
790
+ async send(method, path, body) {
791
+ return (await firstValueFrom(this.http.request(method, this.base + path, { body }))).data;
792
+ }
793
+ // ---- conversations ----
794
+ conversations(box = 'inbox') {
795
+ return this.get('/conversations', { box: box === 'inbox' ? undefined : box });
796
+ }
797
+ conversation(id) {
798
+ return this.get(`/conversations/${encodeURIComponent(id)}`);
799
+ }
800
+ startDirect(userId) {
801
+ return this.send('POST', '/conversations', { kind: 'direct', userId });
802
+ }
803
+ accept(id) {
804
+ return this.send('POST', `/conversations/${encodeURIComponent(id)}/accept`, {});
805
+ }
806
+ decline(id) {
807
+ return this.send('POST', `/conversations/${encodeURIComponent(id)}/decline`, {});
808
+ }
809
+ messages(id, before, limit = 50) {
810
+ return this.get(`/conversations/${encodeURIComponent(id)}/messages`, { before, limit });
811
+ }
812
+ sendMessage(id, body) {
813
+ return this.send('POST', `/conversations/${encodeURIComponent(id)}/messages`, body);
814
+ }
815
+ markRead(id, messageId) {
816
+ return this.send('PUT', `/conversations/${encodeURIComponent(id)}/read`, { messageId });
817
+ }
818
+ typing(id) {
819
+ return this.send('POST', `/conversations/${encodeURIComponent(id)}/typing`, {});
820
+ }
821
+ /** Names for people known only by id; an empty list asks nothing. */
822
+ async people(ids) {
823
+ const unique = [...new Set(ids)].slice(0, 100);
824
+ if (!unique.length)
825
+ return [];
826
+ return (await firstValueFrom(this.http.get(`${this.social}/people`, { params: { ids: unique.join(',') } }))).data;
827
+ }
828
+ // ---- E2eeTransport ----
829
+ async registerDevice(deviceId, body) {
830
+ try {
831
+ await this.send('PUT', `/devices/${encodeURIComponent(deviceId)}`, body);
832
+ return true;
833
+ }
834
+ catch (e) {
835
+ if (e instanceof HttpErrorResponse && e.status === 409)
836
+ return false;
837
+ throw e;
838
+ }
839
+ }
840
+ uploadKeys(deviceId, body) {
841
+ return this.send('POST', `/devices/${encodeURIComponent(deviceId)}/keys`, body);
842
+ }
843
+ async keyCount(deviceId) {
844
+ try {
845
+ return await this.get(`/devices/${encodeURIComponent(deviceId)}/keys/count`);
846
+ }
847
+ catch (e) {
848
+ if (e instanceof HttpErrorResponse && e.status === 404)
849
+ return null;
850
+ throw e;
851
+ }
852
+ }
853
+ devices(userId) {
854
+ return this.get(`/users/${encodeURIComponent(userId)}/devices`);
855
+ }
856
+ claim(devices) {
857
+ return this.send('POST', '/keys/claim', { devices });
858
+ }
859
+ async sendToDevice(senderDevice, messages) {
860
+ return (await this.send('POST', '/to-device', { senderDevice, messages })).sent;
861
+ }
862
+ inbox(deviceId, after) {
863
+ return this.get(`/devices/${encodeURIComponent(deviceId)}/inbox`, { after, limit: 100 });
864
+ }
865
+ async ackInbox(deviceId, upTo) {
866
+ await this.send('POST', `/devices/${encodeURIComponent(deviceId)}/inbox/ack`, { upTo });
867
+ }
868
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: MessagingApi, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
869
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: MessagingApi, providedIn: 'root' }); }
870
+ }
871
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: MessagingApi, decorators: [{
872
+ type: Injectable,
873
+ args: [{ providedIn: 'root' }]
874
+ }] });
875
+ /** A server error's message for people, or a fallback. */
876
+ function errorText(e, fallback) {
877
+ const m = e?.error?.message;
878
+ return m && m.length < 200 ? m : fallback;
879
+ }
880
+ /** An error's code from the server (e.g. cant_message), if any. */
881
+ function errorCode(e) {
882
+ return e?.error?.code;
883
+ }
884
+
885
+ const PREVIEW_LIMIT = 30;
886
+ /**
887
+ * Messages for the signed-in person: the lists, each open thread (decrypted on this device), sending, Requests, and
888
+ * live updates over the dm WebSocket channel. The server only ever sees ciphertext.
889
+ */
890
+ class MessagesStore {
891
+ constructor() {
892
+ this.api = inject(MessagingApi);
893
+ this.host = inject(MESSAGES_HOST);
894
+ this.wasmUrl = inject(MESSAGES_E2EE_WASM);
895
+ this.zone = inject(NgZone);
896
+ this.inbox = signal([], ...(ngDevMode ? [{ debugName: "inbox" }] : /* istanbul ignore next */ []));
897
+ this.requests = signal([], ...(ngDevMode ? [{ debugName: "requests" }] : /* istanbul ignore next */ []));
898
+ this.loaded = signal(false, ...(ngDevMode ? [{ debugName: "loaded" }] : /* istanbul ignore next */ []));
899
+ this.failed = signal(false, ...(ngDevMode ? [{ debugName: "failed" }] : /* istanbul ignore next */ []));
900
+ this.people = signal({}, ...(ngDevMode ? [{ debugName: "people" }] : /* istanbul ignore next */ []));
901
+ this.previews = signal({}, ...(ngDevMode ? [{ debugName: "previews" }] : /* istanbul ignore next */ []));
902
+ /** conversation id → who is typing there */
903
+ this.typing = signal({}, ...(ngDevMode ? [{ debugName: "typing" }] : /* istanbul ignore next */ []));
904
+ this.crypto = signal('idle', ...(ngDevMode ? [{ debugName: "crypto" }] : /* istanbul ignore next */ []));
905
+ /** For the nav badge: unread conversations plus waiting requests. */
906
+ this.badge = computed(() => this.inbox().reduce((n, c) => n + (c.unread > 0 ? 1 : 0), 0) + this.requests().length, ...(ngDevMode ? [{ debugName: "badge" }] : /* istanbul ignore next */ []));
907
+ this.threads = new Map();
908
+ this.machine = null;
909
+ this.machineUser = null;
910
+ this.socket = null;
911
+ this.refreshTimer = null;
912
+ this.typingTimers = new Map();
913
+ this.lastTypingSent = new Map();
914
+ }
915
+ me() {
916
+ return this.host.userId();
917
+ }
918
+ // ---- lifecycle ----
919
+ /** Loads the lists (enough for the nav badge). */
920
+ async loadLists() {
921
+ if (!this.me())
922
+ return;
923
+ try {
924
+ const [inbox, requests] = await Promise.all([this.api.conversations('inbox'), this.api.conversations('requests')]);
925
+ this.inbox.set(inbox);
926
+ this.requests.set(requests);
927
+ this.failed.set(false);
928
+ this.loaded.set(true);
929
+ await this.loadPeople([...inbox, ...requests].flatMap((c) => c.members.map((m) => m.userId)));
930
+ void this.loadPreviews([...requests, ...inbox]);
931
+ }
932
+ catch {
933
+ this.failed.set(true);
934
+ }
935
+ }
936
+ /** Everything Messages needs: the lists, this device's encryption, and live updates. */
937
+ async start() {
938
+ const me = this.me();
939
+ if (!me)
940
+ return;
941
+ if (this.machineUser && this.machineUser !== me)
942
+ this.stop();
943
+ void this.loadLists();
944
+ void this.ensureMachine().then(() => this.openSocket(), () => undefined);
945
+ }
946
+ /** Signing out, or another person signing in. */
947
+ stop() {
948
+ this.socket?.stop();
949
+ this.socket = null;
950
+ this.machine = null;
951
+ this.machineUser = null;
952
+ this.crypto.set('idle');
953
+ this.threads.clear();
954
+ this.inbox.set([]);
955
+ this.requests.set([]);
956
+ this.previews.set({});
957
+ this.loaded.set(false);
958
+ }
959
+ /** This device's encryption: loads the wasm module, then opens (or makes and publishes) the device's keys. */
960
+ ensureMachine() {
961
+ const me = this.me();
962
+ if (!me)
963
+ return Promise.reject(new Error('signed out'));
964
+ if (this.machine && this.machineUser === me)
965
+ return this.machine;
966
+ this.machineUser = me;
967
+ this.crypto.set('starting');
968
+ this.machine = (async () => {
969
+ const wasm = await import('@chisquare-tech/yan-e2ee');
970
+ await wasm.default({ module_or_path: this.wasmUrl });
971
+ return E2eeMachine.open({
972
+ wasm,
973
+ transport: this.api,
974
+ store: idbStore(me),
975
+ pickleKey: await pickleKey(me),
976
+ userId: me,
977
+ deviceName: this.host.deviceName(),
978
+ lock: tabLock(me, localLock()),
979
+ log: (m) => console.debug('[yan-e2ee]', m),
980
+ });
981
+ })();
982
+ this.machine.then(() => this.crypto.set('ready'), (e) => {
983
+ console.warn('[yan-e2ee] could not start', e);
984
+ this.machine = null;
985
+ this.crypto.set('failed');
986
+ });
987
+ return this.machine;
988
+ }
989
+ retryCrypto() {
990
+ void this.start();
991
+ }
992
+ openSocket() {
993
+ if (this.socket)
994
+ return;
995
+ const machine = this.machine;
996
+ if (!machine)
997
+ return;
998
+ let device = '';
999
+ void machine.then((m) => (device = m.deviceId));
1000
+ this.zone.runOutsideAngular(() => {
1001
+ this.socket = new DmSocket({
1002
+ url: () => this.host.wsUrl(),
1003
+ token: () => this.host.accessToken(),
1004
+ device: () => device,
1005
+ onEvent: (e) => this.zone.run(() => void this.onEvent(e)),
1006
+ onResync: () => this.zone.run(() => void this.resync()),
1007
+ });
1008
+ this.socket.start();
1009
+ });
1010
+ }
1011
+ // ---- people and previews ----
1012
+ async loadPeople(ids) {
1013
+ const have = this.people();
1014
+ const missing = [...new Set(ids)].filter((id) => !have[id]);
1015
+ if (!missing.length)
1016
+ return;
1017
+ try {
1018
+ const got = await this.api.people(missing);
1019
+ this.people.update((p) => ({ ...p, ...Object.fromEntries(got.map((x) => [x.userId, x])) }));
1020
+ }
1021
+ catch {
1022
+ // names fall back to "Someone"
1023
+ }
1024
+ }
1025
+ async loadPreviews(convs) {
1026
+ let machine;
1027
+ try {
1028
+ machine = await this.ensureMachine();
1029
+ }
1030
+ catch {
1031
+ return;
1032
+ }
1033
+ const want = convs.filter((c) => c.lastMessageId && this.previews()[c.id + '|' + c.lastMessageId] === undefined).slice(0, PREVIEW_LIMIT);
1034
+ await Promise.all(want.map(async (c) => {
1035
+ try {
1036
+ const page = await this.api.messages(c.id, undefined, 1);
1037
+ const m = page.items[0];
1038
+ if (!m)
1039
+ return;
1040
+ const o = await machine.decrypt(m);
1041
+ const line = o.ok ? previewText(o.kind, o.text) : 'Encrypted message';
1042
+ const mine = m.senderId === this.me();
1043
+ this.previews.update((p) => ({ ...p, [c.id + '|' + c.lastMessageId]: (mine ? 'You: ' : '') + line }));
1044
+ }
1045
+ catch {
1046
+ // no preview
1047
+ }
1048
+ }));
1049
+ }
1050
+ preview(c) {
1051
+ if (!c.lastMessageId)
1052
+ return 'No messages yet';
1053
+ return this.previews()[c.id + '|' + c.lastMessageId] ?? '';
1054
+ }
1055
+ // ---- threads ----
1056
+ thread(id) {
1057
+ let t = this.threads.get(id);
1058
+ if (!t) {
1059
+ t = signal({ conv: this.known(id), items: [], loading: true, failed: false, missing: false });
1060
+ this.threads.set(id, t);
1061
+ }
1062
+ return t;
1063
+ }
1064
+ known(id) {
1065
+ return this.inbox().find((c) => c.id === id) ?? this.requests().find((c) => c.id === id) ?? null;
1066
+ }
1067
+ /** Loads a conversation and its newest messages, decrypted. */
1068
+ async openThread(id) {
1069
+ const t = this.thread(id);
1070
+ t.update((s) => ({ ...s, loading: true, failed: false }));
1071
+ try {
1072
+ const [conv, page] = await Promise.all([this.api.conversation(id), this.api.messages(id)]);
1073
+ void this.loadPeople(conv.members.map((m) => m.userId));
1074
+ const items = await this.open(page.items.slice().reverse());
1075
+ t.set({ conv, items: this.keepPending(t().items, items), next: page.next, loading: false, failed: false, missing: false });
1076
+ this.markRead(conv, items);
1077
+ }
1078
+ catch (e) {
1079
+ const status = e.status;
1080
+ t.update((s) => ({ ...s, loading: false, failed: status !== 404, missing: status === 404 }));
1081
+ }
1082
+ }
1083
+ async loadOlder(id) {
1084
+ const t = this.thread(id);
1085
+ const s = t();
1086
+ if (!s.next || s.loading)
1087
+ return;
1088
+ t.update((x) => ({ ...x, loading: true }));
1089
+ try {
1090
+ const page = await this.api.messages(id, s.next);
1091
+ const older = await this.open(page.items.slice().reverse());
1092
+ t.update((x) => ({ ...x, items: [...older, ...x.items], next: page.next, loading: false }));
1093
+ }
1094
+ catch {
1095
+ t.update((x) => ({ ...x, loading: false }));
1096
+ }
1097
+ }
1098
+ /** Pending sends survive a reload of the thread. */
1099
+ keepPending(before, now) {
1100
+ const sent = new Set(now.map((b) => b.sealed?.clientMsgId));
1101
+ return [...now, ...before.filter((b) => (b.state === 'pending' || b.state === 'failed') && !sent.has(b.clientMsgId))];
1102
+ }
1103
+ async open(msgs) {
1104
+ let machine = null;
1105
+ try {
1106
+ machine = await this.ensureMachine();
1107
+ }
1108
+ catch {
1109
+ // shown as unreadable until encryption starts
1110
+ }
1111
+ const me = this.me();
1112
+ const out = [];
1113
+ for (const m of msgs) {
1114
+ const o = machine ? await machine.decrypt(m) : { ok: false, reason: 'missing-key' };
1115
+ out.push({
1116
+ id: m.id,
1117
+ senderId: m.senderId,
1118
+ mine: m.senderId === me,
1119
+ kind: o.ok ? o.kind : m.kind,
1120
+ text: o.ok ? o.text : unreadableText(o.reason),
1121
+ state: o.ok ? 'ok' : 'unreadable',
1122
+ unreadable: o.ok ? undefined : o.reason,
1123
+ createdAt: m.createdAt,
1124
+ sealed: m,
1125
+ });
1126
+ }
1127
+ return out;
1128
+ }
1129
+ markRead(conv, items) {
1130
+ const last = [...items].reverse().find((b) => b.state !== 'pending' && b.state !== 'failed');
1131
+ if (!last || conv.myState !== 'active')
1132
+ return;
1133
+ const mine = conv.members.find((m) => m.userId === this.me());
1134
+ if (mine?.lastReadMessageId === last.id && !conv.unread)
1135
+ return;
1136
+ void this.api.markRead(conv.id, last.id).then(() => {
1137
+ this.inbox.update((l) => l.map((c) => (c.id === conv.id ? { ...c, unread: 0 } : c)));
1138
+ }, () => undefined);
1139
+ }
1140
+ // ---- sending ----
1141
+ async send(id, text) {
1142
+ const body = text.trim();
1143
+ if (!body)
1144
+ return;
1145
+ const t = this.thread(id);
1146
+ const conv = t().conv;
1147
+ if (!conv)
1148
+ return;
1149
+ const clientMsgId = 'c_' + randomHex(12);
1150
+ const bubble = { id: clientMsgId, clientMsgId, senderId: this.me() ?? '', mine: true, kind: 'text', text: body, state: 'pending', createdAt: new Date().toISOString(), outgoing: { kind: 'text', text: body } };
1151
+ t.update((s) => ({ ...s, items: [...s.items, bubble] }));
1152
+ await this.deliver(id, bubble);
1153
+ }
1154
+ retry(id, b) {
1155
+ this.thread(id).update((s) => ({ ...s, items: s.items.map((x) => (x.clientMsgId === b.clientMsgId ? { ...x, state: 'pending', error: undefined } : x)) }));
1156
+ return this.deliver(id, b);
1157
+ }
1158
+ async deliver(id, b) {
1159
+ const t = this.thread(id);
1160
+ const patch = (p) => t.update((s) => ({ ...s, items: s.items.map((x) => (x.clientMsgId === b.clientMsgId ? { ...x, ...p } : x)) }));
1161
+ let body = b.outgoing?.body;
1162
+ try {
1163
+ if (!body) {
1164
+ // fresh members and epoch: someone added a moment ago must get this session's key
1165
+ const conv = await this.api.conversation(id);
1166
+ t.update((s) => ({ ...s, conv }));
1167
+ const machine = await this.ensureMachine();
1168
+ const enc = await machine.encrypt(keysOf(conv), { kind: b.outgoing?.kind ?? 'text', text: b.outgoing?.text ?? b.text });
1169
+ body = { clientMsgId: b.clientMsgId, kind: b.outgoing?.kind ?? 'text', ...enc };
1170
+ patch({ outgoing: { ...b.outgoing, body } });
1171
+ }
1172
+ const sent = await this.api.sendMessage(id, body);
1173
+ // the push for it may have arrived first
1174
+ t.update((s) => {
1175
+ const already = s.items.some((x) => x.id === sent.id && x.clientMsgId !== b.clientMsgId);
1176
+ const items = already
1177
+ ? s.items.filter((x) => x.clientMsgId !== b.clientMsgId)
1178
+ : s.items.map((x) => (x.clientMsgId === b.clientMsgId ? { ...x, id: sent.id, state: 'ok', sealed: sent, createdAt: sent.createdAt, outgoing: undefined } : x));
1179
+ return { ...s, items };
1180
+ });
1181
+ this.bump(id, sent.id, 'You: ' + previewText(body.kind, b.outgoing?.text ?? b.text));
1182
+ }
1183
+ catch (e) {
1184
+ const code = errorCode(e);
1185
+ patch({ state: 'failed', error: code === 'cant_message' ? "You can't message this person." : this.crypto() === 'failed' ? "Messages can't be encrypted in this browser." : errorText(e, 'Not sent.') });
1186
+ }
1187
+ }
1188
+ bump(id, messageId, line) {
1189
+ const now = new Date().toISOString();
1190
+ this.previews.update((p) => ({ ...p, [id + '|' + messageId]: line }));
1191
+ this.inbox.update((l) => {
1192
+ const c = l.find((x) => x.id === id);
1193
+ if (!c)
1194
+ return l;
1195
+ return [{ ...c, lastMessageId: messageId, lastMessageAt: now }, ...l.filter((x) => x.id !== id)];
1196
+ });
1197
+ }
1198
+ /** Starts (or finds) the direct conversation with someone. */
1199
+ async startDirect(userId) {
1200
+ const c = await this.api.startDirect(userId);
1201
+ if (!this.inbox().some((x) => x.id === c.id))
1202
+ this.inbox.update((l) => [c, ...l]);
1203
+ this.thread(c.id).update((s) => ({ ...s, conv: c }));
1204
+ void this.loadPeople(c.members.map((m) => m.userId));
1205
+ return c;
1206
+ }
1207
+ // ---- requests ----
1208
+ async accept(id) {
1209
+ const c = await this.api.accept(id);
1210
+ this.requests.update((l) => l.filter((x) => x.id !== id));
1211
+ this.inbox.update((l) => [c, ...l.filter((x) => x.id !== id)]);
1212
+ this.thread(id).update((s) => ({ ...s, conv: c }));
1213
+ }
1214
+ async decline(id) {
1215
+ await this.api.decline(id);
1216
+ this.requests.update((l) => l.filter((x) => x.id !== id));
1217
+ this.threads.delete(id);
1218
+ }
1219
+ // ---- typing ----
1220
+ typed(id) {
1221
+ const now = Date.now();
1222
+ if (now - (this.lastTypingSent.get(id) ?? 0) < 4000)
1223
+ return;
1224
+ this.lastTypingSent.set(id, now);
1225
+ void this.api.typing(id).catch(() => undefined);
1226
+ }
1227
+ // ---- live ----
1228
+ async onEvent(e) {
1229
+ const conv = typeof e.d?.['conversationId'] === 'string' ? e.d['conversationId'] : undefined;
1230
+ switch (e.t) {
1231
+ case 'message':
1232
+ if (conv) {
1233
+ // their message is here: they've stopped typing
1234
+ this.typing.update((t) => { const { [conv]: _, ...rest } = t; return rest; });
1235
+ await this.newer(conv);
1236
+ }
1237
+ this.refreshSoon();
1238
+ break;
1239
+ case 'to-device':
1240
+ await this.keysArrived();
1241
+ break;
1242
+ case 'membership':
1243
+ (await this.machine)?.forgetDevices();
1244
+ if (conv)
1245
+ await this.reloadConversation(conv);
1246
+ this.refreshSoon();
1247
+ break;
1248
+ case 'receipt':
1249
+ if (conv && e.d?.['userId'] !== this.me())
1250
+ await this.reloadConversation(conv);
1251
+ break;
1252
+ case 'typing': {
1253
+ const who = e.d?.['userId'];
1254
+ if (!conv || typeof who !== 'string' || who === this.me())
1255
+ break;
1256
+ this.typing.update((t) => ({ ...t, [conv]: who }));
1257
+ clearTimeout(this.typingTimers.get(conv));
1258
+ this.typingTimers.set(conv, setTimeout(() => this.typing.update((t) => { const { [conv]: _, ...rest } = t; return rest; }), 5000));
1259
+ break;
1260
+ }
1261
+ }
1262
+ }
1263
+ /** New messages in a loaded thread. */
1264
+ async newer(id) {
1265
+ const t = this.threads.get(id);
1266
+ if (!t || t().loading && !t().items.length)
1267
+ return;
1268
+ try {
1269
+ const page = await this.api.messages(id, undefined, 20);
1270
+ const have = new Set(t().items.map((b) => b.id));
1271
+ const fresh = await this.open(page.items.filter((m) => !have.has(m.id)).reverse());
1272
+ if (!fresh.length)
1273
+ return;
1274
+ t.update((s) => {
1275
+ const byClient = new Map(fresh.filter((f) => f.mine).map((f) => [f.sealed?.clientMsgId, f]));
1276
+ const items = s.items.filter((b) => !(b.clientMsgId && byClient.has(b.clientMsgId)));
1277
+ const insertAt = items.findIndex((b) => b.state === 'pending' || b.state === 'failed');
1278
+ if (insertAt < 0)
1279
+ return { ...s, items: [...items, ...fresh] };
1280
+ return { ...s, items: [...items.slice(0, insertAt), ...fresh, ...items.slice(insertAt)] };
1281
+ });
1282
+ const c = t().conv;
1283
+ if (c)
1284
+ this.markRead(c, t().items);
1285
+ }
1286
+ catch {
1287
+ // the next event or a reload catches up
1288
+ }
1289
+ }
1290
+ /** Room keys arrived: messages that couldn't be read may open now. */
1291
+ async keysArrived() {
1292
+ const m = await this.machine?.catch(() => null);
1293
+ if (!m)
1294
+ return;
1295
+ await m.sync().catch(() => 0);
1296
+ for (const t of this.threads.values()) {
1297
+ if (!t().items.some((b) => b.state === 'unreadable'))
1298
+ continue;
1299
+ const items = await Promise.all(t().items.map(async (b) => {
1300
+ if (b.state !== 'unreadable' || !b.sealed)
1301
+ return b;
1302
+ const o = await m.decrypt(b.sealed);
1303
+ return o.ok ? { ...b, state: 'ok', unreadable: undefined, kind: o.kind, text: o.text } : b;
1304
+ }));
1305
+ t.update((s) => ({ ...s, items }));
1306
+ }
1307
+ void this.loadPreviews([...this.requests(), ...this.inbox()]);
1308
+ }
1309
+ async reloadConversation(id) {
1310
+ const t = this.threads.get(id);
1311
+ if (!t)
1312
+ return;
1313
+ try {
1314
+ const conv = await this.api.conversation(id);
1315
+ t.update((s) => ({ ...s, conv }));
1316
+ }
1317
+ catch {
1318
+ // keep what we have
1319
+ }
1320
+ }
1321
+ async resync() {
1322
+ await this.loadLists();
1323
+ await this.keysArrived();
1324
+ for (const id of this.threads.keys())
1325
+ await this.newer(id);
1326
+ }
1327
+ refreshSoon() {
1328
+ if (this.refreshTimer)
1329
+ return;
1330
+ this.refreshTimer = setTimeout(() => {
1331
+ this.refreshTimer = null;
1332
+ void this.loadLists();
1333
+ }, 400);
1334
+ }
1335
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: MessagesStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
1336
+ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: MessagesStore, providedIn: 'root' }); }
1337
+ }
1338
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.12", ngImport: i0, type: MessagesStore, decorators: [{
1339
+ type: Injectable,
1340
+ args: [{ providedIn: 'root' }]
1341
+ }] });
1342
+ /** What the encryption needs to know about a conversation. */
1343
+ function keysOf(c) {
1344
+ return { id: c.id, epoch: c.epoch, members: c.members.map((m) => ({ userId: m.userId, state: m.state })) };
1345
+ }
1346
+ function randomHex(n) {
1347
+ const b = new Uint8Array(Math.ceil(n / 2));
1348
+ crypto.getRandomValues(b);
1349
+ return [...b].map((x) => x.toString(16).padStart(2, '0')).join('').slice(0, n);
1350
+ }
1351
+
1352
+ /**
1353
+ * Generated bundle index. Do not edit.
1354
+ */
1355
+
1356
+ export { E2EE_LABEL as E, MessagesStore as M, OLM as O, MESSAGES_HOST as a, personName as b, conversationName as c, errorText as d, expiresIn as e, errorCode as f, E2eeMachine as g, MEGOLM as h, MESSAGES_API_BASE as i, MESSAGES_E2EE_WASM as j, kindLabel as k, MESSAGES_ROUTES as l, MESSAGES_SOCIAL_BASE as m, MessagingApi as n, others as o, pendingFor as p, forget as q, requestsLine as r, seen as s };
1357
+ //# sourceMappingURL=chisquare-tech-yan-ngx-apps-messages-chisquare-tech-yan-ngx-apps-messages-BtQC73sF.mjs.map