@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/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';
@@ -47,7 +47,7 @@
47
47
  * fetch chokepoints ..... 2
48
48
  * REST endpoints ........ GET /api/v1/blocks/me
49
49
  * tRPC procedures ....... 29 = blocks.* (14) + apps.shared.* (10) + apps.storage.* (5)
50
- * switch case labels .... 50, of which 12 REFUSE (enumerated under SCOPE below)
50
+ * switch case labels .... 52, of which 14 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'`
@@ -47,7 +47,7 @@
47
47
  * fetch chokepoints ..... 2
48
48
  * REST endpoints ........ GET /api/v1/blocks/me
49
49
  * tRPC procedures ....... 29 = blocks.* (14) + apps.shared.* (10) + apps.storage.* (5)
50
- * switch case labels .... 50, of which 12 REFUSE (enumerated under SCOPE below)
50
+ * switch case labels .... 52, of which 14 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'`
@@ -147,6 +152,10 @@ import { isRoutableRequestId } from '../transport/requestId.js';
147
152
  import { parseSharedStorageRateLimit } from '../transport/validate.js';
148
153
  import { createTrackEventChecker } from './trackEventCheck.js';
149
154
  import { openPickerOverlay, } from './pickerOverlay.js';
155
+ /** Logged once when a block sends either reaction message to the live host. */
156
+ const REACTIONS_UNSUPPORTED_LOG = 'SET_ENTITY_REACTION / GET_ENTITY_REACTIONS are not supported in dev:live (the reaction ' +
157
+ 'procedures need the signed-in civitai.com session). Replying `entity-unavailable`. Use ' +
158
+ 'dev:mock to exercise the reaction bridge.';
150
159
  /**
151
160
  * The `error` the live host answers `OPEN_IMAGE_PICKER` with. `useImagePicker()`
152
161
  * rejects with it, so it says what to do instead.
@@ -1052,6 +1061,40 @@ export function createLiveHost(options) {
1052
1061
  });
1053
1062
  return;
1054
1063
  }
1064
+ case 'SET_ENTITY_REACTION': {
1065
+ // UNSUPPORTED in live: `blocks.setReactionFromApp` /
1066
+ // `blocks.getReactionsForApp` are session procedures marked
1067
+ // `blockApiKeys`, so the server refuses ANY token as the caller's
1068
+ // authentication and then checks that the block token's subject IS
1069
+ // the session user. A liveHost holds a token and no session.
1070
+ //
1071
+ // `entity-unavailable`: the closed code that already means "this
1072
+ // request cannot be served, nothing was written". `sign-in-required`
1073
+ // would send the block into a REQUEST_SIGN_IN loop against a harness
1074
+ // with no sign-in; `needs-consent` into a consent ask that cannot
1075
+ // help. Use dev:mock (`reactionStates` / `reactionError`, with
1076
+ // `social:react:self` in `declaredScopes`) to exercise the bridge.
1077
+ if (!isRoutableRequestId(requestId))
1078
+ return;
1079
+ logOnce('entity-reaction', REACTIONS_UNSUPPORTED_LOG);
1080
+ dispatchToBlock({
1081
+ type: 'ENTITY_REACTION_RESULT',
1082
+ payload: { requestId, error: 'entity-unavailable' },
1083
+ });
1084
+ return;
1085
+ }
1086
+ case 'GET_ENTITY_REACTIONS': {
1087
+ // UNSUPPORTED in live, for the reason on SET_ENTITY_REACTION directly
1088
+ // above: the read is the same session-authed procedure pair.
1089
+ if (!isRoutableRequestId(requestId))
1090
+ return;
1091
+ logOnce('entity-reaction', REACTIONS_UNSUPPORTED_LOG);
1092
+ dispatchToBlock({
1093
+ type: 'ENTITY_REACTIONS_RESULT',
1094
+ payload: { requestId, error: 'entity-unavailable' },
1095
+ });
1096
+ return;
1097
+ }
1055
1098
  case 'CREATE_POST_FROM_APP': {
1056
1099
  // UNSUPPORTED in live v1, for a STRONGER version of the
1057
1100
  // SET_COLLECTION_FOLLOW reason directly above. The real host makes
@@ -37,8 +37,9 @@
37
37
  * called). Only the postMessage bridge round-trips are exercised. Never import
38
38
  * this from production code.
39
39
  */
