ciphermesh 2.2.0 → 2.4.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,7 @@ 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';
25
26
  import { deriveSharedKey, encryptDeniable, decryptDeniable } from '../crypto/DeniableEncrypt.js';
26
27
  import { GroupSession } from '../crypto/SenderKey.js';
27
28
  import { suggestCommand } from '../shared/commandSuggest.js';
@@ -30,7 +31,13 @@ import { recordVoiceNote, playVoiceNote, isAudioFile } from '../shared/voiceNote
30
31
  import { setTheme, getThemeName, themeNames } from '../shared/themes.js';
31
32
  import { panicWipe } from '../shared/panic.js';
32
33
  import { farewellBanner } from '../shared/banner.js';
33
- import { parseDndWindow, shouldNotify, nowMinutes, mentionsMe } from '../shared/dnd.js';
34
+ import {
35
+ parseDndWindow,
36
+ shouldNotify,
37
+ nowMinutes,
38
+ mentionsMe,
39
+ matchesKeyword,
40
+ } from '../shared/dnd.js';
34
41
  import { trustBadge } from '../shared/trust.js';
35
42
  import { tipAt, TIPS } from '../shared/tips.js';
36
43
  import { COMMANDS } from '../client/UI.js';
@@ -71,6 +78,18 @@ export class P2PChatController {
71
78
  #ephemeralMode;
72
79
  #ephemeralDurationMs;
73
80
  #ephemeralTimers;
81
+ #watchWords = new Set(); // /watch — keywords that alert like a mention does
82
+ #mentions = []; // session mention log (/mentions)
83
+ #away = false;
84
+ #awayReason = null;
85
+ #statusText = null;
86
+ #autoAwayMs = 0;
87
+ #autoAwayTimer = null;
88
+ #autoAwaySet = false;
89
+ #autoLockMs = 0;
90
+ #autoLockTimer = null;
91
+ #roomTopics = new Map(); // room → { text, by, at } (E2EE among peers)
92
+ #historyStore; // encrypted local history (opt-in, needs a passphrase)
74
93
  #lastReceivedMessageId;
75
94
  #lastReceivedNickname;
76
95
  #lastSentMessageId;
@@ -92,6 +111,7 @@ export class P2PChatController {
92
111
  keyManager,
93
112
  restoredState = null,
94
113
  pluginManager = null,
114
+ historyStore = null,
95
115
  ) {
96
116
  this.#nickname = nickname;
97
117
  this.#connManager = connManager;
@@ -119,6 +139,7 @@ export class P2PChatController {
119
139
  this.#trustStore.importData(restoredState.trust);
120
140
  }
121
141
  this.#auditLog = new AuditLog();
142
+ this.#historyStore = historyStore;
122
143
  this.#ephemeralMode = false;
123
144
  this.#ephemeralDurationMs = 0;
124
145
  this.#ephemeralTimers = [];
@@ -180,6 +201,16 @@ export class P2PChatController {
180
201
  this.#handleTypingActivity();
181
202
  });
182
203
 
204
+ this.#ui.on('unlocked', () => {
205
+ this.#auditLog.log(AuditEvent.SCREEN_UNLOCKED, {});
206
+ this.#ui.addSystemMessage('Screen unlocked');
207
+ this.#noteActive();
208
+ });
209
+
210
+ this.#ui.on('lock-failed', () => {
211
+ this.#auditLog.log(AuditEvent.SCREEN_UNLOCK_FAILED, {});
212
+ });
213
+
183
214
  this.#ui.on('quit', () => {
184
215
  this.destroy();
185
216
  process.exit(0);
@@ -206,6 +237,17 @@ export class P2PChatController {
206
237
  false,
207
238
  nickname,
208
239
  );
240
+
241
+ // Push my sender key too, instead of waiting for THEIR announce to ask for
242
+ // it. Both sides connect at once, and a pairwise message from a peer we
243
+ // have not registered yet is dropped (we cannot authenticate it) — so if
244
+ // my announce lost that race, the peer would never learn my key and could
245
+ // never read my room messages. Announcing AND distributing on every
246
+ // connect makes the exchange succeed whatever the ordering.
247
+ if ((this.#peerRooms.get(nickname) || 'general') === this.#currentRoom) {
248
+ this.#distributeSenderKey(this.#currentRoom, nickname);
249
+ }
250
+
209
251
  this.#flushSFQueue(nickname);
210
252
  }
