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.
@@ -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 { parseDndWindow, shouldNotify, nowMinutes, mentionsMe } from '../shared/dnd.js';
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
- this.#ui.toBuffer(msgRoom, () => {
1119
- this.#ui.addSystemMessage(`${data.emoji} ${peer.nickname} reacted to a message`);
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 && author === peer.nickname) {
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 && author === peer.nickname) {
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 mentioned = this.#mentionsMe(data.text) && !data.isDM;
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 } = this.#ui.addMessage(
1229
- peer.nickname,
1230
- data.text,
1231
- !!data.isDM,
1232
- ephLabel,
1233
- isDeniable || !!data.deniable,
1234
- mentioned,
1235
- trust,
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
- this.#ui.addSystemMessage(
2104
- `${emoji} You reacted to ${this.#lastReceivedNickname}'s message`,
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
- this.#ui.addSystemMessage(`You edited: ${editText} (edited)`);
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 } = this.#ui.addMessage(
3139
- this.#nickname,
3140
- text,
3141
- false,
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);
@@ -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
- const opts = this.#url.startsWith('wss://') ? { rejectUnauthorized: false } : {};
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));