40
- import { type BlockContext, type BlockResourceInfo, type BlockResourcePickerType, type BlockUploadedImageInfo, type BlockGenerationSourceImageInfo, type BuzzAccountType, type BlockBuzzTransaction, type BlockBuzzAccount, type BlockDailyCompensationResource, type BlockViewer, type BlockWildcardPack, type BlockWildcardPackErrorCode, type AppWorkflow, type BlockModel3dOutput, type BlockPublishedModel, type BlockRunConfirmedStepHostError, type BlockGatedImage, type BlockPickedImageInfo, type BlockCollectionFollowErrorCode, type BlockCreatePostHostError, type BlockCreatePostResult, type BlockPrepareTrainingDatasetHostError, type BlockRunTrainingHostError, type BlockTrainingRejectionReason, type ColorDomain, type SharedStorageValue, type Theme, type ViewerInfo, type WorkflowBody } from '@civitai/app-sdk/blocks';
40
+ import { type BlockContext, type BlockResourceInfo, type BlockResourcePickerType, type BlockUploadedImageInfo, type BlockGenerationSourceImageInfo, type BuzzAccountType, type BlockBuzzTransaction, type BlockBuzzAccount, type BlockDailyCompensationResource, type BlockViewer, type BlockWildcardPack, type BlockWildcardPackErrorCode, type AppWorkflow, type BlockModel3dOutput, type BlockPublishedModel, type BlockRunConfirmedStepHostError, type BlockGatedImage, type BlockPickedImageInfo, type BlockCollectionFollowErrorCode, type BlockEntityReactionErrorCode, type BlockCreatePostHostError, type BlockCreatePostResult, type BlockPrepareTrainingDatasetHostError, type BlockRunTrainingHostError, type BlockTrainingRejectionReason, type ColorDomain, type SharedStorageValue, type Theme, type ViewerInfo, type WorkflowBody } from '@civitai/app-sdk/blocks';
41
41
  import { type MockWorkflowQuoteFailure } from './mockWorkflowGraph.js';
42
+ import { type MockReactionSeed } from './mockHostReactions.js';
42
43
  /**
43
44
  * How submits behave. `'none'` = everything succeeds; `'all'` / `'insufficient'`
44
45
  * = every submit fails as if the viewer were OUT OF BUZZ, until an
@@ -945,6 +946,33 @@ export interface MockHostOptions {
945
946
  * handling is only exercised by `declined`, never by the timing.
946
947
  */
947
948
  collectionFollowError?: BlockCollectionFollowErrorCode | string;
949
+ /**
950
+ * The images `GET_ENTITY_REACTIONS` / `SET_ENTITY_REACTION` know, keyed by
951
+ * image id: counts and the viewer's existing reactions. An id that is not
952
+ * seeded starts with zero counts and no reactions. Writes persist for the
953
+ * life of the mock. Live-tunable via {@link MockHost.setScenario} (a new seed
954
+ * REPLACES the state).
955
+ *
956
+ * 🔴 BOTH MESSAGES ARE SCOPE-GATED HERE, as on the real host: unless
957
+ * {@link MockHostOptions.declaredScopes} includes `social:react:self`, every
958
+ * call replies `needs-consent`. A `null` {@link MockHostOptions.viewer}
959
+ * replies `sign-in-required`. Any well-formed ticket is accepted — a real one
960
+ * is an HMAC this mock cannot verify — but an image WITHOUT one is refused,
961
+ * as production refuses it. See `./mockHostReactions.ts`.
962
+ */
963
+ reactionStates?: Record<number, MockReactionSeed>;
964
+ /**
965
+ * Force both reaction messages to reply with this `error` once the request
966
+ * would otherwise proceed — one of `REACTION_ERROR_CODES` (`rate-limited`,
967
+ * `entity-unavailable`, `review-mode`, `not-ready`…) or any string for a code
968
+ * a newer host might add. Absent → the request succeeds. Live-tunable via
969
+ * {@link MockHost.setScenario}; pass `null` there to clear it.
970
+ *
971
+ * `review-mode` and `not-ready` are answered FIRST, ahead of the sign-in,
972
+ * payload and scope checks, because the real host's gate checks them first;
973
+ * every other code is answered only once those pass, as a server answer is.
974
+ */
975
+ reactionError?: BlockEntityReactionErrorCode | string | null;
948
976
  /**
949
977
  * The post reported on `CREATE_POST_FROM_APP` (what
950
978
  * `useCreatePostFromApp().createPost()` resolves with). Absent →
@@ -1370,7 +1398,7 @@ export interface MockHostOptions {
1370
1398
  * {@link MockHost.setTheme}, which pushes a real `THEME_CHANGE` — the mock of
1371
1399
  * the viewer toggling dark mode with the block already mounted.)
1372
1400
  */
