@civitai/blocks-react 0.60.0 → 0.61.1
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 +260 -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,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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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({
|
|
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
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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({
|
|
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:
|
|
33
|
-
...(opts
|
|
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
|
-
|
|
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
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
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('
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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('
|
|
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,
|
|
16
|
-
|
|
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
|
/**
|
|
@@ -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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
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)
|