@nolag/feed

Activity feeds with posts, likes and comments, delivered live to every subscriber of a channel.

Overview

@nolag/feed powers live activity feeds for social apps, community platforms and content aggregators. Publish a post, a like or a comment once and the broker fans it out to every subscriber of the channel. Your app owns one core NoLag client and injects it into NoLagFeed; the wrapper attaches its behaviour to that connection and never opens or closes the socket.

Key features

  • Live post, like and comment delivery to all channel subscribers
  • Unread post counts per channel
  • Unlike support with live tallies
  • Flat comments per post, with running comment counts
  • Optional audience filters that likes and comments inherit from their post

How it works

NoLagFeed attaches to the injected client and subscribes to the online lobby. joinChannel(name) returns a FeedChannel: each channel is a room, and the channel subscribes to three topics in it.

TopicPurpose
postsNew posts: content, media, author, timestamp
reactionsLike and unlike events
commentsNew comments, keyed by post ID

A PostStore keeps posts and their like and comment counts, and comments are grouped by post ID. Everything is published with echo: false: your own post, like or comment is added to the local store and emitted as postSent, postLiked or commentSent instead of coming back from the broker.

A subscriber sees what is published from the moment it joins, and the counts on a post are the likes and comments this client has seen. kraken offers clients no message history, so load the timeline of record from your own database and use the feed for what happens next.

Topics to grant

The wrapper expects these names in its app (the nolag-feed-sdk blueprint):

KindNames
Topicsposts, reactions, comments (all three subscribed in every joined channel)
Roomsmain-feed, plus every channel name you pass to joinChannel()
Lobbyonline (subscribed on every connect)

Readers need subscribe, and anyone who posts, likes or comments needs pubSub. If the online lobby is not granted, ready() resolves only after the 10 second lobby subscribe timeout.

With kraken's static auth file (format). Subscriber presence only works in rooms that have a rule carrying room_slug and room_id, and every token must use the same appId:

{
  "tokens": {
    "dev-token-alice": {
      "actorTokenId": "alice",
      "projectId": "demo-project",
      "actorType": "user",
      "appId": "feed-app",
      "appName": "feed",
      "allowedTopics": [
        { "pattern": "feed/main-feed/#", "permission": "pubSub", "room_id": "feed-main-feed", "room_slug": "main-feed" }
      ],
      "allowedLobbies": [{ "lobby_slug": "online", "lobby_id": "feed-online" }]
    }
  }
}

With a @nolag/core project document (format), as one entry in apps:

{
  "slug": "feed",
  "name": "Feed",
  "accessMode": "open",
  "topics": ["posts", "reactions", "comments"],
  "rooms": [{ "slug": "main-feed", "name": "Main Feed" }],
  "lobbies": [{ "slug": "online", "name": "Online", "rooms": ["main-feed"] }]
}

Installation

npm install @nolag/feed @nolag/js-sdk

@nolag/feed peer-depends on @nolag/js-sdk ^1.12.0. Both packages are MIT licensed.

Quick start

import { NoLag } from '@nolag/js-sdk'
import { NoLagFeed } from '@nolag/feed'

// The app owns one core client. `token` is an actor token or a TokenProvider.
const client = NoLag(token, { url: 'ws://localhost:8080/ws' })

const feed = new NoLagFeed({
  client,
  appName: 'feed', // the App Slug the topics are granted under
  username: 'Alice',
  avatar: '/img/alice.png',
})

await client.connect()   // the app owns the connection
await feed.ready()       // wrapper setup complete

// Join a channel. The joined channel becomes the active channel and holds your presence.
const channel = feed.joinChannel('main-feed')

// New posts from other users
channel.on('postCreated', (post) => {
  console.log(`${post.username}: ${post.content}`)
})

// Create a post. It is added to the local store and emitted as `postSent`.
const mine = channel.createPost({
  content: 'Just shipped a new feature!',
  media: [{ type: 'image', url: '/uploads/screenshot.png' }],
})

// Like and comment
channel.likePost(mine.id)
channel.on('postLiked', ({ postId, userId, likeCount }) => {
  console.log(`${userId} liked ${postId}; now ${likeCount} likes`)
})
channel.addComment(mine.id, 'Congrats!')
channel.on('commentAdded', (comment) => {
  console.log(`New comment on ${comment.postId}: ${comment.text}`)
})

// Unread posts while the channel is not active
console.log('Unread:', channel.unreadCount)
channel.markRead()

// Teardown: the wrapper releases its handlers; the app closes the socket.
feed.detach()
client.disconnect()

Reconnects

After the core client reconnects, the wrapper subscribes to the online lobby again, re-sends presence for the active channel and emits reconnected. It does not subscribe to its channel topics again: it assumes the broker restored them. kraken v0.9.0 restores subscriptions on reconnect only from what its auth backend returns, and the static auth file returns none, so on a static-auth kraken posts, likes and comments stop after a reconnect unless you subscribe again (see Production).

In 1.2.0 the wrapper's handlers are registered on the core client by topic and survive the reconnect; only the broker-side subscriptions are gone. Re-issue them from a connect handler on the core client (not reconnect, which fires before the socket is back). kraken treats a repeated subscribe as a replacement, so this is also safe where the backend did restore them.

