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