1373
- export type MockHostScenarioPatch = Pick<MockHostOptions, 'consentGrantable' | 'failMode' | 'cost' | 'pollsUntilDone' | 'cannedPicks' | 'cannedMultiPicks' | 'cannedImagePicks' | 'cannedImageUpload' | 'cannedGenerationSourceUpload' | 'cannedImageScan' | 'generation' | 'buzz' | 'storage' | 'shared' | 'buzzBalanceError' | 'viewerResult' | 'viewerError' | 'buzzTransactions' | 'buzzAccounts' | 'dailyCompensation' | 'buzzReadError' | 'wildcardPack' | 'wildcardPackError' | 'collectionFollowError' | 'createPostResult' | 'createPostError' | 'trainingDatasetRejected' | 'trainingDatasetError' | 'trainingQuoteTotal' | 'runTrainingError' | 'runTrainingCapRefusal' | 'runConfirmedStepError' | 'runConfirmedStepCapRefusal' | 'appWorkflows' | 'appWorkflowsError' | 'publishImageIds' | 'publishError' | 'gatedImages' | 'gatedImagesError' | 'disallowedAccountTypes' | 'saveImageError' | 'uploadImageBytesResult' | 'uploadImageBytesError'>;
1401
+ export type MockHostScenarioPatch = Pick<MockHostOptions, 'consentGrantable' | 'failMode' | 'cost' | 'pollsUntilDone' | 'cannedPicks' | 'cannedMultiPicks' | 'cannedImagePicks' | 'cannedImageUpload' | 'cannedGenerationSourceUpload' | 'cannedImageScan' | 'generation' | 'buzz' | 'storage' | 'shared' | 'buzzBalanceError' | 'viewerResult' | 'viewerError' | 'buzzTransactions' | 'buzzAccounts' | 'dailyCompensation' | 'buzzReadError' | 'wildcardPack' | 'wildcardPackError' | 'collectionFollowError' | 'reactionStates' | 'reactionError' | 'createPostResult' | 'createPostError' | 'trainingDatasetRejected' | 'trainingDatasetError' | 'trainingQuoteTotal' | 'runTrainingError' | 'runTrainingCapRefusal' | 'runConfirmedStepError' | 'runConfirmedStepCapRefusal' | 'appWorkflows' | 'appWorkflowsError' | 'publishImageIds' | 'publishError' | 'gatedImages' | 'gatedImagesError' | 'disallowedAccountTypes' | 'saveImageError' | 'uploadImageBytesResult' | 'uploadImageBytesError'>;
1374
1402
  /** Runtime Buzz-balance handle exposed on {@link MockHost.buzz}. */
