Headless hook
useSitepingInbox — all the inbox logic, none of the UI. Build your own triage view.
Everything SitepingInbox does — fetching, filters, optimistic mutations, undo, pagination — lives in one hook you can drive from your own components:
import { useSitepingInbox } from "@siteping/dashboard";
const inbox = useSitepingInbox({
projects: "my-project",
endpoint: "/api/siteping",
});Options
The options are a union over the three source modes: supply exactly one of source, store or endpoint. Supplying none, supplying two, or pairing endpoint-only options (apiKey, headers) with store/source is a compile error — not a runtime throw and not a silently ignored option.
| Option | Type | Default | Notes |
|---|---|---|---|
projects | string or string[] | required | An empty array throws at render. The first entry is selected initially |
endpoint | string | — | HTTP mode |
store | SitepingStore | — | Store mode |
source | InboxSource | — | Fully custom data source; see Data sources |
apiKey | string | — | Endpoint mode only. Authorization: Bearer on every request |
headers | object or function | — | Endpoint mode only. An explicit Authorization, in any casing, beats apiKey. Read live — changing it doesn't rebuild the source |
pageSize | number | 50 | Clamped to 1–100 |
onStatusChange | (feedback, previousStatus) => void | — | After the server confirms |
onDelete | (feedback) => void | — | After the server confirms |
onError | (error) => void | — | Every failed load or mutation |
author | { name, email? } | — | Who posts replies from this inbox — see Discussion threads. Without it, threads are read-only |
readOnly | boolean | false | No status change and no deletion, of feedbacks or of replies — see Permissions |
Each mode is exported on its own — InboxEndpointOptions, InboxStoreOptions, InboxCustomSourceOptions, the shared InboxSharedOptions, and the UseSitepingInboxOptions union — so a wrapper component can accept just the mode it supports. Plain-JavaScript callers get the old behavior: no source at all still throws at render.
What you get back
Data — items (all loaded pages), total (null until the first page lands), counts (per-status totals, best-effort), loading, loadingMore, error, hasMore, loadMore(), refresh().
Filters — project/setProject, status/setStatus (starts on "open"), type/setType, search/setSearch. Search updates state instantly and debounces the refetch by 250 ms; queries are trimmed and capped at 200 characters.
Focus & drawer — focusedId, focus(id), focusNext(), focusPrev(), openedId, opened (the open record — it stays available even if a filter removes its row), openFeedback(id), closeFeedback().
Mutations — changeStatus(id, status), deleteFeedback(id), pendingUndo, undo().
Discussion thread — canComment, canDeleteComment, addComment(id, body, clientId?), deleteComment(id, commentId); see below.
Permissions — permissionsOf(record); see below.
view — skip the flag algebra
Deciding between a skeleton, an error state, an empty state and the list from loading / error / items.length is fiddly, and getting it subtly wrong is how a refetch ends up blanking the screen. view is that decision, already made — the exact value the shipped component renders from:
view | Meaning |
|---|---|
"loading" | The first page is loading and there is nothing displayable yet |
"error" | The load failed and there is nothing displayable |
"empty" | The load succeeded and no rows match the current filters |
"ready" | Rows are displayable |
if (inbox.view === "loading") return <Skeleton />;
if (inbox.view === "error") return <ErrorState error={inbox.error} onRetry={inbox.refresh} />;
if (inbox.view === "empty") return <EmptyState />;
return <List items={inbox.items} />;Rows win over every other signal: during a refetch the already-loaded rows stay on screen and view stays "ready", so the list never flashes back to a skeleton. Read loading when you want a granular spinner on top of visible rows.
Mutations are optimistic — and they re-throw
The UI updates immediately; if the server rejects, the hook rolls that feedback back (its row, the counts, focus, the open record) and then re-throws. The rollback touches only the rejected feedback: other changes in flight and data refetched in the meantime are kept. Always attach a catch:
<button
onClick={() => {
inbox.changeStatus(item.id, "resolved").catch(() => {
// state is already rolled back — surface a toast here
});
}}
>
Resolve
</button>Only the last status change is undoable (pendingUndo + undo()); delete is permanent, so gate it behind your own confirmation UI.
Discussion threads
Each record carries its thread in comments (oldest first). canComment says whether this inbox may post replies: an author is set, the source implements addComment, and the last list did not say the endpoint keeps no comments. canDeleteComment says whether it may also delete them: the inbox is not readOnly, the source implements removeComment too, and the last list did not say the endpoint cannot delete them.
Replies are not optimistic: addComment resolves once the source has stored the reply, which then shows on the record wherever the hook holds it — its row, opened, and the record a pending status change would roll back to. Both actions re-throw after onError, like the mutations above — except a deleteComment whose reply is gone already (a 404, or the store's StoreNotFoundError): the reply leaves the record and the call resolves. A list or a status change read before a reply was stored or deleted keeps the thread the hook holds.
const [draft, setDraft] = useState("");
// One clientId per reply, kept across its resends: the server answers a
// resend of a reply that did land with the stored one. An edited draft is
// another reply, or that first text would come back in its place.
const sent = useRef<{ body: string; clientId: string } | null>(null);
async function send(feedbackId: string) {
if (sent.current?.body !== draft) {
// crypto.randomUUID() exists only in secure contexts (HTTPS, localhost).
const clientId = crypto.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`;
sent.current = { body: draft, clientId };
}
try {
await inbox.addComment(feedbackId, draft, sent.current.clientId);
setDraft("");
sent.current = null;
} catch {
// the draft stays — show an inline error
}
}The reply goes out as author with the team role, which the server keeps only for callers its access policy vouches for — see who speaks as the team.
Permissions
permissionsOf(record) says what the user may do with a record here — { canChangeStatus, canDelete, canComment, canDeleteComment }: what readOnly, canComment and canDeleteComment allow, minus what the record's permissions refuse. The endpoint source passes on the server's (see what each requester may do); a record without them refuses nothing. Show your controls from it:
const { canChangeStatus, canDelete } = inbox.permissionsOf(item);changeStatus, undo, deleteFeedback, addComment and deleteComment do nothing on a record that refuses them, so a stray call never flashes an optimistic change the server would roll back, nor sends a request it would refuse. The inbox only hides: the server decides what it accepts.
Pagination
loadMore() is manual — the bundled component renders a "Load more" button, not infinite scroll. Pages are deduplicated by id, and the next page number is derived from items.length, so optimistic removals never skip rows.