@stripchatdev/create-stripchat-extension 0.1.49 → 0.2.1
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/bin/create.js +21 -5
- package/dist/package.json +1 -1
- package/dist/src/helpers/hasUI.d.ts +2 -0
- package/dist/src/helpers/hasUI.js +1 -0
- package/dist/src/helpers/hasUI.test.d.ts +1 -0
- package/dist/src/helpers/hasUI.test.js +15 -0
- package/dist/src/projectConfig/buildProjectConfig.d.ts +1 -0
- package/dist/src/projectConfig/buildProjectConfig.js +2 -1
- package/dist/src/projectConfig/updateProjectConfig.js +2 -1
- package/dist/src/projectConfig/validators/getIsValidProjectConfig.test.js +1 -0
- package/dist/src/templater/executor.test.js +39 -2
- package/dist/src/templater/mappers/mapProjectConfigToTemplateModel.test.js +35 -6
- package/dist/src/templater/planner.js +8 -5
- package/dist/src/templater/planner.test.js +51 -3
- package/dist/src/types.d.ts +2 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/templates/v1/AGENTS.md.ejs +59 -133
- package/templates/v1/README.md.ejs +6 -1
- package/templates/v1/package.json.ejs +1 -1
- package/templates/v1/public/manifest.json.ejs +3 -0
- package/templates/v1/src/slots/background/app.ts.ejs +4 -4
- package/templates/v1/src/slots/settings/app.ts.ejs +84 -0
- package/templates/v1/src/slots/settings/app.tsx.ejs +112 -0
- package/templates/v1/src/slots/settings/main.css.ejs +117 -0
- package/templates/v1/src/slots/settings/main.ts.ejs +3 -0
- package/templates/v1/src/slots/settings/main.tsx.ejs +9 -0
- package/templates/v1/src/slots/settings/settings.html.ejs +30 -0
- package/templates/v1/vite.config.ts.ejs +3 -0
- package/templates/v2/AGENTS.md.ejs +61 -18
- package/templates/v2/vite.config.ts.ejs +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# <%= name %>
|
|
2
2
|
Use this file as the primary coding contract for this starter. Use the Stripchat docs to get more details.
|
|
3
|
-
- Category: <%=
|
|
3
|
+
- Category: <%= category %>
|
|
4
4
|
- Slots: <%= slotsEntries.map(({ slot }) => slot).join(', ') %>
|
|
5
5
|
- Framework: <%= framework %>
|
|
6
6
|
- Language: <%= language %>
|
|
@@ -47,7 +47,7 @@ Use this file as the primary coding contract for this starter. Use the Stripchat
|
|
|
47
47
|
- Use v1.ext.whisper.local for communication between this extension's frames for the current user.
|
|
48
48
|
- Use v1.ext.whisper only when every loaded client in the room should receive the message.
|
|
49
49
|
- When a user sends v1.ext.whisper during a public show, paymentHash is required. Obtain it from v1.payment.tokens.spend.succeeded. The model can whisper without paymentHash.
|
|
50
|
-
- Broadcast whispers arrive as
|
|
50
|
+
- Broadcast whispers arrive as v2.ext.whispered with the envelope `{ data, tokenSum }`. Treat `tokenSum` as authoritative actual payment; Validate `tokenSum` against actual activity price. It could be null for free actions, e.g. whispers called by a model.
|
|
51
51
|
- Local whispers arrive as v1.ext.whispered.local
|
|
52
52
|
- Always include a stable type field in whisper payloads.
|
|
53
53
|
- Do not send sensitive data in whisper payloads, including usernames or user IDs.
|
|
@@ -73,10 +73,7 @@ Use this file as the primary coding contract for this starter. Use the Stripchat
|
|
|
73
73
|
- Keep public/manifest.json valid JSON with no trailing commas.
|
|
74
74
|
- In TypeScript, import types from @stripchatdev/ext-helper and use them
|
|
75
75
|
- ALWAYS USE background slot to handle long-running orchestration logic — timers, polling, state machines, centralized state management, events, whispers
|
|
76
|
-
-
|
|
77
|
-
- The background slot owns the state. Visual slots read it and send partial updates.
|
|
78
|
-
- Use the same channel string for the host and every client. Create only one host for each channel.
|
|
79
|
-
- For messages that are not synchronized state, use v1.ext.whisper.local for communication between this extension's frames for the current user. Background should subscribe to events and coordinate it.
|
|
76
|
+
- The flow should be: visual slot -> whisper.local to background -> background handles logic. Background should subscribe to events and coordinate it
|
|
80
77
|
- Create background slot for each actor (model, viewer). Do not put model-only and viewer-only logic in the same background.
|
|
81
78
|
- Chatbot extensions have no UI — only a background slot handles all logic (receiving messages and sending chatbot messages).
|
|
82
79
|
- Do not create visual slots, HTML pages, or resolver entries for chatbot extensions.
|
|
@@ -90,10 +87,6 @@ Use this file as the primary coding contract for this starter. Use the Stripchat
|
|
|
90
87
|
- videoDecorativeOverlay is not interactive. Do not put buttons or interactive elements in it. Use it only for animations, visualizations etc. after calling `v1.ext.activity.request`.
|
|
91
88
|
- When using `v1.ext.activity.request`, ALWAYS make sure that the actual animation is shorter then the activity duration in order to avoid immidiate slot closing after the animation ends.
|
|
92
89
|
|
|
93
|
-
<% if (framework === 'preact') { -%>
|
|
94
|
-
- Use useSlotState from @stripchatdev/ext-helper/helpers/react in React-compatible visual slots.
|
|
95
|
-
<% } -%>
|
|
96
|
-
|
|
97
90
|
## Slot Guardrails
|
|
98
91
|
- Generated slot sets for this starter:
|
|
99
92
|
- Games & Fun: EXTENSION_SLOT_MAIN_GAME_FUN, EXTENSION_SLOT_BACKGROUND, either one of EXTENSION_SLOT_RIGHT_OVERLAY or EXTENSION_SLOT_VIDEO_DECORATIVE_OVERLAY
|
|
@@ -110,19 +103,12 @@ Use this file as the primary coding contract for this starter. Use the Stripchat
|
|
|
110
103
|
## Default Runtime Pattern
|
|
111
104
|
- background owns long-lived state and coordination.
|
|
112
105
|
- Visual slots render UI from current state and forward user actions.
|
|
113
|
-
- Use slot-state helpers when visual slots need a synchronized state snapshot owned by background.
|
|
114
106
|
- Load initial context once with v1.ext.context.get.
|
|
115
107
|
- Keep context fresh with v1.ext.context.updated. Payload is { context: TV1ExtContext }.
|
|
116
108
|
- Send state updates to every frame of the current user with v1.ext.whisper.local.
|
|
117
109
|
- If all loaded clients need the event, use v1.ext.whisper.
|
|
118
110
|
- Assume every visual slot can mount, unmount, and remount without notice.
|
|
119
111
|
|
|
120
|
-
## Shared Slot State helpers
|
|
121
|
-
- Use the same channel value in the host and all clients.
|
|
122
|
-
- The client starts with isLoading: true and state: null until it receives the initial state.
|
|
123
|
-
- Use `setState` for shallow partial updates. Update nested objects explicitly.
|
|
124
|
-
- Unsubscribe clients when visual slots are disposed and unsubscribe the host when background teardown runs.
|
|
125
|
-
|
|
126
112
|
## Resolver
|
|
127
113
|
- Start from the generated resolver and extend it only when needed.
|
|
128
114
|
- Keep resolver logic cheap, synchronous, and defensive.
|
|
@@ -213,7 +199,6 @@ TV1ExtLoggedInUser:
|
|
|
213
199
|
- isGuest: false
|
|
214
200
|
- id: number
|
|
215
201
|
- username: string
|
|
216
|
-
- fanClub: TV1UsersFanClub
|
|
217
202
|
- status: 'public' | 'private' | 'exclusivePrivate' | 'groupShow' | 'ticketShow' | 'spy' | 'off'
|
|
218
203
|
- hasTokens: boolean
|
|
219
204
|
- hasPaidBefore: boolean
|
|
@@ -222,26 +207,11 @@ TV1ExtLoggedInUser:
|
|
|
222
207
|
- `isModel` means the logged-in user has a model account. It does not mean they are the broadcaster of the current room.
|
|
223
208
|
- userRanking?: { league: 'grey' | 'bronze' | 'silver' | 'gold' | 'diamond' | 'royal' | 'legend'; level: number }
|
|
224
209
|
|
|
225
|
-
###
|
|
226
|
-
|
|
227
|
-
-
|
|
228
|
-
-
|
|
229
|
-
-
|
|
230
|
-
|
|
231
|
-
### TV1UsersFanClub
|
|
232
|
-
Discriminated union on `isSubscribed`: TV1UsersFanClubSubscribed | TV1UsersFanClubNotSubscribed.
|
|
233
|
-
|
|
234
|
-
TV1UsersFanClubSubscribed:
|
|
235
|
-
- isSubscribed: true
|
|
236
|
-
- tier: TV1FanClubTier
|
|
237
|
-
|
|
238
|
-
TV1UsersFanClubNotSubscribed:
|
|
239
|
-
- isSubscribed: false
|
|
240
|
-
|
|
241
|
-
### TV1PaymentHash
|
|
242
|
-
- string
|
|
243
|
-
- Payment hash used for authentication. Do not store or reuse it. Use it only once for the request it authorizes.
|
|
244
|
-
|
|
210
|
+
### TV1PaymentData
|
|
211
|
+
- amount: string
|
|
212
|
+
- paymentToken: string
|
|
213
|
+
- transactionId: string
|
|
214
|
+
- userId: string
|
|
245
215
|
|
|
246
216
|
### TV1TipData (payload of v1.tokens.spent)
|
|
247
217
|
- amount: number
|
|
@@ -264,7 +234,7 @@ TV1UsersFanClubNotSubscribed:
|
|
|
264
234
|
- v1.ext.context.updated -> react to context changes. Event payload is { context: TV1ExtContext }.
|
|
265
235
|
- v1.ext.whisper.local -> send a message to every frame of the current user.
|
|
266
236
|
- v1.ext.whisper -> broadcast to all loaded clients in the room. Users in public shows must include paymentHash from v1.payment.tokens.spend.succeeded.
|
|
267
|
-
-
|
|
237
|
+
- v2.ext.whispered -> receives broadcasts from v1.ext.whisper. The event payload is `{ data, tokenSum }`, where custom fields are in `data`;
|
|
268
238
|
- v1.ext.whispered.local -> receives whisper.local messages.
|
|
269
239
|
- v1.chat.message.send -> send a chat message on behalf of the extension. The message is sent locally to each user. Always pass isAnonymous from the payment/tip source and preserve the user's anonymity. It can include a {username} placeholder. You can highlight part of the text by using {#accent}Accent text{/accent} and {#fade}Faded text{/fade}. ALWAYS use it to notify a user about game results, e.g. after v1.ext.activity.request timeout.
|
|
270
240
|
- v1.chatbot.message.send -> send a chat message as bot or model. Message is delivered locally to each user. Supports {username} placeholder in message text. Params: from ('bot' | 'model'), message (string). Use it only in chatbot extensions.
|
|
@@ -279,7 +249,6 @@ TV1UsersFanClubNotSubscribed:
|
|
|
279
249
|
- v1.payment.tokens.topup -> open the token purchase modal directly for an explicit "Buy tokens" CTA. Params null, result null. It does not confirm a spend; use v1.payment.tokens.spend when you need a confirmed payment for a game or interaction.
|
|
280
250
|
- v1.tokens.spent -> receive room-side tip events. Should be used in background to react on tips (e.g. to send a message in chat).
|
|
281
251
|
- v1.private.request -> request a private show with the broadcaster.
|
|
282
|
-
- v1.fanClub.request -> open the fan club subscription modal for the current model. Check `user.fanClub` in context before calling. Params: `{ tier: TV1FanClubTier } | null` — `tier` selects which tab to show in the modal; pass `null` to default to `soldier`. Result null.
|
|
283
252
|
- v1.ext.signup.open -> open sign-up for anonymous users.
|
|
284
253
|
- v1.ext.activity.request -> Reserves exclusive access to shared activity slots (`EXTENSION_SLOT_RIGHT_OVERLAY`, `EXTENSION_SLOT_VIDEO_DECORATIVE_OVERLAY`) for a short visual action (for example: reactions, wheel-of-fortune spin, or a brief action indicator). The call waits until the slot is available, then starts the reservation for `durationMs` (max `30000` ms) and returns `{ activityId: string }`. Reservation is ephemeral (not persisted across reboots and not restored for newly joined users); for long-lived, persistent flows use `v1.session.*`.
|
|
285
254
|
- v1.ext.movableOverlay.update -> update moveableOverlay status pill (status + message). Sex Toys and Broadcast Tools only.
|
|
@@ -300,97 +269,45 @@ TV1UsersFanClubNotSubscribed:
|
|
|
300
269
|
- v1.goal.get -> load the current active regular goal. Returns `{ goal: TV1GoalData | null }`. `goal` is `null` when no regular goal is active.
|
|
301
270
|
- v1.goal.updated -> fires when any field of the active goal changes (description, isCompleted, tokensTarget, tokensCollected) or the active goal is removed. Payload: `{ goal: TV1GoalData | null }`. Use it in background to keep the cached goal in sync and broadcast updates to visual slots via v1.ext.whisper.local.
|
|
302
271
|
- v1.session.join.model -> model-only create/start of a long-term session when none exists. If a session is already active, this call does not recreate/replace it.
|
|
303
|
-
- v1.session.join.user -> user-only join after payment (`paymentHash`
|
|
272
|
+
- v1.session.join.user -> user-only join after payment (`paymentHash` is required and is taken from `paymentHash` in `v1.payment.tokens.spend.succeeded`). If no session is active, this call can create one.
|
|
304
273
|
- v1.session.get -> returns the current long-term session. Before enabling Start/Join UI, check `v1.session.get`: if `session.owner === 'other'`, keep controls blocked because another extension owns the active session. TTL is applied from whoever created the active session first (model or user).
|
|
305
274
|
- v1.session.finish.model -> clear the current long-term session from the model side.
|
|
306
275
|
- v1.session.whisper.user -> send a custom action payload within the current long-term session from the user to the model.
|
|
307
276
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
<% if (framework === 'preact') { -%>
|
|
330
|
-
Visual slot:
|
|
331
|
-
```
|
|
332
|
-
import { createExtHelper } from '@stripchatdev/ext-helper';
|
|
333
|
-
import { useSlotState } from '@stripchatdev/ext-helper/helpers/react';
|
|
334
|
-
|
|
335
|
-
type ScoreState = {
|
|
336
|
-
score: number;
|
|
337
|
-
};
|
|
338
|
-
|
|
339
|
-
const ext = createExtHelper();
|
|
340
|
-
|
|
341
|
-
export const App = () => {
|
|
342
|
-
const { isLoading, state, setState } = useSlotState<ScoreState>({
|
|
343
|
-
channel: 'score',
|
|
344
|
-
extHelper: ext,
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
if (isLoading || !state) return <p>Loading score...</p>;
|
|
348
|
-
|
|
349
|
-
return (
|
|
350
|
-
<button onClick={() => void setState({ score: state.score + 1 })}>
|
|
351
|
-
Score: {state.score}
|
|
352
|
-
</button>
|
|
353
|
-
);
|
|
277
|
+
### Validate broadcast payment before acting
|
|
278
|
+
`tokenSum` on `v2.ext.whispered` is the authoritative number of tokens actually paid. In a runtime slot, load the expected action price with `v1.ext.settings.get` (or use a constant for defining a price) and narrow the returned settings value before acting;
|
|
279
|
+
|
|
280
|
+
```js
|
|
281
|
+
const loadExpectedPrice = async () => {
|
|
282
|
+
try {
|
|
283
|
+
const { settings } = await ext.makeRequest('v1.ext.settings.get', null);
|
|
284
|
+
if (
|
|
285
|
+
typeof settings !== 'object' ||
|
|
286
|
+
settings === null ||
|
|
287
|
+
// priceTokens is an example field, you can name it as you wan't
|
|
288
|
+
!('priceTokens' in settings) ||
|
|
289
|
+
!isValidPrice(settings.priceTokens)
|
|
290
|
+
) {
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
return settings.priceTokens;
|
|
294
|
+
} catch {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
354
297
|
};
|
|
355
|
-
```
|
|
356
|
-
<% } else { -%>
|
|
357
|
-
Visual slot:
|
|
358
|
-
```
|
|
359
|
-
import { createExtHelper } from '@stripchatdev/ext-helper';
|
|
360
|
-
import { createSlotStateClient } from '@stripchatdev/ext-helper/helpers';
|
|
361
298
|
|
|
362
|
-
|
|
363
|
-
score: number;
|
|
364
|
-
};
|
|
299
|
+
const expectedPrice = await loadExpectedPrice();
|
|
365
300
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
document.body.append(button);
|
|
301
|
+
// later in the background slot
|
|
302
|
+
extHelper.susbcribe('v2.ext.whispered', ({ data, tokenSum }) => {
|
|
303
|
+
// tokenSum is null for a model or in private shows
|
|
304
|
+
if (tokenSum !== null && tokenSum !== expectedPrice) return;
|
|
305
|
+
// otherwise proceed with an action
|
|
306
|
+
})
|
|
373
307
|
|
|
374
|
-
const render = () => {
|
|
375
|
-
const { isLoading, state } = scoreState.getSnapshot();
|
|
376
|
-
button.textContent = isLoading || !state ? 'Loading score...' : `Score: ${state.score}`;
|
|
377
|
-
};
|
|
378
|
-
const addPoint = () => {
|
|
379
|
-
const state = scoreState.getSnapshot().state;
|
|
380
|
-
if (state) void scoreState.setState({ score: state.score + 1 });
|
|
381
|
-
};
|
|
382
|
-
|
|
383
|
-
const unsubscribe = scoreState.subscribe(render);
|
|
384
|
-
button.addEventListener('click', addPoint);
|
|
385
|
-
const cleanup = () => {
|
|
386
|
-
unsubscribe();
|
|
387
|
-
button.removeEventListener('click', addPoint);
|
|
388
|
-
button.remove();
|
|
389
|
-
};
|
|
390
308
|
```
|
|
391
|
-
<% } -%>
|
|
392
309
|
|
|
393
|
-
|
|
310
|
+
## Canonical Examples and Practices
|
|
394
311
|
|
|
395
312
|
### Settings save flow in settings.html
|
|
396
313
|
```
|
|
@@ -511,7 +428,7 @@ const spendForRound = async () => {
|
|
|
511
428
|
});
|
|
512
429
|
};
|
|
513
430
|
const onTokensSpendSucceeded = (data) => {
|
|
514
|
-
console.log('Spend confirmed:', data.
|
|
431
|
+
console.log('Spend confirmed:', data.paymentData.amount, data.tokensSpendData);
|
|
515
432
|
};
|
|
516
433
|
ext.subscribe('v1.payment.tokens.spend.succeeded', onTokensSpendSucceeded);
|
|
517
434
|
const cleanup = () => {
|
|
@@ -549,7 +466,7 @@ const cleanup = () => {
|
|
|
549
466
|
};
|
|
550
467
|
```
|
|
551
468
|
|
|
552
|
-
### User-initiated whisper with payment
|
|
469
|
+
### User-initiated whisper with payment token (full flow)
|
|
553
470
|
When a user needs to broadcast a whisper during a public show, paymentHash is required.
|
|
554
471
|
Flow: visual slot calls v1.payment.tokens.spend -> user background listens to v1.payment.tokens.spend.succeeded -> user background sends v1.ext.whisper with paymentHash.
|
|
555
472
|
|
|
@@ -587,33 +504,43 @@ All clients receiving the broadcast:
|
|
|
587
504
|
```
|
|
588
505
|
import { createExtHelper } from '@stripchatdev/ext-helper';
|
|
589
506
|
const ext = createExtHelper();
|
|
590
|
-
const
|
|
591
|
-
|
|
507
|
+
const expectedPrice = 10;
|
|
508
|
+
const onWhispered = async ({ data, tokenSum }) => {
|
|
509
|
+
if (data.type !== 'SPIN_RESULT' || typeof data.value !== 'number') {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
// validate that tokenSum is the same as expectedPrice.
|
|
513
|
+
if (tokenSum !== null && tokenSum !== expectedPrice) {
|
|
592
514
|
return;
|
|
593
515
|
}
|
|
594
516
|
showResult(data.value);
|
|
595
517
|
};
|
|
596
|
-
ext.subscribe('
|
|
518
|
+
ext.subscribe('v2.ext.whispered', onWhispered);
|
|
597
519
|
const cleanup = () => {
|
|
598
|
-
ext.unsubscribe('
|
|
520
|
+
ext.unsubscribe('v2.ext.whispered', onWhispered);
|
|
599
521
|
};
|
|
600
522
|
```
|
|
601
523
|
|
|
602
524
|
### Activity request flow
|
|
603
|
-
In rightOverlay or videoDecorativeOverlay (after user
|
|
525
|
+
In rightOverlay or videoDecorativeOverlay (after user initiated whisper with payment token):
|
|
604
526
|
|
|
605
527
|
```
|
|
606
528
|
import { createExtHelper } from '@stripchatdev/ext-helper';
|
|
607
529
|
const ext = createExtHelper();
|
|
608
|
-
|
|
530
|
+
const expectedPrice = 10;
|
|
609
531
|
const ANIMATION_DURATION = 8_000;
|
|
610
532
|
const ACTIVITY_DURATION = 10_000;
|
|
611
533
|
|
|
612
|
-
const handleWhispered = async (data) => {
|
|
613
|
-
if (
|
|
534
|
+
const handleWhispered = async ({ data, tokenSum }) => {
|
|
535
|
+
if (data.type !== 'SPIN_RESULT' || typeof data.value !== 'number') {
|
|
614
536
|
return;
|
|
615
537
|
};
|
|
616
538
|
|
|
539
|
+
// validate that tokenSum is the same as expectedPrice.
|
|
540
|
+
if (tokenSum !== null && tokenSum !== expectedPrice)
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
617
544
|
await ext.makeRequest('v1.ext.activity.request', {
|
|
618
545
|
durationMs: ACTIVITY_DURATION,
|
|
619
546
|
});
|
|
@@ -631,7 +558,7 @@ const handleWhispered = async (data) => {
|
|
|
631
558
|
});
|
|
632
559
|
};
|
|
633
560
|
|
|
634
|
-
ext.subscribe('
|
|
561
|
+
ext.subscribe('v2.ext.whispered', handleWhispered);
|
|
635
562
|
```
|
|
636
563
|
UI blocking should be handled in UI by listening to the activity changes. For example, in mainGameFun slot:
|
|
637
564
|
```
|
|
@@ -795,4 +722,3 @@ void init();
|
|
|
795
722
|
- Events: https://extensions.stripchat.com/docs/api/events
|
|
796
723
|
- Entities: https://extensions.stripchat.com/docs/api/entities
|
|
797
724
|
- Session guide: https://extensions.stripchat.com/docs/guides/session-extension
|
|
798
|
-
- Slots state: https://extensions.stripchat.com/docs/guides/slot-state
|
|
@@ -11,6 +11,11 @@ Read `AGENTS.md` first. It is the project contract for SDK usage, slot lifecycle
|
|
|
11
11
|
- `public/manifest.json` declares slots, page keys, optional settings entry, and backend actions.
|
|
12
12
|
- `public/resolveSlotPage.js` maps slot types to page keys from `views.pages`.
|
|
13
13
|
- Root HTML files like `<%= slotsEntries[0].slotCamelCased %>.html` and `background.html` are emitted to the archive root.
|
|
14
|
+
<% if (hasSettings) { -%>
|
|
15
|
+
- `settings.html` is the model-facing settings iframe.
|
|
16
|
+
<% } else { -%>
|
|
17
|
+
- To add model-facing settings later, create `settings.html`, add `views.settings`, and add its Vite input.
|
|
18
|
+
<% } -%>
|
|
14
19
|
- Visual slots should stay disposable; keep durable state and coordination in `background`.
|
|
15
20
|
- `vite.config.<%= isTypescript ? 'ts' : 'js' %>` must include every root HTML file in `build.rollupOptions.input`.
|
|
16
21
|
|
|
@@ -32,7 +37,7 @@ Learn more about building Stripchat extensions in the [official documentation](h
|
|
|
32
37
|
## Common Changes
|
|
33
38
|
|
|
34
39
|
- Add a page: create a root HTML file, add it to `public/manifest.json`, return its page key from `public/resolveSlotPage.js`, and include it in `vite.config.<%= isTypescript ? 'ts' : 'js' %>`.
|
|
35
|
-
-
|
|
40
|
+
- Settings are loaded with `v1.model.ext.settings.get` and saved on `v1.model.ext.settings.set.requested` through `v1.model.ext.settings.set`.
|
|
36
41
|
- Add an action: declare it in `public/manifest.json` and call it with `v1.ext.actions.call`.
|
|
37
42
|
|
|
38
43
|
## Usage
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"preact": "10.25.4"<% } %>
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
|
-
"vite": "6.4.
|
|
15
|
+
"vite": "6.4.3"<% if (isTypescript) { %>,
|
|
16
16
|
"typescript": "5.8.2"<% } %><% if (framework === 'preact') { %>,
|
|
17
17
|
"@preact/preset-vite": "2.10.5"<% } %>
|
|
18
18
|
}
|
|
@@ -6,10 +6,10 @@ import { createExtHelper } from '@stripchatdev/ext-helper';
|
|
|
6
6
|
const extHelper = createExtHelper();
|
|
7
7
|
|
|
8
8
|
export const init = () => {
|
|
9
|
-
const handleWhispered = async (
|
|
9
|
+
const handleWhispered = async (event<%- isTypescript ? ': TEvents["v2.ext.whispered"]' : '' %>) => {
|
|
10
10
|
await extHelper.makeRequest('v1.monitoring.report.log', {
|
|
11
11
|
message: 'background whisper received',
|
|
12
|
-
data,
|
|
12
|
+
data: event.data,
|
|
13
13
|
});
|
|
14
14
|
};
|
|
15
15
|
|
|
@@ -20,7 +20,7 @@ export const init = () => {
|
|
|
20
20
|
});
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
-
extHelper.subscribe('
|
|
23
|
+
extHelper.subscribe('v2.ext.whispered', handleWhispered);
|
|
24
24
|
extHelper.subscribe('v1.ext.context.updated', handleContextUpdated);
|
|
25
25
|
|
|
26
26
|
extHelper.makeRequest('v1.ext.context.get', null).then((ctx) => {
|
|
@@ -31,7 +31,7 @@ export const init = () => {
|
|
|
31
31
|
});
|
|
32
32
|
|
|
33
33
|
return () => {
|
|
34
|
-
extHelper.unsubscribe('
|
|
34
|
+
extHelper.unsubscribe('v2.ext.whispered', handleWhispered);
|
|
35
35
|
extHelper.unsubscribe('v1.ext.context.updated', handleContextUpdated);
|
|
36
36
|
};
|
|
37
37
|
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { createExtHelper } from '@stripchatdev/ext-helper';
|
|
2
|
+
|
|
3
|
+
const extHelper = createExtHelper();
|
|
4
|
+
|
|
5
|
+
<% if (isTypescript) { -%>
|
|
6
|
+
type TSettings = {
|
|
7
|
+
viewerMessage: string;
|
|
8
|
+
};
|
|
9
|
+
<% } -%>
|
|
10
|
+
|
|
11
|
+
const DEFAULT_SETTINGS<%- isTypescript ? ': TSettings' : '' %> = {
|
|
12
|
+
viewerMessage: '',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const toSettings = (value<%- isTypescript ? ': unknown' : '' %>)<%- isTypescript ? ': TSettings' : '' %> => {
|
|
16
|
+
if (!value || typeof value !== 'object') {
|
|
17
|
+
return DEFAULT_SETTINGS;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const input<%- isTypescript ? ': Record<string, unknown>' : '' %> = <%- isTypescript ? 'value as Record<string, unknown>' : 'value' %>;
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
viewerMessage:
|
|
24
|
+
typeof input.viewerMessage === 'string'
|
|
25
|
+
? input.viewerMessage
|
|
26
|
+
: DEFAULT_SETTINGS.viewerMessage,
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export const init = () => {
|
|
31
|
+
const viewerMessageInput = document.getElementById('viewer-message')<%- isTypescript ? ' as HTMLInputElement' : '' %>;
|
|
32
|
+
const errorMessage = document.getElementById('settings-error')<%- isTypescript ? ' as HTMLParagraphElement' : '' %>;
|
|
33
|
+
let settingsLoaded = false;
|
|
34
|
+
|
|
35
|
+
const renderValidation = (message<%- isTypescript ? ': string' : '' %>) => {
|
|
36
|
+
errorMessage.textContent = message;
|
|
37
|
+
viewerMessageInput.setAttribute('aria-invalid', String(message.length > 0));
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const applySettings = (settings<%- isTypescript ? ': TSettings' : '' %>) => {
|
|
41
|
+
viewerMessageInput.value = settings.viewerMessage;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const readSettings = ()<%- isTypescript ? ': TSettings' : '' %> => ({
|
|
45
|
+
viewerMessage: viewerMessageInput.value,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const loadSettings = async () => {
|
|
49
|
+
try {
|
|
50
|
+
const response = await extHelper.makeRequest('v1.model.ext.settings.get', null);
|
|
51
|
+
applySettings(toSettings(response.settings));
|
|
52
|
+
} catch {
|
|
53
|
+
applySettings(DEFAULT_SETTINGS);
|
|
54
|
+
} finally {
|
|
55
|
+
settingsLoaded = true;
|
|
56
|
+
viewerMessageInput.removeAttribute('disabled');
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const onSettingsSaveRequested = async () => {
|
|
61
|
+
const settings = readSettings();
|
|
62
|
+
const isError = !settingsLoaded || settings.viewerMessage.trim().length === 0;
|
|
63
|
+
|
|
64
|
+
renderValidation(
|
|
65
|
+
!settingsLoaded
|
|
66
|
+
? 'Settings are still loading.'
|
|
67
|
+
: isError
|
|
68
|
+
? 'Enter a viewer message.'
|
|
69
|
+
: '',
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
await extHelper.makeRequest('v1.model.ext.settings.set', {
|
|
73
|
+
settings,
|
|
74
|
+
isError,
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
extHelper.subscribe('v1.model.ext.settings.set.requested', onSettingsSaveRequested);
|
|
79
|
+
void loadSettings();
|
|
80
|
+
|
|
81
|
+
return () => {
|
|
82
|
+
extHelper.unsubscribe('v1.model.ext.settings.set.requested', onSettingsSaveRequested);
|
|
83
|
+
};
|
|
84
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createExtHelper } from '@stripchatdev/ext-helper';
|
|
2
|
+
import { useEffect, useState } from 'preact/hooks';
|
|
3
|
+
|
|
4
|
+
const extHelper = createExtHelper();
|
|
5
|
+
|
|
6
|
+
<% if (isTypescript) { -%>
|
|
7
|
+
type TSettings = {
|
|
8
|
+
viewerMessage: string;
|
|
9
|
+
};
|
|
10
|
+
<% } -%>
|
|
11
|
+
|
|
12
|
+
const DEFAULT_SETTINGS<%- isTypescript ? ': TSettings' : '' %> = {
|
|
13
|
+
viewerMessage: '',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const toSettings = (value<%- isTypescript ? ': unknown' : '' %>)<%- isTypescript ? ': TSettings' : '' %> => {
|
|
17
|
+
if (!value || typeof value !== 'object') {
|
|
18
|
+
return DEFAULT_SETTINGS;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const input<%- isTypescript ? ': Record<string, unknown>' : '' %> = <%- isTypescript ? 'value as Record<string, unknown>' : 'value' %>;
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
viewerMessage:
|
|
25
|
+
typeof input.viewerMessage === 'string'
|
|
26
|
+
? input.viewerMessage
|
|
27
|
+
: DEFAULT_SETTINGS.viewerMessage,
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const App = () => {
|
|
32
|
+
const [settings, setSettings] = useState<%- isTypescript ? '<TSettings>' : '' %>(DEFAULT_SETTINGS);
|
|
33
|
+
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
|
34
|
+
const [validationMessage, setValidationMessage] = useState('');
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
let isMounted = true;
|
|
38
|
+
|
|
39
|
+
const loadSettings = async () => {
|
|
40
|
+
try {
|
|
41
|
+
const response = await extHelper.makeRequest('v1.model.ext.settings.get', null);
|
|
42
|
+
|
|
43
|
+
if (isMounted) {
|
|
44
|
+
setSettings(toSettings(response.settings));
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
if (isMounted) {
|
|
48
|
+
setSettings(DEFAULT_SETTINGS);
|
|
49
|
+
}
|
|
50
|
+
} finally {
|
|
51
|
+
if (isMounted) {
|
|
52
|
+
setSettingsLoaded(true);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
void loadSettings();
|
|
58
|
+
|
|
59
|
+
return () => {
|
|
60
|
+
isMounted = false;
|
|
61
|
+
};
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const onSettingsSaveRequested = async () => {
|
|
66
|
+
const isError = !settingsLoaded || settings.viewerMessage.trim().length === 0;
|
|
67
|
+
|
|
68
|
+
setValidationMessage(
|
|
69
|
+
!settingsLoaded
|
|
70
|
+
? 'Settings are still loading.'
|
|
71
|
+
: isError
|
|
72
|
+
? 'Enter a viewer message.'
|
|
73
|
+
: '',
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
await extHelper.makeRequest('v1.model.ext.settings.set', {
|
|
77
|
+
settings,
|
|
78
|
+
isError,
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
extHelper.subscribe('v1.model.ext.settings.set.requested', onSettingsSaveRequested);
|
|
83
|
+
|
|
84
|
+
return () => {
|
|
85
|
+
extHelper.unsubscribe('v1.model.ext.settings.set.requested', onSettingsSaveRequested);
|
|
86
|
+
};
|
|
87
|
+
}, [settings, settingsLoaded]);
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<main>
|
|
91
|
+
<h1>Extension settings</h1>
|
|
92
|
+
<form>
|
|
93
|
+
<label className="message-field" for="viewer-message">
|
|
94
|
+
Viewer message
|
|
95
|
+
<input
|
|
96
|
+
className="input"
|
|
97
|
+
id="viewer-message"
|
|
98
|
+
type="text"
|
|
99
|
+
aria-invalid={validationMessage.length > 0}
|
|
100
|
+
value={settings.viewerMessage}
|
|
101
|
+
disabled={!settingsLoaded}
|
|
102
|
+
onInput={(event) => {
|
|
103
|
+
const target = event.currentTarget<%- isTypescript ? ' as HTMLInputElement' : '' %>;
|
|
104
|
+
setSettings({ ...settings, viewerMessage: target.value });
|
|
105
|
+
}}
|
|
106
|
+
/>
|
|
107
|
+
</label>
|
|
108
|
+
<p className="error" role="alert" aria-live="polite">{validationMessage}</p>
|
|
109
|
+
</form>
|
|
110
|
+
</main>
|
|
111
|
+
);
|
|
112
|
+
};
|