@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.
@@ -0,0 +1,384 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { getTransport } from '../transport/singleton.js';
3
+ import { RequestTimeoutError, sendTypedRequest } from '../transport/transport.js';
4
+ import { useTransportConnection } from './useTransportConnection.js';
5
+ /**
6
+ * The reactions an app may set, as a runtime value: `Like`, `Heart`, `Laugh`,
7
+ * `Cry`. Never `Dislike` (the host refuses it).
8
+ *
9
+ * Declared here rather than value-imported from `@civitai/app-sdk/blocks` (where
10
+ * `APP_REACTIONS` holds the same list) so this hook does not raise the package's
11
+ * `@civitai/app-sdk` peer floor; see `PEER_FLOOR.md`. `test/useReactions.test.tsx`
12
+ * asserts the two lists are equal.
13
+ */
14
+ export const REACTIONS = ['Like', 'Heart', 'Laugh', 'Cry'];
15
+ /**
16
+ * The CLOSED set of codes `ENTITY_REACTION(S)_RESULT` carries, as a runtime
17
+ * value. Mirrors civitai's `ENTITY_REACTION_ERRORS` (and `@civitai/app-sdk`'s
18
+ * `ENTITY_REACTION_ERROR_CODES`); declared locally for the peer-floor reason on
19
+ * {@link REACTIONS}.
20
+ */
21
+ export const REACTION_ERROR_CODES = [
22
+ 'invalid-request',
23
+ 'sign-in-required',
24
+ 'review-mode',
25
+ 'not-ready',
26
+ 'needs-consent',
27
+ 'entity-unavailable',
28
+ 'rate-limited',
29
+ ];
30
+ const CODE_SET = new Set(REACTION_ERROR_CODES);
31
+ /** Most ids one read may carry; the hook splits larger sets into several reads. */
32
+ const READ_CHUNK = 100;
33
+ /** `true` when `error` is one of the closed {@link REACTION_ERROR_CODES}. */
34
+ export function isReactionErrorCode(error) {
35
+ return CODE_SET.has(error);
36
+ }
37
+ /**
38
+ * A reaction read or write that did not succeed.
39
+ *
40
+ * `.code` is the host's closed code, or `undefined` for a transport timeout, a
41
+ * code a newer host added, or a local {@link ReactionError.busy} refusal.
42
+ * Check the flags before `.message`: only a `.code` is meant to be branched on,
43
+ * and none of these messages is written for a viewer.
44
+ */
45
+ export class ReactionError extends Error {
46
+ code;
47
+ /**
48
+ * No reply arrived in time. 🔴 The write MAY have landed: the host could have
49
+ * completed it and failed to deliver the reply. The hook reverts its
50
+ * optimistic state and re-reads that image, so `byId` converges on the truth.
51
+ */
52
+ timedOut;
53
+ /**
54
+ * Refused locally because the same image and reaction already has a request
55
+ * in flight. Nothing was sent. Disable the control while
56
+ * `pending(id, reaction)` is true and this does not happen.
57
+ */
58
+ busy;
59
+ /** No session. Route into `useRequestSignIn()`. */
60
+ signInRequired;
61
+ /** The viewer has not granted `social:react:self`. Ask with `useRequestConsent()`. */
62
+ needsConsent;
63
+ constructor(error, opts) {
64
+ super(error);
65
+ this.name = 'ReactionError';
66
+ this.timedOut = opts?.timedOut === true;
67
+ this.busy = opts?.busy === true;
68
+ if (isReactionErrorCode(error))
69
+ this.code = error;
70
+ this.signInRequired = error === 'sign-in-required';
71
+ this.needsConsent = error === 'needs-consent';
72
+ }
73
+ }
74
+ const pendingKey = (id, reaction) => `${id}:${reaction}`;
75
+ /** `state` with `reaction` held or not, and its count moved by one to match. */
76
+ function withReaction(state, reaction, reacted) {
77
+ const holds = state.viewerReactions.includes(reaction);
78
+ if (holds === reacted)
79
+ return state;
80
+ const viewerReactions = reacted
81
+ ? [...state.viewerReactions, reaction]
82
+ : state.viewerReactions.filter((r) => r !== reaction);
83
+ const count = state.counts[reaction] ?? 0;
84
+ return {
85
+ ...state,
86
+ viewerReactions,
87
+ counts: { ...state.counts, [reaction]: Math.max(0, count + (reacted ? 1 : -1)) },
88
+ };
89
+ }
90
+ /**
91
+ * Read and set the VIEWER'S reactions on images this app was served, through
92
+ * the host's `GET_ENTITY_REACTIONS` and `SET_ENTITY_REACTION` bridge (scope
93
+ * `social:react:self`).
94
+ *
95
+ * A reaction made here is a reaction on civitai.com: it pays the reactor and
96
+ * the image owner exactly as the site's own button does, moves the image's
97
+ * rankings, notifies, and lists the viewer among the image's reactors. It draws
98
+ * on the SAME per-viewer quota as the site.
99
+ *
100
+ * 🔴 THE SCOPE IS CONSENT-GATED AND SENSITIVE. Declare `social:react:self` in
101
+ * the manifest with a `scopeJustifications` entry; until the viewer grants it,
102
+ * every call rejects with `needs-consent` (`.needsConsent`), which is the cue
103
+ * for `useRequestConsent()`. There is no per-tap confirm.
104
+ *
105
+ * Reads `ids` on mount in chunks of 100, then reads only what needs it: an id
106
+ * added to `ids`, or one whose ticket changed. Changing `entityType` re-reads
107
+ * nothing (image is the only type the host accepts today). After a write fails,
108
+ * an image is read again if the write timed out or the image has no state.
109
+ *
110
+ * @example
111
+ * const { byId, toggle, pending } = useReactions('image', images.map((i) => i.id), {
112
+ * tickets: Object.fromEntries(images.map((i) => [i.id, i.reactTicket])),
113
+ * });
114
+ */
115
+ export function useReactions(entityType, ids, options) {
116
+ useTransportConnection();
117
+ const [store, setStore] = useState({ byId: {}, unavailable: [] });
118
+ // The latest committed store, read synchronously by `toggle` to decide whether
119
+ // its optimistic move applies.
120
+ const storeRef = useRef(store);
121
+ storeRef.current = store;
122
+ const [pendingKeys, setPendingKeys] = useState(() => new Set());
123
+ const [loadingCount, setLoadingCount] = useState(0);
124
+ const [error, setError] = useState(null);
125
+ const mountedRef = useRef(true);
126
+ useEffect(() => {
127
+ mountedRef.current = true;
128
+ return () => {
129
+ mountedRef.current = false;
130
+ };
131
+ }, []);
132
+ // In-flight writes, read synchronously so a second tap is refused before a
133
+ // re-render could tell it the first one is pending.
134
+ const inFlight = useRef(new Set());
135
+ // Keyed by CONTENT, so a fresh array with the same ids does not re-read.
136
+ const idsKey = ids.join(',');
137
+ const uniqueIds = useMemo(() => (idsKey === '' ? [] : [...new Set(idsKey.split(',').map(Number))]), [idsKey]);
138
+ // The tickets the current ids need, as a stable key, so a fresh `tickets`
139
+ // object with the same contents does not re-read.
140
+ const ticketKey = uniqueIds.map((id) => `${id}=${options.tickets[id] ?? ''}`).join('&');
141
+ const ticketsRef = useRef(options.tickets);
142
+ ticketsRef.current = options.tickets;
143
+ // ── Read bookkeeping ────────────────────────────────────────────────────
144
+ //
145
+ // Every read and every write start takes a tick of ONE monotonic clock, so
146
+ // "which happened later" is a comparison of two numbers. Three rules decide
147
+ // whether a read's answer for an image is applied, each pinned by a test in
148
+ // `test/useReactions.test.tsx` ("read economy and ordering"):
149
+ //
150
+ // 1. NOT while a write on that image is in flight: the write owns the state
151
+ // until it settles (its revert or its result is the next truth).
152
+ // 2. NOT if a write on that image STARTED after the read was issued: the
153
+ // read describes the state before the write, and applying it would
154
+ // overwrite a settled result (and its count reconciliation).
155
+ // 3. NOT if a NEWER read for that image was issued: answers can arrive out
156
+ // of order, and only the latest one sent for an image is the truth.
157
+ const clock = useRef(0);
158
+ const writeStartedAt = useRef(new Map());
159
+ const latestReadAt = useRef(new Map());
160
+ // The ticket each image was last READ with ('' for none). An image is read
161
+ // again only when it has never been read or its ticket changed, because the
162
+ // host bills every read to the block instance's shared catalog budget.
163
+ const readWithTicket = useRef(new Map());
164
+ // A newer pass supersedes an older one: the older pass sends no further
165
+ // chunks. Its chunks already sent are still applied, under the rules above.
166
+ const readGeneration = useRef(0);
167
+ const readIds = useCallback(async (targetIds, generation) => {
168
+ for (let start = 0; start < targetIds.length; start += READ_CHUNK) {
169
+ // Superseded: the newer pass reads whatever this one has not sent.
170
+ if (generation !== readGeneration.current)
171
+ break;
172
+ const chunk = targetIds.slice(start, start + READ_CHUNK);
173
+ const tickets = {};
174
+ clock.current += 1;
175
+ const issuedAt = clock.current;
176
+ for (const id of chunk) {
177
+ const ticket = ticketsRef.current[id];
178
+ if (ticket)
179
+ tickets[String(id)] = ticket;
180
+ readWithTicket.current.set(id, ticket ?? '');
181
+ latestReadAt.current.set(id, issuedAt);
182
+ }
183
+ if (mountedRef.current)
184
+ setLoadingCount((n) => n + 1);
185
+ try {
186
+ const reply = await sendTypedRequest(getTransport(), { type: 'GET_ENTITY_REACTIONS', payload: { entityType, ids: chunk, tickets } }, 'ENTITY_REACTIONS_RESULT');
187
+ if (reply.error || !reply.result) {
188
+ throw new ReactionError(reply.error || 'entity-unavailable');
189
+ }
190
+ const result = reply.result;
191
+ if (!mountedRef.current)
192
+ return;
193
+ const applicable = chunk.filter((id) =>
194
+ // Rule 1.
195
+ !REACTIONS.some((r) => inFlight.current.has(pendingKey(id, r))) &&
196
+ // Rule 2.
197
+ (writeStartedAt.current.get(id) ?? 0) < issuedAt &&
198
+ // Rule 3.
199
+ latestReadAt.current.get(id) === issuedAt);
200
+ if (applicable.length === 0)
201
+ continue;
202
+ const unanswered = new Set(result.unavailable);
203
+ setStore((prev) => {
204
+ const byId = { ...prev.byId };
205
+ const applied = new Set(applicable);
206
+ for (const id of applicable) {
207
+ const state = result.byId[String(id)];
208
+ if (state)
209
+ byId[id] = state;
210
+ else
211
+ delete byId[id];
212
+ }
213
+ const unavailable = [
214
+ ...prev.unavailable.filter((id) => !applied.has(id)),
215
+ ...applicable.filter((id) => unanswered.has(id)),
216
+ ];
217
+ return { byId, unavailable };
218
+ });
219
+ }
220
+ catch (err) {
221
+ // Only the LATEST read for an image speaks for it (rule 3 again): a
222
+ // failed one leaves the image unread, so the next pass retries it,
223
+ // and reports the failure. An older read failing after a newer one
224
+ // answered does neither.
225
+ const current = chunk.filter((id) => latestReadAt.current.get(id) === issuedAt);
226
+ for (const id of current)
227
+ readWithTicket.current.delete(id);
228
+ if (current.length === 0)
229
+ continue;
230
+ const wrapped = err instanceof ReactionError
231
+ ? err
232
+ : new ReactionError(err instanceof Error ? err.message : String(err), {
233
+ timedOut: err instanceof RequestTimeoutError,
234
+ });
235
+ if (mountedRef.current)
236
+ setError(wrapped);
237
+ }
238
+ finally {
239
+ if (mountedRef.current)
240
+ setLoadingCount((n) => n - 1);
241
+ }
242
+ }
243
+ }, [entityType]);
244
+ // Read on mount and whenever the ids or their tickets change, but only the
245
+ // images that need it: never read, or read with a different ticket. An image
246
+ // that leaves the list needs nothing. `ticketKey` is a dependency so a ticket
247
+ // arriving for an image already listed re-reads it.
248
+ useEffect(() => {
249
+ readGeneration.current += 1;
250
+ const generation = readGeneration.current;
251
+ const needed = uniqueIds.filter((id) => readWithTicket.current.get(id) !== (ticketsRef.current[id] ?? ''));
252
+ if (needed.length === 0)
253
+ return;
254
+ setError(null);
255
+ void readIds(needed, generation);
256
+ }, [readIds, uniqueIds, ticketKey]);
257
+ const toggle = useCallback(async (id, reaction, reacted) => {
258
+ const key = pendingKey(id, reaction);
259
+ if (inFlight.current.has(key)) {
260
+ const busy = new ReactionError(`a ${reaction} on ${entityType} ${id} is already in flight`, {
261
+ busy: true,
262
+ });
263
+ // Rejects the CALL only; the hook-level `error` is left alone. A busy
264
+ // refusal is not an outcome of the image: the write it collided with
265
+ // is still running, and its settle re-renders the owner (it clears
266
+ // `pending`), which is what rewrites a bound element.
267
+ throw busy;
268
+ }
269
+ const ticket = ticketsRef.current[id];
270
+ if (!ticket) {
271
+ // Refused HERE, not by the host: the server checks the ticket after its
272
+ // rate limit, so sending this would spend a reaction for nothing.
273
+ const refusal = new ReactionError('entity-unavailable');
274
+ if (mountedRef.current)
275
+ setError(refusal);
276
+ throw refusal;
277
+ }
278
+ inFlight.current.add(key);
279
+ let reread = false;
280
+ clock.current += 1;
281
+ writeStartedAt.current.set(id, clock.current);
282
+ // Optimistic only when the state is known and the request would change
283
+ // it. An unread image, or one already in the asked-for state, waits for
284
+ // the host's answer instead.
285
+ const before = storeRef.current.byId[id];
286
+ const applied = before !== undefined && withReaction(before, reaction, reacted) !== before;
287
+ if (mountedRef.current) {
288
+ setError(null);
289
+ setPendingKeys((prev) => new Set(prev).add(key));
290
+ if (applied) {
291
+ setStore((prev) => {
292
+ const state = prev.byId[id];
293
+ if (!state)
294
+ return prev;
295
+ return { ...prev, byId: { ...prev.byId, [id]: withReaction(state, reaction, reacted) } };
296
+ });
297
+ }
298
+ }
299
+ try {
300
+ const reply = await sendTypedRequest(getTransport(), {
301
+ type: 'SET_ENTITY_REACTION',
302
+ payload: { entityType, entityId: id, reaction, reacted, ticket },
303
+ }, 'ENTITY_REACTION_RESULT');
304
+ if (reply.error || !reply.result) {
305
+ throw new ReactionError(reply.error || 'entity-unavailable');
306
+ }
307
+ const result = reply.result;
308
+ if (mountedRef.current) {
309
+ setStore((prev) => {
310
+ const current = prev.byId[id];
311
+ const counts = { ...result.counts };
312
+ // Counts come from an asynchronous metric store and can lag the
313
+ // write. When the write held, keep the optimistic count unless the
314
+ // server's has already moved past it in the same direction.
315
+ if (applied && current && result.reacted === reacted) {
316
+ const mine = current.counts[reaction] ?? 0;
317
+ const theirs = counts[reaction] ?? 0;
318
+ counts[reaction] = reacted ? Math.max(mine, theirs) : Math.min(mine, theirs);
319
+ }
320
+ const state = {
321
+ counts,
322
+ viewerReactions: result.viewerReactions,
323
+ statsUnknown: result.statsUnknown,
324
+ };
325
+ return {
326
+ byId: { ...prev.byId, [id]: state },
327
+ unavailable: prev.unavailable.filter((u) => u !== id),
328
+ };
329
+ });
330
+ }
331
+ return result;
332
+ }
333
+ catch (err) {
334
+ const wrapped = err instanceof ReactionError
335
+ ? err
336
+ : new ReactionError(err instanceof Error ? err.message : String(err), {
337
+ timedOut: err instanceof RequestTimeoutError,
338
+ });
339
+ if (mountedRef.current) {
340
+ if (applied) {
341
+ setStore((prev) => {
342
+ const state = prev.byId[id];
343
+ if (!state)
344
+ return prev;
345
+ return { ...prev, byId: { ...prev.byId, [id]: withReaction(state, reaction, !reacted) } };
346
+ });
347
+ }
348
+ setError(wrapped);
349
+ // A failed write leaves no truth behind it. Read the image again when
350
+ // the write may have landed (a timeout) or when there is no state to
351
+ // fall back on, which also covers a read discarded behind this write.
352
+ reread = wrapped.timedOut || storeRef.current.byId[id] === undefined;
353
+ }
354
+ // 🔴 Thrown even when unmounted: the caller's `await` is not the
355
+ // component, and an unmount must not read as a success.
356
+ throw wrapped;
357
+ }
358
+ finally {
359
+ inFlight.current.delete(key);
360
+ if (mountedRef.current) {
361
+ setPendingKeys((prev) => {
362
+ const next = new Set(prev);
363
+ next.delete(key);
364
+ return next;
365
+ });
366
+ // Its placement after the in-flight mark is cleared does not matter for
367
+ // discarding: rule 1 is checked when the answer arrives, which is always
368
+ // after this `finally` has run.
369
+ if (reread)
370
+ void readIds([id], readGeneration.current);
371
+ }
372
+ }
373
+ }, [entityType, readIds]);
374
+ const pending = useCallback((id, reaction) => pendingKeys.has(pendingKey(id, reaction)), [pendingKeys]);
375
+ return {
376
+ byId: store.byId,
377
+ unavailable: store.unavailable,
378
+ loading: loadingCount > 0,
379
+ error,
380
+ toggle,
381
+ pending,
382
+ };
383
+ }
384
+ //# sourceMappingURL=useReactions.js.map
package/dist/index.d.ts CHANGED
@@ -69,6 +69,8 @@ export { RequestTimeoutError } from './transport/transport.js';
69
69
  export { InvalidIdempotencyKeyError } from './transport/transport.js';
