@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.
@@ -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