@civitai/blocks-react 0.59.0 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +296 -6
  2. package/dist/hooks/consentRetryOptions.d.ts +40 -0
  3. package/dist/hooks/consentRetryOptions.js +2 -0
  4. package/dist/hooks/useBuzzWorkflow.d.ts +23 -1
  5. package/dist/hooks/useBuzzWorkflow.js +125 -44
  6. package/dist/hooks/useCheckpointPicker.d.ts +43 -10
  7. package/dist/hooks/useCheckpointPicker.js +37 -6
  8. package/dist/hooks/useCivitaiNavigate.d.ts +57 -6
  9. package/dist/hooks/useCivitaiNavigate.js +50 -7
  10. package/dist/hooks/useCivitaiRoute.d.ts +63 -0
  11. package/dist/hooks/useCivitaiRoute.js +71 -0
  12. package/dist/hooks/useCreatePostFromApp.d.ts +2 -1
  13. package/dist/hooks/useCreatePostFromApp.js +87 -34
  14. package/dist/hooks/useGoodPurchase.d.ts +2 -1
  15. package/dist/hooks/useGoodPurchase.js +64 -19
  16. package/dist/hooks/useRequestConsent.js +10 -12
  17. package/dist/hooks/useResourcePicker.d.ts +56 -7
  18. package/dist/hooks/useResourcePicker.js +27 -3
  19. package/dist/hooks/useTip.d.ts +2 -1
  20. package/dist/hooks/useTip.js +99 -20
  21. package/dist/index.d.ts +4 -1
  22. package/dist/index.js +3 -0
  23. package/dist/internal/liveHost.js +238 -7
  24. package/dist/internal/mockHost.js +67 -9
  25. package/dist/internal/popoverShim.d.ts +94 -0
  26. package/dist/internal/popoverShim.js +181 -0
  27. package/dist/internal/withConsentRetry.d.ts +239 -0
  28. package/dist/internal/withConsentRetry.js +457 -0
  29. package/dist/testing.d.ts +1 -0
  30. package/dist/testing.js +1 -0
  31. package/dist/transport/iframeTransport.d.ts +33 -0
  32. package/dist/transport/iframeTransport.js +56 -0
  33. package/dist/transport/validate.d.ts +25 -0
  34. package/dist/transport/validate.js +31 -0
  35. package/package.json +4 -4
@@ -1,6 +1,21 @@
1
- import { useCallback, useState } from 'react';
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { BLOCK_SCOPES } from '@civitai/app-sdk/blocks';
3
+ import { withConsentRetry } from '../internal/withConsentRetry.js';
2
4
  import { getTransport } from '../transport/singleton.js';
3
5
  import { generateIdempotencyKey, sendTypedRequest } from '../transport/transport.js';
6
+ /**
7
+ * The consent-gated scope {@link UseBuzzWorkflow.submit} requires.
8
+ *
9
+ * 🔴 `submit()` ONLY. `estimate()` needs the same scope but is deliberately not
10
+ * routed through the automatic consent retry — see the comment on `estimate`
11
+ * below; it is an on-mount read with no gesture behind it.
12
+ *
13
+ * Named from {@link BLOCK_SCOPES}, never a string literal: this array is sent to
14
+ * the host as the `REQUEST_CONSENT` hint, and the host ignores a hint with no
15
+ * RECOGNISED non-empty name — so a typo here would not error, it would make the
16
+ * automatic prompt silently do nothing.
17
+ */
18
+ const WORKFLOW_SCOPES = [BLOCK_SCOPES.AI_WRITE_BUDGETED];
4
19
  /**
5
20
  * Snapshot statuses that mean "no further polling is needed."
6
21
  * Used by both `submit` (a host can return an instant-fail / cached result)
@@ -556,6 +571,50 @@ export function useBuzzWorkflow() {
556
571
  const [status, setStatus] = useState('idle');
557
572
  const [result, setResult] = useState(null);
558
573
  const [error, setError] = useState(null);
574
+ /**
575
+ * Whether this hook's component is still mounted.
576
+ *
577
+ * 🔴 ITS ONLY JOB IS THE CONSENT RETRY, and that is a MONEY gate rather than a
578
+ * setState-after-unmount tidy-up. `submit()` can sit in `withConsentRetry`'s
579
+ * 60s grant wait long after the component is gone, and nothing else can see
580
+ * that: this hook's calls go through the postMessage bridge, so there is no
581
+ * `AbortController` to fire and rule 3's `AbortError` path never triggers. A
582
+ * grant arriving after unmount would then RESERVE BUZZ for a generation nobody
583
+ * is left to watch. Read immediately before the retry, never cached.
584
+ */
585
+ const mountedRef = useRef(true);
586
+ useEffect(() => {
587
+ mountedRef.current = true;
588
+ return () => {
589
+ mountedRef.current = false;
590
+ };
591
+ }, []);
592
+ /**
593
+ * 🔴 `estimate()` IS DELIBERATELY NOT ROUTED THROUGH `withConsentRetry`, and
594
+ * that exclusion is load-bearing rather than an oversight.
595
+ *
596
+ * The automatic prompt is for calls a PERSON just made. `estimate()` is not
597
+ * one: `starters/examples/buzz-workflow/src/App.tsx` calls it from a
598
+ * `useEffect` keyed on the form inputs, so it fires on mount and again on
599
+ * every parameter edit. Routing it would open a consent dialog with no gesture
600
+ * behind it and hold each call pending for the full 60s grant wait. That is the
601
+ * same reason the Buzz READS are excluded; `estimate()` just happens to live on
602
+ * a hook whose OTHER call moves money.
603
+ *
604
+ * ⚠️ The in-flight DE-DUPLICATION `withConsentRetry` gained in #500 round 2
605
+ * does not change this verdict, and reading it as a reason to route
606
+ * `estimate()` would be a mistake. It collapses CONCURRENT waits on the same
607
+ * scope set into one dialog; a form edited over several seconds produces
608
+ * SEQUENTIAL calls, each after the previous wait settled, so the N-dialogs
609
+ * problem survives for exactly this shape. The no-gesture objection is
610
+ * independent of it either way.
611
+ *
612
+ * A failed estimate keeps the behaviour that starter's own `catch` is written
613
+ * against: reject with the server's reason, and show no price, because a
614
+ * missing quote is not viewer-actionable copy. `submit()` IS routed, and that
615
+ * is where both the gesture and the charge are. Pinned by
616
+ * `test/withConsentRetry.test.tsx`.
617
+ */
559
618
  const estimate = useCallback(async (body) => {
560
619
  setError(null);
561
620
  setStatus('estimating');
@@ -606,62 +665,84 @@ export function useBuzzWorkflow() {
606
665
  throw err;
607
666
  }
608
667
  }, []);