70
70
  export { useCollectionFollow, CollectionFollowError, COLLECTION_FOLLOW_ERROR_CODES, isCollectionFollowErrorCode, } from './hooks/useCollectionFollow.js';
71
71
  export type { UseCollectionFollow } from './hooks/useCollectionFollow.js';
72
+ export { useReactions, ReactionError, REACTIONS, REACTION_ERROR_CODES, isReactionErrorCode, } from './hooks/useReactions.js';
73
+ export type { UseReactions, UseReactionsOptions } from './hooks/useReactions.js';
72
74
  export { useCreatePostFromApp, CreatePostError, CREATE_POST_ERROR_CODES, isCreatePostErrorCode, } from './hooks/useCreatePostFromApp.js';
73
75
  export type { UseCreatePostFromApp, BlockPostSource, BlockCreatePostRequest, BlockCreatePostResult, BlockCreatePostHostError, } from './hooks/useCreatePostFromApp.js';
74
76
  export { usePrepareTrainingDataset, PrepareTrainingDatasetError, PREPARE_TRAINING_DATASET_ERROR_CODES, isPrepareTrainingDatasetErrorCode, } from './hooks/usePrepareTrainingDataset.js';
@@ -77,7 +79,7 @@ export { useRunTraining, RunTrainingError, RUN_TRAINING_ERROR_CODES, isRunTraini
77
79
  export type { UseRunTraining, QuotedTrainingBody, RunTrainingErrorCode, BlockRunTrainingHostError, BlockTrainingQuote, WorkflowBodyTraining, } from './hooks/useRunTraining.js';
78
80
  export { useRunConfirmedStep, RunConfirmedStepError, RUN_CONFIRMED_STEP_ERROR_CODES, isRunConfirmedStepErrorCode, isStepQuoteId, } from './hooks/useRunConfirmedStep.js';
79
81
  export type { UseRunConfirmedStep, QuotedStepBody, RunConfirmedStepErrorCode, BlockRunConfirmedStepHostError, BlockStepQuote, WorkflowBodyPassThroughStep, } from './hooks/useRunConfirmedStep.js';
80
- export type { BlockBuzzTransaction, BlockBuzzAccount, BlockDailyCompensationResource, BlockWildcardPack, BlockWildcardPackErrorCode, AppWorkflow, AppWorkflowImage, AppWorkflowsParams, BlockGatedImage, BlockCollectionFollowErrorCode, BlockCollectionFollowResult, } from '@civitai/app-sdk/blocks';
82
+ export type { BlockBuzzTransaction, BlockBuzzAccount, BlockDailyCompensationResource, BlockWildcardPack, BlockWildcardPackErrorCode, AppWorkflow, AppWorkflowImage, AppWorkflowsParams, BlockGatedImage, BlockCollectionFollowErrorCode, BlockCollectionFollowResult, BlockAppReaction, BlockStoredReaction, BlockReactionEntityType, BlockEntityReactionErrorCode, BlockReactionCounts, BlockEntityReactionState, BlockEntityReactionResult, BlockEntityReactionsResult, } from '@civitai/app-sdk/blocks';
81
83
  export { useCheckpointPicker } from './hooks/useCheckpointPicker.js';
82
84
  export type { UseCheckpointPicker } from './hooks/useCheckpointPicker.js';
83
85
  export { useResourcePicker, RESOURCE_PICKER_MULTIPLE_MAX } from './hooks/useResourcePicker.js';
@@ -86,6 +88,8 @@ export { useImageUpload } from './hooks/useImageUpload.js';
86
88
  export type { UseImageUpload, UseImageUploadAsyncScan, UseImageUploadGenerationSource, UseImageUploadOptions, } from './hooks/useImageUpload.js';
87
89
  export { useUploadImageBytes } from './hooks/useUploadImageBytes.js';
88
90
  export type { UploadImageBytesOptions, UseUploadImageBytes, } from './hooks/useUploadImageBytes.js';
91
+ export { useMediaUpload } from './hooks/useMediaUpload.js';
92
+ export type { UseMediaUpload, MediaUploadOpenOptions, MediaUploadBytesOptions, } from './hooks/useMediaUpload.js';
89
93
  export { useImagePicker, IMAGE_PICKER_MULTIPLE_MAX } from './hooks/useImagePicker.js';
90
94
  export type { UseImagePicker, ImagePickerOpenOptions, ImagePickerMultiple, } from './hooks/useImagePicker.js';
91
95
  export { useGenerationResources } from './hooks/useGenerationResources.js';
package/dist/index.js CHANGED
@@ -60,6 +60,7 @@ export { RequestTimeoutError } from './transport/transport.js';
60
60
  // spent", which is the one distinction the class exists to carry.
61
61
  export { InvalidIdempotencyKeyError } from './transport/transport.js';
62
62
  export { useCollectionFollow, CollectionFollowError, COLLECTION_FOLLOW_ERROR_CODES, isCollectionFollowErrorCode, } from './hooks/useCollectionFollow.js';
63
+ export { useReactions, ReactionError, REACTIONS, REACTION_ERROR_CODES, isReactionErrorCode, } from './hooks/useReactions.js';
63
64
  export { useCreatePostFromApp, CreatePostError, CREATE_POST_ERROR_CODES, isCreatePostErrorCode, } from './hooks/useCreatePostFromApp.js';
64
65
  export { usePrepareTrainingDataset, PrepareTrainingDatasetError, PREPARE_TRAINING_DATASET_ERROR_CODES, isPrepareTrainingDatasetErrorCode, } from './hooks/usePrepareTrainingDataset.js';
65
66
  export { useRunTraining, RunTrainingError, RUN_TRAINING_ERROR_CODES, isRunTrainingErrorCode, } from './hooks/useRunTraining.js';
@@ -68,6 +69,7 @@ export { useCheckpointPicker } from './hooks/useCheckpointPicker.js';
68
69
  export { useResourcePicker, RESOURCE_PICKER_MULTIPLE_MAX } from './hooks/useResourcePicker.js';
69
70
  export { useImageUpload } from './hooks/useImageUpload.js';
70
71
  export { useUploadImageBytes } from './hooks/useUploadImageBytes.js';
72
+ export { useMediaUpload } from './hooks/useMediaUpload.js';
71
73
  export { useImagePicker, IMAGE_PICKER_MULTIPLE_MAX } from './hooks/useImagePicker.js';
72
74
  export { useGenerationResources } from './hooks/useGenerationResources.js';
73
75
  export { GENERATION_RESOURCES_API_BASE, MAX_GENERATION_RESOURCE_IDS, buildGenerationResourcesUrl, responseToResources, } from './api/generationResources.js';
@@ -47,7 +47,7 @@
47
47
  * fetch chokepoints ..... 2
48
48
  * REST endpoints ........ GET /api/v1/blocks/me
49
49
  * tRPC procedures ....... 29 = blocks.* (14) + apps.shared.* (10) + apps.storage.* (5)
50
- * switch case labels .... 50, of which 12 REFUSE (enumerated under SCOPE below)
50
+ * switch case labels .... 53, of which 15 REFUSE (enumerated under SCOPE below)
51
51
  * --- END DERIVED ---
52
52
  *
53
53
  * PICKERS (Phase 1 of "make dev:live a faithful local host"): the live host
@@ -106,6 +106,11 @@
106
106
  * links the real purchase page and replies `purchased: false`.
107
107
  * • SET_COLLECTION_FOLLOW — needs the session-authed follow procedures plus
108
108
  * host-chrome consent. Replies `collection-unavailable`.
109
+ * • SET_ENTITY_REACTION — the server's reaction procedures take the viewer's
110
+ * signed-in SESSION alongside the block token, and refuse any token as
111
+ * authentication. Replies `entity-unavailable`.
112
+ * • GET_ENTITY_REACTIONS — the same session-authed procedure pair. Replies
113
+ * `entity-unavailable`.
109
114
  * • CREATE_POST_FROM_APP — needs the server-resolved preview plus host-chrome
110
115
  * confirm before a PUBLIC post is written. Replies with a refusal.
111
116
  * • PREPARE_TRAINING_DATASET — the server refuses every `kind: 'training'`
@@ -130,6 +135,9 @@
130
135
  * • OPEN_IMAGE_PICKER — needs the host's picker over the viewer's own posted
131
136
  * images and generator outputs, read off their signed-in session; no
132
137
  * block-token endpoint lists either. Replies with a refusal.
138
+ * • OPEN_MEDIA_UPLOAD — the generation-input upload runs in the host with the
139
+ * viewer's signed-in session, which the local harness does not have.
140
+ * Replies with a refusal.
133
141
  * • SAVE_IMAGE — the download bridge is the production host's unsandboxed top
134
142
  * frame plus its CDN origin allowlist / gated per-viewer read. Replies with
135
143
  * a refusal (#386).
@@ -148,6 +156,11 @@ import { type PickerOverlayHandle } from './pickerOverlay.js';
148
156
  * rejects with it, so it says what to do instead.
149
157
  */
150
158
  export declare const IMAGE_PICKER_UNSUPPORTED_ERROR = "the image picker is not supported in dev:live \u2014 use dev:mock";
159
+ /**
160
+ * The `error` the live host answers `OPEN_MEDIA_UPLOAD` with. `useMediaUpload()`
161
+ * rejects with it, so it says what to do instead.
162
+ */
163
+ export declare const MEDIA_UPLOAD_UNSUPPORTED_ERROR = "media upload is not supported in dev:live \u2014 use dev:mock";
151
164
  /**
152
165
  * Options for {@link createLiveHost}. Mirrors the {@link MockHost}-relevant
153
166
  * subset of `MockHostOptions`, plus the live-only `blockToken` / `backendBaseUrl`
@@ -47,7 +47,7 @@
47
47
  * fetch chokepoints ..... 2
48
48
  * REST endpoints ........ GET /api/v1/blocks/me
49
49
  * tRPC procedures ....... 29 = blocks.* (14) + apps.shared.* (10) + apps.storage.* (5)
50
- * switch case labels .... 50, of which 12 REFUSE (enumerated under SCOPE below)
50
+ * switch case labels .... 53, of which 15 REFUSE (enumerated under SCOPE below)
51
51
  * --- END DERIVED ---
52
52
  *
53
53
  * PICKERS (Phase 1 of "make dev:live a faithful local host"): the live host
@@ -106,6 +106,11 @@
106
106
  * links the real purchase page and replies `purchased: false`.
107
107
  * • SET_COLLECTION_FOLLOW — needs the session-authed follow procedures plus
108
108
  * host-chrome consent. Replies `collection-unavailable`.
109
+ * • SET_ENTITY_REACTION — the server's reaction procedures take the viewer's
110
+ * signed-in SESSION alongside the block token, and refuse any token as
111
+ * authentication. Replies `entity-unavailable`.
112
+ * • GET_ENTITY_REACTIONS — the same session-authed procedure pair. Replies
113
+ * `entity-unavailable`.
109
114
  * • CREATE_POST_FROM_APP — needs the server-resolved preview plus host-chrome
110
115
  * confirm before a PUBLIC post is written. Replies with a refusal.
111
116
  * • PREPARE_TRAINING_DATASET — the server refuses every `kind: 'training'`
@@ -130,6 +135,9 @@
130
135
  * • OPEN_IMAGE_PICKER — needs the host's picker over the viewer's own posted
131
136
  * images and generator outputs, read off their signed-in session; no
132
137
  * block-token endpoint lists either. Replies with a refusal.
138
+ * • OPEN_MEDIA_UPLOAD — the generation-input upload runs in the host with the
139
+ * viewer's signed-in session, which the local harness does not have.
140
+ * Replies with a refusal.
133
141
  * • SAVE_IMAGE — the download bridge is the production host's unsandboxed top
134
142
  * frame plus its CDN origin allowlist / gated per-viewer read. Replies with
135
143
  * a refusal (#386).
@@ -147,11 +155,20 @@ import { isRoutableRequestId } from '../transport/requestId.js';
147
155
  import { parseSharedStorageRateLimit } from '../transport/validate.js';
148
156
  import { createTrackEventChecker } from './trackEventCheck.js';
149
157
  import { openPickerOverlay, } from './pickerOverlay.js';
158
+ /** Logged once when a block sends either reaction message to the live host. */
159
+ const REACTIONS_UNSUPPORTED_LOG = 'SET_ENTITY_REACTION / GET_ENTITY_REACTIONS are not supported in dev:live (the reaction ' +
160
+ 'procedures need the signed-in civitai.com session). Replying `entity-unavailable`. Use ' +
161
+ 'dev:mock to exercise the reaction bridge.';
150
162
  /**
151
163
  * The `error` the live host answers `OPEN_IMAGE_PICKER` with. `useImagePicker()`
152
164
  * rejects with it, so it says what to do instead.
153
165
  */
154
166
  export const IMAGE_PICKER_UNSUPPORTED_ERROR = 'the image picker is not supported in dev:live — use dev:mock';
167
+ /**
168
+ * The `error` the live host answers `OPEN_MEDIA_UPLOAD` with. `useMediaUpload()`
169
+ * rejects with it, so it says what to do instead.
170
+ */
171
+ export const MEDIA_UPLOAD_UNSUPPORTED_ERROR = 'media upload is not supported in dev:live — use dev:mock';
155
172
  /** Default civitai backend the live host forwards to. */
156
173
  const DEFAULT_BACKEND_BASE_URL = 'https://civitai.com';
157
174
  /** The budgeted-spend scope the workflow procedures require. */
@@ -1052,6 +1069,40 @@ export function createLiveHost(options) {
1052
1069
  });
1053
1070
  return;
1054
1071
  }
