ciphermesh 2.3.0 → 2.5.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.
@@ -1,8 +1,8 @@
1
1
  import sodium from 'sodium-native';
2
2
  import notifier from 'node-notifier';
3
3
  import qrcode from 'qrcode-terminal';
4
- import { writeFileSync } from 'node:fs';
5
- import { resolve } from 'node:path';
4
+ import { writeFileSync, mkdirSync } from 'node:fs';
5
+ import { resolve, dirname } from 'node:path';
6
6
  import { tmpdir } from 'node:os';
7
7
  import { exportBackup } from '../crypto/IdentityBackup.js';
8
8
  import { keyArt } from '../shared/keyArt.js';
@@ -22,6 +22,8 @@ import { FileTransfer } from '../client/FileTransfer.js';
22
22
  import { isImageFile, renderImagePreview, loadImageBuffers } from '../client/ImagePreview.js';
23
23
  import { detectImageProtocol, encodeInlineImage } from '../shared/terminalGraphics.js';
24
24
  import { AuditLog, AuditEvent } from '../shared/AuditLog.js';
25
+ import { applyShortcodes } from '../shared/emoji.js';
26
+ import { diagnose, formatDiagnosis } from '../shared/doctor.js';
25
27
  import { deriveSharedKey, encryptDeniable, decryptDeniable } from '../crypto/DeniableEncrypt.js';
26
28
  import { GroupSession } from '../crypto/SenderKey.js';
27
29
  import { suggestCommand } from '../shared/commandSuggest.js';
@@ -30,7 +32,13 @@ import { recordVoiceNote, playVoiceNote, isAudioFile } from '../shared/voiceNote
30
32
  import { setTheme, getThemeName, themeNames } from '../shared/themes.js';
31
33
  import { panicWipe } from '../shared/panic.js';
32
34
  import { farewellBanner } from '../shared/banner.js';
33
- import { parseDndWindow, shouldNotify, nowMinutes, mentionsMe } from '../shared/dnd.js';
35
+ import {
36
+ parseDndWindow,
37
+ shouldNotify,
38
+ nowMinutes,
39
+ mentionsMe,
40
+ matchesKeyword,
41
+ } from '../shared/dnd.js';
34
42
  import { trustBadge } from '../shared/trust.js';
35
43
  import { tipAt, TIPS } from '../shared/tips.js';
36
44
  import { COMMANDS } from '../client/UI.js';
@@ -71,6 +79,22 @@ export class P2PChatController {
71
79
  #ephemeralMode;
72
80
  #ephemeralDurationMs;
73
81
  #ephemeralTimers;
82
+ #watchWords = new Set(); // /watch — keywords that alert like a mention does
83
+ #mentions = []; // session mention log (/mentions)
84
+ #away = false;
85
+ #awayReason = null;
86
+ #statusText = null;
87
+ #autoAwayMs = 0;
88
+ #autoAwayTimer = null;
89
+ #autoAwaySet = false;
90
+ #autoLockMs = 0;
91
+ #autoLockTimer = null;
92
+ #roomTopics = new Map(); // room → { text, by, at } (E2EE among peers)
93
+ #historyStore; // encrypted local history (opt-in, needs a passphrase)
94
+ #receiptsEnabled = true; // /receipts — send read confirmations
95
+ #sentMessageLines = new Map(); // messageId → { lineIndex, baseLine, room }
96
+ #messageReaders = new Map(); // messageId → Set<nickname>
97
+ #pendingReceipts = new Map(); // messageId → Set<nickname> acked before we tracked it
74
98
  #lastReceivedMessageId;
75
99
  #lastReceivedNickname;
76
100
  #lastSentMessageId;
@@ -92,6 +116,7 @@ export class P2PChatController {
92
116
  keyManager,
93
117
  restoredState = null,
94
118
  pluginManager = null,
119
+ historyStore = null,
95
120
  ) {
96
121
  this.#nickname = nickname;
97
122
  this.#connManager = connManager;
@@ -119,6 +144,7 @@ export class P2PChatController {
119
144
  this.#trustStore.importData(restoredState.trust);
120
145
  }
121
146
  this.#auditLog = new AuditLog();
147
+ this.#historyStore = historyStore;
122
148
  this.#ephemeralMode = false;
123
149
  this.#ephemeralDurationMs = 0;
124
150
  this.#ephemeralTimers = [];
@@ -180,6 +206,16 @@ export class P2PChatController {
180
206
  this.#handleTypingActivity();
181
207
  });
182
208
 
209
+ this.#ui.on('unlocked', () => {
210
+ this.#auditLog.log(AuditEvent.SCREEN_UNLOCKED, {});
211
+ this.#ui.addSystemMessage('Screen unlocked');
212
+ this.#noteActive();
213
+ });
214
+
215
+ this.#ui.on('lock-failed', () => {
216
+ this.#auditLog.log(AuditEvent.SCREEN_UNLOCK_FAILED, {});
217
+ });
218
+
183
219
  this.#ui.on('quit', () => {
184
220
  this.destroy();
185
221
  process.exit(0);
@@ -206,6 +242,17 @@ export class P2PChatController {
206
242
  false,
207
243
  nickname,
208
244
  );
