ciphermesh 2.1.0 → 2.3.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.
- package/README.md +16 -2
- package/README.pt-BR.md +16 -2
- package/bin/server-binary.js +7 -0
- package/docs/ARCHITECTURE.md +40 -1
- package/package.json +4 -3
- package/src/client/ChatController.js +499 -145
- package/src/client/UI.js +135 -3
- package/src/crypto/DoubleRatchet.js +70 -3
- package/src/crypto/Handshake.js +44 -3
- package/src/crypto/KeyManager.js +27 -0
- package/src/crypto/PQHybrid.js +76 -0
- package/src/protocol/messages.js +66 -17
- package/src/protocol/validators.js +46 -2
- package/src/server/SessionManager.js +101 -15
- package/src/server/WebSocketServer.js +227 -53
|
@@ -11,7 +11,8 @@ import {
|
|
|
11
11
|
createRatchetedMessage,
|
|
12
12
|
createSealedMessage,
|
|
13
13
|
createKeyUpdate,
|
|
14
|
-
|
|
14
|
+
createJoinRoom,
|
|
15
|
+
createLeaveRoom,
|
|
15
16
|
createRoomAuth,
|
|
16
17
|
createListRooms,
|
|
17
18
|
createKickPeer,
|
|
@@ -114,8 +115,12 @@ export class ChatController {
|
|
|
114
115
|
#awayMentions = 0; // …of which mentioned me
|
|
115
116
|
#autoLockMs = 0; // idle screen-lock timeout (0 = off)
|
|
116
117
|
#autoLockTimer = null;
|
|
117
|
-
|
|
118
|
-
|
|
118
|
+
// Multi-room buffers (IRC style): lines live in the UI; membership, unread
|
|
119
|
+
// counters and private-room secrets live here, one entry per joined room.
|
|
120
|
+
#buffers = new Map(); // room → { unread, mentions, private, owner, secrets, pins }
|
|
121
|
+
#bufferOrder = []; // Alt+1..9 order
|
|
122
|
+
#allPeers = new Map(); // sessionId → { nickname, publicKey, rooms: Set } (all my rooms)
|
|
123
|
+
#pendingRoomSecrets = null; // derived while joining/creating, promoted on join
|
|
119
124
|
|
|
120
125
|
constructor(
|
|
121
126
|
nickname,
|
|
@@ -201,7 +206,9 @@ export class ChatController {
|
|
|
201
206
|
// attached the listener).
|
|
202
207
|
#onConnected() {
|
|
203
208
|
this.#ui.setConnectionState('online');
|
|
204
|
-
this.#connection.send(
|
|
209
|
+
this.#connection.send(
|
|
210
|
+
createJoin(this.#nickname, this.#keyManager.publicKeyB64, this.#keyManager.pqPublicKeyB64),
|
|
211
|
+
);
|
|
205
212
|
}
|
|
206
213
|
|
|
207
214
|
// ── Connection event handlers ─────────────────────────────────
|
|
@@ -267,6 +274,13 @@ export class ChatController {
|
|
|
267
274
|
this.#ui.on('lock-failed', () => {
|
|
268
275
|
this.#auditLog.log(AuditEvent.SCREEN_UNLOCK_FAILED, {});
|
|
269
276
|
});
|
|
277
|
+
|
|
278
|
+
this.#ui.on('buffer-switch', (idx) => {
|
|
279
|
+
const room = this.#bufferOrder[idx];
|
|
280
|
+
if (room) {
|
|
281
|
+
this.#switchToBuffer(room);
|
|
282
|
+
}
|
|
283
|
+
});
|
|
270
284
|
}
|
|
271
285
|
|
|
272
286
|
// ── Auto-away (idle) ────────────────────────────────────────
|
|
@@ -423,6 +437,14 @@ export class ChatController {
|
|
|
423
437
|
this.#onRoomChanged(msg);
|
|
424
438
|
break;
|
|
425
439
|
|
|
440
|
+
case MSG.ROOM_JOINED:
|
|
441
|
+
this.#onRoomJoined(msg);
|
|
442
|
+
break;
|
|
443
|
+
|
|
444
|
+
case MSG.ROOM_LEFT:
|
|
445
|
+
this.#onRoomLeft(msg);
|
|
446
|
+
break;
|
|
447
|
+
|
|
426
448
|
case MSG.ROOM_CHALLENGE:
|
|
427
449
|
this.#onRoomChallenge(msg);
|
|
428
450
|
break;
|
|
@@ -488,29 +510,27 @@ export class ChatController {
|
|
|
488
510
|
// ── JOIN_ACK: registered with server ──────────────────────────
|
|
489
511
|
#onJoinAck(msg) {
|
|
490
512
|
this.#sessionId = msg.sessionId;
|
|
491
|
-
|
|
492
|
-
this.#
|
|
493
|
-
this.#currentRoomOwner = msg.roomOwner || null;
|
|
494
|
-
|
|
495
|
-
// (Re)joining always lands in a public room — drop any private-room keys.
|
|
496
|
-
if (this.#roomSecrets && this.#roomSecrets.room !== this.#currentRoom) {
|
|
497
|
-
freeRoomSecrets(this.#roomSecrets);
|
|
498
|
-
this.#roomSecrets = null;
|
|
499
|
-
this.#ui.removeHeaderIndicator('private');
|
|
500
|
-
this.#ui.addInfoMessage('Reconnected outside the private room — /join it again.');
|
|
501
|
-
}
|
|
513
|
+
const room = msg.room || 'general';
|
|
514
|
+
const hadPrivateBuffers = [...this.#buffers.values()].some((b) => b.secrets);
|
|
502
515
|
|
|
503
516
|
// Build map of old sessionIds by nickname for ratchet migration
|
|
504
517
|
const oldSessionByNick = new Map();
|
|
505
|
-
for (const [sid, peer] of this.#
|
|
518
|
+
for (const [sid, peer] of this.#allPeers) {
|
|
506
519
|
oldSessionByNick.set(peer.nickname.toLowerCase(), sid);
|
|
507
520
|
}
|
|
508
|
-
|
|
521
|
+
|
|
522
|
+
// (Re)connecting always starts over in a single public room.
|
|
523
|
+
this.#resetBuffersTo(room);
|
|
524
|
+
this.#currentRoomOwner = msg.roomOwner || null;
|
|
525
|
+
if (hadPrivateBuffers) {
|
|
526
|
+
this.#ui.addInfoMessage('Reconnected outside your private room(s) — /join them again.');
|
|
527
|
+
}
|
|
509
528
|
|
|
510
529
|
for (const peer of msg.peers) {
|
|
511
|
-
this.#
|
|
530
|
+
this.#allPeers.set(peer.sessionId, {
|
|
512
531
|
nickname: peer.nickname,
|
|
513
532
|
publicKey: peer.publicKey,
|
|
533
|
+
rooms: new Set([room]),
|
|
514
534
|
});
|
|
515
535
|
|
|
516
536
|
const oldSid = oldSessionByNick.get(peer.nickname.toLowerCase());
|
|
@@ -518,7 +538,7 @@ export class ChatController {
|
|
|
518
538
|
// Migrate ratchet from old sessionId to new sessionId
|
|
519
539
|
this.#handshake.migrateRatchet(oldSid, peer.sessionId);
|
|
520
540
|
} else if (!oldSid) {
|
|
521
|
-
this.#handshake.registerPeer(peer.sessionId, peer.publicKey);
|
|
541
|
+
this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
|
|
522
542
|
}
|
|
523
543
|
|
|
524
544
|
this.#checkTrust(peer.nickname, peer.publicKey);
|
|
@@ -527,9 +547,8 @@ export class ChatController {
|
|
|
527
547
|
// Initialize ratchets now that we have our session ID
|
|
528
548
|
this.#handshake.setMySessionId(msg.sessionId);
|
|
529
549
|
|
|
550
|
+
this.#rebuildActivePeers();
|
|
530
551
|
const peerNames = [...this.#peers.values()].map((p) => p.nickname);
|
|
531
|
-
this.#ui.setOnlineCount(this.#peers.size + 1);
|
|
532
|
-
this.#ui.setPeerNames(peerNames);
|
|
533
552
|
this.#ui.addSystemMessage('Connected to server with E2E encryption active');
|
|
534
553
|
|
|
535
554
|
if (peerNames.length > 0) {
|
|
@@ -542,7 +561,7 @@ export class ChatController {
|
|
|
542
561
|
|
|
543
562
|
// Invite included a room — join it once after the first connect
|
|
544
563
|
if (this.#inviteRoom && this.#inviteRoom !== this.#currentRoom) {
|
|
545
|
-
this.#connection.send(
|
|
564
|
+
this.#connection.send(createJoinRoom(this.#inviteRoom));
|
|
546
565
|
this.#inviteRoom = null;
|
|
547
566
|
}
|
|
548
567
|
|
|
@@ -554,25 +573,189 @@ export class ChatController {
|
|
|
554
573
|
#saveLastSession(isPrivate = false) {
|
|
555
574
|
saveLastSession({
|
|
556
575
|
server: (this.#connection.url || '').replace(/^wss?:\/\//, ''),
|
|
557
|
-
room: isPrivate || this.#
|
|
576
|
+
room: isPrivate || this.#activeSecrets ? undefined : this.#currentRoom,
|
|
558
577
|
});
|
|
559
578
|
}
|
|
560
579
|
|
|
580
|
+
// ── Multi-room buffer plumbing ───────────────────────────────
|
|
581
|
+
|
|
582
|
+
get #activeSecrets() {
|
|
583
|
+
return this.#buffers.get(this.#currentRoom)?.secrets || null;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
#ensureBuffer(room, { isPrivate = false, owner = null, secrets = null } = {}) {
|
|
587
|
+
if (!this.#buffers.has(room)) {
|
|
588
|
+
this.#buffers.set(room, {
|
|
589
|
+
unread: 0,
|
|
590
|
+
mentions: 0,
|
|
591
|
+
private: isPrivate,
|
|
592
|
+
owner,
|
|
593
|
+
secrets,
|
|
594
|
+
pins: [],
|
|
595
|
+
});
|
|
596
|
+
this.#bufferOrder.push(room);
|
|
597
|
+
}
|
|
598
|
+
return this.#buffers.get(room);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
#dropBufferState(room) {
|
|
602
|
+
const buf = this.#buffers.get(room);
|
|
603
|
+
if (buf) {
|
|
604
|
+
freeRoomSecrets(buf.secrets);
|
|
605
|
+
this.#buffers.delete(room);
|
|
606
|
+
}
|
|
607
|
+
this.#bufferOrder = this.#bufferOrder.filter((r) => r !== room);
|
|
608
|
+
this.#ui.dropBuffer(room);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Forget every buffer and exist only in `room` (connect, reconnect, or a
|
|
612
|
+
// legacy full switch — including being kicked).
|
|
613
|
+
#resetBuffersTo(room, opts = {}) {
|
|
614
|
+
for (const buf of this.#buffers.values()) {
|
|
615
|
+
freeRoomSecrets(buf.secrets);
|
|
616
|
+
}
|
|
617
|
+
this.#buffers.clear();
|
|
618
|
+
this.#bufferOrder = [];
|
|
619
|
+
this.#allPeers.clear();
|
|
620
|
+
this.#peers.clear();
|
|
621
|
+
this.#currentRoom = room;
|
|
622
|
+
const buf = this.#ensureBuffer(room, opts);
|
|
623
|
+
this.#pinnedMessages = buf.pins;
|
|
624
|
+
this.#currentRoomOwner = buf.owner;
|
|
625
|
+
this.#ui.resetBuffers(room);
|
|
626
|
+
this.#updateBufferBar();
|
|
627
|
+
this.#updatePrivateIndicator();
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
#switchToBuffer(room) {
|
|
631
|
+
if (room === this.#currentRoom || !this.#buffers.has(room)) {
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
// Sync the active-view aliases back before leaving the buffer.
|
|
635
|
+
const cur = this.#buffers.get(this.#currentRoom);
|
|
636
|
+
if (cur) {
|
|
637
|
+
cur.pins = this.#pinnedMessages;
|
|
638
|
+
cur.owner = this.#currentRoomOwner;
|
|
639
|
+
}
|
|
640
|
+
this.#currentRoom = room;
|
|
641
|
+
const buf = this.#buffers.get(room);
|
|
642
|
+
buf.unread = 0;
|
|
643
|
+
buf.mentions = 0;
|
|
644
|
+
this.#pinnedMessages = buf.pins;
|
|
645
|
+
this.#currentRoomOwner = buf.owner;
|
|
646
|
+
this.#ui.switchBuffer(room);
|
|
647
|
+
this.#rebuildActivePeers();
|
|
648
|
+
this.#updateBufferBar();
|
|
649
|
+
this.#updatePrivateIndicator();
|
|
650
|
+
this.#saveLastSession(buf.private);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// #peers is always "the active room's peers" so every send path stays
|
|
654
|
+
// room-scoped without changes. Rebuilt from the global map on switches.
|
|
655
|
+
#rebuildActivePeers() {
|
|
656
|
+
this.#peers.clear();
|
|
657
|
+
for (const [sid, p] of this.#allPeers) {
|
|
658
|
+
if (p.rooms.has(this.#currentRoom)) {
|
|
659
|
+
this.#peers.set(sid, { nickname: p.nickname, publicKey: p.publicKey });
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
this.#ui.setOnlineCount(this.#peers.size + 1);
|
|
663
|
+
this.#ui.setPeerNames([...this.#peers.values()].map((p) => p.nickname));
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
#updateBufferBar() {
|
|
667
|
+
this.#ui.setBufferBar(
|
|
668
|
+
this.#bufferOrder.map((room) => {
|
|
669
|
+
const b = this.#buffers.get(room);
|
|
670
|
+
return {
|
|
671
|
+
room,
|
|
672
|
+
active: room === this.#currentRoom,
|
|
673
|
+
unread: b?.unread || 0,
|
|
674
|
+
private: !!b?.private,
|
|
675
|
+
};
|
|
676
|
+
}),
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
#updatePrivateIndicator() {
|
|
681
|
+
if (this.#activeSecrets) {
|
|
682
|
+
this.#ui.setHeaderIndicator('private', '{green-fg}[🔒]{/green-fg}');
|
|
683
|
+
} else {
|
|
684
|
+
this.#ui.removeHeaderIndicator('private');
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Which buffer an incoming payload belongs to: its own E2EE `room` tag when
|
|
689
|
+
// it names a room we're in; otherwise a room shared with the sender.
|
|
690
|
+
#roomForIncoming(data, fromSid) {
|
|
691
|
+
if (typeof data.room === 'string' && this.#buffers.has(data.room)) {
|
|
692
|
+
return data.room;
|
|
693
|
+
}
|
|
694
|
+
const peer = this.#allPeers.get(fromSid);
|
|
695
|
+
if (peer?.rooms.has(this.#currentRoom)) {
|
|
696
|
+
return this.#currentRoom;
|
|
697
|
+
}
|
|
698
|
+
return peer?.rooms.values().next().value || this.#currentRoom;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Count a message that landed in an inactive buffer.
|
|
702
|
+
#noteBufferUnread(room, mentioned) {
|
|
703
|
+
if (room === this.#currentRoom) {
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
const buf = this.#buffers.get(room);
|
|
707
|
+
if (buf) {
|
|
708
|
+
buf.unread++;
|
|
709
|
+
if (mentioned) {
|
|
710
|
+
buf.mentions++;
|
|
711
|
+
}
|
|
712
|
+
this.#updateBufferBar();
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Tag an outgoing payload with the room it belongs to. Travels INSIDE the
|
|
717
|
+
// E2EE envelope — the relay never sees it.
|
|
718
|
+
#tagRoom(payloadStr) {
|
|
719
|
+
try {
|
|
720
|
+
const obj = JSON.parse(payloadStr);
|
|
721
|
+
if (obj && typeof obj === 'object' && !obj.room) {
|
|
722
|
+
obj.room = this.#currentRoom;
|
|
723
|
+
return JSON.stringify(obj);
|
|
724
|
+
}
|
|
725
|
+
} catch {
|
|
726
|
+
/* not JSON — send as is */
|
|
727
|
+
}
|
|
728
|
+
return payloadStr;
|
|
729
|
+
}
|
|
730
|
+
|
|
561
731
|
// ── New peer arrived ──────────────────────────────────────────
|
|
562
732
|
#onPeerJoined(msg) {
|
|
563
733
|
const { peer } = msg;
|
|
564
|
-
this.#
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
734
|
+
const room = msg.room && this.#buffers.has(msg.room) ? msg.room : this.#currentRoom;
|
|
735
|
+
|
|
736
|
+
const existing = this.#allPeers.get(peer.sessionId);
|
|
737
|
+
if (existing) {
|
|
738
|
+
existing.rooms.add(room);
|
|
739
|
+
} else {
|
|
740
|
+
this.#allPeers.set(peer.sessionId, {
|
|
741
|
+
nickname: peer.nickname,
|
|
742
|
+
publicKey: peer.publicKey,
|
|
743
|
+
rooms: new Set([room]),
|
|
744
|
+
});
|
|
745
|
+
this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
|
|
746
|
+
}
|
|
569
747
|
this.#checkTrust(peer.nickname, peer.publicKey);
|
|
748
|
+
this.#auditLog.log(AuditEvent.PEER_CONNECTED, { nickname: peer.nickname, room });
|
|
570
749
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
750
|
+
if (room === this.#currentRoom) {
|
|
751
|
+
this.#rebuildActivePeers();
|
|
752
|
+
this.#ui.handshakeConnect(peer.nickname);
|
|
753
|
+
this.#nudgeVerify(peer.nickname);
|
|
754
|
+
} else {
|
|
755
|
+
this.#ui.toBuffer(room, () => {
|
|
756
|
+
this.#ui.addSystemMessage(`${peer.nickname} joined #${room}`);
|
|
757
|
+
});
|
|
758
|
+
}
|
|
576
759
|
|
|
577
760
|
// A newcomer doesn't know my presence — send only to them
|
|
578
761
|
if (this.#away || this.#statusText) {
|
|
@@ -592,19 +775,32 @@ export class ChatController {
|
|
|
592
775
|
);
|
|
593
776
|
}
|
|
594
777
|
|
|
595
|
-
// ── Peer left
|
|
778
|
+
// ── Peer left (one room, or entirely when untagged) ──────────
|
|
596
779
|
#onPeerLeft(msg) {
|
|
597
|
-
const
|
|
598
|
-
const nickname =
|
|
780
|
+
const entry = this.#allPeers.get(msg.sessionId);
|
|
781
|
+
const nickname = entry?.nickname || msg.nickname || 'Unknown';
|
|
782
|
+
const room = msg.room && this.#buffers.has(msg.room) ? msg.room : null;
|
|
599
783
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
784
|
+
if (entry && room) {
|
|
785
|
+
entry.rooms.delete(room);
|
|
786
|
+
}
|
|
787
|
+
const goneEntirely = !entry || !room || entry.rooms.size === 0;
|
|
604
788
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
789
|
+
if (goneEntirely) {
|
|
790
|
+
this.#hidePeerTyping(msg.sessionId, nickname);
|
|
791
|
+
this.#handshake.removePeer(msg.sessionId);
|
|
792
|
+
this.#nonceManager.removePeer(msg.sessionId);
|
|
793
|
+
this.#allPeers.delete(msg.sessionId);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (!room || room === this.#currentRoom) {
|
|
797
|
+
this.#rebuildActivePeers();
|
|
798
|
+
this.#ui.handshakeDisconnect(nickname);
|
|
799
|
+
} else {
|
|
800
|
+
this.#ui.toBuffer(room, () => {
|
|
801
|
+
this.#ui.addSystemMessage(`${nickname} left #${room}`);
|
|
802
|
+
});
|
|
803
|
+
}
|
|
608
804
|
this.#auditLog.log(AuditEvent.PEER_DISCONNECTED, { nickname });
|
|
609
805
|
}
|
|
610
806
|
|
|
@@ -623,7 +819,8 @@ export class ChatController {
|
|
|
623
819
|
msg = { ...msg, from: opened.from, payload: opened.payload };
|
|
624
820
|
}
|
|
625
821
|
|
|
626
|
-
|
|
822
|
+
// Multi-room: the sender may live in any of my rooms, not just the active one.
|
|
823
|
+
const peer = this.#allPeers.get(msg.from);
|
|
627
824
|
if (!peer) {
|
|
628
825
|
this.#ui.addErrorMessage('Message from unknown peer');
|
|
629
826
|
return;
|
|
@@ -669,6 +866,7 @@ export class ChatController {
|
|
|
669
866
|
ephPub,
|
|
670
867
|
msg.payload.counter,
|
|
671
868
|
msg.payload.previousCounter,
|
|
869
|
+
msg.payload.pqCiphertext ? Buffer.from(msg.payload.pqCiphertext, 'base64') : null,
|
|
672
870
|
);
|
|
673
871
|
}
|
|
674
872
|
|
|
@@ -715,13 +913,22 @@ export class ChatController {
|
|
|
715
913
|
try {
|
|
716
914
|
let data = JSON.parse(plaintext.toString('utf-8'));
|
|
717
915
|
|
|
718
|
-
// Private-room layer:
|
|
719
|
-
// (no key, or stale key
|
|
916
|
+
// Private-room layer: try each private buffer's key (active room first).
|
|
917
|
+
// Content we can't read (no key, or stale key) is dropped silently.
|
|
720
918
|
if (isRoomWrapped(data)) {
|
|
721
|
-
|
|
722
|
-
|
|
919
|
+
let inner = this.#activeSecrets
|
|
920
|
+
? decryptRoomPayload(data, this.#activeSecrets.roomKey)
|
|
921
|
+
: null;
|
|
922
|
+
if (!inner) {
|
|
923
|
+
for (const buf of this.#buffers.values()) {
|
|
924
|
+
if (buf.secrets) {
|
|
925
|
+
inner = decryptRoomPayload(data, buf.secrets.roomKey);
|
|
926
|
+
if (inner) {
|
|
927
|
+
break;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
}
|
|
723
931
|
}
|
|
724
|
-
const inner = decryptRoomPayload(data, this.#roomSecrets.roomKey);
|
|
725
932
|
if (!inner) {
|
|
726
933
|
return;
|
|
727
934
|
}
|
|
@@ -733,18 +940,25 @@ export class ChatController {
|
|
|
733
940
|
return;
|
|
734
941
|
}
|
|
735
942
|
|
|
943
|
+
// Which buffer this belongs to (the tag rides inside the E2EE envelope).
|
|
944
|
+
const msgRoom = this.#roomForIncoming(data, msg.from);
|
|
945
|
+
const roomActive = msgRoom === this.#currentRoom;
|
|
946
|
+
|
|
736
947
|
if (data.action === 'clear') {
|
|
737
|
-
this.#ui.
|
|
948
|
+
this.#ui.clearBuffer(msgRoom);
|
|
738
949
|
return;
|
|
739
950
|
}
|
|
740
951
|
|
|
741
952
|
if (data.action === 'typing') {
|
|
742
|
-
|
|
953
|
+
if (roomActive) {
|
|
954
|
+
this.#showPeerTyping(msg.from, peer.nickname);
|
|
955
|
+
}
|
|
743
956
|
return;
|
|
744
957
|
}
|
|
745
958
|
|
|
746
959
|
if (data.action === 'key_rotation') {
|
|
747
960
|
this.#handshake.updatePeerKey(msg.from, data.newPublicKey);
|
|
961
|
+
peer.publicKey = data.newPublicKey;
|
|
748
962
|
const p = this.#peers.get(msg.from);
|
|
749
963
|
if (p) {
|
|
750
964
|
p.publicKey = data.newPublicKey;
|
|
@@ -871,13 +1085,21 @@ export class ChatController {
|
|
|
871
1085
|
}
|
|
872
1086
|
|
|
873
1087
|
if (data.action === 'presence') {
|
|
874
|
-
|
|
875
|
-
|
|
1088
|
+
// Presence lives on the global peer entry so it survives buffer
|
|
1089
|
+
// switches; the active view (#peers) mirrors it.
|
|
1090
|
+
const p = peer;
|
|
1091
|
+
{
|
|
876
1092
|
const wasAway = !!p.away;
|
|
877
1093
|
const oldStatus = p.status || null;
|
|
878
1094
|
p.away = !!data.away;
|
|
879
1095
|
p.awayReason = typeof data.reason === 'string' ? data.reason.slice(0, 60) : null;
|
|
880
1096
|
p.status = typeof data.status === 'string' ? data.status.slice(0, 60) : null;
|
|
1097
|
+
const view = this.#peers.get(msg.from);
|
|
1098
|
+
if (view) {
|
|
1099
|
+
view.away = p.away;
|
|
1100
|
+
view.awayReason = p.awayReason;
|
|
1101
|
+
view.status = p.status;
|
|
1102
|
+
}
|
|
881
1103
|
|
|
882
1104
|
if (p.away && !wasAway) {
|
|
883
1105
|
const why = p.awayReason ? ` (${p.awayReason})` : '';
|
|
@@ -893,15 +1115,21 @@ export class ChatController {
|
|
|
893
1115
|
}
|
|
894
1116
|
|
|
895
1117
|
if (data.action === 'reaction') {
|
|
896
|
-
this.#ui.
|
|
897
|
-
|
|
1118
|
+
this.#ui.toBuffer(msgRoom, () => {
|
|
1119
|
+
this.#ui.addSystemMessage(`${data.emoji} ${peer.nickname} reacted to a message`);
|
|
1120
|
+
});
|
|
1121
|
+
if (roomActive) {
|
|
1122
|
+
this.#ui.playNotification();
|
|
1123
|
+
}
|
|
898
1124
|
return;
|
|
899
1125
|
}
|
|
900
1126
|
|
|
901
1127
|
if (data.action === 'edit_message') {
|
|
902
1128
|
const author = this.#messageAuthors.get(data.messageId);
|
|
903
1129
|
if (author && author === peer.nickname) {
|
|
904
|
-
this.#ui.
|
|
1130
|
+
this.#ui.toBuffer(msgRoom, () => {
|
|
1131
|
+
this.#ui.addSystemMessage(`${peer.nickname} edited: ${data.newText} (edited)`);
|
|
1132
|
+
});
|
|
905
1133
|
}
|
|
906
1134
|
return;
|
|
907
1135
|
}
|
|
@@ -909,28 +1137,42 @@ export class ChatController {
|
|
|
909
1137
|
if (data.action === 'delete_message') {
|
|
910
1138
|
const author = this.#messageAuthors.get(data.messageId);
|
|
911
1139
|
if (author && author === peer.nickname) {
|
|
912
|
-
this.#ui.
|
|
1140
|
+
this.#ui.toBuffer(msgRoom, () => {
|
|
1141
|
+
this.#ui.addSystemMessage(`${peer.nickname} deleted a message`);
|
|
1142
|
+
});
|
|
913
1143
|
}
|
|
914
1144
|
return;
|
|
915
1145
|
}
|
|
916
1146
|
|
|
917
1147
|
if (data.action === 'pin_message') {
|
|
918
|
-
this.#pinnedMessages.
|
|
1148
|
+
const pins = roomActive ? this.#pinnedMessages : this.#buffers.get(msgRoom)?.pins;
|
|
1149
|
+
pins?.push({
|
|
919
1150
|
messageId: data.messageId,
|
|
920
1151
|
nickname: data.nickname,
|
|
921
1152
|
text: data.text,
|
|
922
1153
|
pinnedBy: peer.nickname,
|
|
923
1154
|
pinnedAt: Date.now(),
|
|
924
1155
|
});
|
|
925
|
-
this.#ui.
|
|
926
|
-
|
|
927
|
-
|
|
1156
|
+
this.#ui.toBuffer(msgRoom, () => {
|
|
1157
|
+
this.#ui.addSystemMessage(
|
|
1158
|
+
`\uD83D\uDCCC ${peer.nickname} pinned: "${data.text}" \u2014 ${data.nickname}`,
|
|
1159
|
+
);
|
|
1160
|
+
});
|
|
928
1161
|
return;
|
|
929
1162
|
}
|
|
930
1163
|
|
|
931
1164
|
if (data.action === 'unpin_message') {
|
|
932
|
-
|
|
933
|
-
|
|
1165
|
+
if (roomActive) {
|
|
1166
|
+
this.#pinnedMessages = this.#pinnedMessages.filter((p) => p.messageId !== data.messageId);
|
|
1167
|
+
} else {
|
|
1168
|
+
const buf = this.#buffers.get(msgRoom);
|
|
1169
|
+
if (buf) {
|
|
1170
|
+
buf.pins = buf.pins.filter((p) => p.messageId !== data.messageId);
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
this.#ui.toBuffer(msgRoom, () => {
|
|
1174
|
+
this.#ui.addSystemMessage(`${peer.nickname} removed a pin`);
|
|
1175
|
+
});
|
|
934
1176
|
return;
|
|
935
1177
|
}
|
|
936
1178
|
|
|
@@ -945,23 +1187,19 @@ export class ChatController {
|
|
|
945
1187
|
// Persist to encrypted history — never ephemeral or deniable messages
|
|
946
1188
|
if (this.#historyStore?.isOpen && !data.ephemeral && !isDeniable && !data.deniable) {
|
|
947
1189
|
this.#historyStore.append({
|
|
948
|
-
room:
|
|
1190
|
+
room: msgRoom,
|
|
949
1191
|
nickname: peer.nickname,
|
|
950
1192
|
text: data.text,
|
|
951
1193
|
isDM: !!data.isDM,
|
|
952
1194
|
});
|
|
953
1195
|
}
|
|
954
1196
|
|
|
955
|
-
if (data.replyTo?.nickname && typeof data.replyTo.excerpt === 'string') {
|
|
956
|
-
this.#ui.addQuoteLine(String(data.replyTo.nickname), data.replyTo.excerpt.slice(0, 80));
|
|
957
|
-
}
|
|
958
|
-
|
|
959
1197
|
const mentioned = this.#mentionsMe(data.text) && !data.isDM;
|
|
960
1198
|
if (mentioned) {
|
|
961
1199
|
this.#mentions.push({
|
|
962
1200
|
nickname: peer.nickname,
|
|
963
1201
|
text: data.text,
|
|
964
|
-
room:
|
|
1202
|
+
room: msgRoom,
|
|
965
1203
|
at: Date.now(),
|
|
966
1204
|
});
|
|
967
1205
|
if (this.#mentions.length > MENTIONS_MAX) {
|
|
@@ -981,21 +1219,31 @@ export class ChatController {
|
|
|
981
1219
|
}
|
|
982
1220
|
const ephLabel = data.ephemeral ? this.#formatDuration(data.ephemeral) : null;
|
|
983
1221
|
const trust = trustBadge(this.#trustStore.getPeerRecord(peer.nickname), peer.publicKey);
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
1222
|
+
// File the message into its buffer (live log when active, stored otherwise).
|
|
1223
|
+
let lineIndex = -1;
|
|
1224
|
+
this.#ui.toBuffer(msgRoom, () => {
|
|
1225
|
+
if (data.replyTo?.nickname && typeof data.replyTo.excerpt === 'string') {
|
|
1226
|
+
this.#ui.addQuoteLine(String(data.replyTo.nickname), data.replyTo.excerpt.slice(0, 80));
|
|
1227
|
+
}
|
|
1228
|
+
({ lineIndex } = this.#ui.addMessage(
|
|
1229
|
+
peer.nickname,
|
|
1230
|
+
data.text,
|
|
1231
|
+
!!data.isDM,
|
|
1232
|
+
ephLabel,
|
|
1233
|
+
isDeniable || !!data.deniable,
|
|
1234
|
+
mentioned,
|
|
1235
|
+
trust,
|
|
1236
|
+
));
|
|
1237
|
+
});
|
|
1238
|
+
this.#noteBufferUnread(msgRoom, mentioned);
|
|
993
1239
|
const notify = shouldNotify(this.#dndMode, this.#dndWindow, nowMinutes(), mentioned);
|
|
994
1240
|
if (notify) {
|
|
995
1241
|
this.#ui.playNotification();
|
|
996
1242
|
}
|
|
997
1243
|
|
|
998
|
-
|
|
1244
|
+
// Ephemeral burn animation only makes sense on the live log; in an
|
|
1245
|
+
// inactive buffer the message simply expires in place.
|
|
1246
|
+
if (data.ephemeral && data.ephemeral > 0 && roomActive) {
|
|
999
1247
|
this.#scheduleEphemeralRemoval(lineIndex, data.ephemeral, peer.nickname);
|
|
1000
1248
|
}
|
|
1001
1249
|
|
|
@@ -1073,7 +1321,8 @@ export class ChatController {
|
|
|
1073
1321
|
this.#ui.addInfoMessage(' /lock - Lock the screen (session passphrase)');
|
|
1074
1322
|
this.#ui.addInfoMessage(' /autolock <min|off> - Auto-lock on inactivity');
|
|
1075
1323
|
this.#ui.addInfoMessage(' /status <text|off> - Set a status (accepts :emoji:)');
|
|
1076
|
-
this.#ui.addInfoMessage(' /join <room> [pass] - Join a room
|
|
1324
|
+
this.#ui.addInfoMessage(' /join <room> [pass] - Join a room as a new buffer (Alt+1..9)');
|
|
1325
|
+
this.#ui.addInfoMessage(' /leave [room] - Leave a room (its buffer closes)');
|
|
1077
1326
|
this.#ui.addInfoMessage(' /create <room> <pass> - Create a private room 🔒');
|
|
1078
1327
|
this.#ui.addInfoMessage(' /invite [host:port] - Generate an invite with QR code');
|
|
1079
1328
|
this.#ui.addInfoMessage(' /rooms - List available rooms');
|
|
@@ -1270,7 +1519,7 @@ export class ChatController {
|
|
|
1270
1519
|
break;
|
|
1271
1520
|
}
|
|
1272
1521
|
this.#ui.addInfoMessage('Trust status:');
|
|
1273
|
-
for (const p of
|
|
1522
|
+
for (const [sid, p] of this.#peers) {
|
|
1274
1523
|
const record = this.#trustStore.getPeerRecord(p.nickname);
|
|
1275
1524
|
let status;
|
|
1276
1525
|
if (!record) {
|
|
@@ -1280,8 +1529,11 @@ export class ChatController {
|
|
|
1280
1529
|
} else {
|
|
1281
1530
|
status = 'trusted (TOFU)';
|
|
1282
1531
|
}
|
|
1283
|
-
this
|
|
1532
|
+
// [PQ] = this session's ratchet root also includes an ML-KEM secret.
|
|
1533
|
+
const pq = this.#handshake.isHybrid(sid) ? ' {green-fg}[PQ]{/green-fg}' : '';
|
|
1534
|
+
this.#ui.addInfoMessage(` ${p.nickname}: ${status}${pq}`);
|
|
1284
1535
|
}
|
|
1536
|
+
this.#ui.addInfoMessage(' [PQ] = hybrid post-quantum session (X25519 + ML-KEM-768)');
|
|
1285
1537
|
break;
|
|
1286
1538
|
}
|
|
1287
1539
|
|
|
@@ -1339,43 +1591,64 @@ export class ChatController {
|
|
|
1339
1591
|
}
|
|
1340
1592
|
|
|
1341
1593
|
case '/join': {
|
|
1342
|
-
const roomName = parts[1];
|
|
1594
|
+
const roomName = parts[1]?.toLowerCase();
|
|
1343
1595
|
if (!roomName) {
|
|
1344
1596
|
this.#ui.addErrorMessage('Usage: /join <room> [password]');
|
|
1345
1597
|
break;
|
|
1346
1598
|
}
|
|
1599
|
+
// Already have that buffer? Just focus it.
|
|
1600
|
+
if (this.#buffers.has(roomName)) {
|
|
1601
|
+
this.#switchToBuffer(roomName);
|
|
1602
|
+
break;
|
|
1603
|
+
}
|
|
1347
1604
|
const joinPassword = parts.slice(2).join(' ');
|
|
1348
1605
|
if (joinPassword) {
|
|
1349
1606
|
// Derive now so we can answer the server's challenge immediately.
|
|
1350
1607
|
this.#prepareRoomSecrets(roomName, joinPassword, () => {
|
|
1351
|
-
this.#connection.send(
|
|
1608
|
+
this.#connection.send(createJoinRoom(roomName));
|
|
1352
1609
|
});
|
|
1353
1610
|
} else {
|
|
1354
|
-
this.#connection.send(
|
|
1611
|
+
this.#connection.send(createJoinRoom(roomName));
|
|
1355
1612
|
}
|
|
1356
1613
|
break;
|
|
1357
1614
|
}
|
|
1358
1615
|
|
|
1359
1616
|
case '/create': {
|
|
1360
|
-
const roomName = parts[1];
|
|
1617
|
+
const roomName = parts[1]?.toLowerCase();
|
|
1361
1618
|
if (!roomName) {
|
|
1362
1619
|
this.#ui.addErrorMessage('Usage: /create <room> <password>');
|
|
1363
1620
|
break;
|
|
1364
1621
|
}
|
|
1622
|
+
if (this.#buffers.has(roomName)) {
|
|
1623
|
+
this.#ui.addErrorMessage(`You are already in #${roomName}`);
|
|
1624
|
+
break;
|
|
1625
|
+
}
|
|
1365
1626
|
const createPassword = parts.slice(2).join(' ');
|
|
1366
1627
|
if (!createPassword) {
|
|
1367
1628
|
// No password — same as joining/creating a public room.
|
|
1368
|
-
this.#connection.send(
|
|
1629
|
+
this.#connection.send(createJoinRoom(roomName));
|
|
1369
1630
|
break;
|
|
1370
1631
|
}
|
|
1371
1632
|
this.#prepareRoomSecrets(roomName, createPassword, (secrets) => {
|
|
1372
|
-
this.#connection.send(
|
|
1373
|
-
createChangeRoom(roomName, secrets.authPublicKey.toString('base64')),
|
|
1374
|
-
);
|
|
1633
|
+
this.#connection.send(createJoinRoom(roomName, secrets.authPublicKey.toString('base64')));
|
|
1375
1634
|
});
|
|
1376
1635
|
break;
|
|
1377
1636
|
}
|
|
1378
1637
|
|
|
1638
|
+
case '/leave': {
|
|
1639
|
+
const target = (parts[1] || this.#currentRoom).toLowerCase();
|
|
1640
|
+
if (!this.#buffers.has(target)) {
|
|
1641
|
+
this.#ui.addErrorMessage(`You are not in #${target}`);
|
|
1642
|
+
break;
|
|
1643
|
+
}
|
|
1644
|
+
if (this.#bufferOrder.length === 1) {
|
|
1645
|
+
this.#ui.addErrorMessage('Cannot leave your last room');
|
|
1646
|
+
break;
|
|
1647
|
+
}
|
|
1648
|
+
this.#connection.send(createLeaveRoom(target));
|
|
1649
|
+
break;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1379
1652
|
case '/rooms':
|
|
1380
1653
|
this.#connection.send(createListRooms());
|
|
1381
1654
|
break;
|
|
@@ -1408,11 +1681,23 @@ export class ChatController {
|
|
|
1408
1681
|
break;
|
|
1409
1682
|
}
|
|
1410
1683
|
|
|
1411
|
-
case '/room':
|
|
1684
|
+
case '/room': {
|
|
1412
1685
|
this.#ui.addInfoMessage(
|
|
1413
|
-
`Current room: #${this.#currentRoom}${this.#
|
|
1686
|
+
`Current room: #${this.#currentRoom}${this.#activeSecrets ? ' 🔒 (private)' : ''}`,
|
|
1414
1687
|
);
|
|
1688
|
+
if (this.#bufferOrder.length > 1) {
|
|
1689
|
+
const list = this.#bufferOrder
|
|
1690
|
+
.map((r, i) => {
|
|
1691
|
+
const b = this.#buffers.get(r);
|
|
1692
|
+
const mark = r === this.#currentRoom ? '*' : ' ';
|
|
1693
|
+
const unread = b?.unread ? ` (${b.unread} unread)` : '';
|
|
1694
|
+
return ` ${mark}${i + 1}. #${r}${b?.private ? ' 🔒' : ''}${unread}`;
|
|
1695
|
+
})
|
|
1696
|
+
.join('\n');
|
|
1697
|
+
this.#ui.addInfoMessage(`Buffers (Alt+1..9):\n${list}`);
|
|
1698
|
+
}
|
|
1415
1699
|
break;
|
|
1700
|
+
}
|
|
1416
1701
|
|
|
1417
1702
|
case '/tips': {
|
|
1418
1703
|
this.#tipIndex = (this.#tipIndex + 1) % TIPS.length;
|
|
@@ -2161,7 +2446,9 @@ export class ChatController {
|
|
|
2161
2446
|
// "nickname taken"): the server still accepts a JOIN on this socket.
|
|
2162
2447
|
this.#nickname = newNick;
|
|
2163
2448
|
this.#ui.setNickname(newNick);
|
|
2164
|
-
this.#connection.send(
|
|
2449
|
+
this.#connection.send(
|
|
2450
|
+
createJoin(newNick, this.#keyManager.publicKeyB64, this.#keyManager.pqPublicKeyB64),
|
|
2451
|
+
);
|
|
2165
2452
|
this.#ui.addSystemMessage(`Trying to join as ${newNick}...`);
|
|
2166
2453
|
break;
|
|
2167
2454
|
}
|
|
@@ -2334,26 +2621,17 @@ export class ChatController {
|
|
|
2334
2621
|
this.#connection.send(createRoomAuth(msg.room, msg.nonce, signature.toString('base64')));
|
|
2335
2622
|
}
|
|
2336
2623
|
|
|
2337
|
-
//
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
this.#
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
// ones when the server confirms the new room is private.
|
|
2345
|
-
if (this.#roomSecrets) {
|
|
2346
|
-
freeRoomSecrets(this.#roomSecrets);
|
|
2347
|
-
this.#roomSecrets = null;
|
|
2348
|
-
}
|
|
2349
|
-
if (this.#pendingRoomSecrets?.room === msg.room) {
|
|
2350
|
-
if (msg.private) {
|
|
2351
|
-
this.#roomSecrets = this.#pendingRoomSecrets;
|
|
2624
|
+
// Promote pending password-derived secrets once the server confirms the
|
|
2625
|
+
// room really is private; warn when it isn't (old server or needless password).
|
|
2626
|
+
#promotePendingSecrets(room, isPrivate) {
|
|
2627
|
+
let secrets = null;
|
|
2628
|
+
if (this.#pendingRoomSecrets?.room === room) {
|
|
2629
|
+
if (isPrivate) {
|
|
2630
|
+
secrets = this.#pendingRoomSecrets;
|
|
2352
2631
|
} else {
|
|
2353
|
-
// Old server without private-room support silently made it public.
|
|
2354
2632
|
freeRoomSecrets(this.#pendingRoomSecrets);
|
|
2355
2633
|
this.#ui.addErrorMessage(
|
|
2356
|
-
'WARNING:
|
|
2634
|
+
'WARNING: the server treated this room as PUBLIC — anyone can join.',
|
|
2357
2635
|
);
|
|
2358
2636
|
}
|
|
2359
2637
|
this.#pendingRoomSecrets = null;
|
|
@@ -2361,52 +2639,119 @@ export class ChatController {
|
|
|
2361
2639
|
freeRoomSecrets(this.#pendingRoomSecrets);
|
|
2362
2640
|
this.#pendingRoomSecrets = null;
|
|
2363
2641
|
}
|
|
2364
|
-
|
|
2365
|
-
|
|
2642
|
+
return secrets;
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
#announceJoinedRoom(room, isPrivate, peerNames) {
|
|
2646
|
+
if (isPrivate) {
|
|
2647
|
+
this.#ui.addSystemMessage(`You joined private room #${room} 🔒`);
|
|
2648
|
+
this.#ui.addInfoMessage('Messages here get an extra layer encrypted with the room key.');
|
|
2366
2649
|
} else {
|
|
2367
|
-
this.#ui.
|
|
2650
|
+
this.#ui.addSystemMessage(`You joined room #${room}`);
|
|
2368
2651
|
}
|
|
2652
|
+
if (peerNames.length > 0) {
|
|
2653
|
+
this.#ui.addSystemMessage(`Online: ${peerNames.join(', ')}`);
|
|
2654
|
+
}
|
|
2655
|
+
if (this.#away || this.#statusText) {
|
|
2656
|
+
this.#broadcastPresence();
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2369
2659
|
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2660
|
+
// ── Handle ROOM_CHANGED (legacy full switch — also how a kick lands) ──
|
|
2661
|
+
// change_room semantics: every buffer is dropped, we exist only in msg.room.
|
|
2662
|
+
#onRoomChanged(msg) {
|
|
2663
|
+
const secrets = this.#promotePendingSecrets(msg.room, !!msg.private);
|
|
2664
|
+
|
|
2665
|
+
this.#resetBuffersTo(msg.room, {
|
|
2666
|
+
isPrivate: !!msg.private,
|
|
2667
|
+
owner: msg.roomOwner || null,
|
|
2668
|
+
secrets,
|
|
2669
|
+
});
|
|
2670
|
+
this.#currentRoomOwner = msg.roomOwner || null;
|
|
2373
2671
|
|
|
2374
|
-
// Populate with new room peers
|
|
2375
2672
|
for (const peer of msg.peers) {
|
|
2376
|
-
this.#
|
|
2673
|
+
this.#allPeers.set(peer.sessionId, {
|
|
2377
2674
|
nickname: peer.nickname,
|
|
2378
2675
|
publicKey: peer.publicKey,
|
|
2676
|
+
rooms: new Set([msg.room]),
|
|
2379
2677
|
});
|
|
2380
|
-
|
|
2381
|
-
// Register ratchet if new peer
|
|
2382
2678
|
if (!this.#handshake.getRatchet(peer.sessionId)) {
|
|
2383
|
-
this.#handshake.registerPeer(peer.sessionId, peer.publicKey);
|
|
2679
|
+
this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
|
|
2384
2680
|
}
|
|
2385
|
-
|
|
2386
2681
|
this.#checkTrust(peer.nickname, peer.publicKey);
|
|
2387
2682
|
}
|
|
2388
2683
|
|
|
2389
|
-
|
|
2390
|
-
this.#ui.setOnlineCount(this.#peers.size + 1);
|
|
2391
|
-
this.#ui.setPeerNames(peerNames);
|
|
2684
|
+
this.#rebuildActivePeers();
|
|
2392
2685
|
this.#auditLog.log(AuditEvent.ROOM_CHANGED, { room: msg.room });
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2686
|
+
this.#announceJoinedRoom(
|
|
2687
|
+
msg.room,
|
|
2688
|
+
!!secrets,
|
|
2689
|
+
[...this.#peers.values()].map((p) => p.nickname),
|
|
2690
|
+
);
|
|
2691
|
+
this.#saveLastSession(!!msg.private);
|
|
2692
|
+
}
|
|
2399
2693
|
|
|
2400
|
-
|
|
2401
|
-
|
|
2694
|
+
// ── Handle ROOM_JOINED (additive join — new buffer, focused) ──
|
|
2695
|
+
#onRoomJoined(msg) {
|
|
2696
|
+
const secrets = this.#promotePendingSecrets(msg.room, !!msg.private);
|
|
2697
|
+
this.#ensureBuffer(msg.room, {
|
|
2698
|
+
isPrivate: !!msg.private,
|
|
2699
|
+
owner: msg.roomOwner || null,
|
|
2700
|
+
secrets,
|
|
2701
|
+
});
|
|
2702
|
+
|
|
2703
|
+
for (const peer of msg.peers || []) {
|
|
2704
|
+
const existing = this.#allPeers.get(peer.sessionId);
|
|
2705
|
+
if (existing) {
|
|
2706
|
+
existing.rooms.add(msg.room);
|
|
2707
|
+
} else {
|
|
2708
|
+
this.#allPeers.set(peer.sessionId, {
|
|
2709
|
+
nickname: peer.nickname,
|
|
2710
|
+
publicKey: peer.publicKey,
|
|
2711
|
+
rooms: new Set([msg.room]),
|
|
2712
|
+
});
|
|
2713
|
+
if (!this.#handshake.getRatchet(peer.sessionId)) {
|
|
2714
|
+
this.#handshake.registerPeer(peer.sessionId, peer.publicKey, peer.pqPublicKey);
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
this.#checkTrust(peer.nickname, peer.publicKey);
|
|
2402
2718
|
}
|
|
2403
2719
|
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2720
|
+
this.#auditLog.log(AuditEvent.ROOM_CHANGED, { room: msg.room, additive: true });
|
|
2721
|
+
this.#switchToBuffer(msg.room);
|
|
2722
|
+
this.#announceJoinedRoom(
|
|
2723
|
+
msg.room,
|
|
2724
|
+
!!secrets,
|
|
2725
|
+
[...this.#peers.values()].map((p) => p.nickname),
|
|
2726
|
+
);
|
|
2727
|
+
this.#updateBufferBar();
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
// ── Handle ROOM_LEFT (we left one room; buffer dies) ─────────
|
|
2731
|
+
#onRoomLeft(msg) {
|
|
2732
|
+
const room = msg.room;
|
|
2733
|
+
const wasActive = room === this.#currentRoom;
|
|
2734
|
+
this.#dropBufferState(room);
|
|
2735
|
+
|
|
2736
|
+
// Peers we only shared that room with are gone for us now.
|
|
2737
|
+
for (const [sid, p] of [...this.#allPeers]) {
|
|
2738
|
+
p.rooms.delete(room);
|
|
2739
|
+
if (p.rooms.size === 0) {
|
|
2740
|
+
this.#hidePeerTyping(sid, p.nickname);
|
|
2741
|
+
this.#handshake.removePeer(sid);
|
|
2742
|
+
this.#nonceManager.removePeer(sid);
|
|
2743
|
+
this.#allPeers.delete(sid);
|
|
2744
|
+
}
|
|
2407
2745
|
}
|
|
2408
2746
|
|
|
2409
|
-
this.#
|
|
2747
|
+
if (wasActive && this.#bufferOrder.length > 0) {
|
|
2748
|
+
this.#switchToBuffer(this.#bufferOrder[0]);
|
|
2749
|
+
} else {
|
|
2750
|
+
this.#rebuildActivePeers();
|
|
2751
|
+
this.#updateBufferBar();
|
|
2752
|
+
}
|
|
2753
|
+
this.#ui.addSystemMessage(`You left #${room}`);
|
|
2754
|
+
this.#auditLog.log(AuditEvent.ROOM_CHANGED, { room, left: true });
|
|
2410
2755
|
}
|
|
2411
2756
|
|
|
2412
2757
|
// ── Handle ROOM_LIST ───────────────────────────────────────
|
|
@@ -2471,6 +2816,11 @@ export class ChatController {
|
|
|
2471
2816
|
if (!tracked) {
|
|
2472
2817
|
return;
|
|
2473
2818
|
}
|
|
2819
|
+
// Line indexes are only valid on the live log — skip the ✓✓ update when
|
|
2820
|
+
// the message's buffer isn't on screen (multi-room v1 limitation).
|
|
2821
|
+
if (tracked.room && tracked.room !== this.#currentRoom) {
|
|
2822
|
+
return;
|
|
2823
|
+
}
|
|
2474
2824
|
|
|
2475
2825
|
let readers = this.#messageReaders.get(messageId);
|
|
2476
2826
|
if (!readers) {
|
|
@@ -2491,7 +2841,7 @@ export class ChatController {
|
|
|
2491
2841
|
if (baseLine === null || baseLine === undefined) {
|
|
2492
2842
|
return;
|
|
2493
2843
|
}
|
|
2494
|
-
this.#sentMessageLines.set(messageId, { lineIndex, baseLine });
|
|
2844
|
+
this.#sentMessageLines.set(messageId, { lineIndex, baseLine, room: this.#currentRoom });
|
|
2495
2845
|
|
|
2496
2846
|
// Bound memory: keep only the most recent 200 tracked messages
|
|
2497
2847
|
if (this.#sentMessageLines.size > 200) {
|
|
@@ -2530,9 +2880,11 @@ export class ChatController {
|
|
|
2530
2880
|
return;
|
|
2531
2881
|
}
|
|
2532
2882
|
|
|
2533
|
-
//
|
|
2534
|
-
|
|
2535
|
-
|
|
2883
|
+
// Tag with the active room (inside the E2EE envelope), then the private
|
|
2884
|
+
// room's extra symmetric layer when there is one.
|
|
2885
|
+
payload = this.#tagRoom(payload);
|
|
2886
|
+
if (this.#activeSecrets) {
|
|
2887
|
+
payload = encryptRoomPayload(payload, this.#activeSecrets.roomKey);
|
|
2536
2888
|
}
|
|
2537
2889
|
|
|
2538
2890
|
const ratchet = this.#handshake.getRatchet(peerId);
|
|
@@ -2652,10 +3004,12 @@ export class ChatController {
|
|
|
2652
3004
|
|
|
2653
3005
|
// ── Broadcast encrypted payload to all peers ───────────────────
|
|
2654
3006
|
#broadcastPayload(payload, deniable = false) {
|
|
2655
|
-
//
|
|
2656
|
-
// even a relay-injected member can't
|
|
2657
|
-
|
|
2658
|
-
|
|
3007
|
+
// Tag with the active room (inside the E2EE envelope), then the private
|
|
3008
|
+
// room's extra symmetric layer, so even a relay-injected member can't
|
|
3009
|
+
// read the room without the password.
|
|
3010
|
+
payload = this.#tagRoom(payload);
|
|
3011
|
+
if (this.#activeSecrets) {
|
|
3012
|
+
payload = encryptRoomPayload(payload, this.#activeSecrets.roomKey);
|
|
2659
3013
|
}
|
|
2660
3014
|
|
|
2661
3015
|
for (const [peerId] of this.#peers) {
|