211
253
 
@@ -547,6 +589,52 @@ export class P2PChatController {
547
589
  if (data.room && data.room !== this.#currentRoom && !data.isDM) {
548
590
  return;
549
591
  }
592
+ if (data.action === 'presence') {
593
+ const peer = this.#livePeer(fromNickname);
594
+ const wasAway = !!peer?.away;
595
+ const oldStatus = peer?.status || null;
596
+ if (peer) {
597
+ peer.away = !!data.away;
598
+ peer.awayReason = typeof data.reason === 'string' ? data.reason.slice(0, 60) : null;
599
+ peer.status = typeof data.status === 'string' ? data.status.slice(0, 60) : null;
600
+ }
601
+ if (data.away && !wasAway) {
602
+ const why = data.reason ? `: ${String(data.reason).slice(0, 60)}` : '';
603
+ this.#ui.addSystemMessage(`${fromNickname} is away${why}`);
604
+ } else if (!data.away && wasAway) {
605
+ this.#ui.addSystemMessage(`${fromNickname} is back`);
606
+ }
607
+ if (peer?.status && peer.status !== oldStatus) {
608
+ this.#ui.addSystemMessage(`${fromNickname} set status: ${peer.status}`);
609
+ }
610
+ return;
611
+ }
612
+
613
+ if (data.action === 'set_topic') {
614
+ const room = typeof data.room === 'string' ? data.room : this.#currentRoom;
615
+ if (typeof data.text === 'string') {
616
+ const at = Number(data.at) || 0;
617
+ const current = this.#roomTopics.get(room);
618
+ // Last write wins — on join everyone who knows it answers.
619
+ if (!current || at >= current.at) {
620
+ const text = data.text.slice(0, 200);
621
+ const changed = current?.text !== text;
622
+ this.#roomTopics.set(room, { text, by: fromNickname, at });
623
+ if (room === this.#currentRoom) {
624
+ this.#ui.setTopic(text || null);
625
+ }
626
+ if (changed && !data.silent) {
627
+ this.#ui.addSystemMessage(
628
+ text
629
+ ? `📋 ${fromNickname} set the topic of #${room}: ${text}`
630
+ : `📋 ${fromNickname} cleared the topic of #${room}`,
631
+ );
632
+ }
633
+ }
634
+ }
635
+ return;
636
+ }
637
+
550
638
  this.#hidePeerTyping(fromNickname);
551
639
  if (data.messageId) {
552
640
  this.#lastReceivedMessageId = data.messageId;
@@ -554,21 +642,49 @@ export class P2PChatController {
554
642
  this.#lastReceivedText = data.text;
555
643
  this.#messageAuthors.set(data.messageId, fromNickname);
556
644
  }
557
- const mentioned = mentionsMe(data.text, this.#nickname) && !data.isDM;
645
+ const watchHit = this.#matchedWatch(data.text);
646
+ // A watched keyword deserves the same attention a mention gets.
647
+ const mentioned = (mentionsMe(data.text, this.#nickname) || !!watchHit) && !data.isDM;
648
+ if (watchHit) {
649
+ this.#ui.addSystemMessage(`👁 "${watchHit}" mentioned by ${fromNickname}`);
650
+ }
651
+ if (mentioned) {
652
+ this.#mentions.push({
653
+ nickname: fromNickname,
654
+ text: data.text,
655
+ room: this.#currentRoom,
656
+ at: Date.now(),
657
+ });
658
+ if (this.#mentions.length > 50) {
659
+ this.#mentions.shift();
660
+ }
661
+ }
662
+ // Persist to encrypted history — never ephemeral or deniable messages
663
+ if (this.#historyStore?.isOpen && !data.ephemeral && !isDeniable && !data.deniable) {
664
+ this.#historyStore.append({
665
+ room: this.#currentRoom,
666
+ nickname: fromNickname,
667
+ text: data.text,
668
+ isDM: !!data.isDM,
669
+ });
670
+ }
671
+
558
672
  const ephLabel = data.ephemeral ? this.#formatDuration(data.ephemeral) : null;
