@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/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
|
+
* 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
|
+
* 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
|