client.on('connect', () => {
  for (const channel of feed.getChannels()) {
    const ctx = client.setApp('feed').setRoom(channel.name)
    const { posts, reactions, comments } = channel.filters
    ctx.subscribe('posts', posts.length > 0 ? { filters: posts } : {})
    ctx.subscribe('reactions', reactions.length > 0 ? { filters: reactions } : {})
    ctx.subscribe('comments', comments.length > 0 ? { filters: comments } : {})
  }
})

Posts published while a client was disconnected are not delivered to it later; reload the timeline from your database after a reconnect if it matters.

Filters

createPost({ content, filter }) publishes a post to subscribers filtering on that value, and joinChannel(name, { filters }) or channel.setFilters() narrows a subscriber to matching posts. Reactions and comments are published with the filter of the post they belong to, and setFilters() applies to all three topics unless you pass { topic }, so a filtered subscriber does not receive likes for posts it cannot see.

Two caveats apply on kraken v0.9.0:

  • Subscribers without filters receive every post, filtered or not. A filter routes traffic; it does not hide it. Keep audiences that must not see each other's posts in separate channels with their own grants.
  • On the built-in syn broker, a subscriber without filters receives every message with filter set to #, and the wrapper stores that on the posts it receives. Likes and comments it then sends on those posts go out with filter # and reach only other unfiltered subscribers. When a channel mixes filtered and unfiltered members on the syn broker, the filtered members miss the likes and comments sent by unfiltered ones.

See Filters.

Limitations on kraken v0.9.0

  • Lobby. At kraken v0.9.0 a lobby's room list comes from the rooms the subscribing actor can reach and is cached per node for 5 minutes after the last lobby subscribe; after that, userOnline and userOffline from the lobby stop until a client subscribes again. See Lobbies.
  • Presence follows the active channel, which matches kraken's one presence room per connection.

API reference

NoLagFeed

Constructor options

OptionTypeDescription
clientNoLagSocketRequired. The injected core client the app owns and connects.
usernamestringRequired. Display name for this user.
avatarstringOptional avatar URL.
metadataRecord<string, unknown>Optional custom user data carried in presence.
appNamestringThe App Slug used as the topic prefix (default 'feed').
channelsstring[]Channels to subscribe to once the wrapper is ready.
maxPostCachenumberMax posts kept in memory per channel (default 200).
maxCommentCachenumberAccepted and stored (default 100) but not enforced: comments per post are kept without a cap.
debugbooleanEnable wrapper debug logging (default false).
Method / propertyReturnsDescription
ready()Promise<void>Resolves once the first setup completed. joinChannel() throws before this.
detach()voidRelease this wrapper's handlers and topics. Terminal; never closes the socket.
joinChannel(name, opts?)FeedChannelJoin a channel and make it the active channel. opts.filters limits delivery to matching posts and their likes and comments.
leaveChannel(name)voidLeave a channel and unsubscribe its topics.
getChannels()FeedChannel[]All joined channels.
getOnlineUsers()FeedUser[]Users currently online.
updateProfile(updates)voidUpdate username, avatar or metadata, sent through presence.
localUserFeedUser | nullThe local user, available after ready().

Events

EventPayloadDescription
connectednoneFirst setup completed.
disconnectedreason: stringThe connection closed.
reconnectingnoneThe core client scheduled a reconnect attempt.
reconnectednoneSetup ran again after a reconnect (see Reconnects).
errorerror: ErrorAn error from the core client, including broker refusals.
userOnlineuser: FeedUserA user came online.
userOfflineuser: FeedUserA user went offline.

FeedChannel

Method / propertyReturnsDescription
createPost(opts)FeedPostPublish a post from { content, media?, data?, filter?, filters? }; returns the local copy.
likePost(postId) / unlikePost(postId)voidLike a post, or remove your like.
addComment(postId, text)FeedCommentComment on a post; returns the local copy.
getPosts()FeedPost[]Posts in the local store, oldest first.
getComments(postId)FeedComment[]Comments on one post.
getUsers()FeedUser[]Remote users present in this channel (active channel only).
unreadCount / markRead()number / voidPosts that arrived while the channel was not active, and the reset.
filters{ posts, reactions, comments }The current filters per topic.
setFilters(values, opts?) / addFilters(values, opts?) / removeFilters(values, opts?)voidChange the filters; opts.topic limits the change to one topic.

A FeedPost has id, userId, username, avatar?, content, media?, data?, likeCount, commentCount, likedByMe, timestamp, filter? and status. A FeedComment has id, postId, userId, username, avatar?, text and timestamp. Media attachments are { type: 'image' | 'video' | 'link', url, thumbnail?, title? }.

Events

EventPayloadDescription
postCreatedFeedPostA post from another user arrived.
postSentFeedPostYour own post was added to the local store and published.
postLiked{ postId, userId, likeCount }A post received a like (yours included).
postUnliked{ postId, userId, likeCount }A like was removed (yours included).
commentAddedFeedCommentA comment from another user arrived.
commentSentFeedCommentYour own comment was added to the local store and published.
subscriberJoinedFeedUserA user joined this channel.
subscriberLeftFeedUserA user left this channel.
unreadChanged{ channel, count }The channel's unread count changed.