559
673
  const trust = trustBadge(
560
674
  this.#trustStore.getPeerRecord(fromNickname),
561
675
  this.#findPeer(fromNickname)?.publicKey,
562
676
  );
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
- );
677
+ const { lineIndex } = data.isAction
678
+ ? this.#ui.addActionMessage(fromNickname, data.text)
679
+ : this.#ui.addMessage(
680
+ fromNickname,
681
+ data.text,
682
+ !!data.isDM,
683
+ ephLabel,
684
+ isDeniable || !!data.deniable,
685
+ mentioned,
686
+ trust,
687
+ );
572
688
  const notify = shouldNotify(this.#dndMode, this.#dndWindow, nowMinutes(), mentioned);
573
689
  if (notify) {
574
690
  this.#ui.playNotification();
@@ -661,6 +777,7 @@ export class P2PChatController {
661
777
 
662
778
  // ── User input ─────────────────────────────────────────────────
663
779
  #handleUserInput(text) {
780
+ this.#noteActive();
664
781
  if (text.startsWith('/')) {
665
782
  this.#handleCommand(text);
666
783
  return;
@@ -669,13 +786,484 @@ export class P2PChatController {
669
786
  this.#sendMessageToAll(text);
670
787
  }
671
788
 
789
+ // ── Presence (away/status), mirrored from the relay client ──
790
+ #presencePayload() {
791
+ return JSON.stringify({
792
+ action: 'presence',
793
+ away: this.#away,
794
+ reason: this.#awayReason,
795
+ status: this.#statusText,
796
+ sentAt: Date.now(),
797
+ });
798
+ }
799
+
800
+ #broadcastPresence() {
801
+ this.#broadcastPayload(this.#presencePayload());
802
+ }
803
+
804
+ // ── Auto-away / auto-lock on inactivity ────────────────────
805
+ #noteActive() {
806
+ if (this.#autoAwaySet && this.#away) {
807
+ this.#away = false;
808
+ this.#awayReason = null;
809
+ this.#autoAwaySet = false;
810
+ this.#ui.removeHeaderIndicator('away');
811
+ this.#ui.addSystemMessage("You're back (auto)");
812
+ this.#broadcastPresence();
813
+ }
814
+ this.#armAutoAway();
815
+ this.#armAutoLock();
816
+ }
817
+
818
+ #armAutoAway() {
819
+ if (this.#autoAwayTimer) {
820
+ clearTimeout(this.#autoAwayTimer);
821
+ this.#autoAwayTimer = null;
822
+ }
823
+ if (this.#autoAwayMs > 0) {
824
+ this.#autoAwayTimer = setTimeout(() => this.#triggerAutoAway(), this.#autoAwayMs);
825
+ if (this.#autoAwayTimer.unref) {
826
+ this.#autoAwayTimer.unref();
827
+ }
828
+ }
829
+ }
830
+
831
+ #triggerAutoAway() {
832
+ if (this.#away) {
833
+ return;
834
+ }
835
+ this.#away = true;
836
+ this.#awayReason = 'away (idle)';
837
+ this.#autoAwaySet = true;
838
+ this.#ui.setHeaderIndicator('away', '{yellow-fg}[away]{/yellow-fg}');
839
+ this.#ui.addSystemMessage('Auto-away: marked as away due to inactivity');
840
+ this.#broadcastPresence();
841
+ }
842
+
843
+ #lockNow() {
844
+ if (!this.#passphrase) {
845
+ this.#ui.addErrorMessage(
846
+ 'No session passphrase — /lock needs one (set it at startup to enable locking)',
847
+ );
848
+ return;
849
+ }
850
+ if (this.#ui.isLocked) {
851
+ return;
852
+ }
853
+ this.#auditLog.log(AuditEvent.SCREEN_LOCKED, {});
854
+ this.#ui.showLock((attempt) => attempt === this.#passphrase);
855
+ }
856
+
857
+ #armAutoLock() {
858
+ if (this.#autoLockTimer) {
859
+ clearTimeout(this.#autoLockTimer);
860
+ this.#autoLockTimer = null;
861
+ }
862
+ if (this.#autoLockMs > 0) {
863
+ this.#autoLockTimer = setTimeout(() => this.#lockNow(), this.#autoLockMs);
864
+ if (this.#autoLockTimer.unref) {
865
+ this.#autoLockTimer.unref();
866
+ }
867
+ }
868
+ }
869
+
870
+ #formatHistoryEntry(e) {
871
+ const when = new Date(e.ts).toLocaleString('en-US', {
872
+ day: '2-digit',
873
+ month: '2-digit',
874
+ hour: '2-digit',
875
+ minute: '2-digit',
876
+ });
877
+ const dm = e.isDM ? ' (DM)' : '';
878
+ return `[${when}] [#${e.room}]${dm} ${e.nickname}: ${e.text}`;
879
+ }
880
+
881
+ #parseRetentionTime(arg) {
882
+ if (!arg) {
883
+ return null;
884
+ }
885
+ const m = arg.match(/^(\d+)([mhd])$/);
886
+ if (!m) {
887
+ return null;
888
+ }
889
+ const n = parseInt(m[1], 10);
890
+ const unit = { m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2]];
891
+ return n > 0 ? n * unit : null;
892
+ }
893
+
894
+ // The first /watch keyword present in the text, or null.
895
+ #matchedWatch(text) {
896
+ for (const word of this.#watchWords) {
897
+ if (matchesKeyword(text, word)) {
898
+ return word;
899
+ }
900
+ }
901
+ return null;
902
+ }
903
+
672
904
  #handleCommand(text) {
