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