@azurioh/discord-kernel 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.
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/dist/authz/authorizer.d.ts +45 -0
- package/dist/authz/authorizer.d.ts.map +1 -0
- package/dist/authz/authorizer.js +31 -0
- package/dist/authz/authorizer.js.map +1 -0
- package/dist/authz/require-level-guard.d.ts +23 -0
- package/dist/authz/require-level-guard.d.ts.map +1 -0
- package/dist/authz/require-level-guard.js +37 -0
- package/dist/authz/require-level-guard.js.map +1 -0
- package/dist/clock.d.ts +13 -0
- package/dist/clock.d.ts.map +1 -0
- package/dist/clock.js +13 -0
- package/dist/clock.js.map +1 -0
- package/dist/concurrency/keyed-queue.d.ts +18 -0
- package/dist/concurrency/keyed-queue.d.ts.map +1 -0
- package/dist/concurrency/keyed-queue.js +37 -0
- package/dist/concurrency/keyed-queue.js.map +1 -0
- package/dist/concurrency/run-with-concurrency.d.ts +15 -0
- package/dist/concurrency/run-with-concurrency.d.ts.map +1 -0
- package/dist/concurrency/run-with-concurrency.js +40 -0
- package/dist/concurrency/run-with-concurrency.js.map +1 -0
- package/dist/config/env.d.ts +17 -0
- package/dist/config/env.d.ts.map +1 -0
- package/dist/config/env.js +49 -0
- package/dist/config/env.js.map +1 -0
- package/dist/config/errors.d.ts +14 -0
- package/dist/config/errors.d.ts.map +1 -0
- package/dist/config/errors.js +25 -0
- package/dist/config/errors.js.map +1 -0
- package/dist/datetime.d.ts +34 -0
- package/dist/datetime.d.ts.map +1 -0
- package/dist/datetime.js +188 -0
- package/dist/datetime.js.map +1 -0
- package/dist/discord/api-errors.d.ts +20 -0
- package/dist/discord/api-errors.d.ts.map +1 -0
- package/dist/discord/api-errors.js +90 -0
- package/dist/discord/api-errors.js.map +1 -0
- package/dist/discord/channel-exporter.d.ts +42 -0
- package/dist/discord/channel-exporter.d.ts.map +1 -0
- package/dist/discord/channel-exporter.js +3 -0
- package/dist/discord/channel-exporter.js.map +1 -0
- package/dist/discord/command/autocomplete-dispatcher.d.ts +26 -0
- package/dist/discord/command/autocomplete-dispatcher.d.ts.map +1 -0
- package/dist/discord/command/autocomplete-dispatcher.js +41 -0
- package/dist/discord/command/autocomplete-dispatcher.js.map +1 -0
- package/dist/discord/command/autocomplete.d.ts +18 -0
- package/dist/discord/command/autocomplete.d.ts.map +1 -0
- package/dist/discord/command/autocomplete.js +65 -0
- package/dist/discord/command/autocomplete.js.map +1 -0
- package/dist/discord/command/context.d.ts +33 -0
- package/dist/discord/command/context.d.ts.map +1 -0
- package/dist/discord/command/context.js +48 -0
- package/dist/discord/command/context.js.map +1 -0
- package/dist/discord/command/cooldown-guard.d.ts +25 -0
- package/dist/discord/command/cooldown-guard.d.ts.map +1 -0
- package/dist/discord/command/cooldown-guard.js +60 -0
- package/dist/discord/command/cooldown-guard.js.map +1 -0
- package/dist/discord/command/create-command.d.ts +100 -0
- package/dist/discord/command/create-command.d.ts.map +1 -0
- package/dist/discord/command/create-command.js +245 -0
- package/dist/discord/command/create-command.js.map +1 -0
- package/dist/discord/command/create-context-menu-command.d.ts +51 -0
- package/dist/discord/command/create-context-menu-command.d.ts.map +1 -0
- package/dist/discord/command/create-context-menu-command.js +128 -0
- package/dist/discord/command/create-context-menu-command.js.map +1 -0
- package/dist/discord/command/errors.d.ts +19 -0
- package/dist/discord/command/errors.d.ts.map +1 -0
- package/dist/discord/command/errors.js +27 -0
- package/dist/discord/command/errors.js.map +1 -0
- package/dist/discord/command/guard.d.ts +30 -0
- package/dist/discord/command/guard.d.ts.map +1 -0
- package/dist/discord/command/guard.js +34 -0
- package/dist/discord/command/guard.js.map +1 -0
- package/dist/discord/command/localization.d.ts +9 -0
- package/dist/discord/command/localization.d.ts.map +1 -0
- package/dist/discord/command/localization.js +13 -0
- package/dist/discord/command/localization.js.map +1 -0
- package/dist/discord/command/options.d.ts +113 -0
- package/dist/discord/command/options.d.ts.map +1 -0
- package/dist/discord/command/options.js +509 -0
- package/dist/discord/command/options.js.map +1 -0
- package/dist/discord/command/permission-guard.d.ts +21 -0
- package/dist/discord/command/permission-guard.d.ts.map +1 -0
- package/dist/discord/command/permission-guard.js +69 -0
- package/dist/discord/command/permission-guard.js.map +1 -0
- package/dist/discord/command/router.d.ts +48 -0
- package/dist/discord/command/router.d.ts.map +1 -0
- package/dist/discord/command/router.js +118 -0
- package/dist/discord/command/router.js.map +1 -0
- package/dist/discord/command/types.d.ts +51 -0
- package/dist/discord/command/types.d.ts.map +1 -0
- package/dist/discord/command/types.js +3 -0
- package/dist/discord/command/types.js.map +1 -0
- package/dist/discord/components/component-router.d.ts +116 -0
- package/dist/discord/components/component-router.d.ts.map +1 -0
- package/dist/discord/components/component-router.js +123 -0
- package/dist/discord/components/component-router.js.map +1 -0
- package/dist/discord/components/interactive-message/index.d.ts +3 -0
- package/dist/discord/components/interactive-message/index.d.ts.map +1 -0
- package/dist/discord/components/interactive-message/index.js +11 -0
- package/dist/discord/components/interactive-message/index.js.map +1 -0
- package/dist/discord/components/interactive-message/interactive-message-collector.d.ts +195 -0
- package/dist/discord/components/interactive-message/interactive-message-collector.d.ts.map +1 -0
- package/dist/discord/components/interactive-message/interactive-message-collector.js +151 -0
- package/dist/discord/components/interactive-message/interactive-message-collector.js.map +1 -0
- package/dist/discord/components/interactive-message/mount-interactive-message.d.ts +30 -0
- package/dist/discord/components/interactive-message/mount-interactive-message.d.ts.map +1 -0
- package/dist/discord/components/interactive-message/mount-interactive-message.js +44 -0
- package/dist/discord/components/interactive-message/mount-interactive-message.js.map +1 -0
- package/dist/discord/components/paginator/index.d.ts +5 -0
- package/dist/discord/components/paginator/index.d.ts.map +1 -0
- package/dist/discord/components/paginator/index.js +15 -0
- package/dist/discord/components/paginator/index.js.map +1 -0
- package/dist/discord/components/paginator/mount-paginator.d.ts +25 -0
- package/dist/discord/components/paginator/mount-paginator.d.ts.map +1 -0
- package/dist/discord/components/paginator/mount-paginator.js +41 -0
- package/dist/discord/components/paginator/mount-paginator.js.map +1 -0
- package/dist/discord/components/paginator/page-state.d.ts +21 -0
- package/dist/discord/components/paginator/page-state.d.ts.map +1 -0
- package/dist/discord/components/paginator/page-state.js +44 -0
- package/dist/discord/components/paginator/page-state.js.map +1 -0
- package/dist/discord/components/paginator/paginator-buttons.d.ts +17 -0
- package/dist/discord/components/paginator/paginator-buttons.d.ts.map +1 -0
- package/dist/discord/components/paginator/paginator-buttons.js +45 -0
- package/dist/discord/components/paginator/paginator-buttons.js.map +1 -0
- package/dist/discord/components/paginator/paginator-view.d.ts +26 -0
- package/dist/discord/components/paginator/paginator-view.d.ts.map +1 -0
- package/dist/discord/components/paginator/paginator-view.js +28 -0
- package/dist/discord/components/paginator/paginator-view.js.map +1 -0
- package/dist/discord/components/persistent-paginator.d.ts +47 -0
- package/dist/discord/components/persistent-paginator.d.ts.map +1 -0
- package/dist/discord/components/persistent-paginator.js +137 -0
- package/dist/discord/components/persistent-paginator.js.map +1 -0
- package/dist/discord/components/settings-editor/index.d.ts +7 -0
- package/dist/discord/components/settings-editor/index.d.ts.map +1 -0
- package/dist/discord/components/settings-editor/index.js +22 -0
- package/dist/discord/components/settings-editor/index.js.map +1 -0
- package/dist/discord/components/settings-editor/mount-settings-editor.d.ts +140 -0
- package/dist/discord/components/settings-editor/mount-settings-editor.d.ts.map +1 -0
- package/dist/discord/components/settings-editor/mount-settings-editor.js +344 -0
- package/dist/discord/components/settings-editor/mount-settings-editor.js.map +1 -0
- package/dist/discord/components/settings-editor/navigation.d.ts +49 -0
- package/dist/discord/components/settings-editor/navigation.d.ts.map +1 -0
- package/dist/discord/components/settings-editor/navigation.js +39 -0
- package/dist/discord/components/settings-editor/navigation.js.map +1 -0
- package/dist/discord/components/settings-editor/settings-editor-card.view.d.ts +56 -0
- package/dist/discord/components/settings-editor/settings-editor-card.view.d.ts.map +1 -0
- package/dist/discord/components/settings-editor/settings-editor-card.view.js +284 -0
- package/dist/discord/components/settings-editor/settings-editor-card.view.js.map +1 -0
- package/dist/discord/components/settings-editor/settings-editor-field-modal.d.ts +69 -0
- package/dist/discord/components/settings-editor/settings-editor-field-modal.d.ts.map +1 -0
- package/dist/discord/components/settings-editor/settings-editor-field-modal.js +353 -0
- package/dist/discord/components/settings-editor/settings-editor-field-modal.js.map +1 -0
- package/dist/discord/components/settings-editor/settings-editor-fields.d.ts +216 -0
- package/dist/discord/components/settings-editor/settings-editor-fields.d.ts.map +1 -0
- package/dist/discord/components/settings-editor/settings-editor-fields.js +66 -0
- package/dist/discord/components/settings-editor/settings-editor-fields.js.map +1 -0
- package/dist/discord/components/settings-editor/settings-editor.view.d.ts +303 -0
- package/dist/discord/components/settings-editor/settings-editor.view.d.ts.map +1 -0
- package/dist/discord/components/settings-editor/settings-editor.view.js +316 -0
- package/dist/discord/components/settings-editor/settings-editor.view.js.map +1 -0
- package/dist/discord/events/create-event.d.ts +5 -0
- package/dist/discord/events/create-event.d.ts.map +1 -0
- package/dist/discord/events/create-event.js +8 -0
- package/dist/discord/events/create-event.js.map +1 -0
- package/dist/discord/events/event-router.d.ts +16 -0
- package/dist/discord/events/event-router.d.ts.map +1 -0
- package/dist/discord/events/event-router.js +51 -0
- package/dist/discord/events/event-router.js.map +1 -0
- package/dist/discord/events/types.d.ts +16 -0
- package/dist/discord/events/types.d.ts.map +1 -0
- package/dist/discord/events/types.js +3 -0
- package/dist/discord/events/types.js.map +1 -0
- package/dist/discord/i18n.d.ts +58 -0
- package/dist/discord/i18n.d.ts.map +1 -0
- package/dist/discord/i18n.js +173 -0
- package/dist/discord/i18n.js.map +1 -0
- package/dist/discord/interaction/button.d.ts +39 -0
- package/dist/discord/interaction/button.d.ts.map +1 -0
- package/dist/discord/interaction/button.js +36 -0
- package/dist/discord/interaction/button.js.map +1 -0
- package/dist/discord/interaction/interaction-router.d.ts +21 -0
- package/dist/discord/interaction/interaction-router.d.ts.map +1 -0
- package/dist/discord/interaction/interaction-router.js +24 -0
- package/dist/discord/interaction/interaction-router.js.map +1 -0
- package/dist/discord/interaction/modal.d.ts +171 -0
- package/dist/discord/interaction/modal.d.ts.map +1 -0
- package/dist/discord/interaction/modal.js +264 -0
- package/dist/discord/interaction/modal.js.map +1 -0
- package/dist/discord/interaction/select-menu.d.ts +104 -0
- package/dist/discord/interaction/select-menu.d.ts.map +1 -0
- package/dist/discord/interaction/select-menu.js +126 -0
- package/dist/discord/interaction/select-menu.js.map +1 -0
- package/dist/discord/permissions.d.ts +32 -0
- package/dist/discord/permissions.d.ts.map +1 -0
- package/dist/discord/permissions.js +40 -0
- package/dist/discord/permissions.js.map +1 -0
- package/dist/discord/placeholder-registry-errors.d.ts +10 -0
- package/dist/discord/placeholder-registry-errors.d.ts.map +1 -0
- package/dist/discord/placeholder-registry-errors.js +18 -0
- package/dist/discord/placeholder-registry-errors.js.map +1 -0
- package/dist/discord/placeholder-registry.d.ts +53 -0
- package/dist/discord/placeholder-registry.d.ts.map +1 -0
- package/dist/discord/placeholder-registry.js +52 -0
- package/dist/discord/placeholder-registry.js.map +1 -0
- package/dist/discord/presenter.d.ts +26 -0
- package/dist/discord/presenter.d.ts.map +1 -0
- package/dist/discord/presenter.js +3 -0
- package/dist/discord/presenter.js.map +1 -0
- package/dist/discord/ui/card.d.ts +114 -0
- package/dist/discord/ui/card.d.ts.map +1 -0
- package/dist/discord/ui/card.js +162 -0
- package/dist/discord/ui/card.js.map +1 -0
- package/dist/discord/ui/color-input-errors.d.ts +12 -0
- package/dist/discord/ui/color-input-errors.d.ts.map +1 -0
- package/dist/discord/ui/color-input-errors.js +21 -0
- package/dist/discord/ui/color-input-errors.js.map +1 -0
- package/dist/discord/ui/color-input.d.ts +37 -0
- package/dist/discord/ui/color-input.d.ts.map +1 -0
- package/dist/discord/ui/color-input.js +166 -0
- package/dist/discord/ui/color-input.js.map +1 -0
- package/dist/discord/ui/colors.d.ts +44 -0
- package/dist/discord/ui/colors.d.ts.map +1 -0
- package/dist/discord/ui/colors.js +45 -0
- package/dist/discord/ui/colors.js.map +1 -0
- package/dist/discord/ui/embed.d.ts +16 -0
- package/dist/discord/ui/embed.d.ts.map +1 -0
- package/dist/discord/ui/embed.js +53 -0
- package/dist/discord/ui/embed.js.map +1 -0
- package/dist/discord/ui/message-attachments.d.ts +40 -0
- package/dist/discord/ui/message-attachments.d.ts.map +1 -0
- package/dist/discord/ui/message-attachments.js +27 -0
- package/dist/discord/ui/message-attachments.js.map +1 -0
- package/dist/errors/business-error.d.ts +41 -0
- package/dist/errors/business-error.d.ts.map +1 -0
- package/dist/errors/business-error.js +39 -0
- package/dist/errors/business-error.js.map +1 -0
- package/dist/errors/describe-error.d.ts +29 -0
- package/dist/errors/describe-error.d.ts.map +1 -0
- package/dist/errors/describe-error.js +49 -0
- package/dist/errors/describe-error.js.map +1 -0
- package/dist/errors/incident-ref.d.ts +7 -0
- package/dist/errors/incident-ref.d.ts.map +1 -0
- package/dist/errors/incident-ref.js +13 -0
- package/dist/errors/incident-ref.js.map +1 -0
- package/dist/errors/index.d.ts +4 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +15 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/i18n/business-message.d.ts +14 -0
- package/dist/i18n/business-message.d.ts.map +1 -0
- package/dist/i18n/business-message.js +19 -0
- package/dist/i18n/business-message.js.map +1 -0
- package/dist/i18n/catalog.d.ts +27 -0
- package/dist/i18n/catalog.d.ts.map +1 -0
- package/dist/i18n/catalog.js +25 -0
- package/dist/i18n/catalog.js.map +1 -0
- package/dist/i18n/errors.d.ts +10 -0
- package/dist/i18n/errors.d.ts.map +1 -0
- package/dist/i18n/errors.js +18 -0
- package/dist/i18n/errors.js.map +1 -0
- package/dist/i18n/index.d.ts +6 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.js +16 -0
- package/dist/i18n/index.js.map +1 -0
- package/dist/i18n/locale.d.ts +22 -0
- package/dist/i18n/locale.d.ts.map +1 -0
- package/dist/i18n/locale.js +40 -0
- package/dist/i18n/locale.js.map +1 -0
- package/dist/i18n/translator.d.ts +35 -0
- package/dist/i18n/translator.d.ts.map +1 -0
- package/dist/i18n/translator.js +40 -0
- package/dist/i18n/translator.js.map +1 -0
- package/dist/logger.d.ts +28 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +3 -0
- package/dist/logger.js.map +1 -0
- package/dist/module/module.d.ts +42 -0
- package/dist/module/module.d.ts.map +1 -0
- package/dist/module/module.js +3 -0
- package/dist/module/module.js.map +1 -0
- package/dist/persistence/database.d.ts +24 -0
- package/dist/persistence/database.d.ts.map +1 -0
- package/dist/persistence/database.js +3 -0
- package/dist/persistence/database.js.map +1 -0
- package/dist/resilience/retry.d.ts +36 -0
- package/dist/resilience/retry.d.ts.map +1 -0
- package/dist/resilience/retry.js +78 -0
- package/dist/resilience/retry.js.map +1 -0
- package/dist/scheduler/errors.d.ts +25 -0
- package/dist/scheduler/errors.d.ts.map +1 -0
- package/dist/scheduler/errors.js +45 -0
- package/dist/scheduler/errors.js.map +1 -0
- package/dist/scheduler/scheduler.d.ts +68 -0
- package/dist/scheduler/scheduler.d.ts.map +1 -0
- package/dist/scheduler/scheduler.js +162 -0
- package/dist/scheduler/scheduler.js.map +1 -0
- package/package.json +89 -0
- package/src/authz/authorizer.ts +76 -0
- package/src/authz/require-level-guard.ts +42 -0
- package/src/clock.ts +18 -0
- package/src/concurrency/keyed-queue.ts +50 -0
- package/src/concurrency/run-with-concurrency.ts +44 -0
- package/src/config/env.ts +58 -0
- package/src/config/errors.ts +21 -0
- package/src/datetime.ts +221 -0
- package/src/discord/api-errors.ts +91 -0
- package/src/discord/channel-exporter.ts +48 -0
- package/src/discord/command/autocomplete-dispatcher.ts +54 -0
- package/src/discord/command/autocomplete.ts +76 -0
- package/src/discord/command/context.ts +85 -0
- package/src/discord/command/cooldown-guard.ts +81 -0
- package/src/discord/command/create-command.ts +431 -0
- package/src/discord/command/create-context-menu-command.ts +227 -0
- package/src/discord/command/errors.ts +22 -0
- package/src/discord/command/guard.ts +54 -0
- package/src/discord/command/localization.ts +11 -0
- package/src/discord/command/options.ts +847 -0
- package/src/discord/command/permission-guard.ts +74 -0
- package/src/discord/command/router.ts +142 -0
- package/src/discord/command/types.ts +52 -0
- package/src/discord/components/component-router.ts +213 -0
- package/src/discord/components/interactive-message/index.ts +18 -0
- package/src/discord/components/interactive-message/interactive-message-collector.ts +374 -0
- package/src/discord/components/interactive-message/mount-interactive-message.ts +71 -0
- package/src/discord/components/paginator/index.ts +17 -0
- package/src/discord/components/paginator/mount-paginator.ts +69 -0
- package/src/discord/components/paginator/page-state.ts +45 -0
- package/src/discord/components/paginator/paginator-buttons.ts +60 -0
- package/src/discord/components/paginator/paginator-view.ts +61 -0
- package/src/discord/components/persistent-paginator.ts +197 -0
- package/src/discord/components/settings-editor/index.ts +41 -0
- package/src/discord/components/settings-editor/mount-settings-editor.ts +646 -0
- package/src/discord/components/settings-editor/navigation.ts +67 -0
- package/src/discord/components/settings-editor/settings-editor-card.view.ts +344 -0
- package/src/discord/components/settings-editor/settings-editor-field-modal.ts +538 -0
- package/src/discord/components/settings-editor/settings-editor-fields.ts +290 -0
- package/src/discord/components/settings-editor/settings-editor.view.ts +654 -0
- package/src/discord/events/create-event.ts +7 -0
- package/src/discord/events/event-router.ts +53 -0
- package/src/discord/events/types.ts +17 -0
- package/src/discord/i18n.ts +173 -0
- package/src/discord/interaction/button.ts +75 -0
- package/src/discord/interaction/interaction-router.ts +28 -0
- package/src/discord/interaction/modal.ts +505 -0
- package/src/discord/interaction/select-menu.ts +246 -0
- package/src/discord/permissions.ts +57 -0
- package/src/discord/placeholder-registry-errors.ts +11 -0
- package/src/discord/placeholder-registry.ts +77 -0
- package/src/discord/presenter.ts +26 -0
- package/src/discord/ui/card.ts +303 -0
- package/src/discord/ui/color-input-errors.ts +17 -0
- package/src/discord/ui/color-input.ts +179 -0
- package/src/discord/ui/colors.ts +58 -0
- package/src/discord/ui/embed.ts +64 -0
- package/src/discord/ui/message-attachments.ts +57 -0
- package/src/errors/business-error.ts +49 -0
- package/src/errors/describe-error.ts +73 -0
- package/src/errors/incident-ref.ts +10 -0
- package/src/errors/index.ts +12 -0
- package/src/i18n/business-message.ts +23 -0
- package/src/i18n/catalog.ts +41 -0
- package/src/i18n/errors.ts +11 -0
- package/src/i18n/index.ts +11 -0
- package/src/i18n/locale.ts +41 -0
- package/src/i18n/translator.ts +78 -0
- package/src/logger.ts +28 -0
- package/src/module/module.ts +42 -0
- package/src/persistence/database.ts +23 -0
- package/src/resilience/retry.ts +102 -0
- package/src/scheduler/errors.ts +40 -0
- package/src/scheduler/scheduler.ts +248 -0
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChatInputCommandInteraction,
|
|
3
|
+
MessageComponentInteraction,
|
|
4
|
+
ModalMessageModalSubmitInteraction,
|
|
5
|
+
} from "discord.js";
|
|
6
|
+
import type { Clock } from "@/clock";
|
|
7
|
+
import {
|
|
8
|
+
type InteractiveMessageHandle,
|
|
9
|
+
type InteractiveSelect,
|
|
10
|
+
type InteractiveView,
|
|
11
|
+
mountInteractiveMessage,
|
|
12
|
+
type StateStore,
|
|
13
|
+
toMessageEditOptions,
|
|
14
|
+
} from "@/discord/components/interactive-message";
|
|
15
|
+
import {
|
|
16
|
+
popLevel,
|
|
17
|
+
pushLevel,
|
|
18
|
+
ROOT_LEVEL_KEY,
|
|
19
|
+
type SettingsEditorLevel,
|
|
20
|
+
} from "@/discord/components/settings-editor/navigation";
|
|
21
|
+
import {
|
|
22
|
+
createEditorButtons,
|
|
23
|
+
createEditorView,
|
|
24
|
+
SETTINGS_EDITOR_RESET_CONFIRMING,
|
|
25
|
+
type SettingsEditorChrome,
|
|
26
|
+
type SettingsEditorIds,
|
|
27
|
+
type SettingsEditorState,
|
|
28
|
+
} from "@/discord/components/settings-editor/settings-editor.view";
|
|
29
|
+
import {
|
|
30
|
+
createCardEditorView,
|
|
31
|
+
fieldButtonId,
|
|
32
|
+
} from "@/discord/components/settings-editor/settings-editor-card.view";
|
|
33
|
+
import type {
|
|
34
|
+
SettingsEditorGroupSubmission,
|
|
35
|
+
SettingsEditorSubmission,
|
|
36
|
+
} from "@/discord/components/settings-editor/settings-editor-field-modal";
|
|
37
|
+
import {
|
|
38
|
+
promptEditorFieldValue,
|
|
39
|
+
promptEditorGroupValue,
|
|
40
|
+
} from "@/discord/components/settings-editor/settings-editor-field-modal";
|
|
41
|
+
import {
|
|
42
|
+
editorField,
|
|
43
|
+
isEditorField,
|
|
44
|
+
isGroupField,
|
|
45
|
+
isPickerField,
|
|
46
|
+
isValueField,
|
|
47
|
+
type SettingsEditorField,
|
|
48
|
+
type SettingsEditorFieldValue,
|
|
49
|
+
type SettingsEditorGroupField,
|
|
50
|
+
} from "@/discord/components/settings-editor/settings-editor-fields";
|
|
51
|
+
import { SETTINGS_EDITOR_MESSAGES } from "@/discord/i18n";
|
|
52
|
+
import { type Button, type ComponentContext, createButton } from "@/discord/interaction/button";
|
|
53
|
+
import { BusinessError, describeError } from "@/errors";
|
|
54
|
+
import { type Locale, resolveBusinessMessage, type Translator } from "@/i18n";
|
|
55
|
+
import type { Logger } from "@/logger";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What one write leaves behind: the new subject, and the files the message must
|
|
59
|
+
* now carry.
|
|
60
|
+
*
|
|
61
|
+
* Files, not the caller's own notion of an asset: the editor attaches them and
|
|
62
|
+
* never looks further, so a caller with no uploads at all is not made to carry
|
|
63
|
+
* the concept.
|
|
64
|
+
*/
|
|
65
|
+
export interface SettingsEditorWrite<S, A> {
|
|
66
|
+
readonly subject: S;
|
|
67
|
+
readonly assets: readonly A[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SettingsEditorOptions<S, A, F extends string> {
|
|
71
|
+
/**
|
|
72
|
+
* The screen's own custom ids, from `editorComponentIds`. Declared by the
|
|
73
|
+
* screen rather than derived here, so a test — and a log — names the very
|
|
74
|
+
* control an administrator clicked.
|
|
75
|
+
*/
|
|
76
|
+
readonly ids: SettingsEditorIds;
|
|
77
|
+
readonly fields: readonly SettingsEditorField<F>[];
|
|
78
|
+
/**
|
|
79
|
+
* The levels below the root, if the screen has any. Omitted by a flat screen,
|
|
80
|
+
* which then never renders a return control.
|
|
81
|
+
*/
|
|
82
|
+
readonly levels?: readonly SettingsEditorLevel<F>[];
|
|
83
|
+
readonly initial: SettingsEditorWrite<S, A>;
|
|
84
|
+
/** What the modal for `field` opens on. */
|
|
85
|
+
currentValue(subject: S, field: F): SettingsEditorFieldValue;
|
|
86
|
+
/**
|
|
87
|
+
* Persist one field — its typed value, its upload, or what a picker's select
|
|
88
|
+
* came back with, whichever `submission` carries.
|
|
89
|
+
*
|
|
90
|
+
* A `BusinessError` — an unsupported placeholder, a colour that is not one, an
|
|
91
|
+
* oversized upload — is caught here and shown as a notice: nothing is written,
|
|
92
|
+
* the previous value stands, and the screen stays open saying why in the
|
|
93
|
+
* administrator's language.
|
|
94
|
+
*
|
|
95
|
+
* `submission.interaction` is the modal's own submit interaction, handed over
|
|
96
|
+
* because a screen lives for minutes: a caller whose write is restricted must
|
|
97
|
+
* be able to re-read who is asking *now*, not who opened the screen.
|
|
98
|
+
*/
|
|
99
|
+
save(
|
|
100
|
+
subject: S,
|
|
101
|
+
field: F,
|
|
102
|
+
submission: SettingsEditorSubmission,
|
|
103
|
+
): Promise<SettingsEditorWrite<S, A>>;
|
|
104
|
+
/**
|
|
105
|
+
* Persist a group's whole modal at once — one write, whatever business
|
|
106
|
+
* refusal any of its members can raise refusing before any of it lands.
|
|
107
|
+
* Split across several calls, a refusal partway through would leave the
|
|
108
|
+
* fields before it written while the administrator is told the group
|
|
109
|
+
* failed; the module deciding what "one write" means for its own group is
|
|
110
|
+
* what keeps that from happening (a service already taking a partial patch,
|
|
111
|
+
* most often, the very shape a group's submission already is).
|
|
112
|
+
*
|
|
113
|
+
* Required only of a screen that declares a `group`-kind field — never
|
|
114
|
+
* called otherwise, so a screen with none may leave it out.
|
|
115
|
+
*/
|
|
116
|
+
saveGroup?(
|
|
117
|
+
subject: S,
|
|
118
|
+
group: F,
|
|
119
|
+
submissions: SettingsEditorGroupSubmission<F>,
|
|
120
|
+
interaction: ModalMessageModalSubmitInteraction,
|
|
121
|
+
): Promise<SettingsEditorWrite<S, A>>;
|
|
122
|
+
/**
|
|
123
|
+
* Extra text shown inside a group's modal, above its member fields — a
|
|
124
|
+
* token legend whose content depends on the subject (a guild's own declared
|
|
125
|
+
* request lines) and so cannot be declared statically alongside the group
|
|
126
|
+
* the way its label and hint are. `null`, or the hook left out entirely, is
|
|
127
|
+
* a group with nothing to say there.
|
|
128
|
+
*/
|
|
129
|
+
groupLegend?(subject: S, group: F): string | null;
|
|
130
|
+
/** Clear every override of the shown subject, uploads included. */
|
|
131
|
+
reset(subject: S): Promise<SettingsEditorWrite<S, A>>;
|
|
132
|
+
readonly chrome: SettingsEditorChrome<S, A>;
|
|
133
|
+
/**
|
|
134
|
+
* Behaviour for whatever the chrome renders above the field menu — the
|
|
135
|
+
* welcome editor's type picker.
|
|
136
|
+
*/
|
|
137
|
+
readonly selects?: InteractiveSelect<SettingsEditorState<S, A>>[];
|
|
138
|
+
/**
|
|
139
|
+
* Behaviour for the buttons the chrome renders beside the editor's own.
|
|
140
|
+
*
|
|
141
|
+
* The counterpart of {@link selects}, and needed for the same reason: a screen
|
|
142
|
+
* whose subject is a list needs controls that add to it and remove from it, and
|
|
143
|
+
* those are buttons. Without this the chrome could draw them and nothing would
|
|
144
|
+
* route the click — which is why the welcome preview's look-alikes are declared
|
|
145
|
+
* permanently disabled rather than merely left unhandled.
|
|
146
|
+
*
|
|
147
|
+
* The editor renders none of them itself: what appears on screen is whatever
|
|
148
|
+
* `chrome.extraRows`/`chrome.extraBlocks` puts there, level by level.
|
|
149
|
+
* Registering a button that no row or block carries is harmless, and is how
|
|
150
|
+
* a control confined to one level works.
|
|
151
|
+
*/
|
|
152
|
+
readonly buttons?: Button<SettingsEditorState<S, A>>[];
|
|
153
|
+
/**
|
|
154
|
+
* What one write changes outside the screen — a published panel that names
|
|
155
|
+
* a ticket type, say. Called after every write that actually lands, never
|
|
156
|
+
* after a refusal, since nothing changed for it to report then.
|
|
157
|
+
*
|
|
158
|
+
* This screen has no "done" control any more, and so no reliable moment
|
|
159
|
+
* that says "the administrator has finished" — only the message being
|
|
160
|
+
* dismissed, which nothing here is told about. Republishing used to wait
|
|
161
|
+
* for that moment on purpose, so a member watching the public result never
|
|
162
|
+
* saw the half-finished states an administrator passes through on the way
|
|
163
|
+
* to the setting they meant. That trade is deliberately given up: a public
|
|
164
|
+
* page that is current beats one that is merely correct but stale for
|
|
165
|
+
* however long this administrator takes, or forever if the process
|
|
166
|
+
* restarts before they are done.
|
|
167
|
+
*
|
|
168
|
+
* A throw here never reaches the administrator as a refusal — see
|
|
169
|
+
* `applyChange`'s own doc: by the time this runs, the write it reports on
|
|
170
|
+
* has already succeeded, and telling them otherwise would be a lie. It is
|
|
171
|
+
* logged instead and the screen still confirms.
|
|
172
|
+
*/
|
|
173
|
+
onWritten?: () => Promise<void>;
|
|
174
|
+
readonly translator: Translator;
|
|
175
|
+
readonly locale: Locale;
|
|
176
|
+
/** Stamps the screen's own embeds, so a render says when it was last refreshed. */
|
|
177
|
+
readonly clock: Clock;
|
|
178
|
+
readonly logger: Logger;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The mounted screen, filled in once `mountInteractiveMessage` returns. The
|
|
183
|
+
* controls need their behaviour before the message exists, so they close over
|
|
184
|
+
* this cell — the shape `delete.subcommand.ts` uses for the same reason.
|
|
185
|
+
*/
|
|
186
|
+
interface MountedScreen<S, A> {
|
|
187
|
+
screen:
|
|
188
|
+
| (InteractiveMessageHandle & { readonly store: StateStore<SettingsEditorState<S, A>> })
|
|
189
|
+
| null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* What triggers a field's own round trip, whichever control reached it: the
|
|
194
|
+
* embeds layout's field select, or a card layout's per-entry button. Both
|
|
195
|
+
* {@link ComponentContext} and
|
|
196
|
+
* {@link import("@/discord/components/interactive-message").SelectComponentContext}
|
|
197
|
+
* satisfy this — their `interaction` is
|
|
198
|
+
* `ButtonInteraction`/`AnySelectMenuInteraction` respectively, both assignable
|
|
199
|
+
* to the shared `MessageComponentInteraction` base a modal round trip
|
|
200
|
+
* actually needs.
|
|
201
|
+
*/
|
|
202
|
+
interface EditorTriggerContext<S> {
|
|
203
|
+
readonly interaction: MessageComponentInteraction;
|
|
204
|
+
readonly state: S;
|
|
205
|
+
update(next: S): Promise<void>;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The view, filled in once it is built. A card layout's per-field buttons must
|
|
210
|
+
* exist *before* `createCardEditorView` returns — the card renders their
|
|
211
|
+
* `Section` from them — yet their own `onClick` needs the very view being
|
|
212
|
+
* built to re-render through. This cell is what lets the buttons close over
|
|
213
|
+
* "the view, whenever it exists" instead of a value that is not there yet.
|
|
214
|
+
*/
|
|
215
|
+
interface MountedView<S, A> {
|
|
216
|
+
view: InteractiveView<SettingsEditorState<S, A>> | null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Mount the editor every customisable ticket embed is edited through: a preview
|
|
221
|
+
* of what members see, a menu of fields, a modal per field, and a confirmed
|
|
222
|
+
* reset.
|
|
223
|
+
*
|
|
224
|
+
* Everything specific to *what* is being edited — where the values are read,
|
|
225
|
+
* where they are written, how the preview is rendered — is supplied by the
|
|
226
|
+
* caller. What lives here is the part both screens would otherwise have written
|
|
227
|
+
* twice: the modal round trip, the read-at-submit-time rule, refusals turned
|
|
228
|
+
* into notices, and re-rendering through the interaction that is still usable.
|
|
229
|
+
*
|
|
230
|
+
* Call it on an already-deferred interaction.
|
|
231
|
+
*/
|
|
232
|
+
export async function mountSettingsEditor<S, A, F extends string>(
|
|
233
|
+
interaction: ChatInputCommandInteraction,
|
|
234
|
+
options: SettingsEditorOptions<S, A, F>,
|
|
235
|
+
): Promise<InteractiveMessageHandle & { readonly store: StateStore<SettingsEditorState<S, A>> }> {
|
|
236
|
+
const { translator, locale } = options;
|
|
237
|
+
const { ids } = options;
|
|
238
|
+
const mounted: MountedScreen<S, A> = { screen: null };
|
|
239
|
+
const mountedView: MountedView<S, A> = { view: null };
|
|
240
|
+
// Every level's fields, flattened: a pick is routed by key, and the key is
|
|
241
|
+
// unique across the screen whichever level offered it.
|
|
242
|
+
const allFields: readonly SettingsEditorField<F>[] = [
|
|
243
|
+
...options.fields,
|
|
244
|
+
...(options.levels ?? []).flatMap((level) => level.fields),
|
|
245
|
+
];
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Run one write and fold its outcome into the state. A refusal is answered
|
|
249
|
+
* identically wherever it comes from: nothing changes but the notice.
|
|
250
|
+
*
|
|
251
|
+
* The `try` covers `write()` alone — a `BusinessError` out of it is a
|
|
252
|
+
* legitimate refusal to show, because nothing landed. `onWritten` runs
|
|
253
|
+
* only once that write has actually succeeded, deliberately outside that
|
|
254
|
+
* `try`: a hook failing after the data is already saved is not a refusal
|
|
255
|
+
* of the write, and folding it into the same catch would tell the
|
|
256
|
+
* administrator their change was rejected when it was not — written and
|
|
257
|
+
* called a refusal is worse than either alone.
|
|
258
|
+
*
|
|
259
|
+
* `onWritten` is a public hook; nothing here trusts it not to throw. Its
|
|
260
|
+
* failure is logged rather than shown or re-thrown: the write already
|
|
261
|
+
* landed, so the administrator is owed the confirmation of what they
|
|
262
|
+
* actually did, and a side effect that failed to keep up (a public panel
|
|
263
|
+
* not yet refreshed, say) is an incident to trace, not a message to hand
|
|
264
|
+
* back to them on a screen that has nothing to do with it.
|
|
265
|
+
*/
|
|
266
|
+
async function applyChange(
|
|
267
|
+
state: SettingsEditorState<S, A>,
|
|
268
|
+
write: () => Promise<SettingsEditorWrite<S, A>>,
|
|
269
|
+
notice: string,
|
|
270
|
+
): Promise<SettingsEditorState<S, A>> {
|
|
271
|
+
let written: SettingsEditorWrite<S, A>;
|
|
272
|
+
try {
|
|
273
|
+
written = await write();
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (error instanceof BusinessError) {
|
|
276
|
+
// A refusal is the one outcome the screen has to say out loud:
|
|
277
|
+
// nothing was written, so the preview shows no trace of it.
|
|
278
|
+
return {
|
|
279
|
+
...state,
|
|
280
|
+
notice: { text: resolveBusinessMessage(error, translator, locale), tone: "refusal" },
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
try {
|
|
286
|
+
await options.onWritten?.();
|
|
287
|
+
} catch (error) {
|
|
288
|
+
options.logger.error(
|
|
289
|
+
{ err: describeError(error) },
|
|
290
|
+
"onWritten failed after a settings editor write had already landed",
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
const { subject, assets } = written;
|
|
294
|
+
return { ...state, subject, assets, notice: { text: notice, tone: "confirmation" } };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Undo one step of where the screen is: leave the level for the one above it. */
|
|
298
|
+
function stepBack(clickCtx: ComponentContext<SettingsEditorState<S, A>>): Promise<void> {
|
|
299
|
+
const { state } = clickCtx;
|
|
300
|
+
return clickCtx.update({
|
|
301
|
+
...state,
|
|
302
|
+
path: popLevel(state.path),
|
|
303
|
+
notice: null,
|
|
304
|
+
// Whatever a screen's own accessory button was waiting to have confirmed
|
|
305
|
+
// belonged to the level being left; carrying it up would let a click
|
|
306
|
+
// meant for something else fire it.
|
|
307
|
+
confirming: null,
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function fieldNotice(
|
|
312
|
+
field: SettingsEditorField<F>,
|
|
313
|
+
submission: SettingsEditorSubmission,
|
|
314
|
+
): string {
|
|
315
|
+
return translator.translate(locale, fieldNoticeKey(field, submission), {
|
|
316
|
+
field: translator.translate(locale, field.labelKey),
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Edit one field: ask for its new value in a modal prefilled with the current
|
|
322
|
+
* one — a text input, an upload, or a select of the picker's own shape — save
|
|
323
|
+
* what comes back, and re-render the preview through the modal's own
|
|
324
|
+
* interaction, since `showModal` has already acknowledged the pick or click.
|
|
325
|
+
*
|
|
326
|
+
* `fieldKey` is the key of the field to edit — the select's own picked value
|
|
327
|
+
* on the embeds layout, the key baked into the button's id on the card
|
|
328
|
+
* layout — so both reach the very same round trip, and this is where they
|
|
329
|
+
* meet back up.
|
|
330
|
+
*/
|
|
331
|
+
async function editField(
|
|
332
|
+
ctx: EditorTriggerContext<SettingsEditorState<S, A>>,
|
|
333
|
+
fieldKey: string,
|
|
334
|
+
): Promise<void> {
|
|
335
|
+
const view = mountedView.view;
|
|
336
|
+
if (view === null || !isEditorField(allFields, fieldKey)) {
|
|
337
|
+
// `view` is unreachable while `null`: nothing can click a control before
|
|
338
|
+
// `mountInteractiveMessage` sends the message it belongs to, and `view` is
|
|
339
|
+
// assigned before that call. An unrecognised key cannot happen from either
|
|
340
|
+
// control either — both are built from `allFields` — but is not worth
|
|
341
|
+
// throwing over.
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const field = editorField(allFields, fieldKey);
|
|
345
|
+
if (isGroupField(field)) {
|
|
346
|
+
await editGroup(ctx, field);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (!isValueField(field) && !isPickerField(field)) {
|
|
350
|
+
// A level: descending writes nothing, so it answers the click by
|
|
351
|
+
// re-rendering rather than going through `applyChange` — there is no
|
|
352
|
+
// outcome to report.
|
|
353
|
+
const state = mounted.screen?.store.read() ?? ctx.state;
|
|
354
|
+
await ctx.update({
|
|
355
|
+
...state,
|
|
356
|
+
path: pushLevel(state.path, field.levelKey),
|
|
357
|
+
// The notice belonged to the level being left; carrying it down would
|
|
358
|
+
// credit this one with something that happened elsewhere. Likewise
|
|
359
|
+
// whatever a screen's own accessory button on that level was waiting
|
|
360
|
+
// to have confirmed.
|
|
361
|
+
notice: null,
|
|
362
|
+
confirming: null,
|
|
363
|
+
});
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const submission = await promptEditorFieldValue(
|
|
367
|
+
ctx.interaction,
|
|
368
|
+
field,
|
|
369
|
+
options.currentValue(ctx.state.subject, fieldKey),
|
|
370
|
+
translator,
|
|
371
|
+
locale,
|
|
372
|
+
);
|
|
373
|
+
if (submission === null) {
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Read at submit time rather than at pick time: a modal can stay open for
|
|
378
|
+
// minutes, during which the screen may have moved on to another subject.
|
|
379
|
+
const state = mounted.screen?.store.read() ?? ctx.state;
|
|
380
|
+
const next = await applyChange(
|
|
381
|
+
state,
|
|
382
|
+
() => options.save(state.subject, fieldKey, submission),
|
|
383
|
+
fieldNotice(field, submission),
|
|
384
|
+
);
|
|
385
|
+
mounted.screen?.store.write(next);
|
|
386
|
+
await submission.interaction.update(
|
|
387
|
+
toMessageEditOptions(
|
|
388
|
+
view.render(next),
|
|
389
|
+
[...submission.interaction.message.attachments.values()],
|
|
390
|
+
next,
|
|
391
|
+
),
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Edit a group: ask for every member field it declares in one modal,
|
|
397
|
+
* prefilled the same way a single field is, and save the whole thing as one
|
|
398
|
+
* write — `options.saveGroup` is what decides what "one write" means for
|
|
399
|
+
* this group, most often a service that already takes a partial patch.
|
|
400
|
+
*/
|
|
401
|
+
async function editGroup(
|
|
402
|
+
ctx: EditorTriggerContext<SettingsEditorState<S, A>>,
|
|
403
|
+
group: SettingsEditorGroupField<F>,
|
|
404
|
+
): Promise<void> {
|
|
405
|
+
const view = mountedView.view;
|
|
406
|
+
if (view === null) {
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const prompt = await promptEditorGroupValue(
|
|
410
|
+
ctx.interaction,
|
|
411
|
+
group,
|
|
412
|
+
(memberKey) => options.currentValue(ctx.state.subject, memberKey),
|
|
413
|
+
options.groupLegend?.(ctx.state.subject, group.key) ?? null,
|
|
414
|
+
translator,
|
|
415
|
+
locale,
|
|
416
|
+
);
|
|
417
|
+
if (prompt === null) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const { saveGroup } = options;
|
|
421
|
+
if (saveGroup === undefined) {
|
|
422
|
+
// A screen declaring a `group`-kind field must also declare `saveGroup` —
|
|
423
|
+
// a wiring mistake in the module, not something an administrator caused,
|
|
424
|
+
// so the round trip simply ends here rather than throwing at them for it.
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Read at submit time rather than at pick time, exactly as `editField`
|
|
429
|
+
// does: a modal can stay open for minutes, during which the screen may
|
|
430
|
+
// have moved on to another subject.
|
|
431
|
+
const state = mounted.screen?.store.read() ?? ctx.state;
|
|
432
|
+
const next = await applyChange(
|
|
433
|
+
state,
|
|
434
|
+
() => saveGroup(state.subject, group.key, prompt.values, prompt.interaction),
|
|
435
|
+
translator.translate(locale, SETTINGS_EDITOR_MESSAGES.fieldSaved, {
|
|
436
|
+
field: translator.translate(locale, group.labelKey),
|
|
437
|
+
}),
|
|
438
|
+
);
|
|
439
|
+
mounted.screen?.store.write(next);
|
|
440
|
+
await prompt.interaction.update(
|
|
441
|
+
toMessageEditOptions(
|
|
442
|
+
view.render(next),
|
|
443
|
+
[...prompt.interaction.message.attachments.values()],
|
|
444
|
+
next,
|
|
445
|
+
),
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const buttons = createEditorButtons<S, A>(ids, translator, locale, {
|
|
450
|
+
// Nothing is written here. Clearing an override that names an uploaded
|
|
451
|
+
// image destroys the bytes with it, so the click that asks and the click
|
|
452
|
+
// that carries it out are deliberately two different ones.
|
|
453
|
+
onReset: (clickCtx) =>
|
|
454
|
+
clickCtx.update({ ...clickCtx.state, confirming: SETTINGS_EDITOR_RESET_CONFIRMING }),
|
|
455
|
+
onConfirmReset: async (clickCtx) => {
|
|
456
|
+
const next = await applyChange(
|
|
457
|
+
clickCtx.state,
|
|
458
|
+
() => options.reset(clickCtx.state.subject),
|
|
459
|
+
translator.translate(locale, SETTINGS_EDITOR_MESSAGES.resetDone),
|
|
460
|
+
);
|
|
461
|
+
await clickCtx.update({ ...next, confirming: null });
|
|
462
|
+
},
|
|
463
|
+
onCancelReset: (clickCtx) =>
|
|
464
|
+
clickCtx.update({
|
|
465
|
+
...clickCtx.state,
|
|
466
|
+
notice: {
|
|
467
|
+
text: translator.translate(locale, SETTINGS_EDITOR_MESSAGES.resetCancelled),
|
|
468
|
+
tone: "confirmation",
|
|
469
|
+
},
|
|
470
|
+
confirming: null,
|
|
471
|
+
}),
|
|
472
|
+
onReturn: (clickCtx) => stepBack(clickCtx),
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
const dressing = {
|
|
476
|
+
clock: options.clock,
|
|
477
|
+
// Read from the client rather than asked of the caller: every screen wants
|
|
478
|
+
// the same avatar, and it is the one thing here nobody has to configure.
|
|
479
|
+
iconUrl: interaction.client.user?.displayAvatarURL() ?? null,
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Whether `field` goes somewhere rather than editing anything — the same
|
|
484
|
+
* question `editField` itself answers for its own routing, asked here only
|
|
485
|
+
* to word its button: "Open" for a level, "Edit" for everything else.
|
|
486
|
+
*/
|
|
487
|
+
function isLevelField(field: SettingsEditorField<F>): boolean {
|
|
488
|
+
return !isValueField(field) && !isPickerField(field) && !isGroupField(field);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Which layout the level owning `field` renders its entries with — the
|
|
493
|
+
* root's own fields are always `"sections"`, since the root has no picker
|
|
494
|
+
* above it for a `"buttons"` row to act on. Looked up once per field rather
|
|
495
|
+
* than threaded through as a parameter: `buildFieldButton` is called from
|
|
496
|
+
* one place, over every field up front, and this is what tells it apart
|
|
497
|
+
* from the level a field belongs to without that caller repeating the walk
|
|
498
|
+
* `createCardEditorView` already does over `options.levels`.
|
|
499
|
+
*/
|
|
500
|
+
const fieldEntryLayout = new Map<string, "sections" | "buttons">();
|
|
501
|
+
// The root's own layout is the card chrome's `entryLayout` — the embeds
|
|
502
|
+
// layout never reads this map at all, so a screen without a card chrome
|
|
503
|
+
// simply leaves every root field labelled the default it is never asked for.
|
|
504
|
+
const rootEntryLayout =
|
|
505
|
+
options.chrome.layout === "card" ? (options.chrome.entryLayout ?? "sections") : "sections";
|
|
506
|
+
for (const field of options.fields) {
|
|
507
|
+
fieldEntryLayout.set(field.key, rootEntryLayout);
|
|
508
|
+
}
|
|
509
|
+
for (const level of options.levels ?? []) {
|
|
510
|
+
const entryLayout = level.entryLayout ?? "sections";
|
|
511
|
+
for (const field of level.fields) {
|
|
512
|
+
fieldEntryLayout.set(field.key, entryLayout);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* The button a card layout's `Section` carries for one menu entry — what
|
|
518
|
+
* picking it from the old field select used to do, now reached by a click.
|
|
519
|
+
* Built for every field up front, whichever level declares it: a button not
|
|
520
|
+
* on the level currently shown is simply never rendered, exactly as
|
|
521
|
+
* `SettingsEditorButtons.returning` is carried whether or not the current
|
|
522
|
+
* state shows it.
|
|
523
|
+
*
|
|
524
|
+
* A level rendering its entries as a `"buttons"` row has no `Section` text
|
|
525
|
+
* to say what each one does, so its button carries the entry's own label —
|
|
526
|
+
* "Declare a game", "This game" — instead of the generic "Open"/"Edit" a
|
|
527
|
+
* `"sections"` level's accessory button wears.
|
|
528
|
+
*/
|
|
529
|
+
function buildFieldButton(field: SettingsEditorField<F>): Button<SettingsEditorState<S, A>> {
|
|
530
|
+
const navigational = isLevelField(field);
|
|
531
|
+
const label =
|
|
532
|
+
fieldEntryLayout.get(field.key) === "buttons"
|
|
533
|
+
? translator.translate(locale, field.labelKey)
|
|
534
|
+
: translator.translate(
|
|
535
|
+
locale,
|
|
536
|
+
navigational
|
|
537
|
+
? SETTINGS_EDITOR_MESSAGES.openLevelButton
|
|
538
|
+
: SETTINGS_EDITOR_MESSAGES.editFieldButton,
|
|
539
|
+
);
|
|
540
|
+
return createButton<SettingsEditorState<S, A>>({
|
|
541
|
+
id: fieldButtonId(ids, field.key),
|
|
542
|
+
label,
|
|
543
|
+
// The entry's own icon wins when it declares one — the generic one lies
|
|
544
|
+
// for an entry that opens a modal to create something rather than edit
|
|
545
|
+
// what is already there. Left undeclared, this is exactly the generic
|
|
546
|
+
// this repository always rendered.
|
|
547
|
+
emoji: field.icon ?? (navigational ? "➡️" : "✏️"),
|
|
548
|
+
style: "secondary",
|
|
549
|
+
// Frozen along with the rest of the screen while the editor's own reset is
|
|
550
|
+
// waiting for its second click. A screen's own accessory button — a
|
|
551
|
+
// game's own "remove" — gates itself on whatever *it* is waiting to have
|
|
552
|
+
// confirmed instead; this one only ever means the editor's.
|
|
553
|
+
disabled: (state) => state.confirming === SETTINGS_EDITOR_RESET_CONFIRMING,
|
|
554
|
+
onClick: (clickCtx) => editField(clickCtx, field.key),
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// The card layout replaces the field select with one button per entry,
|
|
559
|
+
// looked up by the very key `editField` already routes on; the embeds
|
|
560
|
+
// layout keeps the select, and never builds a button nothing would render.
|
|
561
|
+
const fieldButtons: Button<SettingsEditorState<S, A>>[] = [];
|
|
562
|
+
const selects: InteractiveSelect<SettingsEditorState<S, A>>[] = [...(options.selects ?? [])];
|
|
563
|
+
let view: InteractiveView<SettingsEditorState<S, A>>;
|
|
564
|
+
|
|
565
|
+
if (options.chrome.layout === "card") {
|
|
566
|
+
const fieldButtonMap = new Map(allFields.map((field) => [field.key, buildFieldButton(field)]));
|
|
567
|
+
fieldButtons.push(...fieldButtonMap.values());
|
|
568
|
+
view = createCardEditorView(
|
|
569
|
+
options.fields,
|
|
570
|
+
options.levels,
|
|
571
|
+
buttons,
|
|
572
|
+
fieldButtonMap,
|
|
573
|
+
options.chrome,
|
|
574
|
+
translator,
|
|
575
|
+
locale,
|
|
576
|
+
dressing,
|
|
577
|
+
);
|
|
578
|
+
} else {
|
|
579
|
+
view = createEditorView(
|
|
580
|
+
ids,
|
|
581
|
+
options.fields,
|
|
582
|
+
options.levels,
|
|
583
|
+
buttons,
|
|
584
|
+
options.chrome,
|
|
585
|
+
translator,
|
|
586
|
+
locale,
|
|
587
|
+
dressing,
|
|
588
|
+
// Whether *any* group on this screen might open on a legend: `groupLegend`
|
|
589
|
+
// is one hook for the whole screen, not declared per group, so every group
|
|
590
|
+
// here reserves the one component a legend would cost rather than risk a
|
|
591
|
+
// modal that only overflows for the one group that actually gets one.
|
|
592
|
+
options.groupLegend !== undefined,
|
|
593
|
+
);
|
|
594
|
+
selects.unshift({
|
|
595
|
+
id: ids.field,
|
|
596
|
+
onSelect: (pickCtx) => {
|
|
597
|
+
const picked = pickCtx.values[0];
|
|
598
|
+
return picked === undefined ? undefined : editField(pickCtx, picked);
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
mountedView.view = view;
|
|
603
|
+
|
|
604
|
+
// The screen's own after the editor's, so a caller cannot shadow a control
|
|
605
|
+
// the editor depends on: `mountInteractiveMessage` routes to the first match.
|
|
606
|
+
const allButtons = [...buttons.all, ...fieldButtons, ...(options.buttons ?? [])];
|
|
607
|
+
|
|
608
|
+
mounted.screen = await mountInteractiveMessage<SettingsEditorState<S, A>>(interaction, {
|
|
609
|
+
initialState: {
|
|
610
|
+
subject: options.initial.subject,
|
|
611
|
+
assets: options.initial.assets,
|
|
612
|
+
notice: null,
|
|
613
|
+
confirming: null,
|
|
614
|
+
path: [ROOT_LEVEL_KEY],
|
|
615
|
+
},
|
|
616
|
+
buttons: allButtons,
|
|
617
|
+
selects,
|
|
618
|
+
view,
|
|
619
|
+
ownerId: interaction.user.id,
|
|
620
|
+
logger: options.logger,
|
|
621
|
+
});
|
|
622
|
+
return mounted.screen;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Which notice a submission earns, in the vocabulary its field's own kind
|
|
627
|
+
* understands: an upload always says so; a picker field reads its clear from
|
|
628
|
+
* `submission.ids` being `null`, a value field from `submission.value` being
|
|
629
|
+
* `null` — the two never mean the same thing on the other kind of field.
|
|
630
|
+
*/
|
|
631
|
+
function fieldNoticeKey<F extends string>(
|
|
632
|
+
field: SettingsEditorField<F>,
|
|
633
|
+
submission: SettingsEditorSubmission,
|
|
634
|
+
): string {
|
|
635
|
+
if (submission.upload !== null) {
|
|
636
|
+
return SETTINGS_EDITOR_MESSAGES.fieldUploaded;
|
|
637
|
+
}
|
|
638
|
+
if (isPickerField(field)) {
|
|
639
|
+
return submission.ids === null
|
|
640
|
+
? SETTINGS_EDITOR_MESSAGES.fieldCleared
|
|
641
|
+
: SETTINGS_EDITOR_MESSAGES.fieldSaved;
|
|
642
|
+
}
|
|
643
|
+
return submission.value === null
|
|
644
|
+
? SETTINGS_EDITOR_MESSAGES.fieldCleared
|
|
645
|
+
: SETTINGS_EDITOR_MESSAGES.fieldSaved;
|
|
646
|
+
}
|