673
905
  const parts = text.split(/\s+/);
674
906
  const cmd = parts[0].toLowerCase();
675
907
 
676
908
  switch (cmd) {
909
+ case '/search': {
910
+ if (!this.#historyStore?.isOpen) {
911
+ this.#ui.addErrorMessage('History disabled — start with a passphrase');
912
+ break;
913
+ }
914
+ const term = parts.slice(1).join(' ');
915
+ if (!term) {
916
+ this.#ui.addErrorMessage('Usage: /search <term>');
917
+ break;
918
+ }
919
+ const results = this.#historyStore.search(term);
920
+ if (results.length === 0) {
921
+ this.#ui.addInfoMessage(`Nothing found for "${term}"`);
922
+ break;
923
+ }
924
+ this.#ui.addInfoMessage(`${results.length} result(s) for "${term}":`);
925
+ for (const e of results) {
926
+ this.#ui.addInfoMessage(` ${this.#formatHistoryEntry(e)}`);
927
+ }
928
+ break;
929
+ }
930
+
931
+ case '/history': {
932
+ if (!this.#historyStore?.isOpen) {
933
+ this.#ui.addErrorMessage('History disabled — start with a passphrase');
934
+ break;
935
+ }
936
+ const count = parseInt(parts[1]) || 20;
937
+ const entries = this.#historyStore.recent(count);
938
+ if (entries.length === 0) {
939
+ this.#ui.addInfoMessage('History empty');
940
+ break;
941
+ }
942
+ this.#ui.addInfoMessage(`Last ${entries.length} message(s) from history:`);
943
+ for (const e of entries) {
944
+ this.#ui.addInfoMessage(` ${this.#formatHistoryEntry(e)}`);
945
+ }
946
+ break;
947
+ }
948
+
949
+ case '/export': {
950
+ if (!this.#historyStore?.isOpen) {
951
+ this.#ui.addErrorMessage('History disabled — start with a passphrase');
952
+ break;
953
+ }
954
+ if (this.#historyStore.size === 0) {
955
+ this.#ui.addInfoMessage('History empty, nothing to export');
956
+ break;
957
+ }
958
+ let target = parts.slice(1).join(' ');
959
+ if (!target) {
960
+ const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, '-');
961
+ target = `exports/ciphermesh-${stamp}.txt`;
962
+ }
963
+ try {
964
+ const fullPath = resolve(target);
965
+ mkdirSync(dirname(fullPath), { recursive: true });
966
+ const count = this.#historyStore.exportTo(fullPath);
967
+ this.#ui.addSystemMessage(`${count} message(s) exported to ${fullPath}`);
968
+ this.#ui.addErrorMessage('Warning: the exported file is in plain text');
969
+ } catch (err) {
970
+ this.#ui.addErrorMessage(`Export failed: ${err.message}`);
971
+ }
972
+ break;
973
+ }
974
+
975
+ case '/retention': {
976
+ if (!this.#historyStore?.isOpen) {
977
+ this.#ui.addErrorMessage('History is not active (open the session with a passphrase).');
978
+ break;
979
+ }
980
+ const ms = this.#parseRetentionTime(parts[1]?.toLowerCase());
981
+ if (!ms) {
982
+ this.#ui.addErrorMessage('Usage: /retention <time> (e.g. 7d, 24h, 30m)');
983
+ break;
984
+ }
985
+ const removed = this.#historyStore.purgeOlderThan(ms);
986
+ this.#ui.addSystemMessage(
987
+ `Retention applied: ${removed} old message(s) removed from local history.`,
988
+ );
989
+ break;
990
+ }
991
+
992
+ case '/leave': {
993
+ if (this.#currentRoom === 'general') {
994
+ this.#ui.addErrorMessage('You are already in #general');
995
+ break;
996
+ }
997
+ this.#handleCommand('/join general');
998
+ break;
999
+ }
1000
+
1001
+ case '/away': {
1002
+ this.#away = true;
1003
+ this.#autoAwaySet = false;
1004
+ this.#awayReason = applyShortcodes(parts.slice(1).join(' ')).slice(0, 60) || null;
1005
+ this.#ui.setHeaderIndicator('away', '{yellow-fg}[away]{/yellow-fg}');
1006
+ this.#ui.addInfoMessage(
1007
+ this.#awayReason ? `You are away: ${this.#awayReason}` : 'You are away',
1008
+ );
1009
+ this.#broadcastPresence();
1010
+ break;
1011
+ }
1012
+
1013
+ case '/back': {
1014
+ if (!this.#away) {
1015
+ this.#ui.addInfoMessage('You are not away');
1016
+ break;
1017
+ }
1018
+ this.#away = false;
1019
+ this.#awayReason = null;
1020
+ this.#autoAwaySet = false;
1021
+ this.#ui.removeHeaderIndicator('away');
1022
+ this.#ui.addInfoMessage("You're back");
1023
+ this.#broadcastPresence();
1024
+ break;
1025
+ }
1026
+
1027
+ case '/autoaway': {
1028
+ const aaArg = parts[1]?.toLowerCase();
1029
+ if (aaArg === 'off' || aaArg === '0') {
1030
+ this.#autoAwayMs = 0;
1031
+ this.#armAutoAway();
1032
+ this.#ui.addInfoMessage('Auto-away disabled');
1033
+ break;
1034
+ }
1035
+ const min = parseInt(aaArg, 10);
1036
+ if (!Number.isInteger(min) || min < 1 || min > 240) {
1037
+ this.#ui.addInfoMessage(
1038
+ `Auto-away: ${this.#autoAwayMs ? `${this.#autoAwayMs / 60000}min` : 'off'}. Usage: /autoaway <minutes|off>`,
1039
+ );
1040
+ break;
1041
+ }
1042
+ this.#autoAwayMs = min * 60_000;
1043
+ this.#armAutoAway();
1044
+ this.#ui.addInfoMessage(`Auto-away after ${min}min of inactivity`);
1045
+ break;
1046
+ }
1047
+
1048
+ case '/status': {
1049
+ const statusArg = parts.slice(1).join(' ').trim();
1050
+ if (!statusArg || statusArg.toLowerCase() === 'off') {
1051
+ this.#statusText = null;
1052
+ this.#ui.addInfoMessage('Status cleared');
1053
+ } else {
1054
+ this.#statusText = applyShortcodes(statusArg).slice(0, 60);
1055
+ this.#ui.addInfoMessage(`Status: ${this.#statusText}`);
1056
+ }
1057
+ this.#broadcastPresence();
1058
+ break;
1059
+ }
1060
+
1061
+ case '/lock':
1062
+ this.#lockNow();
1063
+ break;
1064
+
1065
+ case '/autolock': {
1066
+ const alArg = parts[1]?.toLowerCase();
1067
+ if (alArg === 'off' || alArg === '0') {
1068
+ this.#autoLockMs = 0;
1069
+ this.#armAutoLock();
1070
+ this.#ui.addInfoMessage('Auto-lock disabled');
1071
+ break;
1072
+ }
1073
+ const alMin = parseInt(alArg, 10);
1074
+ if (!Number.isInteger(alMin) || alMin < 1 || alMin > 240) {
1075
+ this.#ui.addInfoMessage(
1076
+ `Auto-lock: ${this.#autoLockMs ? `${this.#autoLockMs / 60000}min` : 'off'}. Usage: /autolock <minutes|off>`,
1077
+ );
1078
+ break;
1079
+ }
1080
+ if (!this.#passphrase) {
1081
+ this.#ui.addErrorMessage(
1082
+ 'No session passphrase — auto-lock needs one (set it at startup)',
1083
+ );
1084
+ break;
1085
+ }
1086
+ this.#autoLockMs = alMin * 60_000;
1087
+ this.#armAutoLock();
1088
+ this.#ui.addInfoMessage(`Auto-lock after ${alMin}min of inactivity`);
1089
+ break;
1090
+ }
1091
+
1092
+ case '/mentions': {
1093
+ if (this.#mentions.length === 0) {
1094
+ this.#ui.addInfoMessage('No mentions in this session yet.');
1095
+ break;
1096
+ }
1097
+ const count = Math.min(parseInt(parts[1], 10) || 10, this.#mentions.length);
1098
+ this.#ui.addInfoMessage(`Last ${count} mention(s) of you:`);
1099
+ for (const m of this.#mentions.slice(-count)) {
1100
+ const when = new Date(m.at).toLocaleString('en-US', {
1101
+ hour: '2-digit',
1102
+ minute: '2-digit',
1103
+ });
1104
+ this.#ui.addInfoMessage(` [${when}] [#${m.room}] ${m.nickname}: ${m.text.slice(0, 80)}`);
1105
+ }
1106
+ break;
1107
+ }
1108
+
1109
+ case '/contacts': {
1110
+ const sub = (parts[1] || 'list').toLowerCase();
1111
+ if (sub === 'add') {
1112
+ const nick = parts[2];
1113
+ const alias = parts.slice(3).join(' ').trim();
1114
+ if (!nick || !alias) {
1115
+ this.#ui.addErrorMessage('Usage: /contacts add <nick> <alias>');
1116
+ break;
1117
+ }
1118
+ if (this.#trustStore.setAlias(nick, alias)) {
1119
+ this.#ui.addInfoMessage(`Contact saved: ${nick} → "${alias.slice(0, 30)}"`);
1120
+ } else {
1121
+ this.#ui.addErrorMessage(`"${nick}" was never seen on this identity`);
1122
+ }
1123
+ break;
1124
+ }
1125
+ if (sub === 'remove') {
1126
+ const nick = parts[2];
1127
+ if (!nick) {
1128
+ this.#ui.addErrorMessage('Usage: /contacts remove <nick>');
1129
+ break;
1130
+ }
1131
+ if (this.#trustStore.clearAlias(nick)) {
1132
+ this.#ui.addInfoMessage(`Alias removed from ${nick}`);
1133
+ } else {
1134
+ this.#ui.addErrorMessage(`${nick} has no alias`);
1135
+ }
1136
+ break;
1137
+ }
1138
+ if (sub !== 'list' && sub !== 'all') {
1139
+ this.#ui.addErrorMessage('Usage: /contacts [add <nick> <alias> | remove <nick> | all]');
1140
+ break;
1141
+ }
1142
+ const contacts = this.#trustStore.listContacts(sub === 'all');
1143
+ if (contacts.length === 0) {
1144
+ this.#ui.addInfoMessage(
1145
+ sub === 'all' ? 'No peers known yet.' : 'No contacts yet. Use /contacts add',
1146
+ );
1147
+ break;
1148
+ }
1149
+ this.#ui.addInfoMessage(sub === 'all' ? 'Known peers:' : 'Contacts:');
1150
+ for (const c of contacts) {
1151
+ const badge = c.verified ? ' ✓' : '';
1152
+ const alias = c.alias ? ` (${c.alias})` : '';
1153
+ this.#ui.addInfoMessage(` ${c.nickname}${alias}${badge}`);
1154
+ }
1155
+ break;
1156
+ }
1157
+
1158
+ case '/reply': {
1159
+ const replyText = parts.slice(1).join(' ').trim();
1160
+ if (!replyText) {
1161
+ this.#ui.addErrorMessage('Usage: /reply <text>');
1162
+ break;
1163
+ }
1164
+ if (!this.#lastReceivedMessageId) {
1165
+ this.#ui.addErrorMessage('Nothing to reply to yet');
1166
+ break;
1167
+ }
1168
+ this.#ui.addQuoteLine(
1169
+ this.#lastReceivedNickname,
1170
+ (this.#lastReceivedText || '').slice(0, 80),
1171
+ true,
1172
+ );
1173
+ this.#sendMessageToAll(replyText);
1174
+ break;
1175
+ }
1176
+
1177
+ case '/topic': {
1178
+ const newTopic = parts.slice(1).join(' ').trim();
1179
+ if (!newTopic) {
1180
+ const t = this.#roomTopics.get(this.#currentRoom);
1181
+ this.#ui.addInfoMessage(
1182
+ t?.text
1183
+ ? `Topic of #${this.#currentRoom}: ${t.text} (set by ${t.by})`
1184
+ : `#${this.#currentRoom} has no topic. Set one with /topic <text>`,
1185
+ );
1186
+ break;
1187
+ }
1188
+ const text = newTopic === 'clear' ? '' : applyShortcodes(newTopic).slice(0, 200);
1189
+ const at = Date.now();
1190
+ this.#roomTopics.set(this.#currentRoom, { text, by: this.#nickname, at });
1191
+ this.#ui.setTopic(text || null);
1192
+ this.#broadcastPayload(
1193
+ JSON.stringify({ action: 'set_topic', room: this.#currentRoom, text, at, sentAt: at }),
1194
+ );
1195
+ this.#ui.addSystemMessage(
1196
+ text ? `📋 You set the topic: ${text}` : '📋 You cleared the topic',
1197
+ );
1198
+ break;
1199
+ }
1200
+
1201
+ case '/me': {
1202
+ const actionText = parts.slice(1).join(' ').trim();
1203
+ if (!actionText) {
1204
+ this.#ui.addErrorMessage('Usage: /me <action> — e.g. /me is compiling');
1205
+ break;
1206
+ }
1207
+ this.#sendMessageToAll(actionText, true);
1208
+ break;
1209
+ }
1210
+
1211
+ case '/watch': {
1212
+ const sub = (parts[1] || 'list').toLowerCase();
1213
+ if (sub === 'add') {
1214
+ const word = parts.slice(2).join(' ').trim();
1215
+ if (!word) {
1216
+ this.#ui.addErrorMessage('Usage: /watch add <keyword>');
1217
+ break;
1218
+ }
1219
+ this.#watchWords.add(word.toLowerCase());
1220
+ this.#ui.addInfoMessage(`Watching for "${word.toLowerCase()}"`);
1221
+ break;
1222
+ }
1223
+ if (sub === 'remove' || sub === 'rm') {
1224
+ const word = parts.slice(2).join(' ').trim().toLowerCase();
1225
+ if (this.#watchWords.delete(word)) {
1226
+ this.#ui.addInfoMessage(`No longer watching "${word}"`);
1227
+ } else {
1228
+ this.#ui.addErrorMessage(`"${word}" is not being watched`);
1229
+ }
1230
+ break;
1231
+ }
1232
+ if (sub === 'clear') {
1233
+ this.#watchWords.clear();
1234
+ this.#ui.addInfoMessage('Watch list cleared');
1235
+ break;
1236
+ }
1237
+ if (sub !== 'list') {
1238
+ this.#ui.addErrorMessage('Usage: /watch [add <word> | remove <word> | clear]');
1239
+ break;
1240
+ }
1241
+ if (this.#watchWords.size === 0) {
1242
+ this.#ui.addInfoMessage('Not watching any keyword. Use /watch add <word>');
1243
+ break;
1244
+ }
1245
+ this.#ui.addInfoMessage(`Watching: ${[...this.#watchWords].join(', ')}`);
1246
+ break;
1247
+ }
1248
+
677
1249
  case '/help':
