ciphermesh 2.0.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,9 @@ import {
11
11
  createRatchetedMessage,
12
12
  createSealedMessage,
13
13
  createKeyUpdate,
14
- createChangeRoom,
14
+ createJoinRoom,
15
+ createLeaveRoom,
16
+ createRoomAuth,
15
17
  createListRooms,
16
18
  createKickPeer,
17
19
  createMutePeer,
@@ -19,6 +21,14 @@ import {
19
21
  ERR,
20
22
  } from '../protocol/messages.js';
21
23
  import { sealEnvelope, openEnvelope } from '../crypto/SealedSender.js';
24
+ import {
25
+ deriveRoomSecrets,
26
+ signRoomChallenge,
27
+ encryptRoomPayload,
28
+ decryptRoomPayload,
29
+ isRoomWrapped,
30
+ freeRoomSecrets,
31
+ } from '../crypto/RoomKey.js';
22
32
  import { KEY_ROTATION_INTERVAL_MS, EMOJI_MAP, COVER_CONSTANT_MS } from '../shared/constants.js';
23
33
  import { KeyManager } from '../crypto/KeyManager.js';
24
34
  import { Handshake } from '../crypto/Handshake.js';
@@ -43,10 +53,12 @@ import { setTheme, getThemeName, themeNames } from '../shared/themes.js';
43
53
  import { panicWipe } from '../shared/panic.js';
44
54
  import { farewellBanner } from '../shared/banner.js';
45
55
  import { parseDndWindow, shouldNotify, nowMinutes, mentionsMe } from '../shared/dnd.js';
56
+ import { saveLastSession } from '../shared/lastSession.js';
46
57
  import { COMMANDS } from './UI.js';
47
58
 
48
59
  const TYPING_SEND_INTERVAL = 2000; // debounce: max 1 typing event per 2s
49
60
  const TYPING_EXPIRE_TIMEOUT = 3000; // hide indicator after 3s of silence
61
+ const MENTIONS_MAX = 50; // session mention log cap (memory only, never persisted)
50
62
 
51
63
  export class ChatController {
52
64
  #nickname;
@@ -98,6 +110,17 @@ export class ChatController {
98
110
  #autoAwayMs = 0; // idle timeout in ms (0 = off)
99
111
  #autoAwayTimer = null;
100
112
  #autoAwaySet = false; // whether the current away was set automatically
113
+ #mentions = []; // session mention log: { nickname, text, room, at }
114
+ #awayUnread = 0; // messages received while away
115
+ #awayMentions = 0; // …of which mentioned me
116
+ #autoLockMs = 0; // idle screen-lock timeout (0 = off)
117
+ #autoLockTimer = null;
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
101
124
 
102
125
  constructor(
103
126
  nickname,
@@ -239,6 +262,23 @@ export class ChatController {
239
262
  this.destroy();
240
263
  process.exit(0);
241
264
  });
265
+
266
+ this.#ui.on('unlocked', () => {
267
+ this.#auditLog.log(AuditEvent.SCREEN_UNLOCKED, {});
268
+ this.#ui.addSystemMessage('Screen unlocked');
269
+ this.#noteActive();
270
+ });
271
+
272
+ this.#ui.on('lock-failed', () => {
273
+ this.#auditLog.log(AuditEvent.SCREEN_UNLOCK_FAILED, {});
274
+ });
275
+
276
+ this.#ui.on('buffer-switch', (idx) => {
277
+ const room = this.#bufferOrder[idx];
278
+ if (room) {
279
+ this.#switchToBuffer(room);
280
+ }
281
+ });
242
282
  }
243
283
 
244
284
  // ── Auto-away (idle) ────────────────────────────────────────
@@ -250,9 +290,52 @@ export class ChatController {
250
290
  this.#autoAwaySet = false;
251
291
  this.#ui.removeHeaderIndicator('away');
252
292
  this.#ui.addSystemMessage("You're back (auto)");
293
+ this.#reportAwayUnread();
253
294
  this.#broadcastPresence();
254
295
  }
255
296
  this.#armAutoAway();
297
+ this.#armAutoLock();
298
+ }
299
+
300
+ // ── Screen lock (privacy, not duress — that's /panic) ────────
301
+ #lockNow() {
302
+ if (!this.#passphrase) {
303
+ this.#ui.addErrorMessage(
304
+ 'No session passphrase — /lock needs one (set it at startup to enable locking)',
305
+ );
306
+ return;
307
+ }
308
+ if (this.#ui.isLocked) {
309
+ return;
310
+ }
311
+ this.#auditLog.log(AuditEvent.SCREEN_LOCKED, {});
312
+ this.#ui.showLock((attempt) => attempt === this.#passphrase);
313
+ }
314
+
315
+ #armAutoLock() {
316
+ if (this.#autoLockTimer) {
317
+ clearTimeout(this.#autoLockTimer);
318
+ this.#autoLockTimer = null;
319
+ }
320
+ if (this.#autoLockMs > 0) {
321
+ this.#autoLockTimer = setTimeout(() => this.#lockNow(), this.#autoLockMs);
322
+ if (this.#autoLockTimer.unref) {
323
+ this.#autoLockTimer.unref();
324
+ }
325
+ }
326
+ }
327
+
328
+ // Summarize what arrived while away, then reset the counters.
329
+ #reportAwayUnread() {
330
+ if (this.#awayUnread > 0) {
331
+ const mentions =
332
+ this.#awayMentions > 0 ? ` — ${this.#awayMentions} mention(s), see /mentions` : '';
333
+ this.#ui.addSystemMessage(
334
+ `While you were away: ${this.#awayUnread} new message(s)${mentions}`,
335
+ );
336
+ }
337
+ this.#awayUnread = 0;
338
+ this.#awayMentions = 0;
256
339
  }
