Guide: In-app notifications

A ticket-assignment notification that shows up in an inbox list and pops a toast the moment it arrives — deduplicated by the item's idempotency key, since deliveries can repeat.

Server-side: turn an event into a notification

Notifications are created config-side, with a notify bridge on the channel your assignment messages go through (see Authoring portal.config.ts → Notifications):

// file: portal.config.ts
import { defineConfig } from "@portalsdk/config";

interface AssignmentPayload {
  ticketId: string;
  title: string;
}

export default defineConfig({
  channels: {
    "tickets-*": {
      notify: (ctx) => {
        const assignedTo = ctx.message.to;
        if (!assignedTo || ctx.message.type !== "ticket.assigned") return null;

        const payload = ctx.message.content as AssignmentPayload;
        return {
          title: `You were assigned ${payload.title}`,
          data: payload,
          to: assignedTo,
        };
      },
    },
  },
});

Your app sends the assignment as an ordinary directed message (to, plus a type to distinguish it from chat content); the config above turns matching sends into inbox items for the assignee.

import type { ChannelHandle } from "@portalsdk/core";

interface AssignmentMessage {
  ticketId: string;
  title: string;
}

async function assignTicket(
  channel: ChannelHandle<AssignmentMessage>,
  userId: string,
  ticketId: string,
  title: string,
) {
  await channel.send({
    type: "ticket.assigned",
    to: userId,
    content: { ticketId, title },
  });
}

Client-side: list, and a toast on arrival

useInbox's onItem fires once per item arriving after mount — never for the backlog already present when the inbox becomes ready, and never twice for the same item, since InboxItem.id is the event's idempotency key and redelivery is deduped by that id. That makes it safe to append straight onto a toast list with no dedup bookkeeping of your own:

// file: use-assignment-toasts.ts
import { useState } from "react";
import { useInbox } from "@portalsdk/react";

interface AssignmentPayload {
  ticketId: string;
  title: string;
}

interface Toast {
  id: string;
  message: string;
}

export function useAssignmentToasts() {
  const [toasts, setToasts] = useState<Toast[]>([]);

  useInbox<AssignmentPayload>({
    onItem: (item) => {
      if (item.type !== "ticket.assigned") return;
      setToasts((current) => [
        ...current,
        { id: item.id, message: item.title ?? `Assigned: ${item.data.title}` },
      ]);
    },
  });

  function dismiss(id: string) {
    setToasts((current) => current.filter((t) => t.id !== id));
  }

  return { toasts, dismiss };
}
// file: notifications-toaster.tsx
import { useAssignmentToasts } from "./use-assignment-toasts";

export function NotificationsToaster() {
  const { toasts, dismiss } = useAssignmentToasts();

  return (
    <div>
      {toasts.map((t) => (
        <div key={t.id} onClick={() => dismiss(t.id)}>
          {t.message}
        </div>
      ))}
    </div>
  );
}

useAssignmentToasts no longer needs the Portal client passed in at all — useInbox reads it from PortalProvider's context on its own, same as any other call to the hook.

The persistent list

For the inbox panel itself (not the toast), useInbox's accumulated items is the right tool — no manual dedup needed there, since it's a snapshot of current state, not a stream of arrival events:

import { useInbox } from "@portalsdk/react";

interface AssignmentPayload {
  ticketId: string;
  title: string;
}

export function AssignmentInbox() {
  const { items, unseen, markAllRead } = useInbox<AssignmentPayload>({
    where: { type: { eq: "ticket.assigned" } },
  });

  return (
    <div>
      <button onClick={() => markAllRead()}>Clear ({unseen})</button>
      <ul>
        {items.map((item) => (
          <li key={item.id} onClick={() => item.markAsRead()}>
            {item.title} — {item.data.ticketId} {item.read ? "" : "•"}
          </li>
        ))}
      </ul>
    </div>
  );
}

markAllRead() here is the global, zero-argument call — it clears every inbox item, not just the ones in this filtered view. Mark a single item read from the list itself with item.markAsRead(), which never cascades to older items.