@celestia-island/hikari 0.4.4 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +22 -7
- package/src/DemoApp.tsx +491 -0
- package/src/animation/registerAnimations.test.ts +94 -0
- package/src/animation/registerAnimations.ts +103 -0
- package/src/components/HkAboutModal.scss +95 -0
- package/src/components/HkAboutModal.tsx +114 -0
- package/src/components/HkAdaptiveDialog.scss +22 -0
- package/src/components/HkAdaptiveDialog.test.tsx +271 -0
- package/src/components/HkAdaptiveDialog.tsx +167 -0
- package/src/components/HkAdminHeader.test.tsx +185 -0
- package/src/components/HkAdminHeader.tsx +308 -0
- package/src/components/HkAdminShell.test.tsx +237 -0
- package/src/components/HkAdminShell.tsx +129 -0
- package/src/components/HkAdminTablePage.tsx +70 -0
- package/src/components/HkAdminWidgets.test.ts +89 -0
- package/src/components/HkAlert.tsx +1 -1
- package/src/components/HkAltSignIn.scss +55 -0
- package/src/components/HkAltSignIn.test.tsx +88 -0
- package/src/components/HkAltSignIn.tsx +92 -0
- package/src/components/HkAttachmentModal.scss +49 -0
- package/src/components/HkAttachmentModal.tsx +318 -0
- package/src/components/HkAuthCard.tsx +50 -0
- package/src/components/HkAuthSubmitButton.scss +12 -0
- package/src/components/HkAuthSubmitButton.tsx +94 -0
- package/src/components/HkAvatar.scss +93 -0
- package/src/components/HkAvatar.tsx +2 -2
- package/src/components/HkBlockingToast.scss +123 -0
- package/src/components/HkBlockingToast.test.tsx +221 -0
- package/src/components/HkBlockingToast.tsx +182 -0
- package/src/components/HkBreadcrumb.scss +624 -0
- package/src/components/HkBreadcrumb.tsx +2 -2
- package/src/components/HkButton.scss +16 -2
- package/src/components/HkCaptchaModal.scss +14 -0
- package/src/components/HkCaptchaModal.tsx +62 -0
- package/src/components/HkCaptchaWidget.scss +30 -0
- package/src/components/HkCaptchaWidget.tsx +181 -0
- package/src/components/HkCard.scss +1 -1
- package/src/components/HkChatTypes.ts +65 -0
- package/src/components/HkCheckbox.scss +4 -9
- package/src/components/HkCheckbox.tsx +18 -6
- package/src/components/HkColorPicker.scss +120 -3
- package/src/components/HkColorPicker.tsx +130 -19
- package/src/components/HkColorPickerTouch.test.tsx +145 -0
- package/src/components/HkColorSchemeDialog.scss +52 -0
- package/src/components/HkColorSchemeDialog.test.ts +201 -0
- package/src/components/HkColorSchemeDialog.tsx +96 -0
- package/src/components/HkColorSchemeEditor.test.ts +270 -0
- package/src/components/HkColorSchemeEditor.tsx +410 -0
- package/src/components/HkConfirmDialog.scss +43 -3
- package/src/components/HkConfirmDialog.tsx +7 -4
- package/src/components/HkConnectionInfo.ts +80 -0
- package/src/components/HkConnectionStatus.tsx +158 -0
- package/src/components/HkCookieConsent.scss +22 -0
- package/src/components/HkCookieConsent.test.tsx +70 -0
- package/src/components/HkCookieConsent.tsx +56 -0
- package/src/components/HkCountdownDigit.tsx +105 -0
- package/src/components/HkDatePicker.scss +361 -0
- package/src/components/HkDatePicker.test.ts +459 -0
- package/src/components/HkDatePicker.tsx +568 -0
- package/src/components/HkDateTimePicker.scss +65 -37
- package/src/components/HkDateTimePicker.test.ts +344 -0
- package/src/components/HkDateTimePicker.tsx +249 -82
- package/src/components/HkDraggableGrid.scss +4 -1
- package/src/components/HkDraggableList.scss +4 -1
- package/src/components/HkDrawer.scss +27 -13
- package/src/components/HkDrawer.test.tsx +73 -0
- package/src/components/HkDrawer.tsx +157 -10
- package/src/components/HkErrorBoundary.scss +5 -2
- package/src/components/HkErrorBoundary.tsx +7 -4
- package/src/components/HkExpansionPanel.scss +143 -0
- package/src/components/HkExpansionPanel.test.tsx +109 -0
- package/src/components/HkExpansionPanel.tsx +101 -0
- package/src/components/HkFab.scss +162 -0
- package/src/components/HkFab.test.tsx +217 -0
- package/src/components/HkFab.tsx +214 -0
- package/src/components/HkHoverRevealAction.scss +6 -0
- package/src/components/HkHoverRevealAction.test.ts +68 -0
- package/src/components/HkHoverRevealAction.tsx +84 -6
- package/src/components/HkIcon.scss +123 -0
- package/src/components/HkIcon.tsx +7 -23
- package/src/components/HkIconButton.scss +170 -0
- package/src/components/HkIconButton.test.tsx +75 -0
- package/src/components/HkIconButton.tsx +4 -3
- package/src/components/HkIconButtonVars.scss +180 -0
- package/src/components/HkInput.affix.test.ts +253 -0
- package/src/components/HkInput.enter.test.ts +88 -0
- package/src/components/HkInput.scss +127 -8
- package/src/components/HkInput.tsx +245 -13
- package/src/components/HkKeywordSearchModal.scss +13 -0
- package/src/components/HkKeywordSearchModal.tsx +46 -9
- package/src/components/HkLabel.scss +34 -0
- package/src/components/HkLabel.test.tsx +61 -0
- package/src/components/HkLabel.tsx +45 -0
- package/src/components/HkListTransition.scss +26 -0
- package/src/components/HkListTransition.tsx +57 -0
- package/src/components/HkLocalePicker.scss +37 -0
- package/src/components/HkLocalePicker.tsx +133 -0
- package/src/components/HkLocalePickerPopup.tsx +35 -19
- package/src/components/HkLocalizedInput.scss +277 -0
- package/src/components/HkLocalizedInput.test.tsx +693 -0
- package/src/components/HkLocalizedInput.tsx +495 -0
- package/src/components/HkLogWindow.scss +121 -0
- package/src/components/HkLogWindow.tsx +195 -0
- package/src/components/HkLogo.scss +1 -1
- package/src/components/HkLogo.tsx +1 -1
- package/src/components/HkMarkdownRenderer.scss +22 -2
- package/src/components/HkMarkdownRenderer.tsx +33 -3
- package/src/components/HkMediaControlBar.tsx +20 -13
- package/src/components/HkMenu.scss +200 -0
- package/src/components/HkMenu.test.ts +779 -0
- package/src/components/HkMenu.tsx +770 -0
- package/src/components/HkMenuActionItem.scss +58 -0
- package/src/components/HkMenuActionItem.tsx +41 -0
- package/src/components/HkMenuIdentityItem.scss +70 -0
- package/src/components/HkMenuIdentityItem.tsx +42 -0
- package/src/components/HkMenuPanel.scss +20 -0
- package/src/components/HkMenuPanel.test.tsx +118 -0
- package/src/components/HkMenuPanel.tsx +32 -0
- package/src/components/HkMinimap.tsx +16 -8
- package/src/components/HkModal.autofollow.test.ts +44 -0
- package/src/components/HkModal.scss +170 -53
- package/src/components/HkModal.sheetgap.test.ts +82 -0
- package/src/components/HkModal.test.tsx +334 -0
- package/src/components/HkModal.tsx +314 -68
- package/src/components/HkModalBreadcrumb.scss +4 -1
- package/src/components/HkModalBreadcrumb.test.tsx +111 -0
- package/src/components/HkModalBreadcrumb.tsx +34 -11
- package/src/components/HkModelCatalog.ts +98 -0
- package/src/components/HkModelTag.scss +130 -0
- package/src/components/HkModelTag.test.tsx +111 -0
- package/src/components/HkModelTag.tsx +190 -0
- package/src/components/HkNavItem.scss +13 -2
- package/src/components/HkNavItem.tsx +2 -1
- package/src/components/HkNavSidebar.tsx +99 -0
- package/src/components/HkNumberInput.scss +12 -0
- package/src/components/HkNumberInput.tsx +15 -8
- package/src/components/HkPageHeader.scss +79 -0
- package/src/components/HkPageHeader.test.ts +67 -0
- package/src/components/HkPageHeader.tsx +51 -0
- package/src/components/HkPasswordInput.scss +29 -17
- package/src/components/HkPasswordInput.test.ts +281 -0
- package/src/components/HkPasswordInput.tsx +337 -59
- package/src/components/HkPickerPane.scss +158 -0
- package/src/components/HkPlaceholderMarquee.scss +109 -0
- package/src/components/HkPlaceholderMarquee.test.ts +305 -0
- package/src/components/HkPlaceholderMarquee.tsx +170 -0
- package/src/components/HkPopover.scss +134 -10
- package/src/components/HkPopover.sheetdock.test.ts +34 -0
- package/src/components/HkPopover.surfacevars.test.ts +20 -0
- package/src/components/HkPopover.titleSuppress.test.tsx +265 -0
- package/src/components/HkPopover.tsx +225 -50
- package/src/components/HkPopoverSheet.test.tsx +130 -0
- package/src/components/HkPopupSelect.scss +30 -10
- package/src/components/HkPopupSelect.tsx +21 -1
- package/src/components/HkProgressDialog.scss +31 -0
- package/src/components/HkProgressDialog.tsx +37 -19
- package/src/components/HkProtocolModal.scss +26 -0
- package/src/components/HkProtocolModal.tsx +112 -0
- package/src/components/HkRadio.scss +1 -16
- package/src/components/HkRadio.tsx +5 -2
- package/src/components/HkRichInput.scss +162 -0
- package/src/components/HkRichInput.tsx +335 -0
- package/src/components/HkRollingNumber.test.ts +90 -0
- package/src/components/HkRollingNumber.tsx +39 -1
- package/src/components/HkScrollContainer.scss +71 -54
- package/src/components/HkScrollContainer.test.ts +160 -0
- package/src/components/HkScrollContainer.tsx +187 -160
- package/src/components/HkSearchInput.tsx +3 -3
- package/src/components/HkSecretRevealModal.scss +61 -0
- package/src/components/HkSecretRevealModal.tsx +91 -0
- package/src/components/HkSelect.scss +263 -12
- package/src/components/HkSelect.sheetdock.test.ts +30 -0
- package/src/components/HkSelect.test.tsx +88 -0
- package/src/components/HkSelect.tsx +59 -74
- package/src/components/HkSelectPanel.test.tsx +666 -0
- package/src/components/HkSelectPanel.tsx +595 -0
- package/src/components/HkSelectSheet.test.tsx +132 -0
- package/src/components/HkSelectionGrid.scss +1 -1
- package/src/components/HkSelectionGrid.tsx +2 -1
- package/src/components/HkSelectionWaterfall.scss +1 -1
- package/src/components/HkShareBar.scss +40 -0
- package/src/components/HkShareBar.tsx +50 -0
- package/src/components/HkSidebar.scss +15 -10
- package/src/components/HkSidebar.tsx +26 -2
- package/src/components/HkSignInCard.test.ts +154 -0
- package/src/components/HkSignInCard.tsx +155 -0
- package/src/components/HkSlider.scss +99 -0
- package/src/components/HkSlider.test.tsx +155 -0
- package/src/components/HkSlider.tsx +173 -0
- package/src/components/HkSpinner.scss +36 -1
- package/src/components/HkSpinner.test.tsx +63 -0
- package/src/components/HkSpinner.tsx +16 -1
- package/src/components/HkSplash.scss +1 -1
- package/src/components/HkSplash.tsx +3 -1
- package/src/components/HkStatCard.scss +73 -0
- package/src/components/HkStatCard.tsx +46 -0
- package/src/components/HkStatusBar.test.tsx +178 -0
- package/src/components/HkStatusBar.tsx +379 -0
- package/src/components/HkStatusPill.scss +73 -0
- package/src/components/HkStatusPill.tsx +41 -0
- package/src/components/HkStepFlow.scss +75 -0
- package/src/components/HkStepFlow.test.tsx +246 -0
- package/src/components/HkStepFlow.tsx +115 -0
- package/src/components/HkSwitch.scss +5 -17
- package/src/components/HkSwitch.tsx +6 -3
- package/src/components/HkTable.scss +20 -1
- package/src/components/HkTable.tsx +28 -3
- package/src/components/HkTabs.scss +222 -48
- package/src/components/HkTabs.test.ts +517 -0
- package/src/components/HkTabs.tsx +567 -56
- package/src/components/HkTag.tsx +2 -1
- package/src/components/HkTextarea.tsx +5 -0
- package/src/components/HkThemeToggle.scss +232 -0
- package/src/components/HkThemeToggle.test.ts +231 -0
- package/src/components/HkThemeToggle.tsx +289 -0
- package/src/components/HkTimeline.scss +134 -1
- package/src/components/HkTimeline.test.tsx +319 -0
- package/src/components/HkTimeline.tsx +303 -54
- package/src/components/HkToast.scss +34 -5
- package/src/components/HkToast.test.tsx +109 -0
- package/src/components/HkToast.tsx +57 -8
- package/src/components/HkTokenUsageBadge.scss +53 -0
- package/src/components/HkTokenUsageBadge.test.tsx +59 -0
- package/src/components/HkTokenUsageBadge.tsx +54 -0
- package/src/components/HkTokenUsagePanel.scss +95 -0
- package/src/components/HkTokenUsagePanel.tsx +140 -0
- package/src/components/HkToolBlock.scss +428 -0
- package/src/components/HkToolBlock.tsx +639 -0
- package/src/components/HkTooltip.scss +14 -2
- package/src/components/HkTooltip.tsx +25 -2
- package/src/components/HkTrendChart.tsx +27 -16
- package/src/components/HkVoiceInputPopup.scss +77 -0
- package/src/components/HkVoiceInputPopup.tsx +97 -0
- package/src/components/HkZoomToolbar.scss +4 -1
- package/src/components/HkZoomToolbar.tsx +7 -4
- package/src/components/_hikari-vars.scss +6 -0
- package/src/composables/highlightLanguages.ts +109 -0
- package/src/composables/iconRegistry.ts +127 -0
- package/src/composables/messaging/browserTransport.ts +96 -0
- package/src/composables/messaging/index.ts +204 -0
- package/src/composables/messaging/messaging.test.ts +256 -0
- package/src/composables/messaging/toastTransport.ts +26 -0
- package/src/composables/messaging/types.ts +115 -0
- package/src/composables/useActionBar.ts +66 -0
- package/src/composables/useAttachments.ts +154 -0
- package/src/composables/useConnectionProbe.ts +142 -0
- package/src/composables/useDeferredTransition.ts +16 -0
- package/src/composables/useEngineHealth.ts +69 -0
- package/src/composables/useHighlight.ts +124 -0
- package/src/composables/useMeasuredHighlight.ts +150 -0
- package/src/composables/useOverlayScrollbar.test.ts +221 -0
- package/src/composables/useOverlayScrollbar.ts +296 -0
- package/src/composables/usePageTitle.ts +37 -0
- package/src/composables/useReducedMotion.ts +34 -0
- package/src/composables/useResourceListModal.tsx +147 -0
- package/src/composables/useSafeArea.ts +137 -0
- package/src/composables/useSendShortcut.ts +35 -0
- package/src/composables/useSizeMorph.test.ts +156 -0
- package/src/composables/useSizeMorph.ts +149 -0
- package/src/composables/useSurfaceTransition.test.ts +96 -0
- package/src/composables/useSurfaceTransition.ts +71 -0
- package/src/composables/useZoomPan.ts +192 -0
- package/src/demo.scss +9 -1
- package/src/demo.ts +5 -1
- package/src/i18n/locales/ar/chat.json +38 -0
- package/src/i18n/locales/ar/components.json +141 -40
- package/src/i18n/locales/ar/components.toml +59 -0
- package/src/i18n/locales/ar/platform.json +65 -0
- package/src/i18n/locales/de/chat.json +38 -0
- package/src/i18n/locales/de/components.json +139 -38
- package/src/i18n/locales/de/components.toml +59 -0
- package/src/i18n/locales/de/platform.json +65 -0
- package/src/i18n/locales/en/chat.json +38 -0
- package/src/i18n/locales/en/components.json +104 -3
- package/src/i18n/locales/en/components.toml +59 -0
- package/src/i18n/locales/en/platform.json +65 -0
- package/src/i18n/locales/es/chat.json +38 -0
- package/src/i18n/locales/es/components.json +141 -40
- package/src/i18n/locales/es/components.toml +59 -0
- package/src/i18n/locales/es/platform.json +65 -0
- package/src/i18n/locales/fr/chat.json +38 -0
- package/src/i18n/locales/fr/components.json +137 -36
- package/src/i18n/locales/fr/components.toml +59 -0
- package/src/i18n/locales/fr/platform.json +65 -0
- package/src/i18n/locales/ja/chat.json +38 -0
- package/src/i18n/locales/ja/components.json +104 -3
- package/src/i18n/locales/ja/components.toml +59 -0
- package/src/i18n/locales/ja/platform.json +65 -0
- package/src/i18n/locales/ko/chat.json +38 -0
- package/src/i18n/locales/ko/components.json +104 -3
- package/src/i18n/locales/ko/components.toml +59 -0
- package/src/i18n/locales/ko/platform.json +65 -0
- package/src/i18n/locales/pt/chat.json +38 -0
- package/src/i18n/locales/pt/components.json +141 -40
- package/src/i18n/locales/pt/components.toml +59 -0
- package/src/i18n/locales/pt/platform.json +65 -0
- package/src/i18n/locales/ru/chat.json +38 -0
- package/src/i18n/locales/ru/components.json +141 -40
- package/src/i18n/locales/ru/components.toml +59 -0
- package/src/i18n/locales/ru/platform.json +65 -0
- package/src/i18n/locales/zh-Hans/chat.json +38 -0
- package/src/i18n/locales/zh-Hans/components.json +109 -4
- package/src/i18n/locales/zh-Hans/components.toml +59 -0
- package/src/i18n/locales/zh-Hans/platform.json +65 -0
- package/src/i18n/locales/zh-Hant/chat.json +38 -0
- package/src/i18n/locales/zh-Hant/components.json +109 -4
- package/src/i18n/locales/zh-Hant/components.toml +59 -0
- package/src/i18n/locales/zh-Hant/platform.json +65 -0
- package/src/index.ts +225 -6
- package/src/plugins/bootScriptsPlugin.ts +47 -0
- package/src/plugins/buildHashPlugin.ts +67 -0
- package/src/plugins/index.ts +2 -0
- package/src/render.ts +1 -1
- package/src/runtime/animationBus.test.ts +112 -0
- package/src/runtime/animationBus.ts +33 -2
- package/src/runtime/backStack.test.ts +334 -0
- package/src/runtime/backStack.ts +377 -0
- package/src/runtime/env.ts +4 -0
- package/src/runtime/index.ts +15 -3
- package/src/runtime/intervalBus.test.ts +118 -0
- package/src/runtime/intervalBus.ts +189 -0
- package/src/runtime/mobileViewport.test.ts +132 -0
- package/src/runtime/mobileViewport.ts +157 -0
- package/src/runtime/pageLifecycle.ts +91 -0
- package/src/runtime/useBlockingToast.ts +112 -0
- package/src/runtime/useClipboard.ts +28 -0
- package/src/runtime/useConfirm.ts +15 -7
- package/src/runtime/useMediaQuery.test.ts +67 -0
- package/src/runtime/useMediaQuery.ts +89 -0
- package/src/runtime/useOverlay.test.ts +160 -0
- package/src/runtime/useOverlay.ts +59 -12
- package/src/runtime/usePopupManager.test.ts +200 -0
- package/src/runtime/usePopupManager.ts +106 -10
- package/src/runtime/useToast.test.ts +90 -0
- package/src/runtime/useToast.ts +20 -2
- package/src/styles/admin-tokens.scss +744 -0
- package/src/styles/index.scss +5 -3
- package/src/styles/theme/_glass.scss +33 -0
- package/src/styles/theme/_layout.scss +123 -0
- package/src/styles/theme/_scrollbar.scss +73 -0
- package/src/styles/theme/_tokens.scss +69 -0
- package/src/styles/theme/base.scss +617 -0
- package/src/styles/theme/foundation.scss +219 -0
- package/src/styles/theme/mixins.scss +793 -0
- package/src/styles/theme/themes.scss +25 -0
- package/src/styles/theme/variables.scss +125 -0
- package/src/theme/_field.scss +116 -0
- package/src/theme/fontContext.test.ts +179 -0
- package/src/theme/fontContext.ts +169 -0
- package/src/theme/index.ts +19 -2
- package/src/theme/presets.ts +86 -18
- package/src/theme/theme.scss +61 -1
- package/src/theme/tokenGroups.test.ts +534 -0
- package/src/theme/tokenGroups.ts +552 -0
- package/src/theme/useSolarTime.test.ts +113 -0
- package/src/theme/useSolarTime.ts +95 -20
- package/src/theme/useTheme.test.ts +72 -0
- package/src/theme/useTheme.ts +144 -9
- package/src/tokens.scss +110 -0
- package/src/utils/base64.ts +31 -0
- package/src/utils/connectivity.ts +51 -0
- package/src/utils/dom.test.ts +131 -0
- package/src/utils/dom.ts +133 -0
- package/src/utils/download.ts +22 -0
- package/src/utils/fileType.ts +127 -0
- package/src/utils/format.ts +98 -0
- package/src/utils/fuzzy.ts +77 -0
- package/src/utils/fuzzySearch.test.ts +87 -0
- package/src/utils/healthProbe.ts +64 -0
- package/src/utils/objectPath.ts +59 -0
- package/src/utils/password.ts +53 -0
- package/src/utils/pow.test.ts +45 -0
- package/src/utils/pow.ts +226 -0
- package/src/utils/powNonce.ts +95 -0
- package/src/utils/validation.test.ts +78 -0
- package/src/DemoApp.vue +0 -400
- package/src/components/HkCollapse.tsx +0 -48
- package/src/components/HkMorphingTabs.scss +0 -141
- package/src/components/HkMorphingTabs.tsx +0 -113
- package/src/composables/useBreakpoint.ts +0 -41
- package/src/composables/useClipboard.ts +0 -37
- package/src/composables/useConfirm.ts +0 -35
- package/src/composables/useToast.ts +0 -2
- package/src/runtime/useAsyncData.ts +0 -30
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { defaultDurationFor, defaultRequireInteraction, type MessagePayload, type MessageTransport } from "./types";
|
|
2
|
+
import { scheduleCronAfter } from "@celestia-island/hikari";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Browser Notifications API transport.
|
|
6
|
+
*
|
|
7
|
+
* Fires a native OS notification via `window.Notification`. Used when the page
|
|
8
|
+
* is hidden (user is in another tab/app) and the user has granted permission.
|
|
9
|
+
*
|
|
10
|
+
* Lifecycle notes:
|
|
11
|
+
* - `available()` only returns true when the API is present *and* the user
|
|
12
|
+
* has granted permission. Callers should call `requestPermission()` from
|
|
13
|
+
* a user-gesture-triggered flow (e.g. a settings toggle) before relying
|
|
14
|
+
* on this transport — browsers reject permission prompts that aren't
|
|
15
|
+
* initiated by a user gesture.
|
|
16
|
+
* - For error/warning severities we mirror the toast policy: the
|
|
17
|
+
* notification is auto-closed after the same 30s window so the two
|
|
18
|
+
* surfaces feel consistent. Errors additionally request sticky
|
|
19
|
+
* interaction (`requireInteraction: true`) so they survive OS auto-hide.
|
|
20
|
+
* - `onclick` focuses the originating window so the user lands back on the
|
|
21
|
+
* app — this is the bridge back into the SPA from the OS surface.
|
|
22
|
+
*/
|
|
23
|
+
export const browserTransport: MessageTransport = {
|
|
24
|
+
name: "browser",
|
|
25
|
+
available: () =>
|
|
26
|
+
typeof Notification !== "undefined" && Notification.permission === "granted",
|
|
27
|
+
send(payload: MessagePayload) {
|
|
28
|
+
if (typeof Notification === "undefined") return;
|
|
29
|
+
if (Notification.permission !== "granted") return;
|
|
30
|
+
|
|
31
|
+
const severity = payload.severity;
|
|
32
|
+
const requireInteraction =
|
|
33
|
+
payload.requireInteraction ?? defaultRequireInteraction(severity);
|
|
34
|
+
|
|
35
|
+
const headlineSource = payload.title || payload.message;
|
|
36
|
+
const title = headlineSource.split("\n")[0]?.slice(0, 200) || headlineSource;
|
|
37
|
+
const body =
|
|
38
|
+
payload.body ||
|
|
39
|
+
(payload.message.length > title.length ? payload.message : undefined);
|
|
40
|
+
|
|
41
|
+
let notification: Notification;
|
|
42
|
+
try {
|
|
43
|
+
notification = new Notification(title, {
|
|
44
|
+
body,
|
|
45
|
+
tag: payload.tag,
|
|
46
|
+
requireInteraction,
|
|
47
|
+
data: payload.data,
|
|
48
|
+
});
|
|
49
|
+
} catch {
|
|
50
|
+
// Some browsers throw if the document isn't focused or service worker
|
|
51
|
+
// is unavailable. Swallow — the toast transport still covers the user.
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
notification.onclick = () => {
|
|
56
|
+
try {
|
|
57
|
+
window.focus();
|
|
58
|
+
} catch {
|
|
59
|
+
// Cross-origin or no-op — ignore.
|
|
60
|
+
}
|
|
61
|
+
notification.close();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Mirror the toast auto-close window for transient severities. Errors
|
|
65
|
+
// default to sticky (requireInteraction) and are skipped here.
|
|
66
|
+
if (!requireInteraction) {
|
|
67
|
+
const ttl = payload.duration ?? defaultDurationFor(severity);
|
|
68
|
+
if (ttl > 0) {
|
|
69
|
+
scheduleCronAfter(() => {
|
|
70
|
+
try {
|
|
71
|
+
notification.close();
|
|
72
|
+
} catch {
|
|
73
|
+
// Already closed — ignore.
|
|
74
|
+
}
|
|
75
|
+
}, ttl);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Request browser notification permission. Must be invoked from a user
|
|
83
|
+
* gesture (click handler) — browsers otherwise silently deny.
|
|
84
|
+
*
|
|
85
|
+
* Returns the resulting permission state. The shared permission ref inside
|
|
86
|
+
* {@link useMessaging} is updated as a side effect.
|
|
87
|
+
*/
|
|
88
|
+
export async function requestBrowserPermission(): Promise<NotificationPermission> {
|
|
89
|
+
if (typeof Notification === "undefined") return "denied";
|
|
90
|
+
if (Notification.permission !== "default") return Notification.permission;
|
|
91
|
+
try {
|
|
92
|
+
return await Notification.requestPermission();
|
|
93
|
+
} catch {
|
|
94
|
+
return Notification.permission;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { computed, readonly, ref } from "vue";
|
|
2
|
+
|
|
3
|
+
import { browserTransport, requestBrowserPermission } from "./browserTransport";
|
|
4
|
+
import { toastTransport } from "./toastTransport";
|
|
5
|
+
import { defaultCopyable, defaultDurationFor, type MessagePayload, type MessageSeverity, type MessageTransport, type NotifyOptions, type TransportName } from "./types";
|
|
6
|
+
|
|
7
|
+
// Re-export the messaging types from the public entry point so consumers
|
|
8
|
+
// can `import type { MessageSeverity } from "./messaging"`.
|
|
9
|
+
export type {
|
|
10
|
+
MessagePayload,
|
|
11
|
+
MessageSeverity,
|
|
12
|
+
MessageTransport,
|
|
13
|
+
NotifyOptions,
|
|
14
|
+
TransportName,
|
|
15
|
+
} from "./types";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Registry of all known transports, in priority order.
|
|
19
|
+
*
|
|
20
|
+
* Built-ins (`toast`, `browser`) are pre-registered. Native client bridges
|
|
21
|
+
* (Tauri desktop, mobile native, iOS APNs long-poll) push themselves here
|
|
22
|
+
* via {@link registerTransport} during boot — typically by reading
|
|
23
|
+
* `window.__nativeBridge` and wrapping it in a {@link MessageTransport}.
|
|
24
|
+
*/
|
|
25
|
+
const transports: MessageTransport[] = [toastTransport, browserTransport];
|
|
26
|
+
|
|
27
|
+
/** Page visibility — drives the toast-vs-browser routing decision. */
|
|
28
|
+
const _pageHidden = ref(typeof document !== "undefined" && document.hidden);
|
|
29
|
+
if (typeof document !== "undefined") {
|
|
30
|
+
const onVis = () => { _pageHidden.value = document.hidden; };
|
|
31
|
+
document.addEventListener("visibilitychange", onVis);
|
|
32
|
+
}
|
|
33
|
+
const pageHidden = computed(() => _pageHidden.value);
|
|
34
|
+
|
|
35
|
+
/** Live browser notification permission. */
|
|
36
|
+
const browserPermission = ref<NotificationPermission>(
|
|
37
|
+
typeof Notification !== "undefined" ? Notification.permission : "denied",
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
function refreshPermission() {
|
|
41
|
+
if (typeof Notification !== "undefined") {
|
|
42
|
+
browserPermission.value = Notification.permission;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let nextId = 0;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Register a new transport (e.g. a native bridge). Idempotent — re-registering
|
|
50
|
+
* the same name replaces the prior instance and disposes it if supported.
|
|
51
|
+
*
|
|
52
|
+
* Returns an unregister handle so ephemeral bridges (e.g. a reconnecting
|
|
53
|
+
* WebSocket-based push subscription) can tear themselves down cleanly.
|
|
54
|
+
*/
|
|
55
|
+
export function registerTransport(transport: MessageTransport): () => void {
|
|
56
|
+
const idx = transports.findIndex((t) => t.name === transport.name);
|
|
57
|
+
if (idx !== -1) {
|
|
58
|
+
const [prev] = transports.splice(idx, 1);
|
|
59
|
+
prev.dispose?.();
|
|
60
|
+
}
|
|
61
|
+
transports.push(transport);
|
|
62
|
+
return () => {
|
|
63
|
+
const i = transports.indexOf(transport);
|
|
64
|
+
if (i !== -1) {
|
|
65
|
+
const [removed] = transports.splice(i, 1);
|
|
66
|
+
removed.dispose?.();
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Register a native bridge discovered on `window.__nativeBridge`. Safe to call
|
|
73
|
+
* unconditionally — no-ops if no bridge is present. Future native client
|
|
74
|
+
* integrations (Tauri `notification` plugin, HarmonyOS `NotificationService`,
|
|
75
|
+
* iOS long-poll subscriber) should hook in here.
|
|
76
|
+
*/
|
|
77
|
+
export function registerNativeBridge(): () => void {
|
|
78
|
+
const bridge =
|
|
79
|
+
typeof window !== "undefined"
|
|
80
|
+
? (window as unknown as { __nativeBridge?: MessageTransport }).__nativeBridge
|
|
81
|
+
: undefined;
|
|
82
|
+
if (!bridge || typeof bridge.send !== "function") return () => {};
|
|
83
|
+
if (bridge.name !== "native") {
|
|
84
|
+
// Coerce the name so routing allow-lists resolve consistently.
|
|
85
|
+
(bridge as MessageTransport).name = "native";
|
|
86
|
+
}
|
|
87
|
+
return registerTransport(bridge);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Decide which transports a given payload should fire on. Returns the
|
|
92
|
+
* filtered list (in registry order).
|
|
93
|
+
*
|
|
94
|
+
* Routing policy:
|
|
95
|
+
* - `toast`: ALWAYS (it's the in-app surface; cheap and visible when the
|
|
96
|
+
* user returns).
|
|
97
|
+
* - `browser`: ONLY when the page is hidden AND permission is granted AND
|
|
98
|
+
* the severity is actionable off-page (error/warning/success). Pure info
|
|
99
|
+
* pings don't earn an OS-level interruption.
|
|
100
|
+
* - `native`: ALWAYS when available (the native shell decides how to render
|
|
101
|
+
* based on its own focus state — we don't second-guess it from the web
|
|
102
|
+
* layer).
|
|
103
|
+
*/
|
|
104
|
+
function routePayload(
|
|
105
|
+
payload: MessagePayload,
|
|
106
|
+
allowList: TransportName[] | "all",
|
|
107
|
+
): MessageTransport[] {
|
|
108
|
+
const allow = allowList === "all" ? null : new Set(allowList);
|
|
109
|
+
const out: MessageTransport[] = [];
|
|
110
|
+
for (const t of transports) {
|
|
111
|
+
if (allow && !allow.has(t.name)) continue;
|
|
112
|
+
if (!t.available()) continue;
|
|
113
|
+
if (t.name === "browser") {
|
|
114
|
+
const actionable =
|
|
115
|
+
payload.severity === "error" ||
|
|
116
|
+
payload.severity === "warning" ||
|
|
117
|
+
payload.severity === "success";
|
|
118
|
+
if (!payload.pageHidden || !actionable) continue;
|
|
119
|
+
}
|
|
120
|
+
out.push(t);
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Unified messaging context. Drop-in replacement for {@link useToast} at call
|
|
127
|
+
* sites that want cross-transport routing — the helper signatures
|
|
128
|
+
* (`error`/`warning`/`success`/`info`) mirror the toast API to make migration
|
|
129
|
+
* mechanical.
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* const msg = useMessaging();
|
|
133
|
+
* msg.error(t("auth.login.failed")); // top-right toast + (if hidden) browser push
|
|
134
|
+
* msg.warning("Disk almost full", { tag: "disk" });
|
|
135
|
+
* msg.notify("Saved", { severity: "success", transports: ["toast"] }); // in-app only
|
|
136
|
+
*/
|
|
137
|
+
export function useMessaging() {
|
|
138
|
+
function notify(message: string, options: NotifyOptions = {}): number {
|
|
139
|
+
const severity: MessageSeverity = options.severity || "info";
|
|
140
|
+
const payload: MessagePayload = {
|
|
141
|
+
id: ++nextId,
|
|
142
|
+
severity,
|
|
143
|
+
message,
|
|
144
|
+
title: options.title,
|
|
145
|
+
body: options.body,
|
|
146
|
+
tag: options.tag,
|
|
147
|
+
requireInteraction: options.requireInteraction,
|
|
148
|
+
copyable: options.copyable ?? defaultCopyable(severity),
|
|
149
|
+
duration: options.duration ?? defaultDurationFor(severity),
|
|
150
|
+
data: options.data,
|
|
151
|
+
timestamp: Date.now(),
|
|
152
|
+
pageHidden: pageHidden.value,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const routed = routePayload(payload, options.transports ?? "all");
|
|
156
|
+
for (const t of routed) {
|
|
157
|
+
try {
|
|
158
|
+
t.send(payload);
|
|
159
|
+
} catch {
|
|
160
|
+
// Transport failure must never propagate — the next transport still
|
|
161
|
+
// gets a chance.
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return payload.id;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function error(message: string, options: Omit<NotifyOptions, "severity"> = {}) {
|
|
168
|
+
return notify(message, { ...options, severity: "error" });
|
|
169
|
+
}
|
|
170
|
+
function warning(message: string, options: Omit<NotifyOptions, "severity"> = {}) {
|
|
171
|
+
return notify(message, { ...options, severity: "warning" });
|
|
172
|
+
}
|
|
173
|
+
function success(message: string, options: Omit<NotifyOptions, "severity"> = {}) {
|
|
174
|
+
return notify(message, { ...options, severity: "success" });
|
|
175
|
+
}
|
|
176
|
+
function info(message: string, options: Omit<NotifyOptions, "severity"> = {}) {
|
|
177
|
+
return notify(message, { ...options, severity: "info" });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Request browser notification permission. Must be called from a user
|
|
182
|
+
* gesture handler (button click) — otherwise browsers silently deny.
|
|
183
|
+
*/
|
|
184
|
+
async function ensureBrowserPermission(): Promise<NotificationPermission> {
|
|
185
|
+
const result = await requestBrowserPermission();
|
|
186
|
+
refreshPermission();
|
|
187
|
+
return result;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
notify,
|
|
192
|
+
error,
|
|
193
|
+
warning,
|
|
194
|
+
success,
|
|
195
|
+
info,
|
|
196
|
+
ensureBrowserPermission,
|
|
197
|
+
/** Whether the browser tab is currently hidden. Reactive. */
|
|
198
|
+
pageHidden: readonly(pageHidden),
|
|
199
|
+
/** Live browser notification permission. Reactive. */
|
|
200
|
+
browserPermission: readonly(browserPermission),
|
|
201
|
+
/** Register a transport at runtime (e.g. native bridge). */
|
|
202
|
+
registerTransport,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { useToast } from "@celestia-island/hikari";
|
|
4
|
+
import { registerNativeBridge, registerTransport, useMessaging } from "./index";
|
|
5
|
+
import type { MessagePayload, MessageTransport } from "./types";
|
|
6
|
+
|
|
7
|
+
function flushToasts() {
|
|
8
|
+
const { toasts, remove } = useToast();
|
|
9
|
+
while (toasts.length > 0) remove(toasts[0].id);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function makeStubTransport(name: MessageTransport["name"]): MessageTransport & {
|
|
13
|
+
calls: MessagePayload[];
|
|
14
|
+
} {
|
|
15
|
+
const calls: MessagePayload[] = [];
|
|
16
|
+
return {
|
|
17
|
+
name,
|
|
18
|
+
available: () => true,
|
|
19
|
+
send: (p) => calls.push(p),
|
|
20
|
+
calls,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("useMessaging", () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
flushToasts();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.restoreAllMocks();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("severity helpers", () => {
|
|
34
|
+
it("error() routes through the toast transport by default", () => {
|
|
35
|
+
const { toasts } = useToast();
|
|
36
|
+
const { error } = useMessaging();
|
|
37
|
+
error("boom");
|
|
38
|
+
expect(toasts).toHaveLength(1);
|
|
39
|
+
expect(toasts[0].type).toBe("error");
|
|
40
|
+
expect(toasts[0].messages[0].text).toBe("boom");
|
|
41
|
+
expect(toasts[0].duration).toBe(30_000);
|
|
42
|
+
expect(toasts[0].copyable).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("warning() shares the 30s long-lived duration", () => {
|
|
46
|
+
const { toasts } = useToast();
|
|
47
|
+
const { warning } = useMessaging();
|
|
48
|
+
warning("careful");
|
|
49
|
+
expect(toasts[0].type).toBe("warning");
|
|
50
|
+
expect(toasts[0].duration).toBe(30_000);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("success/info keep the short duration", () => {
|
|
54
|
+
const { toasts } = useToast();
|
|
55
|
+
const { success, info } = useMessaging();
|
|
56
|
+
success("yep");
|
|
57
|
+
expect(toasts[0].duration).toBe(3_000);
|
|
58
|
+
info("hi");
|
|
59
|
+
expect(toasts[1].duration).toBe(3_000);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("notify() accepts an explicit duration override", () => {
|
|
63
|
+
const { toasts } = useToast();
|
|
64
|
+
const { notify } = useMessaging();
|
|
65
|
+
notify("manual", { severity: "error", duration: 1_000 });
|
|
66
|
+
expect(toasts[0].duration).toBe(1_000);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("transport allow-list", () => {
|
|
71
|
+
it("respects transports: ['toast'] to suppress other channels", () => {
|
|
72
|
+
const stub = makeStubTransport("native");
|
|
73
|
+
const unregister = registerTransport(stub);
|
|
74
|
+
const { notify } = useMessaging();
|
|
75
|
+
notify("hi", { severity: "info", transports: ["toast"] });
|
|
76
|
+
expect(stub.calls).toHaveLength(0);
|
|
77
|
+
unregister();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("still routes to whitelisted transports", () => {
|
|
81
|
+
const stub = makeStubTransport("native");
|
|
82
|
+
const unregister = registerTransport(stub);
|
|
83
|
+
const { notify } = useMessaging();
|
|
84
|
+
notify("hi", { severity: "info", transports: ["toast", "native"] });
|
|
85
|
+
expect(stub.calls).toHaveLength(1);
|
|
86
|
+
unregister();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("registerTransport", () => {
|
|
91
|
+
it("is idempotent on name and replaces the prior transport", () => {
|
|
92
|
+
const a = makeStubTransport("native");
|
|
93
|
+
const b = makeStubTransport("native");
|
|
94
|
+
const unA = registerTransport(a);
|
|
95
|
+
const unB = registerTransport(b);
|
|
96
|
+
const { notify } = useMessaging();
|
|
97
|
+
notify("x", { severity: "info", transports: ["native"] });
|
|
98
|
+
expect(a.calls).toHaveLength(0);
|
|
99
|
+
expect(b.calls).toHaveLength(1);
|
|
100
|
+
unB();
|
|
101
|
+
unA();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("unregister handle removes the transport", () => {
|
|
105
|
+
const stub = makeStubTransport("native");
|
|
106
|
+
const unregister = registerTransport(stub);
|
|
107
|
+
const { notify } = useMessaging();
|
|
108
|
+
notify("x", { severity: "info", transports: ["native"] });
|
|
109
|
+
expect(stub.calls).toHaveLength(1);
|
|
110
|
+
stub.calls.length = 0;
|
|
111
|
+
unregister();
|
|
112
|
+
notify("y", { severity: "info", transports: ["native"] });
|
|
113
|
+
expect(stub.calls).toHaveLength(0);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("skips transports that report unavailable", () => {
|
|
117
|
+
const unavailableStub: MessageTransport = {
|
|
118
|
+
name: "native",
|
|
119
|
+
available: () => false,
|
|
120
|
+
send: vi.fn(),
|
|
121
|
+
};
|
|
122
|
+
const unregister = registerTransport(unavailableStub);
|
|
123
|
+
const { notify } = useMessaging();
|
|
124
|
+
notify("x", { severity: "info", transports: ["native"] });
|
|
125
|
+
expect(unavailableStub.send).not.toHaveBeenCalled();
|
|
126
|
+
unregister();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("isolates a transport failure — other transports still fire", () => {
|
|
130
|
+
const exploding: MessageTransport = {
|
|
131
|
+
name: "native",
|
|
132
|
+
available: () => true,
|
|
133
|
+
send: () => {
|
|
134
|
+
throw new Error("boom");
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
const un = registerTransport(exploding);
|
|
138
|
+
const { toasts } = useToast();
|
|
139
|
+
const { notify } = useMessaging();
|
|
140
|
+
expect(() => notify("x", { severity: "info" })).not.toThrow();
|
|
141
|
+
// Toast transport should still have fired despite the native throw.
|
|
142
|
+
expect(toasts.length).toBeGreaterThan(0);
|
|
143
|
+
un();
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("registerNativeBridge", () => {
|
|
148
|
+
it("no-ops when window.__nativeBridge is absent", () => {
|
|
149
|
+
const w = window as unknown as { __nativeBridge?: unknown };
|
|
150
|
+
const original = w.__nativeBridge;
|
|
151
|
+
delete w.__nativeBridge;
|
|
152
|
+
try {
|
|
153
|
+
const unregister = registerNativeBridge();
|
|
154
|
+
expect(typeof unregister).toBe("function");
|
|
155
|
+
// With no native bridge registered, a message restricted to the
|
|
156
|
+
// "native" allow-list must not be delivered to ANY transport —
|
|
157
|
+
// including the always-present toast surface.
|
|
158
|
+
const { toasts } = useToast();
|
|
159
|
+
const { notify } = useMessaging();
|
|
160
|
+
notify("native hi", { severity: "info", transports: ["native"] });
|
|
161
|
+
expect(toasts).toHaveLength(0);
|
|
162
|
+
unregister();
|
|
163
|
+
} finally {
|
|
164
|
+
if (original !== undefined) w.__nativeBridge = original;
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("registers a discovered bridge under the 'native' name", () => {
|
|
169
|
+
const calls: MessagePayload[] = [];
|
|
170
|
+
const bridge = {
|
|
171
|
+
name: "custom" as const,
|
|
172
|
+
available: () => true,
|
|
173
|
+
send: (p: MessagePayload) => calls.push(p),
|
|
174
|
+
};
|
|
175
|
+
const w = window as unknown as { __nativeBridge?: unknown };
|
|
176
|
+
const original = w.__nativeBridge;
|
|
177
|
+
w.__nativeBridge = bridge;
|
|
178
|
+
try {
|
|
179
|
+
const unregister = registerNativeBridge();
|
|
180
|
+
const { notify } = useMessaging();
|
|
181
|
+
notify("native hi", { severity: "warning", transports: ["native"] });
|
|
182
|
+
expect(bridge.name).toBe("native");
|
|
183
|
+
expect(calls).toHaveLength(1);
|
|
184
|
+
expect(calls[0].severity).toBe("warning");
|
|
185
|
+
unregister();
|
|
186
|
+
} finally {
|
|
187
|
+
if (original !== undefined) w.__nativeBridge = original;
|
|
188
|
+
else delete w.__nativeBridge;
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("browser transport gating", () => {
|
|
194
|
+
it("does not throw when Notification is unavailable (SSR-like)", () => {
|
|
195
|
+
const w = window as unknown as { Notification?: unknown };
|
|
196
|
+
const original = w.Notification;
|
|
197
|
+
delete w.Notification;
|
|
198
|
+
try {
|
|
199
|
+
const { toasts } = useToast();
|
|
200
|
+
const { error } = useMessaging();
|
|
201
|
+
expect(() => error("no Notification API")).not.toThrow();
|
|
202
|
+
// Toast still covers the user.
|
|
203
|
+
expect(toasts).toHaveLength(1);
|
|
204
|
+
} finally {
|
|
205
|
+
if (original !== undefined) w.Notification = original;
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("defaults requireInteraction to true only for errors", async () => {
|
|
210
|
+
const created: Array<{ title: string; requireInteraction: boolean }> = [];
|
|
211
|
+
const w = window as unknown as {
|
|
212
|
+
Notification?: typeof Notification;
|
|
213
|
+
};
|
|
214
|
+
const Original = w.Notification;
|
|
215
|
+
class FakeNotification {
|
|
216
|
+
static permission: NotificationPermission = "granted";
|
|
217
|
+
static requestPermission = vi.fn().mockResolvedValue("granted");
|
|
218
|
+
title: string;
|
|
219
|
+
options?: NotificationOptions;
|
|
220
|
+
onclick: (() => void) | null = null;
|
|
221
|
+
close() {}
|
|
222
|
+
constructor(title: string, options?: NotificationOptions) {
|
|
223
|
+
this.title = title;
|
|
224
|
+
this.options = options;
|
|
225
|
+
created.push({ title, requireInteraction: !!options?.requireInteraction });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
w.Notification = FakeNotification as unknown as typeof Notification;
|
|
229
|
+
try {
|
|
230
|
+
// Exercise the browser transport's `send()` directly to assert the
|
|
231
|
+
// severity-driven requireInteraction default (the public routing
|
|
232
|
+
// layer is covered elsewhere).
|
|
233
|
+
const { browserTransport } = await import("./browserTransport");
|
|
234
|
+
browserTransport.send({
|
|
235
|
+
id: 1,
|
|
236
|
+
severity: "error",
|
|
237
|
+
message: "boom",
|
|
238
|
+
timestamp: Date.now(),
|
|
239
|
+
pageHidden: true,
|
|
240
|
+
});
|
|
241
|
+
browserTransport.send({
|
|
242
|
+
id: 2,
|
|
243
|
+
severity: "warning",
|
|
244
|
+
message: "careful",
|
|
245
|
+
timestamp: Date.now(),
|
|
246
|
+
pageHidden: true,
|
|
247
|
+
});
|
|
248
|
+
expect(created).toHaveLength(2);
|
|
249
|
+
expect(created[0].requireInteraction).toBe(true); // error → sticky
|
|
250
|
+
expect(created[1].requireInteraction).toBe(false); // warning → transient
|
|
251
|
+
} finally {
|
|
252
|
+
if (Original !== undefined) w.Notification = Original;
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { useToast } from "@celestia-island/hikari";
|
|
2
|
+
import type { MessagePayload, MessageTransport } from "./types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* In-app toast transport. Always available while the SPA is mounted — the
|
|
6
|
+
* toast container lives in {@link AppShell} and is teleport-bound to
|
|
7
|
+
* `document.body`, so it renders even when individual route views are
|
|
8
|
+
* unmounted.
|
|
9
|
+
*
|
|
10
|
+
* The toast is the canonical surface for user feedback: even when the page
|
|
11
|
+
* is hidden and a browser notification fires, the same payload is also pushed
|
|
12
|
+
* here so the user sees it the moment they return (within the 30s
|
|
13
|
+
* warning/error window).
|
|
14
|
+
*/
|
|
15
|
+
export const toastTransport: MessageTransport = {
|
|
16
|
+
name: "toast",
|
|
17
|
+
available: () => true,
|
|
18
|
+
send(payload: MessagePayload) {
|
|
19
|
+
const toast = useToast();
|
|
20
|
+
toast.show(payload.message, {
|
|
21
|
+
type: payload.severity,
|
|
22
|
+
duration: payload.duration,
|
|
23
|
+
copyable: payload.copyable,
|
|
24
|
+
});
|
|
25
|
+
},
|
|
26
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified messaging context — types & contracts.
|
|
3
|
+
*
|
|
4
|
+
* The messaging context routes user-facing notifications through one or more
|
|
5
|
+
* {@link MessageTransport} adapters. The routing decision is driven by the
|
|
6
|
+
* page-visibility state and the per-transport availability (e.g. browser
|
|
7
|
+
* notification permission).
|
|
8
|
+
*
|
|
9
|
+
* Built-in transports:
|
|
10
|
+
* - {@link "toast"} — always available; renders via {@link useToast} in the
|
|
11
|
+
* top-right corner.
|
|
12
|
+
* - {@link "browser"} — the Web Notifications API; used when the page is
|
|
13
|
+
* hidden and the user has granted permission.
|
|
14
|
+
*
|
|
15
|
+
* Future transports (desktop/mobile/iOS long-poll) will be registered via
|
|
16
|
+
* {@link registerTransport} and follow the same {@link MessageTransport}
|
|
17
|
+
* contract. Native bridges are expected to expose themselves on
|
|
18
|
+
* `window.__nativeBridge` and call {@link registerTransport} at boot — the
|
|
19
|
+
* messaging layer is transport-agnostic.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** User-facing severity. Mirrors {@link ToastType} so toasts stay the source of truth. */
|
|
23
|
+
export type MessageSeverity = "error" | "warning" | "success" | "info" | "loading";
|
|
24
|
+
|
|
25
|
+
/** Transport identifiers used by the routing layer. */
|
|
26
|
+
export type TransportName = "toast" | "browser" | "native";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Options accepted by {@link useMessaging}'s helpers.
|
|
30
|
+
*
|
|
31
|
+
* Most fields are transport-agnostic; transports pick the ones they understand
|
|
32
|
+
* (e.g. the browser transport uses {@link title} as the notification headline
|
|
33
|
+
* while the toast transport only renders {@link message}).
|
|
34
|
+
*/
|
|
35
|
+
export interface NotifyOptions {
|
|
36
|
+
severity?: MessageSeverity;
|
|
37
|
+
/** Short headline. Used by browser/native transports; toast falls back to {@link message}. */
|
|
38
|
+
title?: string;
|
|
39
|
+
/** Long-form body. Used by browser/native transports when set. */
|
|
40
|
+
body?: string;
|
|
41
|
+
/** Toast auto-close ms. `0` keeps the toast sticky. Defaults to severity-based duration. */
|
|
42
|
+
duration?: number;
|
|
43
|
+
/** Toast: show the copy button. Defaults to `true` for error/warning. */
|
|
44
|
+
copyable?: boolean;
|
|
45
|
+
/** Browser: dedup tag — same-tag notifications replace each other. */
|
|
46
|
+
tag?: string;
|
|
47
|
+
/** Browser: stay open until user dismisses. Defaults to severity-driven. */
|
|
48
|
+
requireInteraction?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Restrict routing to a subset of transports. `"all"` (default) lets the
|
|
51
|
+
* visibility/permission gate decide. Useful for low-priority pings that
|
|
52
|
+
* should never interrupt the user off-page (e.g. `transports: ["toast"]`).
|
|
53
|
+
*/
|
|
54
|
+
transports?: TransportName[] | "all";
|
|
55
|
+
/**
|
|
56
|
+
* Opaque payload reserved for future native transports (desktop IPC, mobile
|
|
57
|
+
* push subscription, iOS APNs long-poll). Passed through untouched.
|
|
58
|
+
*/
|
|
59
|
+
data?: Record<string, unknown>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Fully-resolved message handed to each transport. */
|
|
63
|
+
export interface MessagePayload {
|
|
64
|
+
id: number;
|
|
65
|
+
severity: MessageSeverity;
|
|
66
|
+
/** Primary text — always populated. */
|
|
67
|
+
message: string;
|
|
68
|
+
title?: string;
|
|
69
|
+
body?: string;
|
|
70
|
+
tag?: string;
|
|
71
|
+
requireInteraction?: boolean;
|
|
72
|
+
copyable?: boolean;
|
|
73
|
+
duration?: number;
|
|
74
|
+
data?: Record<string, unknown>;
|
|
75
|
+
/** Epoch ms — useful for native transports that queue/batch. */
|
|
76
|
+
timestamp: number;
|
|
77
|
+
/** Whether the page was hidden when this message was dispatched. */
|
|
78
|
+
pageHidden: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Contract every push channel must implement.
|
|
83
|
+
*
|
|
84
|
+
* Transports are stateless from the registry's perspective: they receive a
|
|
85
|
+
* resolved payload and decide for themselves how to render it. Availability
|
|
86
|
+
* (e.g. permission, native bridge presence) is checked before each send so
|
|
87
|
+
* transports can degrade gracefully without re-registration.
|
|
88
|
+
*/
|
|
89
|
+
export interface MessageTransport {
|
|
90
|
+
/** Stable identifier — used by {@link NotifyOptions.transports} allow-lists. */
|
|
91
|
+
name: TransportName;
|
|
92
|
+
/** Whether this transport can be used right now (permission, bridge present, …). */
|
|
93
|
+
available(): boolean;
|
|
94
|
+
/** Issue the message. Must not throw — wrap failures in a no-op. */
|
|
95
|
+
send(payload: MessagePayload): void;
|
|
96
|
+
/** Optional teardown for transports that own resources (event listeners, etc.). */
|
|
97
|
+
dispose?(): void;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Convenience: severity → default toast duration (mirrors {@link TOAST_DURATION}). */
|
|
101
|
+
export function defaultDurationFor(severity: MessageSeverity): number {
|
|
102
|
+
if (severity === "error" || severity === "warning") return 30_000;
|
|
103
|
+
if (severity === "loading") return 0;
|
|
104
|
+
return 3_000;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Convenience: severity → whether a browser notification should stay sticky. */
|
|
108
|
+
export function defaultRequireInteraction(severity: MessageSeverity): boolean {
|
|
109
|
+
return severity === "error";
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Convenience: severity → default copyable flag for the toast surface. */
|
|
113
|
+
export function defaultCopyable(severity: MessageSeverity): boolean {
|
|
114
|
+
return severity === "error" || severity === "warning";
|
|
115
|
+
}
|