257
340
 
258
341
  #armAutoAway() {
@@ -275,6 +358,8 @@ export class ChatController {
275
358
  this.#away = true;
276
359
  this.#awayReason = 'away (idle)';
277
360
  this.#autoAwaySet = true;
361
+ this.#awayUnread = 0;
362
+ this.#awayMentions = 0;
278
363
  this.#ui.setHeaderIndicator('away', '{yellow-fg}[away]{/yellow-fg}');
279
364
  this.#ui.addSystemMessage('Auto-away: marked as away due to inactivity');
280
365
  this.#broadcastPresence();
@@ -350,6 +435,18 @@ export class ChatController {
350
435
  this.#onRoomChanged(msg);
351
436
  break;
352
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
+
446
+ case MSG.ROOM_CHALLENGE:
447
+ this.#onRoomChallenge(msg);
448
+ break;
449
+
353
450
  case MSG.ROOM_LIST:
354
451
  this.#onRoomList(msg);
355
452
  break;
@@ -367,6 +464,11 @@ export class ChatController {
367
464
  this.#ui.addErrorMessage(
368
465
  `${msg.message}. Use /nick <other> to pick a different nickname.`,
369
466
  );
467
+ } else if (msg.code === ERR.ROOM_AUTH_FAILED || msg.code === ERR.ROOM_EXISTS) {
468
+ // Join/create refused — drop the derived secrets for that attempt.
469
+ freeRoomSecrets(this.#pendingRoomSecrets);
470
+ this.#pendingRoomSecrets = null;
471
+ this.#ui.addErrorMessage(`Error: ${msg.message} (${msg.code})`);
370
472
  } else {
371
473
  this.#ui.addErrorMessage(`Error: ${msg.message} (${msg.code})`);
372
474
  }
@@ -406,21 +508,27 @@ export class ChatController {
406
508
  // ── JOIN_ACK: registered with server ──────────────────────────
407
509
  #onJoinAck(msg) {
408
510
  this.#sessionId = msg.sessionId;
409
- this.#currentRoom = msg.room || 'general';
410
- this.#ui.setRoom(this.#currentRoom);
411
- this.#currentRoomOwner = msg.roomOwner || null;
511
+ const room = msg.room || 'general';
512
+ const hadPrivateBuffers = [...this.#buffers.values()].some((b) => b.secrets);
412
513
 
413
514
  // Build map of old sessionIds by nickname for ratchet migration
414
515
  const oldSessionByNick = new Map();
415
- for (const [sid, peer] of this.#peers) {
516
+ for (const [sid, peer] of this.#allPeers) {
416
517
  oldSessionByNick.set(peer.nickname.toLowerCase(), sid);
417
518
  }
418
- 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
+ }
419
526
 
420
527
  for (const peer of msg.peers) {
421
- this.#peers.set(peer.sessionId, {
528
+ this.#allPeers.set(peer.sessionId, {
422
529
  nickname: peer.nickname,
423
530
  publicKey: peer.publicKey,
531
+ rooms: new Set([room]),
424
532
  });
425
533
 
426
534
  const oldSid = oldSessionByNick.get(peer.nickname.toLowerCase());
@@ -437,9 +545,8 @@ export class ChatController {
437
545
  // Initialize ratchets now that we have our session ID
438
546
  this.#handshake.setMySessionId(msg.sessionId);
439
547
 
548
+ this.#rebuildActivePeers();
440
549
  const peerNames = [...this.#peers.values()].map((p) => p.nickname);
441
- this.#ui.setOnlineCount(this.#peers.size + 1);
442
- this.#ui.setPeerNames(peerNames);
443
550
  this.#ui.addSystemMessage('Connected to server with E2E encryption active');
444
551
 
445
552
  if (peerNames.length > 0) {
@@ -452,26 +559,201 @@ export class ChatController {
452
559
 
453
560
  // Invite included a room — join it once after the first connect
454
561
  if (this.#inviteRoom && this.#inviteRoom !== this.#currentRoom) {
455
- this.#connection.send(createChangeRoom(this.#inviteRoom));
562
+ this.#connection.send(createJoinRoom(this.#inviteRoom));
456
563
  this.#inviteRoom = null;
457
564
  }
565
+
566
+ this.#saveLastSession();
567
+ }
568
+
569
+ // Remember where we are for the next launch. Privacy: in a private room only
570
+ // the server is written — the room name never touches disk.
571
+ #saveLastSession(isPrivate = false) {
572
+ saveLastSession({
573
+ server: (this.#connection.url || '').replace(/^wss?:\/\//, ''),
574
+ room: isPrivate || this.#activeSecrets ? undefined : this.#currentRoom,
575
+ });
576
+ }
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;
458
727
  }
459
728
 
460
729
  // ── New peer arrived ──────────────────────────────────────────
461
730
  #onPeerJoined(msg) {
462
731
  const { peer } = msg;
463
- this.#peers.set(peer.sessionId, {
464
- nickname: peer.nickname,
465
- publicKey: peer.publicKey,
466
- });
467
- 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
+ }
468
745
  this.#checkTrust(peer.nickname, peer.publicKey);
746
+ this.#auditLog.log(AuditEvent.PEER_CONNECTED, { nickname: peer.nickname, room });
469
747
 
470
- this.#ui.setOnlineCount(this.#peers.size + 1);
471
- this.#ui.setPeerNames([...this.#peers.values()].map((p) => p.nickname));
472
- this.#ui.handshakeConnect(peer.nickname);
473
- this.#nudgeVerify(peer.nickname);
474
- 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
+ }
475
757
 
476
758
  // A newcomer doesn't know my presence — send only to them
477
759
  if (this.#away || this.#statusText) {
@@ -491,19 +773,32 @@ export class ChatController {
491
773
  );
492
774
  }
493
775
 
494
- // ── Peer left ─────────────────────────────────────────────────
776
+ // ── Peer left (one room, or entirely when untagged) ──────────
495
777
  #onPeerLeft(msg) {
496
- const peer = this.#peers.get(msg.sessionId);
497
- 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;
498
781
 
499
- this.#hidePeerTyping(msg.sessionId, nickname);
500
- this.#handshake.removePeer(msg.sessionId);
501
- this.#nonceManager.removePeer(msg.sessionId);
502
- this.#peers.delete(msg.sessionId);
782
+ if (entry && room) {
783
+ entry.rooms.delete(room);
784
+ }
785
+ const goneEntirely = !entry || !room || entry.rooms.size === 0;
503
786
 
504
- this.#ui.setOnlineCount(this.#peers.size + 1);
505
- this.#ui.setPeerNames([...this.#peers.values()].map((p) => p.nickname));
506
- 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
+ }
507
802
  this.#auditLog.log(AuditEvent.PEER_DISCONNECTED, { nickname });
