@civitai/blocks-react 0.48.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +109 -6
  2. package/dist/hooks/SfwGate.d.ts +16 -9
  3. package/dist/hooks/SfwGate.d.ts.map +1 -1
  4. package/dist/hooks/SfwGate.js +14 -8
  5. package/dist/hooks/SfwGate.js.map +1 -1
  6. package/dist/hooks/useCreatePostFromApp.d.ts +144 -0
  7. package/dist/hooks/useCreatePostFromApp.d.ts.map +1 -0
  8. package/dist/hooks/useCreatePostFromApp.js +213 -0
  9. package/dist/hooks/useCreatePostFromApp.js.map +1 -0
  10. package/dist/hooks/useDomainMaturity.d.ts +57 -17
  11. package/dist/hooks/useDomainMaturity.d.ts.map +1 -1
  12. package/dist/hooks/useDomainMaturity.js +37 -11
  13. package/dist/hooks/useDomainMaturity.js.map +1 -1
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +1 -0
  17. package/dist/index.js.map +1 -1
  18. package/dist/internal/liveHost.d.ts.map +1 -1
  19. package/dist/internal/liveHost.js +36 -0
  20. package/dist/internal/liveHost.js.map +1 -1
  21. package/dist/internal/mockHost.d.ts +48 -2
  22. package/dist/internal/mockHost.d.ts.map +1 -1
  23. package/dist/internal/mockHost.js +67 -0
  24. package/dist/internal/mockHost.js.map +1 -1
  25. package/dist/internal/requestTimeouts.d.ts.map +1 -1
  26. package/dist/internal/requestTimeouts.js +9 -0
  27. package/dist/internal/requestTimeouts.js.map +1 -1
  28. package/dist/internal/transport.d.ts +9 -1
  29. package/dist/internal/transport.d.ts.map +1 -1
  30. package/dist/internal/transport.js +1 -0
  31. package/dist/internal/transport.js.map +1 -1
  32. package/dist/internal/validate.d.ts +23 -0
  33. package/dist/internal/validate.d.ts.map +1 -1
  34. package/dist/internal/validate.js +74 -0
  35. package/dist/internal/validate.js.map +1 -1
  36. package/package.json +4 -4
