@civitai/blocks-react 0.60.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.
- package/README.md +242 -6
- package/dist/hooks/consentRetryOptions.d.ts +40 -0
- package/dist/hooks/consentRetryOptions.js +2 -0
- package/dist/hooks/useBuzzWorkflow.d.ts +23 -1
- package/dist/hooks/useBuzzWorkflow.js +125 -44
- package/dist/hooks/useCheckpointPicker.d.ts +43 -10
- package/dist/hooks/useCheckpointPicker.js +37 -6
- package/dist/hooks/useCivitaiNavigate.d.ts +57 -6
- package/dist/hooks/useCivitaiNavigate.js +50 -7
- package/dist/hooks/useCivitaiRoute.d.ts +63 -0
- package/dist/hooks/useCivitaiRoute.js +71 -0
- package/dist/hooks/useCreatePostFromApp.d.ts +2 -1
- package/dist/hooks/useCreatePostFromApp.js +87 -34
- package/dist/hooks/useGoodPurchase.d.ts +2 -1
- package/dist/hooks/useGoodPurchase.js +64 -19
- package/dist/hooks/useRequestConsent.js +10 -12
- package/dist/hooks/useResourcePicker.d.ts +56 -7
- package/dist/hooks/useResourcePicker.js +27 -3
- package/dist/hooks/useTip.d.ts +2 -1
- package/dist/hooks/useTip.js +99 -20
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -0
- package/dist/internal/liveHost.js +238 -7
- package/dist/internal/mockHost.js +67 -9
- package/dist/internal/withConsentRetry.d.ts +239 -0
- package/dist/internal/withConsentRetry.js +457 -0
- package/dist/transport/iframeTransport.d.ts +33 -0
- package/dist/transport/iframeTransport.js +56 -0
- package/dist/transport/validate.d.ts +25 -0
- package/dist/transport/validate.js +31 -0
- package/package.json +2 -2
|
@@ -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 ${
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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 {
|
|
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
|
-
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* ecosystem
|
|
12
|
-
*
|
|
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'
|
|
42
|
-
* if (
|
|
43
|
-
*
|
|
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'
|
|
30
|
-
* if (
|
|
31
|
-
*
|
|
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) => {
|
package/dist/hooks/useTip.d.ts
CHANGED
|
@@ -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
|
package/dist/hooks/useTip.js
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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 ${
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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,
|
|
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';
|