@nolag/feed 0.1.2 → 1.0.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/index.mjs CHANGED
@@ -1,5 +1,3 @@
1
- import { NoLag } from '@nolag/js-sdk';
2
-
3
1
  class EventEmitter {
4
2
  constructor() {
5
3
  this._handlers = new Map();
@@ -273,6 +271,29 @@ function createLogger(prefix, enabled) {
273
271
  }
274
272
  return (...args) => { console.log(`[${prefix}]`, ...args); };
275
273
  }
274
+ // ============ Wrapper registry ============
275
+ // One wrapper instance per (client, appName): two wrappers sharing an app on
276
+ // one connection would collide on topics, presence and the online lobby.
277
+ // Warn (not throw): HMR and tests legitimately construct before disposing.
278
+ const wrapperRegistry = new WeakMap();
279
+ /** Register a wrapper against a client + appName; warns on collision. */
280
+ function registerWrapper(client, appName, wrapperName) {
281
+ let apps = wrapperRegistry.get(client);
282
+ if (!apps) {
283
+ apps = new Map();
284
+ wrapperRegistry.set(client, apps);
285
+ }
286
+ const existing = apps.get(appName);
287
+ if (existing) {
288
+ console.warn(`[${wrapperName}] Another wrapper (${existing}) is already attached to this client for app "${appName}". ` +
289
+ `Use one wrapper per (client, app) — detach the other instance first.`);
290
+ }
291
+ apps.set(appName, wrapperName);
292
+ }
293
+ /** Release a wrapper's (client, appName) registration on detach. */
294
+ function releaseWrapper(client, appName) {
295
+ wrapperRegistry.get(client)?.delete(appName);
296
+ }
276
297
 
277
298
  const DEFAULT_APP_NAME = 'feed';
278
299
  const DEFAULT_MAX_POST_CACHE = 200;
@@ -281,18 +302,33 @@ const TOPIC_POSTS = 'posts';
281
302
  const TOPIC_REACTIONS = 'reactions';
282
303
  const TOPIC_COMMENTS = 'comments';
283
304
  const LOBBY_ID = 'online';
305
+ /** Delay before the post-setup lobby presence refetch (catches simultaneous joiners) */
306
+ const LOBBY_REFRESH_DELAY_MS = 2000;
284
307
 
308
+ /**
309
+ * FeedChannel — a single feed channel with posts, comments, reactions, and
310
+ * presence.
311
+ *
312
+ * Created via `NoLagFeed.joinChannel(name)`. Do not instantiate directly.
313
+ */
285
314
  class FeedChannel extends EventEmitter {
286
- constructor(name, roomContext, localUser, options, log) {
315
+ /** @internal */
316
+ constructor(name, roomContext, localUser, options, log, isConnected) {
287
317
  super();
288
318
  this._comments = new Map();
289
319
  this._unreadCount = 0;
290
320
  this._active = false;
321
+ // Stored topic handler refs — cleanup removes exactly these, never all
322
+ // handlers for a topic (the client may be shared with other consumers).
323
+ this._onPostsRef = null;
324
+ this._onReactionsRef = null;
325
+ this._onCommentsRef = null;
291
326
  this.name = name;
292
327
  this._roomContext = roomContext;
293
328
  this._localUser = localUser;
294
329
  this._options = options;
295
330
  this._log = log;
331
+ this._isConnected = isConnected;
296
332
  this._presenceManager = new PresenceManager(localUser.actorTokenId);
297
333
  this._postStore = new PostStore(options.maxPostCache);
298
334
  this._reactionManager = new ReactionManager();
@@ -358,13 +394,27 @@ class FeedChannel extends EventEmitter {
358
394
  }
359
395
  }
360
396
  getUsers() { return this._presenceManager.getAll(); }
397
+ /** @internal Subscribe to post/reaction/comment topics and attach listeners (all channels) */
361
398
  _subscribe() {
399
+ this._log('Channel subscribe:', this.name);
362
400
  this._roomContext.subscribe(TOPIC_POSTS);
363
401
  this._roomContext.subscribe(TOPIC_REACTIONS);
364
402
  this._roomContext.subscribe(TOPIC_COMMENTS);
365
- this._roomContext.on(TOPIC_POSTS, (data, meta) => this._handleIncomingPost(data, meta));
366
- this._roomContext.on(TOPIC_REACTIONS, (data) => this._handleIncomingReaction(data));
367
- this._roomContext.on(TOPIC_COMMENTS, (data, meta) => this._handleIncomingComment(data, meta));
403
+ // Listen for posts (refs stored for handler-specific removal)
404
+ this._onPostsRef = (data, meta) => {
405
+ this._handleIncomingPost(data, meta);
406
+ };
407
+ this._roomContext.on(TOPIC_POSTS, this._onPostsRef);
408
+ // Listen for reactions
409
+ this._onReactionsRef = (data) => {
410
+ this._handleIncomingReaction(data);
411
+ };
412
+ this._roomContext.on(TOPIC_REACTIONS, this._onReactionsRef);
413
+ // Listen for comments
414
+ this._onCommentsRef = (data, meta) => {
415
+ this._handleIncomingComment(data, meta);
416
+ };
417
+ this._roomContext.on(TOPIC_COMMENTS, this._onCommentsRef);
368
418
  }
369
419
  _activate() {
370
420
  this._active = true;
@@ -397,13 +447,27 @@ class FeedChannel extends EventEmitter {
397
447
  _handleReplayStart(count) { this.emit('replayStart', { count }); }
398
448
  _handleReplayEnd(replayed) { this.emit('replayEnd', { replayed }); }
399
449
  _updateLocalPresence() { this._setPresence(); }
450
+ /** @internal Unsubscribe and clean up */
400
451
  _cleanup() {
401
- this._roomContext.unsubscribe(TOPIC_POSTS);
402
- this._roomContext.unsubscribe(TOPIC_REACTIONS);
403
- this._roomContext.unsubscribe(TOPIC_COMMENTS);
404
- this._roomContext.off(TOPIC_POSTS);
405
- this._roomContext.off(TOPIC_REACTIONS);
406
- this._roomContext.off(TOPIC_COMMENTS);
452
+ this._log('Channel cleanup:', this.name);
453
+ // Server unsubscribes need a live socket; skip when disconnected
454
+ // (best-effort — the core would no-op with an error callback anyway).
455
+ if (this._isConnected()) {
456
+ this._roomContext.unsubscribe(TOPIC_POSTS);
457
+ this._roomContext.unsubscribe(TOPIC_REACTIONS);
458
+ this._roomContext.unsubscribe(TOPIC_COMMENTS);
459
+ }
460
+ // Handler-specific removal only: the client may be shared, and a bare
461
+ // off(topic) would strip other consumers' handlers too.
462
+ if (this._onPostsRef)
463
+ this._roomContext.off(TOPIC_POSTS, this._onPostsRef);
464
+ if (this._onReactionsRef)
465
+ this._roomContext.off(TOPIC_REACTIONS, this._onReactionsRef);
466
+ if (this._onCommentsRef)
467
+ this._roomContext.off(TOPIC_COMMENTS, this._onCommentsRef);
468
+ this._onPostsRef = null;
469
+ this._onReactionsRef = null;
470
+ this._onCommentsRef = null;
407
471
  this._postStore.clear();
408
472
  this._reactionManager.clear();
409
473
  this._comments.clear();
@@ -465,169 +529,470 @@ class FeedChannel extends EventEmitter {
465
529
  this._roomContext.setPresence({
466
530
  userId: this._localUser.userId, username: this._localUser.username,
467
531
  avatar: this._localUser.avatar, metadata: this._localUser.metadata,
532
+ // Scope tag: on a shared client, other apps' wrappers filter our
533
+ // presence out by this (and we filter theirs).
534
+ __scope: this._options.appName,
468
535
  });
469
536
  }
470
537
  }
471
538
 
539
+ /**
540
+ * NoLagFeed — high-level activity-feed SDK built on @nolag/js-sdk.
541
+ *
542
+ * Provides multi-channel feeds, posts, likes, comments, presence (who's
543
+ * online), replay, and user mapping — all framework-agnostic via events.
544
+ *
545
+ * The wrapper NEVER manages the connection. The app owns one core NoLag
546
+ * client (shared by any number of wrappers on distinct apps) and the
547
+ * wrapper attaches to it at construction and releases it via `detach()`.
548
+ *
549
+ * @example
550
+ * ```typescript
551
+ * import { NoLag } from '@nolag/js-sdk';
552
+ * import { NoLagFeed } from '@nolag/feed';
553
+ *
554
+ * const client = NoLag(async () => (await (await fetch('/api/nolag-token')).json()).token);
555
+ * const feed = new NoLagFeed({ client, appName: 'my-feed', username: 'Alice' });
556
+ *
557
+ * feed.on('userOnline', (user) => console.log(user.username, 'is online'));
558
+ *
559
+ * await client.connect(); // the app owns the connection
560
+ * await feed.ready(); // wrapper setup done (identity, lobby, channels)
561
+ *
562
+ * const channel = feed.joinChannel('general');
563
+ * channel.on('postCreated', (post) => console.log(post.username + ':', post.content));
564
+ * channel.createPost({ content: 'Hello!' });
565
+ *
566
+ * feed.detach(); // wrapper releases its handlers and topics
567
+ * client.disconnect(); // the app closes the socket
568
+ * ```
569
+ */
472
570
  class NoLagFeed extends EventEmitter {
473
- constructor(token, options) {
571
+ constructor(options) {
474
572
  super();
475
- this._client = null;
476
573
  this._localUser = null;
477
574
  this._channels = new Map();
478
575
  this._lobby = null;
479
576
  this._onlineUsers = new Map();
480
577
  this._actorToUserId = new Map();
481
578
  this._activeChannel = null;
482
- this._token = token;
579
+ // Lifecycle: one setup run per connection epoch; detach is terminal.
580
+ this._epoch = 0;
581
+ this._detached = false;
582
+ this._isReady = false;
583
+ this._lobbyRefreshTimer = null;
584
+ // Stored client handler refs. INVARIANT: every client.on() below has a
585
+ // matching client.off() in detach() — never bare off(event), never inline
586
+ // closures on the client.
587
+ this._onConnectRef = () => this._onConnect();
588
+ this._onDisconnectRef = (reason) => {
589
+ this._log('Disconnected:', reason);
590
+ this.emit('disconnected', reason);
591
+ };
592
+ this._onReconnectRef = () => {
593
+ this._log('Reconnecting...');
594
+ this.emit('reconnecting');
595
+ };
596
+ this._onErrorRef = (error) => {
597
+ this._log('Error:', error);
598
+ this.emit('error', error);
599
+ };
600
+ this._onReplayStartRef = (data) => {
601
+ const event = data;
602
+ for (const channel of this._channels.values()) {
603
+ channel._handleReplayStart(event.count);
604
+ }
605
+ };
606
+ this._onReplayEndRef = (data) => {
607
+ const event = data;
608
+ for (const channel of this._channels.values()) {
609
+ channel._handleReplayEnd(event.replayed);
610
+ }
611
+ };
612
+ this._onPresenceJoinRef = (data) => this._handleRoomPresenceJoin(data);
613
+ this._onPresenceLeaveRef = (data) => this._handleRoomPresenceLeave(data);
614
+ this._onPresenceUpdateRef = (data) => this._handleRoomPresenceUpdate(data);
615
+ this._onLobbyJoinRef = (data) => this._handleLobbyJoin(data);
616
+ this._onLobbyLeaveRef = (data) => this._handleLobbyLeave(data);
617
+ this._onLobbyUpdateRef = (data) => this._handleLobbyUpdate(data);
618
+ if (!options?.client) {
619
+ throw new TypeError('NoLagFeed requires an injected NoLag client: new NoLagFeed({ client, username, ... })');
620
+ }
621
+ this._client = options.client;
483
622
  this._userId = generateId();
484
623
  this._options = {
485
- username: options.username, avatar: options.avatar, metadata: options.metadata,
486
- appName: options.appName ?? DEFAULT_APP_NAME, url: options.url,
624
+ username: options.username,
625
+ avatar: options.avatar,
626
+ metadata: options.metadata,
627
+ appName: options.appName ?? DEFAULT_APP_NAME,
487
628
  maxPostCache: options.maxPostCache ?? DEFAULT_MAX_POST_CACHE,
488
629
  maxCommentCache: options.maxCommentCache ?? DEFAULT_MAX_COMMENT_CACHE,
489
- debug: options.debug ?? false, reconnect: options.reconnect ?? true, channels: options.channels ?? [],
630
+ debug: options.debug ?? false,
631
+ channels: options.channels ?? [],
490
632
  };
491
633
  this._log = createLogger('NoLagFeed', this._options.debug);
634
+ this._readyPromise = new Promise((resolve, reject) => {
635
+ this._readyResolve = resolve;
636
+ this._readyReject = reject;
637
+ });
638
+ // ready() rejection is only meaningful to callers that await it
639
+ this._readyPromise.catch(() => { });
640
+ registerWrapper(this._client, this._options.appName, 'NoLagFeed');
641
+ // Construction = attach: wire everything now, with stored refs.
642
+ this._client.on('connect', this._onConnectRef);
643
+ this._client.on('disconnect', this._onDisconnectRef);
644
+ this._client.on('reconnect', this._onReconnectRef);
645
+ this._client.on('error', this._onErrorRef);
646
+ this._client.on('replay:start', this._onReplayStartRef);
647
+ this._client.on('replay:end', this._onReplayEndRef);
648
+ this._client.on('presence:join', this._onPresenceJoinRef);
649
+ this._client.on('presence:leave', this._onPresenceLeaveRef);
650
+ this._client.on('presence:update', this._onPresenceUpdateRef);
651
+ this._client.on('lobbyPresence:join', this._onLobbyJoinRef);
652
+ this._client.on('lobbyPresence:leave', this._onLobbyLeaveRef);
653
+ this._client.on('lobbyPresence:update', this._onLobbyUpdateRef);
654
+ // Attach-to-connected: if the client is already authenticated, run setup.
655
+ // The microtask lets the caller wire wrapper event handlers synchronously
656
+ // first; a racing real 'connect' event wins via the epoch guard.
657
+ queueMicrotask(() => {
658
+ if (this._epoch === 0 && !this._detached && this._client.connected) {
659
+ this._onConnect();
660
+ }
661
+ });
492
662
  }
493
- get connected() { return this._client?.connected ?? false; }
494
- get localUser() { return this._localUser; }
495
- get channels() { return this._channels; }
496
- async connect() {
497
- const clientOptions = { debug: this._options.debug, reconnect: this._options.reconnect };
498
- if (this._options.url)
499
- clientOptions.url = this._options.url;
500
- this._client = NoLag(this._token, clientOptions);
501
- this._client.on('connect', () => { if (this._channels.size > 0) {
502
- this._restoreChannels();
503
- this.emit('reconnected');
504
- } });
505
- this._client.on('disconnect', (reason) => this.emit('disconnected', reason));
506
- this._client.on('reconnect', () => { });
507
- this._client.on('error', (error) => this.emit('error', error));
508
- this._client.on('replay:start', (data) => { for (const ch of this._channels.values())
509
- ch._handleReplayStart(data.count); });
510
- this._client.on('replay:end', (data) => { for (const ch of this._channels.values())
511
- ch._handleReplayEnd(data.replayed); });
512
- await this._client.connect();
513
- this._client.on('presence:join', (data) => this._handleRoomPresenceJoin(data));
514
- this._client.on('presence:leave', (data) => this._handleRoomPresenceLeave(data));
515
- this._client.on('presence:update', (data) => this._handleRoomPresenceUpdate(data));
516
- this._localUser = {
517
- userId: this._userId, actorTokenId: this._client.actorId, username: this._options.username,
518
- avatar: this._options.avatar, metadata: this._options.metadata, joinedAt: Date.now(), isLocal: true,
519
- };
520
- await this._setupLobby();
521
- for (const name of this._options.channels)
522
- this._subscribeChannel(name);
523
- this.emit('connected');
524
- setTimeout(() => { if (this._lobby && this._client?.connected)
525
- this._lobby.fetchPresence().then((s) => this._hydrateOnlineUsers(s)).catch(() => { }); }, 2000);
526
- }
527
- disconnect() {
528
- for (const name of [...this._channels.keys()])
529
- this.leaveChannel(name);
530
- this._lobby?.unsubscribe();
663
+ // ============ Public Properties ============
664
+ /** Whether the underlying connection is established (connected ready) */
665
+ get connected() {
666
+ return !this._detached && this._client.connected;
667
+ }
668
+ /** The injected core client (owned by the app, not the wrapper) */
669
+ get client() {
670
+ return this._client;
671
+ }
672
+ /** The local user's info (available after ready) */
673
+ get localUser() {
674
+ return this._localUser;
675
+ }
676
+ /** All currently joined channels */
677
+ get channels() {
678
+ return this._channels;
679
+ }
680
+ // ============ Lifecycle ============
681
+ /**
682
+ * Resolves once the wrapper's first setup completed (identity, lobby and
683
+ * configured channels ready — equivalently, once 'connected' has fired).
684
+ * Rejects only if detach() is called before that. Client auth failures
685
+ * surface via the app's own `await client.connect()`, not here.
686
+ */
687
+ ready() {
688
+ return this._readyPromise;
689
+ }
690
+ /**
691
+ * Detach from the client: remove every handler this wrapper added,
692
+ * unsubscribe its topics and lobby (when connected), clear state.
693
+ * Terminal and idempotent; never touches the socket. To use the feed again,
694
+ * construct a new instance.
695
+ */
696
+ detach() {
697
+ if (this._detached)
698
+ return;
699
+ this._log('Detaching...');
700
+ this._detached = true;
701
+ this._epoch++; // aborts any in-flight setup at its next checkpoint
702
+ if (this._lobbyRefreshTimer) {
703
+ clearTimeout(this._lobbyRefreshTimer);
704
+ this._lobbyRefreshTimer = null;
705
+ }
706
+ // Remove all client handlers by stored ref
707
+ this._client.off('connect', this._onConnectRef);
708
+ this._client.off('disconnect', this._onDisconnectRef);
709
+ this._client.off('reconnect', this._onReconnectRef);
710
+ this._client.off('error', this._onErrorRef);
711
+ this._client.off('replay:start', this._onReplayStartRef);
712
+ this._client.off('replay:end', this._onReplayEndRef);
713
+ this._client.off('presence:join', this._onPresenceJoinRef);
714
+ this._client.off('presence:leave', this._onPresenceLeaveRef);
715
+ this._client.off('presence:update', this._onPresenceUpdateRef);
716
+ this._client.off('lobbyPresence:join', this._onLobbyJoinRef);
717
+ this._client.off('lobbyPresence:leave', this._onLobbyLeaveRef);
718
+ this._client.off('lobbyPresence:update', this._onLobbyUpdateRef);
719
+ // Channels: handler-specific off + connected-gated server unsubscribe
720
+ for (const name of [...this._channels.keys()]) {
721
+ this._channels.get(name)._cleanup();
722
+ this._channels.delete(name);
723
+ }
724
+ this._activeChannel = null;
725
+ // Lobby: server unsubscribe is best-effort and needs a live socket
726
+ if (this._lobby && this._client.connected) {
727
+ try {
728
+ this._lobby.unsubscribe();
729
+ }
730
+ catch {
731
+ /* best-effort */
732
+ }
733
+ }
531
734
  this._lobby = null;
532
- this._client?.disconnect();
533
- this._client = null;
534
735
  this._onlineUsers.clear();
535
736
  this._actorToUserId.clear();
536
737
  this._localUser = null;
738
+ releaseWrapper(this._client, this._options.appName);
739
+ if (!this._isReady) {
740
+ this._readyReject(new Error('NoLagFeed detached before ready'));
741
+ }
742
+ }
743
+ // ============ Private: Epoch Setup ============
744
+ _onConnect() {
745
+ this._epoch++;
746
+ void this._runSetup(this._epoch);
537
747
  }
748
+ /**
749
+ * One setup pass per connection epoch. Serves both initial setup (epoch 1)
750
+ * and reconnect restore (epoch > 1). Aborts silently whenever a newer
751
+ * epoch started or the wrapper detached — checked after every await.
752
+ */
753
+ async _runSetup(epoch) {
754
+ const stale = () => epoch !== this._epoch || this._detached;
755
+ this._log(this._isReady ? 'Restoring after reconnect...' : 'Setting up...');
756
+ // Identity (client.actorId is guaranteed post-auth)
757
+ if (!this._localUser) {
758
+ this._localUser = {
759
+ userId: this._userId,
760
+ actorTokenId: this._client.actorId,
761
+ username: this._options.username,
762
+ avatar: this._options.avatar,
763
+ metadata: this._options.metadata,
764
+ joinedAt: Date.now(),
765
+ isLocal: true,
766
+ };
767
+ this._log('Local user:', this._localUser.userId, '→', this._localUser.actorTokenId);
768
+ }
769
+ else {
770
+ this._localUser.actorTokenId = this._client.actorId;
771
+ }
772
+ // Lobby: subscribe every epoch (idempotent server-side) and diff-hydrate
773
+ // from the returned snapshot — one path for setup and restore.
774
+ if (!this._lobby) {
775
+ this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
776
+ }
777
+ try {
778
+ const state = await this._lobby.subscribe();
779
+ if (stale())
780
+ return;
781
+ this._diffHydrateOnlineUsers(state);
782
+ this._log('Lobby subscribed, online users:', this._onlineUsers.size);
783
+ }
784
+ catch (err) {
785
+ if (stale())
786
+ return;
787
+ this._log('Lobby subscription failed:', err);
788
+ }
789
+ if (!this._isReady) {
790
+ // First successful setup: pre-subscribe configured channels
791
+ // (posts only, no presence)
792
+ for (const channelName of this._options.channels) {
793
+ this._subscribeChannelInternal(channelName);
794
+ }
795
+ }
796
+ else if (this._activeChannel) {
797
+ // Server auto-restored topic subscriptions; only channel-scoped presence
798
+ // needs re-applying (the core does not restore it).
799
+ this._channels.get(this._activeChannel)?._updateLocalPresence();
800
+ }
801
+ if (stale())
802
+ return;
803
+ // Ready keys on the first setup that COMPLETES, not on epoch 1: an
804
+ // epoch aborted by a racing reconnect must not strand ready().
805
+ if (!this._isReady) {
806
+ this._isReady = true;
807
+ this._readyResolve();
808
+ this.emit('connected');
809
+ }
810
+ else {
811
+ this.emit('reconnected');
812
+ }
813
+ // Deferred lobby refetch: catches users who joined during the setup
814
+ // window (e.g. simultaneous multi-tab connects).
815
+ this._scheduleLobbyRefresh(epoch);
816
+ }
817
+ _scheduleLobbyRefresh(epoch) {
818
+ if (this._lobbyRefreshTimer)
819
+ clearTimeout(this._lobbyRefreshTimer);
820
+ this._lobbyRefreshTimer = setTimeout(() => {
821
+ this._lobbyRefreshTimer = null;
822
+ if (epoch !== this._epoch || this._detached || !this._client.connected || !this._lobby) {
823
+ return;
824
+ }
825
+ this._lobby
826
+ .fetchPresence()
827
+ .then((state) => {
828
+ if (epoch !== this._epoch || this._detached)
829
+ return;
830
+ this._diffHydrateOnlineUsers(state);
831
+ })
832
+ .catch(() => {
833
+ /* best-effort */
834
+ });
835
+ }, LOBBY_REFRESH_DELAY_MS);
836
+ }
837
+ // ============ Channel Management ============
838
+ /**
839
+ * Join (activate) a feed channel. Deactivates the previous active channel.
840
+ * If the channel was pre-subscribed via the `channels` option, activates it.
841
+ * Otherwise creates, subscribes, and activates it.
842
+ */
538
843
  joinChannel(name) {
539
- if (!this._client || !this._localUser)
540
- throw new Error('Not connected call connect() first');
844
+ this._assertUsable();
845
+ // Deactivate the current active channel
541
846
  if (this._activeChannel && this._activeChannel !== name) {
542
- this._channels.get(this._activeChannel)?._deactivate();
847
+ const prev = this._channels.get(this._activeChannel);
848
+ if (prev)
849
+ prev._deactivate();
850
+ }
851
+ // Get or create the channel
852
+ let channel = this._channels.get(name);
853
+ if (!channel) {
854
+ channel = this._subscribeChannelInternal(name);
543
855
  }
544
- let ch = this._channels.get(name);
545
- if (!ch)
546
- ch = this._subscribeChannel(name);
547
856
  this._activeChannel = name;
548
- ch._activate();
549
- return ch;
857
+ channel._activate();
858
+ return channel;
550
859
  }
860
+ /**
861
+ * Leave a feed channel. Fully unsubscribes and removes it.
862
+ */
551
863
  leaveChannel(name) {
552
- const ch = this._channels.get(name);
553
- if (!ch)
864
+ const channel = this._channels.get(name);
865
+ if (!channel)
554
866
  return;
555
- ch._cleanup();
867
+ this._log('Leaving channel:', name);
868
+ channel._cleanup();
556
869
  this._channels.delete(name);
557
- if (this._activeChannel === name)
870
+ if (this._activeChannel === name) {
558
871
  this._activeChannel = null;
872
+ }
873
+ }
874
+ /**
875
+ * Get all joined channels.
876
+ */
877
+ getChannels() {
878
+ return Array.from(this._channels.values());
879
+ }
880
+ // ============ Global Presence ============
881
+ /**
882
+ * Get all users currently online across all channels.
883
+ */
884
+ getOnlineUsers() {
885
+ return Array.from(this._onlineUsers.values());
886
+ }
887
+ // ============ Profile ============
888
+ /**
889
+ * Update the local user's profile info (broadcast to the active channel).
890
+ */
891
+ updateProfile(updates) {
892
+ if (!this._localUser)
893
+ return;
894
+ if (updates.username !== undefined) {
895
+ this._localUser.username = updates.username;
896
+ this._options.username = updates.username;
897
+ }
898
+ if (updates.avatar !== undefined) {
899
+ this._localUser.avatar = updates.avatar;
900
+ this._options.avatar = updates.avatar;
901
+ }
902
+ if (updates.metadata !== undefined) {
903
+ this._localUser.metadata = { ...this._localUser.metadata, ...updates.metadata };
904
+ this._options.metadata = this._localUser.metadata;
905
+ }
906
+ // Re-set presence only on the active channel
907
+ if (this._activeChannel) {
908
+ const activeChannel = this._channels.get(this._activeChannel);
909
+ if (activeChannel)
910
+ activeChannel._updateLocalPresence();
911
+ }
912
+ }
913
+ // ============ Private: Guards ============
914
+ _assertUsable() {
915
+ if (this._detached) {
916
+ throw new Error('NoLagFeed has been detached — construct a new instance');
917
+ }
918
+ if (!this._isReady || !this._localUser) {
919
+ throw new Error('NoLagFeed not ready — await ready() or the "connected" event');
920
+ }
559
921
  }
560
- getOnlineUsers() { return Array.from(this._onlineUsers.values()); }
561
- _subscribeChannel(name) {
562
- if (!this._client || !this._localUser)
563
- throw new Error('Not connected');
922
+ // ============ Private: Channel Setup ============
923
+ _subscribeChannelInternal(name) {
924
+ this._log('Subscribing channel:', name);
564
925
  const roomContext = this._client.setApp(this._options.appName).setRoom(name);
565
- const ch = new FeedChannel(name, roomContext, this._localUser, this._options, createLogger(`FeedChannel:${name}`, this._options.debug));
566
- this._channels.set(name, ch);
567
- ch._subscribe();
568
- return ch;
926
+ const channel = new FeedChannel(name, roomContext, this._localUser, this._options, createLogger(`FeedChannel:${name}`, this._options.debug), () => this._client.connected);
927
+ this._channels.set(name, channel);
928
+ channel._subscribe();
929
+ return channel;
569
930
  }
931
+ // ============ Private: Scope Filtering ============
932
+ /**
933
+ * On a shared client, presence events from other apps' wrappers arrive on
934
+ * the same connection-level events. Wrappers stamp their presence with a
935
+ * `__scope` (their appName); a mismatched tag means another app's data.
936
+ * Untagged presence is accepted (older peers in this same app).
937
+ */
938
+ _foreignScope(data) {
939
+ const scope = data?.__scope;
940
+ return typeof scope === 'string' && scope !== this._options.appName;
941
+ }
942
+ // ============ Private: Channel Presence → Active Channel ============
570
943
  _handleRoomPresenceJoin(data) {
571
944
  if (data.actorTokenId === this._localUser?.actorTokenId)
572
945
  return;
573
- const pd = data.presence;
574
- if (!pd?.userId)
946
+ const presenceData = data.presence;
947
+ if (!presenceData?.userId || this._foreignScope(presenceData))
575
948
  return;
576
- const user = this._presenceToUser(data.actorTokenId, pd);
949
+ // Track as online user
950
+ const user = this._presenceToUser(data.actorTokenId, presenceData);
577
951
  this._actorToUserId.set(data.actorTokenId, user.userId);
578
952
  if (!this._onlineUsers.has(user.userId)) {
579
953
  this._onlineUsers.set(user.userId, user);
580
954
  this.emit('userOnline', user);
581
955
  }
582
- const room = this._activeChannel ? this._channels.get(this._activeChannel) : undefined;
583
- if (room)
584
- room._handlePresenceJoin(data.actorTokenId, pd);
956
+ const channel = this._activeChannel ? this._channels.get(this._activeChannel) : undefined;
957
+ if (channel) {
958
+ channel._handlePresenceJoin(data.actorTokenId, presenceData);
959
+ }
585
960
  }
586
961
  _handleRoomPresenceLeave(data) {
587
962
  if (data.actorTokenId === this._localUser?.actorTokenId)
588
963
  return;
589
- const room = this._activeChannel ? this._channels.get(this._activeChannel) : undefined;
590
- if (room)
591
- room._handlePresenceLeave(data.actorTokenId);
964
+ // Channel leave offline user may still be in another channel.
965
+ // Lobby leave handles actual offline status.
966
+ const channel = this._activeChannel ? this._channels.get(this._activeChannel) : undefined;
967
+ if (channel) {
968
+ channel._handlePresenceLeave(data.actorTokenId);
969
+ }
592
970
  }
593
971
  _handleRoomPresenceUpdate(data) {
594
972
  if (data.actorTokenId === this._localUser?.actorTokenId)
595
973
  return;
596
- const pd = data.presence;
597
- if (!pd?.userId)
598
- return;
599
- const room = this._activeChannel ? this._channels.get(this._activeChannel) : undefined;
600
- if (room)
601
- room._handlePresenceUpdate(data.actorTokenId, pd);
602
- }
603
- async _setupLobby() {
604
- if (!this._client)
974
+ const presenceData = data.presence;
975
+ if (!presenceData?.userId || this._foreignScope(presenceData))
605
976
  return;
606
- this._lobby = this._client.setApp(this._options.appName).setLobby(LOBBY_ID);
607
- const lh = (type) => (data) => {
608
- const e = data;
609
- if (type === 'join')
610
- this._handleLobbyJoin(e);
611
- else if (type === 'leave')
612
- this._handleLobbyLeave(e);
613
- };
614
- this._client.on('lobbyPresence:join', lh('join'));
615
- this._client.on('lobbyPresence:leave', lh('leave'));
616
- this._client.on('lobbyPresence:update', lh('update'));
617
- try {
618
- const s = await this._lobby.subscribe();
619
- this._hydrateOnlineUsers(s);
977
+ // Update online user info if we already track them
978
+ if (this._onlineUsers.has(presenceData.userId)) {
979
+ const user = this._presenceToUser(data.actorTokenId, presenceData);
980
+ this._onlineUsers.set(user.userId, user);
981
+ }
982
+ const channel = this._activeChannel ? this._channels.get(this._activeChannel) : undefined;
983
+ if (channel) {
984
+ channel._handlePresenceUpdate(data.actorTokenId, presenceData);
620
985
  }
621
- catch { }
622
986
  }
987
+ // ============ Private: Lobby ============
623
988
  _handleLobbyJoin(event) {
624
989
  const { actorId, data } = event;
625
990
  if (actorId === this._localUser?.actorTokenId)
626
991
  return;
627
- const pd = data;
628
- if (!pd.userId)
992
+ const presenceData = data;
993
+ if (!presenceData.userId || this._foreignScope(presenceData))
629
994
  return;
630
- const user = this._presenceToUser(actorId, pd);
995
+ const user = this._presenceToUser(actorId, presenceData);
631
996
  this._actorToUserId.set(actorId, user.userId);
632
997
  if (!this._onlineUsers.has(user.userId)) {
633
998
  this._onlineUsers.set(user.userId, user);
@@ -638,8 +1003,12 @@ class NoLagFeed extends EventEmitter {
638
1003
  const { actorId, data } = event;
639
1004
  if (actorId === this._localUser?.actorTokenId)
640
1005
  return;
641
- const pd = data;
642
- const userId = pd?.userId || this._actorToUserId.get(actorId);
1006
+ const presenceData = data;
1007
+ if (this._foreignScope(presenceData))
1008
+ return;
1009
+ const userId = presenceData?.userId
1010
+ || this._actorToUserId.get(actorId)
1011
+ || this._findUserIdByActorId(actorId);
643
1012
  if (userId) {
644
1013
  const user = this._onlineUsers.get(userId);
645
1014
  if (user) {
@@ -649,34 +1018,81 @@ class NoLagFeed extends EventEmitter {
649
1018
  }
650
1019
  }
651
1020
  }
652
- _hydrateOnlineUsers(state) {
1021
+ _handleLobbyUpdate(event) {
1022
+ const { actorId, data } = event;
1023
+ if (actorId === this._localUser?.actorTokenId)
1024
+ return;
1025
+ const presenceData = data;
1026
+ if (!presenceData.userId || this._foreignScope(presenceData))
1027
+ return;
1028
+ const user = this._presenceToUser(actorId, presenceData);
1029
+ this._onlineUsers.set(user.userId, user);
1030
+ }
1031
+ /**
1032
+ * Reconcile the online-user map against a fresh lobby snapshot, emitting
1033
+ * only the deltas (userOffline for vanished, userOnline for new). One path
1034
+ * for initial hydration, reconnect restore, and the deferred refetch.
1035
+ */
1036
+ _diffHydrateOnlineUsers(state) {
1037
+ // Build the fresh user set from the snapshot
1038
+ const fresh = new Map();
1039
+ const freshActors = new Map();
653
1040
  for (const roomId of Object.keys(state)) {
654
- for (const actorId of Object.keys(state[roomId])) {
1041
+ const roomPresence = state[roomId];
1042
+ for (const actorId of Object.keys(roomPresence)) {
655
1043
  if (actorId === this._localUser?.actorTokenId)
656
1044
  continue;
657
- const raw = state[roomId][actorId];
658
- const pd = (raw?.presence ?? raw);
659
- if (pd?.userId) {
660
- const user = this._presenceToUser(actorId, pd);
661
- this._actorToUserId.set(actorId, user.userId);
662
- if (!this._onlineUsers.has(user.userId)) {
663
- this._onlineUsers.set(user.userId, user);
664
- this.emit('userOnline', user);
1045
+ const raw = roomPresence[actorId];
1046
+ // Server returns full actor records with presence nested under .presence
1047
+ const presenceData = (raw?.presence ?? raw);
1048
+ if (presenceData?.userId && !this._foreignScope(presenceData)) {
1049
+ if (!fresh.has(presenceData.userId)) {
1050
+ fresh.set(presenceData.userId, this._presenceToUser(actorId, presenceData));
665
1051
  }
1052
+ freshActors.set(actorId, presenceData.userId);
1053
+ }
1054
+ }
1055
+ }
1056
+ // Vanished users
1057
+ for (const [userId, user] of [...this._onlineUsers]) {
1058
+ if (!fresh.has(userId)) {
1059
+ this._onlineUsers.delete(userId);
1060
+ for (const [actorId, mappedUserId] of [...this._actorToUserId]) {
1061
+ if (mappedUserId === userId)
1062
+ this._actorToUserId.delete(actorId);
666
1063
  }
1064
+ this.emit('userOffline', user);
667
1065
  }
668
1066
  }
1067
+ // New users
1068
+ for (const [userId, user] of fresh) {
1069
+ if (!this._onlineUsers.has(userId)) {
1070
+ this._onlineUsers.set(userId, user);
1071
+ this.emit('userOnline', user);
1072
+ }
1073
+ }
1074
+ for (const [actorId, userId] of freshActors) {
1075
+ this._actorToUserId.set(actorId, userId);
1076
+ }
669
1077
  }
1078
+ // ============ Private: Helpers ============
670
1079
  _presenceToUser(actorTokenId, data) {
671
- return { userId: data.userId, actorTokenId, username: data.username, avatar: data.avatar, metadata: data.metadata, joinedAt: Date.now(), isLocal: false };
1080
+ return {
1081
+ userId: data.userId,
1082
+ actorTokenId,
1083
+ username: data.username,
1084
+ avatar: data.avatar,
1085
+ metadata: data.metadata,
1086
+ joinedAt: Date.now(),
1087
+ isLocal: false,
1088
+ };
672
1089
  }
673
- _restoreChannels() {
674
- if (this._activeChannel) {
675
- const ch = this._channels.get(this._activeChannel);
676
- if (ch)
677
- ch._updateLocalPresence();
1090
+ _findUserIdByActorId(actorTokenId) {
1091
+ for (const user of this._onlineUsers.values()) {
1092
+ if (user.actorTokenId === actorTokenId)
1093
+ return user.userId;
678
1094
  }
679
- this._lobby?.fetchPresence().then((s) => { this._onlineUsers.clear(); this._actorToUserId.clear(); this._hydrateOnlineUsers(s); }).catch(() => { });
1095
+ return undefined;
680
1096
  }
681
1097
  }
682
1098