245
+
246
+ // Push my sender key too, instead of waiting for THEIR announce to ask for
247
+ // it. Both sides connect at once, and a pairwise message from a peer we
248
+ // have not registered yet is dropped (we cannot authenticate it) — so if
249
+ // my announce lost that race, the peer would never learn my key and could
250
+ // never read my room messages. Announcing AND distributing on every
251
+ // connect makes the exchange succeed whatever the ordering.
252
+ if ((this.#peerRooms.get(nickname) || 'general') === this.#currentRoom) {
253
+ this.#distributeSenderKey(this.#currentRoom, nickname);
254
+ }
255
+
209
256
  this.#flushSFQueue(nickname);
210
257
  }
211
258
 
@@ -547,6 +594,57 @@ export class P2PChatController {
547
594
  if (data.room && data.room !== this.#currentRoom && !data.isDM) {
548
595
  return;
549
596
  }
597
+ if (data.action === 'presence') {
598
+ const peer = this.#livePeer(fromNickname);
599
+ const wasAway = !!peer?.away;
600
+ const oldStatus = peer?.status || null;
601
+ if (peer) {
602
+ peer.away = !!data.away;
603
+ peer.awayReason = typeof data.reason === 'string' ? data.reason.slice(0, 60) : null;
604
+ peer.status = typeof data.status === 'string' ? data.status.slice(0, 60) : null;
605
+ }
606
+ if (data.away && !wasAway) {
607
+ const why = data.reason ? `: ${String(data.reason).slice(0, 60)}` : '';
608
+ this.#ui.addSystemMessage(`${fromNickname} is away${why}`);
609
+ } else if (!data.away && wasAway) {
610
+ this.#ui.addSystemMessage(`${fromNickname} is back`);
611
+ }
612
+ if (peer?.status && peer.status !== oldStatus) {
613
+ this.#ui.addSystemMessage(`${fromNickname} set status: ${peer.status}`);
614
+ }
615
+ return;
616
+ }
617
+
618
+ if (data.action === 'read_receipt') {
619
+ this.#onReadReceipt(fromNickname, data.messageId);
620
+ return;
621
+ }
622
+
623
+ if (data.action === 'set_topic') {
624
+ const room = typeof data.room === 'string' ? data.room : this.#currentRoom;
625
+ if (typeof data.text === 'string') {
626
+ const at = Number(data.at) || 0;
627
+ const current = this.#roomTopics.get(room);
628
+ // Last write wins — on join everyone who knows it answers.
629
+ if (!current || at >= current.at) {
630
+ const text = data.text.slice(0, 200);
631
+ const changed = current?.text !== text;
632
+ this.#roomTopics.set(room, { text, by: fromNickname, at });
633
+ if (room === this.#currentRoom) {
634
+ this.#ui.setTopic(text || null);
635
+ }
636
+ if (changed && !data.silent) {
637
+ this.#ui.addSystemMessage(
638
+ text
639
+ ? `📋 ${fromNickname} set the topic of #${room}: ${text}`
640
+ : `📋 ${fromNickname} cleared the topic of #${room}`,
641
+ );
642
+ }
643
+ }
644
+ }
645
+ return;
646
+ }
647
+
550
648
  this.#hidePeerTyping(fromNickname);
551
649
  if (data.messageId) {
552
650
  this.#lastReceivedMessageId = data.messageId;
@@ -554,26 +652,71 @@ export class P2PChatController {
554
652
  this.#lastReceivedText = data.text;
555
653
  this.#messageAuthors.set(data.messageId, fromNickname);
556
654
  }
557
- const mentioned = mentionsMe(data.text, this.#nickname) && !data.isDM;
655
+ const watchHit = this.#matchedWatch(data.text);
656
+ // A watched keyword deserves the same attention a mention gets.
657
+ const mentioned = (mentionsMe(data.text, this.#nickname) || !!watchHit) && !data.isDM;
658
+ if (watchHit) {
659
+ this.#ui.addSystemMessage(`👁 "${watchHit}" mentioned by ${fromNickname}`);
660
+ }
661
+ if (mentioned) {
662
+ this.#mentions.push({
663
+ nickname: fromNickname,
664
+ text: data.text,
665
+ room: this.#currentRoom,
666
+ at: Date.now(),
667
+ });
668
+ if (this.#mentions.length > 50) {
669
+ this.#mentions.shift();
670
+ }
671
+ }
672
+ // Persist to encrypted history — never ephemeral or deniable messages
673
+ if (this.#historyStore?.isOpen && !data.ephemeral && !isDeniable && !data.deniable) {
674
+ this.#historyStore.append({
675
+ room: this.#currentRoom,
676
+ nickname: fromNickname,
677
+ text: data.text,
678
+ isDM: !!data.isDM,
679
+ });
680
+ }
681
+
558
682
  const ephLabel = data.ephemeral ? this.#formatDuration(data.ephemeral) : null;
559
683
  const trust = trustBadge(
560
684
  this.#trustStore.getPeerRecord(fromNickname),
561
685
  this.#findPeer(fromNickname)?.publicKey,
562
686
  );
