@chisquare-tech/yan-ngx-apps-messages 0.1.1 → 0.2.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.
@@ -5,10 +5,10 @@ import { firstValueFrom } from 'rxjs';
5
5
 
6
6
  /** Mounted at `messages`. `to/:userId` opens (or starts) the direct conversation with someone, then replaces itself. */
7
7
  const MESSAGES_ROUTES = [
8
- { path: '', pathMatch: 'full', loadComponent: () => import('./chisquare-tech-yan-ngx-apps-messages-messages.page-Dy0RhQwj.mjs').then((m) => m.MessagesPage) },
9
- { path: 'requests', loadComponent: () => import('./chisquare-tech-yan-ngx-apps-messages-messages.page-Dy0RhQwj.mjs').then((m) => m.MessagesPage) },
10
- { path: 'to/:userId', loadComponent: () => import('./chisquare-tech-yan-ngx-apps-messages-start.page-D97JIFrb.mjs').then((m) => m.StartPage) },
11
- { path: ':id', loadComponent: () => import('./chisquare-tech-yan-ngx-apps-messages-messages.page-Dy0RhQwj.mjs').then((m) => m.MessagesPage) },
8
+ { path: '', pathMatch: 'full', loadComponent: () => import('./chisquare-tech-yan-ngx-apps-messages-messages.page-Dc4cpq4L.mjs').then((m) => m.MessagesPage) },
9
+ { path: 'requests', loadComponent: () => import('./chisquare-tech-yan-ngx-apps-messages-messages.page-Dc4cpq4L.mjs').then((m) => m.MessagesPage) },
10
+ { path: 'to/:userId', loadComponent: () => import('./chisquare-tech-yan-ngx-apps-messages-start.page-Dnv52Der.mjs').then((m) => m.StartPage) },
11
+ { path: ':id', loadComponent: () => import('./chisquare-tech-yan-ngx-apps-messages-messages.page-Dc4cpq4L.mjs').then((m) => m.MessagesPage) },
12
12
  ];
13
13
 
14
14
  // The `dm` channel of Yan's one WebSocket (/ws, cloud/server/realtime): auth with an access token in the first frame
