@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 +236 -0
- package/dist/hooks/useMediaUpload.d.ts +97 -0
- package/dist/hooks/useMediaUpload.js +108 -0
- package/dist/hooks/useReactions.d.ts +126 -0
- package/dist/hooks/useReactions.js +384 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -0
- package/dist/internal/liveHost.d.ts +14 -1
- package/dist/internal/liveHost.js +70 -1
- package/dist/internal/mediaUploadBytes.d.ts +169 -0
- package/dist/internal/mediaUploadBytes.js +268 -0
- package/dist/internal/mockHost.d.ts +62 -2
- package/dist/internal/mockHost.js +104 -2
- package/dist/internal/mockHostReactions.d.ts +62 -0
- package/dist/internal/mockHostReactions.js +166 -0
- package/dist/internal/mockHostStepInput.d.ts +62 -0
- package/dist/internal/mockHostStepInput.js +120 -0
- package/dist/internal/mockWorkflowGraph.d.ts +21 -1
- package/dist/internal/mockWorkflowGraph.js +22 -6
- package/dist/internal/stepInputLimits.d.ts +104 -0
- package/dist/internal/stepInputLimits.js +179 -0
- package/dist/testing.d.ts +2 -0
- package/dist/transport/requestTimeouts.js +7 -0
- package/dist/transport/validate.d.ts +35 -0
- package/dist/transport/validate.js +175 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/package.json +3 -3
|
@@ -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
|
+
* 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
|
+
* 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
|