563
- const { lineIndex } = this.#ui.addMessage(
564
- fromNickname,
565
- data.text,
566
- !!data.isDM,
567
- ephLabel,
568
- isDeniable || !!data.deniable,
569
- mentioned,
570
- trust,
571
- );
687
+ const { lineIndex } = data.isAction
688
+ ? this.#ui.addActionMessage(fromNickname, data.text)
689
+ : this.#ui.addMessage(
690
+ fromNickname,
691
+ data.text,
692
+ !!data.isDM,
693
+ ephLabel,
694
+ isDeniable || !!data.deniable,
695
+ mentioned,
696
+ trust,
697
+ );
572
698
  const notify = shouldNotify(this.#dndMode, this.#dndWindow, nowMinutes(), mentioned);
573
699
  if (notify) {
574
700
  this.#ui.playNotification();
575
701
  }
576
702
 
703
+ // Confirm the read to the author — an ordinary E2EE payload, sent only to
704
+ // them. Never for ephemeral or deniable messages: acknowledging those
705
+ // would defeat the point of not leaving a trace.
706
+ if (
707
+ this.#receiptsEnabled &&
708
+ data.messageId &&
709
+ !data.ephemeral &&
710
+ !isDeniable &&
711
+ !data.deniable
712
+ ) {
713
+ this.#broadcastPayload(
714
+ JSON.stringify({ action: 'read_receipt', messageId: data.messageId, sentAt: Date.now() }),
715
+ false,
716
+ fromNickname,
717
+ );
718
+ }
719
+
577
720
  if (data.ephemeral && data.ephemeral > 0) {
578
721
  this.#scheduleEphemeralRemoval(lineIndex, data.ephemeral, fromNickname);
579
722
  }
@@ -661,6 +804,7 @@ export class P2PChatController {
661
804
 
662
805
  // ── User input ─────────────────────────────────────────────────
663
806
  #handleUserInput(text) {
807
+ this.#noteActive();
664
808
  if (text.startsWith('/')) {
665
809
  this.#handleCommand(text);
666
810
  return;
@@ -669,13 +813,530 @@ export class P2PChatController {
669
813
  this.#sendMessageToAll(text);
670
814
  }
671
815
 
816
+ // ── Presence (away/status), mirrored from the relay client ──
817
+ #presencePayload() {
818
+ return JSON.stringify({
819
+ action: 'presence',
820
+ away: this.#away,
821
+ reason: this.#awayReason,
822
+ status: this.#statusText,
823
+ sentAt: Date.now(),
824
+ });
825
+ }
826
+
827
+ #broadcastPresence() {
828
+ this.#broadcastPayload(this.#presencePayload());
829
+ }
830
+
831
+ // ── Auto-away / auto-lock on inactivity ────────────────────
832
+ #noteActive() {
833
+ if (this.#autoAwaySet && this.#away) {
834
+ this.#away = false;
835
+ this.#awayReason = null;
836
+ this.#autoAwaySet = false;
837
+ this.#ui.removeHeaderIndicator('away');
838
+ this.#ui.addSystemMessage("You're back (auto)");
839
+ this.#broadcastPresence();
840
+ }
841
+ this.#armAutoAway();
842
+ this.#armAutoLock();
843
+ }
844
+
845
+ #armAutoAway() {
846
+ if (this.#autoAwayTimer) {
847
+ clearTimeout(this.#autoAwayTimer);
848
+ this.#autoAwayTimer = null;
849
+ }
850
+ if (this.#autoAwayMs > 0) {
851
+ this.#autoAwayTimer = setTimeout(() => this.#triggerAutoAway(), this.#autoAwayMs);
852
+ if (this.#autoAwayTimer.unref) {
853
+ this.#autoAwayTimer.unref();
854
+ }
855
+ }
856
+ }
857
+
858
+ #triggerAutoAway() {
859
+ if (this.#away) {
860
+ return;
861
+ }
862
+ this.#away = true;
863
+ this.#awayReason = 'away (idle)';
864
+ this.#autoAwaySet = true;
865
+ this.#ui.setHeaderIndicator('away', '{yellow-fg}[away]{/yellow-fg}');
866
+ this.#ui.addSystemMessage('Auto-away: marked as away due to inactivity');
867
+ this.#broadcastPresence();
868
+ }
869
+
870
+ #lockNow() {
871
+ if (!this.#passphrase) {
872
+ this.#ui.addErrorMessage(
873
+ 'No session passphrase — /lock needs one (set it at startup to enable locking)',
874
+ );
875
+ return;
876
+ }
877
+ if (this.#ui.isLocked) {
878
+ return;
879
+ }
880
+ this.#auditLog.log(AuditEvent.SCREEN_LOCKED, {});
881
+ this.#ui.showLock((attempt) => attempt === this.#passphrase);
882
+ }
883
+
884
+ #armAutoLock() {
885
+ if (this.#autoLockTimer) {
886
+ clearTimeout(this.#autoLockTimer);
887
+ this.#autoLockTimer = null;
888
+ }
889
+ if (this.#autoLockMs > 0) {
890
+ this.#autoLockTimer = setTimeout(() => this.#lockNow(), this.#autoLockMs);
891
+ if (this.#autoLockTimer.unref) {
892
+ this.#autoLockTimer.unref();
893
+ }
894
+ }
895
+ }
896
+
897
+ #formatHistoryEntry(e) {
898
+ const when = new Date(e.ts).toLocaleString('en-US', {
899
+ day: '2-digit',
900
+ month: '2-digit',
901
+ hour: '2-digit',
902
+ minute: '2-digit',
903
+ });
904
+ const dm = e.isDM ? ' (DM)' : '';
905
+ return `[${when}] [#${e.room}]${dm} ${e.nickname}: ${e.text}`;
906
+ }
907
+
908
+ #parseRetentionTime(arg) {
909
+ if (!arg) {
910
+ return null;
911
+ }
912
+ const m = arg.match(/^(\d+)([mhd])$/);
913
+ if (!m) {
914
+ return null;
915
+ }
916
+ const n = parseInt(m[1], 10);
917
+ const unit = { m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]];
918
+ return n > 0 ? n * unit : null;
919
+ }
920
+
921
+ // The first /watch keyword present in the text, or null.
922
+ #matchedWatch(text) {
923
+ for (const word of this.#watchWords) {
924
+ if (matchesKeyword(text, word)) {
925
+ return word;
926
+ }
927
+ }
928
+ return null;
929
+ }
930
+
672
931
  #handleCommand(text) {
