In this post, we'll explore how to build a custom in-app feed using the Knock JavaScript client. This project is modeled visually on the Notion in-app feed, and we'll use Next.js and shadcn/ui to build out the interface. We'll look at how to configure an in-app channel and workflow in Knock, fetch a user's feed from the Feed API, and update message engagement statuses as a user marks things as read
and archived
. Finally, we'll explore some of the small details that make Notion's feed such a great example.
Video walkthrough
If you prefer to learn with video, watch it here.
Getting started
Before we dive in too deep, let's take a look at what the end product will look like:
We'll end up with a tabbed feed where users can mark things as read, archive them, and switch between different views of the feed. This whole example project can be downloaded from the Knock GitHub account in the notion-feed-example
repo. It has two branches:
main
: The finished versionstart
: What we'll be starting with for this project
We'll cover adding the functionality to read from the Feed API and update message engagement statuses.
Cloning the repository
First, clone the repository from the GitHub repo using this command:
git clone https://github.com/knocklabs/notion-feed-example.git
Next, install the dependencies and make a copy of the .env.sample
file:
npm install
cp .env.sample .env.local
Then, we can open it up and see what values we need, as there are a few things we'll want to retrieve from the Knock dashboard:
- Knock User ID
- Knock Feed Channel ID
- Public API Key
Let's head into the Knock dashboard to grab those values.
Configuring the Knock client
The first thing we'll grab is our public API key. Under the "Developers > API Keys" heading, we can copy the public key and paste it into our .env.local
file.
Next, we'll need our Feed Channel ID. If you go to "Integrations" and find the in-app channel feed that gets created automatically (or another one you've created for your own projects), you can copy that ID and paste it into your environment file as well.
Lastly, we'll need a user ID. Go ahead and copy a user ID and paste it into the .env.local
file. If you don't have a user, you can create one from the dashboard under "Users."
Now we should be all set, as long as we've installed the project's dependencies. Let's fire up the development server:
npm run dev
This should start our local server on localhost:3000
. If you click the link to open it, you should see a project that looks like this:
Right now, there's nothing in either of the tabs, and the inbox says we don't have any messages. That's what we'll be adding in the next steps.
Seeding messages
Before we go any further, let's hop back into the Knock dashboard and take a look at an in-app workflow.
Creating a workflow
If you don't have one already, you can create one on the 'Workflows' screen of the dashboard. Then you can use the in-app channel that's created automatically when you create an account by dragging it onto the workflow canvas. You'll need to save your changes and commit them to the development branch.
Sending test messages
As you can see, we've got a really basic in-app workflow set up for this project:
If we click into the workflow itself and go to the workflow editor canvas, we can see that we've got a simple message template. Let's go ahead and seed a bunch of messages by clicking "Run a test."
You can pick a user ID, select an actor, and we'll pass in a different message as our message
property in our data payload.
Click "Run test" a couple of times to seed that in-app feed with some stuff we can work with.
It's worth noting that this example app doesn't actually go through the sending or triggering of workflows. It's just showing you how to read from an in-app feed. You can explore our docs on triggering workflows to build that into your project.
Now that we have a couple of example messages in this in-app feed for our user, let's hop back into the code editor and figure out how we can read that user's Feed API.
Configuring the Knock client in our app
Open up the ActivityFeed
component. We'll configure a new instance of the Knock client. We can see that we're already importing a couple of things from the @knocklabs/client
package, which should have been installed as a project dependency.
Right below FeedItemCard
, create a new constant variable called knockClient
. This will store a new configured instance of the client:
const knockClient = new Knock(
process.env.NEXT_PUBLIC_KNOCK_PUBLIC_API_KEY as string,
);
Below that, since we've created a new instance of the Knock client, we also need to authenticate it by passing a user ID:
knockClient.authenticate(process.env.NEXT_PUBLIC_KNOCK_USER_ID as string);
Now that we've created a new instance of the client and authenticated, we'll initialize a new feed:
const knockFeed = knockClient.feeds.initialize({
channelId: process.env.NEXT_PUBLIC_KNOCK_FEED_CHANNEL_ID as string,
pageSize: 20,
archived: "include",
});
This ties the feed to the channel ID we got in the first step. The second argument, which is optional, are feed client options. We're including two things:
pageSize
: How many items it will grab at one time (set to 20)archived
: Set to'include'
so we get the most recent 20 items of our feed and include archived items as well
In the next step, we'll talk about how we can actually fetch items from that feed and tie those into the user interface.
Fetching feed items
To begin the process of fetching, let's delete the feedItems
variable for now. We'll start by declaring some new constant variables using the useState
hook:
const [feed, setFeed] = useState<FeedStoreState>({});
We're telling TypeScript that this is a FeedStoreState
type, which we're importing from the @knocklabs/client
package. We'll initialize it to an empty object.
Below that, we've got this useState
hook to set some local state for this component. But we also want to make some network calls using the knockFeed
to load the initial state of our feed and listen for updates. We'll use another React hook to do that:
useEffect(() => {
knockFeed.listenForUpdates();
const fetchFeed = async () => {
await knockFeed.fetch();
const feedState = knockFeed.getState();
setFeed(feedState);
};
fetchFeed();
}, []);
Let's take a recap of what we're doing here:
- We used
useState
to create some local state variables for theActivityFeed
component. - Inside the
useEffect
hook, we're asking theknockFeed
client to listen for real-time updates. - We create a function to fetch the feed initially.
- We fetch the feed, get the state that we get back, and set it to our local component state.
There's one additional thing we need to do: add event listeners to the knockFeed
so that when we get those real-time updates from listenForUpdates()
or make changes to the status of messages, we can reconcile those updates with our local state:
knockFeed.on("items.received.*", () => {
setFeed(knockFeed.getState());
});
knockFeed.on("items.*", () => {
setFeed(knockFeed.getState());
});
The Knock feed does a good job of keeping track of its own internal state. When there are changes to that internal state, we're just going to reconcile those with the state in React by calling setFeed
and overriding whatever feed was in that local variable.
Now we've got a handle on getting those items from the API. In the next step, we'll make those items available to the interface so we can render some different components.
Rendering feed items
Before we proceed, let's make sure we have event handlers for both 'items.received.realtime'
and 'items.*'
. This captures a wider array of changes that can happen to the Knock feeds that deal with both message statuses and receiving new messages.
Even as we start updating the message engagement statuses on these items, we'll see that reflected when this event trigger fires, helping us reconcile our state as we make different changes to the feed.
If we reference back to our user interface, we can see that we've got a couple of different options:
- Inbox: Things that aren't archived
- Archive: Archived items
- All: All items
We'll want to separate these things out and create different arrays for these different types of items. We'll use the useMemo
hook to compute some of these values for us every time the feed array changes:
const [feedItems, archivedItems] = useMemo(() => {
const feedItems = feed.items?.filter((item: FeedItem) => !item.archivedAt);
const archivedItems = feed.items?.filter((item: FeedItem) => item.archivedAt);
return [feedItems, archivedItems];
}, [feed]);
We create two arrays:
feedItems
: Items that haven't been archived yetarchivedItems
: Items that have been archived
We return these arrays from useMemo
, which will re-compute them whenever feed
changes.
Now, let's implement the list of regular feed items (what we would find in the user's inbox). If we scroll down to our tabbed content, we can see that we're going to implement that here:
{
feedItems?.length > 0 ? (
feedItems.map((item: FeedItem) => (
<FeedItemCard key={item.id} item={item} knockFeed={knockFeed} />
))
) : (
<div className="p-4 text-gray-500">{/* ... */}</div>
);
}
We do a conditional check to make sure feedItems
has a length greater than 0. If it does, we map through feedItems
and return a FeedItemCard
for each one, passing in the item
and knockFeed
as props.
Head back into you interface and double-check that everything's working as expected now. You should see FeedItems
displaying in your inbox tab.
Let's do something similar with our archived items tab:
{
archivedItems?.length > 0 ? (
archivedItems.map((item: FeedItem) => (
<FeedItemCard key={item.id} item={item} knockFeed={knockFeed} />
))
) : (
<div className="p-4 text-gray-500">{/* ... */}</div>
);
}
If you go back to the interface, you should see any archived items there. If you archive an item, it should update and move to the "Archived" tab.
It's worth noting that message engagement statuses in
Knock are
mutually inclusive, meaning they can be in multiple different states at the
same time. For example, an item can be both unread
and archived
. This
gives you a lot of flexibility as the developer in how you want to model these
engagement statuses in your own application.
The FeedItemCard component
The FeedItemCard
component is doing a lot of the heavy lifting in this example app. Let's explore that component next.
We can see the props it takes: item
(the feed item) and knockFeed
. Inside the component, we do a number of things:
- Extract the different blocks attached to the feed item (like the
body
, which is the message template we created as part of our workflow). - Loop through the item's actors (the people generating the notification by triggering the workflow in Knock) and create the heading.
- Render the date using the
toLocaleDateString
function and theitem.insertedAt
property. - Handle message engagement statuses.
For the message engagement statuses, we conditionally render different buttons based on whether the item has been read or not:
{
!item.readAt ? (
<button onClick={/* ... */}>Mark as Read</button>
) : (
<button onClick={/* ... */}>Mark as Unread</button>
);
}
We pass the knockFeed
client into the component so we can attach each of those onClick
handlers to a particular method on knockFeed
, like markAsArchived
, markAsUnarchived
, markAsRead
, markAsUnread
, etc.
When we call these methods, the Knock feed updates for us locally inside our ActivityFeed
component because of the event listener we set up in our useEffect
hook. We told knockFeed
to listen to any of those lower-level item changes and then reset our feed state.
Additional functionality
There are a couple of other pieces of functionality we want to build into this, like the "Mark all as Read" and "Archive All" buttons in our inbox experience.
To do that, we'll create two functions in our ActivityFeed
component:
const markAllAsRead = () => {
knockFeed.markAllAsRead();
setFeed(knockFeed.getState());
};
const markAllAsArchived = () => {
knockFeed.markAllAsArchived();
setFeed(knockFeed.getState());
};
These functions:
- Use special bulk methods on the
knockFeed
to make status updates to allFeedItems
in the current scope - Manually set our feed state using
knockFeed.getState()
Then, we can add onClick
handlers to our buttons that call these functions:
<button onClick={markAllAsRead}>Mark all as Read</button><button onClick={markAllAsArchived}>Archive All</button>
If you go back to the interface, these buttons should work as expected, allowing you to quickly clear out our notification feed.
Polish and finishing touches
There are a couple of things that Notion does that add an extra layer of polish to the in-app feed experience.
The "New" unread icon
If we look at our FeedItemCard
component, we can see that we're conditionally rendering a "New" icon based on whether the item has been read or not:
{
!item.read_at && <div className="new-icon" />;
}
This is just a div
with some additional styling applied to it. In the example app, we apply these styles with the styles
attribute, but this is what a CSS class may look like:
.new-icon {
height: 8px;
width: 8px;
background: rgb(35, 131, 226);
position: absolute; /* ... */
}
These styles come one-to-one from the Notion UI, with a few tweaks to the left
and top
properties because the avatar size was a little different using shadcn/ui.
This is just one of those really nice features that calls attention to an unread item in a feed.
Opacity treatment for read items
There's another treatment that Notion applies to feed items that is really classy. If we look at our FeedItemCard
component, we can see that we're applying a 70% opacity to the element whenever it's marked as read:
<div className={`${item.read_at ? "opacity-70" : ""}`}>{/* ... */}</div>
When we look at the interface, we can see how this de-emphasizes the things we've already interacted with and makes the unread items stand out much more. We've got this double encoding of the unread status:
- The "New" item icon
- 100% opacity (vs. 70% for read items)
When you compare these things against each other, they both stand out really well. These are really tasteful affordances that Notion builds into their in-app feed experience.
Wrapping up
Awesome! Thanks so much for reading. In this post, we covered how to build a Notion-style in-app feed experience using Knock's Vanilla JavaScript client.
We looked at:
- Creating an in-app feed channel and workflow
- Fetching items from a user's feed
- Managing engagement statuses on feed messages
The feed client gives developers pretty much unlimited flexibility in the types of experiences they can create, so we're excited to see what you'll build.
You can get started with Knock for free by signing up here. Knock on.