508
803
  }
509
804
 
@@ -522,7 +817,8 @@ export class ChatController {
522
817
  msg = { ...msg, from: opened.from, payload: opened.payload };
523
818
  }
524
819
 
525
- 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);
526
822
  if (!peer) {
527
823
  this.#ui.addErrorMessage('Message from unknown peer');
528
824
  return;
@@ -612,25 +908,54 @@ export class ChatController {
612
908
  }
613
909
 
614
910
  try {
615
- const data = JSON.parse(plaintext.toString('utf-8'));
911
+ let data = JSON.parse(plaintext.toString('utf-8'));
912
+
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.
915
+ if (isRoomWrapped(data)) {
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
+ }
928
+ }
929
+ if (!inner) {
930
+ return;
931
+ }
932
+ data = JSON.parse(inner);
933
+ }
616
934
 
617
935
  // Cover traffic: a decoy — drop it silently (no UI, no history, no receipt).
618
936
  if (isCover(data)) {
619
937
  return;
620
938
  }
621
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
+
622
944
  if (data.action === 'clear') {
623
- this.#ui.clearChat();
945
+ this.#ui.clearBuffer(msgRoom);
624
946
  return;
625
947
  }
626
948
 
627
949
  if (data.action === 'typing') {
628
- this.#showPeerTyping(msg.from, peer.nickname);
950
+ if (roomActive) {
951
+ this.#showPeerTyping(msg.from, peer.nickname);
952
+ }
629
953
  return;
630
954
  }
631
955
 