673
932
  const parts = text.split(/\s+/);
674
933
  const cmd = parts[0].toLowerCase();
675
934
 
676
935
  switch (cmd) {
936
+ case '/doctor': {
937
+ const target = parts.slice(1).join(' ').trim();
938
+ if (!target) {
939
+ this.#ui.addErrorMessage(
940
+ 'Usage: /doctor <host:port> — P2P finds peers over mDNS, so give an address to test',
941
+ );
942
+ break;
943
+ }
944
+ this.#ui.addInfoMessage(`Diagnosing ${target} …`);
945
+ diagnose(target)
946
+ .then((steps) => {
947
+ for (const line of formatDiagnosis(steps)) {
948
+ this.#ui.addInfoMessage(line);
949
+ }
950
+ })
951
+ .catch((err) => {
952
+ this.#ui.addErrorMessage(`Diagnostics failed to run: ${err.message}`);
953
+ });
954
+ break;
955
+ }
956
+
957
+ case '/find': {
958
+ // Pure UI: searches the lines on screen, so it works the same here.
959
+ this.#ui.openFinder(parts.slice(1).join(' ').trim());
960
+ break;
961
+ }
962
+
963
+ case '/receipts': {
964
+ const arg = parts[1]?.toLowerCase();
965
+ if (arg === 'off') {
966
+ this.#receiptsEnabled = false;
967
+ this.#ui.addInfoMessage('Read receipts disabled — you no longer send read confirmations');
968
+ } else if (arg === 'on') {
969
+ this.#receiptsEnabled = true;
970
+ this.#ui.addInfoMessage('Read receipts enabled');
971
+ } else {
972
+ this.#ui.addInfoMessage(
973
+ `Read receipts: ${this.#receiptsEnabled ? 'enabled' : 'disabled'}. Use /receipts on or /receipts off`,
974
+ );
975
+ }
976
+ break;
977
+ }
978
+
979
+ case '/search': {
980
+ if (!this.#historyStore?.isOpen) {
981
+ this.#ui.addErrorMessage('History disabled — start with a passphrase');
982
+ break;
983
+ }
984
+ const term = parts.slice(1).join(' ');
985
+ if (!term) {
986
+ this.#ui.addErrorMessage('Usage: /search <term>');
987
+ break;
988
+ }
989
+ const results = this.#historyStore.search(term);
990
+ if (results.length === 0) {
991
+ this.#ui.addInfoMessage(`Nothing found for "${term}"`);
992
+ break;
993
+ }
994
+ this.#ui.addInfoMessage(`${results.length} result(s) for "${term}":`);
995
+ for (const e of results) {
996
+ this.#ui.addInfoMessage(` ${this.#formatHistoryEntry(e)}`);
997
+ }
998
+ break;
999
+ }
1000
+
1001
+ case '/history': {
1002
+ if (!this.#historyStore?.isOpen) {
1003
+ this.#ui.addErrorMessage('History disabled — start with a passphrase');
1004
+ break;
1005
+ }
1006
+ const count = parseInt(parts[1]) || 20;
1007
+ const entries = this.#historyStore.recent(count);
1008
+ if (entries.length === 0) {
1009
+ this.#ui.addInfoMessage('History empty');
1010
+ break;
1011
+ }
1012
+ this.#ui.addInfoMessage(`Last ${entries.length} message(s) from history:`);
1013
+ for (const e of entries) {
1014
+ this.#ui.addInfoMessage(` ${this.#formatHistoryEntry(e)}`);
1015
+ }
1016
+ break;
1017
+ }
1018
+
1019
+ case '/export': {
1020
+ if (!this.#historyStore?.isOpen) {
1021
+ this.#ui.addErrorMessage('History disabled — start with a passphrase');
1022
+ break;
1023
+ }
1024
+ if (this.#historyStore.size === 0) {
1025
+ this.#ui.addInfoMessage('History empty, nothing to export');
1026
+ break;
1027
+ }
1028
+ let target = parts.slice(1).join(' ');
1029
+ if (!target) {
1030
+ const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
1031
+ target = `exports/ciphermesh-${stamp}.txt`;
1032
+ }
1033
+ try {
1034
+ const fullPath = resolve(target);
1035
+ mkdirSync(dirname(fullPath), { recursive: true });
1036
+ const count = this.#historyStore.exportTo(fullPath);
1037
+ this.#ui.addSystemMessage(`${count} message(s) exported to ${fullPath}`);
1038
+ this.#ui.addErrorMessage('Warning: the exported file is in plain text');
1039
+ } catch (err) {
1040
+ this.#ui.addErrorMessage(`Export failed: ${err.message}`);
1041
+ }
1042
+ break;
1043
+ }
1044
+
1045
+ case '/retention': {
1046
+ if (!this.#historyStore?.isOpen) {
1047
+ this.#ui.addErrorMessage('History is not active (open the session with a passphrase).');
1048
+ break;
1049
+ }
1050
+ const ms = this.#parseRetentionTime(parts[1]?.toLowerCase());
1051
+ if (!ms) {
1052
+ this.#ui.addErrorMessage('Usage: /retention <time> (e.g. 7d, 24h, 30m)');
1053
+ break;
1054
+ }
1055
+ const removed = this.#historyStore.purgeOlderThan(ms);
1056
+ this.#ui.addSystemMessage(
1057
+ `Retention applied: ${removed} old message(s) removed from local history.`,
1058
+ );
1059
+ break;
1060
+ }
1061
+
1062
+ case '/leave': {
1063
+ if (this.#currentRoom === 'general') {
1064
+ this.#ui.addErrorMessage('You are already in #general');
1065
+ break;
1066
+ }
1067
+ this.#handleCommand('/join general');
1068
+ break;
1069
+ }
1070
+
1071
+ case '/away': {
1072
+ this.#away = true;
1073
+ this.#autoAwaySet = false;
1074
+ this.#awayReason = applyShortcodes(parts.slice(1).join(' ')).slice(0, 60) || null;
1075
+ this.#ui.setHeaderIndicator('away', '{yellow-fg}[away]{/yellow-fg}');
1076
+ this.#ui.addInfoMessage(
1077
+ this.#awayReason ? `You are away: ${this.#awayReason}` : 'You are away',
1078
+ );
1079
+ this.#broadcastPresence();
1080
+ break;
1081
+ }
1082
+
1083
+ case '/back': {
1084
+ if (!this.#away) {
1085
+ this.#ui.addInfoMessage('You are not away');
1086
+ break;
1087
+ }
1088
+ this.#away = false;
1089
+ this.#awayReason = null;
1090
+ this.#autoAwaySet = false;
1091
+ this.#ui.removeHeaderIndicator('away');
1092
+ this.#ui.addInfoMessage("You're back");
1093
+ this.#broadcastPresence();
1094
+ break;
1095
+ }
1096
+
1097
+ case '/autoaway': {
1098
+ const aaArg = parts[1]?.toLowerCase();
1099
+ if (aaArg === 'off' || aaArg === '0') {
1100
+ this.#autoAwayMs = 0;
1101
+ this.#armAutoAway();
1102
+ this.#ui.addInfoMessage('Auto-away disabled');
1103
+ break;
1104
+ }
1105
+ const min = parseInt(aaArg, 10);
1106
+ if (!Number.isInteger(min) || min < 1 || min > 240) {
1107
+ this.#ui.addInfoMessage(
1108
+ `Auto-away: ${this.#autoAwayMs ? `${this.#autoAwayMs / 60000}min` : 'off'}. Usage: /autoaway <minutes|off>`,
1109
+ );
1110
+ break;
1111
+ }
1112
+ this.#autoAwayMs = min * 60_000;
1113
+ this.#armAutoAway();
1114
+ this.#ui.addInfoMessage(`Auto-away after ${min}min of inactivity`);
1115
+ break;
1116
+ }
1117
+
1118
+ case '/status': {
1119
+ const statusArg = parts.slice(1).join(' ').trim();
1120
+ if (!statusArg || statusArg.toLowerCase() === 'off') {
1121
+ this.#statusText = null;
1122
+ this.#ui.addInfoMessage('Status cleared');
1123
+ } else {
1124
+ this.#statusText = applyShortcodes(statusArg).slice(0, 60);
1125
+ this.#ui.addInfoMessage(`Status: ${this.#statusText}`);
1126
+ }
1127
+ this.#broadcastPresence();
1128
+ break;
1129
+ }
1130
+
1131
+ case '/lock':
1132
+ this.#lockNow();
1133
+ break;
1134
+
1135
+ case '/autolock': {
1136
+ const alArg = parts[1]?.toLowerCase();
1137
+ if (alArg === 'off' || alArg === '0') {
1138
+ this.#autoLockMs = 0;
1139
+ this.#armAutoLock();
1140
+ this.#ui.addInfoMessage('Auto-lock disabled');
1141
+ break;
1142
+ }
1143
+ const alMin = parseInt(alArg, 10);
1144
+ if (!Number.isInteger(alMin) || alMin < 1 || alMin > 240) {
1145
+ this.#ui.addInfoMessage(
1146
+ `Auto-lock: ${this.#autoLockMs ? `${this.#autoLockMs / 60000}min` : 'off'}. Usage: /autolock <minutes|off>`,
1147
+ );
1148
+ break;
1149
+ }
1150
+ if (!this.#passphrase) {
1151
+ this.#ui.addErrorMessage(
1152
+ 'No session passphrase — auto-lock needs one (set it at startup)',
1153
+ );
1154
+ break;
1155
+ }
1156
+ this.#autoLockMs = alMin * 60_000;
1157
+ this.#armAutoLock();
1158
+ this.#ui.addInfoMessage(`Auto-lock after ${alMin}min of inactivity`);
1159
+ break;
1160
+ }
1161
+
1162
+ case '/mentions': {
1163
+ if (this.#mentions.length === 0) {
1164
+ this.#ui.addInfoMessage('No mentions in this session yet.');
1165
+ break;
1166
+ }
1167
+ const count = Math.min(parseInt(parts[1], 10) || 10, this.#mentions.length);
1168
+ this.#ui.addInfoMessage(`Last ${count} mention(s) of you:`);
1169
+ for (const m of this.#mentions.slice(-count)) {
1170
+ const when = new Date(m.at).toLocaleString('en-US', {
1171
+ hour: '2-digit',
1172
+ minute: '2-digit',
1173
+ });
1174
+ this.#ui.addInfoMessage(` [${when}] [#${m.room}] ${m.nickname}: ${m.text.slice(0, 80)}`);
1175
+ }
1176
+ break;
1177
+ }
1178
+
1179
+ case '/contacts': {
1180
+ const sub = (parts[1] || 'list').toLowerCase();
1181
+ if (sub === 'add') {
1182
+ const nick = parts[2];
1183
+ const alias = parts.slice(3).join(' ').trim();
1184
+ if (!nick || !alias) {
1185
+ this.#ui.addErrorMessage('Usage: /contacts add <nick> <alias>');
1186
+ break;
1187
+ }
1188
+ if (this.#trustStore.setAlias(nick, alias)) {
1189
+ this.#ui.addInfoMessage(`Contact saved: ${nick} → "${alias.slice(0, 30)}"`);
1190
+ } else {
1191
+ this.#ui.addErrorMessage(`"${nick}" was never seen on this identity`);
1192
+ }
1193
+ break;
1194
+ }
1195
+ if (sub === 'remove') {
1196
+ const nick = parts[2];
1197
+ if (!nick) {
1198
+ this.#ui.addErrorMessage('Usage: /contacts remove <nick>');
1199
+ break;
1200
+ }
1201
+ if (this.#trustStore.clearAlias(nick)) {
1202
+ this.#ui.addInfoMessage(`Alias removed from ${nick}`);
1203
+ } else {
1204
+ this.#ui.addErrorMessage(`${nick} has no alias`);
1205
+ }
1206
+ break;
1207
+ }
1208
+ if (sub !== 'list' && sub !== 'all') {
1209
+ this.#ui.addErrorMessage('Usage: /contacts [add <nick> <alias> | remove <nick> | all]');
1210
+ break;
1211
+ }
1212
+ const contacts = this.#trustStore.listContacts(sub === 'all');
1213
+ if (contacts.length === 0) {
1214
+ this.#ui.addInfoMessage(
1215
+ sub === 'all' ? 'No peers known yet.' : 'No contacts yet. Use /contacts add',
1216
+ );
1217
+ break;
1218
+ }
1219
+ this.#ui.addInfoMessage(sub === 'all' ? 'Known peers:' : 'Contacts:');
1220
+ for (const c of contacts) {
1221
+ const badge = c.verified ? ' ✓' : '';
1222
+ const alias = c.alias ? ` (${c.alias})` : '';
1223
+ this.#ui.addInfoMessage(` ${c.nickname}${alias}${badge}`);
1224
+ }
1225
+ break;
1226
+ }
1227
+
1228
+ case '/reply': {
1229
+ const replyText = parts.slice(1).join(' ').trim();
1230
+ if (!replyText) {
1231
+ this.#ui.addErrorMessage('Usage: /reply <text>');
1232
+ break;
1233
+ }
1234
+ if (!this.#lastReceivedMessageId) {
1235
+ this.#ui.addErrorMessage('Nothing to reply to yet');
1236
+ break;
1237
+ }
1238
+ this.#ui.addQuoteLine(
1239
+ this.#lastReceivedNickname,
1240
+ (this.#lastReceivedText || '').slice(0, 80),
1241
+ true,
1242
+ );
1243
+ this.#sendMessageToAll(replyText);
1244
+ break;
1245
+ }
1246
+
1247
+ case '/topic': {
1248
+ const newTopic = parts.slice(1).join(' ').trim();
1249
+ if (!newTopic) {
1250
+ const t = this.#roomTopics.get(this.#currentRoom);
1251
+ this.#ui.addInfoMessage(
1252
+ t?.text
1253
+ ? `Topic of #${this.#currentRoom}: ${t.text} (set by ${t.by})`
1254
+ : `#${this.#currentRoom} has no topic. Set one with /topic <text>`,
1255
+ );
1256
+ break;
1257
+ }
1258
+ const text = newTopic === 'clear' ? '' : applyShortcodes(newTopic).slice(0, 200);
1259
+ const at = Date.now();
1260
+ this.#roomTopics.set(this.#currentRoom, { text, by: this.#nickname, at });
1261
+ this.#ui.setTopic(text || null);
1262
+ this.#broadcastPayload(
1263
+ JSON.stringify({ action: 'set_topic', room: this.#currentRoom, text, at, sentAt: at }),
1264
+ );
1265
+ this.#ui.addSystemMessage(
1266
+ text ? `📋 You set the topic: ${text}` : '📋 You cleared the topic',
1267
+ );
1268
+ break;
1269
+ }
1270
+
1271
+ case '/me': {
1272
+ const actionText = parts.slice(1).join(' ').trim();
1273
+ if (!actionText) {
1274
+ this.#ui.addErrorMessage('Usage: /me <action> — e.g. /me is compiling');
1275
+ break;
1276
+ }
1277
+ this.#sendMessageToAll(actionText, true);
1278
+ break;
1279
+ }
1280
+
1281
+ case '/watch': {
1282
+ const sub = (parts[1] || 'list').toLowerCase();
1283
+ if (sub === 'add') {
1284
+ const word = parts.slice(2).join(' ').trim();
1285
+ if (!word) {
1286
+ this.#ui.addErrorMessage('Usage: /watch add <keyword>');
1287
+ break;
1288
+ }
1289
+ this.#watchWords.add(word.toLowerCase());
1290
+ this.#ui.addInfoMessage(`Watching for "${word.toLowerCase()}"`);
1291
+ break;
1292
+ }
1293
+ if (sub === 'remove' || sub === 'rm') {
1294
+ const word = parts.slice(2).join(' ').trim().toLowerCase();
1295
+ if (this.#watchWords.delete(word)) {
1296
+ this.#ui.addInfoMessage(`No longer watching "${word}"`);
1297
+ } else {
1298
+ this.#ui.addErrorMessage(`"${word}" is not being watched`);
1299
+ }
1300
+ break;
1301
+ }
1302
+ if (sub === 'clear') {
1303
+ this.#watchWords.clear();
1304
+ this.#ui.addInfoMessage('Watch list cleared');
1305
+ break;
1306
+ }
1307
+ if (sub !== 'list') {
1308
+ this.#ui.addErrorMessage('Usage: /watch [add <word> | remove <word> | clear]');
1309
+ break;
1310
+ }
1311
+ if (this.#watchWords.size === 0) {
1312
+ this.#ui.addInfoMessage('Not watching any keyword. Use /watch add <word>');
1313
+ break;
1314
+ }
1315
+ this.#ui.addInfoMessage(`Watching: ${[...this.#watchWords].join(', ')}`);
1316
+ break;
1317
+ }
1318
+
677
1319
  case '/help':