1375
1403
  export interface MockBuzzHandle {
1376
1404
  /** Current simulated balance, or `undefined` when balance isn't simulated. */
@@ -48,6 +48,7 @@ import { mockUnpricedWorkflowRefusal, mockUnquotedWorkflowEstimateRefusal, mockW
48
48
  import { BATCH_ESTIMATE_CELL_TIMEOUT_ERROR } from '../hooks/useBatchEstimate.js';
49
49
  import { RUN_CONFIRMED_STEP_ERROR_CODES, STEP_QUOTE_ID_PATTERN, } from '../hooks/useRunConfirmedStep.js';
50
50
  import { RUN_TRAINING_ERROR_CODES } from '../hooks/useRunTraining.js';
51
+ import { createMockReactions, REACTION_SCOPE, } from './mockHostReactions.js';
51
52
  import { hostContextWithTheme } from '../transport/transport.js';
52
53
  import { isRoutableRequestId } from '../transport/requestId.js';
53
54
  /**
@@ -1092,6 +1093,16 @@ export function createMockHost(options = {}) {
1092
1093
  // survived review. There is no READ op on this bridge for such a map to feed,
1093
1094
  // so the honest shape is not to offer one.
1094
1095
  let collectionFollowError = options.collectionFollowError;
1096
+ // App reactions: per-image state + a forced-error knob, owned by
1097
+ // `./mockHostReactions.ts`. The scope test is PRESENCE in the manifest's
1098
+ // declared scopes, the same set the storage gate reads.
1099
+ const reactionScopeDeclared = (options.declaredScopes ?? []).includes(REACTION_SCOPE);
1100
+ const reactions = createMockReactions({
1101
+ seed: options.reactionStates,
1102
+ error: options.reactionError ?? undefined,
1103
+ signedIn: () => viewer !== null,
1104
+ hasScope: () => reactionScopeDeclared,
1105
+ });
1095
1106
  // App Blocks → Post bridge: the canned post + a forced-refusal knob.
1096
1107
  let createPostResult = options.createPostResult ?? DEFAULT_CREATE_POST_RESULT;
1097
1108
  let createPostError = options.createPostError;
@@ -2582,6 +2593,27 @@ export function createMockHost(options = {}) {
2582
2593
  });
2583
2594
  return;
2584
2595
  }
2596
+ case 'SET_ENTITY_REACTION': {
2597
+ // App reactions. Every path with a routable requestId replies exactly
2598
+ // once; the decision order is the real host's (see
2599
+ // `./mockHostReactions.ts`).
2600
+ if (!isRoutableRequestId(requestId))
2601
+ return;
2602
+ dispatchToBlock({
2603
+ type: 'ENTITY_REACTION_RESULT',
2604
+ payload: { requestId, ...reactions.set(typed.payload) },
2605
+ });
2606
+ return;
2607
+ }
2608
+ case 'GET_ENTITY_REACTIONS': {
2609
+ if (!isRoutableRequestId(requestId))
2610
+ return;
2611
+ dispatchToBlock({
2612
+ type: 'ENTITY_REACTIONS_RESULT',
2613
+ payload: { requestId, ...reactions.get(typed.payload) },
2614
+ });
2615
+ return;
2616
+ }
2585
2617
  case 'CREATE_POST_FROM_APP': {
2586
2618
  // Publish a REAL Post on the viewer's profile from the app's own
2587
2619
  // outputs. Drop a request with no requestId (unroutable) — same as
@@ -3754,6 +3786,9 @@ export function createMockHost(options = {}) {
3754
3786
  wildcardPackError = patch.wildcardPackError;
3755
3787
  if (patch.collectionFollowError !== undefined)
3756
3788
  collectionFollowError = patch.collectionFollowError;
3789
+ if (patch.reactionStates !== undefined || patch.reactionError !== undefined) {
3790
+ reactions.configure({ seed: patch.reactionStates, error: patch.reactionError });
3791
+ }
3757
3792
  if (patch.createPostResult !== undefined)
3758
3793
  createPostResult = patch.createPostResult;
3759
3794
  if (patch.createPostError !== undefined)
@@ -0,0 +1,62 @@
1
+ /**
2
+ * mockHostReactions.ts — the dev host's half of `SET_ENTITY_REACTION` /
3
+ * `GET_ENTITY_REACTIONS` (app reactions, civitai/civitai#6144).
4
+ *
5
+ * It mirrors the REAL host's decision order, from civitai's `reactionGate.ts`
6
+ * and `blocks.setReactionFromApp` / `blocks.getReactionsForApp`:
7
+ *
8
+ * 1. no usable `requestId` → dropped (nothing to reply to);
9
+ * 1b. a forced `review-mode` / `not-ready` (`reactionError`) → that code,
10
+ * since the host's preflight checks both before anything else;
11
+ * 2. no viewer → `sign-in-required` (identity before payload);
12
+ * 3. malformed payload, or a reaction apps may not set (`Dislike`) →
13
+ * `invalid-request`;
14
+ * 4. `social:react:self` not among the manifest's declared scopes →
15
+ * `needs-consent`;
16
+ * 5. the forced-error knob, when set;
17
+ * 6. a write without a ticket → `entity-unavailable`; a read answers only
18
+ * ticketed ids and lists the rest in `unavailable`.
19
+ *
20
+ * 🔴 WHAT THIS CANNOT CHECK: whether a ticket is GENUINE. A real ticket is an
21
+ * HMAC the server signs with a secret no block holds, so this mock accepts any
22
+ * well-formed one (1 to 256 characters). A block that invents tickets passes
23
+ * here and fails in production with `entity-unavailable`; the tickets a block
24
+ * passes should be the `reactTicket` fields its images arrived with.
25
+ *
26
+ * The mock never produces `review-mode` or `not-ready` on its own; force them
27
+ * with `reactionError`, and they are answered at step 1b, as the host would.
28
+ */
29
+ import type { BlockEntityReactionResult, BlockEntityReactionsResult, BlockReactionCounts, BlockStoredReaction } from '@civitai/app-sdk/blocks';
30
+ /** The scope both messages need. */
31
+ export declare const REACTION_SCOPE = "social:react:self";
32
+ /** A seeded image for the mock. Every field is optional; absent counts are 0. */
33
+ export interface MockReactionSeed {
34
+ counts?: Partial<BlockReactionCounts>;
35
+ viewerReactions?: BlockStoredReaction[];
36
+ /** Report this image's counts as unknown, as the real metric store sometimes does. */
37
+ statsUnknown?: boolean;
38
+ }
39
+ /** The reply payload, minus `requestId`. */
40
+ export type MockReactionReply<T> = {
41
+ result: T;
42
+ } | {
43
+ error: string;
44
+ };
45
+ export interface MockReactions {
46
+ set(payload: unknown): MockReactionReply<BlockEntityReactionResult>;
47
+ get(payload: unknown): MockReactionReply<BlockEntityReactionsResult>;
48
+ /** Replace the seed and the forced error, as `setScenario` does. */
49
+ configure(patch: {
50
+ seed?: Record<number, MockReactionSeed>;
51
+ error?: string | null;
52
+ }): void;
53
+ }
54
+ export declare function createMockReactions(opts: {
55
+ seed?: Record<number, MockReactionSeed>;
56
+ error?: string;
57
+ /** Is a viewer signed in? Read on every call. */
58
+ signedIn: () => boolean;
59
+ /** Does the manifest declare the scope? Read on every call. */
60
+ hasScope: () => boolean;
61
+ }): MockReactions;
62
+ //# sourceMappingURL=mockHostReactions.d.ts.map
@@ -0,0 +1,166 @@
1
+ /**
2
+ * mockHostReactions.ts — the dev host's half of `SET_ENTITY_REACTION` /
3
+ * `GET_ENTITY_REACTIONS` (app reactions, civitai/civitai#6144).
4
+ *
5
+ * It mirrors the REAL host's decision order, from civitai's `reactionGate.ts`
6
+ * and `blocks.setReactionFromApp` / `blocks.getReactionsForApp`:
7
+ *
8
+ * 1. no usable `requestId` → dropped (nothing to reply to);
9
+ * 1b. a forced `review-mode` / `not-ready` (`reactionError`) → that code,
10
+ * since the host's preflight checks both before anything else;
11
+ * 2. no viewer → `sign-in-required` (identity before payload);
12
+ * 3. malformed payload, or a reaction apps may not set (`Dislike`) →
13
+ * `invalid-request`;
14
+ * 4. `social:react:self` not among the manifest's declared scopes →
15
+ * `needs-consent`;
16
+ * 5. the forced-error knob, when set;
17
+ * 6. a write without a ticket → `entity-unavailable`; a read answers only
18
+ * ticketed ids and lists the rest in `unavailable`.
19
+ *
20
+ * 🔴 WHAT THIS CANNOT CHECK: whether a ticket is GENUINE. A real ticket is an
21
+ * HMAC the server signs with a secret no block holds, so this mock accepts any
22
+ * well-formed one (1 to 256 characters). A block that invents tickets passes
23
+ * here and fails in production with `entity-unavailable`; the tickets a block
24
+ * passes should be the `reactTicket` fields its images arrived with.
25
+ *
26
+ * The mock never produces `review-mode` or `not-ready` on its own; force them
27
+ * with `reactionError`, and they are answered at step 1b, as the host would.
28
+ */
29
+ import { REACTIONS } from '../hooks/useReactions.js';
30
+ /** The scope both messages need. */
31
+ export const REACTION_SCOPE = 'social:react:self';
32
+ const MAX_IDS = 100;
33
+ const MAX_TICKET = 256;
34
+ const isPositiveInt = (v) => typeof v === 'number' && Number.isInteger(v) && v > 0;
35
+ const isTicket = (v) => typeof v === 'string' && v.length > 0 && v.length <= MAX_TICKET;
36
+ const isAppReaction = (v) => typeof v === 'string' && REACTIONS.includes(v);
37
+ function zeroCounts() {
38
+ return { Like: 0, Heart: 0, Laugh: 0, Cry: 0 };
39
+ }
40
+ export function createMockReactions(opts) {
41
+ const states = new Map();
42
+ let forcedError = opts.error;
43
+ const seedWith = (seed) => {
44
+ states.clear();
45
+ for (const [key, value] of Object.entries(seed ?? {})) {
46
+ states.set(Number(key), {
47
+ counts: { ...zeroCounts(), ...(value.counts ?? {}) },
48
+ viewerReactions: [...(value.viewerReactions ?? [])],
49
+ statsUnknown: value.statsUnknown === true,
50
+ });
51
+ }
52
+ };
53
+ seedWith(opts.seed);
54
+ const stateOf = (id) => states.get(id) ?? { counts: zeroCounts(), viewerReactions: [], statsUnknown: false };
55
+ /** Steps 2 to 5; `null` when the request may proceed. */
56
+ const refuse = (validPayload) => {
57
+ // The host's own preflight refusals come before everything else
58
+ // (`reactionGate.ts`: review mode, then the handshake, then the viewer), so
59
+ // a forced one is answered first. Every other forced code stands for a
60
+ // SERVER answer, which only a request past the host's gate can get.
61
+ if (forcedError === 'review-mode' || forcedError === 'not-ready')
62
+ return forcedError;
63
+ if (!opts.signedIn())
64
+ return 'sign-in-required';
65
+ if (!validPayload)
66
+ return 'invalid-request';
67
+ if (!opts.hasScope())
68
+ return 'needs-consent';
69
+ if (forcedError !== undefined)
70
+ return forcedError;
71
+ return null;
72
+ };
73
+ return {
74
+ set(payload) {
75
+ const p = (payload ?? {});
76
+ const valid = p.entityType === 'image' &&
77
+ isPositiveInt(p.entityId) &&
78
+ isAppReaction(p.reaction) &&
79
+ typeof p.reacted === 'boolean' &&
80
+ (p.ticket === undefined || isTicket(p.ticket));
81
+ const refusal = refuse(valid);
82
+ if (refusal !== null)
83
+ return { error: refusal };
84
+ if (!isTicket(p.ticket))
85
+ return { error: 'entity-unavailable' };
86
+ const id = p.entityId;
87
+ const reaction = p.reaction;
88
+ const reacted = p.reacted;
89
+ const before = stateOf(id);
90
+ const holds = before.viewerReactions.includes(reaction);
91
+ const changed = holds !== reacted;
92
+ const after = changed
93
+ ? {
94
+ counts: {
95
+ ...before.counts,
96
+ [reaction]: Math.max(0, before.counts[reaction] + (reacted ? 1 : -1)),
97
+ },
98
+ viewerReactions: reacted
99
+ ? [...before.viewerReactions, reaction]
100
+ : before.viewerReactions.filter((r) => r !== reaction),
101
+ statsUnknown: before.statsUnknown,
102
+ }
103
+ : before;
104
+ states.set(id, after);
105
+ return {
106
+ result: {
107
+ entityType: 'image',
108
+ entityId: id,
109
+ reaction,
110
+ reacted,
111
+ changed,
112
+ counts: { ...after.counts },
113
+ viewerReactions: [...after.viewerReactions],
114
+ statsUnknown: after.statsUnknown,
115
+ },
116
+ };
117
+ },
118
+ get(payload) {
119
+ const p = (payload ?? {});
120
+ const ids = p.ids;
121
+ const ticketsOk = p.tickets === undefined ||
122
+ (typeof p.tickets === 'object' && p.tickets !== null && !Array.isArray(p.tickets));
123
+ const valid = p.entityType === 'image' &&
124
+ Array.isArray(ids) &&
125
+ ids.length > 0 &&
126
+ ids.length <= MAX_IDS &&
127
+ ids.every(isPositiveInt) &&
128
+ ticketsOk;
129
+ if (valid && p.tickets !== undefined) {
130
+ // The real gate refuses a malformed ticket for a REQUESTED id, and
131
+ // ignores the tickets for ids it was not asked about.
132
+ const wanted = new Set(ids.map(String));
133
+ for (const [key, value] of Object.entries(p.tickets)) {
134
+ if (wanted.has(key) && !isTicket(value))
135
+ return { error: refuse(false) ?? 'invalid-request' };
136
+ }
137
+ }
138
+ const refusal = refuse(valid);
139
+ if (refusal !== null)
140
+ return { error: refusal };
141
+ const tickets = (p.tickets ?? {});
142
+ const byId = {};
143
+ const unavailable = [];
144
+ for (const id of new Set(ids)) {
145
+ if (!isTicket(tickets[String(id)])) {
146
+ unavailable.push(id);
147
+ continue;
148
+ }
149
+ const s = stateOf(id);
150
+ byId[String(id)] = {
151
+ counts: { ...s.counts },
152
+ viewerReactions: [...s.viewerReactions],
153
+ statsUnknown: s.statsUnknown,
154
+ };
155
+ }
156
+ return { result: { entityType: 'image', byId, unavailable } };
157
+ },
158
+ configure(patch) {
159
+ if (patch.seed !== undefined)
160
+ seedWith(patch.seed);
161
+ if (patch.error !== undefined)
162
+ forcedError = patch.error ?? undefined;
163
+ },
164
+ };
165
+ }
166
+ //# sourceMappingURL=mockHostReactions.js.map
package/dist/testing.d.ts CHANGED
@@ -22,6 +22,7 @@ import { type MockHostOptions } from './internal/mockHost.js';
22
22
  export { __resetTransport as resetTransport };
23
23
  export { installPopoverShim, type PopoverShimHandle, type PopoverShimOptions, } from './internal/popoverShim.js';
24
24
  export { createMockHost, readMockHostUrlOptions, type MockHost, type MockHostOptions, type MockHostFailMode, type MockHostScenarioPatch, type MockGenerationScenario, type MockBuzzScenario, type MockBuzzBalance, type MockBuzzHandle, type MockStorageScenario, type MockSharedScenario, type MockSharedSeed, type MockCannedImageScan, type MockWorkflowStepInfo, type MockWorkflowQuoteFailure, type CostSpec, type ImageSpec, type CannedPick, } from './internal/mockHost.js';
25
+ export { type MockReactionSeed } from './internal/mockHostReactions.js';
25
26
  export { fakeResourceSource, type FakeResourceCanned, type FakeResourceSource, } from './internal/fakeResourceSource.js';
26
27
  /**
27
28
  * Props for the dev {@link Harness}.
@@ -159,6 +159,12 @@ const REQUEST_TIMEOUT_CLASS = {
159
159
  // whether a human is involved. Not reclassified here.
160
160
  SAVE_IMAGE: 'protocol',
161
161
  SET_USER_CHECKPOINT: 'protocol',
162
+ // App reactions. `'protocol'`, NOT `'human'`, and that is a decision rather
163
+ // than an omission: unlike SET_COLLECTION_FOLLOW there is no host confirm to
164
+ // wait on. The consent is the `social:react:self` grant, made beforehand; a
165
+ // host without it refuses `needs-consent` at once.
166
+ SET_ENTITY_REACTION: 'protocol',
167
+ GET_ENTITY_REACTIONS: 'protocol',
162
168
  SHARED_APPEND: 'protocol',
163
169
  SHARED_GET: 'protocol',
164
170
  SHARED_GET_COUNT: 'protocol',
@@ -395,6 +395,30 @@ export declare function isValidImagesResult(p: unknown): boolean;
395
395
  * reach a consumer keying UI state by id.
396
396
  */
397
397
  export declare function isValidCollectionFollowResult(p: unknown): boolean;
398
+ /**
399
+ * Reply to a block-initiated `SET_ENTITY_REACTION`. A well-formed reply carries
400
+ * EITHER a `result` OR an `error`; one with neither is malformed and dropped.
401
+ *
402
+ * `error` is SHAPE-CHECKED ONLY, although the host's set is closed
403
+ * (`ENTITY_REACTION_ERROR_CODES`): a code a newer host adds must still reach
404
+ * the block as a rejection. A membership check would drop it, and a dropped
405
+ * reply on a request-style message does not reject, it waits out the timeout.
406
+ * `useReactions` turns an unknown string into a `ReactionError` with no `code`.
407
+ *
408
+ * `result` is checked as far as a consumer reads it: `entityId` a positive
409
+ * integer (state is keyed by it), `reaction` a non-empty string, `reacted` and
410
+ * `changed` booleans, plus the state fields.
411
+ */
412
+ export declare function isValidEntityReactionResult(p: unknown): boolean;
413
+ /**
414
+ * Reply to a block-initiated `GET_ENTITY_REACTIONS`. Same one-of and shape-only
415
+ * `error` contract as {@link isValidEntityReactionResult}.
416
+ *
417
+ * `result.byId` must be an object whose every key is a positive-integer id
418
+ * spelled as a string and whose every value is a valid state;
419
+ * `result.unavailable` an array of positive-integer ids.
420
+ */
421
+ export declare function isValidEntityReactionsResult(p: unknown): boolean;
398
422
  /**
399
423
  * Reply to a block-initiated `CREATE_POST_FROM_APP`. A well-formed reply carries
400
424
  * EITHER a `result` (`{ postId: positive int, url: non-empty string, imageIds:
@@ -1368,6 +1368,121 @@ export function isValidCollectionFollowResult(p) {
1368
1368
  return false;
1369
1369
  return true;
1370
1370
  }
1371
+ /** A positive integer id: what a reaction reply keys UI state by. */
1372
+ function isPositiveIntId(v) {
1373
+ return typeof v === 'number' && Number.isInteger(v) && v > 0;
1374
+ }
1375
+ /**
1376
+ * One image's reaction state inside an `ENTITY_REACTION(S)_RESULT`: `counts` an
1377
+ * object of finite numbers, `viewerReactions` an array of strings,
1378
+ * `statsUnknown` a boolean.
1379
+ *
1380
+ * 🔴 The reaction NAMES are not membership-checked, in `counts` or in
1381
+ * `viewerReactions`. The site stores five reactions and may add a sixth; a
1382
+ * reply naming one this SDK does not know is still a true statement about the
1383
+ * viewer, and dropping it would hang the request to its timeout instead.
1384
+ */
1385
+ function isValidEntityReactionState(v) {
1386
+ if (!isObject(v))
1387
+ return false;
1388
+ if (!isObject(v.counts))
1389
+ return false;
1390
+ for (const n of Object.values(v.counts)) {
1391
+ if (!isFiniteNumber(n))
1392
+ return false;
1393
+ }
1394
+ if (!Array.isArray(v.viewerReactions))
1395
+ return false;
1396
+ for (const r of v.viewerReactions) {
1397
+ if (typeof r !== 'string')
1398
+ return false;
1399
+ }
1400
+ if (typeof v.statsUnknown !== 'boolean')
1401
+ return false;
1402
+ return true;
1403
+ }
1404
+ /**
1405
+ * Reply to a block-initiated `SET_ENTITY_REACTION`. A well-formed reply carries
1406
+ * EITHER a `result` OR an `error`; one with neither is malformed and dropped.
1407
+ *
1408
+ * `error` is SHAPE-CHECKED ONLY, although the host's set is closed
1409
+ * (`ENTITY_REACTION_ERROR_CODES`): a code a newer host adds must still reach
1410
+ * the block as a rejection. A membership check would drop it, and a dropped
1411
+ * reply on a request-style message does not reject, it waits out the timeout.
1412
+ * `useReactions` turns an unknown string into a `ReactionError` with no `code`.
1413
+ *
1414
+ * `result` is checked as far as a consumer reads it: `entityId` a positive
1415
+ * integer (state is keyed by it), `reaction` a non-empty string, `reacted` and
1416
+ * `changed` booleans, plus the state fields.
1417
+ */
1418
+ export function isValidEntityReactionResult(p) {
1419
+ if (!isObject(p))
1420
+ return false;
1421
+ if (!isWireRequestIdShape(p.requestId))
1422
+ return false;
1423
+ if (p.error !== undefined && typeof p.error !== 'string')
1424
+ return false;
1425
+ if (p.result !== undefined) {
1426
+ const r = p.result;
1427
+ if (!isObject(r))
1428
+ return false;
1429
+ if (!isNonEmptyString(r.entityType))
1430
+ return false;
1431
+ if (!isPositiveIntId(r.entityId))
1432
+ return false;
1433
+ if (!isNonEmptyString(r.reaction))
1434
+ return false;
1435
+ if (typeof r.reacted !== 'boolean')
1436
+ return false;
1437
+ if (typeof r.changed !== 'boolean')
1438
+ return false;
1439
+ if (!isValidEntityReactionState(r))
1440
+ return false;
1441
+ }
1442
+ if (p.result === undefined && p.error === undefined)
1443
+ return false;
1444
+ return true;
1445
+ }
1446
+ /**
1447
+ * Reply to a block-initiated `GET_ENTITY_REACTIONS`. Same one-of and shape-only
1448
+ * `error` contract as {@link isValidEntityReactionResult}.
1449
+ *
1450
+ * `result.byId` must be an object whose every key is a positive-integer id
1451
+ * spelled as a string and whose every value is a valid state;
1452
+ * `result.unavailable` an array of positive-integer ids.
1453
+ */
1454
+ export function isValidEntityReactionsResult(p) {
1455
+ if (!isObject(p))
1456
+ return false;
1457
+ if (!isWireRequestIdShape(p.requestId))
1458
+ return false;
1459
+ if (p.error !== undefined && typeof p.error !== 'string')
1460
+ return false;
1461
+ if (p.result !== undefined) {
1462
+ const r = p.result;
1463
+ if (!isObject(r))
1464
+ return false;
1465
+ if (!isNonEmptyString(r.entityType))
1466
+ return false;
1467
+ if (!isObject(r.byId))
1468
+ return false;
1469
+ for (const [key, state] of Object.entries(r.byId)) {
1470
+ if (!/^[1-9][0-9]*$/.test(key))
1471
+ return false;
1472
+ if (!isValidEntityReactionState(state))
1473
+ return false;
1474
+ }
1475
+ if (!Array.isArray(r.unavailable))
1476
+ return false;
1477
+ for (const id of r.unavailable) {
1478
+ if (!isPositiveIntId(id))
1479
+ return false;
1480
+ }
1481
+ }
1482
+ if (p.result === undefined && p.error === undefined)
1483
+ return false;
1484
+ return true;
1485
+ }
1371
1486
  /**
1372
1487
  * Reply to a block-initiated `CREATE_POST_FROM_APP`. A well-formed reply carries
1373
1488
  * EITHER a `result` (`{ postId: positive int, url: non-empty string, imageIds:
@@ -2167,6 +2282,10 @@ export function payloadValidatorFor(type) {
2167
2282
  return isValidImagesResult;
2168
2283
  case 'COLLECTION_FOLLOW_RESULT':
2169
2284
  return isValidCollectionFollowResult;
2285
+ case 'ENTITY_REACTION_RESULT':
2286
+ return isValidEntityReactionResult;
2287
+ case 'ENTITY_REACTIONS_RESULT':
2288
+ return isValidEntityReactionsResult;
2170
2289
  // 🔴 PRESENCE of this entry IS compiler-enforced (the `never` bind in
2171
2290
  // `default:`); WHICH validator it names is not. This reply settles a PUBLIC
2172
2291
  // POST, so the identity is pinned by test as well — see the mapping pin in