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/README.md +8 -2
- package/README.pt-BR.md +8 -2
- package/docs/ARCHITECTURE.md +4 -1
- package/package.json +3 -3
- package/src/client/ChatController.js +485 -139
- package/src/client/UI.js +135 -3
- package/src/protocol/messages.js +45 -4
- package/src/protocol/validators.js +40 -1
- package/src/server/SessionManager.js +98 -14
- package/src/server/WebSocketServer.js +213 -52
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/protocol/messages.js
CHANGED
|
@@ -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
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
return null;
|
|
230
|
+
if (session.rooms.size === 1 && session.rooms.has(newRoom)) {
|
|
231
|
+
return null; // already exactly there
|
|
197
232
|
}
|
|
198
233
|
|
|
199
|
-
|
|
200
|
-
|
|
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: ${
|
|
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
|
-
|
|
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) {
|