678
1320
  this.#ui.addInfoMessage('Available commands (P2P mode):');
1321
+ this.#ui.addInfoMessage(' /me <action> - Third-person action message');
1322
+ this.#ui.addInfoMessage(' /topic [text|clear] - Show or set the room topic');
1323
+ this.#ui.addInfoMessage(' /reply <text> - Reply to the last received message');
1324
+ this.#ui.addInfoMessage(' /away [reason] /back - Presence');
1325
+ this.#ui.addInfoMessage(' /autoaway <min|off> - Auto-away on inactivity');
1326
+ this.#ui.addInfoMessage(' /status <text|off> - Set a status');
1327
+ this.#ui.addInfoMessage(' /lock - Lock the screen (session passphrase)');
1328
+ this.#ui.addInfoMessage(' /autolock <min|off> - Auto-lock on inactivity');
1329
+ this.#ui.addInfoMessage(' /mentions [n] - Recent mentions of you');
1330
+ this.#ui.addInfoMessage(' /contacts [add|remove|all] - Contact book');
1331
+ this.#ui.addInfoMessage(' /leave - Go back to #general');
1332
+ this.#ui.addInfoMessage(' /search <term> - Search the encrypted local history');
1333
+ this.#ui.addInfoMessage(' /history [n] - Last n messages from history');
1334
+ this.#ui.addInfoMessage(' /export [path] - Export the history');
1335
+ this.#ui.addInfoMessage(' /retention <time> - Local history retention');
1336
+ this.#ui.addInfoMessage(' /receipts [on|off] - Read receipts (✓✓)');
1337
+ this.#ui.addInfoMessage(' /find [term] - Find in this room and jump (Ctrl+F)');
1338
+ this.#ui.addInfoMessage(' /doctor <host:port> - Diagnose why a connection fails');
1339
+ this.#ui.addInfoMessage(' /watch [add|remove|clear] - Alert on a keyword');
679
1340
  this.#ui.addInfoMessage(' /help - Show this help');
