@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.
- package/README.md +296 -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/popoverShim.d.ts +94 -0
- package/dist/internal/popoverShim.js +181 -0
- package/dist/internal/withConsentRetry.d.ts +239 -0
- package/dist/internal/withConsentRetry.js +457 -0
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +1 -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 +4 -4
|
@@ -1,6 +1,21 @@
|
|
|
1
|
-
import { useCallback, useState } from 'react';
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { BLOCK_SCOPES } from '@civitai/app-sdk/blocks';
|
|
3
|
+
import { withConsentRetry } from '../internal/withConsentRetry.js';
|
|
2
4
|
import { getTransport } from '../transport/singleton.js';
|
|
3
5
|
import { generateIdempotencyKey, sendTypedRequest } from '../transport/transport.js';
|
|
6
|
+
/**
|
|
7
|
+
* The consent-gated scope {@link UseBuzzWorkflow.submit} requires.
|
|
8
|
+
*
|
|
9
|
+
* 🔴 `submit()` ONLY. `estimate()` needs the same scope but is deliberately not
|
|
10
|
+
* routed through the automatic consent retry — see the comment on `estimate`
|
|
11
|
+
* below; it is an on-mount read with no gesture behind it.
|
|
12
|
+
*
|
|
13
|
+
* Named from {@link BLOCK_SCOPES}, never a string literal: this array is sent to
|
|
14
|
+
* the host as the `REQUEST_CONSENT` hint, and the host ignores a hint with no
|
|
15
|
+
* RECOGNISED non-empty name — so a typo here would not error, it would make the
|
|
16
|
+
* automatic prompt silently do nothing.
|
|
17
|
+
*/
|
|
18
|
+
const WORKFLOW_SCOPES = [BLOCK_SCOPES.AI_WRITE_BUDGETED];
|
|
4
19
|
/**
|
|
5
20
|
* Snapshot statuses that mean "no further polling is needed."
|
|
6
21
|
* Used by both `submit` (a host can return an instant-fail / cached result)
|
|
@@ -556,6 +571,50 @@ export function useBuzzWorkflow() {
|
|
|
556
571
|
const [status, setStatus] = useState('idle');
|
|
557
572
|
const [result, setResult] = useState(null);
|
|
558
573
|
const [error, setError] = useState(null);
|
|
574
|
+
/**
|
|
575
|
+
* Whether this hook's component is still mounted.
|
|
576
|
+
*
|
|
577
|
+
* 🔴 ITS ONLY JOB IS THE CONSENT RETRY, and that is a MONEY gate rather than a
|
|
578
|
+
* setState-after-unmount tidy-up. `submit()` can sit in `withConsentRetry`'s
|
|
579
|
+
* 60s grant wait long after the component is gone, and nothing else can see
|
|
580
|
+
* that: this hook's calls go through the postMessage bridge, so there is no
|
|
581
|
+
* `AbortController` to fire and rule 3's `AbortError` path never triggers. A
|
|
582
|
+
* grant arriving after unmount would then RESERVE BUZZ for a generation nobody
|
|
583
|
+
* is left to watch. Read immediately before the retry, never cached.
|
|
584
|
+
*/
|
|
585
|
+
const mountedRef = useRef(true);
|
|
586
|
+
useEffect(() => {
|
|
587
|
+
mountedRef.current = true;
|
|
588
|
+
return () => {
|
|
589
|
+
mountedRef.current = false;
|
|
590
|
+
};
|
|
591
|
+
}, []);
|
|
592
|
+
/**
|
|
593
|
+
* 🔴 `estimate()` IS DELIBERATELY NOT ROUTED THROUGH `withConsentRetry`, and
|
|
594
|
+
* that exclusion is load-bearing rather than an oversight.
|
|
595
|
+
*
|
|
596
|
+
* The automatic prompt is for calls a PERSON just made. `estimate()` is not
|
|
597
|
+
* one: `starters/examples/buzz-workflow/src/App.tsx` calls it from a
|
|
598
|
+
* `useEffect` keyed on the form inputs, so it fires on mount and again on
|
|
599
|
+
* every parameter edit. Routing it would open a consent dialog with no gesture
|
|
600
|
+
* behind it and hold each call pending for the full 60s grant wait. That is the
|
|
601
|
+
* same reason the Buzz READS are excluded; `estimate()` just happens to live on
|
|
602
|
+
* a hook whose OTHER call moves money.
|
|
603
|
+
*
|
|
604
|
+
* ⚠️ The in-flight DE-DUPLICATION `withConsentRetry` gained in #500 round 2
|
|
605
|
+
* does not change this verdict, and reading it as a reason to route
|
|
606
|
+
* `estimate()` would be a mistake. It collapses CONCURRENT waits on the same
|
|
607
|
+
* scope set into one dialog; a form edited over several seconds produces
|
|
608
|
+
* SEQUENTIAL calls, each after the previous wait settled, so the N-dialogs
|
|
609
|
+
* problem survives for exactly this shape. The no-gesture objection is
|
|
610
|
+
* independent of it either way.
|
|
611
|
+
*
|
|
612
|
+
* A failed estimate keeps the behaviour that starter's own `catch` is written
|
|
613
|
+
* against: reject with the server's reason, and show no price, because a
|
|
614
|
+
* missing quote is not viewer-actionable copy. `submit()` IS routed, and that
|
|
615
|
+
* is where both the gesture and the charge are. Pinned by
|
|
616
|
+
* `test/withConsentRetry.test.tsx`.
|
|
617
|
+
*/
|
|
559
618
|
const estimate = useCallback(async (body) => {
|
|
560
619
|
setError(null);
|
|
561
620
|
setStatus('estimating');
|
|
@@ -606,62 +665,84 @@ export function useBuzzWorkflow() {
|
|
|
606
665
|
throw err;
|
|
607
666
|
}
|
|
608
667
|
}, []);
|
|
668
|
+
/**
|
|
669
|
+
* ONE submit round-trip AND its result contract.
|
|
670
|
+
*
|
|
671
|
+
* 🔴 `idempotencyKey` IS A PARAMETER, NOT MINTED HERE, AND THAT IS THE MONEY
|
|
672
|
+
* SAFETY PROPERTY OF THIS WHOLE FILE. `submit` mints it ONCE, above the
|
|
673
|
+
* consent retry, and passes the same value into both invocations of this
|
|
674
|
+
* function. Minting it here instead would give the automatic retry a FRESH
|
|
675
|
+
* key — a SECOND Buzz reservation for one logical submit, which is exactly
|
|
676
|
+
* what {@link SubmitWorkflowOptions.idempotencyKey}'s docs forbid.
|
|
677
|
+
*/
|
|
678
|
+
const submitOnce = useCallback(async (body, idempotencyKey) => {
|
|
679
|
+
const { snapshot } = await sendTypedRequest(getTransport(), { type: 'SUBMIT_WORKFLOW', payload: { body, idempotencyKey } }, 'WORKFLOW_SUBMITTED', { timeoutMs: WORKFLOW_REQUEST_TIMEOUT_MS });
|
|
680
|
+
// 🔴 PUBLISH THE SNAPSHOT BEFORE THE REJECTION BELOW, for the same reason
|
|
681
|
+
// `estimate` does: a block may render from `result` rather than from the
|
|
682
|
+
// returned value, and jumping over this line would leave the PREVIOUS
|
|
683
|
+
// submit's snapshot in place — a live control pointing at a workflow THIS
|
|
684
|
+
// submit did not queue.
|
|
685
|
+
setResult(snapshot);
|
|
686
|
+
// 🔴 AN ERRORED SUBMIT MUST REJECT (civitai/civitai-app-starters#251, the
|
|
687
|
+
// `submit` half of civitai/civitai#4159). Two producers report
|
|
688
|
+
// `status:'failed'` and `status` separates neither:
|
|
689
|
+
//
|
|
690
|
+
// - a budget / spend-cap REJECTION is an OUTCOME the block recovers from
|
|
691
|
+
// (open a top-up flow). The server quotes the price it refused to
|
|
692
|
+
// charge, so `cost.total` is present. It RESOLVES — turning this arm
|
|
693
|
+
// into a throw is the one change that would break the recovery path.
|
|
694
|
+
// - a failure-shaped reply with NO `cost` is not a usable outcome. It
|
|
695
|
+
// REJECTS — and the `code` says which kind, because they differ on
|
|
696
|
+
// whether money moved (see WorkflowSubmitError.code).
|
|
697
|
+
//
|
|
698
|
+
// BOTH clauses are load-bearing. Dropping `status === 'failed'` would
|
|
699
|
+
// reject every ordinary in-flight reply (`{status:'pending'}` is cost-less
|
|
700
|
+
// too); dropping the cost test would reject the budget rejection. And the
|
|
701
|
+
// test is `typeof … !== 'number'`, never `!snapshot.cost?.total`: `0` is a
|
|
702
|
+
// real price and falsy.
|
|
703
|
+
//
|
|
704
|
+
// 🔴 `status === 'failed'`, NOT `TERMINAL_STATUSES.has(status)`. Widening it
|
|
705
|
+
// would reject cost-less `succeeded`/`canceled`/`expired` replies, which are
|
|
706
|
+
// legitimate outcomes the server really does emit without a price
|
|
707
|
+
// (`snapshotFromWorkflow` omits `cost` on any non-numeric total). That
|
|
708
|
+
// WIDENING is invisible to a mutation sweep that only deletes clauses, so
|
|
709
|
+
// all three statuses are pinned by their own fixtures in the test file.
|
|
710
|
+
if (snapshot.status === 'failed' && typeof snapshot.cost?.total !== 'number') {
|
|
711
|
+
// 🔴 WHICH ARM: the host stamps its own synthesised failures with the
|
|
712
|
+
// `'failed'` sentinel, while a server-built reply carries `workflow.id`
|
|
713
|
+
// (or the `'whatif'` sentinel).
|
|
714
|
+
// Anything unrecognised falls to `'workflow-failed'`, the arm that assumes
|
|
715
|
+
// money MAY be committed — an unknown id must never buy the reassuring
|
|
716
|
+
// "nothing was charged" reading.
|
|
717
|
+
throw new WorkflowSubmitError(snapshot, snapshot.workflowId === HOST_SYNTHESISED_WORKFLOW_ID ? 'exception' : 'workflow-failed');
|
|
718
|
+
}
|
|
719
|
+
setStatus(TERMINAL_STATUSES.has(snapshot.status) ? 'done' : 'polling');
|
|
720
|
+
return snapshot;
|
|
721
|
+
}, []);
|
|
609
722
|
const submit = useCallback(async (body, options) => {
|
|
610
723
|
setError(null);
|
|
611
724
|
setStatus('submitting');
|
|
612
725
|
// Idempotency: reuse a caller-supplied stable key across a retry (→ one Buzz
|
|
613
726
|
// charge), or mint a fresh one per call (each call is a new logical submit).
|
|
727
|
+
//
|
|
728
|
+
// 🔴 MINTED HERE, OUTSIDE THE CLOSURE `withConsentRetry` RE-INVOKES. Both
|
|
729
|
+
// attempts therefore carry the SAME key and the host+orchestrator collapse
|
|
730
|
+
// them to ONE reservation. Move this line inside `submitOnce` and an
|
|
731
|
+
// automatic retry double-reserves a real person's Buzz — the single
|
|
732
|
+
// regression this feature exists to not have.
|
|
614
733
|
const idempotencyKey = options?.idempotencyKey ?? generateIdempotencyKey();
|
|
615
734
|
try {
|
|
616
|
-
|
|
617
|
-
//
|
|
618
|
-
//
|
|
619
|
-
|
|
620
|
-
// submit's snapshot in place — a live control pointing at a workflow THIS
|
|
621
|
-
// submit did not queue.
|
|
622
|
-
setResult(snapshot);
|
|
623
|
-
// 🔴 AN ERRORED SUBMIT MUST REJECT (civitai/civitai-app-starters#251, the
|
|
624
|
-
// `submit` half of civitai/civitai#4159). Two producers report
|
|
625
|
-
// `status:'failed'` and `status` separates neither:
|
|
626
|
-
//
|
|
627
|
-
// - a budget / spend-cap REJECTION is an OUTCOME the block recovers from
|
|
628
|
-
// (open a top-up flow). The server quotes the price it refused to
|
|
629
|
-
// charge, so `cost.total` is present. It RESOLVES — turning this arm
|
|
630
|
-
// into a throw is the one change that would break the recovery path.
|
|
631
|
-
// - a failure-shaped reply with NO `cost` is not a usable outcome. It
|
|
632
|
-
// REJECTS — and the `code` says which kind, because they differ on
|
|
633
|
-
// whether money moved (see WorkflowSubmitError.code).
|
|
634
|
-
//
|
|
635
|
-
// BOTH clauses are load-bearing. Dropping `status === 'failed'` would
|
|
636
|
-
// reject every ordinary in-flight reply (`{status:'pending'}` is cost-less
|
|
637
|
-
// too); dropping the cost test would reject the budget rejection. And the
|
|
638
|
-
// test is `typeof … !== 'number'`, never `!snapshot.cost?.total`: `0` is a
|
|
639
|
-
// real price and falsy.
|
|
640
|
-
//
|
|
641
|
-
// 🔴 `status === 'failed'`, NOT `TERMINAL_STATUSES.has(status)`. Widening it
|
|
642
|
-
// would reject cost-less `succeeded`/`canceled`/`expired` replies, which are
|
|
643
|
-
// legitimate outcomes the server really does emit without a price
|
|
644
|
-
// (`snapshotFromWorkflow` omits `cost` on any non-numeric total). That
|
|
645
|
-
// WIDENING is invisible to a mutation sweep that only deletes clauses, so
|
|
646
|
-
// all three statuses are pinned by their own fixtures in the test file.
|
|
647
|
-
if (snapshot.status === 'failed' && typeof snapshot.cost?.total !== 'number') {
|
|
648
|
-
// 🔴 WHICH ARM: the host stamps its own synthesised failures with the
|
|
649
|
-
// `'failed'` sentinel, while a server-built reply carries `workflow.id`
|
|
650
|
-
// (or the `'whatif'` sentinel).
|
|
651
|
-
// Anything unrecognised falls to `'workflow-failed'`, the arm that assumes
|
|
652
|
-
// money MAY be committed — an unknown id must never buy the reassuring
|
|
653
|
-
// "nothing was charged" reading.
|
|
654
|
-
throw new WorkflowSubmitError(snapshot, snapshot.workflowId === HOST_SYNTHESISED_WORKFLOW_ID ? 'exception' : 'workflow-failed');
|
|
655
|
-
}
|
|
656
|
-
setStatus(TERMINAL_STATUSES.has(snapshot.status) ? 'done' : 'polling');
|
|
657
|
-
return snapshot;
|
|
735
|
+
return await withConsentRetry(getTransport(), WORKFLOW_SCOPES, () => submitOnce(body, idempotencyKey), options,
|
|
736
|
+
// Rule 3 in the time axis — see `mountedRef` above for why this hook
|
|
737
|
+
// needs it even though it has no `AbortController`.
|
|
738
|
+
() => mountedRef.current);
|
|
658
739
|
}
|
|
659
740
|
catch (err) {
|
|
660
741
|
setError(err);
|
|
661
742
|
setStatus('error');
|
|
662
743
|
throw err;
|
|
663
744
|
}
|
|
664
|
-
}, []);
|
|
745
|
+
}, [submitOnce]);
|
|
665
746
|
/**
|
|
666
747
|
* ONE poll round-trip, with an optional long-poll hint. The single place that
|
|
667
748
|
* builds a `POLL_WORKFLOW` message, so `poll` and `watch` cannot drift on the
|
|
@@ -1,14 +1,40 @@
|
|
|
1
1
|
import type { BlockCheckpointInfo } from '@civitai/app-sdk/blocks';
|
|
2
2
|
/** What {@link useCheckpointPicker} returns. */
|
|
3
3
|
export interface UseCheckpointPicker {
|
|
4
|
-
open: (opts
|
|
4
|
+
open: (opts?: {
|
|
5
5
|
/**
|
|
6
|
-
*
|
|
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
|
/**
|