678
1250
  this.#ui.addInfoMessage('Available commands (P2P mode):');
1251
+ this.#ui.addInfoMessage(' /me <action> - Third-person action message');
1252
+ this.#ui.addInfoMessage(' /topic [text|clear] - Show or set the room topic');
1253
+ this.#ui.addInfoMessage(' /reply <text> - Reply to the last received message');
1254
+ this.#ui.addInfoMessage(' /away [reason] /back - Presence');
1255
+ this.#ui.addInfoMessage(' /autoaway <min|off> - Auto-away on inactivity');
1256
+ this.#ui.addInfoMessage(' /status <text|off> - Set a status');
1257
+ this.#ui.addInfoMessage(' /lock - Lock the screen (session passphrase)');
1258
+ this.#ui.addInfoMessage(' /autolock <min|off> - Auto-lock on inactivity');
1259
+ this.#ui.addInfoMessage(' /mentions [n] - Recent mentions of you');
1260
+ this.#ui.addInfoMessage(' /contacts [add|remove|all] - Contact book');
1261
+ this.#ui.addInfoMessage(' /leave - Go back to #general');
1262
+ this.#ui.addInfoMessage(' /search <term> - Search the encrypted local history');
1263
+ this.#ui.addInfoMessage(' /history [n] - Last n messages from history');
1264
+ this.#ui.addInfoMessage(' /export [path] - Export the history');
1265
+ this.#ui.addInfoMessage(' /retention <time> - Local history retention');
1266
+ this.#ui.addInfoMessage(' /watch [add|remove|clear] - Alert on a keyword');
679
1267
  this.#ui.addInfoMessage(' /help - Show this help');