680
1341
  this.#ui.addInfoMessage(' /tips - Show a security/UX tip');
681
1342
  this.#ui.addInfoMessage(' /users - List connected peers');
@@ -1387,6 +2048,21 @@ export class P2PChatController {
1387
2048
  }
1388
2049
  }
1389
2050
 
2051
+ // The STORED peer object (mutable). #findPeer returns a copy, which is fine
2052
+ // for reads but silently drops writes like presence updates.
2053
+ #livePeer(nickname) {
2054
+ const direct = this.#peers.get(nickname);
2055
+ if (direct) {
2056
+ return direct;
2057
+ }
2058
+ for (const [name, p] of this.#peers) {
2059
+ if (name.toLowerCase() === nickname.toLowerCase()) {
2060
+ return p;
2061
+ }
2062
+ }
2063
+ return null;
2064
+ }
2065
+
1390
2066
  #findPeer(nickname) {
1391
2067
  const direct = this.#peers.get(nickname);
1392
2068
  if (direct) {
@@ -1689,7 +2365,7 @@ export class P2PChatController {
1689
2365
  }
1690
2366
  }
1691
2367
 
1692
- #sendMessageToAll(text) {
2368
+ #sendMessageToAll(text, isAction = false) {
1693
2369
  const inMyRoom = (n) => (this.#peerRooms.get(n) || 'general') === this.#currentRoom;
1694
2370
  const onlineInRoom = [...this.#peers.keys()].filter(inMyRoom);
1695
2371
  const offlineKnownInRoom = [...this.#knownPeers].filter(
@@ -1708,6 +2384,9 @@ export class P2PChatController {
1708
2384
  messageId,
1709
2385
  room: this.#currentRoom,
1710
2386
  };
2387
+ if (isAction) {
2388
+ msgObj.isAction = true;
2389
+ }
1711
2390
 
1712
2391
  this.#lastSentMessageId = messageId;
1713
2392
 
@@ -1741,20 +2420,84 @@ export class P2PChatController {
1741
2420
  }
1742
2421
  }
1743
2422
 
2423
+ if (this.#historyStore?.isOpen && !this.#ephemeralMode && !this.#deniableMode) {
2424
+ this.#historyStore.append({
2425
+ room: this.#currentRoom,
2426
+ nickname: this.#nickname,
2427
+ text,
2428
+ isDM: false,
2429
+ });
2430
+ }
2431
+
1744
2432
  const ephLabel = this.#ephemeralMode ? this.#formatDuration(this.#ephemeralDurationMs) : null;
