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.
- package/README.md +23 -2
- package/README.pt-BR.md +23 -2
- package/bin/server-binary.js +7 -0
- package/docs/ARCHITECTURE.md +36 -0
- package/package.json +2 -1
- package/src/client/ChatController.js +217 -27
- package/src/client/Connection.js +31 -1
- package/src/client/FileTransfer.js +53 -24
- package/src/client/UI.js +211 -4
- package/src/crypto/CertPinStore.js +39 -3
- package/src/crypto/DoubleRatchet.js +70 -3
- package/src/crypto/Handshake.js +44 -3
- package/src/crypto/KeyManager.js +27 -0
- package/src/crypto/PQHybrid.js +76 -0
- package/src/p2p/P2PChatController.js +632 -21
- package/src/p2p/index.js +12 -0
- package/src/protocol/messages.js +21 -13
- package/src/protocol/validators.js +16 -2
- package/src/server/SessionManager.js +3 -1
- package/src/server/WebSocketServer.js +15 -2
- package/src/shared/dnd.js +13 -0
|
@@ -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 {
|
|
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
|
|
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 } =
|
|
564
|
-
fromNickname,
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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 } =
|
|
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);
|