package/README.md CHANGED
@@ -570,6 +570,80 @@ async function toggle() {
570
570
  Most blocks want `<FollowButton>` from `@civitai/blocks-react/ui` instead, which
571
571
  wires all of the above.
572
572
 
573
+ ### `useCreatePostFromApp()`
574
+
575
+ Publish a **real, published Post on the viewer's profile** from this app's own
576
+ outputs, host-mediated over `CREATE_POST_FROM_APP`. Returns
577
+ `{ createPost, pending, error }`.
578
+
579
+ The strictly-more-consequential sibling of `usePublishGenerationOutputs()`: that
580
+ one makes a bare `Image` row with no post, no feed presence, no reward and no
581
+ notification; this one makes **public, feed-visible, reward-earning content under
582
+ the viewer's byline**.
583
+
584
+ 🔴 **Requires the `posts:write:self` scope**, which is **sensitive** and
585
+ **consent-gated**. Declare it in your manifest *with* a `scopeJustifications`
586
+ entry — the server rejects the manifest at submit without one — and expect the
587
+ viewer to be prompted to grant it before the first call succeeds.
588
+
589
+ 🔴 **The grant is not the consent.** Every call opens a host-chrome confirm, and
590
+ what it shows is the **server's** resolution of your request, never your strings:
591
+ the tag names that will *actually* be applied, host-fetched model and version
592
+ names for a gallery attach, and real thumbnails. A block cannot show one post and
593
+ publish another.
594
+
595
+ 🔴 **No arm of `sources` takes a URL.** Name a workflow from this app's own
596
+ subqueue plus indexes into its outputs, or `Image` ids from a previous
597
+ `usePublishGenerationOutputs()` publish. The server re-verifies both — ownership,
598
+ this app's provenance marker, and that the image is not already in a post.
599
+
600
+ ⚠️ **Posting a published image removes it from this app's own grid.** The
601
+ app-scoped read behind `useGatedImages()` is conjoined with `postId IS NULL`, so
602
+ an image that joins a post stops resolving there. An app cannot both keep an
603
+ image in its shared grid and let the viewer post it — design around it.
604
+
605
+ Text is advisory: the server bounds `title`/`detail`, screens them, refuses a
606
+ `detail` containing a link, and resolves `tags` against **existing** tags only (a
607
+ name matching no tag is dropped, never minted, and is shown to the viewer on the
608
+ confirm).
609
+
610
+ `createPost` **rejects** with a `CreatePostError` on every non-success:
611
+
612
+ | | meaning | what to do |
613
+ |---|---|---|
614
+ | `err.declined` | the viewer dismissed the confirm — **no post was created** | revert, say nothing |
615
+ | `err.signInRequired` | no session | route into `useRequestSignIn()` |
616
+ | `err.timedOut` | no reply arrived within the 10-min consent bound | 🔴 **check this BEFORE `.message`** — it also has no `.code`, and its message is an SDK-internal string. It does **not** mean nothing happened; tell the viewer to check their profile and never retry automatically |
617
+ | `err.code` set otherwise | a host refusal (`review-mode` / `block is not ready` / `no images to post` / `no block token`) | show or ignore per case |
618
+ | `err.code === undefined` **and** `!err.timedOut` | a **server** message the host forwarded verbatim (rate limit, blocked title, refused gallery attach) | show `err.message` |
619
+
620
+ ```tsx
621
+ const { createPost, pending } = useCreatePostFromApp();
622
+ const { requestSignIn } = useRequestSignIn();
623
+
624
+ async function share() {
625
+ try {
626
+ const post = await createPost({
627
+ sources: [{ kind: 'workflow', workflowId: w.workflowId, imageIndexes: [0, 2] }],
628
+ title: 'Made with Sticker Studio',
629
+ });
630
+ showToast(`Posted! ${post.url}`);
631
+ } catch (err) {
632
+ if (err instanceof CreatePostError) {
633
+ if (err.signInRequired) return requestSignIn();
634
+ if (err.declined) return; // the viewer said no — say nothing
635
+ if (err.timedOut) return showToast('Still working — check your profile.');
636
+ showToast(err.message); // a real server message, safe to render
637
+ }
638
+ }
639
+ }
640
+ ```
641
+
642
+ In `dev:mock` the `createPostResult` / `createPostError` scenario knobs drive
643
+ both arms (including `declined`). **`dev:live` refuses this bridge on purpose** —
644
+ it has no civitai chrome to render the server-resolved confirm in, and driving
645
+ the write without it would let dev prove out a flow production does not have.
646
+
573
647
  ### `useAppWorkflows(params?)`
574
648
 
575
649
  The calling app's **own** generator subqueue — the tag-scoped list of generations
@@ -830,20 +904,48 @@ any request for a scope your dev token lacks produces one.
830
904
 
831
905
  ### `useDomainMaturity()`
832
906
 
833
- Read the surrounding color-domain's maturity ceiling (civitai #2670) so a block
834
- can hide/blur mature affordances on a SFW domain. **Fail-closed SFW** until
835
- `BLOCK_INIT` lands or against a host that predates the field.
907
+ Read the maturity ceiling in force for the current viewer, so a block can
908
+ hide/blur mature affordances. **Fail-closed SFW** until `BLOCK_INIT` lands or
909
+ against a host that projects no ceiling.
836
910
 
837
911
  ```tsx
838
912
  const { isSfw, isLevelAllowed } = useDomainMaturity();
839
913
  const showRSlider = isLevelAllowed(BrowsingLevel.R); // false on a SFW domain
840
914
  ```
841
915
 
916
+ The gates account for **two** things, and the distinction matters:
917
+
918
+ | field | answers |
919
+ | --- | --- |
920
+ | `maxBrowsingLevel` | what this **domain** permits anybody (identical for every viewer on it) |
921
+ | `effectiveBrowsingLevel` | what **this viewer** may be shown here — the domain ceiling narrowed by their own NSFW setting |
922
+
923
+ `isSfw` / `isLevelAllowed` gate on the second, so a viewer who turned NSFW off
924
+ sees SFW affordances even on a mature domain. `effectiveBrowsingLevel` is always
925
+ a subset of `maxBrowsingLevel`, so reading these can only ever show the viewer
926
+ **less** — never more. Compare the two when you want to explain *why* something
927
+ is hidden:
928
+
929
+ ```tsx
930
+ const { maxBrowsingLevel, effectiveBrowsingLevel } = useDomainMaturity();
931
+ const hiddenByYourSettings = effectiveBrowsingLevel !== maxBrowsingLevel;
932
+ ```
933
+
934
+ The hook's name is historic — it shipped when the domain ceiling was the only
935
+ signal. There is deliberately no separate viewer-maturity hook: two hooks would
936
+ mean two answers to "may I show this", and the one named for the domain would be
937
+ the wider of the pair.
938
+
939
+ Drive it locally with `createMockHost({ domain: 'red', viewerBrowsingLevel: BrowsingLevel.PG })`.
940
+ The mock clamps that option to its own ceiling exactly as the real host does, so
941
+ you cannot test against a viewer wider than the domain — production cannot
942
+ produce one either.
943
+
842
944
  ### `SfwGate`
843
945
 
844
- Convenience component that renders `children` only when the domain permits the
845
- maturity — no `level` prop gates on the SFW ceiling, a `level` prop gates on that
846
- browsing-level bit. Fail-closed SFW.
946
+ Convenience component that renders `children` only when the current viewer may be
947
+ shown them — no `level` prop gates on `isSfw`, a `level` prop gates on that
948
+ browsing-level bit. Both account for the domain **and** the viewer. Fail-closed SFW.
847
949
 
848
950
  ```tsx
849
951
  function MatureSection() {
@@ -1039,6 +1141,7 @@ Runnable, minimal blocks — one per feature, each with its own README:
1039
1141
 
1040
1142
  | `@civitai/blocks-react` | pairs with `@civitai/app-sdk` | adds |
1041
1143
  |---|---|---|
1144
+ | `0.50.x` | `^0.40.0` | `useCreatePostFromApp()` (`CREATE_POST_FROM_APP`) + the `posts:write:self` scope. 🔴 Same wide `peerDependencies` floor as the row below, so **npm will not warn you**: pairing this with an SDK below `0.40.0` fails at `tsc` with `Cannot find name 'BlockCreatePostHostError'`, not at install. |
1042
1145
  | `0.48.x` | `^0.38.0` | `useCollectionFollow()` + `<FollowButton>` / `<TipButton>` (`SET_COLLECTION_FOLLOW`). 🔴 The `peerDependencies` floor stays the deliberately-wide `>=0.29.0 <1.0.0` (#206 — a per-minor floor forced a major on consumers), so **npm will not warn you**: pairing this with an SDK below `0.38.0` fails at `tsc` with `Cannot find name 'BlockCollectionFollowErrorCode'`, not at install. |
1043
1146
  | `0.36.x` | `^0.27.0` | auto-installs the SDK's opaque-origin web-storage shim (`@civitai/app-sdk/safe-storage`) on import |
1044
1147
  | `0.29.x` | `^0.24.0` | `useAppWorkflows()` — app generator subqueue read + cancel (`QUERY_APP_WORKFLOWS` / `CANCEL_APP_WORKFLOW`) |
@@ -8,25 +8,32 @@ export interface SfwGateProps {
8
8
  /**
9
9
  * When set, gate on `isLevelAllowed(level)` (a single `BrowsingLevel` bit)
10
10
  * instead of the coarse `isSfw`. Lets a block reveal a level-specific
11
- * affordance (e.g. an R-rated toggle) only when the domain permits that level.
11
+ * affordance (e.g. an R-rated toggle) only when that level is permitted —
12
+ * by the domain AND by the viewer's own browsing-level setting.
12
13
  */
13
14
  level?: number;
14
15
  /** Rendered when the gate is closed. Defaults to `null` (render nothing). */
15
16
  fallback?: ReactNode;
16
17
  }
17
18
  /**
18
- * Convenience wrapper that renders `children` only when the surrounding
19
- * color-domain permits it, else `fallback` — so a block can hide/blur mature
20
- * affordances on a SFW domain without wiring {@link useDomainMaturity} by hand.
19
+ * Convenience wrapper that renders `children` only when the current viewer may
20
+ * be shown them here, else `fallback` — so a block can hide/blur mature
21
+ * affordances without wiring {@link useDomainMaturity} by hand.
21
22
  *
22
- * Gating:
23
- * - no `level` prop → renders `children` when the domain is SFW (`isSfw`).
23
+ * Gating (both delegate to the hook, so both account for the DOMAIN's ceiling
24
+ * AND the VIEWER's own browsing level — see `effectiveBrowsingLevel`):
25
+ * - no `level` prop → renders `children` when nothing mature may be shown
26
+ * (`isSfw`).
24
27
  * - `level` prop set → renders `children` when that browsing-level bit is
25
- * allowed by the domain ceiling (`isLevelAllowed(level)`).
28
+ * permitted (`isLevelAllowed(level)`).
29
+ *
30
+ * 🔴 A red-domain viewer who turned NSFW off closes this gate. That is the
31
+ * point: before the per-viewer ceiling existed, the gate opened for everyone on
32
+ * a mature domain regardless of their own setting.
26
33
  *
27
34
  * **Fail-closed SFW**: before `BLOCK_INIT` lands, and against a host that
28
- * predates civitai #2670 (no ceiling field), the gate is treated as SFW —
29
- * `children` show only for SFW content, mature content shows `fallback`.
35
+ * projects no ceiling at all, the gate is treated as SFW — `children` show only
36
+ * for SFW content, mature content shows `fallback`.
30
37
  *
31
38
  * @example
32
39
  * // Hide a mature-only carousel on a SFW domain:
@@ -1 +1 @@
1
- {"version":3,"file":"SfwGate.d.ts","sourceRoot":"","sources":["../../src/hooks/SfwGate.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAIvC;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,+EAA+E;IAC/E,QAAQ,EAAE,SAAS,CAAC;IACpB;;;;OAIG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,SAAS,CAAC;CACtB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,OAAO,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAe,EAAE,EAAE,YAAY,GAAG,SAAS,CAIrF"}
1
+ {"version":3,"file":"SfwGate.d.ts","sourceRoot":"","sources":["../../src/hooks/SfwGate.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAIvC;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,+EAA+E;IAC/E,QAAQ,EAAE,SAAS,CAAC;IACpB;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,SAAS,CAAC;CACtB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,OAAO,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAe,EAAE,EAAE,YAAY,GAAG,SAAS,CAIrF"}
@@ -1,17 +1,23 @@
1
1
  import { useDomainMaturity } from './useDomainMaturity.js';
2
2
  /**
3
- * Convenience wrapper that renders `children` only when the surrounding
4
- * color-domain permits it, else `fallback` — so a block can hide/blur mature
5
- * affordances on a SFW domain without wiring {@link useDomainMaturity} by hand.
3
+ * Convenience wrapper that renders `children` only when the current viewer may
4
+ * be shown them here, else `fallback` — so a block can hide/blur mature
5
+ * affordances without wiring {@link useDomainMaturity} by hand.
6
6
  *
7
- * Gating:
8
- * - no `level` prop → renders `children` when the domain is SFW (`isSfw`).
7
+ * Gating (both delegate to the hook, so both account for the DOMAIN's ceiling
8
+ * AND the VIEWER's own browsing level — see `effectiveBrowsingLevel`):
9
+ * - no `level` prop → renders `children` when nothing mature may be shown
10
+ * (`isSfw`).
9
11
  * - `level` prop set → renders `children` when that browsing-level bit is
10
- * allowed by the domain ceiling (`isLevelAllowed(level)`).
12
+ * permitted (`isLevelAllowed(level)`).
13
+ *
14
+ * 🔴 A red-domain viewer who turned NSFW off closes this gate. That is the
15
+ * point: before the per-viewer ceiling existed, the gate opened for everyone on
16
+ * a mature domain regardless of their own setting.
11
17
  *
12
18
  * **Fail-closed SFW**: before `BLOCK_INIT` lands, and against a host that
13
- * predates civitai #2670 (no ceiling field), the gate is treated as SFW —
14
- * `children` show only for SFW content, mature content shows `fallback`.
19
+ * projects no ceiling at all, the gate is treated as SFW — `children` show only
20
+ * for SFW content, mature content shows `fallback`.
15
21
  *
16
22
  * @example
17
23
  * // Hide a mature-only carousel on a SFW domain:
@@ -1 +1 @@
1
- {"version":3,"file":"SfwGate.js","sourceRoot":"","sources":["../../src/hooks/SfwGate.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAkB3D;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,UAAU,OAAO,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,GAAG,IAAI,EAAgB;IACxE,MAAM,EAAE,KAAK,EAAE,cAAc,EAAE,GAAG,iBAAiB,EAAE,CAAC;IACtD,MAAM,IAAI,GAAG,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC;IACjE,OAAO,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC;AACpC,CAAC"}
1
+ {"version":3,"file":"SfwGate.js","sourceRoot":"","sources":["../../src/hooks/SfwGate.tsx"],"names":[],"mappings":"AAEA,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAC;AAmB3D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,MAAM,UAAU,OAAO,CAAC,EAAE,QAAQ,EAAE,KAAK,EAAE,QAAQ,GAAG,IAAI,EAAgB;IACxE,MAAM,EAAE,KAAK,EAAE,cAAc,EAAE,GAAG,iBAAiB,EAAE,CAAC;IACtD,MAAM,IAAI,GAAG,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC;IACjE,OAAO,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC;AACpC,CAAC"}
@@ -0,0 +1,144 @@
1
+ import type { BlockCreatePostHostError, BlockCreatePostRequest, BlockCreatePostResult, BlockPostSource } from '@civitai/app-sdk/blocks';
2
+ export type { BlockCreatePostHostError, BlockCreatePostRequest, BlockCreatePostResult, BlockPostSource, };
3
+ /**
4
+ * The closed set of HOST refusal codes, as a runtime Set.
5
+ *
6
+ * 🔴 THIS EXISTS BECAUSE THE ERROR CHANNEL IS NOT AN ENUM. The host sends either
7
+ * one of these codes or a free-text server message (a rate limit, a blocked
8
+ * title, a refused gallery attach), so "is this a code?" is a MEMBERSHIP
9
+ * question at runtime, not a type-level one — a `switch` over the union type
10
+ * would silently treat `"You do not have permission…"` as unmatched prose while
11
+ * a typo'd literal compiled fine. Derived from the array below so the two cannot
12
+ * drift, and mirrored from civitai/civitai's `CREATE_POST_HOST_ERRORS`.
13
+ */
14
+ export declare const CREATE_POST_ERROR_CODES: readonly ["review-mode", "block is not ready", "sign in to post", "no images to post", "no block token", "declined"];
15
+ /**
16
+ * `true` when `error` is one of the host's CLOSED refusal codes rather than a
17
+ * free-text server message.
18
+ *
19
+ * Use it before comparing against a code — see {@link CREATE_POST_ERROR_CODES}
20
+ * for why equality alone is not enough.
21
+ */
22
+ export declare function isCreatePostErrorCode(error: string): error is BlockCreatePostHostError;
23
+ /**
24
+ * A post-creation failure.
25
+ *
26
+ * `.code` is the host's refusal code when the host refused, and `undefined`
27
+ * otherwise.
28
+ *
29
+ * 🔴 `.code === undefined` IS NOT BY ITSELF "A SERVER MESSAGE WORTH SHOWING".
30
+ * TWO different failures land there: a server message the host forwarded
31
+ * verbatim, and a TRANSPORT TIMEOUT whose `.message` is an SDK-internal string.
32
+ * **Check `.timedOut` first**; `.code === undefined && !timedOut` is the branch
33
+ * whose `.message` is meant to be rendered.
34
+ */
35
+ export declare class CreatePostError extends Error {
36
+ /** The closed host refusal code, or `undefined` for a server/transport error. */
37
+ readonly code?: BlockCreatePostHostError;
38
+ /**
39
+ * The SDK transport gave up waiting — no reply ever arrived.
40
+ *
41
+ * 🔴 CHECK THIS BEFORE SHOWING `.message`, and 🔴 DO NOT PRESENT IT AS
42
+ * "NOTHING HAPPENED". A timeout also has `code === undefined`, so "no code ⇒ a
43
+ * server message worth rendering" is false and acting on it puts an
44
+ * SDK-internal string in front of a viewer. More importantly the write may
45
+ * have LANDED and only the reply failed to arrive — and here the write is a
46
+ * PUBLIC POST under the viewer's name. Tell the viewer to check their profile;
47
+ * never retry automatically, which is how a duplicate post happens.
48
+ */
49
+ readonly timedOut: boolean;
50
+ /**
51
+ * The viewer DISMISSED the host's consent confirm, so NO POST WAS CREATED.
52
+ *
53
+ * 🔴 Not an error condition to shout about, and it is trustworthy in the one
54
+ * direction that matters: the host takes its consent latch SYNCHRONOUSLY
55
+ * before the write, so `declined` can never be reported for a post that
56
+ * landed. Revert optimistic state and render nothing.
57
+ */
58
+ readonly declined: boolean;
59
+ /**
60
+ * There is no session. Route this into `useRequestSignIn()` rather than
61
+ * showing an error — the viewer's next step is signing in, not retrying.
62
+ */
63
+ readonly signInRequired: boolean;
64
+ constructor(error: string, opts?: {
65
+ timedOut?: boolean;
66
+ });
67
+ }
68
+ /** What {@link useCreatePostFromApp} returns. */
69
+ export interface UseCreatePostFromApp {
70
+ /**
71
+ * Ask the host to publish a REAL Post on the viewer's profile from this app's
72
+ * OWN outputs, and resolve with the created post.
73
+ *
74
+ * REJECTS with a {@link CreatePostError} on every non-success — including
75
+ * `declined`, which means the viewer dismissed the confirm and NO POST EXISTS.
76
+ * Check `.declined` before rendering a failure.
77
+ */
78
+ createPost: (args: BlockCreatePostRequest) => Promise<BlockCreatePostResult>;
79
+ /** `true` while a request is in flight (including the viewer's confirm). */
80
+ pending: boolean;
81
+ /**
82
+ * The last request's failure, or `null`. Cleared at the start of the next
83
+ * `createPost`. A dismissal lands here too — read `.declined`.
84
+ */
85
+ error: CreatePostError | null;
86
+ }
87
+ /**
88
+ * Publish a REAL, PUBLISHED Post on the VIEWER'S profile from the calling app's
89
+ * OWN outputs, through the host-mediated `CREATE_POST_FROM_APP` →
90
+ * `CREATE_POST_RESULT` bridge.
91
+ *
92
+ * The strictly-more-consequential sibling of `usePublishGenerationOutputs()`:
93
+ * that one makes a bare `Image` row with no post, no feed presence, no reward
94
+ * and no notification; this one makes public, feed-visible, reward-earning
95
+ * content under the viewer's byline.
96
+ *
97
+ * 🔴 REQUIRES THE `posts:write:self` SCOPE, which is SENSITIVE and
98
+ * CONSENT-GATED. Declare it in your manifest WITH a `scopeJustifications` entry
99
+ * — the server rejects the manifest at submit without one — and expect the
100
+ * viewer to be prompted to grant it before the first call succeeds.
101
+ *
102
+ * 🔴 THE SCOPE GRANT IS NOT THE CONSENT. Every call additionally opens a
103
+ * host-chrome confirm, and what that confirm shows is the SERVER'S resolution of
104
+ * your request, never your strings: the tag names that will ACTUALLY be applied,
105
+ * host-fetched model and version names for a gallery attach, and real
106
+ * thumbnails. A block cannot show one post and publish another.
107
+ *
108
+ * 🔴 NO ARM OF `sources` TAKES A URL. Name a workflow from this app's own
109
+ * subqueue plus indexes into its outputs, or `Image` ids from a previous
110
+ * `usePublishGenerationOutputs()` publish. The server re-verifies both.
111
+ *
112
+ * ⚠️ POSTING A PUBLISHED IMAGE REMOVES IT FROM THIS APP'S OWN GRID. The
113
+ * app-scoped read behind `useGatedImages()` is conjoined with `postId IS NULL`,
114
+ * so an image that joins a post stops resolving there. An app cannot both keep
115
+ * an image in its shared grid and let the viewer post it.
116
+ *
117
+ * Because the reply waits on a person, the request carries
118
+ * {@link HUMAN_INTERACTION_TIMEOUT_MS} (10 min) rather than the ~30s protocol
119
+ * default — it resolves the instant the viewer acts, and the ceiling only bounds
120
+ * an abandoned dialog.
121
+ *
122
+ * @example
123
+ * const { createPost, pending } = useCreatePostFromApp();
124
+ * const { requestSignIn } = useRequestSignIn();
125
+ *
126
+ * const share = async () => {
127
+ * try {
128
+ * const post = await createPost({
129
+ * sources: [{ kind: 'workflow', workflowId: w.workflowId, imageIndexes: [0, 2] }],
130
+ * title: 'Made with Sticker Studio',
131
+ * });
132
+ * showToast(`Posted! ${post.url}`);
133
+ * } catch (e) {
134
+ * if (e instanceof CreatePostError) {
135
+ * if (e.signInRequired) return requestSignIn();
136
+ * if (e.declined) return; // the viewer said no — say nothing
137
+ * if (e.timedOut) return showToast('Still working — check your profile.');
138
+ * showToast(e.message); // a real server message, safe to render
139
+ * }
140
+ * }
141
+ * };
142
+ */
143
+ export declare function useCreatePostFromApp(): UseCreatePostFromApp;
144
+ //# sourceMappingURL=useCreatePostFromApp.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useCreatePostFromApp.d.ts","sourceRoot":"","sources":["../../src/hooks/useCreatePostFromApp.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,wBAAwB,EACxB,sBAAsB,EACtB,qBAAqB,EACrB,eAAe,EAChB,MAAM,yBAAyB,CAAC;AAMjC,YAAY,EACV,wBAAwB,EACxB,sBAAsB,EACtB,qBAAqB,EACrB,eAAe,GAChB,CAAC;AAEF;;;;;;;;;;GAUG;AACH,eAAO,MAAM,uBAAuB,sHAOoB,CAAC;AAIzD;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,MAAM,GAAG,KAAK,IAAI,wBAAwB,CAEtF;AAED;;;;;;;;;;;GAWG;AACH,qBAAa,eAAgB,SAAQ,KAAK;IACxC,iFAAiF;IACjF,QAAQ,CAAC,IAAI,CAAC,EAAE,wBAAwB,CAAC;IACzC;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B;;;;;;;OAOG;IACH,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B;;;OAGG;IACH,QAAQ,CAAC,cAAc,EAAE,OAAO,CAAC;gBAErB,KAAK,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE;QAAE,QAAQ,CAAC,EAAE,OAAO,CAAA;KAAE;CAQzD;AAED,iDAAiD;AACjD,MAAM,WAAW,oBAAoB;IACnC;;;;;;;OAOG;IACH,UAAU,EAAE,CAAC,IAAI,EAAE,sBAAsB,KAAK,OAAO,CAAC,qBAAqB,CAAC,CAAC;IAC7E,4EAA4E;IAC5E,OAAO,EAAE,OAAO,CAAC;IACjB;;;OAGG;IACH,KAAK,EAAE,eAAe,GAAG,IAAI,CAAC;CAC/B;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AACH,wBAAgB,oBAAoB,IAAI,oBAAoB,CA6E3D"}
@@ -0,0 +1,213 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { HUMAN_INTERACTION_TIMEOUT_MS } from '../internal/requestTimeouts.js';
3
+ import { getTransport } from '../internal/singleton.js';
4
+ import { RequestTimeoutError, sendTypedRequest } from '../internal/transport.js';
5
+ /**
6
+ * The closed set of HOST refusal codes, as a runtime Set.
7
+ *
8
+ * 🔴 THIS EXISTS BECAUSE THE ERROR CHANNEL IS NOT AN ENUM. The host sends either
9
+ * one of these codes or a free-text server message (a rate limit, a blocked
10
+ * title, a refused gallery attach), so "is this a code?" is a MEMBERSHIP
11
+ * question at runtime, not a type-level one — a `switch` over the union type
12
+ * would silently treat `"You do not have permission…"` as unmatched prose while
13
+ * a typo'd literal compiled fine. Derived from the array below so the two cannot
14
+ * drift, and mirrored from civitai/civitai's `CREATE_POST_HOST_ERRORS`.
15
+ */
16
+ export const CREATE_POST_ERROR_CODES = [
17
+ 'review-mode',
18
+ 'block is not ready',
19
+ 'sign in to post',
20
+ 'no images to post',
21
+ 'no block token',
22
+ 'declined',
23
+ ];
24
+ const CODE_SET = new Set(CREATE_POST_ERROR_CODES);
25
+ /**
26
+ * `true` when `error` is one of the host's CLOSED refusal codes rather than a
27
+ * free-text server message.
28
+ *
29
+ * Use it before comparing against a code — see {@link CREATE_POST_ERROR_CODES}
30
+ * for why equality alone is not enough.
31
+ */
32
+ export function isCreatePostErrorCode(error) {
33
+ return CODE_SET.has(error);
34
+ }
35
+ /**
36
+ * A post-creation failure.
37
+ *
38
+ * `.code` is the host's refusal code when the host refused, and `undefined`
39
+ * otherwise.
40
+ *
41
+ * 🔴 `.code === undefined` IS NOT BY ITSELF "A SERVER MESSAGE WORTH SHOWING".
42
+ * TWO different failures land there: a server message the host forwarded
43
+ * verbatim, and a TRANSPORT TIMEOUT whose `.message` is an SDK-internal string.
44
+ * **Check `.timedOut` first**; `.code === undefined && !timedOut` is the branch
45
+ * whose `.message` is meant to be rendered.
46
+ */
47
+ export class CreatePostError extends Error {
48
+ /** The closed host refusal code, or `undefined` for a server/transport error. */
49
+ code;
50
+ /**
51
+ * The SDK transport gave up waiting — no reply ever arrived.
52
+ *
53
+ * 🔴 CHECK THIS BEFORE SHOWING `.message`, and 🔴 DO NOT PRESENT IT AS
54
+ * "NOTHING HAPPENED". A timeout also has `code === undefined`, so "no code ⇒ a
55
+ * server message worth rendering" is false and acting on it puts an
56
+ * SDK-internal string in front of a viewer. More importantly the write may
57
+ * have LANDED and only the reply failed to arrive — and here the write is a
58
+ * PUBLIC POST under the viewer's name. Tell the viewer to check their profile;
59
+ * never retry automatically, which is how a duplicate post happens.
60
+ */
61
+ timedOut;
62
+ /**
63
+ * The viewer DISMISSED the host's consent confirm, so NO POST WAS CREATED.
64
+ *
65
+ * 🔴 Not an error condition to shout about, and it is trustworthy in the one
66
+ * direction that matters: the host takes its consent latch SYNCHRONOUSLY
67
+ * before the write, so `declined` can never be reported for a post that
68
+ * landed. Revert optimistic state and render nothing.
69
+ */
70
+ declined;
71
+ /**
72
+ * There is no session. Route this into `useRequestSignIn()` rather than
73
+ * showing an error — the viewer's next step is signing in, not retrying.
74
+ */
75
+ signInRequired;
76
+ constructor(error, opts) {
77
+ super(error);
78
+ this.name = 'CreatePostError';
79
+ this.timedOut = opts?.timedOut === true;
80
+ if (isCreatePostErrorCode(error))
81
+ this.code = error;
82
+ this.declined = error === 'declined';
83
+ this.signInRequired = error === 'sign in to post';
84
+ }
85
+ }
86
+ /**
87
+ * Publish a REAL, PUBLISHED Post on the VIEWER'S profile from the calling app's
88
+ * OWN outputs, through the host-mediated `CREATE_POST_FROM_APP` →
89
+ * `CREATE_POST_RESULT` bridge.
90
+ *
91
+ * The strictly-more-consequential sibling of `usePublishGenerationOutputs()`:
92
+ * that one makes a bare `Image` row with no post, no feed presence, no reward
93
+ * and no notification; this one makes public, feed-visible, reward-earning
94
+ * content under the viewer's byline.
95
+ *
96
+ * 🔴 REQUIRES THE `posts:write:self` SCOPE, which is SENSITIVE and
97
+ * CONSENT-GATED. Declare it in your manifest WITH a `scopeJustifications` entry
98
+ * — the server rejects the manifest at submit without one — and expect the
99
+ * viewer to be prompted to grant it before the first call succeeds.
100
+ *
101
+ * 🔴 THE SCOPE GRANT IS NOT THE CONSENT. Every call additionally opens a
102
+ * host-chrome confirm, and what that confirm shows is the SERVER'S resolution of
103
+ * your request, never your strings: the tag names that will ACTUALLY be applied,
104
+ * host-fetched model and version names for a gallery attach, and real
105
+ * thumbnails. A block cannot show one post and publish another.
106
+ *
107
+ * 🔴 NO ARM OF `sources` TAKES A URL. Name a workflow from this app's own
108
+ * subqueue plus indexes into its outputs, or `Image` ids from a previous
109
+ * `usePublishGenerationOutputs()` publish. The server re-verifies both.
110
+ *
111
+ * ⚠️ POSTING A PUBLISHED IMAGE REMOVES IT FROM THIS APP'S OWN GRID. The
112
+ * app-scoped read behind `useGatedImages()` is conjoined with `postId IS NULL`,
113
+ * so an image that joins a post stops resolving there. An app cannot both keep
114
+ * an image in its shared grid and let the viewer post it.
115
+ *
116
+ * Because the reply waits on a person, the request carries
117
+ * {@link HUMAN_INTERACTION_TIMEOUT_MS} (10 min) rather than the ~30s protocol
118
+ * default — it resolves the instant the viewer acts, and the ceiling only bounds
119
+ * an abandoned dialog.
120
+ *
121
+ * @example
122
+ * const { createPost, pending } = useCreatePostFromApp();
123
+ * const { requestSignIn } = useRequestSignIn();
124
+ *
125
+ * const share = async () => {
126
+ * try {
127
+ * const post = await createPost({
128
+ * sources: [{ kind: 'workflow', workflowId: w.workflowId, imageIndexes: [0, 2] }],
129
+ * title: 'Made with Sticker Studio',
130
+ * });
131
+ * showToast(`Posted! ${post.url}`);
132
+ * } catch (e) {
133
+ * if (e instanceof CreatePostError) {
134
+ * if (e.signInRequired) return requestSignIn();
135
+ * if (e.declined) return; // the viewer said no — say nothing
136
+ * if (e.timedOut) return showToast('Still working — check your profile.');
137
+ * showToast(e.message); // a real server message, safe to render
138
+ * }
139
+ * }
140
+ * };
141
+ */
142
+ export function useCreatePostFromApp() {
143
+ const [pending, setPending] = useState(false);
144
+ const [error, setError] = useState(null);
145
+ // Same guard as `useCollectionFollow`: this request can outlive the component
146
+ // by up to ten minutes (the viewer may leave the confirm open), so a `pending`
147
+ // toggled on an unmounted control is state nobody can clear.
148
+ const mountedRef = useRef(true);
149
+ useEffect(() => {
150
+ mountedRef.current = true;
151
+ return () => {
152
+ mountedRef.current = false;
153
+ };
154
+ }, []);
155
+ const createPost = useCallback(async (args) => {
156
+ if (mountedRef.current) {
157
+ setPending(true);
158
+ setError(null);
159
+ }
160
+ try {
161
+ const reply = await sendTypedRequest(getTransport(), {
162
+ type: 'CREATE_POST_FROM_APP',
163
+ payload: {
164
+ sources: args.sources,
165
+ ...(args.title !== undefined ? { title: args.title } : {}),
166
+ ...(args.detail !== undefined ? { detail: args.detail } : {}),
167
+ ...(args.tags !== undefined ? { tags: args.tags } : {}),
168
+ ...(args.modelVersionId !== undefined
169
+ ? { modelVersionId: args.modelVersionId }
170
+ : {}),
171
+ },
172
+ }, 'CREATE_POST_RESULT',
173
+ // See the `'human'` bucketing in `internal/requestTimeouts.ts`: the
174
+ // host answers only when the viewer clicks or dismisses its confirm.
175
+ { timeoutMs: HUMAN_INTERACTION_TIMEOUT_MS });
176
+ if (reply.error || !reply.result) {
177
+ // 🔴 `||`, NOT `??`. `isValidCreatePostResult` gates `error` on SHAPE
178
+ // only (it has to: the channel carries free-text server messages), so
179
+ // a host `error: ''` is a VALID reply that reaches here. `??` would
180
+ // then throw an Error with an EMPTY message for a public post, which
181
+ // renders as a blank failure. Fall through to a code that at least
182
+ // names a real outcome.
183
+ throw new CreatePostError(reply.error || 'no images to post');
184
+ }
185
+ return reply.result;
186
+ }
187
+ catch (err) {
188
+ // A transport timeout arrives as a plain Error; wrap it so callers have
189
+ // ONE error type to test, with `.code` left undefined (it is not a host
190
+ // refusal). Re-wrapping our own error would lose `.code`, so pass it
191
+ // through.
192
+ const wrapped = err instanceof CreatePostError
193
+ ? 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
+ });
198
+ if (mountedRef.current)
199
+ setError(wrapped);
200
+ // 🔴 THROWN UNCONDITIONALLY, even when unmounted. The caller's `await`
201
+ // is not the component — an app that persists the result must still
202
+ // learn the write failed, and swallowing it here would make an unmount
203
+ // look like a success.
204
+ throw wrapped;
205
+ }
206
+ finally {
207
+ if (mountedRef.current)
208
+ setPending(false);
209
+ }
210
+ }, []);
211
+ return { createPost, pending, error };
212
+ }
213
+ //# sourceMappingURL=useCreatePostFromApp.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useCreatePostFromApp.js","sourceRoot":"","sources":["../../src/hooks/useCreatePostFromApp.ts"],"names":[],"mappings":"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"}