632
956
  if (data.action === 'key_rotation') {
633
957
  this.#handshake.updatePeerKey(msg.from, data.newPublicKey);
958
+ peer.publicKey = data.newPublicKey;
634
959
  const p = this.#peers.get(msg.from);
635
960
  if (p) {
636
961
  p.publicKey = data.newPublicKey;
@@ -757,13 +1082,21 @@ export class ChatController {
757
1082
  }
758
1083
 
759
1084
  if (data.action === 'presence') {
760
- const p = this.#peers.get(msg.from);
761
- 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
+ {
762
1089
  const wasAway = !!p.away;
763
1090
  const oldStatus = p.status || null;
764
1091
  p.away = !!data.away;
765
1092
  p.awayReason = typeof data.reason === 'string' ? data.reason.slice(0, 60) : null;
766
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
+ }
767
1100
 
768
1101
  if (p.away && !wasAway) {
769
1102
  const why = p.awayReason ? ` (${p.awayReason})` : '';
@@ -779,15 +1112,21 @@ export class ChatController {
779
1112
  }
780
1113
 
781
1114
  if (data.action === 'reaction') {
782
- this.#ui.addSystemMessage(`${data.emoji} ${peer.nickname} reacted to a message`);
783
- 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
+ }
784
1121
  return;
785
1122
  }
786
1123
 
787
1124
  if (data.action === 'edit_message') {
788
1125
  const author = this.#messageAuthors.get(data.messageId);
789
1126
  if (author && author === peer.nickname) {
790
- 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
+ });
791
1130
  }
792
1131
  return;
793
1132
  }
@@ -795,28 +1134,42 @@ export class ChatController {
795
1134
  if (data.action === 'delete_message') {
796
1135
  const author = this.#messageAuthors.get(data.messageId);
797
1136
  if (author && author === peer.nickname) {
798
- this.#ui.addSystemMessage(`${peer.nickname} deleted a message`);
1137
+ this.#ui.toBuffer(msgRoom, () => {
1138
+ this.#ui.addSystemMessage(`${peer.nickname} deleted a message`);
1139
+ });
799
1140
  }
800
1141
  return;
801
1142
  }
802
1143
 
803
1144
  if (data.action === 'pin_message') {
804
- this.#pinnedMessages.push({
1145
+ const pins = roomActive ? this.#pinnedMessages : this.#buffers.get(msgRoom)?.pins;
1146
+ pins?.push({
805
1147
  messageId: data.messageId,
806
1148
  nickname: data.nickname,
807
1149
  text: data.text,
808
1150
  pinnedBy: peer.nickname,
809
1151
  pinnedAt: Date.now(),
810
1152
  });
811
- this.#ui.addSystemMessage(
812
- `\uD83D\uDCCC ${peer.nickname} pinned: "${data.text}" \u2014 ${data.nickname}`,
813
- );
1153
+ this.#ui.toBuffer(msgRoom, () => {
1154
+ this.#ui.addSystemMessage(
1155
+ `\uD83D\uDCCC ${peer.nickname} pinned: "${data.text}" \u2014 ${data.nickname}`,
1156
+ );
1157
+ });
814
1158
  return;
815
1159
  }
816
1160
 
817
1161
  if (data.action === 'unpin_message') {
818
- this.#pinnedMessages = this.#pinnedMessages.filter((p) => p.messageId !== data.messageId);
819
- 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
+ });
820
1173
  return;
821
1174
  }
822
1175
 
@@ -831,35 +1184,63 @@ export class ChatController {
831
1184
  // Persist to encrypted history — never ephemeral or deniable messages
832
1185
  if (this.#historyStore?.isOpen && !data.ephemeral && !isDeniable && !data.deniable) {
833
1186
  this.#historyStore.append({
834
- room: this.#currentRoom,
1187
+ room: msgRoom,
835
1188
  nickname: peer.nickname,
836
1189
  text: data.text,
837
1190
  isDM: !!data.isDM,
838
1191
  });
839
1192
  }
840
1193
 
841
- if (data.replyTo?.nickname && typeof data.replyTo.excerpt === 'string') {
842
- this.#ui.addQuoteLine(String(data.replyTo.nickname), data.replyTo.excerpt.slice(0, 80));
843
- }
844
-
845
1194
  const mentioned = this.#mentionsMe(data.text) && !data.isDM;
1195
+ if (mentioned) {
1196
+ this.#mentions.push({
1197
+ nickname: peer.nickname,
1198
+ text: data.text,
1199
+ room: msgRoom,
1200
+ at: Date.now(),
1201
+ });
1202
+ if (this.#mentions.length > MENTIONS_MAX) {
1203
+ this.#mentions.shift();
1204
+ }
1205
+ }
1206
+ // Away: count what's arriving and keep the header badge live.
1207
+ if (this.#away) {
1208
+ this.#awayUnread++;
1209
+ if (mentioned) {
1210
+ this.#awayMentions++;
1211
+ }
1212
+ this.#ui.setHeaderIndicator(
1213
+ 'away',
1214
+ `{yellow-fg}[away · ${this.#awayUnread} new]{/yellow-fg}`,
1215
+ );
1216
+ }
846
1217
  const ephLabel = data.ephemeral ? this.#formatDuration(data.ephemeral) : null;
847
1218
  const trust = trustBadge(this.#trustStore.getPeerRecord(peer.nickname), peer.publicKey);
848
- const { lineIndex } = this.#ui.addMessage(
849
- peer.nickname,
850
- data.text,
851
- !!data.isDM,
852
- ephLabel,
853
- isDeniable || !!data.deniable,
854
- mentioned,
855
- trust,
856
- );
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);
857
1236
  const notify = shouldNotify(this.#dndMode, this.#dndWindow, nowMinutes(), mentioned);
858
1237
  if (notify) {
859
1238
  this.#ui.playNotification();
860
1239
  }
861
1240
 
862
- 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) {
863
1244
  this.#scheduleEphemeralRemoval(lineIndex, data.ephemeral, peer.nickname);
864
1245
  }
