@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,7 +1,17 @@
1
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 { HUMAN_INTERACTION_TIMEOUT_MS } from '../transport/requestTimeouts.js';
3
5
  import { getTransport } from '../transport/singleton.js';
4
6
  import { RequestTimeoutError, sendTypedRequest } from '../transport/transport.js';
7
+ /**
8
+ * The consent-gated scope this bridge requires — `posts:write:self`, named in
9
+ * `@civitai/app-sdk`'s own `CREATE_POST_FROM_APP` message docs ("Scope
10
+ * `posts:write:self` (NOT `ai:write:budgeted`), which is SENSITIVE and
11
+ * CONSENT-GATED"). From {@link BLOCK_SCOPES}, never a literal: a typo would not
12
+ * error, it would make the automatic consent prompt silently do nothing.
13
+ */
14
+ const CREATE_POST_SCOPES = [BLOCK_SCOPES.POSTS_WRITE_SELF];
5
15
  /**
6
16
  * The closed set of HOST refusal codes, as a runtime Set.
7
17
  *
@@ -152,49 +162,92 @@ export function useCreatePostFromApp() {
152
162
  mountedRef.current = false;
153
163
  };
154
164
  }, []);
155
- const createPost = useCallback(async (args) => {
165
+ /** ONE round-trip + its result contract. Re-invoked verbatim on a consent retry. */
166
+ const createPostOnce = useCallback(async (args) => {
167
+ const reply = await sendTypedRequest(getTransport(), {
168
+ type: 'CREATE_POST_FROM_APP',
169
+ payload: {
170
+ sources: args.sources,
171
+ ...(args.title !== undefined ? { title: args.title } : {}),
172
+ ...(args.detail !== undefined ? { detail: args.detail } : {}),
173
+ ...(args.tags !== undefined ? { tags: args.tags } : {}),
174
+ ...(args.modelVersionId !== undefined
175
+ ? { modelVersionId: args.modelVersionId }
176
+ : {}),
177
+ },
178
+ }, 'CREATE_POST_RESULT',
179
+ // See the `'human'` bucketing in `transport/requestTimeouts.ts`: the
180
+ // host answers only when the viewer clicks or dismisses its confirm.
181
+ { timeoutMs: HUMAN_INTERACTION_TIMEOUT_MS }).catch((err) => {
182
+ // 🔴 THE `timedOut` STAMP LIVES HERE, INSIDE THE CLOSURE
183
+ // `withConsentRetry` RE-INVOKES — NOT IN `createPost`'s OUTER CATCH.
184
+ // That placement is the whole point and it is load-bearing: rule 4 of
185
+ // `internal/withConsentRetry.ts` ("never retry a keyless bridge's
186
+ // timeout") is read off THIS error, so a stamp applied outside the
187
+ // wrapper is invisible to it. Until #500 round 2 it was applied outside,
188
+ // and the consequence was exactly what `CreatePostError.timedOut`'s
189
+ // docstring exists to prevent: a raw `RequestTimeoutError` reached the
190
+ // helper carrying neither `declined` nor `timedOut`, so on a token
191
+ // lacking `posts:write:self` the SDK prompted and RE-SENT THE POST.
192
+ // `CREATE_POST_FROM_APP` has no `idempotencyKey` on the wire, so that
193
+ // second send is a genuine second write — a DUPLICATE PUBLIC POST under
194
+ // the viewer's name.
195
+ //
196
+ // Structural, not a message match — see `RequestTimeoutError`.
197
+ if (err instanceof RequestTimeoutError) {
198
+ throw new CreatePostError(err.message, { timedOut: true });
199
+ }
200
+ throw err instanceof Error ? err : new Error(String(err));
201
+ });
202
+ if (reply.error || !reply.result) {
203
+ // 🔴 `||`, NOT `??`. `isValidCreatePostResult` gates `error` on SHAPE
204
+ // only (it has to: the channel carries free-text server messages), so
205
+ // a host `error: ''` is a VALID reply that reaches here. `??` would
206
+ // then throw an Error with an EMPTY message for a public post, which
207
+ // renders as a blank failure. Fall through to a code that at least
208
+ // names a real outcome.
209
+ throw new CreatePostError(reply.error || 'no images to post');
210
+ }
211
+ return reply.result;
212
+ }, []);
213
+ const createPost = useCallback(async (args, options) => {
156
214
  if (mountedRef.current) {
157
215
  setPending(true);
158
216
  setError(null);
159
217
  }
160
218
  try {
161
- const reply = await sendTypedRequest(getTransport(), {
162
- type: 'CREATE_POST_FROM_APP',
163
- payload: {
164
- sources: args.sources,
165
- ...(args.title !== undefined ? { title: args.title } : {}),
166
- ...(args.detail !== undefined ? { detail: args.detail } : {}),
167
- ...(args.tags !== undefined ? { tags: args.tags } : {}),
168
- ...(args.modelVersionId !== undefined
169
- ? { modelVersionId: args.modelVersionId }
170
- : {}),
171
- },
172
- }, 'CREATE_POST_RESULT',
173
- // See the `'human'` bucketing in `transport/requestTimeouts.ts`: the
174
- // host answers only when the viewer clicks or dismisses its confirm.
175
- { timeoutMs: HUMAN_INTERACTION_TIMEOUT_MS });
176
- if (reply.error || !reply.result) {
177
- // 🔴 `||`, NOT `??`. `isValidCreatePostResult` gates `error` on SHAPE
178
- // only (it has to: the channel carries free-text server messages), so
179
- // a host `error: ''` is a VALID reply that reaches here. `??` would
180
- // then throw an Error with an EMPTY message for a public post, which
181
- // renders as a blank failure. Fall through to a code that at least
182
- // names a real outcome.
183
- throw new CreatePostError(reply.error || 'no images to post');
184
- }
185
- return reply.result;
219
+ // 🔴 INSIDE the try, not around it: the wrapper must sit between the
220
+ // round-trip and the `setError` below, so a first attempt that is
221
+ // recovered by a consent grant never leaves a failure in `error` for the
222
+ // UI to render next to a post that succeeded.
223
+ //
224
+ // No idempotency key is involved — `CREATE_POST_FROM_APP` has none on the
225
+ // wire, which is exactly why a TIMEOUT here must never be retried: there
226
+ // is nothing for the server to dedupe a second send against, so the retry
227
+ // would be a second public post. `createPostOnce` stamps `timedOut` on
228
+ // its own error for that reason, INSIDE this closure where rule 4 can see
229
+ // it. The remaining duplicate-protection is the host's own per-post
230
+ // confirm, which a consent retry re-opens exactly once; and a `declined`
231
+ // (the viewer dismissing that confirm) is never retried at all.
232
+ return await withConsentRetry(getTransport(), CREATE_POST_SCOPES, () => createPostOnce(args), options,
233
+ // Rule 3 in the time axis: a grant that lands after this component is
234
+ // gone must not publish a post nobody is left to see.
235
+ () => mountedRef.current);
186
236
  }
187
237
  catch (err) {
188
- // A transport timeout arrives as a plain Error; wrap it so callers have
189
- // ONE error type to test, with `.code` left undefined (it is not a host
190
- // refusal). Re-wrapping our own error would lose `.code`, so pass it
191
- // through.
238
+ // Wrap anything that is not already ours so callers have ONE error type
239
+ // to test, with `.code` left undefined (it is not a host refusal).
240
+ // Re-wrapping our own error would lose `.code` AND `.timedOut`, so pass
241
+ // it through.
242
+ //
243
+ // 🔴 NO `timedOut` STAMP HERE. It used to be applied at this line, which
244
+ // put it OUTSIDE `withConsentRetry` and made rule 4 unreachable for this
245
+ // bridge — the round-2 defect. `createPostOnce` owns the stamp now, so
246
+ // every transport timeout arrives here ALREADY a `CreatePostError` and
247
+ // takes the pass-through branch above.
192
248
  const wrapped = err instanceof CreatePostError
193
249
  ? err
194
- : new CreatePostError(err instanceof Error ? err.message : String(err), {
195
- // Structural, not a message match — see `RequestTimeoutError`.
196
- timedOut: err instanceof RequestTimeoutError,
197
- });
250
+ : new CreatePostError(err instanceof Error ? err.message : String(err));
198
251
  if (mountedRef.current)
199
252
  setError(wrapped);
200
253
  // 🔴 THROWN UNCONDITIONALLY, even when unmounted. The caller's `await`
@@ -1,3 +1,4 @@
1
+ import type { ConsentRetryOptions } from './consentRetryOptions.js';
1
2
  import type { Entitlement } from './useEntitlements.js';
2
3
  /** Which good to buy, and optionally the price the app showed the viewer. */
3
4
  export interface GoodPurchaseParams {
@@ -12,7 +13,7 @@ export interface GoodPurchaseParams {
12
13
  expectedPriceBuzz?: number;
13
14
  }
14
15
  /** Optional per-purchase controls. */
15
- export interface GoodPurchaseOptions {
16
+ export interface GoodPurchaseOptions extends ConsentRetryOptions {
16
17
  /**
17
18
  * A STABLE idempotency key for this logical purchase. Reuse the SAME value
18
19
  * when RETRYING a purchase whose response was lost (timeout / network drop)
@@ -1,8 +1,22 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { BLOCK_SCOPES } from '@civitai/app-sdk/blocks';
3
+ import { withConsentRetry } from '../internal/withConsentRetry.js';
4
+ import { getTransport } from '../transport/singleton.js';
2
5
  import { generateIdempotencyKey } from '../transport/transport.js';
3
6
  import { useBlockToken } from './useBlockToken.js';
4
7
  import { useBuzzPurchase } from './useBuzzPurchase.js';
5
8
  import { useHostOrigin } from './useHostOrigin.js';
9
+ /**
10
+ * The consent-gated scope a purchase needs.
11
+ *
12
+ * 🔴 `goods:purchase:self`, NEVER `goods:read:self` — `BLOCK_SCOPES`' own
13
+ * comment draws the line: the read is consent-EXEMPT (server-scoped to the
14
+ * calling app's own goods, so there is nothing third-party to consent to) while
15
+ * money out of the viewer's balance always needs an explicit grant. "Do not
16
+ * collapse the two." Prompting for the read would ask for the wrong thing and
17
+ * the wait below would never see it granted.
18
+ */
19
+ const GOOD_PURCHASE_SCOPES = [BLOCK_SCOPES.GOODS_PURCHASE_SELF];
6
20
  /**
7
21
  * Backstop timeout for the direct REST purchase POST. Like {@link useTip} (and
8
22
  * unlike the postMessage hooks), this talks to the HTTP API directly, so it
@@ -95,10 +109,27 @@ export function useGoodPurchase() {
95
109
  timedOut = true;
96
110
  controller.abort();
97
111
  }, GOOD_PURCHASE_TIMEOUT_MS);
112
+ // 🔴 THE BEARER IS READ LIVE, NOT OUT OF THE RENDER CLOSURE — WITHOUT THIS
113
+ // THE AUTOMATIC CONSENT RETRY CANNOT WORK. A consent grant re-mints the
114
+ // token and pushes `TOKEN_REFRESH`; React then re-renders and `raw` gets a
115
+ // new value, but the in-flight `purchase` call is still holding the
116
+ // closure created at call time, whose `raw` is the PRE-grant token. Retry
117
+ // with that and the server sees the same scope-less token and refuses
118
+ // again — a retry that could never succeed, for a grant that did.
119
+ //
120
+ // ⚠️ `|| raw` CANNOT SUBSTITUTE A DIFFERENT VALUE, and an earlier version
121
+ // of this comment claimed it covered "the pre-init case" as though it
122
+ // could. `raw` comes from `useBlockToken()`, which returns
123
+ // `useTransportSnapshot().token` — the SAME snapshot this line reads, one
124
+ // render older. The token only ever goes sentinel-empty → real, so
125
+ // whenever the live read is empty the closure's `raw` is empty too. It is
126
+ // an equal-valued default, not a second source; the LIVE READ is the part
127
+ // that does the work.
128
+ const bearer = getTransport().getSnapshot().token.raw || raw;
98
129
  try {
99
130
  const res = await fetch(`${host}/api/v1/blocks/goods/purchase`, {
100
131
  method: 'POST',
101
- headers: { Authorization: `Bearer ${raw}`, 'Content-Type': 'application/json' },
132
+ headers: { Authorization: `Bearer ${bearer}`, 'Content-Type': 'application/json' },
102
133
  body: JSON.stringify({
103
134
  goodId: params.goodId,
104
135
  ...(params.expectedPriceBuzz != null
@@ -216,26 +247,40 @@ export function useGoodPurchase() {
216
247
  setLoading(true);
217
248
  setError(null);
218
249
  }
250
+ // 🔴 MINTED ONCE, OUTSIDE BOTH RETRY MECHANISMS. The top-up retry below
251
+ // and the consent retry wrapped around it BOTH re-post with this exact
252
+ // value, so however many attempts a single `purchase()` makes, the server
253
+ // sees ONE logical purchase. Moving it inside either closure turns an
254
+ // automatic retry into a second charge.
219
255
  const idempotencyKey = options?.idempotencyKey ?? generateIdempotencyKey();
220
256
  try {
221
- try {
222
- return await postOnce(params, idempotencyKey);
223
- }
224
- catch (first) {
225
- const canTopUp = options?.topUpOnInsufficientFunds === true &&
226
- first instanceof GoodPurchaseRefusal &&
227
- first.reason === 'insufficient_funds';
228
- if (!canTopUp)
229
- throw first;
230
- // Only retry if the viewer ACTUALLY bought Buzz. `purchased: false`
231
- // is the ordinary "they closed the modal" case, and retrying it would
232
- // just reproduce the same refusal — so the original refusal is what
233
- // the app should see.
234
- const { purchased } = await openPurchaseModal(params.expectedPriceBuzz);
235
- if (!purchased)
236
- throw first;
237
- return await postOnce(params, idempotencyKey);
238
- }
257
+ return await withConsentRetry(getTransport(), GOOD_PURCHASE_SCOPES, async () => {
258
+ try {
259
+ return await postOnce(params, idempotencyKey);
260
+ }
261
+ catch (first) {
262
+ const canTopUp = options?.topUpOnInsufficientFunds === true &&
263
+ first instanceof GoodPurchaseRefusal &&
264
+ first.reason === 'insufficient_funds';
265
+ if (!canTopUp)
266
+ throw first;
267
+ // Only retry if the viewer ACTUALLY bought Buzz. `purchased: false`
268
+ // is the ordinary "they closed the modal" case, and retrying it would
269
+ // just reproduce the same refusal — so the original refusal is what
270
+ // the app should see.
271
+ const { purchased } = await openPurchaseModal(params.expectedPriceBuzz);
272
+ if (!purchased)
273
+ throw first;
274
+ return await postOnce(params, idempotencyKey);
275
+ }
276
+ }, options,
277
+ // 🔴 RULE 3 IN THE TIME AXIS, AND IT IS NOT COVERED BY THE UNMOUNT
278
+ // ABORT IN `postOnce`. During the 60s consent wait there is no
279
+ // in-flight request for the cleanup to abort, so no `AbortError` is
280
+ // produced and the grant drove a SECOND CHARGE against a component
281
+ // that no longer exists. `withConsentRetry` reads this immediately
282
+ // before the retry.
283
+ () => mountedRef.current);
239
284
  }
240
285
  catch (err) {
241
286
  const e = err instanceof Error ? err : new Error(String(err));
@@ -1,5 +1,5 @@
1
1
  import { useCallback } from 'react';
2
- import { armConsentRefusalLatch } from '../internal/consentRefusalLatch.js';
2
+ import { sendRequestConsent } from '../internal/withConsentRetry.js';
3
3
  import { getTransport } from '../transport/singleton.js';
4
4
  /**
5
5
  * Lazy consent. Asks the host to open civitai.com's consent UI when a
@@ -44,17 +44,15 @@ import { getTransport } from '../transport/singleton.js';
44
44
  */
45
45
  export function useRequestConsent() {
46
46
  const requestConsent = useCallback((payload) => {
47
- const transport = getTransport();
48
- // Arm the refusal buffer BEFORE the request goes out. A `CONSENT_UNAVAILABLE`
49
- // can only ever follow a `REQUEST_CONSENT`, and the transport drops an
50
- // unsolicited push that has no listener at the instant it arrives — so this
51
- // ordering is what lets a refusal survive until a `useConsentUnavailable()`
52
- // mounts, instead of requiring one to already be mounted. Idempotent.
53
- armConsentRefusalLatch(transport);
54
- transport.sendMessage({
55
- type: 'REQUEST_CONSENT',
56
- ...(payload ? { payload } : {}),
57
- });
47
+ // Single-sourced with the SDK's own automatic prompt-and-retry
48
+ // (`internal/withConsentRetry.ts`), which posts the identical message. The
49
+ // helper arms the refusal buffer BEFORE the request goes out: a
50
+ // `CONSENT_UNAVAILABLE` can only ever follow a `REQUEST_CONSENT`, and the
51
+ // transport drops an unsolicited push that has no listener at the instant it
52
+ // arrives — so that ordering is what lets a refusal survive until a
53
+ // `useConsentUnavailable()` mounts, instead of requiring one to already be
54
+ // mounted. Two spellings of "arm, then send" is how one of them forgets.
55
+ sendRequestConsent(getTransport(), payload);
58
56
  }, []);
59
57
  return { requestConsent };
60
58
  }
@@ -6,10 +6,35 @@ export interface UseResourcePicker {
6
6
  * host rejects any other type (the modal never opens). */
7
7
  resourceType: BlockResourcePickerType;
8
8
  /**
9
- * Optional base-model family hint — an ecosystem key (e.g. 'Flux1', 'SDXL')
10
- * OR a baseModel name (e.g. 'Flux.1 D'); the host collapses it to the
11
- * ecosystem family. Use the chosen checkpoint's `baseModel` to constrain a
12
- * LoRA pick to the same family. Omit for an unconstrained pick of the type.
9
+ * 🔴 OMIT THIS BY DEFAULT. It is an optional base-model family FILTER, not a
10
+ * label: whatever you pass, the host HIDES every resource outside that
11
+ * ecosystem, so the viewer's own perfectly valid LoRAs simply do not appear
12
+ * and the picker looks empty or broken. Omitting it is an unconstrained pick
13
+ * of the type — every resource the viewer can reach.
14
+ *
15
+ * Pass it ONLY when the block already holds a chosen checkpoint the pick has
16
+ * to match, and then DERIVE it from that checkpoint. Never a hardcoded
17
+ * ecosystem string: a literal pins every viewer of the app to whichever
18
+ * family the author happened to be testing with. Nor a literal reached
19
+ * through a fallback — `checkpoint?.baseModel ?? 'SDXL'` re-pins every viewer
20
+ * who has not picked yet, which is the same defect wearing a default.
21
+ *
22
+ * 🔴 WHERE THE FAMILY COMES FROM ON THIS HOOK'S ONLY SURFACE. This hook is
23
+ * PAGE-ONLY (`OPEN_RESOURCE_PICKER` is a page affordance; the model slot has
24
+ * the narrower `useCheckpointPicker` instead), and a page slot carries no
25
+ * checkpoint: `checkpoint` exists on `ModelSlotContext` alone, and
26
+ * `BlockContext` is a union with no index signature, so
27
+ * `useBlockContext().context.checkpoint` does not even typecheck. The source
28
+ * is {@link BlockResourceInfo}`.baseModel` — the `baseModel` of a Checkpoint
29
+ * THIS picker returned earlier:
30
+ *
31
+ * const checkpoint = await open({ resourceType: 'Checkpoint' });
32
+ * if (checkpoint) await open({ resourceType: 'LORA', baseModelGroup: checkpoint.baseModel });
33
+ *
34
+ * Accepts an ecosystem key (e.g. 'Flux1', 'SDXL') OR a baseModel name (e.g.
35
+ * 'Flux.1 D'); the host collapses either to the ecosystem family. `''` is not
36
+ * a way to widen: the page host drops a zero-length value, so it is only ever
37
+ * a confusing spelling of omitting the key.
13
38
  */
14
39
  baseModelGroup?: string;
15
40
  }) => Promise<BlockResourceInfo | null>;
@@ -36,11 +61,35 @@ export interface UseResourcePicker {
36
61
  * Host-mediated, same trust model as `useCheckpointPicker` / `useBuzzWorkflow`:
37
62
  * the block never touches the picker UI directly.
38
63
  *
64
+ * 🔴 THE ORDER OF THE TWO `@example` BLOCKS BELOW IS LOAD-BEARING — DO NOT SWAP
65
+ * THEM. `<civitai-developer-docs>/scripts/gen-appblocks-hooks.mjs` assigns
66
+ * `jsdocExample` inside its tag loop, so on a hook with several `@example` tags
67
+ * the LAST one wins and becomes the single published example whenever the README
68
+ * fence is unavailable (renamed heading, dropped fence). The unconstrained call
69
+ * is what every agent should copy, so it is last. The constrained variant is
70
+ * first, where a human reading top-down still meets the exception before the
71
+ * default it excepts.
72
+ *
73
+ * @example
74
+ * // CONSTRAINED — only when the pick has to match a checkpoint the block
75
+ * // already holds. Derive the family from THAT checkpoint; a hardcoded
76
+ * // ecosystem here hides every resource outside it from the viewer. On a page
77
+ * // block the checkpoint comes from this same picker, not from the context.
78
+ * const { open } = useResourcePicker();
79
+ * const checkpoint = await open({ resourceType: 'Checkpoint' });
80
+ * if (checkpoint) {
81
+ * const lora = await open({ resourceType: 'LORA', baseModelGroup: checkpoint.baseModel });
82
+ * // feed lora?.versionId into body.additionalResources and submit
83
+ * }
84
+ *
39
85
  * @example
86
+ * // THE DEFAULT — no ecosystem filter at all. The viewer sees every LoRA they
87
+ * // can reach, which is almost always what you want.
40
88
  * const { open } = useResourcePicker();
41
- * const picked = await open({ resourceType: 'LORA', baseModelGroup: 'SDXL' });
42
- * if (!picked) return; // user dismissed
43
- * // feed picked.versionId into body.additionalResources and submit
89
+ * const picked = await open({ resourceType: 'LORA' });
90
+ * if (picked) {
91
+ * // feed picked.versionId into body.additionalResources and submit
92
+ * }
44
93
  */
45
94
  export declare function useResourcePicker(): UseResourcePicker;
46
95
  //# sourceMappingURL=useResourcePicker.d.ts.map
@@ -24,11 +24,35 @@ import { sendTypedRequest } from '../transport/transport.js';
24
24
  * Host-mediated, same trust model as `useCheckpointPicker` / `useBuzzWorkflow`:
25
25
  * the block never touches the picker UI directly.
26
26
  *
27
+ * 🔴 THE ORDER OF THE TWO `@example` BLOCKS BELOW IS LOAD-BEARING — DO NOT SWAP
28
+ * THEM. `<civitai-developer-docs>/scripts/gen-appblocks-hooks.mjs` assigns
29
+ * `jsdocExample` inside its tag loop, so on a hook with several `@example` tags
30
+ * the LAST one wins and becomes the single published example whenever the README
31
+ * fence is unavailable (renamed heading, dropped fence). The unconstrained call
32
+ * is what every agent should copy, so it is last. The constrained variant is
33
+ * first, where a human reading top-down still meets the exception before the
34
+ * default it excepts.
35
+ *
36
+ * @example
37
+ * // CONSTRAINED — only when the pick has to match a checkpoint the block
38
+ * // already holds. Derive the family from THAT checkpoint; a hardcoded
39
+ * // ecosystem here hides every resource outside it from the viewer. On a page
40
+ * // block the checkpoint comes from this same picker, not from the context.
41
+ * const { open } = useResourcePicker();
42
+ * const checkpoint = await open({ resourceType: 'Checkpoint' });
43
+ * if (checkpoint) {
44
+ * const lora = await open({ resourceType: 'LORA', baseModelGroup: checkpoint.baseModel });
45
+ * // feed lora?.versionId into body.additionalResources and submit
46
+ * }
47
+ *
27
48
  * @example
49
+ * // THE DEFAULT — no ecosystem filter at all. The viewer sees every LoRA they
50
+ * // can reach, which is almost always what you want.
28
51
  * const { open } = useResourcePicker();
29
- * const picked = await open({ resourceType: 'LORA', baseModelGroup: 'SDXL' });
30
- * if (!picked) return; // user dismissed
31
- * // feed picked.versionId into body.additionalResources and submit
52
+ * const picked = await open({ resourceType: 'LORA' });
53
+ * if (picked) {
54
+ * // feed picked.versionId into body.additionalResources and submit
55
+ * }
32
56
  */
33
57
  export function useResourcePicker() {
34
58
  const open = useCallback(async (opts) => {
@@ -1,3 +1,4 @@
1
+ import type { ConsentRetryOptions } from './consentRetryOptions.js';
1
2
  /** The tip target + amount. `entityType`/`entityId` are optional context. */
2
3
  export interface TipParams {
3
4
  toUserId: number;
@@ -6,7 +7,7 @@ export interface TipParams {
6
7
  entityId?: number;
7
8
  }
8
9
  /** Optional per-tip controls. */
9
- export interface TipOptions {
10
+ export interface TipOptions extends ConsentRetryOptions {
10
11
  /**
11
12
  * A STABLE idempotency key for this logical tip. Reuse the SAME value when
12
13
  * RETRYING a tip whose response was lost (timeout / network drop) so the host
@@ -1,7 +1,12 @@
1
1
  import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { BLOCK_SCOPES } from '@civitai/app-sdk/blocks';
3
+ import { withConsentRetry } from '../internal/withConsentRetry.js';
4
+ import { getTransport } from '../transport/singleton.js';
2
5
  import { useHostOrigin } from './useHostOrigin.js';
3
6
  import { useBlockToken } from './useBlockToken.js';
4
7
  import { generateIdempotencyKey } from '../transport/transport.js';
8
+ /** The consent-gated scope a tip needs (`social:tip:self`). */
9
+ const TIP_SCOPES = [BLOCK_SCOPES.SOCIAL_TIP_SELF];
5
10
  /**
6
11
  * Backstop timeout for the direct REST tip POST. Like {@link useGenerationResources}
7
12
  * (and unlike the postMessage hooks), this talks to the HTTP API directly, so it
@@ -44,22 +49,47 @@ export function useTip() {
44
49
  controllers.clear();
45
50
  };
46
51
  }, []);
47
- const tip = useCallback(async (params, options) => {
48
- if (!host) {
49
- throw new Error('useTip: host origin not established yet (wait for BLOCK_INIT).');
50
- }
51
- if (mountedRef.current) {
52
- setLoading(true);
53
- setError(null);
54
- }
55
- const idempotencyKey = options?.idempotencyKey ?? generateIdempotencyKey();
52
+ /**
53
+ * ONE tip POST + its result contract. Re-invoked verbatim on a consent retry.
54
+ *
55
+ * 🔴 `idempotencyKey` IS A PARAMETER. `tip()` mints it once, above the retry,
56
+ * and hands the SAME value to both attempts — the property that turns a retry
57
+ * into a replay instead of a second transfer.
58
+ */
59
+ const postTipOnce = useCallback(async (params, idempotencyKey) => {
56
60
  const controller = new AbortController();
57
61
  inFlight.current.add(controller);
58
- const timeoutId = setTimeout(() => controller.abort(), TIP_REQUEST_TIMEOUT_MS);
62
+ // 🔴 WHICH ABORT FIRED IS NOT RECOVERABLE FROM THE SIGNAL — the bound and
63
+ // the unmount cleanup both set `aborted` — so record it at the source.
64
+ // The two need OPPOSITE handling below, exactly as in `useGoodPurchase`:
65
+ // an unmount is a cancellation nothing may resurrect, while the bound
66
+ // elapsing is a real failure whose recovery IS the same-key retry.
67
+ let timedOut = false;
68
+ const timeoutId = setTimeout(() => {
69
+ timedOut = true;
70
+ controller.abort();
71
+ }, TIP_REQUEST_TIMEOUT_MS);
72
+ // 🔴 THE BEARER IS READ LIVE, NOT OUT OF THE RENDER CLOSURE — WITHOUT THIS
73
+ // THE AUTOMATIC CONSENT RETRY CANNOT WORK. A consent grant re-mints the
74
+ // token and pushes `TOKEN_REFRESH`; React then re-renders and `raw` gets a
75
+ // new value, but the in-flight `purchase`/`tip` call is still holding the
76
+ // closure created at call time, whose `raw` is the PRE-grant token. Retry
77
+ // with that and the server sees the same scope-less token and refuses
78
+ // again — a retry that could never succeed, for a grant that did.
79
+ //
80
+ // ⚠️ `|| raw` CANNOT SUBSTITUTE A DIFFERENT VALUE, and an earlier version
81
+ // of this comment claimed it covered "the pre-init case" as though it
82
+ // could. `raw` comes from `useBlockToken()`, which returns
83
+ // `useTransportSnapshot().token` — the SAME snapshot this line reads, one
84
+ // render older. The token only ever goes sentinel-empty → real, so
85
+ // whenever the live read is empty the closure's `raw` is empty too. It is
86
+ // an equal-valued default, not a second source; the LIVE READ is the part
87
+ // that does the work.
88
+ const bearer = getTransport().getSnapshot().token.raw || raw;
59
89
  try {
60
90
  const res = await fetch(`${host}/api/v1/blocks/tip`, {
61
91
  method: 'POST',
62
- headers: { Authorization: `Bearer ${raw}`, 'Content-Type': 'application/json' },
92
+ headers: { Authorization: `Bearer ${bearer}`, 'Content-Type': 'application/json' },
63
93
  body: JSON.stringify({
64
94
  toUserId: params.toUserId,
65
95
  amount: params.amount,
@@ -76,22 +106,71 @@ export function useTip() {
76
106
  return bodyJson;
77
107
  }
78
108
  catch (err) {
79
- const e = controller.signal.aborted && !(err instanceof Error && err.message.startsWith('tip'))
80
- ? new Error(`useTip: request aborted (timed out after ${TIP_REQUEST_TIMEOUT_MS}ms or the hook unmounted).`)
81
- : err instanceof Error
82
- ? err
83
- : new Error(String(err));
84
- if (mountedRef.current)
85
- setError(e);
86
- throw e;
109
+ // The `!… startsWith('tip')` half keeps a server refusal that was
110
+ // ALREADY read from being rewritten by an abort that raced it — the
111
+ // same precedence `useGoodPurchase` gives a `GoodPurchaseRefusal`.
112
+ if (controller.signal.aborted &&
113
+ !(err instanceof Error && err.message.startsWith('tip'))) {
114
+ if (timedOut) {
115
+ // 🔴 THE BOUND FIRED, AND THIS ERROR IS DELIBERATELY RETRYABLE.
116
+ // `name` stays `Error` (callers routinely ignore `AbortError` as
117
+ // "we navigated away", and a silently-ignored money-path timeout is
118
+ // the worst outcome here) and it carries NO `timedOut` flag, so the
119
+ // automatic consent retry may re-send it. That is safe for exactly
120
+ // one reason: this hook mints an `idempotencyKey` above the retry
121
+ // and both POSTs carry it, so the server collapses them to ONE
122
+ // transfer. See rule 4 in `internal/withConsentRetry.ts` — the flag
123
+ // marks bridges with NO key to dedupe with, and this is not one.
124
+ throw new Error(`useTip: request aborted (timed out after ${TIP_REQUEST_TIMEOUT_MS}ms). The transfer may or may not have landed — retry with the SAME idempotencyKey to find out safely.`);
125
+ }
126
+ // 🔴 UNMOUNT. `name = 'AbortError'` is what a caller discriminates on
127
+ // to IGNORE a rejection its component no longer cares about, and it
128
+ // is also what makes `withConsentRetry` rule 3 re-throw rather than
129
+ // resurrect work that was cancelled on purpose.
130
+ const aborted = new Error('useTip: request aborted (the hook unmounted before the response arrived). The transfer may or may not have landed — retry with the SAME idempotencyKey to find out safely.');
131
+ aborted.name = 'AbortError';
132
+ throw aborted;
133
+ }
134
+ throw err instanceof Error ? err : new Error(String(err));
87
135
  }
88
136
  finally {
89
137
  clearTimeout(timeoutId);
90
138
  inFlight.current.delete(controller);
139
+ }
140
+ }, [host, raw]);
141
+ const tip = useCallback(async (params, options) => {
142
+ if (!host) {
143
+ throw new Error('useTip: host origin not established yet (wait for BLOCK_INIT).');
144
+ }
145
+ if (mountedRef.current) {
146
+ setLoading(true);
147
+ setError(null);
148
+ }
149
+ // 🔴 MINTED ONCE, OUTSIDE the closure the consent retry re-invokes, so
150
+ // both POSTs carry the SAME key and the host collapses them to ONE
151
+ // transfer. Minting it inside would DOUBLE-TIP a real person.
152
+ const idempotencyKey = options?.idempotencyKey ?? generateIdempotencyKey();
153
+ try {
154
+ return await withConsentRetry(getTransport(), TIP_SCOPES, () => postTipOnce(params, idempotencyKey), options,
155
+ // 🔴 RULE 3 IN THE TIME AXIS, AND IT IS NOT COVERED BY THE UNMOUNT
156
+ // ABORT ABOVE. During the 60s consent wait there is no in-flight
157
+ // request for the cleanup to abort, so no `AbortError` is produced and
158
+ // the grant drove a SECOND POST against a component that no longer
159
+ // exists — Buzz leaving the viewer's balance with no UI left to report
160
+ // it, and with `inFlight` already cleared that POST is not even
161
+ // abortable. `withConsentRetry` reads this immediately before the retry.
162
+ () => mountedRef.current);
163
+ }
164
+ catch (err) {
165
+ if (mountedRef.current)
166
+ setError(err);
167
+ throw err;
168
+ }
169
+ finally {
91
170
  if (mountedRef.current)
92
171
  setLoading(false);
93
172
  }
94
- }, [host, raw]);
173
+ }, [host, postTipOnce]);
95
174
  return { tip, loading, error };
96
175
  }
97
176
  //# sourceMappingURL=useTip.js.map
package/dist/index.d.ts CHANGED
@@ -77,13 +77,16 @@ export type { UseGenerationResources } from './hooks/useGenerationResources.js';
77
77
  export { GENERATION_RESOURCES_API_BASE, MAX_GENERATION_RESOURCE_IDS, buildGenerationResourcesUrl, responseToResources, } from './api/generationResources.js';
78
78
  export type { RawGenerationResource, RawGenerationResourcesResponse, } from './api/generationResources.js';
79
79
  export { useCivitaiNavigate } from './hooks/useCivitaiNavigate.js';
80
- export type { UseCivitaiNavigate } from './hooks/useCivitaiNavigate.js';
80
+ export type { UseCivitaiNavigate, UseCivitaiNavigateOptions, } from './hooks/useCivitaiNavigate.js';
81
+ export { useCivitaiRoute } from './hooks/useCivitaiRoute.js';
82
+ export type { UseCivitaiRoute } from './hooks/useCivitaiRoute.js';
81
83
  export { useRequestSignIn } from './hooks/useRequestSignIn.js';
82
84
  export type { UseRequestSignIn } from './hooks/useRequestSignIn.js';
83
85
  export { useRequestConsent } from './hooks/useRequestConsent.js';
84
86
  export type { UseRequestConsent } from './hooks/useRequestConsent.js';
85
87
  export { useConsentUnavailable } from './hooks/useConsentUnavailable.js';
86
88
  export type { UseConsentUnavailable, ConsentUnavailablePayload, } from './hooks/useConsentUnavailable.js';
89
+ export type { ConsentRetryOptions } from './hooks/consentRetryOptions.js';
87
90
  export { useBlockAnalytics } from './hooks/useBlockAnalytics.js';
88
91
  export type { UseBlockAnalytics } from './hooks/useBlockAnalytics.js';
89
92
  export { useDomainMaturity } from './hooks/useDomainMaturity.js';
package/dist/index.js CHANGED
@@ -53,6 +53,9 @@ export { useImageUpload } from './hooks/useImageUpload.js';
53
53
  export { useGenerationResources } from './hooks/useGenerationResources.js';
54
54
  export { GENERATION_RESOURCES_API_BASE, MAX_GENERATION_RESOURCE_IDS, buildGenerationResourcesUrl, responseToResources, } from './api/generationResources.js';
55
55
  export { useCivitaiNavigate } from './hooks/useCivitaiNavigate.js';
56
+ // The other half of the `NAVIGATE` round trip: `useCivitaiNavigate` asks the
57
+ // host to move, `useCivitaiRoute` is how the block learns where it now is.
58
+ export { useCivitaiRoute } from './hooks/useCivitaiRoute.js';
56
59
  export { useRequestSignIn } from './hooks/useRequestSignIn.js';
57
60
  export { useRequestConsent } from './hooks/useRequestConsent.js';
58
61
  export { useConsentUnavailable } from './hooks/useConsentUnavailable.js';