ciphermesh 2.1.0 → 2.2.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/src/client/UI.js CHANGED
@@ -24,7 +24,8 @@ const COMMAND_INFO = [
24
24
  ['/away', 'Mark yourself as away'],
25
25
  ['/back', 'Clear away status'],
26
26
  ['/status', 'Set a status'],
27
- ['/join', 'Join a room (password if private)'],
27
+ ['/join', 'Join a room as a new buffer (Alt+1..9 switches)'],
28
+ ['/leave', 'Leave a room — its buffer closes'],
28
29
  ['/create', 'Create a private room with a password'],
29
30
  ['/rooms', 'List rooms'],
30
31
  ['/room', 'Show the current room'],
@@ -97,6 +98,7 @@ export const COMMANDS = [
97
98
  '/notify',
98
99
  '/dnd',
99
100
  '/join',
101
+ '/leave',
100
102
  '/create',
101
103
  '/rooms',
102
104
  '/room',
@@ -498,6 +500,10 @@ export class UI extends EventEmitter {
498
500
  #lockInput;
499
501
  #lockError;
500
502
  #lockVerify;
503
+ #bufferLines; // Map<room, lines[]> — stored content of INACTIVE buffers
504
+ #activeBuffer; // name of the buffer currently on screen
505
+ #redirecting; // true while add* calls are being written to an inactive buffer
506
+ #bufferBar; // [{ room, active, unread, private }] for the status bar
501
507
 
502
508
  constructor(nickname) {
503
509
  super();
@@ -547,6 +553,10 @@ export class UI extends EventEmitter {
547
553
  this.#lockInput = '';
548
554
  this.#lockError = false;
549
555
  this.#lockVerify = null;
556
+ this.#bufferLines = new Map();
557
+ this.#activeBuffer = 'general';
558
+ this.#redirecting = false;
559
+ this.#bufferBar = [];
550
560
 
551
561
  // blessed's terminfo parser can't compile the modern Setulc (underline
552
562
  // colour) capability that terminals like ghostty ship, so it dumps a
@@ -772,6 +782,12 @@ export class UI extends EventEmitter {
772
782
  // Any non-tab key resets tab cycling
773
783
  this.#tabState = { suggestions: [], index: -1, original: '' };
774
784
 
785
+ // Alt+1..9 — switch chat buffer (multi-room)
786
+ if (key.meta && /^[1-9]$/.test(name)) {
787
+ this.emit('buffer-switch', Number(name) - 1);
788
+ return;
789
+ }
790
+
775
791
  // Alt+Enter / Ctrl+J — insert a newline (reliable across terminals, unlike
776
792
  // bare Shift+Enter which most terminals don't distinguish from Enter).
777
793
  if (((name === 'return' || name === 'enter') && key.meta) || (key.ctrl && name === 'j')) {
@@ -1384,12 +1400,29 @@ export class UI extends EventEmitter {
1384
1400
  }
1385
1401
 
1386
1402
  #statusContent() {
1387
- const room = `{cyan-fg}#${this.#statusRoom}{/cyan-fg}`;
1403
+ // Several buffers → an IRC-style bar with unread badges; one → plain room.
1404
+ let room;
1405
+ if (this.#bufferBar.length > 1) {
1406
+ room = this.#bufferBar
1407
+ .map((b, i) => {
1408
+ const lock = b.private ? '🔒' : '';
1409
+ const unread = b.unread > 0 ? `{yellow-fg}•${b.unread}{/yellow-fg}` : '';
1410
+ const label = `${i + 1}:${lock}${b.room}${unread}`;
1411
+ return b.active
1412
+ ? `{inverse}{cyan-fg}[${label}]{/cyan-fg}{/inverse}`
1413
+ : `{cyan-fg}[${label}]{/cyan-fg}`;
1414
+ })
1415
+ .join(' ');
1416
+ } else {
1417
+ room = `{cyan-fg}#${this.#statusRoom}{/cyan-fg}`;
1418
+ }
1388
1419
  const fp = this.#statusFingerprint
1389
1420
  ? ` {#8888aa-fg}🔑 ${this.#statusFingerprint}{/#8888aa-fg}`
1390
1421
  : '';
1391
1422
  const hint =
1392
- '{#7777aa-fg}Tab · Ctrl+K commands · Ctrl+E emoji · PgUp/PgDn scroll · /help · Ctrl+C quit{/#7777aa-fg}';
1423
+ this.#bufferBar.length > 1
1424
+ ? '{#7777aa-fg}Alt+1..9 buffers · Ctrl+K commands · /help{/#7777aa-fg}'
1425
+ : '{#7777aa-fg}Tab · Ctrl+K commands · Ctrl+E emoji · PgUp/PgDn scroll · /help · Ctrl+C quit{/#7777aa-fg}';
1393
1426
  return ` ${room}${fp} {|} ${hint} `;
1394
1427
  }
1395
1428
 
@@ -1411,6 +1444,102 @@ export class UI extends EventEmitter {
1411
1444
  this.#updateStatusBar();
1412
1445
  }
1413
1446
 
1447
+ // ── Buffers (multi-room, IRC style) ──────────────────────────
1448
+ // The active buffer lives in #lines + the chat log; inactive ones are plain
1449
+ // line arrays in #bufferLines. All add*() methods target the active buffer —
1450
+ // toBuffer() retargets them for one call without touching the screen.
1451
+
1452
+ get activeBuffer() {
1453
+ return this.#activeBuffer;
1454
+ }
1455
+
1456
+ /**
1457
+ * Run `fn` with every add*() call landing in `room`'s stored buffer instead
1458
+ * of the screen. Uses proxies so the formatting pipeline (widths, colors,
1459
+ * grouping) is exactly the one the live log uses.
1460
+ */
1461
+ toBuffer(room, fn) {
1462
+ if (!room || room === this.#activeBuffer) {
1463
+ return fn();
1464
+ }
1465
+ if (!this.#bufferLines.has(room)) {
1466
+ this.#bufferLines.set(room, []);
1467
+ }
1468
+ const liveLines = this.#lines;
1469
+ const liveSender = this.#lastSender;
1470
+ const liveLog = this.#chatLog;
1471
+ const liveScreen = this.#screen;
1472
+ this.#lines = this.#bufferLines.get(room);
1473
+ this.#lastSender = null;
1474
+ this.#redirecting = true;
1475
+ this.#chatLog = new Proxy(liveLog, {
1476
+ get: (t, p) => (p === 'log' ? () => {} : t[p]),
1477
+ });
1478
+ this.#screen = new Proxy(liveScreen, {
1479
+ get: (t, p) => {
1480
+ if (p === 'render') {
1481
+ return () => {};
1482
+ }
1483
+ const v = t[p];
1484
+ return typeof v === 'function' ? v.bind(t) : v;
1485
+ },
1486
+ });
1487
+ try {
1488
+ return fn();
1489
+ } finally {
1490
+ this.#lines = liveLines;
1491
+ this.#lastSender = liveSender;
1492
+ this.#chatLog = liveLog;
1493
+ this.#screen = liveScreen;
1494
+ this.#redirecting = false;
1495
+ }
1496
+ }
1497
+
1498
+ /** Bring `room`'s buffer on screen, storing the current one. */
1499
+ switchBuffer(room) {
1500
+ if (room === this.#activeBuffer) {
1501
+ return;
1502
+ }
1503
+ this.#bufferLines.set(this.#activeBuffer, this.#lines);
1504
+ this.#lines = this.#bufferLines.get(room) || [];
1505
+ this.#bufferLines.delete(room);
1506
+ this.#activeBuffer = room;
1507
+ this.#lastSender = null;
1508
+ this.#chatLog.setContent(this.#lines.join('\n'));
1509
+ this.#chatLog.setScrollPerc(100);
1510
+ this.setRoom(room);
1511
+ this.#screen.render();
1512
+ }
1513
+
1514
+ /** Forget every buffer and start fresh in `room` (reconnect / legacy switch). */
1515
+ resetBuffers(room) {
1516
+ this.#bufferLines.clear();
1517
+ this.#activeBuffer = room;
1518
+ this.#lines = [];
1519
+ this.#lastSender = null;
1520
+ this.#chatLog.setContent('');
1521
+ this.setRoom(room);
1522
+ this.#screen.render();
1523
+ }
1524
+
1525
+ dropBuffer(room) {
1526
+ this.#bufferLines.delete(room);
1527
+ }
1528
+
1529
+ clearBuffer(room) {
1530
+ if (room === this.#activeBuffer) {
1531
+ this.clearChat();
1532
+ } else if (this.#bufferLines.has(room)) {
1533
+ this.#bufferLines.get(room).length = 0;
1534
+ }
1535
+ }
1536
+
1537
+ /** Status-bar buffer list: [{ room, active, unread, private }]. */
1538
+ setBufferBar(items) {
1539
+ this.#bufferBar = Array.isArray(items) ? items : [];
1540
+ this.#updateStatusBar();
1541
+ }
1542
+
1414
1543
  // Render a real image inline by briefly leaving the TUI (kitty/iTerm2). Safe
1415
1544
  // best-effort: any keypress or a 30s timeout returns to the chat.
1416
1545
  showRealImage(escapeSeq) {
@@ -1647,6 +1776,9 @@ export class UI extends EventEmitter {
1647
1776
  // Count a fresh arrival while the user is reading history, and pulse the
1648
1777
  // "new messages" pill so they know to page down.
1649
1778
  #noteIncoming(important = false) {
1779
+ if (this.#redirecting) {
1780
+ return; // inactive buffer — its unread badge lives in the buffer bar
1781
+ }
1650
1782
  if (this.#scrolledUp) {
1651
1783
  this.#unseenCount++;
1652
1784
  if (important) {
@@ -12,6 +12,10 @@ export const MSG = {
12
12
  PEER_KEY_UPDATED: 'peer_key_updated',
13
13
  CHANGE_ROOM: 'change_room',
14
14
  ROOM_CHANGED: 'room_changed',
15
+ JOIN_ROOM: 'join_room',
16
+ ROOM_JOINED: 'room_joined',
17
+ LEAVE_ROOM: 'leave_room',
18
+ ROOM_LEFT: 'room_left',
15
19
  LIST_ROOMS: 'list_rooms',
16
20
  ROOM_LIST: 'room_list',
17
21
  ROOM_CHALLENGE: 'room_challenge',
@@ -53,12 +57,22 @@ export function createJoinAck(sessionId, peers, queuedCount = 0, room = 'general
53
57
  return ack;
54
58
  }
55
59
 
56
- export function createPeerJoined(peer) {
57
- return { ...base(MSG.PEER_JOINED), peer };
60
+ // `room` (multi-room, additive): which room this event refers to. A peer can
61
+ // leave room X while still sharing room Y with you. Old clients ignore it.
62
+ export function createPeerJoined(peer, room = null) {
63
+ const msg = { ...base(MSG.PEER_JOINED), peer };
64
+ if (room) {
65
+ msg.room = room;
66
+ }
67
+ return msg;
58
68
  }
59
69
 
60
- export function createPeerLeft(sessionId, nickname) {
61
- return { ...base(MSG.PEER_LEFT), sessionId, nickname };
70
+ export function createPeerLeft(sessionId, nickname, room = null) {
71
+ const msg = { ...base(MSG.PEER_LEFT), sessionId, nickname };
72
+ if (room) {
73
+ msg.room = room;
74
+ }
75
+ return msg;
62
76
  }
63
77
 
64
78
  export function createEncryptedMessage(from, to, ciphertextB64, nonceB64) {
@@ -131,6 +145,33 @@ export function createRoomChanged(room, peers, isPrivate = false) {
131
145
  return msg;
132
146
  }
133
147
 
148
+ // ── Multi-room (IRC-style buffers) ─────────────────────────────
149
+ // join_room ADDS a membership (unlike change_room, which replaces them all).
150
+ // Same optional roomAuthPk as change_room for creating a private room.
151
+ export function createJoinRoom(room, roomAuthPkB64 = null) {
152
+ const msg = { ...base(MSG.JOIN_ROOM), room };
153
+ if (roomAuthPkB64) {
154
+ msg.roomAuthPk = roomAuthPkB64;
155
+ }
156
+ return msg;
157
+ }
158
+
159
+ export function createRoomJoined(room, peers, isPrivate = false) {
160
+ const msg = { ...base(MSG.ROOM_JOINED), room, peers };
161
+ if (isPrivate) {
162
+ msg.private = true;
163
+ }
164
+ return msg;
165
+ }
166
+
167
+ export function createLeaveRoom(room) {
168
+ return { ...base(MSG.LEAVE_ROOM), room };
169
+ }
170
+
171
+ export function createRoomLeft(room) {
172
+ return { ...base(MSG.ROOM_LEFT), room };
173
+ }
174
+
134
175
  // Server → client: the target room is private; prove password knowledge by
135
176
  // signing this nonce. The password itself never travels.
136
177
  export function createRoomChallenge(room, nonceB64) {
@@ -126,6 +126,21 @@ export function validateChangeRoom(msg) {
126
126
  return { valid: true, room: msg.room.toLowerCase(), roomAuthPk: msg.roomAuthPk || null };
127
127
  }
128
128
 
129
+ // join_room shares change_room's shape (room + optional verifier key).
130
+ export function validateJoinRoom(msg) {
131
+ return validateChangeRoom(msg);
132
+ }
133
+
134
+ export function validateLeaveRoom(msg) {
135
+ if (!isString(msg.room) || msg.room.length === 0 || msg.room.length > 30) {
136
+ return { valid: false, error: 'Invalid room name (1-30 chars)' };
137
+ }
138
+ if (!/^[a-zA-Z0-9_-]+$/.test(msg.room)) {
139
+ return { valid: false, error: 'Room name must be alphanumeric, dash or underscore' };
140
+ }
141
+ return { valid: true, room: msg.room.toLowerCase() };
142
+ }
143
+
129
144
  export function validateRoomAuth(msg) {
130
145
  if (!isString(msg.room) || msg.room.length === 0 || msg.room.length > 30) {
131
146
  return { valid: false, error: 'Invalid room name (1-30 chars)' };
@@ -143,15 +158,30 @@ export function validateListRooms() {
143
158
  return { valid: true };
144
159
  }
145
160
 
161
+ // Optional multi-room context on moderation commands: which room the owner is
162
+ // acting on. Absent → the server falls back to the session's only room.
163
+ function optionalRoom(msg) {
164
+ if (msg.room === undefined) {
165
+ return { ok: true, room: null };
166
+ }
167
+ const v = validateLeaveRoom({ room: msg.room });
168
+ return v.valid ? { ok: true, room: v.room } : { ok: false, error: v.error };
169
+ }
170
+
146
171
  export function validateKickPeer(msg) {
147
172
  const nick = sanitizeNickname(msg.targetNickname);
148
173
  if (!nick) {
149
174
  return { valid: false, error: 'Invalid target nickname' };
150
175
  }
176
+ const roomCheck = optionalRoom(msg);
177
+ if (!roomCheck.ok) {
178
+ return { valid: false, error: roomCheck.error };
179
+ }
151
180
  return {
152
181
  valid: true,
153
182
  targetNickname: nick,
154
183
  reason: isString(msg.reason) ? msg.reason.slice(0, 200) : '',
184
+ room: roomCheck.room,
155
185
  };
156
186
  }
157
187
 
@@ -163,7 +193,11 @@ export function validateMutePeer(msg) {
163
193
  if (!isNumber(msg.durationMs) || msg.durationMs <= 0) {
164
194
  return { valid: false, error: 'Invalid mute duration' };
165
195
  }
166
- return { valid: true, targetNickname: nick, durationMs: msg.durationMs };
196
+ const roomCheck = optionalRoom(msg);
197
+ if (!roomCheck.ok) {
198
+ return { valid: false, error: roomCheck.error };
199
+ }
200
+ return { valid: true, targetNickname: nick, durationMs: msg.durationMs, room: roomCheck.room };
167
201
  }
168
202
 
169
203
  export function validateBanPeer(msg) {
@@ -171,10 +205,15 @@ export function validateBanPeer(msg) {
171
205
  if (!nick) {
172
206
  return { valid: false, error: 'Invalid target nickname' };
173
207
  }
208
+ const roomCheck = optionalRoom(msg);
209
+ if (!roomCheck.ok) {
210
+ return { valid: false, error: roomCheck.error };
211
+ }
174
212
  return {
175
213
  valid: true,
176
214
  targetNickname: nick,
177
215
  reason: isString(msg.reason) ? msg.reason.slice(0, 200) : '',
216
+ room: roomCheck.room,
178
217
  };
179
218
  }
180
219
 
@@ -4,7 +4,7 @@ import { createLogger } from '../shared/logger.js';
4
4
  const log = createLogger('session');
5
5
 
6
6
  export class SessionManager {
7
- #sessions; // Map<sessionId, { ws, nickname, publicKey, connectedAt, room }>
7
+ #sessions; // Map<sessionId, { ws, nickname, publicKey, connectedAt, rooms: Set }>
8
8
  #nicknames; // Set<nickname> for quick dupe check
9
9
  #recentlyLeft; // Map<sessionId, { nickname, publicKey, leftAt }>
10
10
  #rooms; // Map<roomName, Set<sessionId>>
@@ -37,7 +37,7 @@ export class SessionManager {
37
37
  nickname,
38
38
  publicKey,
39
39
  connectedAt: Date.now(),
40
- room,
40
+ rooms: new Set(),
41
41
  };
42
42
 
43
43
  this.#sessions.set(sessionId, session);
@@ -54,7 +54,9 @@ export class SessionManager {
54
54
  return null;
55
55
  }
56
56
 
57
- this.#leaveRoom(sessionId);
57
+ for (const room of [...session.rooms]) {
58
+ this.#leaveRoom(sessionId, room);
59
+ }
58
60
  this.#nicknames.delete(session.nickname.toLowerCase());
59
61
  this.#sessions.delete(sessionId);
60
62
  this.#muteState.delete(sessionId);
@@ -93,7 +95,7 @@ export class SessionManager {
93
95
  const peers = [];
94
96
  for (const [id, session] of this.#sessions) {
95
97
  if (id !== excludeSessionId) {
96
- if (room && session.room !== room) {
98
+ if (room && !session.rooms.has(room)) {
97
99
  continue;
98
100
  }
99
101
  peers.push({
@@ -106,6 +108,35 @@ export class SessionManager {
106
108
  return peers;
107
109
  }
108
110
 
111
+ /**
112
+ * Send a message once to every session that shares at least one room with
113
+ * the given session (deduplicated — a peer in two shared rooms gets one).
114
+ */
115
+ broadcastToPeersOf(sessionId, msg, excludeSessionId = sessionId) {
116
+ const session = this.#sessions.get(sessionId);
117
+ if (!session) {
118
+ return;
119
+ }
120
+ const data = JSON.stringify(msg);
121
+ const notified = new Set();
122
+ for (const room of session.rooms) {
123
+ const members = this.#rooms.get(room);
124
+ if (!members) {
125
+ continue;
126
+ }
127
+ for (const sid of members) {
128
+ if (sid === excludeSessionId || notified.has(sid)) {
129
+ continue;
130
+ }
131
+ notified.add(sid);
132
+ const peer = this.#sessions.get(sid);
133
+ if (peer && peer.ws.readyState === 1) {
134
+ peer.ws.send(data);
135
+ }
136
+ }
137
+ }
138
+ }
139
+
109
140
  /**
110
141
  * Send a JSON message to all sessions except one.
111
142
  */
@@ -147,6 +178,7 @@ export class SessionManager {
147
178
  this.#rooms.set(room, new Set());
148
179
  }
149
180
  this.#rooms.get(room).add(sessionId);
181
+ this.#sessions.get(sessionId)?.rooms.add(room);
150
182
 
151
183
  // First person to create/join an empty non-general room becomes owner
152
184
  if (isNew && room !== 'general' && !this.#roomOwners.has(room)) {
@@ -155,13 +187,13 @@ export class SessionManager {
155
187
  }
156
188
  }
157
189
 
158
- #leaveRoom(sessionId) {
190
+ #leaveRoom(sessionId, room) {
159
191
  const session = this.#sessions.get(sessionId);
160
192
  if (!session) {
161
193
  return;
162
194
  }
163
195
 
164
- const room = session.room;
196
+ session.rooms.delete(room);
165
197
  const members = this.#rooms.get(room);
166
198
  if (members) {
167
199
  members.delete(sessionId);
@@ -185,23 +217,68 @@ export class SessionManager {
185
217
  }
186
218
  }
187
219
 
220
+ /**
221
+ * Legacy single-room semantics (protocol `change_room`): leave every
222
+ * current room and end up only in `newRoom`.
223
+ */
188
224
  switchRoom(sessionId, newRoom) {
189
225
  const session = this.#sessions.get(sessionId);
190
226
  if (!session) {
191
227
  return null;
192
228
  }
193
229
 
194
- const oldRoom = session.room;
195
- if (oldRoom === newRoom) {
196
- return null;
230
+ if (session.rooms.size === 1 && session.rooms.has(newRoom)) {
231
+ return null; // already exactly there
197
232
  }
198
233
 
199
- this.#leaveRoom(sessionId);
200
- session.room = newRoom;
234
+ const oldRooms = [...session.rooms].filter((r) => r !== newRoom);
235
+ for (const room of oldRooms) {
236
+ this.#leaveRoom(sessionId, room);
237
+ }
201
238
  this.#joinRoom(sessionId, newRoom);
202
239
 
203
- log.info(`${session.nickname} switched room: ${oldRoom} → ${newRoom}`);
204
- return { oldRoom, newRoom };
240
+ log.info(`${session.nickname} switched room: ${oldRooms.join(',') || '-'} → ${newRoom}`);
241
+ return { oldRoom: oldRooms[0] || null, oldRooms, newRoom };
242
+ }
243
+
244
+ /**
245
+ * Multi-room: join an ADDITIONAL room, keeping the current ones.
246
+ * Returns null when already a member.
247
+ */
248
+ joinAdditional(sessionId, room) {
249
+ const session = this.#sessions.get(sessionId);
250
+ if (!session || session.rooms.has(room)) {
251
+ return null;
252
+ }
253
+ this.#joinRoom(sessionId, room);
254
+ log.info(`${session.nickname} joined room ${room} (now in ${session.rooms.size})`);
255
+ return { room };
256
+ }
257
+
258
+ /**
259
+ * Multi-room: leave one room. Refuses to leave the last one (a session is
260
+ * always somewhere — that keeps peer visibility and moderation coherent).
261
+ */
262
+ leaveOneRoom(sessionId, room) {
263
+ const session = this.#sessions.get(sessionId);
264
+ if (!session || !session.rooms.has(room)) {
265
+ return null;
266
+ }
267
+ if (session.rooms.size === 1) {
268
+ return { lastRoom: true };
269
+ }
270
+ this.#leaveRoom(sessionId, room);
271
+ log.info(`${session.nickname} left room ${room} (now in ${session.rooms.size})`);
272
+ return { room };
273
+ }
274
+
275
+ getSessionRooms(sessionId) {
276
+ const session = this.#sessions.get(sessionId);
277
+ return session ? [...session.rooms] : [];
278
+ }
279
+
280
+ isInRoom(sessionId, room) {
281
+ return this.#sessions.get(sessionId)?.rooms.has(room) === true;
205
282
  }
206
283
 
207
284
  getRoomPeers(room, excludeSessionId) {
@@ -241,9 +318,16 @@ export class SessionManager {
241
318
  return this.#roomMeta.get(room)?.authPk || null;
242
319
  }
243
320
 
321
+ /**
322
+ * Legacy helper: a session's room when it has exactly one; null otherwise.
323
+ * Multi-room callers must pass the room explicitly instead.
324
+ */
244
325
  getSessionRoom(sessionId) {
245
326
  const session = this.#sessions.get(sessionId);
246
- return session?.room || null;
327
+ if (!session) {
328
+ return null;
329
+ }
330
+ return session.rooms.size === 1 ? [...session.rooms][0] : null;
247
331
  }
248
332
 
249
333
  updatePublicKey(sessionId, newPublicKey) {