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.
- package/README.md +40 -7
- package/README.pt-BR.md +40 -7
- package/bin/ciphermesh.js +5 -0
- package/docs/ARCHITECTURE.md +44 -1
- package/docs/PLUGINS.md +69 -0
- package/examples/plugins/poll.js +28 -0
- package/examples/plugins/roll.js +24 -0
- package/package.json +4 -3
- package/src/client/ChatController.js +814 -92
- package/src/client/UI.js +262 -6
- package/src/client/index.js +50 -7
- package/src/crypto/RoomKey.js +162 -0
- package/src/crypto/TrustStore.js +47 -0
- package/src/p2p/P2PChatController.js +9 -1
- package/src/protocol/messages.js +76 -8
- package/src/protocol/validators.js +61 -1
- package/src/server/SessionManager.js +124 -16
- package/src/server/WebSocketServer.js +339 -41
- package/src/shared/AuditLog.js +3 -0
- package/src/shared/PluginManager.js +9 -6
- package/src/shared/config.js +27 -2
- package/src/shared/constants.js +8 -0
- package/src/shared/lastSession.js +55 -0
- package/src/shared/onboarding.js +111 -0
|
@@ -11,7 +11,9 @@ import {
|
|
|
11
11
|
createRatchetedMessage,
|
|
12
12
|
createSealedMessage,
|
|
13
13
|
createKeyUpdate,
|
|
14
|
-
|
|
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
|
-
|
|
410
|
-
this.#
|
|
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.#
|
|
516
|
+
for (const [sid, peer] of this.#allPeers) {
|
|
416
517
|
oldSessionByNick.set(peer.nickname.toLowerCase(), sid);
|
|
417
518
|
}
|
|
418
|
-
|
|
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.#
|
|
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(
|
|
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.#
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
|
497
|
-
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;
|
|
498
781
|
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
782
|
+
if (entry && room) {
|
|
783
|
+
entry.rooms.delete(room);
|
|
784
|
+
}
|
|
785
|
+
const goneEntirely = !entry || !room || entry.rooms.size === 0;
|
|
503
786
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
945
|
+
this.#ui.clearBuffer(msgRoom);
|
|
624
946
|
return;
|
|
625
947
|
}
|
|
626
948
|
|
|
627
949
|
if (data.action === 'typing') {
|
|
628
|
-
|
|
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
|
-
|
|
761
|
-
|
|
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.
|
|
783
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
812
|
-
|
|
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
|
-
|
|
819
|
-
|
|
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:
|
|
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
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
-
|
|
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(' /
|
|
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>
|
|
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: /
|
|
1613
|
+
this.#ui.addErrorMessage('Usage: /create <room> <password>');
|
|
1198
1614
|
break;
|
|
1199
1615
|
}
|
|
1200
|
-
this.#
|
|
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(
|
|
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
|
-
|
|
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
|
-
// ──
|
|
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
|
-
|
|
2010
|
-
this.#ui.setRoom(this.#currentRoom);
|
|
2011
|
-
this.#currentRoomOwner = msg.roomOwner || null;
|
|
2655
|
+
const secrets = this.#promotePendingSecrets(msg.room, !!msg.private);
|
|
2012
2656
|
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
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.#
|
|
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
|
-
|
|
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.#
|
|
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
|
-
|
|
2039
|
-
|
|
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
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
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
|
-
|
|
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) {
|