1072
+ case 'SET_ENTITY_REACTION': {
1073
+ // UNSUPPORTED in live: `blocks.setReactionFromApp` /
1074
+ // `blocks.getReactionsForApp` are session procedures marked
1075
+ // `blockApiKeys`, so the server refuses ANY token as the caller's
1076
+ // authentication and then checks that the block token's subject IS
1077
+ // the session user. A liveHost holds a token and no session.
1078
+ //
1079
+ // `entity-unavailable`: the closed code that already means "this
1080
+ // request cannot be served, nothing was written". `sign-in-required`
1081
+ // would send the block into a REQUEST_SIGN_IN loop against a harness
1082
+ // with no sign-in; `needs-consent` into a consent ask that cannot
1083
+ // help. Use dev:mock (`reactionStates` / `reactionError`, with
1084
+ // `social:react:self` in `declaredScopes`) to exercise the bridge.
1085
+ if (!isRoutableRequestId(requestId))
1086
+ return;
1087
+ logOnce('entity-reaction', REACTIONS_UNSUPPORTED_LOG);
1088
+ dispatchToBlock({
1089
+ type: 'ENTITY_REACTION_RESULT',
1090
+ payload: { requestId, error: 'entity-unavailable' },
1091
+ });
1092
+ return;
1093
+ }
1094
+ case 'GET_ENTITY_REACTIONS': {
1095
+ // UNSUPPORTED in live, for the reason on SET_ENTITY_REACTION directly
1096
+ // above: the read is the same session-authed procedure pair.
1097
+ if (!isRoutableRequestId(requestId))
1098
+ return;
1099
+ logOnce('entity-reaction', REACTIONS_UNSUPPORTED_LOG);
1100
+ dispatchToBlock({
1101
+ type: 'ENTITY_REACTIONS_RESULT',
1102
+ payload: { requestId, error: 'entity-unavailable' },
1103
+ });
1104
+ return;
1105
+ }
1055
1106
  case 'CREATE_POST_FROM_APP': {
1056
1107
  // UNSUPPORTED in live v1, for a STRONGER version of the
1057
1108
  // SET_COLLECTION_FOLLOW reason directly above. The real host makes
@@ -1371,6 +1422,24 @@ export function createLiveHost(options) {
1371
1422
  });