865
1246
 
@@ -927,11 +1308,19 @@ export class ChatController {
927
1308
  this.#ui.addInfoMessage(' /users - List online users');
928
1309
  this.#ui.addInfoMessage(' /msg <nick> <text> - Send a private message (DM)');
929
1310
  this.#ui.addInfoMessage(' /reply <text> - Reply to the last received message');
930
- this.#ui.addInfoMessage(' /away [reason] - Mark yourself as away');
1311
+ this.#ui.addInfoMessage(' /mentions [n] - Recent mentions of you (this session)');
1312
+ this.#ui.addInfoMessage(' /contacts [add|remove|all] - Contact book (aliases for peers)');
1313
+ this.#ui.addInfoMessage(
1314
+ ' /away [reason] - Mark yourself as away (unreads are counted)',
1315
+ );
931
1316
  this.#ui.addInfoMessage(' /back - Clear the away status');
932
1317
  this.#ui.addInfoMessage(' /autoaway <min|off> - Auto-away on inactivity');
1318
+ this.#ui.addInfoMessage(' /lock - Lock the screen (session passphrase)');
1319
+ this.#ui.addInfoMessage(' /autolock <min|off> - Auto-lock on inactivity');
933
1320
  this.#ui.addInfoMessage(' /status <text|off> - Set a status (accepts :emoji:)');
934
- this.#ui.addInfoMessage(' /join <room> - 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)');
1323
+ this.#ui.addInfoMessage(' /create <room> <pass> - Create a private room 🔒');
935
1324
  this.#ui.addInfoMessage(' /invite [host:port] - Generate an invite with QR code');
936
1325
  this.#ui.addInfoMessage(' /rooms - List available rooms');
937
1326
  this.#ui.addInfoMessage(' /room - Show the current room');
@@ -984,6 +1373,10 @@ export class ChatController {
984
1373
  case '/users': {
985
1374
  const names = [...this.#peers.values()].map((p) => {
986
1375
  let label = p.nickname;
1376
+ const alias = this.#trustStore.getAlias(p.nickname);
1377
+ if (alias) {
1378
+ label += ` (${alias})`;
1379
+ }
987
1380
  if (p.away) {
988
1381
  label += ` [away${p.awayReason ? `: ${p.awayReason}` : ''}]`;
989
1382
  }
@@ -1192,12 +1585,61 @@ export class ChatController {
1192
1585
  }
1193
1586
 
1194
1587
  case '/join': {
1195
- const roomName = parts[1];
1588
+ const roomName = parts[1]?.toLowerCase();
1589
+ if (!roomName) {
1590
+ this.#ui.addErrorMessage('Usage: /join <room> [password]');
1591
+ break;
1592
+ }
1593
+ // Already have that buffer? Just focus it.
1594
+ if (this.#buffers.has(roomName)) {
1595
+ this.#switchToBuffer(roomName);
1596
+ break;
1597
+ }
1598
+ const joinPassword = parts.slice(2).join(' ');
1599
+ if (joinPassword) {
1600
+ // Derive now so we can answer the server's challenge immediately.
1601
+ this.#prepareRoomSecrets(roomName, joinPassword, () => {
1602
+ this.#connection.send(createJoinRoom(roomName));
1603
+ });
1604
+ } else {
1605
+ this.#connection.send(createJoinRoom(roomName));
1606
+ }
1607
+ break;
1608
+ }
1609
+
1610
+ case '/create': {
1611
+ const roomName = parts[1]?.toLowerCase();
1196
1612
  if (!roomName) {
1197
- this.#ui.addErrorMessage('Usage: /join <room>');
1613
+ this.#ui.addErrorMessage('Usage: /create <room> <password>');
1198
1614
  break;
1199
1615
  }
1200
- this.#connection.send(createChangeRoom(roomName));
1616
+ if (this.#buffers.has(roomName)) {
1617
+ this.#ui.addErrorMessage(`You are already in #${roomName}`);
1618
+ break;
1619
+ }
1620
+ const createPassword = parts.slice(2).join(' ');
1621
+ if (!createPassword) {
1622
+ // No password — same as joining/creating a public room.
1623
+ this.#connection.send(createJoinRoom(roomName));
1624
+ break;
1625
+ }
1626
+ this.#prepareRoomSecrets(roomName, createPassword, (secrets) => {
1627
+ this.#connection.send(createJoinRoom(roomName, secrets.authPublicKey.toString('base64')));
1628
+ });
1629
+ break;
1630
+ }
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));
1201
1643
  break;
1202
1644
  }
1203
1645
 
@@ -1233,9 +1675,23 @@ export class ChatController {
1233
1675
  break;
1234
1676
  }
1235
1677
 