@@ -124,6 +124,15 @@ class DmSocket {
124
124
  // - its plaintext: {"t":"yan.room_key","conversationId","epoch","sessionId","sessionKey",
125
125
  // "sender":{"userId","deviceId","ed25519"},"recipient":{"userId","deviceId","ed25519"}}
126
126
  // - a message's Megolm plaintext: {"v":1,"conversationId","kind","text"}
127
+ // - history for a new device of yours (owner-approved, "Verifying a new device" in the yan-e2ee README):
128
+ // new → old {"t":"yan.history_request","requestId","commitment":sha256(n2),"requester":Party}
129
+ // old → new {"t":"yan.history_challenge","requestId","nonce":n1,"approver":Party,"recipient":Party}
130
+ // new → old {"t":"yan.history_reveal","requestId","nonce":n2,"requester":Party,"recipient":Party}
131
+ // both show verification_code("yan.verify.v1|user|newDevice|newEd|oldDevice|oldEd|n1|n2"); the person allows
132
+ // it on the old device, which then sends
133
+ // old → new {"t":"yan.forwarded_room_keys","requestId","keys":[{conversationId,sessionId,senderUserId,
134
+ // senderDeviceId,exported}],"sender":Party,"recipient":Party}
135
+ // The commitment means neither side (nor a server posing as one) can steer the code.
127
136
  // A room key is accepted only when it names this device as the recipient and its sender matches the device that
128
137
  // sent it (its keys as the server published them, signed by itself). A message is shown only when its session's
129
138
  // conversation and sender match the message's own.
@@ -164,6 +173,9 @@ function keysToGenerate(onServer, target = KEY_TARGET) {
164
173
  return onServer >= target / 2 ? 0 : target - onServer;
165
174
  }
166
175
  const MAX_OLM_PER_PEER = 5;
176
+ /** forwarded sessions per to-device message (each well under the server's 64 KB) */
177
+ const KEYS_PER_MESSAGE = 40;
178
+ const verifyText = (userId, requester, approver, n1, n2) => `yan.verify.v1|${userId}|${requester.deviceId}|${requester.ed25519}|${approver.deviceId}|${approver.ed25519}|${n1}|${n2}`;
167
179
  /** One person's encryption on one device. */
168
180
  class E2eeMachine {
169
181
  constructor(o) {
@@ -479,6 +491,27 @@ class E2eeMachine {
479
491
  async receive(it) {
480
492
  if (it.type !== OLM)
481
493
  return { added: 0, newSession: false };
494
+ const { from, plaintext, newSession } = await this.openOlm(it);
495
+ const msg = JSON.parse(plaintext);
496
+ const me = { userId: this.userId, deviceId: this.dev.deviceId, ed25519: this.ed };
497
+ const sender = { userId: from.userId, deviceId: from.deviceId, ed25519: from.ed25519 };
498
+ switch (msg.t) {
499
+ case 'yan.room_key':
500
+ return { added: await this.roomKey(msg, me, sender), newSession };
501
+ case 'yan.history_request':
502
+ case 'yan.history_challenge':
503
+ case 'yan.history_reveal':
504
+ case 'yan.forwarded_room_keys':
505
+ // only between your own devices
506
+ if (from.userId !== this.userId || from.deviceId === this.dev.deviceId)
507
+ throw new Error('history from someone else');
508
+ return { added: await this.history(msg, me, sender, from), newSession };
509
+ default:
510
+ throw new Error('unknown payload');
511
+ }
512
+ }
513
+ /** Decrypts an Olm to-device payload from a device whose keys check out. */
514
+ async openOlm(it) {
482
515
  const env = JSON.parse(it.body);
483
516
  if (env.v !== 1)
484
517
  throw new Error('unknown envelope');
@@ -491,15 +524,13 @@ class E2eeMachine {
491
524
  }
492
525
  if (!from)
493
526
  throw new Error('unknown sender device');
494
- let plaintext = null;
495
- let newSession = false;
496
527
  const rec = await this.olmSessions(from.curve25519);
497
528
  for (const s of rec.sessions) {
498
529
  const sess = this.w.Session.from_pickle(s.pickle, this.key);
499
530
  try {
500
- plaintext = sess.decrypt(env.type, env.body);
531
+ const plaintext = sess.decrypt(env.type, env.body);
501
532
  await this.saveOlm(from.curve25519, sess, rec);
502
- break;
533
+ return { from, plaintext, newSession: false };
503
534
  }
504
535
  catch {
505
536
  // not this session
@@ -508,47 +539,248 @@ class E2eeMachine {
508
539
  sess.free();
509
540
  }
510
541
  }
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
- }
542
+ if (env.type !== 0)
543
+ throw new Error('no session for this message');
544
+ const acct = this.account();
545
+ try {
546
+ const inbound = acct.create_inbound_session(from.curve25519, env.type, env.body);
547
+ const plaintext = inbound.plaintext;
548
+ const sess = inbound.take_session();
549
+ await this.saveDevice(acct); // its one-time key is spent
550
+ await this.saveOlm(from.curve25519, sess, rec);
551
+ sess.free();
552
+ return { from, plaintext, newSession: true };
553
+ }
554
+ finally {
555
+ acct.free();
527
556
  }
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 });
557
+ }
558
+ async roomKey(k, me, from) {
559
+ const problem = roomKeyProblem(k, me, from);
530
560
  if (problem)
531
561
  throw new Error(problem);
532
562
  const ib = new this.w.InboundGroupSession(k.sessionKey);
533
563
  try {
534
564
  if (ib.session_id !== k.sessionId)
535
565
  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 };
566
+ return await this.keep(ib, k.conversationId, from.userId, from.deviceId);
547
567
  }
548
568
  finally {
549
569
  ib.free();
550
570
  }
551
571
  }
572
+ /** Stores an inbound session unless we hold one that opens as much or more. */
573
+ async keep(ib, conversationId, senderUserId, senderDeviceId) {
574
+ const id = ib.session_id;
575
+ const existing = await this.store.get(`in:${id}`);
576
+ if (existing) {
577
+ const e = JSON.parse(existing);
578
+ if (e.senderUserId !== senderUserId || e.senderDeviceId !== senderDeviceId || e.conversationId !== conversationId)
579
+ throw new Error('session claimed by another sender');
580
+ if (e.firstIndex <= ib.first_known_index)
581
+ return 0;
582
+ }
583
+ const r = { pickle: ib.pickle(this.key), conversationId, senderUserId, senderDeviceId, firstIndex: ib.first_known_index };
584
+ await this.store.set(`in:${id}`, JSON.stringify(r));
585
+ return 1;
586
+ }
587
+ // ---- history for a new device of yours ----
588
+ async sendOlm(to, payload) {
589
+ const sessions = await this.olmTo(to);
590
+ const messages = [];
591
+ const sent = [];
592
+ for (const d of to) {
593
+ const s = sessions.get(d.curve25519);
594
+ if (!s)
595
+ continue;
596
+ const sealed = s.encrypt(JSON.stringify(payload(d)));
597
+ await this.saveOlm(d.curve25519, s);
598
+ s.free();
599
+ messages.push({ userId: d.userId, deviceId: d.deviceId, type: OLM, body: JSON.stringify({ v: 1, type: sealed.message_type, body: sealed.body }) });
600
+ sent.push(d.deviceId);
601
+ }
602
+ for (let i = 0; i < messages.length; i += 200)
603
+ await this.api.sendToDevice(this.dev.deviceId, messages.slice(i, i + 200));
604
+ return sent;
605
+ }
606
+ party(d) {
607
+ return { userId: d.userId, deviceId: d.deviceId, ed25519: d.ed25519 };
608
+ }
609
+ /**
610
+ * Asks your other devices for the keys to messages sent before this one was set up. Each that answers agrees a code
611
+ * with this device; you allow it there after checking the codes match. Answers false when there's nobody to ask.
612
+ */
613
+ requestHistory() {
614
+ return this.lock(async () => {
615
+ this.forgetDevices(this.userId);
616
+ const mine = (await this.devicesOf(this.userId)).filter((d) => d.deviceId !== this.dev.deviceId);
617
+ if (!mine.length) {
618
+ await this.store.set('history:out', JSON.stringify({ requestId: '', nonce: '', startedAt: new Date().toISOString(), asked: [], codes: {}, received: 0 }));
619
+ return false;
620
+ }
621
+ const req = { requestId: 'h_' + randomB64(12), nonce: randomB64(16), startedAt: new Date().toISOString(), asked: [], codes: {}, received: 0 };
622
+ const me = { userId: this.userId, deviceId: this.dev.deviceId, ed25519: this.ed };
623
+ req.asked = await this.sendOlm(mine, () => ({ t: 'yan.history_request', requestId: req.requestId, commitment: this.w.sha256_base64(req.nonce), requester: me }));
624
+ await this.store.set('history:out', JSON.stringify(req));
625
+ return req.asked.length > 0;
626
+ });
627
+ }
628
+ /** Where this device's request for history stands. */
629
+ historyStatus() {
630
+ return this.lock(async () => {
631
+ const raw = await this.store.get('history:out');
632
+ if (!raw)
633
+ return { state: 'none', codes: [], received: 0 };
634
+ const r = JSON.parse(raw);
635
+ if (!r.requestId)
636
+ return { state: 'alone', codes: [], received: 0 };
637
+ const names = new Map((await this.devicesOf(this.userId)).map((d) => [d.deviceId, d.name || 'Another device']));
638
+ return {
639
+ state: r.done ? 'received' : 'asked',
640
+ codes: Object.entries(r.codes).map(([deviceId, code]) => ({ deviceId, deviceName: names.get(deviceId) ?? 'Another device', code })),
641
+ received: r.received,
642
+ };
643
+ });
644
+ }
645
+ /** New devices of yours waiting for you to allow them here, each with the code it should be showing. */
646
+ pendingApprovals() {
647
+ return this.lock(async () => {
648
+ const out = [];
649
+ const devices = await this.devicesOf(this.userId);
650
+ for (const k of await this.store.keys('history:in:')) {
651
+ const r = JSON.parse((await this.store.get(k)));
652
+ if (r.state !== 'ready' || !r.code)
653
+ continue;
654
+ const d = devices.find((x) => x.deviceId === r.requester.deviceId && x.ed25519 === r.requester.ed25519);
655
+ if (!d)
656
+ continue; // signed out since
657
+ out.push({ requestId: r.requestId, deviceId: d.deviceId, deviceName: d.name || 'A new device', code: r.code, createdAt: r.createdAt });
658
+ }
659
+ return out;
660
+ });
661
+ }
662
+ /** You checked the codes match: send that device every session key this one holds. Answers how many. */
663
+ approveHistory(requestId) {
664
+ return this.lock(async () => {
665
+ const key = `history:in:${requestId}`;
666
+ const raw = await this.store.get(key);
667
+ if (!raw)
668
+ throw new Error('no such request');
669
+ const r = JSON.parse(raw);
670
+ if (r.state !== 'ready')
671
+ throw new Error('this request is not waiting');
672
+ const d = (await this.devicesOf(this.userId)).find((x) => x.deviceId === r.requester.deviceId && x.ed25519 === r.requester.ed25519);
673
+ if (!d)
674
+ throw new Error('that device is gone');
675
+ const keys = [];
676
+ for (const k of await this.store.keys('in:')) {
677
+ const rec = JSON.parse((await this.store.get(k)));
678
+ const ib = this.w.InboundGroupSession.from_pickle(rec.pickle, this.key);
679
+ keys.push({ conversationId: rec.conversationId, sessionId: ib.session_id, senderUserId: rec.senderUserId, senderDeviceId: rec.senderDeviceId, exported: ib.export() });
680
+ ib.free();
681
+ }
682
+ const me = { userId: this.userId, deviceId: this.dev.deviceId, ed25519: this.ed };
683
+ // at least one message, so the new device hears back even when there's nothing to forward
684
+ for (let i = 0; i === 0 || i < keys.length; i += KEYS_PER_MESSAGE) {
685
+ await this.sendOlm([d], () => ({ t: 'yan.forwarded_room_keys', requestId, keys: keys.slice(i, i + KEYS_PER_MESSAGE), sender: me, recipient: this.party(d) }));
686
+ }
687
+ r.state = 'sent';
688
+ await this.store.set(key, JSON.stringify(r));
689
+ return keys.length;
690
+ });
691
+ }
692
+ /** Not yours, or you don't want it to read your history: nothing is sent. */
693
+ denyHistory(requestId) {
694
+ return this.lock(async () => {
695
+ const key = `history:in:${requestId}`;
696
+ const raw = await this.store.get(key);
697
+ if (!raw)
698
+ return;
699
+ await this.store.set(key, JSON.stringify({ ...JSON.parse(raw), state: 'denied' }));
700
+ });
701
+ }
702
+ async history(m, me, sender, from) {
703
+ const requestId = typeof m['requestId'] === 'string' ? m['requestId'] : '';
704
+ if (!/^h_[A-Za-z0-9+/]{16}$/.test(requestId))
705
+ throw new Error('bad request id');
706
+ switch (m.t) {
707
+ case 'yan.history_request': {
708
+ // an existing device: a new one of yours asks; answer with our nonce, then wait for theirs
709
+ if (!same(m['requester'], sender))
710
+ throw new Error('requester mismatch');
711
+ const key = `history:in:${requestId}`;
712
+ if (await this.store.get(key))
713
+ return 0;
714
+ const r = { requestId, requester: sender, commitment: String(m['commitment'] ?? ''), nonce: randomB64(16), state: 'challenged', createdAt: new Date().toISOString() };
715
+ await this.store.set(key, JSON.stringify(r));
716
+ await this.sendOlm([from], () => ({ t: 'yan.history_challenge', requestId, nonce: r.nonce, approver: me, recipient: sender }));
717
+ return 0;
718
+ }
719
+ case 'yan.history_challenge': {
720
+ // the new device: an existing one answered; reveal our nonce, and show the code
721
+ if (!same(m['approver'], sender) || !same(m['recipient'], me))
722
+ throw new Error('challenge mismatch');
723
+ const raw = await this.store.get('history:out');
724
+ const out = raw ? JSON.parse(raw) : null;
725
+ if (!out || out.requestId !== requestId || out.codes[sender.deviceId])
726
+ return 0;
727
+ out.codes[sender.deviceId] = this.w.verification_code(verifyText(this.userId, me, sender, String(m['nonce']), out.nonce));
728
+ await this.store.set('history:out', JSON.stringify(out));
729
+ await this.sendOlm([from], () => ({ t: 'yan.history_reveal', requestId, nonce: out.nonce, requester: me, recipient: sender }));
730
+ return 0;
731
+ }
732
+ case 'yan.history_reveal': {
733
+ // an existing device: check the nonce against the commitment; the request now waits for you to allow it
734
+ if (!same(m['requester'], sender) || !same(m['recipient'], me))
735
+ throw new Error('reveal mismatch');
736
+ const key = `history:in:${requestId}`;
737
+ const raw = await this.store.get(key);
738
+ const r = raw ? JSON.parse(raw) : null;
739
+ if (!r || r.state !== 'challenged' || !same(r.requester, sender))
740
+ return 0;
741
+ const n2 = String(m['nonce'] ?? '');
742
+ if (this.w.sha256_base64(n2) !== r.commitment) {
743
+ r.state = 'denied';
744
+ await this.store.set(key, JSON.stringify(r));
745
+ throw new Error('the nonce does not match its commitment');
746
+ }
747
+ r.code = this.w.verification_code(verifyText(this.userId, sender, me, r.nonce, n2));
748
+ r.state = 'ready';
749
+ await this.store.set(key, JSON.stringify(r));
750
+ return 0;
751
+ }
752
+ case 'yan.forwarded_room_keys': {
753
+ // the new device: only from a device we agreed a code with, for our request
754
+ if (!same(m['sender'], sender) || !same(m['recipient'], me))
755
+ throw new Error('forward mismatch');
756
+ const raw = await this.store.get('history:out');
757
+ const out = raw ? JSON.parse(raw) : null;
758
+ if (!out || out.requestId !== requestId || !out.codes[sender.deviceId])
759
+ throw new Error('keys nobody asked for');
760
+ let added = 0;
761
+ for (const k of m['keys'] ?? []) {
762
+ let ib = null;
763
+ try {
764
+ ib = this.w.InboundGroupSession.import(k.exported);
765
+ if (ib.session_id !== k.sessionId)
766
+ continue;
767
+ added += await this.keep(ib, k.conversationId, k.senderUserId, k.senderDeviceId);
768
+ }
769
+ catch (e) {
770
+ this.log(`forwarded key ${k.sessionId}: ${e.message}`);
771
+ }
772
+ finally {
773
+ ib?.free();
774
+ }
775
+ }
776
+ out.received += added;
777
+ out.done = true;
778
+ await this.store.set('history:out', JSON.stringify(out));
779
+ return added;
780
+ }
781
+ }
782
+ return 0;
783
+ }
552
784
  /** Decrypts a message; when its key hasn't arrived, reads the inbox once and tries again. */
553
785
  decrypt(msg) {
554
786
  return this.lock(async () => {
@@ -586,6 +818,11 @@ class E2eeMachine {
586
818
  });
587
819
  }
588
820
  }
821
+ function randomB64(bytes) {
822
+ const b = new Uint8Array(bytes);
823
+ crypto.getRandomValues(b);
824
+ return btoa(String.fromCharCode(...b)).replace(/=+$/, '');
825
+ }
589
826
  /** web_ + 16 random hex characters. */
590
827
  function randomDeviceId() {
591
828
  const b = new Uint8Array(8);
@@ -629,6 +866,10 @@ function idbStore(userId) {
629
866
  get: (key) => req(KV, 'readonly', (s) => s.get(k(key))),
630
867
  set: (key, value) => req(KV, 'readwrite', (s) => s.put(value, k(key))),
631
868
  del: (key) => req(KV, 'readwrite', (s) => s.delete(k(key))),
869
+ keys: async (prefix) => {
870
+ const all = await req(KV, 'readonly', (s) => s.getAllKeys(IDBKeyRange.bound(k(prefix), k(prefix) + '\uffff')));
871
+ return all.map((x) => String(x).slice(userId.length + 1));
872
+ },
632
873
  };
633
874
  }
634
875
  /** This person's pickle key on this browser, made on first use. */
@@ -840,6 +1081,10 @@ class MessagingApi {
840
1081
  uploadKeys(deviceId, body) {
841
1082
  return this.send('POST', `/devices/${encodeURIComponent(deviceId)}/keys`, body);
842
1083
  }
1084
+ /** Signs this device out of messaging: its keys and inbox go from the server. */
1085
+ async revokeDevice(deviceId) {
1086
+ await this.send('DELETE', `/devices/${encodeURIComponent(deviceId)}`);
1087
+ }
843
1088
  async keyCount(deviceId) {
844
1089
  try {
845
1090
  return await this.get(`/devices/${encodeURIComponent(deviceId)}/keys/count`);
@@ -902,8 +1147,12 @@ class MessagesStore {
902
1147
  /** conversation id → who is typing there */
903
1148
  this.typing = signal({}, ...(ngDevMode ? [{ debugName: "typing" }] : /* istanbul ignore next */ []));
904
1149
  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 */ []));
1150
+ /** New devices of yours asking, here, for your message history. */
1151
+ this.approvals = signal([], ...(ngDevMode ? [{ debugName: "approvals" }] : /* istanbul ignore next */ []));
1152
+ /** This device's request for history (when it can't read older messages). */
1153
+ this.history = signal({ state: 'none', codes: [], received: 0 }, ...(ngDevMode ? [{ debugName: "history" }] : /* istanbul ignore next */ []));
1154
+ /** For the nav badge: unread conversations, waiting requests, and devices asking for your history. */
1155
+ this.badge = computed(() => this.inbox().reduce((n, c) => n + (c.unread > 0 ? 1 : 0), 0) + this.requests().length + this.approvals().length, ...(ngDevMode ? [{ debugName: "badge" }] : /* istanbul ignore next */ []));
907
1156
  this.threads = new Map();
908
1157
  this.machine = null;
909
1158
  this.machineUser = null;
@@ -941,7 +1190,33 @@ class MessagesStore {
941
1190
  if (this.machineUser && this.machineUser !== me)
942
1191
  this.stop();
943
1192
  void this.loadLists();
944
- void this.ensureMachine().then(() => this.openSocket(), () => undefined);
1193
+ void this.ensureMachine().then(async (m) => {
1194
+ this.openSocket();
1195
+ // what arrived while this device was away (room keys, history requests)
1196
+ await m.sync().catch(() => 0);
1197
+ await this.refreshHistory();
1198
+ }, () => undefined);
1199
+ }
1200
+ /**
1201
+ * Signing out on purpose: this device leaves messaging (the server drops its keys and inbox) and the browser forgets
1202
+ * its keys, so nobody using it next can read your messages. Signing in again makes a new device, which reads older
1203
+ * messages once you allow it from another device.
1204
+ */
1205
+ async signOutDevice() {
1206
+ const me = this.me();
1207
+ if (!me)
1208
+ return;
1209
+ this.stop();
1210
+ try {
1211
+ const raw = await idbStore(me).get('device');
1212
+ const id = raw ? JSON.parse(raw).deviceId : undefined;
1213
+ if (id)
1214
+ await this.api.revokeDevice(id).catch(() => undefined);
1215
+ await forget(me);
1216
+ }
1217
+ catch {
1218
+ // storage unavailable: nothing was kept here
1219
+ }
945
1220
  }
946
1221
  /** Signing out, or another person signing in. */
947
1222
  stop() {
@@ -954,6 +1229,8 @@ class MessagesStore {
954
1229
  this.inbox.set([]);
955
1230
  this.requests.set([]);
956
1231
  this.previews.set({});
1232
+ this.approvals.set([]);
1233
+ this.history.set({ state: 'none', codes: [], received: 0 });
957
1234
  this.loaded.set(false);
958
1235
  }
959
1236
  /** This device's encryption: loads the wasm module, then opens (or makes and publishes) the device's keys. */
@@ -1074,6 +1351,8 @@ class MessagesStore {
1074
1351
  const items = await this.open(page.items.slice().reverse());
1075
1352
  t.set({ conv, items: this.keepPending(t().items, items), next: page.next, loading: false, failed: false, missing: false });
1076
1353
  this.markRead(conv, items);
1354
+ if (items.some((b) => b.unreadable === 'missing-key'))
1355
+ void this.askForHistory();
1077
1356
  }
1078
1357
  catch (e) {
1079
1358
  const status = e.status;
@@ -1216,6 +1495,35 @@ class MessagesStore {
1216
1495
  this.requests.update((l) => l.filter((x) => x.id !== id));
1217
1496
  this.threads.delete(id);
1218
1497
  }
1498
+ // ---- history for a new device ----
1499
+ /** Asks your other devices for older messages' keys, once per page load unless you ask again. */
1500
+ async askForHistory(again = false) {
1501
+ const m = await this.machine?.catch(() => null);
1502
+ if (!m)
1503
+ return;
1504
+ const st = await m.historyStatus();
1505
+ if (!again && st.state !== 'none')
1506
+ return void this.history.set(st);
1507
+ await m.requestHistory().catch(() => false);
1508
+ await this.refreshHistory();
1509
+ }
1510
+ async approve(requestId) {
1511
+ const m = await this.ensureMachine();
1512
+ await m.approveHistory(requestId);
1513
+ await this.refreshHistory();
1514
+ }
1515
+ async deny(requestId) {
1516
+ const m = await this.ensureMachine();
1517
+ await m.denyHistory(requestId);
1518
+ await this.refreshHistory();
1519
+ }
1520
+ async refreshHistory() {
1521
+ const m = await this.machine?.catch(() => null);
1522
+ if (!m)
1523
+ return;
1524
+ this.approvals.set(await m.pendingApprovals().catch(() => []));
1525
+ this.history.set(await m.historyStatus().catch(() => this.history()));
1526
+ }
1219
1527
  // ---- typing ----
1220
1528
  typed(id) {
1221
1529
  const now = Date.now();
@@ -1293,6 +1601,7 @@ class MessagesStore {
1293
1601
  if (!m)
1294
1602
  return;
1295
1603
  await m.sync().catch(() => 0);
1604
+ await this.refreshHistory();
1296
1605
  for (const t of this.threads.values()) {
1297
1606
  if (!t().items.some((b) => b.state === 'unreadable'))
1298
1607
  continue;
@@ -1354,4 +1663,4 @@ function randomHex(n) {
1354
1663
  */
1355
1664
 
1356
1665
  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-BYCxC81Q.mjs.map
1666
+ //# sourceMappingURL=chisquare-tech-yan-ngx-apps-messages-chisquare-tech-yan-ngx-apps-messages-BBsDA0CG.mjs.map