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.
@@ -11,7 +11,8 @@ import {
11
11
  createRatchetedMessage,
12
12
  createSealedMessage,
13
13
  createKeyUpdate,
14
- createChangeRoom,
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
- #roomSecrets = null; // active private-room secrets { room, authSecretKey, roomKey, … }
118
- #pendingRoomSecrets = null; // derived while joining/creating, promoted on ROOM_CHANGED
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
- this.#currentRoom = msg.room || 'general';
492
- this.#ui.setRoom(this.#currentRoom);
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.#peers) {
516
+ for (const [sid, peer] of this.#allPeers) {
506
517
  oldSessionByNick.set(peer.nickname.toLowerCase(), sid);
507
518
  }
508
- this.#peers.clear();
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.#peers.set(peer.sessionId, {
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(createChangeRoom(this.#inviteRoom));
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.#roomSecrets ? undefined : this.#currentRoom,
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.#peers.set(peer.sessionId, {
565
- nickname: peer.nickname,
566
- publicKey: peer.publicKey,
567
- });
568
- this.#handshake.registerPeer(peer.sessionId, peer.publicKey);
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
- this.#ui.setOnlineCount(this.#peers.size + 1);
572
- this.#ui.setPeerNames([...this.#peers.values()].map((p) => p.nickname));
573
- this.#ui.handshakeConnect(peer.nickname);
574
- this.#nudgeVerify(peer.nickname);
575
- this.#auditLog.log(AuditEvent.PEER_CONNECTED, { nickname: peer.nickname });
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 peer = this.#peers.get(msg.sessionId);
598
- const nickname = peer?.nickname || msg.nickname || 'Unknown';
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
- this.#hidePeerTyping(msg.sessionId, nickname);
601
- this.#handshake.removePeer(msg.sessionId);
602
- this.#nonceManager.removePeer(msg.sessionId);
603
- this.#peers.delete(msg.sessionId);
782
+ if (entry && room) {
783
+ entry.rooms.delete(room);
784
+ }
785
+ const goneEntirely = !entry || !room || entry.rooms.size === 0;
604
786
 
605
- this.#ui.setOnlineCount(this.#peers.size + 1);
606
- this.#ui.setPeerNames([...this.#peers.values()].map((p) => p.nickname));
607
- this.#ui.handshakeDisconnect(nickname);
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
- const peer = this.#peers.get(msg.from);
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: unwrap with the room key. Content we can't read
719
- // (no key, or stale key after a room switch) is dropped silently.
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
- if (!this.#roomSecrets) {
722
- return;
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.clearChat();
945
+ this.#ui.clearBuffer(msgRoom);
738
946
  return;
739
947
  }
740
948
 
741
949
  if (data.action === 'typing') {
742
- this.#showPeerTyping(msg.from, peer.nickname);
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
- const p = this.#peers.get(msg.from);
875
- if (p) {
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.addSystemMessage(`${data.emoji} ${peer.nickname} reacted to a message`);
897
- this.#ui.playNotification();
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.addSystemMessage(`${peer.nickname} edited: ${data.newText} (edited)`);
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.addSystemMessage(`${peer.nickname} deleted a message`);
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.push({
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.addSystemMessage(
926
- `\uD83D\uDCCC ${peer.nickname} pinned: "${data.text}" \u2014 ${data.nickname}`,
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
- this.#pinnedMessages = this.#pinnedMessages.filter((p) => p.messageId !== data.messageId);
933
- this.#ui.addSystemMessage(`${peer.nickname} removed a pin`);
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: this.#currentRoom,
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: this.#currentRoom,
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
- const { lineIndex } = this.#ui.addMessage(
985
- peer.nickname,
986
- data.text,
987
- !!data.isDM,
988
- ephLabel,
989
- isDeniable || !!data.deniable,
990
- mentioned,
991
- trust,
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
- if (data.ephemeral && data.ephemeral > 0) {
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 (password if private)');
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(createChangeRoom(roomName));
1602
+ this.#connection.send(createJoinRoom(roomName));
1352
1603
  });
1353
1604
  } else {
1354
- this.#connection.send(createChangeRoom(roomName));
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(createChangeRoom(roomName));
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.#roomSecrets ? ' 🔒 (private)' : ''}`,
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
- // ── Handle ROOM_CHANGED (after /join) ──────────────────────
2338
- #onRoomChanged(msg) {
2339
- this.#currentRoom = msg.room;
2340
- this.#ui.setRoom(this.#currentRoom);
2341
- this.#currentRoomOwner = msg.roomOwner || null;
2342
-
2343
- // Rotate private-room secrets: drop the old room's, promote the pending
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: this server does not support private rooms — the room is PUBLIC and anyone can join.',
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
- if (this.#roomSecrets) {
2365
- this.#ui.setHeaderIndicator('private', '{green-fg}[🔒]{/green-fg}');
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.removeHeaderIndicator('private');
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
- // Clear old peers and pins
2371
- this.#peers.clear();
2372
- this.#pinnedMessages = [];
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.#peers.set(peer.sessionId, {
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
- const peerNames = [...this.#peers.values()].map((p) => p.nickname);
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
- if (this.#roomSecrets) {
2394
- this.#ui.addSystemMessage(`You joined private room #${msg.room} 🔒`);
2395
- this.#ui.addInfoMessage('Messages here get an extra layer encrypted with the room key.');
2396
- } else {
2397
- this.#ui.addSystemMessage(`You joined room #${msg.room}`);
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
- if (peerNames.length > 0) {
2401
- this.#ui.addSystemMessage(`Online: ${peerNames.join(', ')}`);
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
- // The new room doesn't know my presence
2405
- if (this.#away || this.#statusText) {
2406
- this.#broadcastPresence();
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.#saveLastSession(!!msg.private);
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
- // Private room: extra symmetric layer under the pairwise encryption.
2534
- if (this.#roomSecrets) {
2535
- payload = encryptRoomPayload(payload, this.#roomSecrets.roomKey);
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
- // Private room: extra symmetric layer under the pairwise encryption, so
2656
- // even a relay-injected member can't read the room without the password.
2657
- if (this.#roomSecrets) {
2658
- payload = encryptRoomPayload(payload, this.#roomSecrets.roomKey);
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) {