@dcl-regenesislabs/web-editor 0.0.0-stage → 0.1.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.
Files changed (277) hide show
  1. package/editor.css +1 -0
  2. package/editor.js +33891 -0
  3. package/package.json +8 -4
  4. package/prefabs/admin-tools/composite.json +77 -0
  5. package/prefabs/admin-tools/data.json +28 -0
  6. package/prefabs/admin-tools/icons/admin-panel-control-button.png +0 -0
  7. package/prefabs/admin-tools/icons/admin-panel-moderation-control-button.png +0 -0
  8. package/prefabs/admin-tools/icons/admin-panel-rewards-control-button.png +0 -0
  9. package/prefabs/admin-tools/icons/admin-panel-smart-item-control-button.png +0 -0
  10. package/prefabs/admin-tools/icons/admin-panel-text-announcement-control-button.png +0 -0
  11. package/prefabs/admin-tools/icons/admin-panel-video-control-button.png +0 -0
  12. package/prefabs/admin-tools/icons/admin-tool-background.png +0 -0
  13. package/prefabs/admin-tools/icons/announcements/text-announcement-chat-message.png +0 -0
  14. package/prefabs/admin-tools/icons/announcements/text-announcement-check.png +0 -0
  15. package/prefabs/admin-tools/icons/announcements/text-announcement-close-button.png +0 -0
  16. package/prefabs/admin-tools/icons/moderation/admin-panel-verified-user.png +0 -0
  17. package/prefabs/admin-tools/icons/moderation/ban.png +0 -0
  18. package/prefabs/admin-tools/icons/moderation/chevron-back.png +0 -0
  19. package/prefabs/admin-tools/icons/moderation/chevron-forward.png +0 -0
  20. package/prefabs/admin-tools/icons/moderation/close.png +0 -0
  21. package/prefabs/admin-tools/icons/moderation/error.png +0 -0
  22. package/prefabs/admin-tools/icons/moderation/person-outline.png +0 -0
  23. package/prefabs/admin-tools/icons/moderation-control-icon.png +0 -0
  24. package/prefabs/admin-tools/icons/rewards/close.png +0 -0
  25. package/prefabs/admin-tools/icons/rewards/send.png +0 -0
  26. package/prefabs/admin-tools/icons/rewards-control.png +0 -0
  27. package/prefabs/admin-tools/icons/smart-item-control.png +0 -0
  28. package/prefabs/admin-tools/icons/text-announcement-control.png +0 -0
  29. package/prefabs/admin-tools/icons/video/chevron-down.png +0 -0
  30. package/prefabs/admin-tools/icons/video/chevron-up.png +0 -0
  31. package/prefabs/admin-tools/icons/video/copy-to-clipboard.png +0 -0
  32. package/prefabs/admin-tools/icons/video/error.png +0 -0
  33. package/prefabs/admin-tools/icons/video/eye-off.png +0 -0
  34. package/prefabs/admin-tools/icons/video/eye.png +0 -0
  35. package/prefabs/admin-tools/icons/video/help.png +0 -0
  36. package/prefabs/admin-tools/icons/video/info.png +0 -0
  37. package/prefabs/admin-tools/icons/video/person-outline.png +0 -0
  38. package/prefabs/admin-tools/icons/video/star.png +0 -0
  39. package/prefabs/admin-tools/icons/video/video-control-dcl-cast.png +0 -0
  40. package/prefabs/admin-tools/icons/video/video-control-live.png +0 -0
  41. package/prefabs/admin-tools/icons/video/video-control-loop.png +0 -0
  42. package/prefabs/admin-tools/icons/video/video-control-mute.png +0 -0
  43. package/prefabs/admin-tools/icons/video/video-control-pause-button.png +0 -0
  44. package/prefabs/admin-tools/icons/video/video-control-play-button.png +0 -0
  45. package/prefabs/admin-tools/icons/video/video-control-video-icon.png +0 -0
  46. package/prefabs/admin-tools/icons/video/video-control-volume-minus-button.png +0 -0
  47. package/prefabs/admin-tools/icons/video/video-control-volume-plus-button.png +0 -0
  48. package/prefabs/admin-tools/icons/video-control.png +0 -0
  49. package/prefabs/admin-tools/scripts/actions.ts +206 -0
  50. package/prefabs/admin-tools/scripts/admin.tsx +253 -0
  51. package/prefabs/admin-tools/scripts/api.ts +125 -0
  52. package/prefabs/admin-tools/scripts/components.ts +190 -0
  53. package/prefabs/admin-tools/scripts/icons.ts +53 -0
  54. package/prefabs/admin-tools/scripts/message-bus.ts +297 -0
  55. package/prefabs/admin-tools/scripts/state.ts +69 -0
  56. package/prefabs/admin-tools/scripts/tabs/Moderation.tsx +155 -0
  57. package/prefabs/admin-tools/scripts/tabs/Rewards.tsx +136 -0
  58. package/prefabs/admin-tools/scripts/tabs/SmartItemActions.tsx +167 -0
  59. package/prefabs/admin-tools/scripts/tabs/TextAnnouncements.tsx +195 -0
  60. package/prefabs/admin-tools/scripts/tabs/VideoControl.tsx +156 -0
  61. package/prefabs/admin-tools/scripts/tabs/announcements/Overlay.tsx +94 -0
  62. package/prefabs/admin-tools/scripts/tabs/announcements/icons.ts +15 -0
  63. package/prefabs/admin-tools/scripts/tabs/announcements/state.ts +71 -0
  64. package/prefabs/admin-tools/scripts/tabs/announcements/ui.tsx +37 -0
  65. package/prefabs/admin-tools/scripts/tabs/moderation/AddUserInput.tsx +184 -0
  66. package/prefabs/admin-tools/scripts/tabs/moderation/RemoveAdminConfirmation.tsx +93 -0
  67. package/prefabs/admin-tools/scripts/tabs/moderation/UsersList.tsx +228 -0
  68. package/prefabs/admin-tools/scripts/tabs/moderation/api.ts +35 -0
  69. package/prefabs/admin-tools/scripts/tabs/moderation/icons.ts +23 -0
  70. package/prefabs/admin-tools/scripts/tabs/moderation/kick.ts +95 -0
  71. package/prefabs/admin-tools/scripts/tabs/moderation/state.ts +69 -0
  72. package/prefabs/admin-tools/scripts/tabs/moderation/ui.tsx +172 -0
  73. package/prefabs/admin-tools/scripts/tabs/moderation/utils.ts +88 -0
  74. package/prefabs/admin-tools/scripts/tabs/rewards/Button.tsx +52 -0
  75. package/prefabs/admin-tools/scripts/tabs/rewards/Captcha.tsx +88 -0
  76. package/prefabs/admin-tools/scripts/tabs/rewards/api.ts +138 -0
  77. package/prefabs/admin-tools/scripts/tabs/rewards/claim.ts +99 -0
  78. package/prefabs/admin-tools/scripts/tabs/rewards/controller.ts +103 -0
  79. package/prefabs/admin-tools/scripts/tabs/rewards/icons.ts +13 -0
  80. package/prefabs/admin-tools/scripts/tabs/rewards/state.ts +32 -0
  81. package/prefabs/admin-tools/scripts/tabs/smart-items/selection.ts +90 -0
  82. package/prefabs/admin-tools/scripts/tabs/video/DclCast.tsx +226 -0
  83. package/prefabs/admin-tools/scripts/tabs/video/LiveStream.tsx +284 -0
  84. package/prefabs/admin-tools/scripts/tabs/video/SpeakerShowcase.tsx +112 -0
  85. package/prefabs/admin-tools/scripts/tabs/video/VideoUrl.tsx +107 -0
  86. package/prefabs/admin-tools/scripts/tabs/video/Volume.tsx +69 -0
  87. package/prefabs/admin-tools/scripts/tabs/video/api.ts +172 -0
  88. package/prefabs/admin-tools/scripts/tabs/video/controls.ts +108 -0
  89. package/prefabs/admin-tools/scripts/tabs/video/icons.ts +59 -0
  90. package/prefabs/admin-tools/scripts/tabs/video/speakers.ts +43 -0
  91. package/prefabs/admin-tools/scripts/tabs/video/state.ts +82 -0
  92. package/prefabs/admin-tools/scripts/tabs/video/ui.tsx +243 -0
  93. package/prefabs/admin-tools/scripts/types.ts +46 -0
  94. package/prefabs/admin-tools/scripts/ui.tsx +122 -0
  95. package/prefabs/admin-tools/thumbnail.png +0 -0
  96. package/prefabs/announcer/ai.md +76 -0
  97. package/prefabs/announcer/composite.json +31 -0
  98. package/prefabs/announcer/data.json +35 -0
  99. package/prefabs/announcer/scripts/announcer.tsx +79 -0
  100. package/prefabs/announcer/scripts/pure/toast.ts +30 -0
  101. package/prefabs/announcer/thumbnail.png +0 -0
  102. package/prefabs/black-chair/HighBackChair.glb +0 -0
  103. package/prefabs/black-chair/composite.json +74 -0
  104. package/prefabs/black-chair/data.json +30 -0
  105. package/prefabs/black-chair/scripts/seat.ts +191 -0
  106. package/prefabs/black-chair/thumbnail.png +0 -0
  107. package/prefabs/catalog.json +1 -0
  108. package/prefabs/classic-bench/Bench_01.glb +0 -0
  109. package/prefabs/classic-bench/composite.json +100 -0
  110. package/prefabs/classic-bench/data.json +34 -0
  111. package/prefabs/classic-bench/file1.png +0 -0
  112. package/prefabs/classic-bench/scripts/seat.ts +191 -0
  113. package/prefabs/classic-bench/thumbnail.png +0 -0
  114. package/prefabs/classic-bench-armrests/Bench 1.glb +0 -0
  115. package/prefabs/classic-bench-armrests/composite.json +100 -0
  116. package/prefabs/classic-bench-armrests/data.json +30 -0
  117. package/prefabs/classic-bench-armrests/scripts/seat.ts +191 -0
  118. package/prefabs/classic-bench-armrests/thumbnail.png +0 -0
  119. package/prefabs/classroom-chair/Chair_02.glb +0 -0
  120. package/prefabs/classroom-chair/composite.json +74 -0
  121. package/prefabs/classroom-chair/data.json +34 -0
  122. package/prefabs/classroom-chair/file1.png +0 -0
  123. package/prefabs/classroom-chair/scripts/seat.ts +191 -0
  124. package/prefabs/classroom-chair/thumbnail.png +0 -0
  125. package/prefabs/court-chair/Chair_Wood_01.glb +0 -0
  126. package/prefabs/court-chair/FanstasyPack_TX.png +0 -0
  127. package/prefabs/court-chair/composite.json +74 -0
  128. package/prefabs/court-chair/data.json +34 -0
  129. package/prefabs/court-chair/scripts/seat.ts +191 -0
  130. package/prefabs/court-chair/thumbnail.png +0 -0
  131. package/prefabs/curved-couch/CurvedCouch.glb +0 -0
  132. package/prefabs/curved-couch/composite.json +100 -0
  133. package/prefabs/curved-couch/data.json +30 -0
  134. package/prefabs/curved-couch/scripts/seat.ts +191 -0
  135. package/prefabs/curved-couch/thumbnail.png +0 -0
  136. package/prefabs/game-flow/ai.md +94 -0
  137. package/prefabs/game-flow/composite.json +45 -0
  138. package/prefabs/game-flow/data.json +27 -0
  139. package/prefabs/game-flow/scripts/game-flow.ts +168 -0
  140. package/prefabs/game-flow/scripts/pure/flow.ts +185 -0
  141. package/prefabs/game-flow/thumbnail.png +0 -0
  142. package/prefabs/health-respawn/ai.md +76 -0
  143. package/prefabs/health-respawn/composite.json +31 -0
  144. package/prefabs/health-respawn/data.json +38 -0
  145. package/prefabs/health-respawn/scripts/health-respawn.ts +98 -0
  146. package/prefabs/health-respawn/scripts/health.ts +22 -0
  147. package/prefabs/health-respawn/scripts/pure/health.ts +60 -0
  148. package/prefabs/health-respawn/thumbnail.png +0 -0
  149. package/prefabs/high-stool/HighStool.glb +0 -0
  150. package/prefabs/high-stool/composite.json +74 -0
  151. package/prefabs/high-stool/data.json +30 -0
  152. package/prefabs/high-stool/scripts/seat.ts +191 -0
  153. package/prefabs/high-stool/thumbnail.png +0 -0
  154. package/prefabs/large-couch/ThreeSeater_Couch.glb +0 -0
  155. package/prefabs/large-couch/composite.json +100 -0
  156. package/prefabs/large-couch/data.json +30 -0
  157. package/prefabs/large-couch/scripts/seat.ts +191 -0
  158. package/prefabs/large-couch/thumbnail.png +0 -0
  159. package/prefabs/leaderboard/ai.md +75 -0
  160. package/prefabs/leaderboard/board-panel.glb +0 -0
  161. package/prefabs/leaderboard/composite.json +82 -0
  162. package/prefabs/leaderboard/data.json +35 -0
  163. package/prefabs/leaderboard/scripts/leaderboard.ts +66 -0
  164. package/prefabs/leaderboard/scripts/pure/board.ts +71 -0
  165. package/prefabs/leaderboard/thumbnail.png +0 -0
  166. package/prefabs/loveseat/Couch_01.glb +0 -0
  167. package/prefabs/loveseat/composite.json +100 -0
  168. package/prefabs/loveseat/data.json +34 -0
  169. package/prefabs/loveseat/file1.png +0 -0
  170. package/prefabs/loveseat/scripts/seat.ts +191 -0
  171. package/prefabs/loveseat/thumbnail.png +0 -0
  172. package/prefabs/modern-sofa/ModernSofa.glb +0 -0
  173. package/prefabs/modern-sofa/composite.json +100 -0
  174. package/prefabs/modern-sofa/data.json +30 -0
  175. package/prefabs/modern-sofa/scripts/seat.ts +191 -0
  176. package/prefabs/modern-sofa/thumbnail.png +0 -0
  177. package/prefabs/outdoor-chair/Chair_07.glb +0 -0
  178. package/prefabs/outdoor-chair/composite.json +74 -0
  179. package/prefabs/outdoor-chair/data.json +34 -0
  180. package/prefabs/outdoor-chair/file1.png +0 -0
  181. package/prefabs/outdoor-chair/scripts/seat.ts +191 -0
  182. package/prefabs/outdoor-chair/thumbnail.png +0 -0
  183. package/prefabs/rustic-bench/Bench_01.glb +0 -0
  184. package/prefabs/rustic-bench/PiratesPack_TX.png.png +0 -0
  185. package/prefabs/rustic-bench/composite.json +100 -0
  186. package/prefabs/rustic-bench/data.json +34 -0
  187. package/prefabs/rustic-bench/scripts/seat.ts +191 -0
  188. package/prefabs/rustic-bench/thumbnail.png +0 -0
  189. package/prefabs/rustic-round-stool/Chairwood_01.glb +0 -0
  190. package/prefabs/rustic-round-stool/PiratesPack_TX.png.png +0 -0
  191. package/prefabs/rustic-round-stool/composite.json +74 -0
  192. package/prefabs/rustic-round-stool/data.json +34 -0
  193. package/prefabs/rustic-round-stool/scripts/seat.ts +191 -0
  194. package/prefabs/rustic-round-stool/thumbnail.png +0 -0
  195. package/prefabs/rustic-square-stool/Chairwood_02.glb +0 -0
  196. package/prefabs/rustic-square-stool/PiratesPack_TX.png.png +0 -0
  197. package/prefabs/rustic-square-stool/composite.json +74 -0
  198. package/prefabs/rustic-square-stool/data.json +34 -0
  199. package/prefabs/rustic-square-stool/scripts/seat.ts +191 -0
  200. package/prefabs/rustic-square-stool/thumbnail.png +0 -0
  201. package/prefabs/server-clock/ai.md +67 -0
  202. package/prefabs/server-clock/composite.json +45 -0
  203. package/prefabs/server-clock/data.json +24 -0
  204. package/prefabs/server-clock/scripts/clock-hud.tsx +55 -0
  205. package/prefabs/server-clock/scripts/server-clock.ts +71 -0
  206. package/prefabs/server-clock/thumbnail.png +0 -0
  207. package/prefabs/simple-chair/Chair_03.glb +0 -0
  208. package/prefabs/simple-chair/composite.json +74 -0
  209. package/prefabs/simple-chair/data.json +34 -0
  210. package/prefabs/simple-chair/file1.png +0 -0
  211. package/prefabs/simple-chair/scripts/seat.ts +191 -0
  212. package/prefabs/simple-chair/thumbnail.png +0 -0
  213. package/prefabs/sit-spot/composite.json +45 -0
  214. package/prefabs/sit-spot/data.json +30 -0
  215. package/prefabs/sit-spot/scripts/seat.ts +191 -0
  216. package/prefabs/sit-spot/thumbnail.png +0 -0
  217. package/prefabs/sit-spot-edge/composite.json +42 -0
  218. package/prefabs/sit-spot-edge/data.json +32 -0
  219. package/prefabs/sit-spot-edge/emotes/Sit_Edge1_emote.glb +0 -0
  220. package/prefabs/sit-spot-edge/emotes/Sit_Edge2_emote.glb +0 -0
  221. package/prefabs/sit-spot-edge/scripts/seat.ts +191 -0
  222. package/prefabs/sit-spot-edge/thumbnail.png +0 -0
  223. package/prefabs/small-couch/TwoSeater_Couch.glb +0 -0
  224. package/prefabs/small-couch/composite.json +100 -0
  225. package/prefabs/small-couch/data.json +30 -0
  226. package/prefabs/small-couch/scripts/seat.ts +191 -0
  227. package/prefabs/small-couch/thumbnail.png +0 -0
  228. package/prefabs/spawner/ai.md +109 -0
  229. package/prefabs/spawner/composite.json +73 -0
  230. package/prefabs/spawner/data.json +22 -0
  231. package/prefabs/spawner/scripts/spawner.ts +380 -0
  232. package/prefabs/spawner/thumbnail.png +0 -0
  233. package/prefabs/steampunk-bench/Steampunk Bench.glb +0 -0
  234. package/prefabs/steampunk-bench/composite.json +100 -0
  235. package/prefabs/steampunk-bench/data.json +30 -0
  236. package/prefabs/steampunk-bench/scripts/seat.ts +191 -0
  237. package/prefabs/steampunk-bench/thumbnail.png +0 -0
  238. package/prefabs/steampunk-chair/Steampunk Chair.glb +0 -0
  239. package/prefabs/steampunk-chair/composite.json +74 -0
  240. package/prefabs/steampunk-chair/data.json +30 -0
  241. package/prefabs/steampunk-chair/scripts/seat.ts +191 -0
  242. package/prefabs/steampunk-chair/thumbnail.png +0 -0
  243. package/prefabs/tall-stool/Stool_D.glb +0 -0
  244. package/prefabs/tall-stool/composite.json +74 -0
  245. package/prefabs/tall-stool/data.json +30 -0
  246. package/prefabs/tall-stool/scripts/seat.ts +191 -0
  247. package/prefabs/tall-stool/thumbnail.png +0 -0
  248. package/prefabs/trigger-zone/ai.md +115 -0
  249. package/prefabs/trigger-zone/composite.json +52 -0
  250. package/prefabs/trigger-zone/data.json +31 -0
  251. package/prefabs/trigger-zone/scripts/trigger-zone.ts +89 -0
  252. package/prefabs/trigger-zone/thumbnail.png +0 -0
  253. package/prefabs/video-screen/composite.json +98 -0
  254. package/prefabs/video-screen/data.json +21 -0
  255. package/prefabs/video-screen/thumbnail.png +0 -0
  256. package/prefabs/video-screen/video_player.glb +0 -0
  257. package/prefabs/wooden-chair/Chair 1.glb +0 -0
  258. package/prefabs/wooden-chair/composite.json +74 -0
  259. package/prefabs/wooden-chair/data.json +30 -0
  260. package/prefabs/wooden-chair/scripts/seat.ts +191 -0
  261. package/prefabs/wooden-chair/thumbnail.png +0 -0
  262. package/prefabs/wooden-chair-armrests/Chair 3.glb +0 -0
  263. package/prefabs/wooden-chair-armrests/composite.json +74 -0
  264. package/prefabs/wooden-chair-armrests/data.json +30 -0
  265. package/prefabs/wooden-chair-armrests/scripts/seat.ts +191 -0
  266. package/prefabs/wooden-chair-armrests/thumbnail.png +0 -0
  267. package/scene/about +17 -0
  268. package/scene/bafkreias2w4xuqaic6sdcvblrisayi6xg7drfowzpt5ubjkmrk3mx6czc4 +1 -0
  269. package/scene/bafkreiby2gmthtauf7cckttphmhpecsueyypi2hzlke4lcodg7fm4cm2sq +23 -0
  270. package/scene/bafkreidhwqwajqk4nsuffef5fkdrgmp5ql6vnn2knpqsuqalyosv5o6lk4 +1 -0
  271. package/scene/bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku +0 -0
  272. package/scene/bafybeihw3xlbff4sf5sc3kvmlmlwf2visnty5lfymawlunzt5vrkp5fd7u +90 -0
  273. package/web-build/esbuild.wasm +0 -0
  274. package/web-build/index.js +3 -0
  275. package/web-build/sdk-snapshot.json +1 -0
  276. package/web-build/worker.js +701 -0
  277. package/README.md +0 -3