668
+ /**
669
+ * ONE submit round-trip AND its result contract.
670
+ *
671
+ * 🔴 `idempotencyKey` IS A PARAMETER, NOT MINTED HERE, AND THAT IS THE MONEY
672
+ * SAFETY PROPERTY OF THIS WHOLE FILE. `submit` mints it ONCE, above the
673
+ * consent retry, and passes the same value into both invocations of this
674
+ * function. Minting it here instead would give the automatic retry a FRESH
675
+ * key — a SECOND Buzz reservation for one logical submit, which is exactly
676
+ * what {@link SubmitWorkflowOptions.idempotencyKey}'s docs forbid.
677
+ */
678
+ const submitOnce = useCallback(async (body, idempotencyKey) => {
679
+ const { snapshot } = await sendTypedRequest(getTransport(), { type: 'SUBMIT_WORKFLOW', payload: { body, idempotencyKey } }, 'WORKFLOW_SUBMITTED', { timeoutMs: WORKFLOW_REQUEST_TIMEOUT_MS });
680
+ // 🔴 PUBLISH THE SNAPSHOT BEFORE THE REJECTION BELOW, for the same reason
681
+ // `estimate` does: a block may render from `result` rather than from the
682
+ // returned value, and jumping over this line would leave the PREVIOUS
683
+ // submit's snapshot in place — a live control pointing at a workflow THIS
684
+ // submit did not queue.
685
+ setResult(snapshot);
686
+ // 🔴 AN ERRORED SUBMIT MUST REJECT (civitai/civitai-app-starters#251, the
687
+ // `submit` half of civitai/civitai#4159). Two producers report
688
+ // `status:'failed'` and `status` separates neither:
689
+ //
690
+ // - a budget / spend-cap REJECTION is an OUTCOME the block recovers from
691
+ // (open a top-up flow). The server quotes the price it refused to
692
+ // charge, so `cost.total` is present. It RESOLVES — turning this arm
693
+ // into a throw is the one change that would break the recovery path.
694
+ // - a failure-shaped reply with NO `cost` is not a usable outcome. It
695
+ // REJECTS — and the `code` says which kind, because they differ on
696
+ // whether money moved (see WorkflowSubmitError.code).
697
+ //
698
+ // BOTH clauses are load-bearing. Dropping `status === 'failed'` would
699
+ // reject every ordinary in-flight reply (`{status:'pending'}` is cost-less
700
+ // too); dropping the cost test would reject the budget rejection. And the
701
+ // test is `typeof … !== 'number'`, never `!snapshot.cost?.total`: `0` is a
702
+ // real price and falsy.
703
+ //
704
+ // 🔴 `status === 'failed'`, NOT `TERMINAL_STATUSES.has(status)`. Widening it
705
+ // would reject cost-less `succeeded`/`canceled`/`expired` replies, which are
706
+ // legitimate outcomes the server really does emit without a price
707
+ // (`snapshotFromWorkflow` omits `cost` on any non-numeric total). That
708
+ // WIDENING is invisible to a mutation sweep that only deletes clauses, so
709
+ // all three statuses are pinned by their own fixtures in the test file.
710
+ if (snapshot.status === 'failed' && typeof snapshot.cost?.total !== 'number') {
711
+ // 🔴 WHICH ARM: the host stamps its own synthesised failures with the
712
+ // `'failed'` sentinel, while a server-built reply carries `workflow.id`
713
+ // (or the `'whatif'` sentinel).
714
+ // Anything unrecognised falls to `'workflow-failed'`, the arm that assumes
715
+ // money MAY be committed — an unknown id must never buy the reassuring
716
+ // "nothing was charged" reading.
717
+ throw new WorkflowSubmitError(snapshot, snapshot.workflowId === HOST_SYNTHESISED_WORKFLOW_ID ? 'exception' : 'workflow-failed');
718
+ }
719
+ setStatus(TERMINAL_STATUSES.has(snapshot.status) ? 'done' : 'polling');
720
+ return snapshot;
721
+ }, []);
609
722
  const submit = useCallback(async (body, options) => {
610
723
  setError(null);
611
724
  setStatus('submitting');
612
725
  // Idempotency: reuse a caller-supplied stable key across a retry (→ one Buzz
613
726
  // charge), or mint a fresh one per call (each call is a new logical submit).
727
+ //
728
+ // 🔴 MINTED HERE, OUTSIDE THE CLOSURE `withConsentRetry` RE-INVOKES. Both
729
+ // attempts therefore carry the SAME key and the host+orchestrator collapse
730
+ // them to ONE reservation. Move this line inside `submitOnce` and an
731
+ // automatic retry double-reserves a real person's Buzz — the single
732
+ // regression this feature exists to not have.
614
733
  const idempotencyKey = options?.idempotencyKey ?? generateIdempotencyKey();
615
734
  try {
616
- const { snapshot } = await sendTypedRequest(getTransport(), { type: 'SUBMIT_WORKFLOW', payload: { body, idempotencyKey } }, 'WORKFLOW_SUBMITTED', { timeoutMs: WORKFLOW_REQUEST_TIMEOUT_MS });
617
- // 🔴 PUBLISH THE SNAPSHOT BEFORE THE REJECTION BELOW, for the same reason
618
- // `estimate` does: a block may render from `result` rather than from the
619
- // returned value, and jumping over this line would leave the PREVIOUS
620
- // submit's snapshot in place — a live control pointing at a workflow THIS
621
- // submit did not queue.
622
- setResult(snapshot);
623
- // 🔴 AN ERRORED SUBMIT MUST REJECT (civitai/civitai-app-starters#251, the
624
- // `submit` half of civitai/civitai#4159). Two producers report
625
- // `status:'failed'` and `status` separates neither:
626
- //
627
- // - a budget / spend-cap REJECTION is an OUTCOME the block recovers from
628
- // (open a top-up flow). The server quotes the price it refused to
629
- // charge, so `cost.total` is present. It RESOLVES — turning this arm
630
- // into a throw is the one change that would break the recovery path.
631
- // - a failure-shaped reply with NO `cost` is not a usable outcome. It
632
- // REJECTS — and the `code` says which kind, because they differ on
633
- // whether money moved (see WorkflowSubmitError.code).
634
- //
635
- // BOTH clauses are load-bearing. Dropping `status === 'failed'` would
636
- // reject every ordinary in-flight reply (`{status:'pending'}` is cost-less
637
- // too); dropping the cost test would reject the budget rejection. And the
638
- // test is `typeof … !== 'number'`, never `!snapshot.cost?.total`: `0` is a
639
- // real price and falsy.
640
- //
641
- // 🔴 `status === 'failed'`, NOT `TERMINAL_STATUSES.has(status)`. Widening it
642
- // would reject cost-less `succeeded`/`canceled`/`expired` replies, which are
643
- // legitimate outcomes the server really does emit without a price
644
- // (`snapshotFromWorkflow` omits `cost` on any non-numeric total). That
645
- // WIDENING is invisible to a mutation sweep that only deletes clauses, so
646
- // all three statuses are pinned by their own fixtures in the test file.
647
- if (snapshot.status === 'failed' && typeof snapshot.cost?.total !== 'number') {
648
- // 🔴 WHICH ARM: the host stamps its own synthesised failures with the
649
- // `'failed'` sentinel, while a server-built reply carries `workflow.id`
650
- // (or the `'whatif'` sentinel).
651
- // Anything unrecognised falls to `'workflow-failed'`, the arm that assumes
652
- // money MAY be committed — an unknown id must never buy the reassuring
653
- // "nothing was charged" reading.
654
- throw new WorkflowSubmitError(snapshot, snapshot.workflowId === HOST_SYNTHESISED_WORKFLOW_ID ? 'exception' : 'workflow-failed');
655
- }
656
- setStatus(TERMINAL_STATUSES.has(snapshot.status) ? 'done' : 'polling');
657
- return snapshot;
735
+ return await withConsentRetry(getTransport(), WORKFLOW_SCOPES, () => submitOnce(body, idempotencyKey), options,
736
+ // Rule 3 in the time axis — see `mountedRef` above for why this hook
737
+ // needs it even though it has no `AbortController`.
738
+ () => mountedRef.current);
658
739
  }
659
740
  catch (err) {
660
741
  setError(err);
661
742
  setStatus('error');
662
743
  throw err;
663
744
  }
664
- }, []);
745
+ }, [submitOnce]);
665
746
  /**
666
747
  * ONE poll round-trip, with an optional long-poll hint. The single place that
667
748
  * builds a `POLL_WORKFLOW` message, so `poll` and `watch` cannot drift on the
@@ -1,14 +1,40 @@
1
1
  import type { BlockCheckpointInfo } from '@civitai/app-sdk/blocks';
2
2
  /** What {@link useCheckpointPicker} returns. */