1745
- const { lineIndex } = this.#ui.addMessage(
1746
- this.#nickname,
1747
- text,
1748
- false,
1749
- ephLabel,
1750
- this.#deniableMode,
1751
- );
2433
+ const { lineIndex } = isAction
2434
+ ? this.#ui.addActionMessage(this.#nickname, text)
2435
+ : this.#ui.addMessage(this.#nickname, text, false, ephLabel, this.#deniableMode);
1752
2436
 
1753
2437
  if (this.#ephemeralMode) {
1754
2438
  this.#scheduleEphemeralRemoval(lineIndex, this.#ephemeralDurationMs, this.#nickname);
2439
+ } else if (!this.#deniableMode) {
2440
+ this.#trackSentMessage(messageId, lineIndex);
1755
2441
  }
1756
2442
  }
1757
2443
 
2444
+ // ── Read receipts ────────────────────────────────────────────
2445
+ #trackSentMessage(messageId, lineIndex) {
2446
+ const baseLine = this.#ui.getLine(lineIndex);
2447
+ if (baseLine === null || baseLine === undefined) {
2448
+ return;
2449
+ }
2450
+ this.#sentMessageLines.set(messageId, { lineIndex, baseLine, room: this.#currentRoom });
2451
+
2452
+ // A peer on the same machine (or a very fast link) can acknowledge before
2453
+ // we finish rendering our own echo. Apply anything that arrived early.
2454
+ const early = this.#pendingReceipts.get(messageId);
2455
+ if (early) {
2456
+ this.#pendingReceipts.delete(messageId);
2457
+ for (const nickname of early) {
2458
+ this.#onReadReceipt(nickname, messageId);
2459
+ }
2460
+ }
2461
+
2462
+ // Bound memory: keep only the most recent 200 tracked messages
2463
+ if (this.#sentMessageLines.size > 200) {
2464
+ const oldest = this.#sentMessageLines.keys().next().value;
2465
+ this.#sentMessageLines.delete(oldest);
2466
+ this.#messageReaders.delete(oldest);
2467
+ }
2468
+ }
2469
+
2470
+ #onReadReceipt(nickname, messageId) {
2471
+ const tracked = this.#sentMessageLines.get(messageId);
2472
+ if (!tracked) {
2473
+ // Arrived before we tracked our own message — remember it (bounded) and
2474
+ // let #trackSentMessage apply it a moment later.
2475
+ if (messageId && this.#pendingReceipts.size < 200) {
2476
+ const set = this.#pendingReceipts.get(messageId) || new Set();
2477
+ set.add(nickname);
2478
+ this.#pendingReceipts.set(messageId, set);
2479
+ }
2480
+ return;
2481
+ }
2482
+ // Line indexes only address the room currently on screen.
2483
+ if (tracked.room && tracked.room !== this.#currentRoom) {
2484
+ return;
2485
+ }
2486
+
2487
+ let readers = this.#messageReaders.get(messageId);
2488
+ if (!readers) {
2489
+ readers = new Set();
2490
+ this.#messageReaders.set(messageId, readers);
2491
+ }
2492
+ if (readers.has(nickname)) {
2493
+ return;
2494
+ }
2495
+ readers.add(nickname);
2496
+
2497
+ const marker = readers.size > 1 ? `✓✓ ${readers.size}` : '✓✓';
2498
+ this.#ui.appendBadge(tracked.lineIndex, tracked.baseLine, `{green-fg}${marker}{/green-fg}`);
2499
+ }
2500
+
1758
2501
  // ── Send encrypted DM to one peer ────────────────────────────
1759
2502
  #sendMessageToPeer(peerNickname, text) {
1760
2503
  const peerPublicKey = this.#handshake.getPeerPublicKey(peerNickname);