@civitai/blocks-react 0.74.0 → 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 +112 -3
- package/dist/hooks/useReactions.d.ts +126 -0
- package/dist/hooks/useReactions.js +384 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +1 -0
- package/dist/internal/liveHost.d.ts +6 -1
- package/dist/internal/liveHost.js +44 -1
- package/dist/internal/mockHost.d.ts +30 -2
- package/dist/internal/mockHost.js +35 -0
- package/dist/internal/mockHostReactions.d.ts +62 -0
- package/dist/internal/mockHostReactions.js +166 -0
- package/dist/testing.d.ts +1 -0
- package/dist/transport/requestTimeouts.js +6 -0
- package/dist/transport/validate.d.ts +24 -0
- package/dist/transport/validate.js +119 -0
- package/dist/ui/WorkflowPicker.js +14 -0
- package/dist/version.generated.d.ts +1 -1
- package/dist/version.generated.js +1 -1
- package/package.json +3 -3
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
|
|
@@ -3166,9 +3272,11 @@ a backdrop click, and re-opens on the next render if `open` still says `true`.
|
|
|
3166
3272
|
- **Search is debounced** (300 ms), and a newer search cancels the one in
|
|
3167
3273
|
flight. A busy catalog (`429` / `503`, rate limit 150 requests per 10 s per
|
|
3168
3274
|
block) is retried after its `Retry-After`, twice, then offered as "Try again".
|
|
3169
|
-
- **An anonymous viewer** gets a sign-in message on the "mine" list
|
|
3170
|
-
|
|
3171
|
-
|
|
3275
|
+
- **An anonymous viewer** gets a sign-in message on the "mine" list as soon as
|
|
3276
|
+
the catalog answers `401`, with no `REQUEST_TOKEN` to the host first — the
|
|
3277
|
+
picker knows the viewer is anonymous from `useBlockContext()`. "All" works. An expired block token is not mistaken for
|
|
3278
|
+
that: for a signed-in viewer, or on "All", a `401` makes the picker mint a
|
|
3279
|
+
fresh token through `useBlockToken().refresh()` and retry once.
|
|
3172
3280
|
- **Before `BLOCK_INIT`** the picker says it is not connected yet, and lists as
|
|
3173
3281
|
soon as the host origin lands.
|
|
3174
3282
|
- **Under the mock host** there is no catalog to call. Pass canned results from
|
|
@@ -3316,6 +3424,7 @@ MockHost
|
|
|
3316
3424
|
MockHostFailMode
|
|
3317
3425
|
MockHostOptions
|
|
3318
3426
|
MockHostScenarioPatch
|
|
3427
|
+
MockReactionSeed
|
|
3319
3428
|
MockSharedScenario
|
|
3320
3429
|
MockSharedSeed
|
|
3321
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';
|