Message inbox
A persistent, offline-first notification centre — a headless controller you can render yourself, or a built-in bell and panel that drops into an existing navbar.
A message inbox outlives the moment a notification arrives. Push and in-app messages are interruptions; the inbox is where they persist, so someone who dismissed a message on Monday can still find it on Thursday.
It is off by default. Enable it at init:
await ScaleBun.init({ clientKey, apiBaseUrl, features: { inbox: true } });With the feature off, ScaleBun.inbox() returns a safe no-op controller — every
method does nothing rather than throwing. That is deliberate, but it means a
forgotten flag looks exactly like an empty inbox.
Headless or rendered#
The controller is headless first. The built-in UI is opt-in, so a host that renders its own list never pays for the widget.
const inbox = ScaleBun.inbox();
// Render your ownconst unsubscribe = inbox.subscribe((state) => { setUnread(state.unreadCount); setSyncing(state.syncStatus === 'syncing');});
const page = await inbox.query({ limit: 20 });await inbox.markRead(page.messages[0].id);Or mount the built-in panel:
inbox.mount({ trigger: document.querySelector('#bell')!, // your own bell, or omit for the floating one layout: 'dropdown', // 'drawer' (default) | 'dropdown' accent: '#6c5ce7', onViewAll: () => router.push('/notifications'),});| Option | Notes |
|---|---|
trigger | CSS selector or element to use as the open/close toggle. |
layout | drawer is a full-height slide-over; dropdown is a compact popover. |
position | Corner for the built-in bell. Default bottom-right. |
theme | Force light/dark. Default follows prefers-color-scheme. |
accent | Brand colour for bell, badge, unread dot and CTAs. |
onViewAll | Renders a footer button and calls this. The SDK never navigates — your routes are yours. |
The controller#
| Group | Methods |
|---|---|
| State | subscribe · onUnreadChange · getState |
| Read | query · get |
| Mutate | markRead · markUnread · markAllRead · archive · unarchive · pin · remove |
| Actions | act · onCtaAction |
| Sync | refresh · ingest |
| UI | mount · open · close · unmount |
subscribe and onUnreadChange both fire immediately with the current value,
then on every change — so a badge does not need a separate first read.
act(id, ctaKey) records the click and hands it to your onCtaAction handler.
As with onViewAll, the SDK does not navigate for you.
Offline-first#
Messages are stored locally and synced by delta, so the list renders instantly
on a cold load and survives a lost connection. syncStatus on the state object
tells you whether a refresh is in flight, which is what a spinner should key off
rather than the presence of messages.
Use ingest() to push messages you already hold — a server-rendered payload, or
a message delivered by your own channel — into the same store.
Theming#
The panel reads --scalebun-inbox-* CSS custom properties, and accent on
mount() overrides the accent token directly.
All user-facing strings are overridable, and every one is rendered with
textContent, never innerHTML — these can come from a dashboard field, and
interpolating them as markup would put an injection point into every host page.
Count-bearing labels are functions (unread: (n) => …) so your own
Intl.PluralRules decides plurals rather than receiving "1 unread".
Events#
Eleven reserved events are emitted, all beginning $message_ plus
$inbox_unread_changed. See
Reserved events before you name your own.
Next#
Engagement — the campaigns that fill the inbox.
In-app messaging — surfaces that interrupt instead.