1236
- case '/room':
1237
- this.#ui.addInfoMessage(`Current room: #${this.#currentRoom}`);
1678
+ case '/room': {
1679
+ this.#ui.addInfoMessage(
1680
+ `Current room: #${this.#currentRoom}${this.#activeSecrets ? ' 🔒 (private)' : ''}`,
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
+ }
1238
1693
  break;
1694
+ }
1239
1695
 
1240
1696
  case '/tips': {
1241
1697
  this.#tipIndex = (this.#tipIndex + 1) % TIPS.length;
@@ -1265,6 +1721,8 @@ export class ChatController {
1265
1721
  case '/away': {
1266
1722
  this.#away = true;
1267
1723
  this.#autoAwaySet = false; // an explicit /away is not auto
1724
+ this.#awayUnread = 0;
1725
+ this.#awayMentions = 0;
1268
1726
  this.#awayReason = applyShortcodes(parts.slice(1).join(' ')).slice(0, 60) || null;
1269
1727
  this.#ui.setHeaderIndicator('away', '{yellow-fg}[away]{/yellow-fg}');
1270
1728
  this.#ui.addInfoMessage(
@@ -1284,10 +1742,124 @@ export class ChatController {
1284
1742
  this.#autoAwaySet = false;
1285
1743
  this.#ui.removeHeaderIndicator('away');
1286
1744
  this.#ui.addInfoMessage("You're back");
1745
+ this.#reportAwayUnread();
1287
1746
  this.#broadcastPresence();
1288
1747
  break;
1289
1748
  }
1290
1749
 
1750
+ case '/contacts': {
1751
+ const sub = (parts[1] || 'list').toLowerCase();
1752
+
1753
+ if (sub === 'add') {
1754
+ const nick = parts[2];
1755
+ const alias = parts.slice(3).join(' ').trim();
1756
+ if (!nick || !alias) {
1757
+ this.#ui.addErrorMessage('Usage: /contacts add <nick> <alias>');
1758
+ break;
1759
+ }
1760
+ if (this.#trustStore.setAlias(nick, alias)) {
1761
+ this.#ui.addInfoMessage(`Contact saved: ${nick} → "${alias.slice(0, 30)}"`);
1762
+ } else {
1763
+ this.#ui.addErrorMessage(
1764
+ `"${nick}" was never seen on this identity — no trust record to alias`,
1765
+ );
1766
+ }
1767
+ break;
1768
+ }
1769
+
1770
+ if (sub === 'remove') {
1771
+ const nick = parts[2];
1772
+ if (!nick) {
1773
+ this.#ui.addErrorMessage('Usage: /contacts remove <nick>');
1774
+ break;
1775
+ }
1776
+ if (this.#trustStore.clearAlias(nick)) {
1777
+ this.#ui.addInfoMessage(`Alias removed from ${nick}`);
1778
+ } else {
1779
+ this.#ui.addErrorMessage(`${nick} has no alias`);
1780
+ }
1781
+ break;
1782
+ }
1783
+
1784
+ if (sub !== 'list' && sub !== 'all') {
1785
+ this.#ui.addErrorMessage('Usage: /contacts [add <nick> <alias> | remove <nick> | all]');
1786
+ break;
1787
+ }
1788
+
1789
+ const contacts = this.#trustStore.listContacts(sub === 'all');
1790
+ if (contacts.length === 0) {
1791
+ this.#ui.addInfoMessage(
1792
+ sub === 'all'
1793
+ ? 'No peers known yet.'
1794
+ : 'No contacts yet. Use /contacts add <nick> <alias>',
1795
+ );
1796
+ break;
1797
+ }
1798
+ this.#ui.addInfoMessage(sub === 'all' ? 'Known peers:' : 'Contacts:');
1799
+ for (const c of contacts) {
1800
+ const badge = c.verified ? ' ✓' : '';
1801
+ const alias = c.alias ? ` (${c.alias})` : '';
1802
+ const seen = c.lastSeen
1803
+ ? ` — last seen ${new Date(c.lastSeen).toLocaleString('en-US', {
1804
+ day: '2-digit',
1805
+ month: '2-digit',
1806
+ hour: '2-digit',
1807
+ minute: '2-digit',
1808
+ })}`
1809
+ : '';
1810
+ this.#ui.addInfoMessage(` ${c.nickname}${alias}${badge}${seen}`);
1811
+ }
1812
+ break;
1813
+ }
1814
+
1815
+ case '/mentions': {
1816
+ if (this.#mentions.length === 0) {
1817
+ this.#ui.addInfoMessage('No mentions in this session yet.');
1818
+ break;
1819
+ }
1820
+ const count = Math.min(parseInt(parts[1], 10) || 10, this.#mentions.length);
1821
+ this.#ui.addInfoMessage(`Last ${count} mention(s) of you:`);
1822
+ for (const m of this.#mentions.slice(-count)) {
1823
+ const when = new Date(m.at).toLocaleString('en-US', {
1824
+ hour: '2-digit',
1825
+ minute: '2-digit',
1826
+ });
1827
+ this.#ui.addInfoMessage(` [${when}] [#${m.room}] ${m.nickname}: ${m.text.slice(0, 80)}`);
1828
+ }
1829
+ break;
1830
+ }
1831
+
1832
+ case '/lock':
1833
+ this.#lockNow();
1834
+ break;
1835
+
1836
+ case '/autolock': {
1837
+ const alArg = parts[1]?.toLowerCase();
1838
+ if (alArg === 'off' || alArg === '0') {
1839
+ this.#autoLockMs = 0;
1840
+ this.#armAutoLock();
1841
+ this.#ui.addInfoMessage('Auto-lock disabled');
1842
+ break;
1843
+ }
1844
+ const alMin = parseInt(alArg, 10);
1845
+ if (!Number.isInteger(alMin) || alMin < 1 || alMin > 240) {
1846
+ this.#ui.addInfoMessage(
1847
+ `Auto-lock: ${this.#autoLockMs ? `${this.#autoLockMs / 60000}min` : 'off'}. Usage: /autolock <minutes|off>`,
1848
+ );
1849
+ break;
1850
+ }
1851
+ if (!this.#passphrase) {
1852
+ this.#ui.addErrorMessage(
1853
+ 'No session passphrase — auto-lock needs one (set it at startup)',
1854
+ );
1855
+ break;
1856
+ }
1857
+ this.#autoLockMs = alMin * 60_000;
1858
+ this.#armAutoLock();
1859
+ this.#ui.addInfoMessage(`Auto-lock after ${alMin}min of inactivity`);
1860
+ break;
1861
+ }
1862
+
1291
1863
  case '/autoaway': {
1292
1864
  const aaArg = parts[1]?.toLowerCase();
1293
1865
  if (aaArg === 'off' || aaArg === '0') {
@@ -1915,7 +2487,15 @@ export class ChatController {
1915
2487
  if (this.#pluginManager) {
1916
2488
  const result = this.#pluginManager.handleCommand(cmd, parts.slice(1));
1917
2489
  if (result) {
1918
- this.#ui.addInfoMessage(result);
2490
+ // Plugin API: `{ send }` goes to the room as a normal E2EE
2491
+ // message; `{ info }` or a plain string stays local.
2492
+ if (typeof result === 'object' && typeof result.send === 'string' && result.send) {
2493
+ this.#sendMessageToAll(result.send);
2494
+ } else if (typeof result === 'object' && typeof result.info === 'string') {
2495
+ this.#ui.addInfoMessage(result.info);
2496
+ } else if (typeof result === 'string') {
2497
+ this.#ui.addInfoMessage(result);
2498
+ }
1919
2499
  break;
1920
2500
  }
1921
2501
  }
@@ -2004,45 +2584,166 @@ export class ChatController {
2004
2584
  this.#ui.addSystemMessage(`${peer.nickname} updated key (via server — unauthenticated)`);
2005
2585
  }
2006
2586
 
2007
- // ── Handle ROOM_CHANGED (after /join) ──────────────────────
2587
+ // ── Private rooms: derive secrets off the input handler ─────
2588
+ // Argon2id (MODERATE) blocks for ~1s — let the UI paint the notice first.
2589
+ #prepareRoomSecrets(roomName, password, onReady) {
2590
+ const room = roomName.toLowerCase();
2591
+ this.#ui.addInfoMessage('Deriving room key (Argon2id)…');
2592
+ setImmediate(() => {
2593
+ freeRoomSecrets(this.#pendingRoomSecrets);
2594
+ const secrets = deriveRoomSecrets(room, password);
2595
+ this.#pendingRoomSecrets = { room, ...secrets };
2596
+ onReady(secrets);
2597
+ });
2598
+ }
2599
+
2600
+ // ── Handle ROOM_CHALLENGE (target room is private) ──────────
2601
+ #onRoomChallenge(msg) {
2602
+ const pending = this.#pendingRoomSecrets;
2603
+ if (!pending || pending.room !== msg.room) {
2604
+ this.#ui.addErrorMessage(`Room #${msg.room} is private. Usage: /join ${msg.room} <password>`);
2605
+ return;
2606
+ }
2607
+ const signature = signRoomChallenge(
2608
+ pending.authSecretKey,
2609
+ msg.room,
2610
+ msg.nonce,
2611
+ this.#sessionId,
2612
+ );
2613
+ this.#connection.send(createRoomAuth(msg.room, msg.nonce, signature.toString('base64')));
2614
+ }
2615
+
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;
2623
+ } else {
2624
+ freeRoomSecrets(this.#pendingRoomSecrets);
2625
+ this.#ui.addErrorMessage(
2626
+ 'WARNING: the server treated this room as PUBLIC — anyone can join.',
2627
+ );
2628
+ }
2629
+ this.#pendingRoomSecrets = null;
2630
+ } else if (this.#pendingRoomSecrets) {
2631
+ freeRoomSecrets(this.#pendingRoomSecrets);
2632
+ this.#pendingRoomSecrets = null;
2633
+ }
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.');
2641
+ } else {
2642
+ this.#ui.addSystemMessage(`You joined room #${room}`);
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
+ }
2651
+
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.
2008
2654
  #onRoomChanged(msg) {
2009
- this.#currentRoom = msg.room;
2010
- this.#ui.setRoom(this.#currentRoom);
2011
- this.#currentRoomOwner = msg.roomOwner || null;
2655
+ const secrets = this.#promotePendingSecrets(msg.room, !!msg.private);
2012
2656
 
2013
- // Clear old peers and pins
2014
- this.#peers.clear();
2015
- this.#pinnedMessages = [];
2657
+ this.#resetBuffersTo(msg.room, {
2658
+ isPrivate: !!msg.private,
2659
+ owner: msg.roomOwner || null,
2660
+ secrets,
2661
+ });
2662
+ this.#currentRoomOwner = msg.roomOwner || null;
2016
2663
 
