@civitai/blocks-react 0.74.1 → 0.75.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
@@ -1273,6 +1273,112 @@ async function toggle() {
1273
1273
  Most blocks want `<FollowButton>` from `@civitai/blocks-react/ui` instead, which
1274
1274
  wires all of the above.
1275
1275
 
1276
+ ### `useReactions(entityType, ids, { tickets })`
1277
+
1278
+ Read and set the **viewer's** reactions on images this app was served, over
1279
+ `GET_ENTITY_REACTIONS` / `SET_ENTITY_REACTION`. Returns
1280
+ `{ byId, unavailable, loading, error, toggle, pending }`.
1281
+
1282
+ It reads on mount, then reads again only the images that need it: an id added
1283
+ to `ids`, or one whose ticket changed. An id that leaves `ids` costs nothing.
1284
+ Every read counts against the block instance's catalog rate limit on the host,
1285
+ which every viewer of a page app shares with its other catalog reads.
1286
+
1287
+ A reaction made here **is** a reaction on civitai.com: it pays the reactor and
1288
+ the image owner exactly as the site's own button does, moves rankings, notifies,
1289
+ and lists the viewer among the reactors. It draws on the **same** per-viewer
1290
+ quota as the site. Apps may set `Like`, `Heart`, `Laugh` and `Cry`; never
1291
+ `Dislike`. Image is the only entity type today.
1292
+
1293
+ 🔴 **Scope `social:react:self`: consent-gated and sensitive.** Declare it with a
1294
+ `scopeJustifications` entry. Until the viewer grants it, every call fails with
1295
+ `needs-consent` (`err.needsConsent`): ask with `useRequestConsent()`. There is
1296
+ no per-tap confirm.
1297
+
1298
+ 🔴 **Tickets are required, and only the REST block endpoints supply them.** Each
1299
+ image that `/api/v1/blocks/images`, `/api/v1/blocks/gated-images` or
1300
+ `/api/v1/blocks/collections/{id}` serves, fetched with the block token for a
1301
+ signed-in viewer, carries a `reactTicket` signed for this app and this viewer
1302
+ for 24 hours. **No hook in this package supplies one yet:** `useGatedImages()`
1303
+ goes through the host bridge, whose reply carries no `reactTicket`
1304
+ ([#687](https://github.com/civitai/civitai-app-starters/issues/687)). Pass the
1305
+ tickets as `tickets`, keyed by image id. An image without a valid one is not
1306
+ answered by the read (it lands in `unavailable`, which is **not** "not reacted")
1307
+ and a `toggle` on it is refused locally, before anything is sent.
1308
+
1309
+ `toggle(id, reaction, reacted)` sets a **desired state**, moves `byId[id]`
1310
+ optimistically, and puts it back on failure. It rejects with a `ReactionError`:
1311
+
1312
+ | | meaning | what to do |
1313
+ |---|---|---|
1314
+ | `err.signInRequired` | no session | route into `useRequestSignIn()` |
1315
+ | `err.needsConsent` | the scope is not granted | `useRequestConsent()` |
1316
+ | `err.code === 'rate-limited'` | the viewer's reaction quota is spent | back off |
1317
+ | `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 |
1318
+ | `err.code` otherwise | `invalid-request` / `review-mode` / `not-ready` | a block bug, a review sandbox, or retry after `ready` |
1319
+ | `err.timedOut` | no reply within 30s | the write **may** have landed; the hook re-reads that image |
1320
+ | `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)` |
1321
+
1322
+ 🔴 **Bind `<civitai-reaction>` from `byId` on every render; never let it keep
1323
+ its own state.** The element flips `reacted` and `count` itself on a click. When
1324
+ the hook makes no optimistic move of its own (an image not read yet, or a
1325
+ refusal such as a missing ticket), `byId` does not change, so an element that
1326
+ only listens would keep showing a reaction that does not exist. Every rejected
1327
+ `toggle` re-renders the owner: a refusal or a failed write sets `error`, and a
1328
+ `busy` refusal is followed by the in-flight write's own settle, which clears
1329
+ `pending`. An element whose `reacted` and `count` come from `byId` on each
1330
+ render is then put back. Outside React, set the two properties back yourself
1331
+ after a rejection.
1332
+
1333
+ ```tsx
1334
+ import { useEffect, useState } from 'react';
1335
+ import { isAppReaction } from '@civitai/app-sdk/blocks';
1336
+ import { useBlockToken, useHostOrigin, useReactions } from '@civitai/blocks-react';
1337
+
1338
+ type ServedImage = { id: number; url: string; reactTicket?: string };
1339
+
1340
+ function useReactableImages() {
1341
+ const host = useHostOrigin();
1342
+ const { raw } = useBlockToken();
1343
+ const [images, setImages] = useState<ServedImage[]>([]);
1344
+
1345
+ // The tickets come from the REST endpoint, fetched with the block token.
1346
+ useEffect(() => {
1347
+ if (!host || !raw) return;
1348
+ void fetch(`${host}/api/v1/blocks/images?limit=20`, {
1349
+ headers: { Authorization: `Bearer ${raw}` },
1350
+ })
1351
+ .then((res) => res.json() as Promise<{ items: ServedImage[] }>)
1352
+ .then((body) => setImages(body.items));
1353
+ }, [host, raw]);
1354
+
1355
+ const reactions = useReactions(
1356
+ 'image',
1357
+ images.map((image) => image.id),
1358
+ { tickets: Object.fromEntries(images.map((image) => [image.id, image.reactTicket])) },
1359
+ );
1360
+
1361
+ // Props for one `<civitai-reaction>` (e.g. `CivitaiReaction` from
1362
+ // `@civitai/components-react`), derived from `byId` on every render.
1363
+ const bind = (id: number, reaction: 'Like' | 'Heart' | 'Laugh' | 'Cry') => ({
1364
+ reaction,
1365
+ reacted: reactions.byId[id]?.viewerReactions.includes(reaction) ?? false,
1366
+ count: reactions.byId[id]?.counts[reaction] ?? 0,
1367
+ disabled: !reactions.byId[id] || reactions.pending(id, reaction),
1368
+ onReact: (event: CustomEvent<{ reaction?: string; reacted: boolean }>) => {
1369
+ const { reaction: which, reacted } = event.detail;
1370
+ // `detail.reaction` is any string the element was given; narrow it.
1371
+ if (!isAppReaction(which)) return;
1372
+ // A rejection needs no handling here to put the element back: it
1373
+ // re-renders, and `bind` reads `byId` again.
1374
+ reactions.toggle(id, which, reacted).catch(() => {});
1375
+ },
1376
+ });
1377
+
1378
+ return { images, bind, error: reactions.error };
1379
+ }
1380
+ ```
1381
+
1276
1382
  ### `useCreatePostFromApp()`
1277
1383
 
1278
1384
  Publish a **real, published Post on the viewer's profile** from this app's own
@@ -3318,6 +3424,7 @@ MockHost
3318
3424
  MockHostFailMode
3319
3425
  MockHostOptions
3320
3426
  MockHostScenarioPatch
3427
+ MockReactionSeed
3321
3428
  MockSharedScenario
3322
3429
  MockSharedSeed
3323
3430
  MockStorageScenario
@@ -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
@@ -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';
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';