Viewer, consent and navigation hooks
The viewer, sign-in and consent prompts, maturity and gated images, following collections, and navigating the host.
This page documents 9 of the 47 hooks in the Hooks reference, which indexes every hook by name. Generated from the published @civitai/blocks-react@0.73.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(): UseViewerThe 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(): UseCollectionFollowFollow / 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
}
}
}useCivitaiNavigate
useCivitaiNavigate(): UseCivitaiNavigateRequest 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(): UseRequestSignInAsk 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(): UseRequestConsentThe 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(): UseConsentUnavailableSome 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(): UseDomainMaturityRead 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(): UseGatedImagesRead 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);
}