2017
- // Populate with new room peers
2018
2664
  for (const peer of msg.peers) {
2019
- this.#peers.set(peer.sessionId, {
2665
+ this.#allPeers.set(peer.sessionId, {
2020
2666
  nickname: peer.nickname,
2021
2667
  publicKey: peer.publicKey,
2668
+ rooms: new Set([msg.room]),
2022
2669
  });
2023
-
2024
- // Register ratchet if new peer
2025
2670
  if (!this.#handshake.getRatchet(peer.sessionId)) {
2026
2671
  this.#handshake.registerPeer(peer.sessionId, peer.publicKey);
2027
2672
  }
2028
-
2029
2673
  this.#checkTrust(peer.nickname, peer.publicKey);
2030
2674
  }
2031
2675
 
2032
- const peerNames = [...this.#peers.values()].map((p) => p.nickname);
2033
- this.#ui.setOnlineCount(this.#peers.size + 1);
2034
- this.#ui.setPeerNames(peerNames);
2676
+ this.#rebuildActivePeers();
2035
2677
  this.#auditLog.log(AuditEvent.ROOM_CHANGED, { room: msg.room });
2036
- this.#ui.addSystemMessage(`You joined room #${msg.room}`);
2678
+ this.#announceJoinedRoom(
2679
+ msg.room,
2680
+ !!secrets,
2681
+ [...this.#peers.values()].map((p) => p.nickname),
2682
+ );
2683
+ this.#saveLastSession(!!msg.private);
2684
+ }
2037
2685
 
