@nolag/chat 1.0.0 → 1.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/dist/ChatRoom.d.ts +31 -2
- package/dist/NoLagChat.d.ts +2 -2
- package/dist/browser.js +1 -1
- package/dist/browser.js.map +1 -1
- package/dist/index.cjs +114 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.mjs +114 -14
- package/dist/index.mjs.map +1 -1
- package/dist/react-native.d.ts +13 -0
- package/dist/react-native.js +1579 -0
- package/dist/react-native.js.map +1 -0
- package/dist/types.d.ts +36 -0
- package/dist/utils.d.ts +49 -0
- package/package.json +10 -4
|
@@ -0,0 +1,1579 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny typed event emitter — framework-agnostic base for NoLagChat and ChatRoom.
|
|
3
|
+
*
|
|
4
|
+
* EventMap is a record of event name → tuple of handler arguments.
|
|
5
|
+
* e.g. { message: [ChatMessage]; typing: [{ users: ChatUser[] }] }
|
|
6
|
+
*/
|
|
7
|
+
class EventEmitter {
|
|
8
|
+
constructor() {
|
|
9
|
+
this._handlers = new Map();
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Register an event handler.
|
|
13
|
+
*/
|
|
14
|
+
on(event, handler) {
|
|
15
|
+
if (!this._handlers.has(event)) {
|
|
16
|
+
this._handlers.set(event, new Set());
|
|
17
|
+
}
|
|
18
|
+
this._handlers.get(event).add(handler);
|
|
19
|
+
return this;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Remove a specific handler, or all handlers for an event.
|
|
23
|
+
*/
|
|
24
|
+
off(event, handler) {
|
|
25
|
+
if (handler) {
|
|
26
|
+
this._handlers.get(event)?.delete(handler);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
this._handlers.delete(event);
|
|
30
|
+
}
|
|
31
|
+
return this;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Remove all handlers for all events.
|
|
35
|
+
*/
|
|
36
|
+
removeAllListeners() {
|
|
37
|
+
this._handlers.clear();
|
|
38
|
+
return this;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Emit an event to all registered handlers.
|
|
42
|
+
*/
|
|
43
|
+
emit(event, ...args) {
|
|
44
|
+
const handlers = this._handlers.get(event);
|
|
45
|
+
if (!handlers)
|
|
46
|
+
return;
|
|
47
|
+
for (const handler of handlers) {
|
|
48
|
+
try {
|
|
49
|
+
handler(...args);
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
console.error(`Error in ${String(event)} handler:`, e);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Returns the number of handlers registered for an event.
|
|
58
|
+
*/
|
|
59
|
+
listenerCount(event) {
|
|
60
|
+
return this._handlers.get(event)?.size ?? 0;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Bounded, deduplicated message cache ordered by timestamp.
|
|
66
|
+
*/
|
|
67
|
+
class MessageStore {
|
|
68
|
+
constructor(maxSize) {
|
|
69
|
+
this._messages = [];
|
|
70
|
+
this._ids = new Set();
|
|
71
|
+
this._maxSize = maxSize;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Add a message. Returns true if the message was new (not a duplicate).
|
|
75
|
+
*/
|
|
76
|
+
add(message) {
|
|
77
|
+
if (this._ids.has(message.id)) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
this._ids.add(message.id);
|
|
81
|
+
this._messages.push(message);
|
|
82
|
+
// Keep sorted by timestamp
|
|
83
|
+
if (this._messages.length > 1 &&
|
|
84
|
+
message.timestamp < this._messages[this._messages.length - 2].timestamp) {
|
|
85
|
+
this._messages.sort((a, b) => a.timestamp - b.timestamp);
|
|
86
|
+
}
|
|
87
|
+
// Trim if over capacity
|
|
88
|
+
while (this._messages.length > this._maxSize) {
|
|
89
|
+
const removed = this._messages.shift();
|
|
90
|
+
this._ids.delete(removed.id);
|
|
91
|
+
}
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Get all messages in timestamp order.
|
|
96
|
+
*/
|
|
97
|
+
getAll() {
|
|
98
|
+
return [...this._messages];
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Get message count.
|
|
102
|
+
*/
|
|
103
|
+
get size() {
|
|
104
|
+
return this._messages.length;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Check if a message ID exists.
|
|
108
|
+
*/
|
|
109
|
+
has(id) {
|
|
110
|
+
return this._ids.has(id);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Get a message by ID. Returns the live reference (mutating it mutates the
|
|
114
|
+
* stored message), or undefined if not present.
|
|
115
|
+
*/
|
|
116
|
+
get(id) {
|
|
117
|
+
if (!this._ids.has(id))
|
|
118
|
+
return undefined;
|
|
119
|
+
return this._messages.find((m) => m.id === id);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Clear all messages.
|
|
123
|
+
*/
|
|
124
|
+
clear() {
|
|
125
|
+
this._messages = [];
|
|
126
|
+
this._ids.clear();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Maps actorTokenId ↔ ChatUser, filtering self.
|
|
132
|
+
*/
|
|
133
|
+
class PresenceManager {
|
|
134
|
+
constructor(localActorId) {
|
|
135
|
+
this._users = new Map();
|
|
136
|
+
this._actorToUserId = new Map();
|
|
137
|
+
this._localActorId = localActorId;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Add or update a user from presence data.
|
|
141
|
+
* Returns the ChatUser if it's a remote user, null if it's self.
|
|
142
|
+
*/
|
|
143
|
+
addFromPresence(actorTokenId, presence, joinedAt) {
|
|
144
|
+
const isLocal = actorTokenId === this._localActorId;
|
|
145
|
+
// Skip self
|
|
146
|
+
if (isLocal)
|
|
147
|
+
return null;
|
|
148
|
+
const existing = this._actorToUserId.get(actorTokenId);
|
|
149
|
+
const userId = presence.userId || existing || actorTokenId;
|
|
150
|
+
const user = {
|
|
151
|
+
userId,
|
|
152
|
+
actorTokenId,
|
|
153
|
+
username: presence.username,
|
|
154
|
+
avatar: presence.avatar,
|
|
155
|
+
metadata: presence.metadata,
|
|
156
|
+
status: presence.status || 'online',
|
|
157
|
+
joinedAt: joinedAt || Date.now(),
|
|
158
|
+
isLocal: false,
|
|
159
|
+
};
|
|
160
|
+
this._users.set(userId, user);
|
|
161
|
+
this._actorToUserId.set(actorTokenId, userId);
|
|
162
|
+
return user;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Remove a user by actorTokenId.
|
|
166
|
+
* Returns the removed user, or null if not found / is self.
|
|
167
|
+
*/
|
|
168
|
+
removeByActorId(actorTokenId) {
|
|
169
|
+
if (actorTokenId === this._localActorId)
|
|
170
|
+
return null;
|
|
171
|
+
const userId = this._actorToUserId.get(actorTokenId);
|
|
172
|
+
if (!userId)
|
|
173
|
+
return null;
|
|
174
|
+
const user = this._users.get(userId) || null;
|
|
175
|
+
this._users.delete(userId);
|
|
176
|
+
this._actorToUserId.delete(actorTokenId);
|
|
177
|
+
return user;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Get a user by userId.
|
|
181
|
+
*/
|
|
182
|
+
getUser(userId) {
|
|
183
|
+
return this._users.get(userId);
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Get a user by actorTokenId.
|
|
187
|
+
*/
|
|
188
|
+
getUserByActorId(actorTokenId) {
|
|
189
|
+
const userId = this._actorToUserId.get(actorTokenId);
|
|
190
|
+
return userId ? this._users.get(userId) : undefined;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Get all remote users.
|
|
194
|
+
*/
|
|
195
|
+
getAll() {
|
|
196
|
+
return Array.from(this._users.values());
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Get the users Map (readonly view).
|
|
200
|
+
*/
|
|
201
|
+
get users() {
|
|
202
|
+
return this._users;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Clear all tracked users.
|
|
206
|
+
*/
|
|
207
|
+
clear() {
|
|
208
|
+
this._users.clear();
|
|
209
|
+
this._actorToUserId.clear();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Manages typing indicator state for both local (send) and remote (receive) sides.
|
|
215
|
+
*
|
|
216
|
+
* Send-side: debounced — calling startTyping() sends a signal, then auto-stops
|
|
217
|
+
* after a configurable timeout unless startTyping() is called again.
|
|
218
|
+
*
|
|
219
|
+
* Receive-side: per-user timeouts — if no typing signal arrives within the
|
|
220
|
+
* timeout window, the user is considered to have stopped typing.
|
|
221
|
+
*/
|
|
222
|
+
class TypingManager {
|
|
223
|
+
constructor(timeout) {
|
|
224
|
+
// Send-side
|
|
225
|
+
this._localTimer = null;
|
|
226
|
+
this._localTyping = false;
|
|
227
|
+
// Receive-side: userId → timeout
|
|
228
|
+
this._remoteTimers = new Map();
|
|
229
|
+
this._typingUserIds = new Set();
|
|
230
|
+
// Callbacks
|
|
231
|
+
this._onSend = null;
|
|
232
|
+
this._onChange = null;
|
|
233
|
+
this._timeout = timeout;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Set the callback invoked when a typing signal needs to be sent.
|
|
237
|
+
*/
|
|
238
|
+
onSend(cb) {
|
|
239
|
+
this._onSend = cb;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Set the callback invoked when the set of typing users changes.
|
|
243
|
+
*/
|
|
244
|
+
onChange(cb) {
|
|
245
|
+
this._onChange = cb;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Called by the local user when they are typing.
|
|
249
|
+
* Sends typing=true if not already sent, and (re)starts the auto-stop timer.
|
|
250
|
+
*/
|
|
251
|
+
startTyping() {
|
|
252
|
+
if (!this._localTyping) {
|
|
253
|
+
this._localTyping = true;
|
|
254
|
+
this._onSend?.(true);
|
|
255
|
+
}
|
|
256
|
+
// Reset auto-stop timer
|
|
257
|
+
if (this._localTimer)
|
|
258
|
+
clearTimeout(this._localTimer);
|
|
259
|
+
this._localTimer = setTimeout(() => {
|
|
260
|
+
this.stopTyping();
|
|
261
|
+
}, this._timeout);
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Called by the local user to explicitly stop typing.
|
|
265
|
+
*/
|
|
266
|
+
stopTyping() {
|
|
267
|
+
if (!this._localTyping)
|
|
268
|
+
return;
|
|
269
|
+
this._localTyping = false;
|
|
270
|
+
if (this._localTimer) {
|
|
271
|
+
clearTimeout(this._localTimer);
|
|
272
|
+
this._localTimer = null;
|
|
273
|
+
}
|
|
274
|
+
this._onSend?.(false);
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* Handle a remote typing signal.
|
|
278
|
+
*/
|
|
279
|
+
handleRemote(userId, typing) {
|
|
280
|
+
// Clear existing timer for this user
|
|
281
|
+
const existing = this._remoteTimers.get(userId);
|
|
282
|
+
if (existing)
|
|
283
|
+
clearTimeout(existing);
|
|
284
|
+
if (typing) {
|
|
285
|
+
this._typingUserIds.add(userId);
|
|
286
|
+
// Auto-expire if no follow-up
|
|
287
|
+
this._remoteTimers.set(userId, setTimeout(() => {
|
|
288
|
+
this._typingUserIds.delete(userId);
|
|
289
|
+
this._remoteTimers.delete(userId);
|
|
290
|
+
this._onChange?.();
|
|
291
|
+
}, this._timeout + 1000));
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
this._typingUserIds.delete(userId);
|
|
295
|
+
this._remoteTimers.delete(userId);
|
|
296
|
+
}
|
|
297
|
+
this._onChange?.();
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Get the set of currently-typing remote user IDs.
|
|
301
|
+
*/
|
|
302
|
+
getTypingUserIds() {
|
|
303
|
+
return this._typingUserIds;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Whether the local user is currently typing.
|
|
307
|
+
*/
|
|
308
|
+
get isLocalTyping() {
|
|
309
|
+
return this._localTyping;
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Clean up all timers.
|
|
313
|
+
*/
|
|
314
|
+
dispose() {
|
|
315
|
+
if (this._localTimer)
|
|
316
|
+
clearTimeout(this._localTimer);
|
|
317
|
+
for (const timer of this._remoteTimers.values()) {
|
|
318
|
+
clearTimeout(timer);
|
|
319
|
+
}
|
|
320
|
+
this._remoteTimers.clear();
|
|
321
|
+
this._typingUserIds.clear();
|
|
322
|
+
this._localTyping = false;
|
|
323
|
+
this._onSend = null;
|
|
324
|
+
this._onChange = null;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Generate a unique ID.
|
|
330
|
+
* Uses crypto.randomUUID when available, falls back to a simple random string.
|
|
331
|
+
*/
|
|
332
|
+
function generateId() {
|
|
333
|
+
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
334
|
+
return crypto.randomUUID();
|
|
335
|
+
}
|
|
336
|
+
// Fallback for environments without crypto.randomUUID
|
|
337
|
+
return 'xxxx-xxxx-xxxx-xxxx'.replace(/x/g, () => Math.floor(Math.random() * 16).toString(16));
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Create a debug logger that only logs when enabled.
|
|
341
|
+
*/
|
|
342
|
+
function createLogger(prefix, enabled) {
|
|
343
|
+
if (!enabled) {
|
|
344
|
+
return (..._args) => { };
|
|
345
|
+
}
|
|
346
|
+
return (...args) => {
|
|
347
|
+
console.log(`[${prefix}]`, ...args);
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
// ============ Filters ============
|
|
351
|
+
/**
|
|
352
|
+
* Build the filter fragment of an emit options object.
|
|
353
|
+
*
|
|
354
|
+
* `filter` wins over `filters`: a publish is routed to exactly one topic, so
|
|
355
|
+
* honouring both would silently drop one of them.
|
|
356
|
+
*/
|
|
357
|
+
function filterEmitOptions(opts) {
|
|
358
|
+
if (opts?.filter)
|
|
359
|
+
return { filter: opts.filter };
|
|
360
|
+
if (opts?.filters && opts.filters.length > 0)
|
|
361
|
+
return { filters: opts.filters };
|
|
362
|
+
return {};
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Merge OR terms into an existing filter set. AND groups (nested arrays) are
|
|
366
|
+
* preserved as-is — only plain string terms are deduplicated.
|
|
367
|
+
*/
|
|
368
|
+
function mergeFilters(existing, add) {
|
|
369
|
+
const simple = new Set();
|
|
370
|
+
const groups = [];
|
|
371
|
+
for (const f of existing) {
|
|
372
|
+
if (typeof f === 'string')
|
|
373
|
+
simple.add(f);
|
|
374
|
+
else
|
|
375
|
+
groups.push(f);
|
|
376
|
+
}
|
|
377
|
+
for (const v of add)
|
|
378
|
+
simple.add(v);
|
|
379
|
+
return [...simple, ...groups];
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Drop OR terms from a filter set. AND groups are left untouched — remove
|
|
383
|
+
* those by calling `setFilters` with the set you want.
|
|
384
|
+
*/
|
|
385
|
+
function withoutFilters(existing, remove) {
|
|
386
|
+
const drop = new Set(remove);
|
|
387
|
+
return existing.filter((f) => typeof f !== 'string' || !drop.has(f));
|
|
388
|
+
}
|
|
389
|
+
// ============ Wrapper registry ============
|
|
390
|
+
// One wrapper instance per (client, appName): two wrappers sharing an app on
|
|
391
|
+
// one connection would collide on topics, presence and the online lobby.
|
|
392
|
+
// Warn (not throw): HMR and tests legitimately construct before disposing.
|
|
393
|
+
const wrapperRegistry = new WeakMap();
|
|
394
|
+
/** Register a wrapper against a client + appName; warns on collision. */
|
|
395
|
+
function registerWrapper(client, appName, wrapperName) {
|
|
396
|
+
let apps = wrapperRegistry.get(client);
|
|
397
|
+
if (!apps) {
|
|
398
|
+
apps = new Map();
|
|
399
|
+
wrapperRegistry.set(client, apps);
|
|
400
|
+
}
|
|
401
|
+
const existing = apps.get(appName);
|
|
402
|
+
if (existing) {
|
|
403
|
+
console.warn(`[${wrapperName}] Another wrapper (${existing}) is already attached to this client for app "${appName}". ` +
|
|
404
|
+
`Use one wrapper per (client, app) — detach the other instance first.`);
|
|
405
|
+
}
|
|
406
|
+
apps.set(appName, wrapperName);
|
|
407
|
+
}
|
|
408
|
+
/** Release a wrapper's (client, appName) registration on detach. */
|
|
409
|
+
function releaseWrapper(client, appName) {
|
|
410
|
+
wrapperRegistry.get(client)?.delete(appName);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Default app name for room topic prefixes */
|
|
414
|
+
const DEFAULT_APP_NAME = 'chat';
|
|
415
|
+
/** Default typing indicator auto-stop timeout (ms) */
|
|
416
|
+
const DEFAULT_TYPING_TIMEOUT = 3000;
|
|
417
|
+
/** Default max messages kept per room */
|
|
418
|
+
const DEFAULT_MAX_MESSAGE_CACHE = 500;
|
|
419
|
+
/** Topic name for chat messages within a room */
|
|
420
|
+
const TOPIC_MESSAGES = 'messages';
|
|
421
|
+
/** Topic name for typing indicators within a room */
|
|
422
|
+
const TOPIC_TYPING = '_typing';
|
|
423
|
+
/** Topic name for live streamed message chunks within a room (ephemeral) */
|
|
424
|
+
const TOPIC_STREAM = '_stream';
|
|
425
|
+
/** Default coalesce/flush interval for streamed token chunks (ms) */
|
|
426
|
+
const DEFAULT_STREAM_FLUSH_MS = 60;
|
|
427
|
+
/** Lobby ID for global online presence */
|
|
428
|
+
const LOBBY_ID = 'online';
|
|
429
|
+
/** Delay before the post-setup lobby presence refetch (catches simultaneous joiners) */
|
|
430
|
+
const LOBBY_REFRESH_DELAY_MS = 2000;
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Producer-side controller for an outgoing streamed message.
|
|
434
|
+
*
|
|
435
|
+
* `append()` updates the local message immediately and buffers the token; a
|
|
436
|
+
* timer flushes buffered tokens as a single network delta at most every
|
|
437
|
+
* `flushIntervalMs`, so a token-per-character source doesn't flood the broker.
|
|
438
|
+
*/
|
|
439
|
+
class MessageStreamController {
|
|
440
|
+
constructor(message, _flushIntervalMs, _hooks) {
|
|
441
|
+
this._flushIntervalMs = _flushIntervalMs;
|
|
442
|
+
this._hooks = _hooks;
|
|
443
|
+
this._buffer = '';
|
|
444
|
+
this._flushTimer = null;
|
|
445
|
+
this._closed = false;
|
|
446
|
+
this.message = message;
|
|
447
|
+
// Announce the stream so receivers can render a live placeholder at once.
|
|
448
|
+
this._hooks.publishStream({
|
|
449
|
+
type: 'start',
|
|
450
|
+
id: message.id,
|
|
451
|
+
userId: message.userId,
|
|
452
|
+
username: message.username,
|
|
453
|
+
avatar: message.avatar,
|
|
454
|
+
timestamp: message.timestamp,
|
|
455
|
+
});
|
|
456
|
+
this._hooks.emitStart(message);
|
|
457
|
+
}
|
|
458
|
+
append(text) {
|
|
459
|
+
if (this._closed || !text)
|
|
460
|
+
return;
|
|
461
|
+
this.message.text += text;
|
|
462
|
+
this._buffer += text;
|
|
463
|
+
this._hooks.emitChunk(this.message, text);
|
|
464
|
+
if (!this._flushTimer) {
|
|
465
|
+
this._flushTimer = setTimeout(() => this._flush(), this._flushIntervalMs);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
complete() {
|
|
469
|
+
if (this._closed)
|
|
470
|
+
return this.message;
|
|
471
|
+
this._flush(); // send any buffered tokens
|
|
472
|
+
this._closed = true;
|
|
473
|
+
this._clearTimer();
|
|
474
|
+
this.message.status = 'sent';
|
|
475
|
+
this._hooks.publishFinal(this.message); // persisted final → finalizes receivers
|
|
476
|
+
this._hooks.emitEnd(this.message);
|
|
477
|
+
return this.message;
|
|
478
|
+
}
|
|
479
|
+
abort(error) {
|
|
480
|
+
if (this._closed)
|
|
481
|
+
return;
|
|
482
|
+
this._closed = true;
|
|
483
|
+
this._clearTimer();
|
|
484
|
+
this._buffer = '';
|
|
485
|
+
this.message.status = 'aborted';
|
|
486
|
+
this._hooks.publishStream({ type: 'abort', id: this.message.id, error });
|
|
487
|
+
this._hooks.emitAbort(this.message, error);
|
|
488
|
+
}
|
|
489
|
+
_flush() {
|
|
490
|
+
this._clearTimer();
|
|
491
|
+
if (this._buffer) {
|
|
492
|
+
this._hooks.publishStream({
|
|
493
|
+
type: 'delta',
|
|
494
|
+
id: this.message.id,
|
|
495
|
+
text: this._buffer,
|
|
496
|
+
});
|
|
497
|
+
this._buffer = '';
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
_clearTimer() {
|
|
501
|
+
if (this._flushTimer) {
|
|
502
|
+
clearTimeout(this._flushTimer);
|
|
503
|
+
this._flushTimer = null;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* ChatRoom — a single chat room with messages, users, and typing indicators.
|
|
510
|
+
*
|
|
511
|
+
* Created via `NoLagChat.joinRoom(name)`. Do not instantiate directly.
|
|
512
|
+
*/
|
|
513
|
+
class ChatRoom extends EventEmitter {
|
|
514
|
+
/** @internal */
|
|
515
|
+
constructor(name, roomContext, localUser, options, log, isConnected) {
|
|
516
|
+
super();
|
|
517
|
+
this._unreadCount = 0;
|
|
518
|
+
this._active = false;
|
|
519
|
+
this._filters = [];
|
|
520
|
+
// Stored topic handler refs — cleanup removes exactly these, never all
|
|
521
|
+
// handlers for a topic (the client may be shared with other consumers).
|
|
522
|
+
this._onMessagesRef = null;
|
|
523
|
+
this._onTypingRef = null;
|
|
524
|
+
this._onStreamRef = null;
|
|
525
|
+
this.name = name;
|
|
526
|
+
this._roomContext = roomContext;
|
|
527
|
+
this._localUser = localUser;
|
|
528
|
+
this._options = options;
|
|
529
|
+
this._log = log;
|
|
530
|
+
this._isConnected = isConnected;
|
|
531
|
+
this._presenceManager = new PresenceManager(localUser.actorTokenId);
|
|
532
|
+
this._typingManager = new TypingManager(options.typingTimeout);
|
|
533
|
+
this._messageStore = new MessageStore(options.maxMessageCache);
|
|
534
|
+
// Wire typing send callback
|
|
535
|
+
this._typingManager.onSend((typing) => {
|
|
536
|
+
this._roomContext.emit(TOPIC_TYPING, {
|
|
537
|
+
userId: this._localUser.userId,
|
|
538
|
+
typing,
|
|
539
|
+
}, { echo: false });
|
|
540
|
+
});
|
|
541
|
+
// Wire typing change callback
|
|
542
|
+
this._typingManager.onChange(() => {
|
|
543
|
+
this.emit('typing', { users: this.typingUsers });
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
// ============ Public Properties ============
|
|
547
|
+
/** All remote users currently in this room */
|
|
548
|
+
get users() {
|
|
549
|
+
return this._presenceManager.users;
|
|
550
|
+
}
|
|
551
|
+
/** All messages in this room (timestamp order) */
|
|
552
|
+
get messages() {
|
|
553
|
+
return this._messageStore.getAll();
|
|
554
|
+
}
|
|
555
|
+
/** Users currently typing */
|
|
556
|
+
get typingUsers() {
|
|
557
|
+
const typingIds = this._typingManager.getTypingUserIds();
|
|
558
|
+
const users = [];
|
|
559
|
+
for (const userId of typingIds) {
|
|
560
|
+
const user = this._presenceManager.getUser(userId);
|
|
561
|
+
if (user)
|
|
562
|
+
users.push(user);
|
|
563
|
+
}
|
|
564
|
+
return users;
|
|
565
|
+
}
|
|
566
|
+
/** Number of unread messages (increments when room is not active) */
|
|
567
|
+
get unreadCount() {
|
|
568
|
+
return this._unreadCount;
|
|
569
|
+
}
|
|
570
|
+
/** Whether this room is the currently active (visible) room */
|
|
571
|
+
get active() {
|
|
572
|
+
return this._active;
|
|
573
|
+
}
|
|
574
|
+
/** Reset the unread count to zero */
|
|
575
|
+
markRead() {
|
|
576
|
+
if (this._unreadCount !== 0) {
|
|
577
|
+
this._unreadCount = 0;
|
|
578
|
+
this.emit('unreadChanged', { room: this.name, count: 0 });
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
// ============ Filters ============
|
|
582
|
+
/** The filter values currently applied to this room's messages. */
|
|
583
|
+
get filters() {
|
|
584
|
+
return [...this._filters];
|
|
585
|
+
}
|
|
586
|
+
/**
|
|
587
|
+
* Replace this room's message filters — only messages published with one of
|
|
588
|
+
* these values are delivered. Applies to live streams as well as finalized
|
|
589
|
+
* messages, so a filtered stream reaches the same audience as its final
|
|
590
|
+
* message.
|
|
591
|
+
*
|
|
592
|
+
* Passing an empty array clears filtering and restores the wildcard
|
|
593
|
+
* subscription, which receives everything on the topic.
|
|
594
|
+
*
|
|
595
|
+
* @example
|
|
596
|
+
* ```ts
|
|
597
|
+
* room.setFilters(['alice', 'bob']); // alice OR bob
|
|
598
|
+
* room.setFilters([['alice', 'admin']]); // alice AND admin
|
|
599
|
+
* room.setFilters([]); // everything
|
|
600
|
+
* ```
|
|
601
|
+
*/
|
|
602
|
+
setFilters(values) {
|
|
603
|
+
this._filters = [...values];
|
|
604
|
+
this._applyFilters(TOPIC_MESSAGES);
|
|
605
|
+
this._applyFilters(TOPIC_STREAM);
|
|
606
|
+
}
|
|
607
|
+
/** Add filter values to the existing set. Existing AND groups are kept. */
|
|
608
|
+
addFilters(values) {
|
|
609
|
+
this.setFilters(mergeFilters(this._filters, values));
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Remove filter values from the existing set. Removing the last value
|
|
613
|
+
* restores the wildcard subscription.
|
|
614
|
+
*/
|
|
615
|
+
removeFilters(values) {
|
|
616
|
+
this.setFilters(withoutFilters(this._filters, values));
|
|
617
|
+
}
|
|
618
|
+
/** @internal Push the current filter set to one topic. */
|
|
619
|
+
_applyFilters(topic) {
|
|
620
|
+
// The core types filters as `string[]`, but both its implementation and
|
|
621
|
+
// the wire protocol accept AND groups (nested arrays).
|
|
622
|
+
this._roomContext.setFilters(topic, this._filters);
|
|
623
|
+
}
|
|
624
|
+
// ============ Messaging ============
|
|
625
|
+
/**
|
|
626
|
+
* Send a text message to this room. Returns an optimistic ChatMessage.
|
|
627
|
+
*/
|
|
628
|
+
sendMessage(text, options) {
|
|
629
|
+
const message = {
|
|
630
|
+
id: generateId(),
|
|
631
|
+
userId: this._localUser.userId,
|
|
632
|
+
username: this._localUser.username,
|
|
633
|
+
avatar: this._localUser.avatar,
|
|
634
|
+
text,
|
|
635
|
+
data: options?.data,
|
|
636
|
+
timestamp: Date.now(),
|
|
637
|
+
status: 'sending',
|
|
638
|
+
isReplay: false,
|
|
639
|
+
};
|
|
640
|
+
// Add to local store (optimistic)
|
|
641
|
+
this._messageStore.add(message);
|
|
642
|
+
this.emit('messageSent', message);
|
|
643
|
+
// Publish to room (echo: false prevents duplicate)
|
|
644
|
+
this._publishFinalMessage(message, filterEmitOptions(options));
|
|
645
|
+
// Mark as sent
|
|
646
|
+
message.status = 'sent';
|
|
647
|
+
// Stop typing on send
|
|
648
|
+
this._typingManager.stopTyping();
|
|
649
|
+
return message;
|
|
650
|
+
}
|
|
651
|
+
// ============ Streaming ============
|
|
652
|
+
/**
|
|
653
|
+
* Begin a streamed message (e.g. an AI response). Returns a handle you append
|
|
654
|
+
* tokens to. Receivers see the message appear and grow live; on `complete()`
|
|
655
|
+
* the full message is persisted like a normal message.
|
|
656
|
+
*
|
|
657
|
+
* @example
|
|
658
|
+
* ```ts
|
|
659
|
+
* const stream = room.startStream();
|
|
660
|
+
* for await (const token of llm) stream.append(token);
|
|
661
|
+
* stream.complete();
|
|
662
|
+
* ```
|
|
663
|
+
*/
|
|
664
|
+
startStream(options) {
|
|
665
|
+
const message = {
|
|
666
|
+
id: generateId(),
|
|
667
|
+
userId: this._localUser.userId,
|
|
668
|
+
username: this._localUser.username,
|
|
669
|
+
avatar: this._localUser.avatar,
|
|
670
|
+
text: '',
|
|
671
|
+
data: options?.data,
|
|
672
|
+
timestamp: Date.now(),
|
|
673
|
+
status: 'streaming',
|
|
674
|
+
isReplay: false,
|
|
675
|
+
};
|
|
676
|
+
// Optimistic: appears in room.messages and grows as tokens arrive.
|
|
677
|
+
this._messageStore.add(message);
|
|
678
|
+
this._typingManager.stopTyping();
|
|
679
|
+
// Deltas and the final message carry the same filter, so subscribers who
|
|
680
|
+
// see the stream grow are exactly those who end up with the message.
|
|
681
|
+
const filterOpts = filterEmitOptions(options);
|
|
682
|
+
return new MessageStreamController(message, options?.flushIntervalMs ?? DEFAULT_STREAM_FLUSH_MS, {
|
|
683
|
+
publishStream: (payload) => this._roomContext.emit(TOPIC_STREAM, payload, { echo: false, ...filterOpts }),
|
|
684
|
+
publishFinal: (m) => this._publishFinalMessage(m, filterOpts),
|
|
685
|
+
emitStart: (m) => this.emit('streamStart', m),
|
|
686
|
+
emitChunk: (m, delta) => this.emit('streamChunk', { message: m, delta }),
|
|
687
|
+
emitEnd: (m) => this.emit('streamEnd', m),
|
|
688
|
+
emitAbort: (m, error) => this.emit('streamAbort', { message: m, error }),
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Stream a message from a token source (sync or async iterable) — drops in
|
|
693
|
+
* for an LLM stream. Appends each chunk, finalizes on completion, and aborts
|
|
694
|
+
* (re-throwing) if the source errors.
|
|
695
|
+
*
|
|
696
|
+
* @example
|
|
697
|
+
* ```ts
|
|
698
|
+
* // OpenAI / Anthropic style streams yield text chunks
|
|
699
|
+
* await room.streamMessage(tokenIterable);
|
|
700
|
+
* ```
|
|
701
|
+
*/
|
|
702
|
+
async streamMessage(source, options) {
|
|
703
|
+
const stream = this.startStream(options);
|
|
704
|
+
try {
|
|
705
|
+
for await (const chunk of source) {
|
|
706
|
+
stream.append(chunk);
|
|
707
|
+
}
|
|
708
|
+
return stream.complete();
|
|
709
|
+
}
|
|
710
|
+
catch (err) {
|
|
711
|
+
stream.abort(err instanceof Error ? err.message : String(err));
|
|
712
|
+
throw err;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
/** @internal Publish a final message on the persisted `messages` topic. */
|
|
716
|
+
_publishFinalMessage(message, filterOpts = {}) {
|
|
717
|
+
this._roomContext.emit(TOPIC_MESSAGES, {
|
|
718
|
+
id: message.id,
|
|
719
|
+
userId: message.userId,
|
|
720
|
+
username: message.username,
|
|
721
|
+
avatar: message.avatar,
|
|
722
|
+
text: message.text,
|
|
723
|
+
data: message.data,
|
|
724
|
+
timestamp: message.timestamp,
|
|
725
|
+
}, { echo: false, ...filterOpts });
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Get all messages (alias for the messages getter).
|
|
729
|
+
*/
|
|
730
|
+
getMessages() {
|
|
731
|
+
return this._messageStore.getAll();
|
|
732
|
+
}
|
|
733
|
+
// ============ Typing ============
|
|
734
|
+
/**
|
|
735
|
+
* Signal that the local user is typing. Auto-stops after timeout.
|
|
736
|
+
*/
|
|
737
|
+
startTyping() {
|
|
738
|
+
this._typingManager.startTyping();
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Explicitly signal that the local user has stopped typing.
|
|
742
|
+
*/
|
|
743
|
+
stopTyping() {
|
|
744
|
+
this._typingManager.stopTyping();
|
|
745
|
+
}
|
|
746
|
+
// ============ Users ============
|
|
747
|
+
/**
|
|
748
|
+
* Get all remote users in this room.
|
|
749
|
+
*/
|
|
750
|
+
getUsers() {
|
|
751
|
+
return this._presenceManager.getAll();
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Get a specific user by userId.
|
|
755
|
+
*/
|
|
756
|
+
getUser(userId) {
|
|
757
|
+
return this._presenceManager.getUser(userId);
|
|
758
|
+
}
|
|
759
|
+
// ============ Internal (called by NoLagChat) ============
|
|
760
|
+
/** @internal Subscribe to message/typing topics and attach listeners (all rooms) */
|
|
761
|
+
_subscribe(filters) {
|
|
762
|
+
this._log('Room subscribe:', this.name, filters?.length ? `filters: ${filters.length}` : '');
|
|
763
|
+
this._filters = filters ? [...filters] : [];
|
|
764
|
+
// Subscribe to topics. Typing stays unfiltered: it is ephemeral, room-wide
|
|
765
|
+
// and already keyed by userId in the payload.
|
|
766
|
+
if (this._filters.length > 0) {
|
|
767
|
+
const opts = { filters: this._filters };
|
|
768
|
+
this._roomContext.subscribe(TOPIC_MESSAGES, opts);
|
|
769
|
+
this._roomContext.subscribe(TOPIC_STREAM, opts);
|
|
770
|
+
}
|
|
771
|
+
else {
|
|
772
|
+
this._roomContext.subscribe(TOPIC_MESSAGES);
|
|
773
|
+
this._roomContext.subscribe(TOPIC_STREAM);
|
|
774
|
+
}
|
|
775
|
+
this._roomContext.subscribe(TOPIC_TYPING);
|
|
776
|
+
// Listen for messages (refs stored for handler-specific removal)
|
|
777
|
+
this._onMessagesRef = (data, meta) => {
|
|
778
|
+
this._handleIncomingMessage(data, meta);
|
|
779
|
+
};
|
|
780
|
+
this._roomContext.on(TOPIC_MESSAGES, this._onMessagesRef);
|
|
781
|
+
// Listen for typing
|
|
782
|
+
this._onTypingRef = (data) => {
|
|
783
|
+
const { userId, typing } = data;
|
|
784
|
+
if (userId !== this._localUser.userId) {
|
|
785
|
+
this._typingManager.handleRemote(userId, typing);
|
|
786
|
+
}
|
|
787
|
+
};
|
|
788
|
+
this._roomContext.on(TOPIC_TYPING, this._onTypingRef);
|
|
789
|
+
// Listen for live streamed messages (start / delta / abort)
|
|
790
|
+
this._onStreamRef = (data) => {
|
|
791
|
+
this._handleStreamEvent(data);
|
|
792
|
+
};
|
|
793
|
+
this._roomContext.on(TOPIC_STREAM, this._onStreamRef);
|
|
794
|
+
}
|
|
795
|
+
/** @internal Set presence and fetch room members (active room only) */
|
|
796
|
+
_activate() {
|
|
797
|
+
this._log('Room activate:', this.name);
|
|
798
|
+
this._active = true;
|
|
799
|
+
this._markRead();
|
|
800
|
+
// Set room presence
|
|
801
|
+
this._setPresence();
|
|
802
|
+
// Fetch existing users
|
|
803
|
+
this._roomContext.fetchPresence().then((actors) => {
|
|
804
|
+
this._log('Room presence fetched:', this.name, actors.length, 'actors');
|
|
805
|
+
for (const actor of actors) {
|
|
806
|
+
if (actor.presence) {
|
|
807
|
+
const user = this._presenceManager.addFromPresence(actor.actorTokenId, actor.presence, actor.joinedAt);
|
|
808
|
+
if (user) {
|
|
809
|
+
this.emit('userJoined', user);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
}).catch((err) => {
|
|
814
|
+
this._log('Failed to fetch room presence:', err);
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
/** @internal Clear presence state but keep subscriptions alive */
|
|
818
|
+
_deactivate() {
|
|
819
|
+
this._log('Room deactivate:', this.name);
|
|
820
|
+
this._active = false;
|
|
821
|
+
this._presenceManager.clear();
|
|
822
|
+
}
|
|
823
|
+
/** @internal Handle a lobby presence:join event routed from NoLagChat */
|
|
824
|
+
_handlePresenceJoin(actorTokenId, presenceData) {
|
|
825
|
+
const user = this._presenceManager.addFromPresence(actorTokenId, presenceData);
|
|
826
|
+
if (user) {
|
|
827
|
+
this._log('User joined room:', this.name, user.username);
|
|
828
|
+
this.emit('userJoined', user);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
/** @internal Handle a lobby presence:leave event routed from NoLagChat */
|
|
832
|
+
_handlePresenceLeave(actorTokenId) {
|
|
833
|
+
const user = this._presenceManager.removeByActorId(actorTokenId);
|
|
834
|
+
if (user) {
|
|
835
|
+
this._log('User left room:', this.name, user.username);
|
|
836
|
+
// Remove from typing
|
|
837
|
+
this._typingManager.handleRemote(user.userId, false);
|
|
838
|
+
this.emit('userLeft', user);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
/** @internal Handle a lobby presence:update event routed from NoLagChat */
|
|
842
|
+
_handlePresenceUpdate(actorTokenId, presenceData) {
|
|
843
|
+
this._presenceManager.addFromPresence(actorTokenId, presenceData);
|
|
844
|
+
}
|
|
845
|
+
/** @internal Handle replay start event */
|
|
846
|
+
_handleReplayStart(count) {
|
|
847
|
+
this.emit('replayStart', { count });
|
|
848
|
+
}
|
|
849
|
+
/** @internal Handle replay end event */
|
|
850
|
+
_handleReplayEnd(replayed) {
|
|
851
|
+
this.emit('replayEnd', { replayed });
|
|
852
|
+
}
|
|
853
|
+
/** @internal Re-set presence after reconnect */
|
|
854
|
+
_updateLocalPresence() {
|
|
855
|
+
this._setPresence();
|
|
856
|
+
}
|
|
857
|
+
/** @internal Unsubscribe and clean up */
|
|
858
|
+
_cleanup() {
|
|
859
|
+
this._log('Room cleanup:', this.name);
|
|
860
|
+
// Server unsubscribes need a live socket; skip when disconnected
|
|
861
|
+
// (best-effort — the core would no-op with an error callback anyway).
|
|
862
|
+
if (this._isConnected()) {
|
|
863
|
+
this._roomContext.unsubscribe(TOPIC_MESSAGES);
|
|
864
|
+
this._roomContext.unsubscribe(TOPIC_TYPING);
|
|
865
|
+
this._roomContext.unsubscribe(TOPIC_STREAM);
|
|
866
|
+
}
|
|
867
|
+
// Handler-specific removal only: the client may be shared, and a bare
|
|
868
|
+
// off(topic) would strip other consumers' handlers too.
|
|
869
|
+
if (this._onMessagesRef)
|
|
870
|
+
this._roomContext.off(TOPIC_MESSAGES, this._onMessagesRef);
|
|
871
|
+
if (this._onTypingRef)
|
|
872
|
+
this._roomContext.off(TOPIC_TYPING, this._onTypingRef);
|
|
873
|
+
if (this._onStreamRef)
|
|
874
|
+
this._roomContext.off(TOPIC_STREAM, this._onStreamRef);
|
|
875
|
+
this._onMessagesRef = null;
|
|
876
|
+
this._onTypingRef = null;
|
|
877
|
+
this._onStreamRef = null;
|
|
878
|
+
this._typingManager.dispose();
|
|
879
|
+
this._messageStore.clear();
|
|
880
|
+
this._presenceManager.clear();
|
|
881
|
+
this.removeAllListeners();
|
|
882
|
+
}
|
|
883
|
+
// ============ Private ============
|
|
884
|
+
_handleIncomingMessage(data, meta) {
|
|
885
|
+
const msg = data;
|
|
886
|
+
const id = msg.id;
|
|
887
|
+
// If this is the persisted final for a message we streamed live, finalize
|
|
888
|
+
// the existing placeholder in place (authoritative text) rather than adding
|
|
889
|
+
// a duplicate. This also catches a late delta race — the final wins.
|
|
890
|
+
const streaming = this._messageStore.get(id);
|
|
891
|
+
if (streaming && streaming.status === 'streaming') {
|
|
892
|
+
streaming.text = msg.text;
|
|
893
|
+
streaming.data = msg.data;
|
|
894
|
+
streaming.status = 'delivered';
|
|
895
|
+
this.emit('streamEnd', streaming);
|
|
896
|
+
this.emit('message', streaming);
|
|
897
|
+
if (!this._active && !streaming.isReplay) {
|
|
898
|
+
this._unreadCount++;
|
|
899
|
+
this.emit('unreadChanged', { room: this.name, count: this._unreadCount });
|
|
900
|
+
}
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
const chatMessage = {
|
|
904
|
+
id,
|
|
905
|
+
userId: msg.userId,
|
|
906
|
+
username: msg.username,
|
|
907
|
+
avatar: msg.avatar,
|
|
908
|
+
text: msg.text,
|
|
909
|
+
data: msg.data,
|
|
910
|
+
timestamp: msg.timestamp,
|
|
911
|
+
status: 'delivered',
|
|
912
|
+
isReplay: meta.isReplay ?? false,
|
|
913
|
+
};
|
|
914
|
+
if (this._messageStore.add(chatMessage)) {
|
|
915
|
+
this.emit('message', chatMessage);
|
|
916
|
+
// Track unread when not the active room
|
|
917
|
+
if (!this._active && !chatMessage.isReplay) {
|
|
918
|
+
this._unreadCount++;
|
|
919
|
+
this.emit('unreadChanged', { room: this.name, count: this._unreadCount });
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
/** Handle an incoming live stream control payload (start / delta / abort). */
|
|
924
|
+
_handleStreamEvent(data) {
|
|
925
|
+
const evt = data;
|
|
926
|
+
if (!evt || !evt.id)
|
|
927
|
+
return;
|
|
928
|
+
switch (evt.type) {
|
|
929
|
+
case 'start': {
|
|
930
|
+
// Ignore our own (echo:false should prevent it, but be safe).
|
|
931
|
+
if (evt.userId === this._localUser.userId)
|
|
932
|
+
return;
|
|
933
|
+
const message = {
|
|
934
|
+
id: evt.id,
|
|
935
|
+
userId: evt.userId,
|
|
936
|
+
username: evt.username,
|
|
937
|
+
avatar: evt.avatar,
|
|
938
|
+
text: '',
|
|
939
|
+
timestamp: evt.timestamp,
|
|
940
|
+
status: 'streaming',
|
|
941
|
+
isReplay: false,
|
|
942
|
+
};
|
|
943
|
+
if (this._messageStore.add(message)) {
|
|
944
|
+
this.emit('streamStart', message);
|
|
945
|
+
}
|
|
946
|
+
break;
|
|
947
|
+
}
|
|
948
|
+
case 'delta': {
|
|
949
|
+
const message = this._messageStore.get(evt.id);
|
|
950
|
+
if (message && message.status === 'streaming') {
|
|
951
|
+
message.text += evt.text;
|
|
952
|
+
this.emit('streamChunk', { message, delta: evt.text });
|
|
953
|
+
}
|
|
954
|
+
break;
|
|
955
|
+
}
|
|
956
|
+
case 'abort': {
|
|
957
|
+
const message = this._messageStore.get(evt.id);
|
|
958
|
+
if (message && message.status === 'streaming') {
|
|
959
|
+
message.status = 'aborted';
|
|
960
|
+
this.emit('streamAbort', { message, error: evt.error });
|
|
961
|
+
}
|
|
962
|
+
break;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
_markRead() {
|
|
967
|
+
if (this._unreadCount !== 0) {
|
|
968
|
+
this._unreadCount = 0;
|
|
969
|
+
this.emit('unreadChanged', { room: this.name, count: 0 });
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
_setPresence() {
|
|
973
|
+
const presenceData = {
|
|
974
|
+
userId: this._localUser.userId,
|
|
975
|
+
username: this._localUser.username,
|
|
976
|
+
avatar: this._localUser.avatar,
|
|
977
|
+
status: this._localUser.status,
|
|
978
|
+
metadata: this._localUser.metadata,
|
|
979
|
+
// Scope tag: on a shared client, other apps' wrappers filter our
|
|
980
|
+
// presence out by this (and we filter theirs).
|
|
981
|
+
__scope: this._options.appName,
|
|
982
|
+
};
|
|
983
|
+
this._roomContext.setPresence(presenceData);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* NoLagChat — high-level chat SDK built on @nolag/js-sdk.
|
|
989
|
+
*
|
|
990
|
+
* Provides multi-room chat, presence (who's online), typing indicators,
|
|
991
|
+
* message replay, and user mapping — all framework-agnostic via events.
|
|
992
|
+
*
|
|
993
|
+
* The wrapper NEVER manages the connection. The app owns one core NoLag
|
|
994
|
+
* client (shared by any number of wrappers on distinct apps) and the
|
|
995
|
+
* wrapper attaches to it at construction and releases it via `detach()`.
|
|
996
|
+
*
|
|
997
|
+
* @example
|
|
998
|
+
* ```typescript
|
|
999
|
+
* import { NoLag } from '@nolag/js-sdk';
|
|
1000
|
+
* import { NoLagChat } from '@nolag/chat';
|
|
1001
|
+
*
|
|
1002
|
+
* const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token);
|
|
1003
|
+
* const chat = new NoLagChat({ client, appName: 'my-chat', username: 'Alice' });
|
|
1004
|
+
*
|
|
1005
|
+
* chat.on('userOnline', (user) => console.log(user.username, 'is online'));
|
|
1006
|
+
*
|
|
1007
|
+
* await client.connect(); // the app owns the connection
|
|
1008
|
+
* await chat.ready(); // wrapper setup done (identity, lobby, rooms)
|
|
1009
|
+
*
|
|
1010
|
+
* const room = chat.joinRoom('general');
|
|
1011
|
+
* room.on('message', (msg) => console.log(msg.username + ':', msg.text));
|
|
1012
|
+
* room.sendMessage('Hello!');
|
|
1013
|
+
*
|
|
1014
|
+
* chat.detach(); // wrapper releases its handlers and topics
|
|
1015
|
+
* client.disconnect(); // the app closes the socket
|
|
1016
|
+
* ```
|
|
1017
|
+
*/
|
|
1018
|
+
class NoLagChat extends EventEmitter {
|
|
1019
|
+
constructor(options) {
|
|
1020
|
+
super();
|
|
1021
|
+
this._localUser = null;
|
|
1022
|
+
this._rooms = new Map();
|
|
1023
|
+
this._lobby = null;
|
|
1024
|
+
this._onlineUsers = new Map();
|
|
1025
|
+
this._actorToUserId = new Map();
|
|
1026
|
+
this._activeRoom = null;
|
|
1027
|
+
// Lifecycle: one setup run per connection epoch; detach is terminal.
|
|
1028
|
+
this._epoch = 0;
|
|
1029
|
+
this._detached = false;
|
|
1030
|
+
this._isReady = false;
|
|
1031
|
+
this._lobbyRefreshTimer = null;
|
|
1032
|
+
// Stored client handler refs. INVARIANT: every client.on() below has a
|
|
1033
|
+
// matching client.off() in detach() — never bare off(event), never inline
|
|
1034
|
+
// closures on the client.
|
|
1035
|
+
this._onConnectRef = () => this._onConnect();
|
|
1036
|
+
this._onDisconnectRef = (reason) => {
|
|
1037
|
+
this._log('Disconnected:', reason);
|
|
1038
|
+
this.emit('disconnected', reason);
|
|
1039
|
+
};
|
|
1040
|
+
this._onReconnectRef = () => {
|
|
1041
|
+
this._log('Reconnecting...');
|
|
1042
|
+
this.emit('reconnecting');
|
|
1043
|
+
};
|
|
1044
|
+
this._onErrorRef = (error) => {
|
|
1045
|
+
this._log('Error:', error);
|
|
1046
|
+
this.emit('error', error);
|
|
1047
|
+
};
|
|
1048
|
+
this._onReplayStartRef = (data) => {
|
|
1049
|
+
const event = data;
|
|
1050
|
+
for (const room of this._rooms.values()) {
|
|
1051
|
+
room._handleReplayStart(event.count);
|
|
1052
|
+
}
|
|
1053
|
+
};
|
|
1054
|
+
this._onReplayEndRef = (data) => {
|
|
1055
|
+
const event = data;
|
|
1056
|
+
for (const room of this._rooms.values()) {
|
|
1057
|
+
room._handleReplayEnd(event.replayed);
|
|
1058
|
+
}
|
|
1059
|
+
};
|
|
1060
|
+
this._onPresenceJoinRef = (data) => this._handleRoomPresenceJoin(data);
|
|
1061
|
+
this._onPresenceLeaveRef = (data) => this._handleRoomPresenceLeave(data);
|
|
1062
|
+
this._onPresenceUpdateRef = (data) => this._handleRoomPresenceUpdate(data);
|
|
1063
|
+
this._onLobbyJoinRef = (data) => this._handleLobbyJoin(data);
|
|
1064
|
+
this._onLobbyLeaveRef = (data) => this._handleLobbyLeave(data);
|
|
1065
|
+
this._onLobbyUpdateRef = (data) => this._handleLobbyUpdate(data);
|
|
1066
|
+
if (!options?.client) {
|
|
1067
|
+
throw new TypeError('NoLagChat requires an injected NoLag client: new NoLagChat({ client, username, ... })');
|
|
1068
|
+
}
|
|
1069
|
+
this._client = options.client;
|
|
1070
|
+
this._userId = generateId();
|
|
1071
|
+
this._options = {
|
|
1072
|
+
username: options.username,
|
|
1073
|
+
avatar: options.avatar,
|
|
1074
|
+
metadata: options.metadata,
|
|
1075
|
+
appName: options.appName ?? DEFAULT_APP_NAME,
|
|
1076
|
+
typingTimeout: options.typingTimeout ?? DEFAULT_TYPING_TIMEOUT,
|
|
1077
|
+
maxMessageCache: options.maxMessageCache ?? DEFAULT_MAX_MESSAGE_CACHE,
|
|
1078
|
+
debug: options.debug ?? false,
|
|
1079
|
+
rooms: options.rooms ?? [],
|
|
1080
|
+
};
|
|
1081
|
+
this._log = createLogger('NoLagChat', this._options.debug);
|
|
1082
|
+
this._readyPromise = new Promise((resolve, reject) => {
|
|
1083
|
+
this._readyResolve = resolve;
|
|
1084
|
+
this._readyReject = reject;
|
|
1085
|
+
});
|
|
1086
|
+
// ready() rejection is only meaningful to callers that await it
|
|
1087
|
+
this._readyPromise.catch(() => { });
|
|
1088
|
+
registerWrapper(this._client, this._options.appName, 'NoLagChat');
|
|
1089
|
+
// Construction = attach: wire everything now, with stored refs.
|
|
1090
|
+
this._client.on('connect', this._onConnectRef);
|
|
1091
|
+
this._client.on('disconnect', this._onDisconnectRef);
|
|
1092
|
+
this._client.on('reconnect', this._onReconnectRef);
|
|
1093
|
+
this._client.on('error', this._onErrorRef);
|
|
1094
|
+
this._client.on('replay:start', this._onReplayStartRef);
|
|
1095
|
+
this._client.on('replay:end', this._onReplayEndRef);
|
|
1096
|
+
this._client.on('presence:join', this._onPresenceJoinRef);
|
|
1097
|
+
this._client.on('presence:leave', this._onPresenceLeaveRef);
|
|
1098
|
+
this._client.on('presence:update', this._onPresenceUpdateRef);
|
|
1099
|
+
this._client.on('lobbyPresence:join', this._onLobbyJoinRef);
|
|
1100
|
+
this._client.on('lobbyPresence:leave', this._onLobbyLeaveRef);
|
|
1101
|
+
this._client.on('lobbyPresence:update', this._onLobbyUpdateRef);
|
|
1102
|
+
// Attach-to-connected: if the client is already authenticated, run setup.
|
|
1103
|
+
// The microtask lets the caller wire wrapper event handlers synchronously
|
|
1104
|
+
// first; a racing real 'connect' event wins via the epoch guard.
|
|
1105
|
+
queueMicrotask(() => {
|
|
1106
|
+
if (this._epoch === 0 && !this._detached && this._client.connected) {
|
|
1107
|
+
this._onConnect();
|
|
1108
|
+
}
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
// ============ Public Properties ============
|
|
1112
|
+
/** Whether the underlying connection is established (connected ≠ ready) */
|
|
1113
|
+
get connected() {
|
|
1114
|
+
return !this._detached && this._client.connected;
|
|
1115
|
+
}
|
|
1116
|
+
/** The injected core client (owned by the app, not the wrapper) */
|
|
1117
|
+
get client() {
|
|
1118
|
+
return this._client;
|
|
1119
|
+
}
|
|
1120
|
+
/** The local user's info (available after ready) */
|
|
1121
|
+
get localUser() {
|
|
1122
|
+
return this._localUser;
|
|
1123
|
+
}
|
|
1124
|
+
/** All currently joined rooms */
|
|
1125
|
+
get rooms() {
|
|
1126
|
+
return this._rooms;
|
|
1127
|
+
}
|
|
1128
|
+
// ============ Lifecycle ============
|
|
1129
|
+
/**
|
|
1130
|
+
* Resolves once the wrapper's first setup completed (identity, lobby and
|
|
1131
|
+
* configured rooms ready — equivalently, once 'connected' has fired).
|
|
1132
|
+
* Rejects only if detach() is called before that. Client auth failures
|
|
1133
|
+
* surface via the app's own `await client.connect()`, not here.
|
|
1134
|
+
*/
|
|
1135
|
+
ready() {
|
|
1136
|
+
return this._readyPromise;
|
|
1137
|
+
}
|
|
1138
|
+
/**
|
|
1139
|
+
* Detach from the client: remove every handler this wrapper added,
|
|
1140
|
+
* unsubscribe its topics and lobby (when connected), clear state.
|
|
1141
|
+
* Terminal and idempotent; never touches the socket. To use chat again,
|
|
1142
|
+
* construct a new instance.
|
|
1143
|
+
*/
|
|
1144
|
+
detach() {
|
|
1145
|
+
if (this._detached)
|
|
1146
|
+
return;
|
|
1147
|
+
this._log('Detaching...');
|
|
1148
|
+
this._detached = true;
|
|
1149
|
+
this._epoch++; // aborts any in-flight setup at its next checkpoint
|
|
1150
|
+
if (this._lobbyRefreshTimer) {
|
|
1151
|
+
clearTimeout(this._lobbyRefreshTimer);
|
|
1152
|
+
this._lobbyRefreshTimer = null;
|
|
1153
|
+
}
|
|
1154
|
+
// Remove all client handlers by stored ref
|
|
1155
|
+
this._client.off('connect', this._onConnectRef);
|
|
1156
|
+
this._client.off('disconnect', this._onDisconnectRef);
|
|
1157
|
+
this._client.off('reconnect', this._onReconnectRef);
|
|
1158
|
+
this._client.off('error', this._onErrorRef);
|
|
1159
|
+
this._client.off('replay:start', this._onReplayStartRef);
|
|
1160
|
+
this._client.off('replay:end', this._onReplayEndRef);
|
|
1161
|
+
this._client.off('presence:join', this._onPresenceJoinRef);
|
|
1162
|
+
this._client.off('presence:leave', this._onPresenceLeaveRef);
|
|
1163
|
+
this._client.off('presence:update', this._onPresenceUpdateRef);
|
|
1164
|
+
this._client.off('lobbyPresence:join', this._onLobbyJoinRef);
|
|
1165
|
+
this._client.off('lobbyPresence:leave', this._onLobbyLeaveRef);
|
|
1166
|
+
this._client.off('lobbyPresence:update', this._onLobbyUpdateRef);
|
|
1167
|
+
// Rooms: handler-specific off + connected-gated server unsubscribe
|
|
1168
|
+
for (const name of [...this._rooms.keys()]) {
|
|
1169
|
+
this._rooms.get(name)._cleanup();
|
|
1170
|
+
this._rooms.delete(name);
|
|
1171
|
+
}
|
|
1172
|
+
this._activeRoom = null;
|
|
1173
|
+
// Lobby: server unsubscribe is best-effort and needs a live socket
|
|
1174
|
+
if (this._lobby && this._client.connected) {
|
|
1175
|
+
try {
|
|
1176
|
+
this._lobby.unsubscribe();
|
|
1177
|
+
}
|
|
1178
|
+
catch {
|
|
1179
|
+
/* best-effort */
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
this._lobby = null;
|
|
1183
|
+
this._onlineUsers.clear();
|
|
1184
|
+
this._actorToUserId.clear();
|
|
1185
|
+
this._localUser = null;
|
|
1186
|
+
releaseWrapper(this._client, this._options.appName);
|
|
1187
|
+
if (!this._isReady) {
|
|
1188
|
+
this._readyReject(new Error('NoLagChat detached before ready'));
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
// ============ Private: Epoch Setup ============
|
|
1192
|
+
_onConnect() {
|
|
1193
|
+
this._epoch++;
|
|
1194
|
+
void this._runSetup(this._epoch);
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* One setup pass per connection epoch. Serves both initial setup (epoch 1)
|
|
1198
|
+
* and reconnect restore (epoch > 1). Aborts silently whenever a newer
|
|
1199
|
+
* epoch started or the wrapper detached — checked after every await.
|
|
1200
|
+
*/
|
|
1201
|
+
async _runSetup(epoch) {
|
|
1202
|
+
const stale = () => epoch !== this._epoch || this._detached;
|
|
1203
|
+
this._log(this._isReady ? 'Restoring after reconnect...' : 'Setting up...');
|
|
1204
|
+
// Identity (client.actorId is guaranteed post-auth)
|
|
1205
|
+
if (!this._localUser) {
|
|
1206
|
+
this._localUser = {
|
|
1207
|
+
userId: this._userId,
|
|
1208
|
+
actorTokenId: this._client.actorId,
|
|
1209
|
+
username: this._options.username,
|
|
1210
|
+
avatar: this._options.avatar,
|
|
1211
|
+
metadata: this._options.metadata,
|
|
1212
|
+
status: 'online',
|
|
1213
|
+
joinedAt: Date.now(),
|
|
1214
|
+
isLocal: true,
|
|
1215
|
+
};
|
|
1216
|
+
this._log('Local user:', this._localUser.userId, '→', this._localUser.actorTokenId);
|
|
1217
|
+
}
|
|
1218
|
+
else {
|
|
1219
|
+
this._localUser.actorTokenId = this._client.actorId;
|
|
1220
|
+
}
|
|
1221
|
+
// Lobby: subscribe every epoch (idempotent server-side) and diff-hydrate
|
|
1222
|
+
// from the returned snapshot — one path for setup and restore.
|
|
1223
|
+
if (!this._lobby) {
|
|
1224
|
+
this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
|
|
1225
|
+
}
|
|
1226
|
+
try {
|
|
1227
|
+
const state = await this._lobby.subscribe();
|
|
1228
|
+
if (stale())
|
|
1229
|
+
return;
|
|
1230
|
+
this._diffHydrateOnlineUsers(state);
|
|
1231
|
+
this._log('Lobby subscribed, online users:', this._onlineUsers.size);
|
|
1232
|
+
}
|
|
1233
|
+
catch (err) {
|
|
1234
|
+
if (stale())
|
|
1235
|
+
return;
|
|
1236
|
+
this._log('Lobby subscription failed:', err);
|
|
1237
|
+
}
|
|
1238
|
+
if (!this._isReady) {
|
|
1239
|
+
// First successful setup: pre-subscribe configured rooms
|
|
1240
|
+
// (messages only, no presence)
|
|
1241
|
+
for (const roomName of this._options.rooms) {
|
|
1242
|
+
this._subscribeRoomInternal(roomName);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
else if (this._activeRoom) {
|
|
1246
|
+
// Server auto-restored topic subscriptions; only room-scoped presence
|
|
1247
|
+
// needs re-applying (the core does not restore it).
|
|
1248
|
+
this._rooms.get(this._activeRoom)?._updateLocalPresence();
|
|
1249
|
+
}
|
|
1250
|
+
if (stale())
|
|
1251
|
+
return;
|
|
1252
|
+
// Ready keys on the first setup that COMPLETES, not on epoch 1: an
|
|
1253
|
+
// epoch aborted by a racing reconnect must not strand ready().
|
|
1254
|
+
if (!this._isReady) {
|
|
1255
|
+
this._isReady = true;
|
|
1256
|
+
this._readyResolve();
|
|
1257
|
+
this.emit('connected');
|
|
1258
|
+
}
|
|
1259
|
+
else {
|
|
1260
|
+
this.emit('reconnected');
|
|
1261
|
+
}
|
|
1262
|
+
// Deferred lobby refetch: catches users who joined during the setup
|
|
1263
|
+
// window (e.g. simultaneous multi-tab connects).
|
|
1264
|
+
this._scheduleLobbyRefresh(epoch);
|
|
1265
|
+
}
|
|
1266
|
+
_scheduleLobbyRefresh(epoch) {
|
|
1267
|
+
if (this._lobbyRefreshTimer)
|
|
1268
|
+
clearTimeout(this._lobbyRefreshTimer);
|
|
1269
|
+
this._lobbyRefreshTimer = setTimeout(() => {
|
|
1270
|
+
this._lobbyRefreshTimer = null;
|
|
1271
|
+
if (epoch !== this._epoch || this._detached || !this._client.connected || !this._lobby) {
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
this._lobby
|
|
1275
|
+
.fetchPresence()
|
|
1276
|
+
.then((state) => {
|
|
1277
|
+
if (epoch !== this._epoch || this._detached)
|
|
1278
|
+
return;
|
|
1279
|
+
this._diffHydrateOnlineUsers(state);
|
|
1280
|
+
})
|
|
1281
|
+
.catch(() => {
|
|
1282
|
+
/* best-effort */
|
|
1283
|
+
});
|
|
1284
|
+
}, LOBBY_REFRESH_DELAY_MS);
|
|
1285
|
+
}
|
|
1286
|
+
// ============ Room Management ============
|
|
1287
|
+
/**
|
|
1288
|
+
* Join (activate) a chat room. Deactivates the previous active room.
|
|
1289
|
+
* If the room was pre-subscribed via the `rooms` option, activates it.
|
|
1290
|
+
* Otherwise creates, subscribes, and activates it.
|
|
1291
|
+
*/
|
|
1292
|
+
joinRoom(name, opts) {
|
|
1293
|
+
this._assertUsable();
|
|
1294
|
+
// Deactivate the current active room
|
|
1295
|
+
if (this._activeRoom && this._activeRoom !== name) {
|
|
1296
|
+
const prev = this._rooms.get(this._activeRoom);
|
|
1297
|
+
if (prev)
|
|
1298
|
+
prev._deactivate();
|
|
1299
|
+
}
|
|
1300
|
+
// Get or create the room
|
|
1301
|
+
let room = this._rooms.get(name);
|
|
1302
|
+
if (!room) {
|
|
1303
|
+
room = this._subscribeRoomInternal(name, opts?.filters);
|
|
1304
|
+
}
|
|
1305
|
+
else if (opts?.filters) {
|
|
1306
|
+
// Already subscribed (pre-subscribed via the `rooms` option, or an
|
|
1307
|
+
// earlier join). Re-point its filters rather than ignoring them.
|
|
1308
|
+
room.setFilters(opts.filters);
|
|
1309
|
+
}
|
|
1310
|
+
this._activeRoom = name;
|
|
1311
|
+
room._activate();
|
|
1312
|
+
return room;
|
|
1313
|
+
}
|
|
1314
|
+
/**
|
|
1315
|
+
* Leave a chat room. Fully unsubscribes and removes it.
|
|
1316
|
+
*/
|
|
1317
|
+
leaveRoom(name) {
|
|
1318
|
+
const room = this._rooms.get(name);
|
|
1319
|
+
if (!room)
|
|
1320
|
+
return;
|
|
1321
|
+
this._log('Leaving room:', name);
|
|
1322
|
+
room._cleanup();
|
|
1323
|
+
this._rooms.delete(name);
|
|
1324
|
+
if (this._activeRoom === name) {
|
|
1325
|
+
this._activeRoom = null;
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Get all joined rooms.
|
|
1330
|
+
*/
|
|
1331
|
+
getRooms() {
|
|
1332
|
+
return Array.from(this._rooms.values());
|
|
1333
|
+
}
|
|
1334
|
+
// ============ Global Presence ============
|
|
1335
|
+
/**
|
|
1336
|
+
* Get all users currently online across all rooms.
|
|
1337
|
+
*/
|
|
1338
|
+
getOnlineUsers() {
|
|
1339
|
+
return Array.from(this._onlineUsers.values());
|
|
1340
|
+
}
|
|
1341
|
+
/**
|
|
1342
|
+
* Update the local user's online status.
|
|
1343
|
+
*/
|
|
1344
|
+
setStatus(status) {
|
|
1345
|
+
if (this._localUser) {
|
|
1346
|
+
this._localUser.status = status;
|
|
1347
|
+
}
|
|
1348
|
+
// Re-set presence only on the active room
|
|
1349
|
+
if (this._activeRoom) {
|
|
1350
|
+
const activeRoom = this._rooms.get(this._activeRoom);
|
|
1351
|
+
if (activeRoom)
|
|
1352
|
+
activeRoom._updateLocalPresence();
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
// ============ Profile ============
|
|
1356
|
+
/**
|
|
1357
|
+
* Update the local user's profile info (broadcast to all rooms).
|
|
1358
|
+
*/
|
|
1359
|
+
updateProfile(updates) {
|
|
1360
|
+
if (!this._localUser)
|
|
1361
|
+
return;
|
|
1362
|
+
if (updates.username !== undefined) {
|
|
1363
|
+
this._localUser.username = updates.username;
|
|
1364
|
+
this._options.username = updates.username;
|
|
1365
|
+
}
|
|
1366
|
+
if (updates.avatar !== undefined) {
|
|
1367
|
+
this._localUser.avatar = updates.avatar;
|
|
1368
|
+
this._options.avatar = updates.avatar;
|
|
1369
|
+
}
|
|
1370
|
+
if (updates.metadata !== undefined) {
|
|
1371
|
+
this._localUser.metadata = { ...this._localUser.metadata, ...updates.metadata };
|
|
1372
|
+
this._options.metadata = this._localUser.metadata;
|
|
1373
|
+
}
|
|
1374
|
+
// Re-set presence only on the active room
|
|
1375
|
+
if (this._activeRoom) {
|
|
1376
|
+
const activeRoom = this._rooms.get(this._activeRoom);
|
|
1377
|
+
if (activeRoom)
|
|
1378
|
+
activeRoom._updateLocalPresence();
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
// ============ Private: Guards ============
|
|
1382
|
+
_assertUsable() {
|
|
1383
|
+
if (this._detached) {
|
|
1384
|
+
throw new Error('NoLagChat has been detached — construct a new instance');
|
|
1385
|
+
}
|
|
1386
|
+
if (!this._isReady || !this._localUser) {
|
|
1387
|
+
throw new Error('NoLagChat not ready — await ready() or the "connected" event');
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
// ============ Private: Room Setup ============
|
|
1391
|
+
_subscribeRoomInternal(name, filters) {
|
|
1392
|
+
this._log('Subscribing room:', name);
|
|
1393
|
+
const roomContext = this._client.setApp(this._options.appName).setRoom(name);
|
|
1394
|
+
const room = new ChatRoom(name, roomContext, this._localUser, this._options, createLogger(`ChatRoom:${name}`, this._options.debug), () => this._client.connected);
|
|
1395
|
+
this._rooms.set(name, room);
|
|
1396
|
+
room._subscribe(filters);
|
|
1397
|
+
return room;
|
|
1398
|
+
}
|
|
1399
|
+
// ============ Private: Scope Filtering ============
|
|
1400
|
+
/**
|
|
1401
|
+
* On a shared client, presence events from other apps' wrappers arrive on
|
|
1402
|
+
* the same connection-level events. Wrappers stamp their presence with a
|
|
1403
|
+
* `__scope` (their appName); a mismatched tag means another app's data.
|
|
1404
|
+
* Untagged presence is accepted (older peers in this same app).
|
|
1405
|
+
*/
|
|
1406
|
+
_foreignScope(data) {
|
|
1407
|
+
const scope = data?.__scope;
|
|
1408
|
+
return typeof scope === 'string' && scope !== this._options.appName;
|
|
1409
|
+
}
|
|
1410
|
+
// ============ Private: Room Presence → Active Room ============
|
|
1411
|
+
_handleRoomPresenceJoin(data) {
|
|
1412
|
+
if (data.actorTokenId === this._localUser?.actorTokenId)
|
|
1413
|
+
return;
|
|
1414
|
+
const presenceData = data.presence;
|
|
1415
|
+
if (!presenceData?.userId || this._foreignScope(presenceData))
|
|
1416
|
+
return;
|
|
1417
|
+
// Track as online user
|
|
1418
|
+
const user = this._presenceToUser(data.actorTokenId, presenceData);
|
|
1419
|
+
this._actorToUserId.set(data.actorTokenId, user.userId);
|
|
1420
|
+
if (!this._onlineUsers.has(user.userId)) {
|
|
1421
|
+
this._onlineUsers.set(user.userId, user);
|
|
1422
|
+
this.emit('userOnline', user);
|
|
1423
|
+
}
|
|
1424
|
+
const room = this._activeRoom ? this._rooms.get(this._activeRoom) : undefined;
|
|
1425
|
+
if (room) {
|
|
1426
|
+
room._handlePresenceJoin(data.actorTokenId, presenceData);
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
_handleRoomPresenceLeave(data) {
|
|
1430
|
+
if (data.actorTokenId === this._localUser?.actorTokenId)
|
|
1431
|
+
return;
|
|
1432
|
+
// Room leave ≠ offline — user may still be in another room.
|
|
1433
|
+
// Lobby leave handles actual offline status.
|
|
1434
|
+
const room = this._activeRoom ? this._rooms.get(this._activeRoom) : undefined;
|
|
1435
|
+
if (room) {
|
|
1436
|
+
room._handlePresenceLeave(data.actorTokenId);
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
_handleRoomPresenceUpdate(data) {
|
|
1440
|
+
if (data.actorTokenId === this._localUser?.actorTokenId)
|
|
1441
|
+
return;
|
|
1442
|
+
const presenceData = data.presence;
|
|
1443
|
+
if (!presenceData?.userId || this._foreignScope(presenceData))
|
|
1444
|
+
return;
|
|
1445
|
+
// Update online user info if we already track them
|
|
1446
|
+
if (this._onlineUsers.has(presenceData.userId)) {
|
|
1447
|
+
const user = this._presenceToUser(data.actorTokenId, presenceData);
|
|
1448
|
+
this._onlineUsers.set(user.userId, user);
|
|
1449
|
+
this.emit('userUpdated', user);
|
|
1450
|
+
}
|
|
1451
|
+
const room = this._activeRoom ? this._rooms.get(this._activeRoom) : undefined;
|
|
1452
|
+
if (room) {
|
|
1453
|
+
room._handlePresenceUpdate(data.actorTokenId, presenceData);
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
// ============ Private: Lobby ============
|
|
1457
|
+
_handleLobbyJoin(event) {
|
|
1458
|
+
const { actorId, data } = event;
|
|
1459
|
+
if (actorId === this._localUser?.actorTokenId)
|
|
1460
|
+
return;
|
|
1461
|
+
const presenceData = data;
|
|
1462
|
+
if (!presenceData.userId || this._foreignScope(presenceData))
|
|
1463
|
+
return;
|
|
1464
|
+
const user = this._presenceToUser(actorId, presenceData);
|
|
1465
|
+
this._actorToUserId.set(actorId, user.userId);
|
|
1466
|
+
if (!this._onlineUsers.has(user.userId)) {
|
|
1467
|
+
this._onlineUsers.set(user.userId, user);
|
|
1468
|
+
this.emit('userOnline', user);
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
_handleLobbyLeave(event) {
|
|
1472
|
+
const { actorId, data } = event;
|
|
1473
|
+
if (actorId === this._localUser?.actorTokenId)
|
|
1474
|
+
return;
|
|
1475
|
+
const presenceData = data;
|
|
1476
|
+
if (this._foreignScope(presenceData))
|
|
1477
|
+
return;
|
|
1478
|
+
const userId = presenceData?.userId
|
|
1479
|
+
|| this._actorToUserId.get(actorId)
|
|
1480
|
+
|| this._findUserIdByActorId(actorId);
|
|
1481
|
+
if (userId) {
|
|
1482
|
+
const user = this._onlineUsers.get(userId);
|
|
1483
|
+
if (user) {
|
|
1484
|
+
this._onlineUsers.delete(userId);
|
|
1485
|
+
this._actorToUserId.delete(actorId);
|
|
1486
|
+
this.emit('userOffline', user);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
_handleLobbyUpdate(event) {
|
|
1491
|
+
const { actorId, data } = event;
|
|
1492
|
+
if (actorId === this._localUser?.actorTokenId)
|
|
1493
|
+
return;
|
|
1494
|
+
const presenceData = data;
|
|
1495
|
+
if (!presenceData.userId || this._foreignScope(presenceData))
|
|
1496
|
+
return;
|
|
1497
|
+
const user = this._presenceToUser(actorId, presenceData);
|
|
1498
|
+
this._onlineUsers.set(user.userId, user);
|
|
1499
|
+
this.emit('userUpdated', user);
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* Reconcile the online-user map against a fresh lobby snapshot, emitting
|
|
1503
|
+
* only the deltas (userOffline for vanished, userOnline for new,
|
|
1504
|
+
* userUpdated for changed). One path for initial hydration, reconnect
|
|
1505
|
+
* restore, and the deferred refetch.
|
|
1506
|
+
*/
|
|
1507
|
+
_diffHydrateOnlineUsers(state) {
|
|
1508
|
+
// Build the fresh user set from the snapshot
|
|
1509
|
+
const fresh = new Map();
|
|
1510
|
+
const freshActors = new Map();
|
|
1511
|
+
for (const roomId of Object.keys(state)) {
|
|
1512
|
+
const roomPresence = state[roomId];
|
|
1513
|
+
for (const actorId of Object.keys(roomPresence)) {
|
|
1514
|
+
if (actorId === this._localUser?.actorTokenId)
|
|
1515
|
+
continue;
|
|
1516
|
+
const raw = roomPresence[actorId];
|
|
1517
|
+
// Server returns full actor records with presence nested under .presence
|
|
1518
|
+
const presenceData = (raw?.presence ?? raw);
|
|
1519
|
+
if (presenceData?.userId && !this._foreignScope(presenceData)) {
|
|
1520
|
+
if (!fresh.has(presenceData.userId)) {
|
|
1521
|
+
fresh.set(presenceData.userId, this._presenceToUser(actorId, presenceData));
|
|
1522
|
+
}
|
|
1523
|
+
freshActors.set(actorId, presenceData.userId);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
// Vanished users
|
|
1528
|
+
for (const [userId, user] of [...this._onlineUsers]) {
|
|
1529
|
+
if (!fresh.has(userId)) {
|
|
1530
|
+
this._onlineUsers.delete(userId);
|
|
1531
|
+
for (const [actorId, mappedUserId] of [...this._actorToUserId]) {
|
|
1532
|
+
if (mappedUserId === userId)
|
|
1533
|
+
this._actorToUserId.delete(actorId);
|
|
1534
|
+
}
|
|
1535
|
+
this.emit('userOffline', user);
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
// New and changed users
|
|
1539
|
+
for (const [userId, user] of fresh) {
|
|
1540
|
+
const prev = this._onlineUsers.get(userId);
|
|
1541
|
+
if (!prev) {
|
|
1542
|
+
this._onlineUsers.set(userId, user);
|
|
1543
|
+
this.emit('userOnline', user);
|
|
1544
|
+
}
|
|
1545
|
+
else if (prev.username !== user.username ||
|
|
1546
|
+
prev.avatar !== user.avatar ||
|
|
1547
|
+
prev.status !== user.status) {
|
|
1548
|
+
this._onlineUsers.set(userId, user);
|
|
1549
|
+
this.emit('userUpdated', user);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
for (const [actorId, userId] of freshActors) {
|
|
1553
|
+
this._actorToUserId.set(actorId, userId);
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
// ============ Private: Helpers ============
|
|
1557
|
+
_presenceToUser(actorTokenId, data) {
|
|
1558
|
+
return {
|
|
1559
|
+
userId: data.userId,
|
|
1560
|
+
actorTokenId,
|
|
1561
|
+
username: data.username,
|
|
1562
|
+
avatar: data.avatar,
|
|
1563
|
+
metadata: data.metadata,
|
|
1564
|
+
status: data.status || 'online',
|
|
1565
|
+
joinedAt: Date.now(),
|
|
1566
|
+
isLocal: false,
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
_findUserIdByActorId(actorTokenId) {
|
|
1570
|
+
for (const user of this._onlineUsers.values()) {
|
|
1571
|
+
if (user.actorTokenId === actorTokenId)
|
|
1572
|
+
return user.userId;
|
|
1573
|
+
}
|
|
1574
|
+
return undefined;
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
export { ChatRoom, EventEmitter, NoLagChat };
|
|
1579
|
+
//# sourceMappingURL=react-native.js.map
|