1372
1423
  return;
1373
1424
  }
1425
+ case 'OPEN_MEDIA_UPLOAD': {
1426
+ // REFUSED. The host uploads the file with the viewer's signed-in
1427
+ // session; this harness has none, and nothing a dev block token can
1428
+ // call takes the upload. Rather than let `useMediaUpload()` hang to
1429
+ // its 10-minute timeout, or hand back an invented URL a step would
1430
+ // then fail on, answer with an error (the hook rejects).
1431
+ logOnce('open-media-upload', 'OPEN_MEDIA_UPLOAD received but live dev mode cannot upload generation inputs ' +
1432
+ '— useMediaUpload() REJECTS. Test it against the real site, ' +
1433
+ 'or use createMockHost({ cannedMediaUploads }) in dev:mock.');
1434
+ // A refusal nobody can route is not worth sending.
1435
+ if (!isRoutableRequestId(requestId))
1436
+ return;
1437
+ dispatchToBlock({
1438
+ type: 'MEDIA_UPLOAD_RESULT',
1439
+ payload: { requestId, error: MEDIA_UPLOAD_UNSUPPORTED_ERROR },
1440
+ });
1441
+ return;
1442
+ }
1374
1443
  case 'SET_USER_CHECKPOINT': {
1375
1444
  // 🔴 A PAGE REFUSES, ALWAYS, WITHOUT A BACKEND CALL — and the default
1376
1445
  // context is a page. That is the production page host exactly: the