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.

OptionTypeDefaultNotes
projectsstring or string[]requiredAn empty array throws at render. The first entry is selected initially
endpointstring—HTTP mode
storeSitepingStore—Store mode
sourceInboxSource—Fully custom data source; see Data sources
apiKeystring—Endpoint mode only. Authorization: Bearer on every request
headersobject or function—Endpoint mode only. An explicit Authorization, in any casing, beats apiKey. Read live — changing it doesn't rebuild the source
pageSizenumber50Clamped 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
readOnlybooleanfalseNo 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:

viewMeaning
"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.

Edit on GitHub

On this page