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
|
@@ -52,8 +52,15 @@ import { tipAt, TIPS } from '../shared/tips.js';
|
|
|
52
52
|
import { setTheme, getThemeName, themeNames } from '../shared/themes.js';
|
|
53
53
|
import { panicWipe } from '../shared/panic.js';
|
|
54
54
|
import { farewellBanner } from '../shared/banner.js';
|
|
55
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
parseDndWindow,
|
|
57
|
+
shouldNotify,
|
|
58
|
+
nowMinutes,
|
|
59
|
+
mentionsMe,
|
|
60
|
+
matchesKeyword,
|
|
61
|
+
} from '../shared/dnd.js';
|
|
56
62
|
import { saveLastSession } from '../shared/lastSession.js';
|
|
63
|
+
import { diagnose, formatDiagnosis } from '../shared/doctor.js';
|
|
57
64
|
import { COMMANDS } from './UI.js';
|
|
58
65
|
|
|
59
66
|
const TYPING_SEND_INTERVAL = 2000; // debounce: max 1 typing event per 2s
|
|
@@ -111,8 +118,12 @@ export class ChatController {
|
|
|
111
118
|
#autoAwayTimer = null;
|
|
112
119
|
#autoAwaySet = false; // whether the current away was set automatically
|
|
113
120
|
#mentions = []; // session mention log: { nickname, text, room, at }
|
|
121
|
+
#watchWords = new Set(); // /watch — keywords that alert like a mention does
|
|
114
122
|
#awayUnread = 0; // messages received while away
|
|
115
123
|
#awayMentions = 0; // …of which mentioned me
|
|
124
|
+
#messageLines = new Map(); // messageId → { lineIndex, nickname, text, opts, room }
|
|
125
|
+
#reactions = new Map(); // messageId → Map<emoji, count>
|
|
126
|
+
#reconnectAttempts = 0; // consecutive reconnects, for the /doctor nudge
|
|
116
127
|
#autoLockMs = 0; // idle screen-lock timeout (0 = off)
|
|
117
128
|
#autoLockTimer = null;
|
|
118
129
|
// Multi-room buffers (IRC style): lines live in the UI; membership, unread
|
|
@@ -205,6 +216,7 @@ export class ChatController {
|
|
|
205
216
|
// the initial JOIN would be lost — the 'connected' event fired before we
|
|
206
217
|
// attached the listener).
|
|
207
218
|
#onConnected() {
|
|
219
|
+
this.#reconnectAttempts = 0;
|
|
208
220
|
this.#ui.setConnectionState('online');
|
|
209
221
|
this.#connection.send(
|
|
210
222
|
createJoin(this.#nickname, this.#keyManager.publicKeyB64, this.#keyManager.pqPublicKeyB64),
|
|
@@ -230,6 +242,18 @@ export class ChatController {
|
|
|
230
242
|
this.#connection.on('reconnecting', (delay) => {
|
|
231
243
|
this.#ui.setConnectionState('reconnecting');
|
|
232
244
|
this.#ui.addSystemMessage(`Reconnecting in ${delay / 1000}s...`);
|
|
245
|
+
// After a few failures this is not a blip — point at the tool that can
|
|
246
|
+
// actually explain it, once, instead of looping silently forever.
|
|
247
|
+
this.#reconnectAttempts++;
|
|
248
|
+
if (this.#reconnectAttempts === 3) {
|
|
249
|
+
this.#ui.addInfoMessage('Still failing? Run /doctor to find out where it breaks.');
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
this.#connection.on('cert-ca-valid', ({ issuer }) => {
|
|
254
|
+
this.#ui.addSystemMessage(
|
|
255
|
+
`TLS verified against a public CA (${issuer}) — no trust-on-first-use window`,
|
|
256
|
+
);
|
|
233
257
|
});
|
|
234
258
|
|
|
235
259
|
this.#connection.on('cert-pinned', ({ fingerprint }) => {
|
|
@@ -466,6 +490,13 @@ export class ChatController {
|
|
|
466
490
|
this.#ui.addErrorMessage(
|
|
467
491
|
`${msg.message}. Use /nick <other> to pick a different nickname.`,
|
|
468
492
|
);
|
|
493
|
+
} else if (typeof msg.message === 'string' && msg.message.startsWith('Protocol mismatch')) {
|
|
494
|
+
// Reconnecting cannot fix a version gap — say so instead of letting
|
|
495
|
+
// the user watch an endless retry loop.
|
|
496
|
+
this.#ui.addErrorMessage(msg.message);
|
|
497
|
+
this.#ui.addInfoMessage(
|
|
498
|
+
'Reconnecting will not help until both sides run the same version.',
|
|
499
|
+
);
|
|
469
500
|
} else if (msg.code === ERR.ROOM_AUTH_FAILED || msg.code === ERR.ROOM_EXISTS) {
|
|
470
501
|
// Join/create refused — drop the derived secrets for that attempt.
|
|
471
502
|
freeRoomSecrets(this.#pendingRoomSecrets);
|
|
@@ -592,6 +623,7 @@ export class ChatController {
|
|
|
592
623
|
owner,
|
|
593
624
|
secrets,
|
|
594
625
|
pins: [],
|
|
626
|
+
topic: null, // { text, by, at } — E2EE among members, never on the relay
|
|
595
627
|
});
|
|
596
628
|
this.#bufferOrder.push(room);
|
|
597
629
|
}
|
|
@@ -645,6 +677,7 @@ export class ChatController {
|
|
|
645
677
|
this.#currentRoomOwner = buf.owner;
|
|
646
678
|
this.#ui.switchBuffer(room);
|
|
647
679
|
this.#rebuildActivePeers();
|
|
680
|
+
this.#applyTopicToUI();
|
|
648
681
|
this.#updateBufferBar();
|
|
649
682
|
this.#updatePrivateIndicator();
|
|
650
683
|
this.#saveLastSession(buf.private);
|
|
@@ -663,6 +696,11 @@ export class ChatController {
|
|
|
663
696
|
this.#ui.setPeerNames([...this.#peers.values()].map((p) => p.nickname));
|
|
664
697
|
}
|
|
665
698
|
|
|
699
|
+
#applyTopicToUI() {
|
|
700
|
+
const topic = this.#buffers.get(this.#currentRoom)?.topic;
|
|
701
|
+
this.#ui.setTopic(topic?.text || null);
|
|
702
|
+
}
|
|
703
|
+
|
|
666
704
|
#updateBufferBar() {
|
|
667
705
|
this.#ui.setBufferBar(
|
|
668
706
|
this.#bufferOrder.map((room) => {
|
|
@@ -761,6 +799,23 @@ export class ChatController {
|
|
|
761
799
|
if (this.#away || this.#statusText) {
|
|
762
800
|
this.#sendPayloadToPeer(peer.sessionId, this.#presencePayload());
|
|
763
801
|
}
|
|
802
|
+
|
|
803
|
+
// …nor the room topic. Everyone who has it answers; the timestamp settles
|
|
804
|
+
// any disagreement, and `silent` keeps the sync out of the chat log.
|
|
805
|
+
const topic = this.#buffers.get(room)?.topic;
|
|
806
|
+
if (topic) {
|
|
807
|
+
this.#sendPayloadToPeer(
|
|
808
|
+
peer.sessionId,
|
|
809
|
+
JSON.stringify({
|
|
810
|
+
action: 'set_topic',
|
|
811
|
+
room,
|
|
812
|
+
text: topic.text,
|
|
813
|
+
at: topic.at,
|
|
814
|
+
silent: true,
|
|
815
|
+
sentAt: Date.now(),
|
|
816
|
+
}),
|
|
817
|
+
);
|
|
818
|
+
}
|
|
764
819
|
}
|
|
765
820
|
|
|
766
821
|
// One-time-per-session nudge to verify an unverified peer's identity.
|
|
@@ -1115,9 +1170,17 @@ export class ChatController {
|
|
|
1115
1170
|
}
|
|
1116
1171
|
|
|
1117
1172
|
if (data.action === 'reaction') {
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1173
|
+
// Hang the reaction off the message itself; only fall back to a log
|
|
1174
|
+
// line when the target is not on screen (older or another room).
|
|
1175
|
+
const applied =
|
|
1176
|
+
roomActive && data.targetMessageId
|
|
1177
|
+
? this.#applyReaction(data.targetMessageId, data.emoji)
|
|
1178
|
+
: false;
|
|
1179
|
+
if (!applied) {
|
|
1180
|
+
this.#ui.toBuffer(msgRoom, () => {
|
|
1181
|
+
this.#ui.addSystemMessage(`${data.emoji} ${peer.nickname} reacted to a message`);
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1121
1184
|
if (roomActive) {
|
|
1122
1185
|
this.#ui.playNotification();
|
|
1123
1186
|
}
|
|
@@ -1126,7 +1189,14 @@ export class ChatController {
|
|
|
1126
1189
|
|
|
1127
1190
|
if (data.action === 'edit_message') {
|
|
1128
1191
|
const author = this.#messageAuthors.get(data.messageId);
|
|
1129
|
-
if (author
|
|
1192
|
+
if (!author || author !== peer.nickname) {
|
|
1193
|
+
return; // only the author may rewrite their own message
|
|
1194
|
+
}
|
|
1195
|
+
const entry = this.#editableMessage(data.messageId);
|
|
1196
|
+
if (entry) {
|
|
1197
|
+
entry.text = data.newText;
|
|
1198
|
+
this.#ui.replaceMessageText(entry.lineIndex, entry.nickname, data.newText, entry.opts);
|
|
1199
|
+
} else {
|
|
1130
1200
|
this.#ui.toBuffer(msgRoom, () => {
|
|
1131
1201
|
this.#ui.addSystemMessage(`${peer.nickname} edited: ${data.newText} (edited)`);
|
|
1132
1202
|
});
|
|
@@ -1136,7 +1206,14 @@ export class ChatController {
|
|
|
1136
1206
|
|
|
1137
1207
|
if (data.action === 'delete_message') {
|
|
1138
1208
|
const author = this.#messageAuthors.get(data.messageId);
|
|
1139
|
-
if (author
|
|
1209
|
+
if (!author || author !== peer.nickname) {
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
const entry = this.#editableMessage(data.messageId);
|
|
1213
|
+
if (entry) {
|
|
1214
|
+
this.#ui.tombstoneMessage(entry.lineIndex, peer.nickname);
|
|
1215
|
+
this.#messageLines.delete(data.messageId);
|
|
1216
|
+
} else {
|
|
1140
1217
|
this.#ui.toBuffer(msgRoom, () => {
|
|
1141
1218
|
this.#ui.addSystemMessage(`${peer.nickname} deleted a message`);
|
|
1142
1219
|
});
|
|
@@ -1144,6 +1221,33 @@ export class ChatController {
|
|
|
1144
1221
|
return;
|
|
1145
1222
|
}
|
|
1146
1223
|
|
|
1224
|
+
if (data.action === 'set_topic') {
|
|
1225
|
+
const buf = this.#buffers.get(msgRoom);
|
|
1226
|
+
if (buf && typeof data.text === 'string') {
|
|
1227
|
+
const at = Number(data.at) || 0;
|
|
1228
|
+
// Last write wins. On join everyone who knows the topic answers, so
|
|
1229
|
+
// the timestamp is what keeps those replies from fighting.
|
|
1230
|
+
if (!buf.topic || at >= buf.topic.at) {
|
|
1231
|
+
const text = data.text.slice(0, 200);
|
|
1232
|
+
const changed = buf.topic?.text !== text;
|
|
1233
|
+
buf.topic = { text, by: peer.nickname, at };
|
|
1234
|
+
if (msgRoom === this.#currentRoom) {
|
|
1235
|
+
this.#applyTopicToUI();
|
|
1236
|
+
}
|
|
1237
|
+
if (changed && !data.silent) {
|
|
1238
|
+
this.#ui.toBuffer(msgRoom, () => {
|
|
1239
|
+
this.#ui.addSystemMessage(
|
|
1240
|
+
text
|
|
1241
|
+
? `📋 ${peer.nickname} set the topic of #${msgRoom}: ${text}`
|
|
1242
|
+
: `📋 ${peer.nickname} cleared the topic of #${msgRoom}`,
|
|
1243
|
+
);
|
|
1244
|
+
});
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1147
1251
|
if (data.action === 'pin_message') {
|
|
1148
1252
|
const pins = roomActive ? this.#pinnedMessages : this.#buffers.get(msgRoom)?.pins;
|
|
1149
1253
|
pins?.push({
|
|
@@ -1194,7 +1298,14 @@ export class ChatController {
|
|
|
1194
1298
|
});
|
|
1195
1299
|
}
|
|
1196
1300
|
|
|
1197
|
-
const
|
|
1301
|
+
const watchHit = this.#matchedWatch(data.text);
|
|
1302
|
+
// A watched keyword deserves the same attention a mention gets.
|
|
1303
|
+
const mentioned = (this.#mentionsMe(data.text) || !!watchHit) && !data.isDM;
|
|
1304
|
+
if (watchHit) {
|
|
1305
|
+
this.#ui.toBuffer(msgRoom, () => {
|
|
1306
|
+
this.#ui.addSystemMessage(`👁 "${watchHit}" mentioned by ${peer.nickname} in #${msgRoom}`);
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1198
1309
|
if (mentioned) {
|
|
1199
1310
|
this.#mentions.push({
|
|
1200
1311
|
nickname: peer.nickname,
|
|
@@ -1221,20 +1332,26 @@ export class ChatController {
|
|
|
1221
1332
|
const trust = trustBadge(this.#trustStore.getPeerRecord(peer.nickname), peer.publicKey);
|
|
1222
1333
|
// File the message into its buffer (live log when active, stored otherwise).
|
|
1223
1334
|
let lineIndex = -1;
|
|
1335
|
+
let renderInfo = null;
|
|
1224
1336
|
this.#ui.toBuffer(msgRoom, () => {
|
|
1225
1337
|
if (data.replyTo?.nickname && typeof data.replyTo.excerpt === 'string') {
|
|
1226
1338
|
this.#ui.addQuoteLine(String(data.replyTo.nickname), data.replyTo.excerpt.slice(0, 80));
|
|
1227
1339
|
}
|
|
1228
|
-
({ lineIndex } =
|
|
1229
|
-
peer.nickname,
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1340
|
+
({ lineIndex, render: renderInfo } = data.isAction
|
|
1341
|
+
? this.#ui.addActionMessage(peer.nickname, data.text)
|
|
1342
|
+
: this.#ui.addMessage(
|
|
1343
|
+
peer.nickname,
|
|
1344
|
+
data.text,
|
|
1345
|
+
!!data.isDM,
|
|
1346
|
+
ephLabel,
|
|
1347
|
+
isDeniable || !!data.deniable,
|
|
1348
|
+
mentioned,
|
|
1349
|
+
trust,
|
|
1350
|
+
));
|
|
1237
1351
|
});
|
|
1352
|
+
if (roomActive && data.messageId) {
|
|
1353
|
+
this.#rememberMessage(data.messageId, lineIndex, peer.nickname, data.text, renderInfo);
|
|
1354
|
+
}
|
|
1238
1355
|
this.#noteBufferUnread(msgRoom, mentioned);
|
|
1239
1356
|
const notify = shouldNotify(this.#dndMode, this.#dndWindow, nowMinutes(), mentioned);
|
|
1240
1357
|
if (notify) {
|
|
@@ -1288,6 +1405,16 @@ export class ChatController {
|
|
|
1288
1405
|
return mentionsMe(text, this.#nickname);
|
|
1289
1406
|
}
|
|
1290
1407
|
|
|
1408
|
+
// The first /watch keyword present in the text, or null.
|
|
1409
|
+
#matchedWatch(text) {
|
|
1410
|
+
for (const word of this.#watchWords) {
|
|
1411
|
+
if (matchesKeyword(text, word)) {
|
|
1412
|
+
return word;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
return null;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1291
1418
|
// ── User input handling ───────────────────────────────────────
|
|
1292
1419
|
#handleUserInput(text) {
|
|
1293
1420
|
this.#noteActive();
|
|
@@ -1311,7 +1438,9 @@ export class ChatController {
|
|
|
1311
1438
|
this.#ui.addInfoMessage(' /users - List online users');
|
|
1312
1439
|
this.#ui.addInfoMessage(' /msg <nick> <text> - Send a private message (DM)');
|
|
1313
1440
|
this.#ui.addInfoMessage(' /reply <text> - Reply to the last received message');
|
|
1441
|
+
this.#ui.addInfoMessage(' /me <action> - Third-person action message');
|
|
1314
1442
|
this.#ui.addInfoMessage(' /mentions [n] - Recent mentions of you (this session)');
|
|
1443
|
+
this.#ui.addInfoMessage(' /watch [add|remove|clear] - Alert on a keyword in any room');
|
|
1315
1444
|
this.#ui.addInfoMessage(' /contacts [add|remove|all] - Contact book (aliases for peers)');
|
|
1316
1445
|
this.#ui.addInfoMessage(
|
|
1317
1446
|
' /away [reason] - Mark yourself as away (unreads are counted)',
|
|
@@ -1323,6 +1452,7 @@ export class ChatController {
|
|
|
1323
1452
|
this.#ui.addInfoMessage(' /status <text|off> - Set a status (accepts :emoji:)');
|
|
1324
1453
|
this.#ui.addInfoMessage(' /join <room> [pass] - Join a room as a new buffer (Alt+1..9)');
|
|
1325
1454
|
this.#ui.addInfoMessage(' /leave [room] - Leave a room (its buffer closes)');
|
|
1455
|
+
this.#ui.addInfoMessage(' /topic [text|clear] - Show or set the room topic');
|
|
1326
1456
|
this.#ui.addInfoMessage(' /create <room> <pass> - Create a private room 🔒');
|
|
1327
1457
|
this.#ui.addInfoMessage(' /invite [host:port] - Generate an invite with QR code');
|
|
1328
1458
|
this.#ui.addInfoMessage(' /rooms - List available rooms');
|
|
@@ -1346,6 +1476,8 @@ export class ChatController {
|
|
|
1346
1476
|
' /dnd [on|off|mentions|HH:MM-HH:MM] - Do not disturb / mentions only',
|
|
1347
1477
|
);
|
|
1348
1478
|
this.#ui.addInfoMessage(' /search <term> - Search the encrypted local history');
|
|
1479
|
+
this.#ui.addInfoMessage(' /find [term] - Find in this room and jump (Ctrl+F)');
|
|
1480
|
+
this.#ui.addInfoMessage(' /doctor [host:port] - Diagnose why a connection fails');
|
|
1349
1481
|
this.#ui.addInfoMessage(' /history [n] - Last n messages from history');
|
|
1350
1482
|
this.#ui.addInfoMessage(' /export [path] - Export the history (.txt or .json)');
|
|
1351
1483
|
this.#ui.addInfoMessage(' /audit [N] - Show the last N audit events');
|
|
@@ -1818,6 +1950,83 @@ export class ChatController {
|
|
|
1818
1950
|
break;
|
|
1819
1951
|
}
|
|
1820
1952
|
|
|
1953
|
+
case '/topic': {
|
|
1954
|
+
const buf = this.#buffers.get(this.#currentRoom);
|
|
1955
|
+
const newTopic = parts.slice(1).join(' ').trim();
|
|
1956
|
+
|
|
1957
|
+
if (!newTopic) {
|
|
1958
|
+
const t = buf?.topic;
|
|
1959
|
+
this.#ui.addInfoMessage(
|
|
1960
|
+
t?.text
|
|
1961
|
+
? `Topic of #${this.#currentRoom}: ${t.text} (set by ${t.by})`
|
|
1962
|
+
: `#${this.#currentRoom} has no topic. Set one with /topic <text>`,
|
|
1963
|
+
);
|
|
1964
|
+
break;
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
const text = newTopic === 'clear' ? '' : applyShortcodes(newTopic).slice(0, 200);
|
|
1968
|
+
const at = Date.now();
|
|
1969
|
+
if (buf) {
|
|
1970
|
+
buf.topic = { text, by: this.#nickname, at };
|
|
1971
|
+
}
|
|
1972
|
+
this.#applyTopicToUI();
|
|
1973
|
+
this.#paceOrSend(
|
|
1974
|
+
JSON.stringify({ action: 'set_topic', room: this.#currentRoom, text, at, sentAt: at }),
|
|
1975
|
+
);
|
|
1976
|
+
this.#ui.addSystemMessage(
|
|
1977
|
+
text ? `📋 You set the topic: ${text}` : '📋 You cleared the topic',
|
|
1978
|
+
);
|
|
1979
|
+
break;
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
case '/me': {
|
|
1983
|
+
const actionText = parts.slice(1).join(' ').trim();
|
|
1984
|
+
if (!actionText) {
|
|
1985
|
+
this.#ui.addErrorMessage('Usage: /me <action> — e.g. /me is compiling');
|
|
1986
|
+
break;
|
|
1987
|
+
}
|
|
1988
|
+
this.#sendMessageToAll(actionText, null, true);
|
|
1989
|
+
break;
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
case '/watch': {
|
|
1993
|
+
const sub = (parts[1] || 'list').toLowerCase();
|
|
1994
|
+
if (sub === 'add') {
|
|
1995
|
+
const word = parts.slice(2).join(' ').trim();
|
|
1996
|
+
if (!word) {
|
|
1997
|
+
this.#ui.addErrorMessage('Usage: /watch add <keyword>');
|
|
1998
|
+
break;
|
|
1999
|
+
}
|
|
2000
|
+
this.#watchWords.add(word.toLowerCase());
|
|
2001
|
+
this.#ui.addInfoMessage(`Watching for "${word.toLowerCase()}" in every room`);
|
|
2002
|
+
break;
|
|
2003
|
+
}
|
|
2004
|
+
if (sub === 'remove' || sub === 'rm') {
|
|
2005
|
+
const word = parts.slice(2).join(' ').trim().toLowerCase();
|
|
2006
|
+
if (this.#watchWords.delete(word)) {
|
|
2007
|
+
this.#ui.addInfoMessage(`No longer watching "${word}"`);
|
|
2008
|
+
} else {
|
|
2009
|
+
this.#ui.addErrorMessage(`"${word}" is not being watched`);
|
|
2010
|
+
}
|
|
2011
|
+
break;
|
|
2012
|
+
}
|
|
2013
|
+
if (sub === 'clear') {
|
|
2014
|
+
this.#watchWords.clear();
|
|
2015
|
+
this.#ui.addInfoMessage('Watch list cleared');
|
|
2016
|
+
break;
|
|
2017
|
+
}
|
|
2018
|
+
if (sub !== 'list') {
|
|
2019
|
+
this.#ui.addErrorMessage('Usage: /watch [add <word> | remove <word> | clear]');
|
|
2020
|
+
break;
|
|
2021
|
+
}
|
|
2022
|
+
if (this.#watchWords.size === 0) {
|
|
2023
|
+
this.#ui.addInfoMessage('Not watching any keyword. Use /watch add <word>');
|
|
2024
|
+
break;
|
|
2025
|
+
}
|
|
2026
|
+
this.#ui.addInfoMessage(`Watching: ${[...this.#watchWords].join(', ')}`);
|
|
2027
|
+
break;
|
|
2028
|
+
}
|
|
2029
|
+
|
|
1821
2030
|
case '/mentions': {
|
|
1822
2031
|
if (this.#mentions.length === 0) {
|
|
1823
2032
|
this.#ui.addInfoMessage('No mentions in this session yet.');
|
|
@@ -2000,6 +2209,28 @@ export class ChatController {
|
|
|
2000
2209
|
break;
|
|
2001
2210
|
}
|
|
2002
2211
|
|
|
2212
|
+
case '/doctor': {
|
|
2213
|
+
const target = parts.slice(1).join(' ').trim() || this.#connection.url || '';
|
|
2214
|
+
this.#ui.addInfoMessage(`Diagnosing ${target} …`);
|
|
2215
|
+
diagnose(target)
|
|
2216
|
+
.then((steps) => {
|
|
2217
|
+
for (const line of formatDiagnosis(steps)) {
|
|
2218
|
+
this.#ui.addInfoMessage(line);
|
|
2219
|
+
}
|
|
2220
|
+
})
|
|
2221
|
+
.catch((err) => {
|
|
2222
|
+
this.#ui.addErrorMessage(`Diagnostics failed to run: ${err.message}`);
|
|
2223
|
+
});
|
|
2224
|
+
break;
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
case '/find': {
|
|
2228
|
+
const term = parts.slice(1).join(' ').trim();
|
|
2229
|
+
// Opens the same overlay as Ctrl+F, pre-filled when a term is given.
|
|
2230
|
+
this.#ui.openFinder(term);
|
|
2231
|
+
break;
|
|
2232
|
+
}
|
|
2233
|
+
|
|
2003
2234
|
case '/search': {
|
|
2004
2235
|
if (!this.#historyStore?.isOpen) {
|
|
2005
2236
|
this.#ui.addErrorMessage('History disabled — start the client with a passphrase');
|
|
@@ -2019,6 +2250,11 @@ export class ChatController {
|
|
|
2019
2250
|
for (const e of results) {
|
|
2020
2251
|
this.#ui.addInfoMessage(` ${this.#formatHistoryEntry(e)}`);
|
|
2021
2252
|
}
|
|
2253
|
+
// /search reads the on-disk history (possibly from other sessions);
|
|
2254
|
+
// point at the navigable finder for what is actually on screen.
|
|
2255
|
+
this.#ui.addInfoMessage(
|
|
2256
|
+
`Tip: /find ${term} (or Ctrl+F) searches this room's scrollback and jumps to the message.`,
|
|
2257
|
+
);
|
|
2022
2258
|
break;
|
|
2023
2259
|
}
|
|
2024
2260
|
|
|
@@ -2100,9 +2336,12 @@ export class ChatController {
|
|
|
2100
2336
|
sentAt: Date.now(),
|
|
2101
2337
|
});
|
|
2102
2338
|
this.#broadcastPayload(reactionPayload);
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2339
|
+
// Show it on the message right away instead of announcing it.
|
|
2340
|
+
if (!this.#applyReaction(this.#lastReceivedMessageId, emoji)) {
|
|
2341
|
+
this.#ui.addSystemMessage(
|
|
2342
|
+
`${emoji} You reacted to ${this.#lastReceivedNickname}'s message`,
|
|
2343
|
+
);
|
|
2344
|
+
}
|
|
2106
2345
|
break;
|
|
2107
2346
|
}
|
|
2108
2347
|
|
|
@@ -2123,7 +2362,19 @@ export class ChatController {
|
|
|
2123
2362
|
sentAt: Date.now(),
|
|
2124
2363
|
});
|
|
2125
2364
|
this.#broadcastPayload(editPayload);
|
|
2126
|
-
|
|
2365
|
+
// Rewrite our own line in place, like the peers will.
|
|
2366
|
+
const editEntry = this.#editableMessage(this.#lastSentMessageId);
|
|
2367
|
+
if (editEntry) {
|
|
2368
|
+
editEntry.text = editText;
|
|
2369
|
+
this.#ui.replaceMessageText(
|
|
2370
|
+
editEntry.lineIndex,
|
|
2371
|
+
editEntry.nickname,
|
|
2372
|
+
editText,
|
|
2373
|
+
editEntry.opts,
|
|
2374
|
+
);
|
|
2375
|
+
} else {
|
|
2376
|
+
this.#ui.addSystemMessage(`You edited: ${editText} (edited)`);
|
|
2377
|
+
}
|
|
2127
2378
|
break;
|
|
2128
2379
|
}
|
|
2129
2380
|
|
|
@@ -2138,8 +2389,14 @@ export class ChatController {
|
|
|
2138
2389
|
sentAt: Date.now(),
|
|
2139
2390
|
});
|
|
2140
2391
|
this.#broadcastPayload(deletePayload);
|
|
2392
|
+
const delEntry = this.#editableMessage(this.#lastSentMessageId);
|
|
2393
|
+
if (delEntry) {
|
|
2394
|
+
this.#ui.tombstoneMessage(delEntry.lineIndex, this.#nickname);
|
|
2395
|
+
this.#messageLines.delete(this.#lastSentMessageId);
|
|
2396
|
+
} else {
|
|
2397
|
+
this.#ui.addSystemMessage('You deleted a message');
|
|
2398
|
+
}
|
|
2141
2399
|
this.#lastSentMessageId = null;
|
|
2142
|
-
this.#ui.addSystemMessage('You deleted a message');
|
|
2143
2400
|
break;
|
|
2144
2401
|
}
|
|
2145
2402
|
|
|
@@ -2836,6 +3093,55 @@ export class ChatController {
|
|
|
2836
3093
|
this.#ui.appendBadge(tracked.lineIndex, tracked.baseLine, `{green-fg}${marker}{/green-fg}`);
|
|
2837
3094
|
}
|
|
2838
3095
|
|
|
3096
|
+
// Remember where a message was drawn so reactions/edits/deletes can change
|
|
3097
|
+
// it in place. Bounded like the receipt tracker.
|
|
3098
|
+
#rememberMessage(messageId, lineIndex, nickname, text, render) {
|
|
3099
|
+
if (!messageId || lineIndex === undefined || lineIndex < 0) {
|
|
3100
|
+
return;
|
|
3101
|
+
}
|
|
3102
|
+
this.#messageLines.set(messageId, {
|
|
3103
|
+
lineIndex,
|
|
3104
|
+
nickname,
|
|
3105
|
+
text,
|
|
3106
|
+
opts: render?.opts || {},
|
|
3107
|
+
room: this.#currentRoom,
|
|
3108
|
+
});
|
|
3109
|
+
if (this.#messageLines.size > 200) {
|
|
3110
|
+
const oldest = this.#messageLines.keys().next().value;
|
|
3111
|
+
this.#messageLines.delete(oldest);
|
|
3112
|
+
this.#reactions.delete(oldest);
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
|
|
3116
|
+
// A message can only be redrawn while its room is the one on screen.
|
|
3117
|
+
#editableMessage(messageId) {
|
|
3118
|
+
const entry = this.#messageLines.get(messageId);
|
|
3119
|
+
if (!entry || entry.room !== this.#currentRoom) {
|
|
3120
|
+
return null;
|
|
3121
|
+
}
|
|
3122
|
+
return entry;
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
#applyReaction(messageId, emoji) {
|
|
3126
|
+
const entry = this.#editableMessage(messageId);
|
|
3127
|
+
if (!entry) {
|
|
3128
|
+
return false;
|
|
3129
|
+
}
|
|
3130
|
+
const counts = this.#reactions.get(messageId) || new Map();
|
|
3131
|
+
counts.set(emoji, (counts.get(emoji) || 0) + 1);
|
|
3132
|
+
this.#reactions.set(messageId, counts);
|
|
3133
|
+
|
|
3134
|
+
// Re-render the line, then hang the reactions off the end of it.
|
|
3135
|
+
const badge = [...counts.entries()].map(([e, n]) => (n > 1 ? `${e}${n}` : e)).join(' ');
|
|
3136
|
+
this.#ui.replaceMessageText(entry.lineIndex, entry.nickname, entry.text, {
|
|
3137
|
+
...entry.opts,
|
|
3138
|
+
edited: entry.opts?.edited,
|
|
3139
|
+
});
|
|
3140
|
+
const rebuilt = this.#ui.getLine(entry.lineIndex);
|
|
3141
|
+
this.#ui.appendBadge(entry.lineIndex, rebuilt, badge);
|
|
3142
|
+
return true;
|
|
3143
|
+
}
|
|
3144
|
+
|
|
2839
3145
|
#trackSentMessage(messageId, lineIndex) {
|
|
2840
3146
|
const baseLine = this.#ui.getLine(lineIndex);
|
|
2841
3147
|
if (baseLine === null || baseLine === undefined) {
|
|
@@ -3090,7 +3396,7 @@ export class ChatController {
|
|
|
3090
3396
|
}
|
|
3091
3397
|
|
|
3092
3398
|
// ── Send encrypted message to all peers ───────────────────────
|
|
3093
|
-
#sendMessageToAll(text, replyTo = null) {
|
|
3399
|
+
#sendMessageToAll(text, replyTo = null, isAction = false) {
|
|
3094
3400
|
if (!this.#connection.connected) {
|
|
3095
3401
|
this.#ui.addErrorMessage('No connection to the server — message not sent');
|
|
3096
3402
|
return;
|
|
@@ -3107,6 +3413,9 @@ export class ChatController {
|
|
|
3107
3413
|
sentAt: Date.now(),
|
|
3108
3414
|
messageId,
|
|
3109
3415
|
};
|
|
3416
|
+
if (isAction) {
|
|
3417
|
+
msgObj.isAction = true;
|
|
3418
|
+
}
|
|
3110
3419
|
if (replyTo) {
|
|
3111
3420
|
msgObj.replyTo = replyTo;
|
|
3112
3421
|
}
|
|
@@ -3135,13 +3444,10 @@ export class ChatController {
|
|
|
3135
3444
|
this.#ui.addQuoteLine(replyTo.nickname, replyTo.excerpt, true);
|
|
3136
3445
|
}
|
|
3137
3446
|
const ephLabel = this.#ephemeralMode ? this.#formatDuration(this.#ephemeralDurationMs) : null;
|
|
3138
|
-
const { lineIndex } =
|
|
3139
|
-
this.#nickname,
|
|
3140
|
-
text,
|
|
3141
|
-
|
|
3142
|
-
ephLabel,
|
|
3143
|
-
this.#deniableMode,
|
|
3144
|
-
);
|
|
3447
|
+
const { lineIndex, render } = isAction
|
|
3448
|
+
? this.#ui.addActionMessage(this.#nickname, text)
|
|
3449
|
+
: this.#ui.addMessage(this.#nickname, text, false, ephLabel, this.#deniableMode);
|
|
3450
|
+
this.#rememberMessage(messageId, lineIndex, this.#nickname, text, render);
|
|
3145
3451
|
|
|
3146
3452
|
if (this.#ephemeralMode) {
|
|
3147
3453
|
this.#scheduleEphemeralRemoval(lineIndex, this.#ephemeralDurationMs, this.#nickname);
|
package/src/client/Connection.js
CHANGED
|
@@ -11,6 +11,7 @@ export class Connection extends EventEmitter {
|
|
|
11
11
|
#connected;
|
|
12
12
|
#pinStore;
|
|
13
13
|
#host;
|
|
14
|
+
#caValidated = false;
|
|
14
15
|
|
|
15
16
|
constructor(url) {
|
|
16
17
|
super();
|
|
@@ -28,6 +29,12 @@ export class Connection extends EventEmitter {
|
|
|
28
29
|
|
|
29
30
|
// Trust-on-first-use pin of the server TLS certificate. Emits 'cert-pinned'
|
|
30
31
|
// on first sight and 'cert-mismatch' if it later changes (possible MITM).
|
|
32
|
+
//
|
|
33
|
+
// A publicly hosted relay presents a cert signed by a real CA (Let's Encrypt
|
|
34
|
+
// et al). Node still runs the full chain + hostname check even with
|
|
35
|
+
// rejectUnauthorized:false, so `socket.authorized` tells us which world we
|
|
36
|
+
// are in: CA-valid needs no TOFU window at all (strictly stronger), while a
|
|
37
|
+
// self-signed LAN cert keeps the pin-on-first-use behaviour.
|
|
31
38
|
#checkCertPin() {
|
|
32
39
|
if (!this.#url.startsWith('wss://')) {
|
|
33
40
|
return;
|
|
@@ -35,6 +42,18 @@ export class Connection extends EventEmitter {
|
|
|
35
42
|
const socket = this.#ws?._socket;
|
|
36
43
|
const cert = socket?.getPeerCertificate?.();
|
|
37
44
|
const fingerprint = cert?.fingerprint256 || null;
|
|
45
|
+
|
|
46
|
+
if (socket?.authorized === true) {
|
|
47
|
+
this.#caValidated = true;
|
|
48
|
+
// Remember it: from now on this host is verified strictly, and a
|
|
49
|
+
// legitimate CA cert renewal no longer trips the TOFU alarm.
|
|
50
|
+
this.#pinStore.markCAValidated(this.#host);
|
|
51
|
+
this.#pinStore.repin(this.#host, fingerprint);
|
|
52
|
+
this.emit('cert-ca-valid', { host: this.#host, issuer: cert?.issuer?.O || 'CA' });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
this.#caValidated = false;
|
|
38
57
|
const result = this.#pinStore.check(this.#host, fingerprint);
|
|
39
58
|
if (result === PinResult.PINNED) {
|
|
40
59
|
this.emit('cert-pinned', { host: this.#host, fingerprint });
|
|
@@ -53,7 +72,13 @@ export class Connection extends EventEmitter {
|
|
|
53
72
|
}
|
|
54
73
|
|
|
55
74
|
#createSocket() {
|
|
56
|
-
|
|
75
|
+
// Hosts that already proved they have a CA-signed certificate are verified
|
|
76
|
+
// strictly from then on — an attacker cannot downgrade a hosted relay to a
|
|
77
|
+
// self-signed cert. Everything else (LAN/Tailscale self-signed) connects
|
|
78
|
+
// with verification relaxed and is protected by TOFU pinning instead.
|
|
79
|
+
const opts = this.#url.startsWith('wss://')
|
|
80
|
+
? { rejectUnauthorized: this.#pinStore.requiresStrictTLS(this.#host) }
|
|
81
|
+
: {};
|
|
57
82
|
this.#ws = new WebSocket(this.#url, opts);
|
|
58
83
|
|
|
59
84
|
this.#ws.on('open', () => {
|
|
@@ -104,6 +129,11 @@ export class Connection extends EventEmitter {
|
|
|
104
129
|
}, this.#reconnectDelay);
|
|
105
130
|
}
|
|
106
131
|
|
|
132
|
+
/** True when the server's TLS cert validated against a public CA. */
|
|
133
|
+
get isCAValidated() {
|
|
134
|
+
return this.#caValidated;
|
|
135
|
+
}
|
|
136
|
+
|
|
107
137
|
send(msg) {
|
|
108
138
|
if (this.#connected && this.#ws.readyState === WebSocket.OPEN) {
|
|
109
139
|
this.#ws.send(JSON.stringify(msg));
|