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.
- package/README.md +18 -4
- package/README.pt-BR.md +18 -4
- package/package.json +1 -1
- package/src/client/ChatController.js +335 -29
- package/src/client/Connection.js +31 -1
- package/src/client/FileTransfer.js +53 -24
- package/src/client/UI.js +277 -19
- package/src/crypto/CertPinStore.js +39 -3
- package/src/p2p/P2PChatController.js +764 -21
- package/src/p2p/index.js +12 -0
- package/src/protocol/validators.js +10 -1
- package/src/shared/dnd.js +13 -0
- package/src/shared/doctor.js +225 -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,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 {
|
|
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
|
|
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 } =
|
|
564
|
-
fromNickname,
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
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 } =
|
|
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);
|