In this post, we'll explore how to create a custom inbox using Knock's Feed API and React hooks. The app itself is built using Next.js and ShadCN UI.

Feed empty state

Inbox vs. feed

Both feeds and inboxes are types of in-app messaging components, but they generally serve different purposes inside your application.

Feeds are typically persistent components that display a stream of notifications or updates. If we consider the NotificationFeedPopover component from the @knocklabs/react package, it's a good example of a feed. It's generally included in your application's header, shows a real-time count of unread messages, and persists across navigations.

Inboxes on the other hand are usually a full-page experiences that allow users to interact with their messages in more detail. They often include features like filtering and sorting, and they may allow users to take actions on individual messages.

In a lot of cases, the information displayed in both components comes from the same source, but you use both an inbox and feed in the same application to give users different ways to interact with their messages.

As always, there are also no hard and fast rules here, so you may see feeds and inboxes used in different ways in different applications. In a recent interview with Thena, they discussed how they use Knock's feed API to power their in-app notifications and how they've built a custom inbox experience on top of it.

Getting started

  1. Clone the repository:

    git clone https://github.com/knocklabs/inbox-example-app.git
    
  2. Configure the environment variables:

    Update the .env.local file in the root of your project and add the following variables:

    NEXT_PUBLIC_KNOCK_API_KEY=your_public_knock_api_key
    NEXT_PUBLIC_KNOCK_USER_ID=your_knock_user_id
    NEXT_PUBLIC_KNOCK_FEED_CHANNEL_ID=your_knock_feed_channel_id
    KNOCK_SECRET_API_KEY=your_secret_knock_api_key

    Replace the values with your actual Knock API credentials. The value for NEXT_PUBLIC_KNOCK_USER_ID can be taken from an account found in data.tsx.

  3. Push and commit the Knock workflow:

    Use the Knock CLI to push and commit the inbox-demo workflow in the .knock/inbox-demo directory:

    knock workflow push .knock/inbox-demo --commit
    
  4. Install dependencies:

    npm install
    
  5. Run the development server:

    npm run dev
    
  6. Open http://localhost:3000 with your browser to see the result.

Seeding Knock data

To populate your Knock feed with sample data:

  1. Navigate to the home page of the application.
  2. Look for the "Seed Knock Data" button in the left-hand navigation.
  3. Click the button to trigger the seedKnockData server action.

The seedKnockData function iterates through each issue in the issues array stored in data.tsx and triggers the inbox-demo workflow for three event types: statusChange, assignment, and comment. Each trigger sends the issue data along with the event type to Knock. This server-side logic is powered by Knock's server-side Node SDK.

try {
  await knock.workflows.trigger("inbox-demo", {
    recipients: [
      {
        id: account.id,
        email: account.email,
        name: account.label,
      },
    ],
    actor: {
      id: account.id,
      email: account.email,
      name: account.label,
    },
    data: {
      id: "ENH-7890",
      event: "statusChange",
      title: "ENH-7890: Optimize database queries",
      description:
        "Improve performance by optimizing frequently used database queries",
      date: new Date(Date.now() - 86400000), // Yesterday
      status: "closed",
      previousStatus: "in progress",
      priority: "high",
      type: "enhancement",
      assignee: "David Lee",
      reporter: "Sarah Brown",
      labels: ["performance", "database", "optimization"],
      comments: [
        {
          text: "I've optimized the main query. Seeing a 50% performance improvement.",
          datetime: new Date(Date.now() - 432000000), // 5 days ago
          author: "David Lee",
        },
      ],
    },
  });
  console.log(`Triggered ${eventType} workflow for issue ${issue.id}`);
} catch (error) {
  console.error(
    `Error triggering ${eventType} workflow for issue ${issue.id}:`,
    error,
  );
}

Creating a custom inbox using the feed API