@@ -0,0 +1,167 @@
1
+ import ReactEcs, { Dropdown, Label, UiEntity } from '@dcl/sdk/react-ecs'
2
+ import { Color4 } from '@dcl/sdk/math'
3
+ import { dispatchAction, getActions, SUPPORTED_ACTIONS } from '../actions'
4
+ import type { ActionEntry } from '../components'
5
+ import { TabId } from '../state'
6
+ import type { TabComponent, TabProps, TabSpec } from '../types'
7
+ import { Card, CardHeader, TextButton, TEXT_DIM } from '../ui'
8
+ import {
9
+ selectedItem,
10
+ selectSmartItem,
11
+ setVisible,
12
+ smartItemList,
13
+ smartItemsUi,
14
+ uiStateFor,
15
+ type SmartItemEntry
16
+ } from './smart-items/selection'
17
+
18
+ const MUTED = Color4.create(187 / 255, 187 / 255, 187 / 255, 1)
19
+
20
+ function FieldLabel(props: { value: string }): ReactEcs.JSX.Element {
21
+ return (
22
+ <Label
23
+ value={`<b>${props.value}</b>`}
24
+ fontSize={16}
25
+ color={Color4.White()}
26
+ textAlign="middle-left"
27
+ uiTransform={{ width: '100%', height: 24, margin: { bottom: 12 } }}
28
+ />
29
+ )
30
+ }
31
+
32
+ function play(props: TabProps, item: SmartItemEntry, action: ActionEntry): void {
33
+ const ran = dispatchAction(item.entity, action.name)
34
+ smartItemsUi.lastPlayed = action.name
35
+ smartItemsUi.lastPlayedFailed = !ran || !SUPPORTED_ACTIONS.includes(action.type)
36
+ uiStateFor(props.state, item).selectedAction = action.name
37
+ }
38
+
39
+ export const SmartItemActions: TabComponent = (props: TabProps) => {
40
+ const items = smartItemList(props.config, props.self)
41
+ const item = selectedItem(props.state, items)
42
+ const actions = item === undefined ? [] : getActions(item.entity)
43
+ const ui = item === undefined ? undefined : uiStateFor(props.state, item)
44
+ const actionIndex =
45
+ ui === undefined ? -1 : actions.findIndex((action) => action.name === ui.selectedAction)
46
+ const action = actionIndex >= 0 ? actions[actionIndex] : undefined
47
+ const unsupported = action !== undefined && !SUPPORTED_ACTIONS.includes(action.type)
48
+
49
+ return (
50
+ <Card>
51
+ <CardHeader icon={props.icons.headerSmartItems} title="SMART ITEM ACTIONS" />
52
+
53
+ {items.length === 0 ? (
54
+ <Label
55
+ value="No smart items linked yet. Add them from the Admin Tools inspector."
56
+ fontSize={14}
57
+ color={TEXT_DIM}
58
+ textAlign="middle-left"
59
+ uiTransform={{ width: '100%', height: 'auto' }}
60
+ />
61
+ ) : (
62
+ <UiEntity uiTransform={{ flexDirection: 'column', width: '100%' }}>
63
+ <UiEntity uiTransform={{ flexDirection: 'column', margin: { bottom: 24 } }}>
64
+ <FieldLabel value="Selected Smart Item" />
65
+ <Dropdown
66
+ key="smart-item-selector"
67
+ acceptEmpty
68
+ emptyLabel="Select Smart Item"
69
+ options={items.map((entry) => entry.customName)}
70
+ selectedIndex={props.state.smartItems.selectedIndex ?? -1}
71
+ onChange={(index) => selectSmartItem(props.state, items, index)}
72
+ textAlign="middle-left"
73
+ fontSize={14}
74
+ color={Color4.Black()}
75
+ uiBackground={{ color: Color4.White() }}
76
+ uiTransform={{ width: '100%', height: 40 }}
77
+ />
78
+ </UiEntity>
79
+
80
+ <UiEntity uiTransform={{ flexDirection: 'column', margin: { bottom: 16 } }}>
81
+ <FieldLabel value="Actions" />
82
+ {actions.length === 0 ? (
83
+ <Label
84
+ value={
85
+ item === undefined
86
+ ? 'Pick a smart item to see its actions.'
87
+ : 'This smart item has no actions authored on it.'
88
+ }
89
+ fontSize={14}
90
+ color={TEXT_DIM}
91
+ textAlign="middle-left"
92
+ uiTransform={{ width: '100%', height: 22 }}
93
+ />
94
+ ) : (
95
+ <Dropdown
96
+ key={`smart-item-actions-${item === undefined ? 'none' : (item.entity as number)}`}
97
+ acceptEmpty
98
+ emptyLabel="Select Action"
99
+ options={actions.map((entry) => entry.name)}
100
+ selectedIndex={actionIndex}
101
+ disabled={item === undefined}
102
+ onChange={(index) => {
103
+ if (item === undefined || index < 0 || index >= actions.length) return
104
+ uiStateFor(props.state, item).selectedAction = actions[index].name
105
+ smartItemsUi.lastPlayed = ''
106
+ smartItemsUi.lastPlayedFailed = false
107
+ }}
108
+ textAlign="middle-left"
109
+ fontSize={14}
110
+ color={Color4.Black()}
111
+ uiBackground={{ color: item === undefined ? Color4.Gray() : Color4.White() }}
112
+ uiTransform={{ width: '100%', height: 40 }}
113
+ />
114
+ )}
115
+ </UiEntity>
116
+
117
+ <UiEntity uiTransform={{ flexDirection: 'row', height: 40 }}>
118
+ <TextButton
119
+ label="Play Action"
120
+ disabled={item === undefined || action === undefined}
121
+ onClick={() => {
122
+ if (item !== undefined && action !== undefined) play(props, item, action)
123
+ }}
124
+ />
125
+ <TextButton
126
+ label={ui !== undefined && ui.visible ? 'Hide Entity' : 'Show Entity'}
127
+ disabled={item === undefined}
128
+ onClick={() => {
129
+ if (item !== undefined && ui !== undefined) setVisible(props.state, item, !ui.visible)
130
+ }}
131
+ />
132
+ </UiEntity>
133
+
134
+ <UiEntity
135
+ uiTransform={{
136
+ width: '100%',
137
+ minHeight: 24,
138
+ margin: { top: 10 },
139
+ display: unsupported || smartItemsUi.lastPlayed !== '' ? 'flex' : 'none'
140
+ }}
141
+ >
142
+ <Label
143
+ value={
144
+ unsupported
145
+ ? `"${action?.type ?? ''}" is not one of the action types this panel can run.`
146
+ : smartItemsUi.lastPlayedFailed
147
+ ? `Could not play "${smartItemsUi.lastPlayed}".`
148
+ : `Played "${smartItemsUi.lastPlayed}".`
149
+ }
150
+ fontSize={13}
151
+ color={MUTED}
152
+ textAlign="middle-left"
153
+ uiTransform={{ width: '100%', height: 'auto' }}
154
+ />
155
+ </UiEntity>
156
+ </UiEntity>
157
+ )}
158
+ </Card>
159
+ )
160
+ }
161
+
162
+ export const smartItemActionsTab: TabSpec = {
163
+ id: TabId.SMART_ITEMS,
164
+ icon: 'tabSmartItems',
165
+ isEnabled: (config) => config.smartItemsControl.isEnabled,
166
+ Component: SmartItemActions
167
+ }
@@ -0,0 +1,195 @@
1
+ import ReactEcs, { Input, Label, UiEntity } from '@dcl/sdk/react-ecs'
2
+ import { Color4 } from '@dcl/sdk/math'
3
+ import { TextAnnouncements as TextAnnouncementsComponent } from '../components'
4
+ import { TabId } from '../state'
5
+ import type { TabComponent, TabProps, TabSpec } from '../types'
6
+ import { Card, CardHeader, TextButton, TEXT_DIM } from '../ui'
7
+ import { AnnouncementOverlay } from './announcements/Overlay'
8
+ import { announcementIcons } from './announcements/icons'
9
+ import { PrimaryButton } from './announcements/ui'
10
+ import {
11
+ ANNOUNCEMENT_MAX_LENGTH,
12
+ announcementId,
13
+ announcementUi,
14
+ cooldownRemaining,
15
+ feedbackText,
16
+ recordAnnouncement,
17
+ recordSend
18
+ } from './announcements/state'
19
+
20
+ const PLACEHOLDER_COLOR = Color4.create(160 / 255, 155 / 255, 168 / 255, 1)
21
+ const MUTED = Color4.create(187 / 255, 187 / 255, 187 / 255, 1)
22
+
23
+ function resetInput(props: TabProps): void {
24
+ props.state.textAnnouncements.draft = ''
25
+ announcementUi.inputResetSeq += 1
26
+ }
27
+
28
+ function send(props: TabProps): void {
29
+ if (props.bus === null) return
30
+ const text = props.state.textAnnouncements.draft.trim()
31
+ if (text === '') {
32
+ announcementUi.feedback = 'empty'
33
+ return
34
+ }
35
+
36
+ const author = props.player?.name ?? ''
37
+ const now = Date.now()
38
+ const throttledFor = cooldownRemaining(author, now)
39
+ if (throttledFor > 0) {
40
+ announcementUi.feedback = 'throttled'
41
+ announcementUi.throttledFor = throttledFor
42
+ return
43
+ }
44
+
45
+ const showAuthor = props.config.textAnnouncementControl.showAuthorOnEachAnnouncement
46
+ props.bus.setAnnouncement(
47
+ text.slice(0, ANNOUNCEMENT_MAX_LENGTH),
48
+ showAuthor ? author : '',
49
+ announcementId(now, author)
50
+ )
51
+ recordSend(author, now)
52
+ resetInput(props)
53
+ announcementUi.feedback = 'sent'
54
+ }
55
+
56
+ function clear(props: TabProps): void {
57
+ if (props.bus === null) return
58
+ props.bus.clearAnnouncement()
59
+ props.state.textAnnouncements.announcements = []
60
+ announcementUi.dismissed.clear()
61
+ resetInput(props)
62
+ announcementUi.feedback = 'cleared'
63
+ }
64
+
65
+ function History(props: TabProps): ReactEcs.JSX.Element | null {
66
+ const entries = props.state.textAnnouncements.announcements
67
+ if (entries.length === 0) return null
68
+ return (
69
+ <UiEntity uiTransform={{ flexDirection: 'column', width: '100%', margin: { top: 8 } }}>
70
+ <Label
71
+ value="<b>Recent</b>"
72
+ fontSize={14}
73
+ color={TEXT_DIM}
74
+ textAlign="middle-left"
75
+ uiTransform={{ width: '100%', height: 22 }}
76
+ />
77
+ {entries.map((entry) => (
78
+ <Label
79
+ key={entry.id}
80
+ value={entry.author === '' ? entry.text : `${entry.text} <b>- ${entry.author}</b>`}
81
+ fontSize={13}
82
+ color={MUTED}
83
+ textAlign="middle-left"
84
+ uiTransform={{ width: '100%', height: 20 }}
85
+ />
86
+ ))}
87
+ </UiEntity>
88
+ )
89
+ }
90
+
91
+ export const TextAnnouncements: TabComponent = (props: TabProps) => {
92
+ const live = TextAnnouncementsComponent.getOrNull(props.self)
93
+ if (live !== null && live.text !== '') {
94
+ recordAnnouncement(props.state, {
95
+ id: live.id,
96
+ text: live.text,
97
+ author: live.author ?? '',
98
+ timestamp: Date.now()
99
+ })
100
+ }
101
+
102
+ const icons = announcementIcons(props.assetBase)
103
+ const ready = props.bus !== null
104
+ const draft = props.state.textAnnouncements.draft
105
+ const feedback = announcementUi.feedback
106
+
107
+ return (
108
+ <Card>
109
+ <CardHeader icon={props.icons.headerTextAnnouncements} title="TEXT ANNOUNCEMENTS" />
110
+ <UiEntity uiTransform={{ flexDirection: 'column', width: '100%' }}>
111
+ <Label
112
+ value="<b>Message window</b>"
113
+ fontSize={16}
114
+ color={Color4.White()}
115
+ textAlign="middle-left"
116
+ uiTransform={{ width: '100%', height: 24, margin: { bottom: 12 } }}
117
+ />
118
+ <Input
119
+ key={`announcement-input-${announcementUi.inputResetSeq}`}
120
+ placeholder="Write your announcement here"
121
+ placeholderColor={PLACEHOLDER_COLOR}
122
+ color={Color4.Black()}
123
+ fontSize={16}
124
+ disabled={!ready}
125
+ textAlign="top-left"
126
+ uiBackground={{ color: Color4.White() }}
127
+ uiTransform={{ width: '100%', height: 80, margin: { bottom: 16 } }}
128
+ onChange={(value) => {
129
+ props.state.textAnnouncements.draft = value
130
+ announcementUi.feedback = undefined
131
+ }}
132
+ onSubmit={(value) => {
133
+ props.state.textAnnouncements.draft = value
134
+ send(props)
135
+ }}
136
+ />
137
+ <UiEntity
138
+ uiTransform={{
139
+ width: '100%',
140
+ height: 40,
141
+ flexDirection: 'row',
142
+ alignItems: 'center',
143
+ margin: { bottom: 10 }
144
+ }}
145
+ >
146
+ <Label
147
+ value={`${draft.length} / ${ANNOUNCEMENT_MAX_LENGTH}`}
148
+ fontSize={14}
149
+ color={draft.length > ANNOUNCEMENT_MAX_LENGTH ? Color4.Red() : MUTED}
150
+ textAlign="middle-left"
151
+ uiTransform={{ flexGrow: 1 }}
152
+ />
153
+ <TextButton
154
+ label="Clear Announcements"
155
+ disabled={!ready}
156
+ onClick={() => clear(props)}
157
+ />
158
+ <PrimaryButton label="Share" disabled={!ready} onClick={() => send(props)} />
159
+ </UiEntity>
160
+ <UiEntity
161
+ uiTransform={{
162
+ width: '100%',
163
+ minHeight: 30,
164
+ flexDirection: 'row',
165
+ alignItems: 'center',
166
+ display: feedback === undefined ? 'none' : 'flex'
167
+ }}
168
+ >
169
+ <UiEntity
170
+ uiTransform={{ width: 24, height: 24, margin: { right: 8 } }}
171
+ uiBackground={{ textureMode: 'stretch', texture: { src: icons.check } }}
172
+ />
173
+ <Label
174
+ value={feedback === undefined ? '' : feedbackText(feedback, announcementUi.throttledFor)}
175
+ fontSize={14}
176
+ color={MUTED}
177
+ textAlign="middle-left"
178
+ uiTransform={{ flexGrow: 1 }}
179
+ />
180
+ </UiEntity>
181
+ <History {...props} />
182
+ </UiEntity>
183
+ </Card>
184
+ )
185
+ }
186
+
187
+ export const textAnnouncementsTab: TabSpec = {
188
+ id: TabId.TEXT_ANNOUNCEMENTS,
189
+ icon: 'tabTextAnnouncements',
190
+ isEnabled: (config) => config.textAnnouncementControl.isEnabled,
191
+ Component: TextAnnouncements
192
+ }
193
+
194
+ export { AnnouncementOverlay }
195
+ export type { AnnouncementOverlayProps } from './announcements/Overlay'
@@ -0,0 +1,156 @@
1
+ import ReactEcs, { Dropdown, UiEntity } from '@dcl/sdk/react-ecs'
2
+ import { VideoPlayer } from '@dcl/sdk/ecs'
3
+ import { TabId } from '../state'
4
+ import type { TabComponent, TabProps, TabSpec } from '../types'
5
+ import { Card, CardHeader } from '../ui'
6
+ import { DclCast } from './video/DclCast'
7
+ import { LiveStream } from './video/LiveStream'
8
+ import { VideoUrl } from './video/VideoUrl'
9
+ import { videoIcons } from './video/icons'
10
+ import { videoTab, type MediaSource } from './video/state'
11
+ import {
12
+ createControls,
13
+ enforceMute,
14
+ isCastSource,
15
+ isVideoUrl,
16
+ LIVEKIT_STREAM_SRC,
17
+ managedScreens
18
+ } from './video/controls'
19
+ import { Button, COLORS, Column, Hint, Row, SectionLabel } from './video/ui'
20
+
21
+ function sourceOf(src: string): MediaSource {
22
+ if (isVideoUrl(src)) return 'video-url'
23
+ if (src === '' || src === LIVEKIT_STREAM_SRC) return 'live'
24
+ return 'dcl-cast'
25
+ }
26
+
27
+ export const VideoControl: TabComponent = (props: TabProps) => {
28
+ const icons = videoIcons(props.assetBase)
29
+ const screens = managedScreens(props.config)
30
+ const soundDisabled = props.config.videoControl.disableVideoPlayersSound
31
+ const selected = Math.min(Math.max(props.state.video.selectedIndex ?? 0, 0), screens.length - 1)
32
+ const screen = screens[selected]
33
+ const entity = screen?.entity
34
+ const src = entity === undefined ? '' : (VideoPlayer.getOrNull(entity)?.src ?? '')
35
+
36
+ ReactEcs.useEffect(() => {
37
+ if (soundDisabled) enforceMute(screens)
38
+ }, [screens.length, soundDisabled])
39
+
40
+ ReactEcs.useEffect(() => {
41
+ const stillCasting =
42
+ videoTab.source === 'dcl-cast' && (isCastSource(src) || src === videoTab.activeTrackSid)
43
+ videoTab.source = stillCasting ? 'dcl-cast' : sourceOf(src)
44
+ }, [entity])
45
+
46
+ if (entity === undefined) {
47
+ return (
48
+ <Card>
49
+ <CardHeader icon={props.icons.headerVideo} title="VIDEO SCREENS" />
50
+ <Hint text="No video screens linked yet. Add them from the Admin Tools inspector, or turn on 'Link all video players'." />
51
+ </Card>
52
+ )
53
+ }
54
+
55
+ const source = videoTab.source ?? sourceOf(src)
56
+ const urlActive = isVideoUrl(src)
57
+ const liveActive = src === LIVEKIT_STREAM_SRC && source !== 'dcl-cast'
58
+ const castActive =
59
+ src !== '' &&
60
+ (isCastSource(src) ? source === 'dcl-cast' : src === videoTab.activeTrackSid)
61
+ const controls = createControls(props.bus, entity, soundDisabled)
62
+
63
+ const pick = (value: MediaSource): void => {
64
+ videoTab.source = value
65
+ }
66
+
67
+ const sourceButton = (
68
+ value: MediaSource,
69
+ label: string,
70
+ icon: string,
71
+ active: boolean
72
+ ): ReactEcs.JSX.Element => (
73
+ <Column uiTransform={{ width: '31%' }}>
74
+ <Button
75
+ label={label}
76
+ icon={icon}
77
+ fontSize={14}
78
+ variant={source === value ? 'primary' : 'secondary'}
79
+ uiTransform={{ width: '100%', height: 36, borderRadius: 6 }}
80
+ onClick={() => pick(value)}
81
+ />
82
+ <UiEntity
83
+ uiTransform={{ width: '100%', height: 4, margin: { top: 6 }, borderRadius: 2 }}
84
+ uiBackground={{ color: active ? COLORS.success : COLORS.transparent }}
85
+ />
86
+ </Column>
87
+ )
88
+
89
+ return (
90
+ <Column>
91
+ <Card>
92
+ <CardHeader icon={props.icons.headerVideo} title="VIDEO SCREENS" />
93
+ {screens.length > 1 ? (
94
+ <Column>
95
+ <SectionLabel text="Current Screen" uiTransform={{ margin: { bottom: 6 } }} />
96
+ <Dropdown
97
+ options={screens.map((item) => item.customName)}
98
+ selectedIndex={selected}
99
+ onChange={(index) => {
100
+ props.state.video.selectedIndex = index
101
+ }}
102
+ textAlign="middle-left"
103
+ fontSize={15}
104
+ color={COLORS.black}
105
+ uiBackground={{ color: COLORS.offWhite }}
106
+ uiTransform={{ width: '100%', height: 36 }}
107
+ />
108
+ </Column>
109
+ ) : null}
110
+ <SectionLabel text="Media Source" />
111
+ <Row uiTransform={{ justifyContent: 'space-between', margin: { top: 4 } }}>
112
+ {sourceButton('video-url', 'VIDEO URL', icons.sourceVideo, urlActive)}
113
+ {sourceButton('dcl-cast', 'DCL CAST', icons.sourceCast, castActive)}
114
+ {sourceButton('live', 'STREAM', icons.sourceLive, liveActive)}
115
+ </Row>
116
+ </Card>
117
+ <Card>
118
+ {source === 'video-url' ? (
119
+ <VideoUrl
120
+ entity={entity}
121
+ icons={icons}
122
+ controls={controls}
123
+ soundDisabled={soundDisabled}
124
+ />
125
+ ) : null}
126
+ {source === 'live' ? (
127
+ <LiveStream
128
+ self={props.self}
129
+ entity={entity}
130
+ icons={icons}
131
+ controls={controls}
132
+ soundDisabled={soundDisabled}
133
+ />
134
+ ) : null}
135
+ {source === 'dcl-cast' ? (
136
+ <DclCast
137
+ self={props.self}
138
+ entity={entity}
139
+ icons={icons}
140
+ controls={controls}
141
+ soundDisabled={soundDisabled}
142
+ playerAddress={props.player?.userId}
143
+ active={castActive}
144
+ />
145
+ ) : null}
146
+ </Card>
147
+ </Column>
148
+ )
149
+ }
150
+
151
+ export const videoControlTab: TabSpec = {
152
+ id: TabId.VIDEO,
153
+ icon: 'tabVideo',
154
+ isEnabled: (config) => config.videoControl.isEnabled,
155
+ Component: VideoControl
156
+ }
@@ -0,0 +1,94 @@
1
+ import ReactEcs, { Label, UiEntity } from '@dcl/sdk/react-ecs'
2
+ import type { Entity } from '@dcl/sdk/ecs'
3
+ import { TextAnnouncements as TextAnnouncementsComponent } from '../../components'
4
+ import type { AdminToolsValue } from '../../components'
5
+ import type { AdminState } from '../../state'
6
+ import { announcementIcons } from './icons'
7
+ import { announcementUi, recordAnnouncement } from './state'
8
+
9
+ export interface AnnouncementOverlayProps {
10
+ self: Entity
11
+ config: AdminToolsValue
12
+ assetBase: string
13
+ state: AdminState
14
+ }
15
+
16
+ export function AnnouncementOverlay(props: AnnouncementOverlayProps): ReactEcs.JSX.Element | null {
17
+ const announcement = TextAnnouncementsComponent.getOrNull(props.self)
18
+ if (announcement === null || announcement.text === '') return null
19
+
20
+ recordAnnouncement(props.state, {
21
+ id: announcement.id,
22
+ text: announcement.text,
23
+ author: announcement.author ?? '',
24
+ timestamp: Date.now()
25
+ })
26
+
27
+ if (announcementUi.dismissed.has(announcement.id)) return null
28
+
29
+ const icons = announcementIcons(props.assetBase)
30
+ const author = announcement.author ?? ''
31
+ const showAuthor =
32
+ props.config.textAnnouncementControl.showAuthorOnEachAnnouncement && author !== ''
33
+
34
+ return (
35
+ <UiEntity
36
+ uiTransform={{
37
+ positionType: 'absolute',
38
+ flexDirection: 'column',
39
+ alignItems: 'center',
40
+ justifyContent: 'flex-end',
41
+ width: '100%',
42
+ height: '100%'
43
+ }}
44
+ >
45
+ <UiEntity
46
+ key={announcement.id}
47
+ uiTransform={{
48
+ flexDirection: 'column',
49
+ width: 400,
50
+ height: 150,
51
+ margin: { bottom: 10 },
52
+ padding: { top: 10, right: 10, bottom: 10, left: 10 },
53
+ pointerFilter: 'block'
54
+ }}
55
+ uiBackground={{ color: { r: 0.15, g: 0.15, b: 0.15, a: 0.95 } }}
56
+ >
57
+ <UiEntity
58
+ uiTransform={{ alignSelf: 'center', justifyContent: 'center', width: 50, height: 50 }}
59
+ uiBackground={{
60
+ textureMode: 'stretch',
61
+ texture: { src: icons.chatMessage },
62
+ color: { r: 1, g: 1, b: 1, a: 1 }
63
+ }}
64
+ />
65
+ <Label
66
+ value={announcement.text}
67
+ fontSize={18}
68
+ uiTransform={{ width: '100%', flexGrow: 1 }}
69
+ />
70
+ {showAuthor ? (
71
+ <Label
72
+ value={`- ${author}`}
73
+ fontSize={14}
74
+ color={{ r: 0.7, g: 0.7, b: 0.7, a: 1 }}
75
+ textAlign="bottom-right"
76
+ uiTransform={{ width: '100%' }}
77
+ />
78
+ ) : null}
79
+ <UiEntity
80
+ uiTransform={{
81
+ width: 24,
82
+ height: 24,
83
+ positionType: 'absolute',
84
+ position: { top: 5, right: 5 }
85
+ }}
86
+ uiBackground={{ textureMode: 'stretch', texture: { src: icons.close } }}
87
+ onMouseDown={() => {
88
+ announcementUi.dismissed.add(announcement.id)
89
+ }}
90
+ />
91
+ </UiEntity>
92
+ </UiEntity>
93
+ )
94
+ }
@@ -0,0 +1,15 @@
1
+ import { iconPath } from '../../icons'
2
+
3
+ export interface AnnouncementIcons {
4
+ check: string
5
+ close: string
6
+ chatMessage: string
7
+ }
8
+
9
+ export function announcementIcons(base: string): AnnouncementIcons {
10
+ return {
11
+ check: iconPath(base, 'announcements/text-announcement-check.png'),
12
+ close: iconPath(base, 'announcements/text-announcement-close-button.png'),
13
+ chatMessage: iconPath(base, 'announcements/text-announcement-chat-message.png')
14
+ }
15
+ }
@@ -0,0 +1,71 @@
1
+ // Announcement state the shared AdminState does not carry: the per-author rate
2
+ // tracker (the Creator Hub declares `messageRateTracker` but never reads it — it
3
+ // is enforced here), the composer's transient feedback line, and the set of
4
+ // announcement ids the local player dismissed on the overlay.
5
+ import type { AdminState } from '../../state'
6
+
7
+ export const ANNOUNCEMENT_MAX_LENGTH = 90
8
+ export const ANNOUNCEMENT_COOLDOWN_MS = 3000
9
+
10
+ export type AnnouncementFeedback = 'sent' | 'cleared' | 'empty' | 'throttled'
11
+
12
+ export interface AnnouncementUiState {
13
+ feedback: AnnouncementFeedback | undefined
14
+ throttledFor: number
15
+ inputResetSeq: number
16
+ rateTracker: Map<string, number>
17
+ dismissed: Set<string>
18
+ }
19
+
20
+ export const announcementUi: AnnouncementUiState = {
21
+ feedback: undefined,
22
+ throttledFor: 0,
23
+ inputResetSeq: 0,
24
+ rateTracker: new Map(),
25
+ dismissed: new Set()
26
+ }
27
+
28
+ export function cooldownRemaining(author: string, now: number): number {
29
+ const last = announcementUi.rateTracker.get(author)
30
+ if (last === undefined) return 0
31
+ return Math.max(0, ANNOUNCEMENT_COOLDOWN_MS - (now - last))
32
+ }
33
+
34
+ export function recordSend(author: string, now: number): void {
35
+ announcementUi.rateTracker.set(author, now)
36
+ }
37
+
38
+ export function announcementId(timestamp: number, author: string): string {
39
+ return `${timestamp}-${author}`
40
+ }
41
+
42
+ export interface AnnouncementRecord {
43
+ id: string
44
+ text: string
45
+ author: string
46
+ timestamp: number
47
+ }
48
+
49
+ // The panel history has to pick up announcements made by *other* admins too, so
50
+ // it is fed from the synced component rather than from the send handler.
51
+ export function recordAnnouncement(state: AdminState, entry: AnnouncementRecord): void {
52
+ if (entry.id === '' || entry.text === '') return
53
+ const list = state.textAnnouncements.announcements
54
+ if (list.some((known) => known.id === entry.id)) return
55
+ list.unshift(entry)
56
+ const max = Math.max(1, state.textAnnouncements.maxAnnouncements)
57
+ if (list.length > max) list.length = max
58
+ }
59
+
60
+ export function feedbackText(feedback: AnnouncementFeedback, throttledFor: number): string {
61
+ switch (feedback) {
62
+ case 'sent':
63
+ return 'Message sent!'
64
+ case 'cleared':
65
+ return 'Message cleared!'
66
+ case 'empty':
67
+ return 'Write an announcement first.'
68
+ default:
69
+ return `Too fast — try again in ${Math.ceil(throttledFor / 1000)}s.`
70
+ }
71
+ }