@civitai/blocks-react 0.74.1 → 0.76.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 CHANGED
@@ -675,6 +675,14 @@ showImages(done.imageUrls ?? []);
675
675
  KB (262144 bytes) for every step's `input` plus `arguments` together. An
676
676
  unknown key on the body or on a step is rejected. The app's velocity limit
677
677
  counts one unit per step, and a `repeat` step one per iteration instead.
678
+ - **No inline media** in a step `input` or in `arguments` — here or in a
679
+ pass-through `kind: 'step'` body's `input`: no `data:image|video|audio` URI
680
+ and no raw base64 string of 4096+ characters, at any depth. Upload the image,
681
+ video or audio with [`useMediaUpload()`](#usemediaupload) (an image also works
682
+ with `useImageUpload({ purpose: 'generationSource' })` or `useImagePicker()`)
683
+ and pass the returned `url`. A body holding more than
684
+ 262144 values in those fields is refused outright. `createMockHost` refuses
685
+ both as the host does, naming the path.
678
686
  - `suppressOutput: true` marks an intermediate step. Its images are left out of
679
687
  `snapshot.imageUrls` and out of its own `steps[i].imageUrls`. It still runs
680
688
  and is still charged.
@@ -1273,6 +1281,112 @@ async function toggle() {
1273
1281
  Most blocks want `<FollowButton>` from `@civitai/blocks-react/ui` instead, which
1274
1282
  wires all of the above.
1275
1283
 
1284
+ ### `useReactions(entityType, ids, { tickets })`
1285
+
1286
+ Read and set the **viewer's** reactions on images this app was served, over
1287
+ `GET_ENTITY_REACTIONS` / `SET_ENTITY_REACTION`. Returns
1288
+ `{ byId, unavailable, loading, error, toggle, pending }`.
1289
+
1290
+ It reads on mount, then reads again only the images that need it: an id added
1291
+ to `ids`, or one whose ticket changed. An id that leaves `ids` costs nothing.
1292
+ Every read counts against the block instance's catalog rate limit on the host,
1293
+ which every viewer of a page app shares with its other catalog reads.
1294
+
1295
+ A reaction made here **is** a reaction on civitai.com: it pays the reactor and
1296
+ the image owner exactly as the site's own button does, moves rankings, notifies,
1297
+ and lists the viewer among the reactors. It draws on the **same** per-viewer
1298
+ quota as the site. Apps may set `Like`, `Heart`, `Laugh` and `Cry`; never
1299
+ `Dislike`. Image is the only entity type today.
1300
+
1301
+ 🔴 **Scope `social:react:self`: consent-gated and sensitive.** Declare it with a
1302
+ `scopeJustifications` entry. Until the viewer grants it, every call fails with
1303
+ `needs-consent` (`err.needsConsent`): ask with `useRequestConsent()`. There is
1304
+ no per-tap confirm.
1305
+
1306
+ 🔴 **Tickets are required, and only the REST block endpoints supply them.** Each
1307
+ image that `/api/v1/blocks/images`, `/api/v1/blocks/gated-images` or
1308
+ `/api/v1/blocks/collections/{id}` serves, fetched with the block token for a
1309
+ signed-in viewer, carries a `reactTicket` signed for this app and this viewer
1310
+ for 24 hours. **No hook in this package supplies one yet:** `useGatedImages()`
1311
+ goes through the host bridge, whose reply carries no `reactTicket`
1312
+ ([#687](https://github.com/civitai/civitai-app-starters/issues/687)). Pass the
1313
+ tickets as `tickets`, keyed by image id. An image without a valid one is not
1314
+ answered by the read (it lands in `unavailable`, which is **not** "not reacted")
1315
+ and a `toggle` on it is refused locally, before anything is sent.
1316
+
1317
+ `toggle(id, reaction, reacted)` sets a **desired state**, moves `byId[id]`
1318
+ optimistically, and puts it back on failure. It rejects with a `ReactionError`:
1319
+
1320
+ | | meaning | what to do |
1321
+ |---|---|---|
1322
+ | `err.signInRequired` | no session | route into `useRequestSignIn()` |
1323
+ | `err.needsConsent` | the scope is not granted | `useRequestConsent()` |
1324
+ | `err.code === 'rate-limited'` | the viewer's reaction quota is spent | back off |
1325
+ | `err.code === 'entity-unavailable'` | no valid ticket, an image the viewer may not react to, the kill switch, or a network failure | retry later or give up |
1326
+ | `err.code` otherwise | `invalid-request` / `review-mode` / `not-ready` | a block bug, a review sandbox, or retry after `ready` |
1327
+ | `err.timedOut` | no reply within 30s | the write **may** have landed; the hook re-reads that image |
1328
+ | `err.busy` | the same image and reaction already has a request in flight. Rejects the call only: `error` is not set, because the write it collided with is still running | disable the control while `pending(id, reaction)` |
1329
+
1330
+ 🔴 **Bind `<civitai-reaction>` from `byId` on every render; never let it keep
1331
+ its own state.** The element flips `reacted` and `count` itself on a click. When
1332
+ the hook makes no optimistic move of its own (an image not read yet, or a
1333
+ refusal such as a missing ticket), `byId` does not change, so an element that
1334
+ only listens would keep showing a reaction that does not exist. Every rejected
1335
+ `toggle` re-renders the owner: a refusal or a failed write sets `error`, and a
1336
+ `busy` refusal is followed by the in-flight write's own settle, which clears
1337
+ `pending`. An element whose `reacted` and `count` come from `byId` on each
1338
+ render is then put back. Outside React, set the two properties back yourself
1339
+ after a rejection.
1340
+
1341
+ ```tsx
1342
+ import { useEffect, useState } from 'react';
1343
+ import { isAppReaction } from '@civitai/app-sdk/blocks';
1344
+ import { useBlockToken, useHostOrigin, useReactions } from '@civitai/blocks-react';
1345
+
1346
+ type ServedImage = { id: number; url: string; reactTicket?: string };
1347
+
1348
+ function useReactableImages() {
1349
+ const host = useHostOrigin();
1350
+ const { raw } = useBlockToken();
1351
+ const [images, setImages] = useState<ServedImage[]>([]);
1352
+
1353
+ // The tickets come from the REST endpoint, fetched with the block token.
1354
+ useEffect(() => {
1355
+ if (!host || !raw) return;
1356
+ void fetch(`${host}/api/v1/blocks/images?limit=20`, {
1357
+ headers: { Authorization: `Bearer ${raw}` },
1358
+ })
1359
+ .then((res) => res.json() as Promise<{ items: ServedImage[] }>)
1360
+ .then((body) => setImages(body.items));
1361
+ }, [host, raw]);
1362
+
1363
+ const reactions = useReactions(
1364
+ 'image',
1365
+ images.map((image) => image.id),
1366
+ { tickets: Object.fromEntries(images.map((image) => [image.id, image.reactTicket])) },
1367
+ );
1368
+
1369
+ // Props for one `<civitai-reaction>` (e.g. `CivitaiReaction` from
1370
+ // `@civitai/components-react`), derived from `byId` on every render.
1371
+ const bind = (id: number, reaction: 'Like' | 'Heart' | 'Laugh' | 'Cry') => ({
1372
+ reaction,
1373
+ reacted: reactions.byId[id]?.viewerReactions.includes(reaction) ?? false,
1374
+ count: reactions.byId[id]?.counts[reaction] ?? 0,
1375
+ disabled: !reactions.byId[id] || reactions.pending(id, reaction),
1376
+ onReact: (event: CustomEvent<{ reaction?: string; reacted: boolean }>) => {
1377
+ const { reaction: which, reacted } = event.detail;
1378
+ // `detail.reaction` is any string the element was given; narrow it.
1379
+ if (!isAppReaction(which)) return;
1380
+ // A rejection needs no handling here to put the element back: it
1381
+ // re-renders, and `bind` reads `byId` again.
1382
+ reactions.toggle(id, which, reacted).catch(() => {});
1383
+ },
1384
+ });
1385
+
1386
+ return { images, bind, error: reactions.error };
1387
+ }
1388
+ ```
1389
+
1276
1390
  ### `useCreatePostFromApp()`
1277
1391
 
1278
1392
  Publish a **real, published Post on the viewer's profile** from this app's own
@@ -2369,6 +2483,126 @@ an invalid `sources` or `multiple` with an error, and answers
2369
2483
  list the viewer's images, so there `open` rejects with a message saying to use
2370
2484
  the mock host.
2371
2485
 
2486
+ ### `useMediaUpload()`
2487
+
2488
+ **The way to pass any media (image, video or audio) into a workflow step.** The
2489
+ host refuses data URIs and inline base64 in step inputs, so upload the file
2490
+ with this hook and pass the `url` it returns into the step input. Returns
2491
+ `{ open, upload }`.
2492
+
2493
+ - **`open({ kind })`** opens the host's own file picker for that kind. The
2494
+ viewer picks a file, the host uploads it, and `open` resolves with the
2495
+ upload, or `null` if nothing was uploaded.
2496
+ - **`upload({ kind, bytes, mimeType? })`** uploads media your app already
2497
+ holds in the tab, with no picker: a canvas export, a crop, a `MediaRecorder`
2498
+ recording. It resolves with the upload, and never resolves without one.
2499
+
2500
+ ```tsx
2501
+ import { useMediaUpload } from '@civitai/blocks-react';
2502
+
2503
+ function UseRecording({ recording }: { recording: Blob }) {
2504
+ const { upload } = useMediaUpload();
2505
+
2506
+ async function onClick() {
2507
+ const audio = await upload({
2508
+ kind: 'audio',
2509
+ bytes: await recording.arrayBuffer(),
2510
+ mimeType: recording.type, // optional; checked against the content
2511
+ });
2512
+ // audio.url goes into the step's audio input
2513
+ startStep({ audioUrl: audio.url });
2514
+ }
2515
+
2516
+ return <button onClick={onClick}>Use this recording</button>;
2517
+ }
2518
+ ```
2519
+
2520
+ ```tsx
2521
+ const { open } = useMediaUpload();
2522
+
2523
+ const video = await open({ kind: 'video' }); // BlockMediaUploadInfo | null
2524
+ if (video) {
2525
+ // video.url, video.width, video.height, video.durationMs (when known)
2526
+ }
2527
+ ```
2528
+
2529
+ What each kind accepts, decided by the file's **content** (its first bytes),
2530
+ never by its name or declared type:
2531
+
2532
+ | `kind` | Accepted | What you get back |
2533
+ |---|---|---|
2534
+ | `'image'` | PNG, JPEG, WebP | Resized so both sides are 64 to 2048 pixels and re-encoded as JPEG: `mimeType` is always `'image/jpeg'`, and `sizeBytes`, `width` and `height` describe that JPEG. |
2535
+ | `'video'` | MP4, WebM | Uploaded as sent. `width` and `height` are always set (a file with no video track is refused). `durationMs` when the browser reports a duration. |
2536
+ | `'audio'` | MP3, WAV, WebM | Uploaded as sent. No `width` or `height`. `durationMs` when the browser reports a duration: a `MediaRecorder` WebM often reports none, so do not rely on it. |
2537
+
2538
+ - **At most 24 MB per image, and 64 MB per video or audio file.** The image
2539
+ cap is lower because the host decodes an image in full before it resizes it.
2540
+ `upload` refuses a larger buffer before sending it, with the host's own
2541
+ message: `file exceeds the 24 MB image upload limit` or
2542
+ `file exceeds the 64 MB upload limit`.
2543
+ - **`upload` is rate-limited:** at most 8 uploads and 128 MB per 60 seconds
2544
+ per page. Past that the host replies `busy`; wait and retry. A refused upload
2545
+ does not count. `open` is not limited this way, because a person picks each
2546
+ file.
2547
+ - **`mimeType` is optional.** When you pass it (for example `blob.type`) it
2548
+ must match the content, or the upload is refused with
2549
+ `mimeType does not match the file's content`. Case and parameters such as
2550
+ `;codecs=opus` are ignored, and `audio/x-wav` counts as `audio/wav`.
2551
+ - Pass an `ArrayBuffer` (`await blob.arrayBuffer()`). A `Blob`, a typed array
2552
+ or an empty buffer is refused before sending with
2553
+ `invalid media-upload request`. The buffer is copied, not transferred.
2554
+ - A file whose content is not one of its kind's types is refused with
2555
+ `images must be PNG, JPEG or WebP`, `videos must be MP4 or WebM` or
2556
+ `audio must be MP3, WAV or WebM`. The host also refuses a file it cannot
2557
+ read (`could not read this video file`, `could not read this audio file`), a
2558
+ video with no video track (`this file has no video track`), and an image too
2559
+ small to resize to 64 pixels. A failed upload rejects with
2560
+ `media upload failed — please try again`, and a file the upload service
2561
+ refuses with `this video was refused by the upload service` or
2562
+ `this audio was refused by the upload service`. A bytes upload made while
2563
+ the host holds no block token rejects with `no block token`.
2564
+
2565
+ What `open` and `upload` resolve and reject with:
2566
+
2567
+ - **`open` resolves `null` when nothing was uploaded.** Usually the viewer
2568
+ closed the picker. The host also answers that way at once, without showing a
2569
+ picker, while your app is viewed in review mode or is not ready yet. A
2570
+ problem with the picked file is shown in the picker, where the viewer can
2571
+ pick another one.
2572
+ - **`upload` never resolves `null`.** When the host declines without
2573
+ uploading (review mode, not ready), `upload` rejects with
2574
+ `the host did not upload the media (the app is in review mode or not ready)`.
2575
+ - **A refusal rejects** with the host's message, in both modes. On a surface
2576
+ other than a page app the host answers at once with
2577
+ `unsupported on this host`.
2578
+ - **Both wait up to 10 minutes** for the host (a person picking a file, or a
2579
+ large upload), then reject with a `RequestTimeoutError`.
2580
+
2581
+ For images only, `useImageUpload().open({ purpose: 'generationSource' })` still
2582
+ works and returns `{ url, width, height }`. Use `useMediaUpload` for video,
2583
+ audio, or bytes your app made in the tab.
2584
+
2585
+ 🔴 **Needs a host that answers `OPEN_MEDIA_UPLOAD`** (civitai/civitai#6149).
2586
+ On a civitai.com that predates it, the request gets no answer and both calls reject with a
2587
+ `RequestTimeoutError` after 10 minutes.
2588
+
2589
+ With the mock host (`createMockHost` or `Harness` from
2590
+ `@civitai/blocks-react/testing`) there is no picker: `open` resolves at once
2591
+ with a built-in upload for the kind. Set `cannedMediaUploads: { video: {...} }`
2592
+ to choose what a kind returns, or `{ audio: null }` for a viewer who closes
2593
+ that picker. `upload` runs the host's checks in the host's order (kind,
2594
+ purpose, an invalid buffer, the per-kind cap, the rate window, the content type,
2595
+ a mismatching `mimeType`) and answers with that kind's upload, carrying the
2596
+ sniffed `mimeType` and the buffer's size for video and audio. The mock cannot
2597
+ decode media, so it never produces the host's decode-time refusals (an
2598
+ unreadable file, no video track, an image too small, an upload failure): set
2599
+ `mediaUploadError: 'this file has no video track'` to force one.
2600
+ `mediaUploadError: 'no block token'` is checked where the host checks it: a
2601
+ bytes upload is refused before the cap and the window, and is not counted. A model-slot
2602
+ `context` answers `unsupported on this host`. `dev:live` cannot upload
2603
+ generation inputs, so there both calls reject with a message saying to use the
2604
+ mock host.
2605
+
2372
2606
  ### `useSaveImage()`
2373
2607
 
2374
2608
  Ask the host to download a file to the viewer's device. **A block cannot
@@ -3318,6 +3552,8 @@ MockHost
3318
3552
  MockHostFailMode
3319
3553
  MockHostOptions
3320
3554
  MockHostScenarioPatch
3555
+ MockMediaUploads
3556
+ MockReactionSeed
3321
3557
  MockSharedScenario
3322
3558
  MockSharedSeed
3323
3559
  MockStorageScenario
@@ -0,0 +1,97 @@
1
+ import type { BlockMediaKind, BlockMediaUploadInfo } from '@civitai/app-sdk/blocks';
2
+ /**
3
+ * What `upload()` rejects with when the host answers with neither a file nor
4
+ * an error. For a bytes upload that means the host DECLINED it without trying
5
+ * (the app is viewed in review mode, or is not ready yet). Nothing was
6
+ * uploaded, so it must never read as a success.
7
+ */
8
+ export declare const MEDIA_UPLOAD_DECLINED_ERROR = "the host did not upload the media (the app is in review mode or not ready)";
9
+ /** Options for {@link UseMediaUpload.open}. */
10
+ export interface MediaUploadOpenOptions {
11
+ /** What the viewer picks: `'image'`, `'video'` or `'audio'`. */
12
+ kind: BlockMediaKind;
13
+ }
14
+ /** Options for {@link UseMediaUpload.upload}. */
15
+ export interface MediaUploadBytesOptions {
16
+ /** What the bytes are: `'image'`, `'video'` or `'audio'`. */
17
+ kind: BlockMediaKind;
18
+ /**
19
+ * The file, as an `ArrayBuffer` (`await blob.arrayBuffer()`). At most 24 MB
20
+ * for an image and 64 MB for video or audio.
21
+ * Copied across `postMessage`, never transferred. A `Blob`, a typed array or
22
+ * an empty buffer is refused before sending.
23
+ */
24
+ bytes: ArrayBuffer;
25
+ /**
26
+ * Optional: the type you believe you are sending (`blob.type`). The host
27
+ * decides the type from the content; when this is given and disagrees, the
28
+ * upload is refused with `mimeType does not match the file's content`.
29
+ * Parameters (`;codecs=…`) and case are ignored.
30
+ */
31
+ mimeType?: string;
32
+ }
33
+ /** What {@link useMediaUpload} returns. */
34
+ export interface UseMediaUpload {
35
+ /**
36
+ * PICKER mode: the host opens its own file picker for `kind`, uploads what
37
+ * the viewer picks, and resolves with it. Resolves `null` when nothing was
38
+ * uploaded: the viewer closed the picker, or the host declined at once
39
+ * (review mode, not ready). Rejects with the host's message on a refusal.
40
+ */
41
+ open: (options: MediaUploadOpenOptions) => Promise<BlockMediaUploadInfo | null>;
42
+ /**
43
+ * BYTES mode: upload media the block holds (a canvas export, a crop, a
44
+ * `MediaRecorder` recording) with no picker. Resolves with the uploaded
45
+ * file. Rejects with the host's message on every refusal or failure, and
46
+ * with {@link MEDIA_UPLOAD_DECLINED_ERROR} when the host declined without
47
+ * uploading. It never resolves without a file.
48
+ */
49
+ upload: (options: MediaUploadBytesOptions) => Promise<BlockMediaUploadInfo>;
50
+ }
51
+ /**
52
+ * Upload an image, video or audio file as a GENERATION INPUT, and get back a
53
+ * `url` to pass into a workflow step input.
54
+ *
55
+ * 🔴 THIS IS THE WAY TO PUT MEDIA INTO A STEP. The host refuses data URIs and
56
+ * inline base64 in step inputs, so upload the file here and pass the returned
57
+ * `url`.
58
+ *
59
+ * - `open({ kind })` lets the viewer pick a file in the host's own picker.
60
+ * - `upload({ kind, bytes, mimeType? })` uploads bytes the block made in the
61
+ * tab, with no picker.
62
+ *
63
+ * The type is decided by the file's content: images PNG, JPEG or WebP (the
64
+ * host resizes them to 64–2048 px per side and re-encodes them as JPEG);
65
+ * videos MP4 or WebM; audio MP3, WAV or WebM. At most 24 MB per image (the
66
+ * host decodes it in full before resizing) and 64 MB per video or audio; bytes
67
+ * mode also allows at most 8 uploads and 128 MB per 60 s per page, and
68
+ * rejects `busy` past that (retry later).
69
+ *
70
+ * Refused BEFORE sending, with the host's own strings: an unknown `kind`, an
71
+ * `upload` whose `bytes` is not a non-empty `ArrayBuffer` (or whose `mimeType`
72
+ * is not a string), and a buffer over the kind's cap.
73
+ *
74
+ * Page apps only: a model-slot host rejects with `unsupported on this host`.
75
+ * A host that predates this message does not answer it, so the call rejects
76
+ * with `RequestTimeoutError` after the human-interaction timeout
77
+ * ({@link HUMAN_INTERACTION_TIMEOUT_MS}, 10 minutes).
78
+ *
79
+ * For images, `useImageUpload().open({ purpose: 'generationSource' })` remains
80
+ * valid; this hook is the one that also takes video, audio and in-tab bytes.
81
+ *
82
+ * @example
83
+ * // A recording made in the tab, passed into a step.
84
+ * const { upload } = useMediaUpload();
85
+ * const audio = await upload({ kind: 'audio', bytes: await recording.arrayBuffer(), mimeType: recording.type });
86
+ * // audio.url goes into the step's audio input
87
+ *
88
+ * @example
89
+ * // The viewer picks a video.
90
+ * const { open } = useMediaUpload();
91
+ * const video = await open({ kind: 'video' });
92
+ * if (video) {
93
+ * // video.url, video.width, video.height, video.durationMs
94
+ * }
95
+ */
96
+ export declare function useMediaUpload(): UseMediaUpload;
97
+ //# sourceMappingURL=useMediaUpload.d.ts.map
@@ -0,0 +1,108 @@
1
+ import { useCallback } from 'react';
2
+ import { MEDIA_UPLOAD_INVALID_ERROR, MEDIA_UPLOAD_KIND_ERROR, isMediaKind, mediaUploadCap, } from '../internal/mediaUploadBytes.js';
3
+ import { throwOnReplyError } from '../internal/replyError.js';
4
+ import { HUMAN_INTERACTION_TIMEOUT_MS } from '../transport/requestTimeouts.js';
5
+ import { getTransport } from '../transport/singleton.js';
6
+ import { sendTypedRequest } from '../transport/transport.js';
7
+ import { useTransportConnection } from './useTransportConnection.js';
8
+ /**
9
+ * What `upload()` rejects with when the host answers with neither a file nor
10
+ * an error. For a bytes upload that means the host DECLINED it without trying
11
+ * (the app is viewed in review mode, or is not ready yet). Nothing was
12
+ * uploaded, so it must never read as a success.
13
+ */
14
+ export const MEDIA_UPLOAD_DECLINED_ERROR = 'the host did not upload the media (the app is in review mode or not ready)';
15
+ /**
16
+ * Upload an image, video or audio file as a GENERATION INPUT, and get back a
17
+ * `url` to pass into a workflow step input.
18
+ *
19
+ * 🔴 THIS IS THE WAY TO PUT MEDIA INTO A STEP. The host refuses data URIs and
20
+ * inline base64 in step inputs, so upload the file here and pass the returned
21
+ * `url`.
22
+ *
23
+ * - `open({ kind })` lets the viewer pick a file in the host's own picker.
24
+ * - `upload({ kind, bytes, mimeType? })` uploads bytes the block made in the
25
+ * tab, with no picker.
26
+ *
27
+ * The type is decided by the file's content: images PNG, JPEG or WebP (the
28
+ * host resizes them to 64–2048 px per side and re-encodes them as JPEG);
29
+ * videos MP4 or WebM; audio MP3, WAV or WebM. At most 24 MB per image (the
30
+ * host decodes it in full before resizing) and 64 MB per video or audio; bytes
31
+ * mode also allows at most 8 uploads and 128 MB per 60 s per page, and
32
+ * rejects `busy` past that (retry later).
33
+ *
34
+ * Refused BEFORE sending, with the host's own strings: an unknown `kind`, an
35
+ * `upload` whose `bytes` is not a non-empty `ArrayBuffer` (or whose `mimeType`
36
+ * is not a string), and a buffer over the kind's cap.
37
+ *
38
+ * Page apps only: a model-slot host rejects with `unsupported on this host`.
39
+ * A host that predates this message does not answer it, so the call rejects
40
+ * with `RequestTimeoutError` after the human-interaction timeout
41
+ * ({@link HUMAN_INTERACTION_TIMEOUT_MS}, 10 minutes).
42
+ *
43
+ * For images, `useImageUpload().open({ purpose: 'generationSource' })` remains
44
+ * valid; this hook is the one that also takes video, audio and in-tab bytes.
45
+ *
46
+ * @example
47
+ * // A recording made in the tab, passed into a step.
48
+ * const { upload } = useMediaUpload();
49
+ * const audio = await upload({ kind: 'audio', bytes: await recording.arrayBuffer(), mimeType: recording.type });
50
+ * // audio.url goes into the step's audio input
51
+ *
52
+ * @example
53
+ * // The viewer picks a video.
54
+ * const { open } = useMediaUpload();
55
+ * const video = await open({ kind: 'video' });
56
+ * if (video) {
57
+ * // video.url, video.width, video.height, video.durationMs
58
+ * }
59
+ */
60
+ export function useMediaUpload() {
61
+ useTransportConnection();
62
+ const open = useCallback(async ({ kind }) => {
63
+ if (!isMediaKind(kind))
64
+ throw new Error(MEDIA_UPLOAD_KIND_ERROR);
65
+ const reply = await sendTypedRequest(getTransport(), { type: 'OPEN_MEDIA_UPLOAD', payload: { kind, purpose: 'generationSource' } }, 'MEDIA_UPLOAD_RESULT',
66
+ // A person picks the file, and the reply waits for the upload.
67
+ { timeoutMs: HUMAN_INTERACTION_TIMEOUT_MS });
68
+ // 🔴 BEFORE reading `selected`: a refusal carries none, and would
69
+ // otherwise resolve `null` exactly like a dismiss.
70
+ throwOnReplyError(reply, 'media upload failed');
71
+ return reply.selected ?? null;
72
+ }, []);
73
+ const upload = useCallback(async ({ kind, bytes, mimeType }) => {
74
+ // The host's own refusals, in the host's order, raised here so a doomed
75
+ // buffer is never copied across postMessage.
76
+ if (!isMediaKind(kind))
77
+ throw new Error(MEDIA_UPLOAD_KIND_ERROR);
78
+ if (!(bytes instanceof ArrayBuffer) || bytes.byteLength === 0) {
79
+ throw new Error(MEDIA_UPLOAD_INVALID_ERROR);
80
+ }
81
+ if (mimeType !== undefined && typeof mimeType !== 'string') {
82
+ throw new Error(MEDIA_UPLOAD_INVALID_ERROR);
83
+ }
84
+ const cap = mediaUploadCap(kind);
85
+ if (bytes.byteLength > cap.maxBytes)
86
+ throw new Error(cap.error);
87
+ const reply = await sendTypedRequest(getTransport(), {
88
+ type: 'OPEN_MEDIA_UPLOAD',
89
+ payload: {
90
+ kind,
91
+ purpose: 'generationSource',
92
+ bytes,
93
+ ...(mimeType !== undefined ? { mimeType } : {}),
94
+ },
95
+ }, 'MEDIA_UPLOAD_RESULT',
96
+ // `'human'` class: a 64 MB upload plus the host's retries can outlast
97
+ // the default protocol timeout.
98
+ { timeoutMs: HUMAN_INTERACTION_TIMEOUT_MS });
99
+ throwOnReplyError(reply, 'media upload failed');
100
+ // A bare reply is the host declining (review mode / not ready). Nothing
101
+ // was uploaded, so this is a failure, not a success.
102
+ if (!reply.selected)
103
+ throw new Error(MEDIA_UPLOAD_DECLINED_ERROR);
104
+ return reply.selected;
105
+ }, []);
106
+ return { open, upload };
107
+ }
108
+ //# sourceMappingURL=useMediaUpload.js.map
@@ -0,0 +1,126 @@
1
+ import type { BlockAppReaction, BlockEntityReactionErrorCode, BlockEntityReactionResult, BlockEntityReactionState, BlockReactionCounts, BlockReactionEntityType, BlockStoredReaction } from '@civitai/app-sdk/blocks';
2
+ export type { BlockAppReaction, BlockEntityReactionErrorCode, BlockEntityReactionResult, BlockEntityReactionState, BlockReactionCounts, BlockReactionEntityType, BlockStoredReaction, };
3
+ /**
4
+ * The reactions an app may set, as a runtime value: `Like`, `Heart`, `Laugh`,
5
+ * `Cry`. Never `Dislike` (the host refuses it).
6
+ *
7
+ * Declared here rather than value-imported from `@civitai/app-sdk/blocks` (where
8
+ * `APP_REACTIONS` holds the same list) so this hook does not raise the package's
9
+ * `@civitai/app-sdk` peer floor; see `PEER_FLOOR.md`. `test/useReactions.test.tsx`
10
+ * asserts the two lists are equal.
11
+ */
12
+ export declare const REACTIONS: readonly ["Like", "Heart", "Laugh", "Cry"];
13
+ /**
14
+ * The CLOSED set of codes `ENTITY_REACTION(S)_RESULT` carries, as a runtime
15
+ * value. Mirrors civitai's `ENTITY_REACTION_ERRORS` (and `@civitai/app-sdk`'s
16
+ * `ENTITY_REACTION_ERROR_CODES`); declared locally for the peer-floor reason on
17
+ * {@link REACTIONS}.
18
+ */
19
+ export declare const REACTION_ERROR_CODES: readonly ["invalid-request", "sign-in-required", "review-mode", "not-ready", "needs-consent", "entity-unavailable", "rate-limited"];
20
+ /** `true` when `error` is one of the closed {@link REACTION_ERROR_CODES}. */
21
+ export declare function isReactionErrorCode(error: string): error is BlockEntityReactionErrorCode;
22
+ /**
23
+ * A reaction read or write that did not succeed.
24
+ *
25
+ * `.code` is the host's closed code, or `undefined` for a transport timeout, a
26
+ * code a newer host added, or a local {@link ReactionError.busy} refusal.
27
+ * Check the flags before `.message`: only a `.code` is meant to be branched on,
28
+ * and none of these messages is written for a viewer.
29
+ */
30
+ export declare class ReactionError extends Error {
31
+ readonly code?: BlockEntityReactionErrorCode;
32
+ /**
33
+ * No reply arrived in time. 🔴 The write MAY have landed: the host could have
34
+ * completed it and failed to deliver the reply. The hook reverts its
35
+ * optimistic state and re-reads that image, so `byId` converges on the truth.
36
+ */
37
+ readonly timedOut: boolean;
38
+ /**
39
+ * Refused locally because the same image and reaction already has a request
40
+ * in flight. Nothing was sent. Disable the control while
41
+ * `pending(id, reaction)` is true and this does not happen.
42
+ */
43
+ readonly busy: boolean;
44
+ /** No session. Route into `useRequestSignIn()`. */
45
+ readonly signInRequired: boolean;
46
+ /** The viewer has not granted `social:react:self`. Ask with `useRequestConsent()`. */
47
+ readonly needsConsent: boolean;
48
+ constructor(error: string, opts?: {
49
+ timedOut?: boolean;
50
+ busy?: boolean;
51
+ });
52
+ }
53
+ /** Options for {@link useReactions}. */
54
+ export interface UseReactionsOptions {
55
+ /**
56
+ * The `reactTicket` each image arrived with, keyed by image id. A block
57
+ * endpoint (`/api/v1/blocks/images`, `/gated-images`, a collection's images)
58
+ * attaches one per image for a signed-in viewer, signed for this app and this
59
+ * viewer, valid 24 hours.
60
+ *
61
+ * 🔴 REQUIRED, for reads and writes alike. The host answers no image without
62
+ * a valid ticket: a read lists it in `unavailable`, and `toggle` on it is
63
+ * refused here with `entity-unavailable` before anything is sent, because
64
+ * the server would refuse it only AFTER spending one of the viewer's
65
+ * reactions from the quota the site's own button shares.
66
+ */
67
+ tickets: Readonly<Record<number | string, string | undefined>>;
68
+ }
69
+ /** What {@link useReactions} returns. */
70
+ export interface UseReactions {
71
+ /**
72
+ * The known state per image id: counts, the viewer's reactions, and
73
+ * `statsUnknown`. An id is absent until a read answers it, and stays absent
74
+ * if the host listed it in {@link UseReactions.unavailable}.
75
+ */
76
+ byId: Readonly<Record<number, BlockEntityReactionState>>;
77
+ /**
78
+ * Ids the host would not answer for want of a valid ticket. 🔴 These have NO
79
+ * known state; do not render them as "not reacted".
80
+ */
81
+ unavailable: readonly number[];
82
+ /** `true` while a read is in flight. */
83
+ loading: boolean;
84
+ /** The last failure of a read or a write, or `null`. Cleared by the next one. */
85
+ error: ReactionError | null;
86
+ /**
87
+ * Set the viewer's `reaction` on image `id` to `reacted`, and resolve with
88
+ * what the host reports.
89
+ *
90
+ * Optimistic: `byId[id]` moves at once and moves back if the write fails.
91
+ * REJECTS with a {@link ReactionError} on every failure. On success `byId[id]`
92
+ * adopts the host's `viewerReactions`, the truth read back after the write.
93
+ * When the host's `reacted` differs from the one asked for, a concurrent
94
+ * change won, and `byId` follows the host.
95
+ */
96
+ toggle: (id: number, reaction: BlockAppReaction, reacted: boolean) => Promise<BlockEntityReactionResult>;
97
+ /** `true` while a `toggle` for this image and reaction is in flight. */
98
+ pending: (id: number, reaction: BlockAppReaction) => boolean;
99
+ }
100
+ /**
101
+ * Read and set the VIEWER'S reactions on images this app was served, through
102
+ * the host's `GET_ENTITY_REACTIONS` and `SET_ENTITY_REACTION` bridge (scope
103
+ * `social:react:self`).
104
+ *
105
+ * A reaction made here is a reaction on civitai.com: it pays the reactor and
106
+ * the image owner exactly as the site's own button does, moves the image's
107
+ * rankings, notifies, and lists the viewer among the image's reactors. It draws
108
+ * on the SAME per-viewer quota as the site.
109
+ *
110
+ * 🔴 THE SCOPE IS CONSENT-GATED AND SENSITIVE. Declare `social:react:self` in
111
+ * the manifest with a `scopeJustifications` entry; until the viewer grants it,
112
+ * every call rejects with `needs-consent` (`.needsConsent`), which is the cue
113
+ * for `useRequestConsent()`. There is no per-tap confirm.
114
+ *
115
+ * Reads `ids` on mount in chunks of 100, then reads only what needs it: an id
116
+ * added to `ids`, or one whose ticket changed. Changing `entityType` re-reads
117
+ * nothing (image is the only type the host accepts today). After a write fails,
118
+ * an image is read again if the write timed out or the image has no state.
119
+ *
120
+ * @example
121
+ * const { byId, toggle, pending } = useReactions('image', images.map((i) => i.id), {
122
+ * tickets: Object.fromEntries(images.map((i) => [i.id, i.reactTicket])),
123
+ * });
124
+ */
125
+ export declare function useReactions(entityType: BlockReactionEntityType, ids: readonly number[], options: UseReactionsOptions): UseReactions;
126
+ //# sourceMappingURL=useReactions.d.ts.map