This project uses components and hooks from the @knocklabs/react package to create a custom inbox view. Here's how these are utilized:

  1. KnockProvider component:

    In inbox-provider.tsx, we wrap our application with the KnockProvider to initialize the Knock client:

    import { KnockProvider } from "@knocklabs/react";
    
    export function InboxProvider({ children }: InboxProviderProps) {
      return (
        <KnockProvider
          apiKey={process.env.NEXT_PUBLIC_KNOCK_API_KEY || ""}
          userId={process.env.NEXT_PUBLIC_KNOCK_USER_ID || ""}
        >
          {children}
        </KnockProvider>
      );
    }
    

    This provider sets up the Knock client with the necessary API key and user ID, making it available to all child components.

  2. useKnockClient hook:

    const knockClient = useKnockClient();
    

    The useKnockClient hook provides access to the Knock client instance for interacting with the Knock API. It's automatically exposed by the KnockProvider and can be used to interact with Knock's client-side methods.

  3. useNotifications hook:

    const feed = useNotifications(
      knockClient,
      process.env.NEXT_PUBLIC_KNOCK_FEED_CHANNEL_ID || "",
      {
        archived: "include",
      },
    );
    

    The useNotifications hook fetches and manages notifications for a specific feed channel. It returns a Feed object with methods to interact with the notifications.

  4. useNotificationStore hook:

    const { items, metadata } = useNotificationStore(feed);
    

    The useNotificationStore hook provides access to notification items and metadata, automatically updating when the feed changes.

  5. Fetch notifications on component mount:

    useEffect(() => {
      feed.fetch();
    }, [feed]);
    

    Lastly, we need to perform a fetch of the feed to load its initial state. Any realtime updates to the feed are handled automatically by the useNotificationStore hook.

Implementing inbox functionality

Generally, inboxes allow the user to create separate views for their inbox items and offer more advanced filtering and sorting options than feeds. Let's look at how we can implement these features using Knock's built-in messages statues and some custom filtering logic.

  • Separate feed items and archived items. This allows us to create separate views for the inbox and archived items:

    const [feedItems, archivedItems] = useMemo(() => {
      const feedItems = items?.filter((item) => !item.archived_at);
      const archivedItems = items?.filter((item) => item.archived_at);
      return [feedItems, archivedItems];
    }, [items]);
    
  • Apply additional filtering based on status and label. The labelFilter and statusFilter variables are used to store the current filter state:

    const filteredFeedItems = useMemo(() => {
      return feedItems?.filter((item) => {
        const issue = issues.find((i) => i.id === item.data?.id);
        return (
          (statusFilter === undefined ||
            statusFilter === "all" ||
            issue?.status === statusFilter) &&
          (labelFilter === undefined ||
            labelFilter === "all" ||
            issue?.labels.includes(labelFilter))
        );
      });
    }, [feedItems, issues, statusFilter, labelFilter]);
    
  • Render filtered items in the MessageList component:

    <MessageList items={filteredFeedItems} />
    
  • Display selected item details in the MessageDisplay component. The message.selected variable is used to store the currently selected message:

    <MessageDisplay
      item={items.find((item) => item.id === message.selected) || null}
      feed={feed}
      issues={issues}
    />
    

By leveraging these hooks, the component loads and displays notifications from Knock, handles real-time updates, and provides filtering and viewing capabilities for the inbox.

But inboxes often facilitate additional interactions with the messages, such as marking as read/unread, archiving/unarchiving, and even replying to comments. Let's look at how we can implement these features.

Managing message status with knockClient

In the MessageDisplay component, we utilize the knockClient through the feed prop to manage the status of messages. This includes marking messages as read/unread and archiving/unarchiving them. Here's how it's implemented:

  1. Receiving the feed prop:

    The MessageDisplay component receives the feed object as a prop from its parent component as well as the current selected item:

    export function MessageDisplay({
      item,
      feed,
      issues,
    }: {
      item: FeedItem | null;
      feed: Feed;
      issues: Issue[];
    }) {
      // ... component implementation ...
    }
    

    This feed object is an instance of the Knock Feed object, which provides methods for interacting with Knock's Feed API.

  2. Marking as Read/Unread:

    // ... existing code ...
    
    {!item?.read_at ? (
      <Button
        // ... other props ...
        onClick={() => {
          if (item) {
            feed.markAsRead(item);
          }
        }}
      >
        <BookCheck className="h-4 w-4" />
        <span className="sr-only">Mark as read</span>
      </Button>
    ) : (
      <Button
        // ... other props ...
        onClick={() => {
          if (item) {
            feed.markAsUnread(item);
          }
        }}
      >
        <BookX className="h-4 w-4" />
        <span className="sr-only">Mark as unread</span>
      </Button>
    )}
    
    // ... existing code ...
    

    We use the feed.markAsRead(item) and feed.markAsUnread(item) methods to toggle the read status of a message.

  3. Archiving/Unarchiving:

    // ... existing code ...
    
    {!item?.archived_at ? (
      <Button
        // ... other props ...
        onClick={() => {
          if (item) {
            feed.markAsRead(item);
            feed.markAsArchived(item);
          }
        }}
      >
        <Archive className="h-4 w-4" />
        <span className="sr-only">Archive</span>
      </Button>
    ) : (
      <Button
        // ... other props ...
        onClick={() => {
          if (item) {
            feed.markAsUnarchived(item);
          }
        }}
      >
        <Unarchive className="h-4 w-4" />
        <span className="sr-only">Unarchive</span>
      </Button>
    )}
    
    // ... existing code ...
    

    We use the feed.markAsArchived(item) and feed.markAsUnarchived(item) methods to toggle the archived status of a message. Since Knock's message engagement statuses are mutually inclusive, we mark the message as read when archiving.