2038
- if (peerNames.length > 0) {
2039
- 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);
2040
2710
  }
2041
2711
 
2042
- // The new room doesn't know my presence
2043
- if (this.#away || this.#statusText) {
2044
- 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
+ }
2045
2737
  }
2738
+
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 });
2046
2747
  }
2047
2748
 
2048
2749
  // ── Handle ROOM_LIST ───────────────────────────────────────
@@ -2050,7 +2751,8 @@ export class ChatController {
2050
2751
  this.#ui.addInfoMessage('Available rooms:');
2051
2752
  for (const room of msg.rooms) {
2052
2753
  const current = room.name === this.#currentRoom ? ' (current)' : '';
2053
- this.#ui.addInfoMessage(` #${room.name} ${room.memberCount} member(s)${current}`);
2754
+ const lock = room.private ? ' 🔒' : '';
2755
+ this.#ui.addInfoMessage(` #${room.name}${lock} — ${room.memberCount} member(s)${current}`);
2054
2756
  }
2055
2757
  }
2056
2758
 
@@ -2106,6 +2808,11 @@ export class ChatController {
2106
2808
  if (!tracked) {
2107
2809
  return;
2108
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
+ }
2109
2816
 
2110
2817
  let readers = this.#messageReaders.get(messageId);
2111
2818
  if (!readers) {
@@ -2126,7 +2833,7 @@ export class ChatController {
2126
2833
  if (baseLine === null || baseLine === undefined) {
2127
2834
  return;
2128
2835
  }
2129
- this.#sentMessageLines.set(messageId, { lineIndex, baseLine });
2836
+ this.#sentMessageLines.set(messageId, { lineIndex, baseLine, room: this.#currentRoom });
2130
2837
 
2131
2838
  // Bound memory: keep only the most recent 200 tracked messages
2132
2839
  if (this.#sentMessageLines.size > 200) {
@@ -2165,6 +2872,13 @@ export class ChatController {
2165
2872
  return;
2166
2873
  }
2167
2874
 
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);
2880
+ }
2881
+
2168
2882
  const ratchet = this.#handshake.getRatchet(peerId);
2169
2883
  if (ratchet && ratchet.isInitialized) {
2170
2884
  try {
@@ -2282,6 +2996,14 @@ export class ChatController {
2282
2996
 
2283
2997
  // ── Broadcast encrypted payload to all peers ───────────────────
2284
2998
  #broadcastPayload(payload, deniable = false) {
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);
3005
+ }
3006
+
2285
3007
  for (const [peerId] of this.#peers) {
2286
3008
  const peerPublicKey = this.#handshake.getPeerPublicKey(peerId);
2287
3009
  if (!peerPublicKey) {