Inbox

The inbox is a per-user, cross-channel feed: mentions, direct sends, and anything a notify bridge in portal.config.ts turns into a notification (see Authoring portal.config.ts). Get it with portal.inbox() — a lazy singleton, created and connected on first call:

import { Portal } from "@portalsdk/core";

const portal = new Portal({ apiKey: "pk_your_publishable_key" });
const inbox = portal.inbox();

Anonymous users get a permanently-empty, ready inbox — there's nothing to fetch for an identity that doesn't persist across sessions.

Two kinds of state: channels and items

InboxHandle tracks two related but distinct things:

  • channels — one entry per channel you have activity in, positional (a watermark + an unread count), recency-sorted.
  • items — individual targeted events (mentions, directed sends, notify descriptors), each with its own per-item read state.
import { Portal } from "@portalsdk/core";

const portal = new Portal({ apiKey: "pk_your_publishable_key" });
const inbox = portal.inbox();

inbox.channels; // InboxEntries — recency-sorted; .get(id) always hits the full registry
inbox.items; // readonly InboxItem[]
inbox.counter; // number — the global badge: Σ channel unreads + unseen items

counter excludes muted channels' ordinary unreads, but not items addressed to you — a mention in a muted room still contributes to the badge, because muting silences aggregation, not delivery.

Channel entries

import { Portal } from "@portalsdk/core";

const portal = new Portal({ apiKey: "pk_your_publishable_key" });
const inbox = portal.inbox();

const entry = inbox.channels.get("room-1");
entry?.unread; // latestSeq − your watermark for this channel
entry?.markAsRead(); // advances the INBOX position for this channel only
entry?.mute();
entry?.unmute();

entry.markAsRead() is independent of the channel's own watermark (room.markAsRead() from Channels) — the inbox tracks noticing, the channel tracks reading, and the two are allowed to disagree.

Items

import { Portal } from "@portalsdk/core";

const portal = new Portal({ apiKey: "pk_your_publishable_key" });
const inbox = portal.inbox();

for (const item of inbox.items) {
  console.log(item.id, item.type, item.title, item.data, item.read);
  item.markAsRead(); // flips THIS item only, never cascades to older items
}

inbox.markAllRead(); // global, zero-argument: marks every item read

Each item.id is the event's idempotency key — stable across redelivery, so it's the right thing to key a React list (or a dedup set) on. See In-app notifications for a worked example.

Filtered views

inbox.view(query) gives you a scoped, live lens without a second connection:

import { Portal } from "@portalsdk/core";

interface AssignmentPayload {
  ticketId: string;
}

const portal = new Portal({ apiKey: "pk_your_publishable_key" });
const inbox = portal.inbox();

const assignments = inbox.view<AssignmentPayload>({
  where: { type: { eq: "ticket.assigned" } },
});

assignments.items; // readonly InboxItem<AssignmentPayload>[]
assignments.unseen; // unseen count scoped to THIS view's filter (unlike inbox.counter)
assignments.subscribe(() => console.log(assignments.getSnapshot().items.length));

You can also scope a view to one channel:

import { Portal } from "@portalsdk/core";

const portal = new Portal({ apiKey: "pk_your_publishable_key" });
const inbox = portal.inbox();

const roomInbox = inbox.view({ channelId: "room-1" });

Reacting to new items

InboxHandle emits an "item" event whenever a new item arrives — useful for toasts and other one-shot reactions, as opposed to reading the accumulated items array:

import { Portal } from "@portalsdk/core";

const portal = new Portal({ apiKey: "pk_your_publishable_key" });
const inbox = portal.inbox();

const unsubscribe = inbox.on("item", (item) => {
  console.log("new inbox item:", item.type, item.data);
});

inbox.on("change", () => {
  // fires on any state change to channels/items/counter
});

unsubscribe();

If you're using React, useInbox's onItem param wraps this event directly — no need to reach for the core client yourself.

Store contract

Like channels, InboxHandle and InboxView are useSyncExternalStore-shaped:

import { Portal } from "@portalsdk/core";
import type { InboxSnapshot } from "@portalsdk/core";

const portal = new Portal({ apiKey: "pk_your_publishable_key" });
const inbox = portal.inbox();

function render(_snapshot: InboxSnapshot) {}

inbox.subscribe(() => render(inbox.getSnapshot()));
inbox.getSnapshot(); // { channels, items, counter, status }
inbox.status; // InboxStatus: "connecting" | "ready" | "reconnecting" (a live inbox is never "idle" — see below)

InboxStatus also includes "idle", for consumers that model a handle that hasn't been created at all — portal.inbox() itself is never "idle" (it's at least "connecting" from the moment it's created); @portalsdk/react's SSR-inert useInbox is the one place that value actually appears (see SSR & Next.js).

If you're using React, useInbox wraps the global handle and a filtered view together.