Handling comment replies

The application provides a feature for users to reply to issues directly from the inbox. This functionality shows how you can integrate Knock notifications with your existing systems. Here's how it works:

  1. Comment form: In the MessageDisplay component, there's a form at the bottom for users to submit comments:

    <form onSubmit={handleSubmit}>
      <div className="grid gap-4">
        <Textarea
          className="p-4"
          placeholder={`Reply to issue ${item?.data?.id}...`}
          value={comment}
          onChange={(e) => setComment(e.target.value)}
        />
        <div className="flex items-center">
          <Label
            htmlFor="mute"
            className="flex items-center gap-2 text-xs font-normal"
          >
            <Switch id="mute" aria-label="Mute thread" /> Mute this thread
          </Label>
          <Button type="submit" size="sm" className="ml-auto">
            Send
          </Button>
        </div>
      </div>
    </form>
    
  2. Handling form submission: When a user submits a comment, the app displays a dialog to simulate sending the comment to your system:

    const handleSubmit = (e: React.FormEvent) => {
      e.preventDefault();
      setIsDialogOpen(true);
    };
    
    <Dialog open={isDialogOpen} onOpenChange={handleDialogClose}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>Comment Submitted</DialogTitle>
          <DialogDescription>
            Your comment has been received and needs to be sent to the system
            for Issue #{item?.data?.id}.
          </DialogDescription>
        </DialogHeader>
        <div className="mt-4 p-4 bg-muted rounded-md">
          <p className="text-sm">{comment}</p>
        </div>
        <DialogFooter>
          <Button onClick={handleDialogClose}>Close</Button>
        </DialogFooter>
      </DialogContent>
    </Dialog>
    
  3. Integration with your system and Knock: In a real-world scenario, submitting a comment would typically involve the following steps:

    • Send the comment to your backend system (e.g., your issue tracking system).
    • Your backend system processes the comment and updates the issue.
    • After successful processing, your backend triggers a Knock workflow.
    • Knock sends out notifications about the new comment to relevant users.

    It's important to note that Knock doesn't actually process or save comments. Instead, Knock's role is to send notifications about new comments to the appropriate users based on the workflows you define.

  4. Triggering Knock workflows: After your system processes the comment, you would trigger a Knock workflow similar to how it's done in the seedKnockData function:

    await knock.workflows.trigger("inbox-demo", {
      recipients: [
        // ... recipient details
      ],
      actor: {
        // ... actor details
      },
      data: {
        id: "ENH-7890",
        event: "comment",
        title: "ENH-7890: Optimize database queries",
        // ... other issue details
        comments: [
          {
            text: "New comment text",
            datetime: new Date(),
            author: "Current User",
          },
        ],
      },
    });
    

By integrating Knock in this way, you can ensure that all relevant parties are notified about new comments and issue updates in real-time, while maintaining the core data and business logic in your own systems.

Wrapping up

Awesome! Thanks so much for reading. In this post, we covered how to build a custom inbox experience using Knock's React hooks and feed API.

We looked at:

  • Using Knock's React hooks to build a custom inbox view
  • Creating different filtered views for inbox and archived items
  • 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.