680
1268
  this.#ui.addInfoMessage(' /tips - Show a security/UX tip');
681
1269
  this.#ui.addInfoMessage(' /users - List connected peers');
@@ -1387,6 +1975,21 @@ export class P2PChatController {
1387
1975
  }
1388
1976
  }
1389
1977
 
1978
+ // The STORED peer object (mutable). #findPeer returns a copy, which is fine
1979
+ // for reads but silently drops writes like presence updates.
1980
+ #livePeer(nickname) {
1981
+ const direct = this.#peers.get(nickname);
1982
+ if (direct) {
1983
+ return direct;
1984
+ }
1985
+ for (const [name, p] of this.#peers) {
1986
+ if (name.toLowerCase() === nickname.toLowerCase()) {
1987
+ return p;
1988
+ }
1989
+ }
1990
+ return null;
1991
+ }
1992
+
1390
1993
  #findPeer(nickname) {
1391
1994
  const direct = this.#peers.get(nickname);
1392
1995
  if (direct) {
@@ -1689,7 +2292,7 @@ export class P2PChatController {
1689
2292
  }
1690
2293
  }
1691
2294
 
1692
- #sendMessageToAll(text) {
2295
+ #sendMessageToAll(text, isAction = false) {
1693
2296
  const inMyRoom = (n) => (this.#peerRooms.get(n) || 'general') === this.#currentRoom;
1694
2297
  const onlineInRoom = [...this.#peers.keys()].filter(inMyRoom);
1695
2298
  const offlineKnownInRoom = [...this.#knownPeers].filter(
@@ -1708,6 +2311,9 @@ export class P2PChatController {
1708
2311
  messageId,
1709
2312
  room: this.#currentRoom,
1710
2313
  };
2314
+ if (isAction) {
2315
+ msgObj.isAction = true;
2316
+ }
1711
2317
 
1712
2318
  this.#lastSentMessageId = messageId;
1713
2319
 
@@ -1741,14 +2347,19 @@ export class P2PChatController {
1741
2347
  }
1742
2348
  }
1743
2349
 
2350
+ if (this.#historyStore?.isOpen && !this.#ephemeralMode && !this.#deniableMode) {
2351
+ this.#historyStore.append({
2352
+ room: this.#currentRoom,
2353
+ nickname: this.#nickname,
2354
+ text,
2355
+ isDM: false,
2356
+ });
2357
+ }
2358
+
1744
2359
  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
- );
2360
+ const { lineIndex } = isAction
2361
+ ? this.#ui.addActionMessage(this.#nickname, text)
2362
+ : this.#ui.addMessage(this.#nickname, text, false, ephLabel, this.#deniableMode);
1752
2363
 
1753
2364
  if (this.#ephemeralMode) {
1754
2365
  this.#scheduleEphemeralRemoval(lineIndex, this.#ephemeralDurationMs, this.#nickname);