3
3
  export interface UseCheckpointPicker {
4
- open: (opts: {
4
+ open: (opts?: {
5
5
  /**
6
- * Ecosystem key (e.g. 'Flux1', 'SDXL'). Get it from
7
- * `useBlockContext().context.checkpoint?.baseModel` — but for the
8
- * picker filter the host will collapse to the ecosystem family, so
9
- * any baseModel in the family works as a hint.
6
+ * 🔴 OMIT THIS BY DEFAULT. It is an ecosystem-family FILTER, not a label:
7
+ * the host HIDES every checkpoint outside the family you pass. Passing the
8
+ * family you are already in is therefore a trap — it makes the picker offer
9
+ * only the ecosystem the user is trying to leave, and every other family
10
+ * becomes unreachable for the life of the session.
11
+ *
12
+ * Omit it for an unconstrained pick: the host applies no base-model
13
+ * narrowing and offers every checkpoint the viewer can generate with.
14
+ *
15
+ * Pass it ONLY when the block must stay inside a family it already holds —
16
+ * a regenerate/variation flow pinned to one checkpoint's ecosystem, say —
17
+ * and then DERIVE it from that checkpoint (`checkpoint.baseModel`, or
18
+ * `useBlockContext().context.checkpoint?.baseModel`). Never a hardcoded
19
+ * ecosystem string: a literal pins every viewer of the app to whichever
20
+ * family the author happened to be testing with. Accepts an ecosystem key
21
+ * (e.g. 'Flux1', 'SDXL') or any baseModel name in the family; the host
22
+ * collapses either to the ecosystem family.
23
+ *
24
+ * An empty string is normalized to absent here and never reaches the wire,
25
+ * and `''` is **not** an escape hatch — it does not even mean the same thing
26
+ * on both hosts. On a **model slot** the host normalises whatever string you
27
+ * send, so `''` resolves to the real ecosystem key `Other` and NARROWS to
28
+ * that one family. On a **page** the host drops a zero-length value, so `''`
29
+ * behaves exactly like omitting it. Neither is what you meant on at least one
30
+ * surface: omit the key, or pass a family derived from a real checkpoint, and
31
+ * never `''`.
32
+ *
33
+ * A WHITESPACE-ONLY string narrows on BOTH hosts — the page host's guard is
34
+ * `length > 0`, which `' '` passes — which is why this hook trims before
35
+ * deciding.
10
36
  */
11
- baseModelGroup: string;
37
+ baseModelGroup?: string;
12
38
  /** Currently-selected versionId so the picker can pre-highlight it. */
13
39
  currentVersionId?: number;
14
40
  }) => Promise<{
@@ -19,9 +45,10 @@ export interface UseCheckpointPicker {
19
45
  /**
20
46
  * Drives the platform-side Checkpoint picker and the persist-override flow.
21
47
  *
22
- * `open` opens the host's Resource picker filtered to Checkpoints in the
23
- * given ecosystem; resolves with `{ selected }` (undefined when the user
24
- * dismissed without picking).
48
+ * `open` opens the host's Resource picker on Checkpoints and resolves with
49
+ * `{ selected }` (undefined when the user dismissed without picking). By
50
+ * default the pick is UNCONSTRAINED — every family the viewer can generate
51
+ * with. Pass `baseModelGroup` only to pin it to one ecosystem.
25
52
  *
26
53
  * `persist` writes the chosen versionId into `block_user_settings` via the
27
54
  * host. Pass `null` to clear the override and fall back to the publisher
@@ -33,9 +60,15 @@ export interface UseCheckpointPicker {
33
60
  * useBuzzWorkflow.
34
61
  *
35
62
  * @example
63
+ * // DEFAULT — no ecosystem. The viewer can reach every family.
36
64
  * const { open, persist } = useCheckpointPicker();
37
- * const { selected } = await open({ baseModelGroup: 'SDXL', currentVersionId });
65
+ * const { selected } = await open({ currentVersionId: checkpoint.versionId });
38
66
  * if (selected) await persist(selected.versionId); // null clears the override
67
+ *
68
+ * @example
69
+ * // ONLY when the block must stay inside a family it already holds: derive the
70
+ * // filter from that checkpoint, never from a hardcoded ecosystem.
71
+ * const { selected } = await open({ baseModelGroup: checkpoint.baseModel });
39
72
  */
40
73
  export declare function useCheckpointPicker(): UseCheckpointPicker;
41
74
  //# sourceMappingURL=useCheckpointPicker.d.ts.map
@@ -6,9 +6,10 @@ import { sendTypedRequest } from '../transport/transport.js';
6
6
  /**
7
7
  * Drives the platform-side Checkpoint picker and the persist-override flow.
8
8
  *
9
- * `open` opens the host's Resource picker filtered to Checkpoints in the
10
- * given ecosystem; resolves with `{ selected }` (undefined when the user
11
- * dismissed without picking).
9
+ * `open` opens the host's Resource picker on Checkpoints and resolves with
10
+ * `{ selected }` (undefined when the user dismissed without picking). By
11
+ * default the pick is UNCONSTRAINED — every family the viewer can generate
12
+ * with. Pass `baseModelGroup` only to pin it to one ecosystem.
12
13
  *
13
14
  * `persist` writes the chosen versionId into `block_user_settings` via the
14
15
  * host. Pass `null` to clear the override and fall back to the publisher
@@ -20,17 +21,47 @@ import { sendTypedRequest } from '../transport/transport.js';
20
21
  * useBuzzWorkflow.
21
22
  *
22
23
  * @example
24
+ * // DEFAULT — no ecosystem. The viewer can reach every family.
23
25
  * const { open, persist } = useCheckpointPicker();
24
- * const { selected } = await open({ baseModelGroup: 'SDXL', currentVersionId });
26
+ * const { selected } = await open({ currentVersionId: checkpoint.versionId });
25
27
  * if (selected) await persist(selected.versionId); // null clears the override
28
+ *
29
+ * @example
30
+ * // ONLY when the block must stay inside a family it already holds: derive the
31
+ * // filter from that checkpoint, never from a hardcoded ecosystem.
32
+ * const { selected } = await open({ baseModelGroup: checkpoint.baseModel });
26
33
  */
27
34
  export function useCheckpointPicker() {
28
35
  const open = useCallback(async (opts) => {
36
+ // Both keys are spread conditionally, and an empty or whitespace-only
37
+ // family is normalized to absent.
38
+ //
39
+ // The distinction that matters is `''`/whitespace vs ABSENT — NOT
40
+ // explicit-`undefined` vs absent. The model-slot host branches on
41
+ // `typeof baseModelGroup === 'string'`, and `typeof undefined === 'string'`
42
+ // is false, so a present-and-undefined key takes exactly the same "no
43
+ // family" branch as an absent one on every host surface. A non-empty string
44
+ // does not: it IS a string, so it survives that branch, and
45
+ // `getBaseModelGroup` collapses an unrecognised value to the real ecosystem
46
+ // key 'Other' — which NARROWS the picker to that one family instead of
47
+ // widening it.
48
+ //
49
+ // 🔴 THE TWO SPELLINGS DIFFER BY HOST, so this normalization is doing two
50
+ // different jobs:
51
+ // - `''` narrows on the MODEL SLOT (any string is normalised there) but
52
+ // is already equivalent to omission on the PAGE host, whose resolver
53
+ // drops a zero-length value before the lookup. Stripping it matters on
54
+ // one surface and is a no-op on the other.
55
+ // - a WHITESPACE-ONLY string narrows on BOTH: the page host's guard is
56
+ // `length > 0`, which `' '` passes. That is what `.trim()` is for, and
57
+ // it is the half a test on `''` alone cannot see.
58
+ // Absence is the only spelling that means "unconstrained" on both surfaces.
59
+ const baseModelGroup = opts?.baseModelGroup?.trim();
29
60
  const { selected } = await sendTypedRequest(getTransport(), {
30
61
  type: 'OPEN_CHECKPOINT_PICKER',
31
62
  payload: {
32
- baseModelGroup: opts.baseModelGroup,
33
- ...(opts.currentVersionId != null
63
+ ...(baseModelGroup ? { baseModelGroup } : {}),
64
+ ...(opts?.currentVersionId != null
34
65
  ? { currentVersionId: opts.currentVersionId }
35
66
  : {}),
36
67
  },
@@ -1,17 +1,68 @@
1
+ import type { BlockNavigateScope } from '@civitai/app-sdk/blocks';
2
+ /**
3
+ * Options for {@link UseCivitaiNavigate.navigate}.
4
+ *
5
+ * Passed as the SECOND argument, where a bare `'current' | 'new_tab'` string is
6
+ * also still accepted — see {@link UseCivitaiNavigate.navigate}.
7
+ */
8
+ export interface UseCivitaiNavigateOptions {
9
+ /**
10
+ * Which SPACE `path` is resolved in. DEFAULTS to `'app'` — this app's own
11
+ * sub-paths, which is all `navigate` could reach before this field existed.
12
+ *
13
+ * 🔴 `'/models/12345'` WITHOUT a scope does NOT reach civitai's model page. A
14
+ * leading slash carries no meaning: the host normalises it away in both
15
+ * scopes, so that call is a request for `<this app>/models/12345`. To reach
16
+ * the civitai.com page, say `{ scope: 'site' }`.
17
+ */
18
+ scope?: BlockNavigateScope;
19
+ /** Where the host should open it. Defaults to `'current'`. */
20
+ target?: 'current' | 'new_tab';
21
+ }
1
22
  /** What {@link useCivitaiNavigate} returns. */
2
23
  export interface UseCivitaiNavigate {
3
- navigate: (path: string, target?: 'current' | 'new_tab') => void;
24
+ /**
25
+ * Requests a navigation. The second argument is either an options object or —
26
+ * for the call shape that predates `scope` — a bare target string.
27
+ */
28
+ navigate: (path: string, options?: 'current' | 'new_tab' | UseCivitaiNavigateOptions) => void;
4
29
  }
5
30
  /**
6
- * Requests a navigation within civitai.com. The host mediates — `target:
7
- * "current"` navigates the parent frame; `"new_tab"` opens a new tab (which
8
- * requires `allow-popups-to-escape-sandbox` in the manifest sandbox).
31
+ * Requests a navigation from the host: the hook sends a `NAVIGATE` message and
32
+ * returns. Fire-and-forget — the host doesn't reply, so the block never learns
33
+ * what the host did, including when the host REFUSES the request.
34
+ *
35
+ * 🔴 `scope` SELECTS THE SPACE, AND IT DEFAULTS TO `'app'`.
36
+ *
37
+ * - `'app'` (default) — `path` is resolved under this app's OWN route, as a
38
+ * sub-path of it, and pushed shallowly so the page stays mounted.
39
+ * - `'site'` — `path` is resolved at the civitai.com root and the viewer leaves
40
+ * the app. The host grants this per-surface and refuses it elsewhere.
41
+ *
42
+ * 🔴 A LEADING SLASH CARRIES NO MEANING — the host normalises it away in BOTH
43
+ * scopes, so `'/settings'` and `'settings'` are one request within whichever
44
+ * scope you chose. `navigate('/models/12345')` is therefore a request for THIS
45
+ * APP's `/models/12345`, not civitai's model page; that needs
46
+ * `{ scope: 'site' }`. (Both spellings were app-scoped before `scope` existed
47
+ * too, so no existing call changed meaning — which is the point of the default.)
48
+ *
49
+ * `target` is a REQUEST, not a guarantee. How the host acts on `'current'` vs
50
+ * `'new_tab'` is host-side behaviour, and the host is the authority on it; this
51
+ * package sends the message and makes no promise about the outcome.
9
52
  *
10
- * Fire-and-forget: the host doesn't reply with confirmation.
53
+ * 🔴 Nothing in your manifest enables `'new_tab'`. In particular, do NOT declare
54
+ * `allow-popups-to-escape-sandbox`: the host intersects a manifest's
55
+ * `iframe.sandbox` with a fixed allowlist that does not contain that token, so it
56
+ * is dropped for every block at every trust tier and declaring it has no effect.
57
+ * (Earlier versions of this doc said `"new_tab"` required it — that was wrong.)
11
58
  *
12
59
  * @example
13
60
  * const { navigate } = useCivitaiNavigate();
14
- * navigate('/models/12345', 'new_tab'); // 'new_tab' needs allow-popups* in the manifest sandbox
61
+ * navigate('settings'); // this app's own /settings
62
+ * navigate('/settings'); // identical — the slash means nothing
63
+ * navigate('models/12345', { scope: 'site' }); // civitai.com/models/12345
64
+ * navigate('models/12345', { scope: 'site', target: 'new_tab' });
65
+ * navigate('detail/7', 'new_tab'); // the pre-`scope` shape, still app-scoped
15
66
  */
16
67
  export declare function useCivitaiNavigate(): UseCivitaiNavigate;
17
68
  //# sourceMappingURL=useCivitaiNavigate.d.ts.map
@@ -1,19 +1,62 @@
1
1
  import { useCallback } from 'react';
2
2
  import { getTransport } from '../transport/singleton.js';
3
3
  /**
4
- * Requests a navigation within civitai.com. The host mediates — `target:
5
- * "current"` navigates the parent frame; `"new_tab"` opens a new tab (which
6
- * requires `allow-popups-to-escape-sandbox` in the manifest sandbox).
4
+ * Requests a navigation from the host: the hook sends a `NAVIGATE` message and
5
+ * returns. Fire-and-forget — the host doesn't reply, so the block never learns
6
+ * what the host did, including when the host REFUSES the request.
7
7
  *
8
- * Fire-and-forget: the host doesn't reply with confirmation.
8
+ * 🔴 `scope` SELECTS THE SPACE, AND IT DEFAULTS TO `'app'`.
9
+ *
10
+ * - `'app'` (default) — `path` is resolved under this app's OWN route, as a
11
+ * sub-path of it, and pushed shallowly so the page stays mounted.
12
+ * - `'site'` — `path` is resolved at the civitai.com root and the viewer leaves
13
+ * the app. The host grants this per-surface and refuses it elsewhere.
14
+ *
15
+ * 🔴 A LEADING SLASH CARRIES NO MEANING — the host normalises it away in BOTH
16
+ * scopes, so `'/settings'` and `'settings'` are one request within whichever
17
+ * scope you chose. `navigate('/models/12345')` is therefore a request for THIS
18
+ * APP's `/models/12345`, not civitai's model page; that needs
19
+ * `{ scope: 'site' }`. (Both spellings were app-scoped before `scope` existed
20
+ * too, so no existing call changed meaning — which is the point of the default.)
21
+ *
22
+ * `target` is a REQUEST, not a guarantee. How the host acts on `'current'` vs
23
+ * `'new_tab'` is host-side behaviour, and the host is the authority on it; this
24
+ * package sends the message and makes no promise about the outcome.
25
+ *
26
+ * 🔴 Nothing in your manifest enables `'new_tab'`. In particular, do NOT declare
27
+ * `allow-popups-to-escape-sandbox`: the host intersects a manifest's
28
+ * `iframe.sandbox` with a fixed allowlist that does not contain that token, so it
29
+ * is dropped for every block at every trust tier and declaring it has no effect.
30
+ * (Earlier versions of this doc said `"new_tab"` required it — that was wrong.)
9
31
  *
10
32
  * @example
11
33
  * const { navigate } = useCivitaiNavigate();
12
- * navigate('/models/12345', 'new_tab'); // 'new_tab' needs allow-popups* in the manifest sandbox
34
+ * navigate('settings'); // this app's own /settings
35
+ * navigate('/settings'); // identical — the slash means nothing
36
+ * navigate('models/12345', { scope: 'site' }); // civitai.com/models/12345
37
+ * navigate('models/12345', { scope: 'site', target: 'new_tab' });
38
+ * navigate('detail/7', 'new_tab'); // the pre-`scope` shape, still app-scoped
13
39
  */
14
40
  export function useCivitaiNavigate() {
15
- const navigate = useCallback((path, target = 'current') => {
16
- getTransport().sendMessage({ type: 'NAVIGATE', payload: { path, target } });
41
+ const navigate = useCallback((path, options = {}) => {
42
+ // A bare string is the pre-`scope` call shape (`navigate(path, 'new_tab')`)
43
+ // and stays supported: there are live callers, and the whole point of the
44
+ // `'app'` default is that none of them changes meaning. Widening the
45
+ // parameter rather than adding an overload keeps ONE published signature,
46
+ // so `UseCivitaiNavigate` still describes the hook exactly.
47
+ const opts = typeof options === 'string' ? { target: options } : options;
48
+ // `scope` is OMITTED, not sent as `undefined`, when the caller did not
49
+ // choose one — absent and `'app'` mean the same thing to the host, so the
50
+ // payload an unscoped call puts on the wire stays byte-identical to what
51
+ // every pre-`scope` build sent.
52
+ getTransport().sendMessage({
53
+ type: 'NAVIGATE',
54
+ payload: {
55
+ path,
56
+ ...(opts.scope ? { scope: opts.scope } : {}),
57
+ target: opts.target ?? 'current',
58
+ },
59
+ });
17
60
  }, []);
18
61
  return { navigate };
19
62
  }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * What {@link useCivitaiRoute} returns. An alias — see `./returnTypeLedger.js`
3
+ * for why every hook on the entry has one of these.
4
+ */
5
+ export type UseCivitaiRoute = string;
6
+ /**
7
+ * The sub-path below your app's root that is CURRENTLY showing, kept live for
8
+ * the whole life of the block ON THE IFRAME TRANSPORT.
9
+ *
10
+ * The page surface owns the browser history; your block does not. You ask for a
11
+ * move with {@link useCivitaiNavigate} (`scope: 'app'`, the default), the host
12
+ * pushes it shallowly so your frame stays mounted — and this is how you learn
13
+ * where you ended up. It also reports the moves you did NOT ask for: the
14
+ * viewer's own back/forward, and a deep link the host resolved after init.
15
+ *
16
+ * Reads the SAME singleton transport snapshot {@link useBlockContext} does, so
17
+ * it re-renders when the value changes. Two things set it:
18
+ *
19
+ * 1. `BLOCK_INIT` — `context.subPath`, the host's value at mount. The FIRST
20
+ * value always arrives here, never over a message;
21
+ * 2. `ROUTE_CHANGED`, the host's push on every later change.
22
+ *
23
+ * It is the same value as `useBlockContext().context.subPath` on a page slot —
24
+ * reach for this when the route is all you need, and because this hook's return
25
+ * type is a plain `string` rather than a field on a union you have to narrow.
26
+ *
27
+ * ```tsx
28
+ * const subPath = useCivitaiRoute(); // '' on your app's index
29
+ * const [view, id] = subPath.split('/'); // 'compare/42' → ['compare', '42']
30
+ * ```
31
+ *
32
+ * 🔴 `''` IS A REAL ROUTE — your app's own index — AND IT IS ALSO THE PRE-INIT
33
+ * SENTINEL. The two are indistinguishable from this hook alone, exactly as
34
+ * `'light'` is both a real theme and {@link useBlockTheme}'s pre-init value.
35
+ * Gate on `useBlockContext().ready` if your first paint must tell them apart.
36
+ *
37
+ * 🔴 NO LEADING SLASH, and no slash-tolerance to lean on. The host sends the
38
+ * segment below your app root — `'compare/42'`, not `'/compare/42'` — so
39
+ * `subPath === 'compare/42'` is the comparison that works and
40
+ * `subPath === '/compare/42'` is the one that silently never matches.
41
+ *
42
+ * 🔴 PAGE SLOT ONLY. A model-page slot has no route of its own, so this returns
43
+ * `''` there and never moves. It is not a defect to debug: the host's own
44
+ * `ROUTE_CHANGED` effect lives in `PageBlockHost`, and `ModelSlotContext` has no
45
+ * `subPath` field for it to update.
46
+ *
47
+ * 🔴 OLD HOST: a host that never sends `ROUTE_CHANGED` simply never moves the
48
+ * value — the hook degrades to the init sub-path, which is the behaviour every
49
+ * page block had before the message existed. Nothing here awaits a message, so
50
+ * there is no hang and no timeout.
51
+ *
52
+ * 🔴 INLINE TRANSPORT: the value is FROZEN at the init sub-path. v1 inline mode
53
+ * receives no host pushes at all (`InlineTransport.onMessage` is a stub and
54
+ * `subscribe` is a no-op, so nothing can emit), the same degradation as an old
55
+ * host, and it lifts when v2 inline mode lands.
56
+ *
57
+ * 🔴 READ IT ON EVERY RENDER. A block that copies the value into state once at
58
+ * mount — or routes imperatively in a mount-only effect — stays on the route it
59
+ * started with and reproduces the symptom this message exists to end: the URL
60
+ * moves and nothing renders.
61
+ */
62
+ export declare function useCivitaiRoute(): UseCivitaiRoute;
63
+ //# sourceMappingURL=useCivitaiRoute.d.ts.map
@@ -0,0 +1,71 @@
1
+ import { useTransportSnapshot } from './useBlockContext.js';
2
+ /**
3
+ * The sub-path below your app's root that is CURRENTLY showing, kept live for
4
+ * the whole life of the block ON THE IFRAME TRANSPORT.
5
+ *
6
+ * The page surface owns the browser history; your block does not. You ask for a
7
+ * move with {@link useCivitaiNavigate} (`scope: 'app'`, the default), the host
8
+ * pushes it shallowly so your frame stays mounted — and this is how you learn
9
+ * where you ended up. It also reports the moves you did NOT ask for: the
10
+ * viewer's own back/forward, and a deep link the host resolved after init.
11
+ *
12
+ * Reads the SAME singleton transport snapshot {@link useBlockContext} does, so
13
+ * it re-renders when the value changes. Two things set it:
14
+ *
15
+ * 1. `BLOCK_INIT` — `context.subPath`, the host's value at mount. The FIRST
16
+ * value always arrives here, never over a message;
17
+ * 2. `ROUTE_CHANGED`, the host's push on every later change.
18
+ *
19
+ * It is the same value as `useBlockContext().context.subPath` on a page slot —
20
+ * reach for this when the route is all you need, and because this hook's return
21
+ * type is a plain `string` rather than a field on a union you have to narrow.
22
+ *
23
+ * ```tsx
24
+ * const subPath = useCivitaiRoute(); // '' on your app's index
25
+ * const [view, id] = subPath.split('/'); // 'compare/42' → ['compare', '42']
26
+ * ```
27
+ *
28
+ * 🔴 `''` IS A REAL ROUTE — your app's own index — AND IT IS ALSO THE PRE-INIT
29
+ * SENTINEL. The two are indistinguishable from this hook alone, exactly as
30
+ * `'light'` is both a real theme and {@link useBlockTheme}'s pre-init value.
31
+ * Gate on `useBlockContext().ready` if your first paint must tell them apart.
32
+ *
33
+ * 🔴 NO LEADING SLASH, and no slash-tolerance to lean on. The host sends the
34
+ * segment below your app root — `'compare/42'`, not `'/compare/42'` — so
35
+ * `subPath === 'compare/42'` is the comparison that works and
36
+ * `subPath === '/compare/42'` is the one that silently never matches.
37
+ *
38
+ * 🔴 PAGE SLOT ONLY. A model-page slot has no route of its own, so this returns
39
+ * `''` there and never moves. It is not a defect to debug: the host's own
40
+ * `ROUTE_CHANGED` effect lives in `PageBlockHost`, and `ModelSlotContext` has no
41
+ * `subPath` field for it to update.
42
+ *
43
+ * 🔴 OLD HOST: a host that never sends `ROUTE_CHANGED` simply never moves the
44
+ * value — the hook degrades to the init sub-path, which is the behaviour every
45
+ * page block had before the message existed. Nothing here awaits a message, so
46
+ * there is no hang and no timeout.
47
+ *
48
+ * 🔴 INLINE TRANSPORT: the value is FROZEN at the init sub-path. v1 inline mode
49
+ * receives no host pushes at all (`InlineTransport.onMessage` is a stub and
50
+ * `subscribe` is a no-op, so nothing can emit), the same degradation as an old
51
+ * host, and it lifts when v2 inline mode lands.
52
+ *
53
+ * 🔴 READ IT ON EVERY RENDER. A block that copies the value into state once at
54
+ * mount — or routes imperatively in a mount-only effect — stays on the route it
55
+ * started with and reproduces the symptom this message exists to end: the URL
56
+ * moves and nothing renders.
57
+ */
58
+ export function useCivitaiRoute() {
59
+ const context = useTransportSnapshot().context;
60
+ // A PRESENCE test, not a slot-id test, and not a cast. `BlockContext` is a
61
+ // union whose `UnknownSlotContext` arm declares `slotId` and nothing else, and
62
+ // the pre-init `EMPTY_SNAPSHOT.context` is exactly that shape — so there is
63
+ // genuinely no field to read before init, or on any slot but the page. The
64
+ // `typeof` half is not belt-and-braces either: `UnknownSlotContext` would let
65
+ // a host put any value on that key, and this hook's published return type is
66
+ // `string`.
67
+ if ('subPath' in context && typeof context.subPath === 'string')
68
+ return context.subPath;
69
+ return '';
70
+ }
71
+ //# sourceMappingURL=useCivitaiRoute.js.map
@@ -1,4 +1,5 @@
1
1
  import type { BlockCreatePostHostError, BlockCreatePostRequest, BlockCreatePostResult, BlockPostSource } from '@civitai/app-sdk/blocks';
2
+ import type { ConsentRetryOptions } from './consentRetryOptions.js';
2
3
  export type { BlockCreatePostHostError, BlockCreatePostRequest, BlockCreatePostResult, BlockPostSource, };
3
4
  /**
4
5
  * The closed set of HOST refusal codes, as a runtime Set.
@@ -75,7 +76,7 @@ export interface UseCreatePostFromApp {
75
76
  * `declined`, which means the viewer dismissed the confirm and NO POST EXISTS.
76
77
  * Check `.declined` before rendering a failure.
77
78
  */
78
- createPost: (args: BlockCreatePostRequest) => Promise<BlockCreatePostResult>;
79
+ createPost: (args: BlockCreatePostRequest, options?: ConsentRetryOptions) => Promise<BlockCreatePostResult>;
79
80
  /** `true` while a request is in flight (including the viewer's confirm). */
80
81
  pending: boolean;
81
82
  /**