Viewer, consent and navigation hooks
The viewer, sign-in and consent prompts, maturity and gated images, following collections, reacting to images, and navigating the host.
This page documents 10 of the 49 hooks in the Hooks reference, which indexes every hook by name. Generated from the published @civitai/blocks-react@0.79.0 package's type definitions and README.
Other pages: Context, frame and analytics hooks · Generation hooks · Batch, confirmed-step and training hooks · Buzz, purchase and tip hooks · Storage, picker and media hooks
useViewer
useViewer(): UseViewerinterface UseViewer {
viewer: BlockViewer | null;
loading: boolean;
error: Error | null;
refetch: () => void;
}The signed-in viewer as an on-demand authoritative self-read (`{ id, username, status, buzzBudget }`) — distinct from `useBlockContext().viewer`, the coarse `BLOCK_INIT`-time snapshot. `status` is `'active' | 'muted'`; `username` (`string | null`) and `buzzBudget` (`number | null`) are present-but-nullable, so handle the null case. Host-mediated over `GET_VIEWER` → `VIEWER_RESULT` (the host resolves the viewer from the block token via `blocks.getMyViewer`); an anonymous / banned viewer comes back as `error`. Fetches on mount; `refetch` for on-demand refreshes.
const { viewer, loading, error, refetch } = useViewer();
// `viewer` is null until the first successful fetch. An anon / banned viewer,
// missing scope, or host failure → `error`. `username`/`buzzBudget` may be null.
if (!loading && viewer) console.log(`${viewer.username ?? 'anon'} · budget ${viewer.buzzBudget ?? 0}`);useCollectionFollow
useCollectionFollow(): UseCollectionFollowinterface UseCollectionFollow {
setFollow: (args: { collectionId: number; follow: boolean; }) => Promise<BlockCollectionFollowResult>;
pending: boolean;
error: CollectionFollowError | null;
}Follow / unfollow a collection **for the viewer**, host-mediated over
`SET_COLLECTION_FOLLOW`. Returns `{ setFollow, pending, error }`.
**No block scope, and no token on the wire.** The host calls the session-authed
`collection.follow` / `collection.unfollow` procedures, which self-bind to the
viewer server-side — `collectionId` is the only thing a block influences.
🔴 **Every call opens a host-chrome consent confirm naming the collection**, and
that click is the *only* consent this path has ever had: the HTTP predecessor's
`collections:write:self` scope is consent-exempt server-side and prompted nobody.
Moving to this bridge **tightens** the flow; what it gives up is the manifest
`scopes` declaration a moderator reads before install. The host resolves the
collection's name itself (there is no `name` field on the wire, deliberately) and
bounds that to **20 distinct ids per block instance** — past the cap it refuses
with `collection-unavailable`, the same code a collection the viewer cannot see
gets.
`setFollow` **rejects** with a `CollectionFollowError` on every non-success. Two
of those are not failures to render:
| | meaning | what to do |
|---|---|---|
| `err.declined` | the viewer dismissed the confirm — **no write occurred** | revert, say nothing |
| `err.signInRequired` | no session | route into `useRequestSignIn()` |
| `err.timedOut` | no reply arrived within the 10-min consent bound | 🔴 **check this BEFORE `.message`** — it also has no `.code`, and its message is an SDK-internal string. It does **not** mean no write occurred; re-read your state |
| `err.code` set otherwise | a host refusal (`invalid-request` / `review-mode` / `not-ready` / `collection-unavailable`) | show or ignore per case |
| `err.code === undefined` **and** `!err.timedOut` | a **server** message the host forwarded verbatim | show `err.message` |const { setFollow, pending } = useCollectionFollow();
const { requestSignIn } = useRequestSignIn();
async function toggle() {
try {
const result = await setFollow({ collectionId, follow: !followed });
setFollowed(result.followed); // adopt the host's echo, not the guess
} catch (err) {
if (err instanceof CollectionFollowError) {
if (err.signInRequired) return requestSignIn();
if (err.declined) return; // the viewer said no — say nothing
if (err.timedOut) return showToast('Still working — check back in a moment.');
showToast(err.message); // a real server message, safe to render
}
}
}useReactions
useReactions(entityType: BlockReactionEntityType, ids: readonly number[], options: UseReactionsOptions): UseReactionsinterface UseReactions {
byId: Readonly<Record<number, BlockEntityReactionState>>;
unavailable: readonly number[];
loading: boolean;
error: ReactionError | null;
toggle: (id: number, reaction: BlockAppReaction, reacted: boolean) => Promise<BlockEntityReactionResult>;
pending: (id: number, reaction: BlockAppReaction) => boolean;
}Read and set the **viewer's** reactions on images this app was served, over
`GET_ENTITY_REACTIONS` / `SET_ENTITY_REACTION`. Returns
`{ byId, unavailable, loading, error, toggle, pending }`.
It reads on mount, then reads again only the images that need it: an id added
to `ids`, or one whose ticket changed. An id that leaves `ids` costs nothing.
Every read counts against the block instance's catalog rate limit on the host,
which every viewer of a page app shares with its other catalog reads.
A reaction made here **is** a reaction on civitai.com: it pays the reactor and
the image owner exactly as the site's own button does, moves rankings, notifies,
and lists the viewer among the reactors. It draws on the **same** per-viewer
quota as the site. Apps may set `Like`, `Heart`, `Laugh` and `Cry`; never
`Dislike`. Image is the only entity type today.
🔴 **Scope `social:react:self`: consent-gated and sensitive.** Declare it with a
`scopeJustifications` entry. Until the viewer grants it, every call fails with
`needs-consent` (`err.needsConsent`): ask with `useRequestConsent()`. There is
no per-tap confirm.
🔴 **Tickets are required, and only the REST block endpoints supply them.** Each
image that `/api/v1/blocks/images`, `/api/v1/blocks/gated-images` or
`/api/v1/blocks/collections/{id}` serves, fetched with the block token for a
signed-in viewer, carries a `reactTicket` signed for this app and this viewer
for 24 hours. **No hook in this package supplies one yet:** `useGatedImages()`
goes through the host bridge, whose reply carries no `reactTicket`
([#687](https://github.com/civitai/civitai-app-starters/issues/687)). Pass the
tickets as `tickets`, keyed by image id. An image without a valid one is not
answered by the read (it lands in `unavailable`, which is **not** "not reacted")
and a `toggle` on it is refused locally, before anything is sent.
`toggle(id, reaction, reacted)` sets a **desired state**, moves `byId[id]`
optimistically, and puts it back on failure. It rejects with a `ReactionError`:
| | meaning | what to do |
|---|---|---|
| `err.signInRequired` | no session | route into `useRequestSignIn()` |
| `err.needsConsent` | the scope is not granted | `useRequestConsent()` |
| `err.code === 'rate-limited'` | the viewer's reaction quota is spent | back off |
| `err.code === 'entity-unavailable'` | no valid ticket, an image the viewer may not react to, the kill switch, or a network failure | retry later or give up |
| `err.code` otherwise | `invalid-request` / `review-mode` / `not-ready` | a block bug, a review sandbox, or retry after `ready` |
| `err.timedOut` | no reply within 30s | the write **may** have landed; the hook re-reads that image |
| `err.busy` | the same image and reaction already has a request in flight. Rejects the call only: `error` is not set, because the write it collided with is still running | disable the control while `pending(id, reaction)` |
🔴 **Bind `<civitai-reaction>` from `byId` on every render; never let it keep
its own state.** The element flips `reacted` and `count` itself on a click. When
the hook makes no optimistic move of its own (an image not read yet, or a
refusal such as a missing ticket), `byId` does not change, so an element that
only listens would keep showing a reaction that does not exist. Every rejected
`toggle` re-renders the owner: a refusal or a failed write sets `error`, and a
`busy` refusal is followed by the in-flight write's own settle, which clears
`pending`. An element whose `reacted` and `count` come from `byId` on each
render is then put back. Outside React, set the two properties back yourself
after a rejection.import { useEffect, useState } from 'react';
import { isAppReaction } from '@civitai/app-sdk/blocks';
import { useBlockToken, useHostOrigin, useReactions } from '@civitai/blocks-react';
type ServedImage = { id: number; url: string; reactTicket?: string };
function useReactableImages() {
const host = useHostOrigin();
const { raw } = useBlockToken();
const [images, setImages] = useState<ServedImage[]>([]);
// The tickets come from the REST endpoint, fetched with the block token.
useEffect(() => {
if (!host || !raw) return;
void fetch(`${host}/api/v1/blocks/images?limit=20`, {
headers: { Authorization: `Bearer ${raw}` },
})
.then((res) => res.json() as Promise<{ items: ServedImage[] }>)
.then((body) => setImages(body.items));
}, [host, raw]);
const reactions = useReactions(
'image',
images.map((image) => image.id),
{ tickets: Object.fromEntries(images.map((image) => [image.id, image.reactTicket])) },
);
// Props for one `<civitai-reaction>` (e.g. `CivitaiReaction` from
// `@civitai/components-react`), derived from `byId` on every render.
const bind = (id: number, reaction: 'Like' | 'Heart' | 'Laugh' | 'Cry') => ({
reaction,
reacted: reactions.byId[id]?.viewerReactions.includes(reaction) ?? false,
count: reactions.byId[id]?.counts[reaction] ?? 0,
disabled: !reactions.byId[id] || reactions.pending(id, reaction),
onReact: (event: CustomEvent<{ reaction?: string; reacted: boolean }>) => {
const { reaction: which, reacted } = event.detail;
// `detail.reaction` is any string the element was given; narrow it.
if (!isAppReaction(which)) return;
// A rejection needs no handling here to put the element back: it
// re-renders, and `bind` reads `byId` again.
reactions.toggle(id, which, reacted).catch(() => {});
},
});
return { images, bind, error: reactions.error };
}useCivitaiNavigate
useCivitaiNavigate(): UseCivitaiNavigateinterface UseCivitaiNavigate {
navigate: (path: string, options?: 'current' | 'new_tab' | UseCivitaiNavigateOptions) => void;
}Request a navigation from the host. The hook sends a `NAVIGATE` message and
returns — fire-and-forget, so the block never learns what the host did,
including when the host **refuses** the request.
`scope` selects the **space** `path` is resolved in, and it **defaults to
`'app'`**:
| `scope` | `path` resolves | The viewer |
|---|---|---|
| `'app'` *(default)* | under **this app's own route**, as a sub-path of it | stays in your app; the page stays mounted |
| `'site'` | at the **civitai.com root** | leaves your app for a site page |
> 🔴 **A leading slash carries no meaning.** The host normalises it away in
> **both** scopes, so `'/settings'` and `'settings'` are one request within
> whichever scope you chose. That means `navigate('/models/12345')` asks for
> **your app's** `/models/12345` — *not* civitai's model page. To reach the
> civitai.com page, say so: `navigate('models/12345', { scope: 'site' })`.
>
> Both spellings were app-scoped before `scope` existed, so no call you have
> already written changed meaning — that is the point of the default.
`'site'` is granted **per-surface**: the public run page and the dev tunnel allow
it, and a private run or a moderator's review preview refuse it. A refusal is
silent, so do not build a flow that needs to know it happened.
`target` is a REQUEST, not a guarantee. How the host acts on `'current'` vs
`'new_tab'` is host-side behaviour and the host is the authority on it; this
package sends the message and makes no promise about the outcome.
> 🔴 **Nothing in your manifest enables `'new_tab'`.** In particular, do **not**
> declare `allow-popups-to-escape-sandbox`: the host intersects a manifest's
> `iframe.sandbox` with a fixed allowlist that does not contain that token, so it
> is dropped for every block at every trust tier and declaring it has no effect.
> Earlier versions of this page said `'new_tab'` required it — that was wrong.const { navigate } = useCivitaiNavigate();
// Your app's own pages — the default.
navigate('settings'); // this app's /settings
navigate('/settings'); // identical; the slash means nothing
// A civitai.com page — needs an explicit scope.
navigate('models/12345', { scope: 'site' });
navigate('models/12345', { scope: 'site', target: 'new_tab' });
// The pre-`scope` two-argument shape still works, and is still app-scoped.
navigate('detail/7', 'new_tab');useCivitaiRoute
useCivitaiRoute(): UseCivitaiRouteThe sub-path below your app's root that is **currently showing**. This is the other half of an app-scoped [`useCivitaiNavigate()`](#usecivitainavigate): you ask the host to move, the host pushes it shallowly so your frame stays mounted, and this is how you find out where you went.
function Router() {
const subPath = useCivitaiRoute(); // '' on your app's index
const [view, id] = subPath.split('/');
return view === 'compare' ? <Compare id={id} /> : <Index />;
}useRequestSignIn
useRequestSignIn(): UseRequestSignIninterface UseRequestSignIn {
requestSignIn: (payload?: { returnUrl?: string; }) => void;
}Ask the host to open its sign-in flow for an ANONYMOUS viewer (fire-and-forget). On sign-in the host re-inits the block with the now-authenticated viewer.
const { requestSignIn } = useRequestSignIn();
// e.g. onClick of a "Sign in to generate" button:
requestSignIn();useRequestConsent
useRequestConsent(): UseRequestConsentinterface UseRequestConsent {
requestConsent: (payload?: { scopes?: string[]; from?: string; }) => void;
}The manual version of the above — still exported, still the right tool when you want to prompt *before* a call (e.g. on an onboarding screen) rather than after one fails. Lazy consent: ask the host to open its consent UI when a LOGGED-IN viewer takes an action whose consent-gated scope the block token is missing (e.g. Generate needs `ai:write:budgeted` but the viewer hasn't granted it). Fire-and-forget — on grant the host pushes a new token; observe `useBlockToken().scopes` and retry.
import { useRequestConsent } from '@civitai/blocks-react';
const { requestConsent } = useRequestConsent();
requestConsent({ scopes: ['ai:write:budgeted', 'buzz:read:self'] });useConsentUnavailable
useConsentUnavailable(): UseConsentUnavailableinterface UseConsentUnavailable {
refusal: ConsentUnavailablePayload | null;
reset: () => void;
}Some environments withhold a scope at mint (a dev-tunnel preview token, a surface that carries no money scope), so no consent round-trip can ever add it. The host then pushes an uncorrelated `CONSENT_UNAVAILABLE` — *not* a reply, because `REQUEST_CONSENT` carries no `requestId`. Consume it and stop telling the user to retry something that can't succeed:
import { useConsentUnavailable, useRequestConsent } from '@civitai/blocks-react';
function ConsentAwareGenerate() {
const { requestConsent } = useRequestConsent();
const { refusal, reset } = useConsentUnavailable();
// 🔴 Branch on `refusal !== null`, NEVER on `refusal.scopes.length`. The host
// refuses on its own unfiltered set but names only scopes in the public
// vocabulary, so `scopes: []` is a legitimate refusal — gating on the length
// silently drops the very message you subscribed for. Use the names for copy.
if (refusal) {
return (
<div>
<p>Generating isn't available on this page.</p>
<button onClick={reset}>Try again</button>
</div>
);
}
// 🔴 `scopes` is REQUIRED for a refusal to ever arrive — see above.
return <button onClick={() => requestConsent({ scopes: ['ai:write:budgeted'] })}>Generate</button>;
}import { getTransport } from '@civitai/blocks-react';
import type { ConsentUnavailablePayload } from '@civitai/app-sdk/blocks';
const unsubscribe = getTransport().onMessage('CONSENT_UNAVAILABLE', (payload) => {
// `onMessage` hands you `unknown`; this cast is UNCHECKED, which is why
// `useConsentUnavailable()` is the preferred path.
const { reason, scopes } = payload as ConsentUnavailablePayload;
console.info('permission unavailable', reason, scopes);
});useDomainMaturity
useDomainMaturity(): UseDomainMaturityinterface DomainMaturity {
domain?: ColorDomain | null;
maxBrowsingLevel?: number;
effectiveBrowsingLevel?: number;
isSfw: boolean;
isLevelAllowed: (level: number) => boolean;
}Read the maturity ceiling in force for the current viewer, so a block can hide/blur mature affordances. **Fail-closed SFW** until `BLOCK_INIT` lands or against a host that projects no ceiling.
const { isSfw, isLevelAllowed } = useDomainMaturity();
const showRSlider = isLevelAllowed(BrowsingLevel.R); // false on a SFW domainconst { maxBrowsingLevel, effectiveBrowsingLevel } = useDomainMaturity();
const hiddenByYourSettings = effectiveBrowsingLevel !== maxBrowsingLevel;useGatedImages
useGatedImages(): UseGatedImagesinterface UseGatedImages {
getImages: (imageIds: number[]) => Promise<BlockGatedImage[]>;
}Read per-viewer gated display data for a list of image ids via the host-mediated `GET_IMAGES_BY_IDS` → `IMAGES_RESULT` bridge — the read side of a cross-user image grid (e.g. ids stored via `useSharedStorage()`). The host applies the requesting viewer's browsing-level clamp server-side and returns each image as `visible` (url, plus a rating UNLESS it is the viewer's own not-yet-rated image) or `hidden` (NO url — above ceiling / flagged / scan-refused / someone else's unrated image). This is the load-bearing cross-user moderation boundary: an unclamped edge URL never crosses to a viewer who can't see the image, and the block must render a placeholder for any `hidden` entry. 🔴 A `hidden` ENTRY IS NARROWED TO `{ imageId, status }` BEFORE IT REACHES HERE, and that is enforced in code rather than asserted in prose: the transport runs `projectInboundPayload` on every `IMAGES_RESULT` before delivery (`src/transport/validate.ts`), so a `previewUrl`, `src`, `imageUrl` or any other key a host attaches to a withheld image is DROPPED, not forwarded. Fields are dropped rather than the reply rejected so a future host-side field addition cannot hang this call — see `projectGatedImage`'s docblock. 🔴 `nsfwLevel` AND `contentRating` ARE OPTIONAL, AND A MISSING ONE IS NOT "G". They are absent exactly when `ratingPending` is present. Treating absent as a safe default is the bug this state exists to stop: an image published seconds earlier came back `hidden` under the old two-state contract and a grid rendered it as *"Hidden — rated mature"*, a maturity claim about an image nothing had rated, which a page reload then contradicted.
const { getImages } = useGatedImages();
const images = await getImages([101, 102, 103]);
for (const image of images) {
if (image.status === 'hidden') renderPlaceholder(image.imageId);
else if (image.ratingPending) renderStillProcessing(image.url); // NO rating to show
else renderRated(image.url, image.contentRating);
}