@eifi1/ui-kit 0.51.4 → 0.51.6
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 +5 -5
- package/dist/account/account-settings-labels.js.map +1 -1
- package/dist/account/account-settings.js +11 -9
- package/dist/account/account-settings.js.map +1 -1
- package/dist/account/delete-account-setting.js +56 -65
- package/dist/account/delete-account-setting.js.map +1 -1
- package/dist/account/email-change-setting-labels.js.map +1 -1
- package/dist/account/email-change-setting.js.map +1 -1
- package/dist/account/passkeys-setting.js.map +1 -1
- package/dist/account/sessions-setting.js.map +1 -1
- package/dist/admin/admin-action-confirm.js +1 -3
- package/dist/admin/admin-action-confirm.js.map +1 -1
- package/dist/admin/admin-action-log.js.map +1 -1
- package/dist/admin/admin-area-labels.js +4 -1
- package/dist/admin/admin-area-labels.js.map +1 -1
- package/dist/admin/admin-overview.js +19 -11
- package/dist/admin/admin-overview.js.map +1 -1
- package/dist/admin/admin-sections.js.map +1 -1
- package/dist/admin/admin-tool-links.d.ts +1 -1
- package/dist/admin/admin-tool-links.js +7 -1
- package/dist/admin/admin-tool-links.js.map +1 -1
- package/dist/admin/invitations-panel.js +2 -13
- package/dist/admin/invitations-panel.js.map +1 -1
- package/dist/admin/mail-test-card.js.map +1 -1
- package/dist/admin/reviewer-scope-editor.js +4 -11
- package/dist/admin/reviewer-scope-editor.js.map +1 -1
- package/dist/admin/role-select.js.map +1 -1
- package/dist/admin/user-roster.js +1 -9
- package/dist/admin/user-roster.js.map +1 -1
- package/dist/auth/accept-invitation.js.map +1 -1
- package/dist/auth/auth-errors.js.map +1 -1
- package/dist/auth/forgot-password-form.js +23 -31
- package/dist/auth/forgot-password-form.js.map +1 -1
- package/dist/auth/register-form.js.map +1 -1
- package/dist/auth/reset-password-form.js.map +1 -1
- package/dist/auth/sign-in-form.js +13 -5
- package/dist/auth/sign-in-form.js.map +1 -1
- package/dist/auth/verify-email.js +1 -8
- package/dist/auth/verify-email.js.map +1 -1
- package/dist/billing/billing-banner.js.map +1 -1
- package/dist/billing/billing-labels.js.map +1 -1
- package/dist/billing/checkout-processing.js.map +1 -1
- package/dist/billing/plan-card.js +91 -85
- package/dist/billing/plan-card.js.map +1 -1
- package/dist/billing/plan-limit-toast.js.map +1 -1
- package/dist/billing/plan-limit.js.map +1 -1
- package/dist/billing/plan-price.js.map +1 -1
- package/dist/billing/plan-switch.js.map +1 -1
- package/dist/billing/subscription-actions.js.map +1 -1
- package/dist/bin/eifi1-pay-page.mjs +6 -1
- package/dist/components/account-chips.js.map +1 -1
- package/dist/components/alert-banner.js +1 -5
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/amount-input.js +29 -12
- package/dist/components/amount-input.js.map +1 -1
- package/dist/components/authed-image.js.map +1 -1
- package/dist/components/autocomplete.js.map +1 -1
- package/dist/components/bulk-action-bar.js +1 -4
- package/dist/components/bulk-action-bar.js.map +1 -1
- package/dist/components/button-group.js.map +1 -1
- package/dist/components/button-look.js.map +1 -1
- package/dist/components/button.js +1 -7
- package/dist/components/button.js.map +1 -1
- package/dist/components/calculator.js +5 -5
- package/dist/components/calculator.js.map +1 -1
- package/dist/components/calendar-heatmap.js +4 -10
- package/dist/components/calendar-heatmap.js.map +1 -1
- package/dist/components/card.js +1 -8
- package/dist/components/card.js.map +1 -1
- package/dist/components/chart-zoom-core.js +3 -17
- package/dist/components/chart-zoom-core.js.map +1 -1
- package/dist/components/chart.d.ts +1 -1
- package/dist/components/chart.js +4 -22
- package/dist/components/chart.js.map +1 -1
- package/dist/components/checkbox-group.js +1 -4
- package/dist/components/checkbox-group.js.map +1 -1
- package/dist/components/chip-input.js +4 -1
- package/dist/components/chip-input.js.map +1 -1
- package/dist/components/chip.js +10 -5
- package/dist/components/chip.js.map +1 -1
- package/dist/components/choice-card.js +58 -74
- package/dist/components/choice-card.js.map +1 -1
- package/dist/components/column-mapper-labels.js.map +1 -1
- package/dist/components/column-mapper.js +167 -227
- package/dist/components/column-mapper.js.map +1 -1
- package/dist/components/combobox-core.js +1 -4
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/combobox.js +1 -4
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/confirm-dialog.js.map +1 -1
- package/dist/components/copy-button.js.map +1 -1
- package/dist/components/country-select.d.ts +5 -4
- package/dist/components/country-select.js +3 -19
- package/dist/components/country-select.js.map +1 -1
- package/dist/components/currency-option-list.js +10 -8
- package/dist/components/currency-option-list.js.map +1 -1
- package/dist/components/currency-select.js +1 -6
- package/dist/components/currency-select.js.map +1 -1
- package/dist/components/danger-confirm.js.map +1 -1
- package/dist/components/data-table-cells.js.map +1 -1
- package/dist/components/data-table-column-settings.js.map +1 -1
- package/dist/components/data-table-filter-popover.js +2 -18
- package/dist/components/data-table-filter-popover.js.map +1 -1
- package/dist/components/data-table-filters.js.map +1 -1
- package/dist/components/data-table-fit.js.map +1 -1
- package/dist/components/data-table-groups.js +3 -1
- package/dist/components/data-table-groups.js.map +1 -1
- package/dist/components/data-table-head.js +44 -56
- package/dist/components/data-table-head.js.map +1 -1
- package/dist/components/data-table-mobile.js +31 -44
- package/dist/components/data-table-mobile.js.map +1 -1
- package/dist/components/data-table-pagination.js.map +1 -1
- package/dist/components/data-table-row-actions.js +19 -16
- package/dist/components/data-table-row-actions.js.map +1 -1
- package/dist/components/data-table-row.js.map +1 -1
- package/dist/components/data-table-shared.js.map +1 -1
- package/dist/components/data-table-sort.js.map +1 -1
- package/dist/components/data-table-virtual.js +4 -1
- package/dist/components/data-table-virtual.js.map +1 -1
- package/dist/components/data-table.js +30 -39
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.js +1 -6
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/date-range-picker.js.map +1 -1
- package/dist/components/description-list.js +10 -10
- package/dist/components/description-list.js.map +1 -1
- package/dist/components/dialog-frame.js +9 -2
- package/dist/components/dialog-frame.js.map +1 -1
- package/dist/components/disclosure.js +17 -27
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/dropdown.js +25 -35
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/empty-state.js.map +1 -1
- package/dist/components/entity-combobox.js.map +1 -1
- package/dist/components/error-boundary.js.map +1 -1
- package/dist/components/field-base.js +5 -5
- package/dist/components/field-base.js.map +1 -1
- package/dist/components/field-parts.d.ts +1 -1
- package/dist/components/field-parts.js +10 -14
- package/dist/components/field-parts.js.map +1 -1
- package/dist/components/field-sync.js +8 -10
- package/dist/components/field-sync.js.map +1 -1
- package/dist/components/file-button.js.map +1 -1
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/floating-panel.js +4 -1
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/focus-ring.js.map +1 -1
- package/dist/components/form-actions.js.map +1 -1
- package/dist/components/grouped-picker.js +6 -8
- package/dist/components/grouped-picker.js.map +1 -1
- package/dist/components/hover-menu.js +1 -4
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/iban-input-labels.js.map +1 -1
- package/dist/components/icon-button.js +4 -1
- package/dist/components/icon-button.js.map +1 -1
- package/dist/components/icon-picker.js +2 -7
- package/dist/components/icon-picker.js.map +1 -1
- package/dist/components/image-grid.js +41 -50
- package/dist/components/image-grid.js.map +1 -1
- package/dist/components/inline-edit-field.js.map +1 -1
- package/dist/components/inline-entity-combobox.js +2 -8
- package/dist/components/inline-entity-combobox.js.map +1 -1
- package/dist/components/input.js +14 -2
- package/dist/components/input.js.map +1 -1
- package/dist/components/jump-calendar.js +1 -7
- package/dist/components/jump-calendar.js.map +1 -1
- package/dist/components/legal-labels.js.map +1 -1
- package/dist/components/legal-page.js +14 -2
- package/dist/components/legal-page.js.map +1 -1
- package/dist/components/legal.js +12 -2
- package/dist/components/legal.js.map +1 -1
- package/dist/components/lightbox.js.map +1 -1
- package/dist/components/line-items.js +26 -36
- package/dist/components/line-items.js.map +1 -1
- package/dist/components/list.js +22 -10
- package/dist/components/list.js.map +1 -1
- package/dist/components/measured-grid.js +29 -53
- package/dist/components/measured-grid.js.map +1 -1
- package/dist/components/menu-item.js.map +1 -1
- package/dist/components/mini-calendar.js +13 -31
- package/dist/components/mini-calendar.js.map +1 -1
- package/dist/components/money-field.js.map +1 -1
- package/dist/components/month-picker.js +10 -21
- package/dist/components/month-picker.js.map +1 -1
- package/dist/components/multi-entity-combobox.js +3 -16
- package/dist/components/multi-entity-combobox.js.map +1 -1
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/nav-pills.js +10 -1
- package/dist/components/nav-pills.js.map +1 -1
- package/dist/components/number-field.js.map +1 -1
- package/dist/components/number-input.js +1 -7
- package/dist/components/number-input.js.map +1 -1
- package/dist/components/numpad-sheet.js +1 -4
- package/dist/components/numpad-sheet.js.map +1 -1
- package/dist/components/one-time-code-input.js +1 -7
- package/dist/components/one-time-code-input.js.map +1 -1
- package/dist/components/page-contents.js +13 -22
- package/dist/components/page-contents.js.map +1 -1
- package/dist/components/page-header.js +1 -11
- package/dist/components/page-header.js.map +1 -1
- package/dist/components/password-strength-labels.js.map +1 -1
- package/dist/components/password-strength.js +2 -8
- package/dist/components/password-strength.js.map +1 -1
- package/dist/components/phone-input.js +42 -57
- package/dist/components/phone-input.js.map +1 -1
- package/dist/components/picker-sheet.js.map +1 -1
- package/dist/components/pie-chart.js +1 -8
- package/dist/components/pie-chart.js.map +1 -1
- package/dist/components/popover.js.map +1 -1
- package/dist/components/progress-bar.js +16 -9
- package/dist/components/progress-bar.js.map +1 -1
- package/dist/components/qr-code.js +1 -8
- package/dist/components/qr-code.js.map +1 -1
- package/dist/components/row-actions.js +5 -1
- package/dist/components/row-actions.js.map +1 -1
- package/dist/components/search-field.js.map +1 -1
- package/dist/components/select.js +28 -38
- package/dist/components/select.js.map +1 -1
- package/dist/components/separator.js +1 -5
- package/dist/components/separator.js.map +1 -1
- package/dist/components/series-chart-axes.js +4 -1
- package/dist/components/series-chart-axes.js.map +1 -1
- package/dist/components/series-chart-budget.js.map +1 -1
- package/dist/components/series-chart-ticks.js.map +1 -1
- package/dist/components/series-chart.js +29 -45
- package/dist/components/series-chart.js.map +1 -1
- package/dist/components/settings-fields.js +21 -17
- package/dist/components/settings-fields.js.map +1 -1
- package/dist/components/share-card.js +4 -9
- package/dist/components/share-card.js.map +1 -1
- package/dist/components/sign-chip.js.map +1 -1
- package/dist/components/signature-pad.js +4 -13
- package/dist/components/signature-pad.js.map +1 -1
- package/dist/components/signed-amount.js +12 -5
- package/dist/components/signed-amount.js.map +1 -1
- package/dist/components/skeleton.js +2 -17
- package/dist/components/skeleton.js.map +1 -1
- package/dist/components/slider.js +13 -36
- package/dist/components/slider.js.map +1 -1
- package/dist/components/sparkline.js.map +1 -1
- package/dist/components/stat-tile.js +14 -10
- package/dist/components/stat-tile.js.map +1 -1
- package/dist/components/swatch-picker.js +14 -4
- package/dist/components/swatch-picker.js.map +1 -1
- package/dist/components/swipeable-row.d.ts +1 -1
- package/dist/components/swipeable-row.js +1 -8
- package/dist/components/swipeable-row.js.map +1 -1
- package/dist/components/table-skeleton.js +4 -1
- package/dist/components/table-skeleton.js.map +1 -1
- package/dist/components/table.js +12 -9
- package/dist/components/table.js.map +1 -1
- package/dist/components/tabs.js +5 -24
- package/dist/components/tabs.js.map +1 -1
- package/dist/components/text-link.js.map +1 -1
- package/dist/components/text.js.map +1 -1
- package/dist/components/tile-radio.js +1 -7
- package/dist/components/tile-radio.js.map +1 -1
- package/dist/components/time-input.js.map +1 -1
- package/dist/components/toast.js.map +1 -1
- package/dist/components/toggle-group.js +6 -1
- package/dist/components/toggle-group.js.map +1 -1
- package/dist/components/toggle-legend.js +2 -11
- package/dist/components/toggle-legend.js.map +1 -1
- package/dist/components/tooltip.js +2 -22
- package/dist/components/tooltip.js.map +1 -1
- package/dist/components/translation-review-editor.js +1 -4
- package/dist/components/translation-review-editor.js.map +1 -1
- package/dist/components/translation-review-parts.d.ts +1 -1
- package/dist/components/translation-review-parts.js +7 -1
- package/dist/components/translation-review-parts.js.map +1 -1
- package/dist/components/translation-review.js +18 -7
- package/dist/components/translation-review.js.map +1 -1
- package/dist/components/tree-view.js +14 -10
- package/dist/components/tree-view.js.map +1 -1
- package/dist/components/treemap.d.ts +24 -2
- package/dist/components/treemap.js +28 -4
- package/dist/components/treemap.js.map +1 -1
- package/dist/components/trigger-aria.js.map +1 -1
- package/dist/components/use-table-state.js +14 -11
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/components/user-avatar.js +3 -26
- package/dist/components/user-avatar.js.map +1 -1
- package/dist/demo/demo-start.js +8 -4
- package/dist/demo/demo-start.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-attachment-labels.js.map +1 -1
- package/dist/feedback/feedback-attachment.js +1 -8
- package/dist/feedback/feedback-attachment.js.map +1 -1
- package/dist/feedback/feedback-capture.d.ts +1 -1
- package/dist/feedback/feedback-capture.js +3 -1
- package/dist/feedback/feedback-capture.js.map +1 -1
- package/dist/feedback/feedback-comment-thread.js +27 -13
- package/dist/feedback/feedback-comment-thread.js.map +1 -1
- package/dist/feedback/feedback-context.js +1 -4
- package/dist/feedback/feedback-context.js.map +1 -1
- package/dist/feedback/feedback-dialog.js.map +1 -1
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-public-list.js +7 -1
- package/dist/feedback/feedback-public-list.js.map +1 -1
- package/dist/feedback/feedback-record.js +1 -5
- package/dist/feedback/feedback-record.js.map +1 -1
- package/dist/feedback/feedback-replies.js +25 -10
- package/dist/feedback/feedback-replies.js.map +1 -1
- package/dist/feedback/feedback-row-detail.js.map +1 -1
- package/dist/feedback/feedback-status-undo.js.map +1 -1
- package/dist/feedback/feedback-submit-dialog.js.map +1 -1
- package/dist/feedback/feedback-table.js +4 -1
- package/dist/feedback/feedback-table.js.map +1 -1
- package/dist/feedback/feedback-thread.js +2 -13
- package/dist/feedback/feedback-thread.js.map +1 -1
- package/dist/feedback/feedback-vocabulary.js.map +1 -1
- package/dist/feedback/feedback-vote-button.js.map +1 -1
- package/dist/hooks/use-anchored-rect.js.map +1 -1
- package/dist/hooks/use-document-title.js.map +1 -1
- package/dist/hooks/use-file-drop.js.map +1 -1
- package/dist/hooks/use-file-picker-labels.js.map +1 -1
- package/dist/hooks/use-file-picker.js.map +1 -1
- package/dist/hooks/use-focus-trap.js.map +1 -1
- package/dist/hooks/use-overlay-history.js +10 -5
- package/dist/hooks/use-overlay-history.js.map +1 -1
- package/dist/hooks/use-row-swipe.d.ts +1 -1
- package/dist/hooks/use-row-swipe.js +1 -6
- package/dist/hooks/use-row-swipe.js.map +1 -1
- package/dist/i18n/defaults.js +1 -4
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +4 -1
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.js +2 -8
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.js.map +1 -1
- package/dist/i18n/locales/en.js +4 -1
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/es.js +4 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +4 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +4 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +4 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/plurals.js.map +1 -1
- package/dist/i18n/review.js +13 -7
- package/dist/i18n/review.js.map +1 -1
- package/dist/i18n/shared-labels.js.map +1 -1
- package/dist/index.d.ts +16 -16
- package/dist/index.js +40 -23
- package/dist/index.js.map +1 -1
- package/dist/landing/access.js +1 -4
- package/dist/landing/access.js.map +1 -1
- package/dist/landing/landing-sections.js +35 -38
- package/dist/landing/landing-sections.js.map +1 -1
- package/dist/landing/page-seo.js.map +1 -1
- package/dist/lib/calc.js.map +1 -1
- package/dist/lib/clipboard-minus.js.map +1 -1
- package/dist/lib/dates.js.map +1 -1
- package/dist/lib/decimal-marks.js.map +1 -1
- package/dist/lib/device-settings.js +2 -1
- package/dist/lib/device-settings.js.map +1 -1
- package/dist/lib/direction.js.map +1 -1
- package/dist/lib/iban.js.map +1 -1
- package/dist/lib/logger.js +1 -5
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/person-name.js.map +1 -1
- package/dist/lib/phone.js.map +1 -1
- package/dist/lib/qr-encode.js +344 -8
- package/dist/lib/qr-encode.js.map +1 -1
- package/dist/lib/session-refresh.js.map +1 -1
- package/dist/lib/table-text.js +15 -3
- package/dist/lib/table-text.js.map +1 -1
- package/dist/lib/text-match.d.ts +4 -0
- package/dist/lib/text-match.js.map +1 -1
- package/dist/lib/translation-review.js.map +1 -1
- package/dist/notifications/notification-inbox.js +63 -44
- package/dist/notifications/notification-inbox.js.map +1 -1
- package/dist/notifications/notification-parts.js.map +1 -1
- package/dist/notifications/notification-types-setting.js.map +1 -1
- package/dist/notifications/notifications-labels.js.map +1 -1
- package/dist/notifications/push-device-setting.js +6 -1
- package/dist/notifications/push-device-setting.js.map +1 -1
- package/dist/notifications/push-devices-setting.js.map +1 -1
- package/dist/notifications/push-worker.js.map +1 -1
- package/dist/notifications/quiet-hours-setting.js.map +1 -1
- package/dist/notifications/use-push-device.js +4 -1
- package/dist/notifications/use-push-device.js.map +1 -1
- package/dist/offline/pwa-update-prompt.d.ts +1 -1
- package/dist/offline/pwa-update-prompt.js +7 -1
- package/dist/offline/pwa-update-prompt.js.map +1 -1
- package/dist/offline/query-persistence.js.map +1 -1
- package/dist/offline.d.ts +1 -1
- package/dist/offline.js +6 -1
- package/dist/offline.js.map +1 -1
- package/dist/pay/pay.css +8 -1
- package/dist/rhf/fields.d.ts +1 -2
- package/dist/rhf/fields.js +1 -10
- package/dist/rhf/fields.js.map +1 -1
- package/dist/rhf/form-core.js +1 -4
- package/dist/rhf/form-core.js.map +1 -1
- package/dist/rhf/line-items.js.map +1 -1
- package/dist/rhf/use-rhf-wizard-step.js.map +1 -1
- package/dist/search/command-palette.js +71 -62
- package/dist/search/command-palette.js.map +1 -1
- package/dist/search/global-search.js +34 -5
- package/dist/search/global-search.js.map +1 -1
- package/dist/search/search-index.js.map +1 -1
- package/dist/settings/device-reset-setting.js +1 -6
- package/dist/settings/device-reset-setting.js.map +1 -1
- package/dist/settings/settings-heading.js.map +1 -1
- package/dist/settings/settings-layout.js +9 -20
- package/dist/settings/settings-layout.js.map +1 -1
- package/dist/settings/use-settings-route.js.map +1 -1
- package/dist/shell/app-shell.js +62 -31
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/auth-layout.js +1 -10
- package/dist/shell/auth-layout.js.map +1 -1
- package/dist/shell/dev-menu.js +12 -6
- package/dist/shell/dev-menu.js.map +1 -1
- package/dist/shell/option-switcher-menu.js.map +1 -1
- package/dist/shell/sync-status-indicator.js +7 -6
- package/dist/shell/sync-status-indicator.js.map +1 -1
- package/dist/shell/top-bar-parts-labels.js.map +1 -1
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell/topbar-controls.js +1 -10
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/shell/tour-menu.js +9 -1
- package/dist/shell/tour-menu.js.map +1 -1
- package/dist/sync/db.js.map +1 -1
- package/dist/sync/engine.js +24 -6
- package/dist/sync/engine.js.map +1 -1
- package/dist/sync.d.ts +2 -2
- package/dist/sync.js +7 -1
- package/dist/sync.js.map +1 -1
- package/dist/testing/css-variables.js.map +1 -1
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +1 -3
- package/dist/testing/index.js.map +1 -1
- package/dist/testing/react.js.map +1 -1
- package/dist/testing/viewport.js +7 -4
- package/dist/testing/viewport.js.map +1 -1
- package/dist/theme/contrast-tokens.js +13 -2
- package/dist/theme/contrast-tokens.js.map +1 -1
- package/dist/theme/contrast.d.ts +2 -2
- package/dist/theme/palette-derive.d.ts +1 -1
- package/dist/theme/palette-derive.js +18 -29
- package/dist/theme/palette-derive.js.map +1 -1
- package/dist/theme/palette-presets.js.map +1 -1
- package/dist/theme/palette-store.js.map +1 -1
- package/dist/theme/text-size.js.map +1 -1
- package/dist/theme/theme-store.js +1 -3
- package/dist/theme/theme-store.js.map +1 -1
- package/dist/tour/tour-catalogue.js.map +1 -1
- package/dist/tour/tour-dock.js +5 -1
- package/dist/tour/tour-dock.js.map +1 -1
- package/dist/tour/tour-engine.js +1 -4
- package/dist/tour/tour-engine.js.map +1 -1
- package/dist/tour/tour.js +21 -12
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour/use-tour-progress.js.map +1 -1
- package/dist/tour/use-tour-url-sync.js.map +1 -1
- package/dist/wizard/stepper-nav.js +3 -24
- package/dist/wizard/stepper-nav.js.map +1 -1
- package/dist/wizard/types.js.map +1 -1
- package/dist/wizard/use-wizard.js +24 -28
- package/dist/wizard/use-wizard.js.map +1 -1
- package/dist/wizard/validation.js.map +1 -1
- package/dist/wizard/wizard-context.js.map +1 -1
- package/dist/wizard/wizard-step.d.ts +1 -1
- package/dist/wizard/wizard-step.js +1 -8
- package/dist/wizard/wizard-step.js.map +1 -1
- package/dist/wizard/wizard-summary.js.map +1 -1
- package/package.json +5 -2
- package/src/account/account-settings-labels.ts +1 -2
- package/src/account/account-settings.tsx +11 -17
- package/src/account/delete-account-setting.tsx +1 -6
- package/src/account/email-change-setting-labels.ts +1 -2
- package/src/account/email-change-setting.tsx +3 -1
- package/src/account/passkeys-setting.tsx +1 -3
- package/src/account/sessions-setting.tsx +4 -2
- package/src/admin/admin-action-confirm.tsx +4 -7
- package/src/admin/admin-action-log.tsx +29 -29
- package/src/admin/admin-area-labels.ts +4 -1
- package/src/admin/admin-overview.tsx +8 -2
- package/src/admin/admin-sections.tsx +3 -1
- package/src/admin/admin-tool-links.tsx +7 -1
- package/src/admin/invitations-panel.tsx +5 -11
- package/src/admin/mail-test-card.tsx +1 -2
- package/src/admin/reviewer-scope-editor.tsx +4 -9
- package/src/admin/role-select.tsx +4 -2
- package/src/admin/user-roster.tsx +2 -11
- package/src/auth/accept-invitation.tsx +1 -7
- package/src/auth/auth-errors.ts +1 -6
- package/src/auth/forgot-password-form.tsx +2 -7
- package/src/auth/register-form.tsx +7 -8
- package/src/auth/reset-password-form.tsx +2 -5
- package/src/auth/sign-in-form.tsx +15 -14
- package/src/auth/verify-email.tsx +2 -7
- package/src/billing/billing-banner.tsx +3 -7
- package/src/billing/billing-labels.ts +6 -4
- package/src/billing/checkout-processing.ts +0 -1
- package/src/billing/plan-card.tsx +9 -8
- package/src/billing/plan-limit-toast.tsx +1 -2
- package/src/billing/plan-limit.ts +1 -2
- package/src/billing/plan-price.ts +1 -4
- package/src/billing/plan-switch.tsx +1 -2
- package/src/billing/subscription-actions.tsx +5 -2
- package/src/components/account-chips.tsx +1 -7
- package/src/components/alert-banner.tsx +2 -8
- package/src/components/amount-input.tsx +206 -178
- package/src/components/authed-image.tsx +7 -4
- package/src/components/autocomplete.tsx +7 -9
- package/src/components/bulk-action-bar.tsx +2 -7
- package/src/components/button-group.tsx +1 -2
- package/src/components/button-look.ts +6 -17
- package/src/components/button.tsx +1 -4
- package/src/components/calculator.tsx +6 -9
- package/src/components/calendar-heatmap.tsx +5 -10
- package/src/components/card.tsx +9 -13
- package/src/components/chart-zoom-core.tsx +6 -33
- package/src/components/chart.tsx +8 -31
- package/src/components/checkbox-group.tsx +1 -4
- package/src/components/chip-input.tsx +6 -6
- package/src/components/chip.tsx +14 -26
- package/src/components/choice-card.tsx +4 -18
- package/src/components/column-mapper-labels.ts +3 -10
- package/src/components/column-mapper.tsx +33 -122
- package/src/components/combobox-core.tsx +12 -42
- package/src/components/combobox.tsx +2 -6
- package/src/components/confirm-dialog.tsx +1 -3
- package/src/components/copy-button.tsx +2 -4
- package/src/components/country-select.tsx +9 -21
- package/src/components/currency-option-list.tsx +13 -14
- package/src/components/currency-select.tsx +2 -7
- package/src/components/danger-confirm.tsx +8 -9
- package/src/components/data-table-cells.tsx +1 -2
- package/src/components/data-table-column-settings.tsx +1 -3
- package/src/components/data-table-filter-popover.tsx +5 -19
- package/src/components/data-table-filters.ts +1 -6
- package/src/components/data-table-fit.ts +1 -3
- package/src/components/data-table-groups.tsx +3 -1
- package/src/components/data-table-head.tsx +27 -45
- package/src/components/data-table-mobile.tsx +99 -108
- package/src/components/data-table-pagination.tsx +2 -6
- package/src/components/data-table-row-actions.tsx +25 -24
- package/src/components/data-table-row.tsx +16 -17
- package/src/components/data-table-shared.tsx +10 -16
- package/src/components/data-table-sort.ts +1 -2
- package/src/components/data-table-virtual.ts +9 -4
- package/src/components/data-table.tsx +489 -501
- package/src/components/date-picker.tsx +1 -7
- package/src/components/date-range-picker.tsx +3 -13
- package/src/components/description-list.tsx +7 -13
- package/src/components/dialog-frame.tsx +12 -3
- package/src/components/disclosure.tsx +8 -12
- package/src/components/dropdown.tsx +3 -11
- package/src/components/empty-state.tsx +1 -3
- package/src/components/entity-combobox.tsx +2 -6
- package/src/components/error-boundary.tsx +7 -7
- package/src/components/field-base.tsx +8 -14
- package/src/components/field-parts.tsx +12 -23
- package/src/components/field-sync.tsx +7 -11
- package/src/components/file-button.tsx +7 -3
- package/src/components/file-dropzone.tsx +7 -12
- package/src/components/floating-panel.tsx +44 -51
- package/src/components/focus-ring.ts +1 -2
- package/src/components/form-actions.tsx +1 -4
- package/src/components/grouped-picker.tsx +6 -8
- package/src/components/hover-menu.tsx +1 -4
- package/src/components/iban-input-labels.ts +1 -2
- package/src/components/icon-button.tsx +46 -48
- package/src/components/icon-picker.tsx +4 -17
- package/src/components/image-grid.tsx +1 -6
- package/src/components/inline-edit-field.tsx +1 -5
- package/src/components/inline-entity-combobox.tsx +6 -21
- package/src/components/input.tsx +14 -2
- package/src/components/jump-calendar.tsx +2 -6
- package/src/components/legal-labels.ts +1 -2
- package/src/components/legal-page.tsx +18 -7
- package/src/components/legal.tsx +14 -5
- package/src/components/lightbox.tsx +1 -3
- package/src/components/line-items.tsx +7 -15
- package/src/components/list.tsx +29 -25
- package/src/components/measured-grid.tsx +8 -30
- package/src/components/menu-item.tsx +1 -3
- package/src/components/mini-calendar.tsx +129 -159
- package/src/components/money-field.tsx +4 -2
- package/src/components/month-picker.tsx +14 -21
- package/src/components/multi-entity-combobox.tsx +6 -26
- package/src/components/multi-select.tsx +5 -9
- package/src/components/nav-pills.tsx +10 -2
- package/src/components/number-field.tsx +18 -15
- package/src/components/number-input.tsx +3 -19
- package/src/components/numpad-sheet.tsx +9 -13
- package/src/components/one-time-code-input.tsx +6 -11
- package/src/components/page-contents.tsx +2 -9
- package/src/components/page-header.tsx +5 -10
- package/src/components/password-strength-labels.ts +1 -2
- package/src/components/password-strength.tsx +6 -9
- package/src/components/phone-input.tsx +2 -13
- package/src/components/picker-sheet.tsx +2 -5
- package/src/components/pie-chart.tsx +6 -12
- package/src/components/popover.tsx +2 -1
- package/src/components/progress-bar.tsx +16 -25
- package/src/components/qr-code.tsx +2 -9
- package/src/components/row-actions.tsx +9 -1
- package/src/components/search-field.tsx +4 -5
- package/src/components/select.tsx +2 -10
- package/src/components/separator.tsx +1 -5
- package/src/components/series-chart-axes.ts +5 -5
- package/src/components/series-chart-budget.ts +1 -4
- package/src/components/series-chart-ticks.ts +1 -4
- package/src/components/series-chart.tsx +43 -83
- package/src/components/settings-fields.tsx +20 -15
- package/src/components/share-card.tsx +6 -14
- package/src/components/sign-chip.tsx +16 -16
- package/src/components/signature-pad.tsx +21 -13
- package/src/components/signed-amount.tsx +6 -14
- package/src/components/skeleton.tsx +2 -13
- package/src/components/slider.tsx +9 -20
- package/src/components/sparkline.tsx +2 -13
- package/src/components/stat-tile.tsx +13 -24
- package/src/components/swatch-picker.tsx +8 -3
- package/src/components/swipeable-row.tsx +2 -11
- package/src/components/table-skeleton.tsx +4 -1
- package/src/components/table.tsx +12 -8
- package/src/components/tabs.tsx +24 -39
- package/src/components/text-link.tsx +6 -6
- package/src/components/text.tsx +1 -2
- package/src/components/tile-radio.tsx +6 -19
- package/src/components/time-input.tsx +4 -5
- package/src/components/toast.tsx +3 -12
- package/src/components/toggle-group.tsx +9 -9
- package/src/components/toggle-legend.tsx +4 -16
- package/src/components/tooltip.tsx +9 -48
- package/src/components/translation-review-editor.tsx +4 -4
- package/src/components/translation-review-parts.tsx +7 -1
- package/src/components/translation-review.tsx +15 -16
- package/src/components/tree-view.tsx +17 -20
- package/src/components/treemap.tsx +59 -9
- package/src/components/trigger-aria.ts +1 -4
- package/src/components/use-table-state.ts +16 -17
- package/src/components/user-avatar.tsx +4 -21
- package/src/demo/demo-start.tsx +8 -4
- package/src/feedback/feedback-attachment-labels.ts +2 -1
- package/src/feedback/feedback-attachment.tsx +16 -29
- package/src/feedback/feedback-capture.ts +3 -1
- package/src/feedback/feedback-comment-thread.tsx +9 -6
- package/src/feedback/feedback-context.tsx +1 -4
- package/src/feedback/feedback-dialog.tsx +3 -4
- package/src/feedback/feedback-inbox.tsx +3 -9
- package/src/feedback/feedback-public-list.tsx +7 -1
- package/src/feedback/feedback-record.ts +6 -14
- package/src/feedback/feedback-replies.tsx +10 -5
- package/src/feedback/feedback-row-detail.tsx +2 -5
- package/src/feedback/feedback-status-undo.ts +1 -4
- package/src/feedback/feedback-submit-dialog.tsx +4 -4
- package/src/feedback/feedback-table.tsx +12 -8
- package/src/feedback/feedback-thread.tsx +4 -16
- package/src/feedback/feedback-vocabulary.ts +72 -82
- package/src/feedback/feedback-vote-button.tsx +9 -8
- package/src/hooks/use-anchored-rect.ts +1 -4
- package/src/hooks/use-document-title.ts +3 -1
- package/src/hooks/use-file-drop.ts +1 -3
- package/src/hooks/use-file-picker-labels.ts +1 -2
- package/src/hooks/use-file-picker.tsx +1 -5
- package/src/hooks/use-focus-trap.ts +1 -2
- package/src/hooks/use-overlay-history.ts +10 -5
- package/src/hooks/use-row-swipe.ts +1 -6
- package/src/i18n/defaults.ts +1 -4
- package/src/i18n/german.ts +33 -49
- package/src/i18n/kit-labels.tsx +5 -20
- package/src/i18n/languages.ts +2 -8
- package/src/i18n/locales/en.ts +11 -31
- package/src/i18n/locales/es.ts +35 -49
- package/src/i18n/locales/fr.ts +34 -51
- package/src/i18n/locales/hu.ts +26 -37
- package/src/i18n/locales/it.ts +35 -52
- package/src/i18n/locales/zh.ts +14 -32
- package/src/i18n/plurals.ts +4 -1
- package/src/i18n/review.ts +18 -7
- package/src/i18n/shared-labels.ts +1 -2
- package/src/index.ts +106 -32
- package/src/landing/access.ts +2 -7
- package/src/landing/landing-sections.tsx +14 -15
- package/src/landing/page-seo.ts +1 -3
- package/src/lib/calc.ts +4 -1
- package/src/lib/clipboard-minus.ts +4 -1
- package/src/lib/dates.ts +2 -10
- package/src/lib/decimal-marks.ts +1 -3
- package/src/lib/device-settings.ts +2 -1
- package/src/lib/direction.ts +1 -3
- package/src/lib/iban.ts +89 -9
- package/src/lib/logger.ts +1 -5
- package/src/lib/person-name.ts +1 -3
- package/src/lib/phone.ts +5 -8
- package/src/lib/qr-encode.ts +32 -8
- package/src/lib/session-refresh.ts +5 -6
- package/src/lib/table-text.ts +24 -6
- package/src/lib/text-match.ts +15 -3
- package/src/lib/translation-review.ts +1 -3
- package/src/notifications/notification-inbox.tsx +17 -4
- package/src/notifications/notification-parts.tsx +1 -2
- package/src/notifications/notification-types-setting.tsx +19 -18
- package/src/notifications/notifications-labels.ts +1 -2
- package/src/notifications/push-device-setting.tsx +7 -1
- package/src/notifications/push-devices-setting.tsx +7 -3
- package/src/notifications/push-worker.ts +10 -2
- package/src/notifications/quiet-hours-setting.tsx +4 -1
- package/src/notifications/use-push-device.ts +4 -1
- package/src/offline/pwa-update-prompt.tsx +7 -1
- package/src/offline/query-persistence.ts +9 -6
- package/src/offline.ts +6 -1
- package/src/rhf/fields.tsx +50 -19
- package/src/rhf/form-core.tsx +2 -6
- package/src/rhf/line-items.tsx +5 -7
- package/src/rhf/use-rhf-wizard-step.ts +3 -12
- package/src/search/command-palette.tsx +15 -23
- package/src/search/global-search.tsx +38 -8
- package/src/search/search-index.ts +5 -1
- package/src/settings/device-reset-setting.tsx +5 -8
- package/src/settings/settings-heading.tsx +3 -1
- package/src/settings/settings-layout.tsx +6 -13
- package/src/settings/use-settings-route.ts +4 -3
- package/src/shell/app-shell.tsx +48 -24
- package/src/shell/auth-layout.tsx +1 -6
- package/src/shell/dev-menu.tsx +12 -6
- package/src/shell/option-switcher-menu.tsx +1 -3
- package/src/shell/sync-status-indicator.tsx +14 -12
- package/src/shell/top-bar-parts-labels.ts +1 -3
- package/src/shell/topbar-action-menu.tsx +9 -8
- package/src/shell/topbar-controls.tsx +5 -19
- package/src/shell/tour-menu.tsx +9 -1
- package/src/sync/db.ts +5 -1
- package/src/sync/engine.ts +44 -12
- package/src/sync.ts +16 -2
- package/src/testing/css-variables.ts +1 -2
- package/src/testing/index.ts +1 -6
- package/src/testing/react.ts +1 -2
- package/src/testing/viewport.ts +9 -9
- package/src/theme/contrast-tokens.ts +13 -2
- package/src/theme/palette-derive.ts +24 -50
- package/src/theme/palette-presets.ts +0 -1
- package/src/theme/palette-store.ts +1 -4
- package/src/theme/text-size.ts +1 -1
- package/src/theme/theme-store.ts +2 -6
- package/src/tour/tour-catalogue.tsx +4 -2
- package/src/tour/tour-dock.ts +5 -1
- package/src/tour/tour-engine.ts +2 -6
- package/src/tour/tour.tsx +28 -12
- package/src/tour/use-tour-progress.ts +8 -2
- package/src/tour/use-tour-url-sync.ts +3 -1
- package/src/vite-env.d.ts +1 -4
- package/src/wizard/stepper-nav.tsx +10 -35
- package/src/wizard/types.ts +1 -4
- package/src/wizard/use-wizard.ts +28 -43
- package/src/wizard/validation.ts +1 -5
- package/src/wizard/wizard-context.tsx +2 -6
- package/src/wizard/wizard-step.tsx +1 -8
- package/src/wizard/wizard-summary.tsx +2 -6
- package/tokens.css +393 -152
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/notifications/push-devices-setting.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithRef, KeyboardEvent, ReactNode } from \"react\";\nimport { MonitorSmartphone } from \"lucide-react\";\n\nimport { useKitLabels, useKitLocale, type LabelOverride } from \"../i18n/kit-labels\";\nimport { DEFAULT_COMMON_LABELS } from \"../i18n/shared-labels\";\nimport { cn } from \"../lib/cn\";\nimport { formatDate } from \"../lib/format\";\nimport { Button } from \"../components/button\";\nimport { Card } from \"../components/card\";\nimport { Chip } from \"../components/chip\";\nimport { Disclosure } from \"../components/disclosure\";\nimport { Skeleton } from \"../components/skeleton\";\nimport { Switch } from \"../components/switch\";\nimport { COMMIT_EXCEPT_BILLING } from \"../components/write-lock\";\nimport { CARD_DESCRIPTION_CLASS } from \"../settings/settings-card-classes\";\nimport { SettingsCardTitle } from \"../settings/settings-heading\";\nimport { settle, useMounted } from \"../account/account-parts\";\nimport { describeFailure, FailureLine, useNotificationsLabels, useWriteOverlay } from \"./notification-parts\";\nimport type { NotificationsLabels } from \"./notifications-labels\";\nimport type { NotificationTypeEntry, NotificationTypesValue, PushDeviceItem } from \"./notification-types\";\nimport { notifyPushSubscriptionChanged } from \"./use-push-device\";\n\n/**\n * A name for a stored user agent: \"Chrome · Android\", \"Firefox · Windows\" (keksdose\n * `push-devices-card.tsx:19-45`). Best effort: an agent it does not recognise comes back\n * as it is, cut at 60 characters so a token soup cannot blow up the row; `null` for none.\n * The order matters — Edge and Opera say \"Chrome\" too, and Chrome says \"Safari\".\n */\nexport function describeUserAgent(ua: string | null): string | null {\n if (!ua) return null;\n const browser = /edg(?:e|a|ios)?\\//i.test(ua)\n ? \"Edge\"\n : /opr\\/|opera/i.test(ua)\n ? \"Opera\"\n : /firefox|fxios/i.test(ua)\n ? \"Firefox\"\n : /chrome|crios/i.test(ua)\n ? \"Chrome\"\n : /safari/i.test(ua)\n ? \"Safari\"\n : null;\n const os = /windows/i.test(ua)\n ? \"Windows\"\n : /android/i.test(ua)\n ? \"Android\"\n : /iphone|ipad|ipod/i.test(ua)\n ? \"iOS\"\n : /mac os x|macintosh/i.test(ua)\n ? \"macOS\"\n : /linux/i.test(ua)\n ? \"Linux\"\n : null;\n if (!browser && !os) return ua.length > 60 ? `${ua.slice(0, 60)}…` : ua;\n return [browser, os].filter(Boolean).join(\" · \");\n}\n\nexport interface PushDevicesSettingProps<Id extends string | number = string>\n extends Omit<ComponentPropsWithRef<\"div\">, \"children\"> {\n /** `GET /notifications/devices`, oldest first; `undefined` while the first load runs. */\n devices: readonly PushDeviceItem<Id>[] | undefined;\n loading?: boolean;\n /** This browser's endpoint (`usePushDevice().currentEndpoint`): its row says \"This\n * device\", and removing it also unsubscribes the browser. */\n currentEndpoint?: string | null;\n /** `DELETE /notifications/devices/{id}`. Asked first; refresh `devices` once it\n * resolves. */\n onRemove: (id: Id) => Promise<unknown> | void;\n /** The dates' text. Default: the provider's locale, medium date. */\n formatDate?: (date: Date) => string;\n /** Overrides the provider's locale for the default format. */\n locale?: string;\n /** The app's own words for a failed removal, or `undefined` for the kit's `failed`. */\n describeError?: (error: unknown) => ReactNode | undefined;\n /**\n * 0.46 (§10.2): \"Types on this device\" on each row — a switch per type whose account\n * `push` is on, all on until the device mutes one. Save the device's WHOLE list:\n * `PATCH /notifications/devices/{id} {\"muted_types\": [...]}`; refresh `devices` once it\n * resolves. Left out, no row has the disclosure.\n */\n onMutedTypesChange?: (id: Id, mutedTypes: string[]) => Promise<unknown> | void;\n /** The app's words for its types (the types card's `types`), for the disclosure. */\n types?: readonly NotificationTypeEntry[];\n /** The settings `GET`'s `types` (the types card's `value`): which types have push on. */\n typeSwitches?: NotificationTypesValue;\n labels?: LabelOverride<NotificationsLabels>;\n}\n\n/** Tear down this browser's subscription after its server row went: otherwise the\n * browser still holds one, and the push card would say \"on\" for a device nothing can\n * reach. Best effort — the row is already gone. */\nasync function unsubscribeThisBrowser(): Promise<void> {\n try {\n const registration = await navigator.serviceWorker?.getRegistration();\n const subscription = await registration?.pushManager.getSubscription();\n await subscription?.unsubscribe();\n } catch {\n /* the server row is gone either way */\n }\n notifyPushSubscriptionChanged();\n}\n\n/**\n * Every device that gets push for the account (docs/notifications-harmonization.md §3.5,\n * §4.2) — keksdose's devices card: the browser and system read from the stored user\n * agent, \"This device\" on this browser's row, when it was added and last delivered to,\n * the failures in a row in the warning tone, and \"Remove\".\n *\n * **Remove asks first, in the row**, as `PasskeysSetting` does: the card must work in an\n * app without a `ConfirmProvider`, and the question belongs beside the device it names.\n * Focus moves to Cancel. **Removing this device** also unsubscribes the browser and fires\n * `PUSH_SUBSCRIPTION_CHANGED` (keksdose `push-devices-card.tsx:83-97`), so the push card\n * stops saying \"on\".\n *\n * **\"Types on this device\"** (0.46, §10.2, with `onMutedTypesChange`): a disclosure under\n * each row with a switch per type the account gets by push; turning one off mutes it on\n * that device only — a device can mute types, never add them. Each flip saves the\n * device's whole `muted_types` list.\n *\n * Under a write lock Remove and the type switches are `commit` controls exempt from\n * billing.\n */\nexport function PushDevicesSetting<Id extends string | number = string>({\n devices,\n loading,\n currentEndpoint,\n onRemove,\n formatDate: formatDateProp,\n locale: localeProp,\n describeError,\n onMutedTypesChange,\n types,\n typeSwitches,\n labels: labelsProp,\n className,\n ...rest\n}: PushDevicesSettingProps<Id>) {\n const labels = useNotificationsLabels(labelsProp);\n const mutedOverlay = useWriteOverlay<string[]>(devices);\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const locale = useKitLocale(localeProp);\n const [asking, setAsking] = useState<Id | null>(null);\n const [busyId, setBusyId] = useState<Id | null>(null);\n const [failure, setFailure] = useState<ReactNode>(null);\n const mounted = useMounted();\n\n const dateText = (iso: string): string => {\n const date = new Date(iso);\n if (Number.isNaN(date.getTime())) return iso;\n return formatDateProp ? formatDateProp(date) : formatDate(date, \"medium\", { locale });\n };\n\n const remove = (device: PushDeviceItem<Id>) => {\n if (busyId !== null) return;\n setBusyId(device.id);\n setFailure(null);\n const isThis = Boolean(currentEndpoint) && device.endpoint === currentEndpoint;\n settle(() => onRemove(device.id))\n .then(async () => {\n if (isThis) await unsubscribeThisBrowser();\n if (!mounted.current) return;\n setBusyId(null);\n setAsking(null);\n })\n .catch((error: unknown) => {\n if (!mounted.current) return;\n setBusyId(null);\n setFailure(describeFailure(describeError?.(error), labels.failed));\n });\n };\n\n // The types a device can mute: those the account gets by push, in the app's order.\n const pushTypes = (types ?? []).filter((entry) => typeSwitches?.[entry.id]?.push === true);\n const mutedOf = (device: PushDeviceItem<Id>): string[] =>\n mutedOverlay.get(String(device.id)) ??\n // Ids the registry dropped (no longer in the settings answer) are ignored.\n (device.muted_types ?? []).filter((id) => !typeSwitches || id in typeSwitches);\n const setMuted = (device: PushDeviceItem<Id>, id: string, on: boolean) => {\n const key = String(device.id);\n if (!onMutedTypesChange || mutedOverlay.pending(key)) return;\n const current = mutedOf(device);\n const next = on ? current.filter((muted) => muted !== id) : [...current.filter((muted) => muted !== id), id];\n setFailure(null);\n mutedOverlay.set(key, next);\n settle(() => onMutedTypesChange(device.id, next)).then(\n () => {\n if (mounted.current) mutedOverlay.settle(key, true);\n },\n (error: unknown) => {\n if (!mounted.current) return;\n mutedOverlay.settle(key, false);\n setFailure(describeFailure(describeError?.(error), labels.failed));\n },\n );\n };\n const typesOf = (device: PushDeviceItem<Id>, name: string): ReactNode => {\n if (!onMutedTypesChange) return null;\n const muted = mutedOf(device);\n return (\n <Disclosure\n variant=\"bare\"\n title={labels.deviceTypes}\n // Every row has one: the name says whose (it starts with the visible words).\n triggerProps={{ \"aria-label\": `${labels.deviceTypes}: ${name}` }}\n className=\"text-sm\"\n data-device-types=\"\"\n >\n <div className=\"space-y-2 pt-1 ps-6\">\n {pushTypes.length === 0 ? (\n <p className={CARD_DESCRIPTION_CLASS}>{labels.deviceTypesEmpty}</p>\n ) : (\n <>\n <p className={CARD_DESCRIPTION_CLASS}>{labels.deviceTypesDescription}</p>\n {pushTypes.map((entry) => (\n <Switch\n key={entry.id}\n size=\"sm\"\n label={entry.label}\n checked={!muted.includes(entry.id)}\n commit={COMMIT_EXCEPT_BILLING}\n onCheckedChange={(on) => setMuted(device, entry.id, on)}\n />\n ))}\n </>\n )}\n </div>\n </Disclosure>\n );\n };\n\n let body: ReactNode;\n if (loading || devices === undefined) {\n body = <Skeleton lines={2} label={labels.devicesLoading} />;\n } else if (devices.length === 0) {\n body = <p className={CARD_DESCRIPTION_CLASS}>{labels.devicesEmpty}</p>;\n } else {\n body = (\n <ul aria-label={labels.devicesTitle} className=\"divide-y divide-subtle\">\n {devices.map((device) => (\n <DeviceRow\n key={device.id}\n device={device}\n name={describeUserAgent(device.user_agent) ?? labels.unknownDevice}\n current={Boolean(currentEndpoint) && device.endpoint === currentEndpoint}\n added={dateText(device.created_at)}\n lastDelivery={device.last_success_at ? dateText(device.last_success_at) : null}\n asking={asking === device.id}\n busy={busyId === device.id}\n blocked={busyId !== null && busyId !== device.id}\n onAsk={() => setAsking(device.id)}\n onCancel={() => setAsking(null)}\n onConfirm={() => remove(device)}\n labels={labels}\n cancelLabel={common.cancel}\n types={typesOf(device, describeUserAgent(device.user_agent) ?? labels.unknownDevice)}\n />\n ))}\n </ul>\n );\n }\n\n return (\n <Card {...rest} className={cn(\"p-4 space-y-3\", className)}>\n <div>\n <SettingsCardTitle>{labels.devicesTitle}</SettingsCardTitle>\n <div className={CARD_DESCRIPTION_CLASS}>{labels.devicesDescription}</div>\n </div>\n {body}\n <FailureLine>{failure}</FailureLine>\n </Card>\n );\n}\n\nfunction DeviceRow<Id extends string | number>({\n device,\n name,\n current,\n added,\n lastDelivery,\n asking,\n busy,\n blocked,\n onAsk,\n onCancel,\n onConfirm,\n labels,\n cancelLabel,\n types,\n}: {\n device: PushDeviceItem<Id>;\n name: string;\n current: boolean;\n added: string;\n lastDelivery: string | null;\n asking: boolean;\n busy: boolean;\n blocked: boolean;\n onAsk: () => void;\n onCancel: () => void;\n onConfirm: () => void;\n labels: NotificationsLabels;\n cancelLabel: string;\n /** \"Types on this device\", under the row (0.46). */\n types: ReactNode;\n}) {\n const cancelRef = useRef<HTMLButtonElement>(null);\n const removeRef = useRef<HTMLButtonElement>(null);\n // Which way the confirm closed: back to Remove, so focus does not fall to <body>.\n const [returning, setReturning] = useState(false);\n useEffect(() => {\n if (asking) cancelRef.current?.focus();\n else if (returning) removeRef.current?.focus();\n }, [asking, returning]);\n\n const leave = () => {\n setReturning(true);\n onCancel();\n };\n const escapeLeaves = (event: KeyboardEvent<HTMLButtonElement>) => {\n if (event.key === \"Escape\") {\n event.preventDefault();\n event.stopPropagation();\n leave();\n }\n };\n\n const row = { \"data-push-device-id\": String(device.id), \"data-current\": current || undefined };\n\n if (asking) {\n return (\n <li {...row} className=\"space-y-2 py-2\">\n <p role=\"alert\" className=\"text-sm\">\n {labels.removePrompt}\n </p>\n <div className=\"flex flex-wrap gap-2\">\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"danger\"\n commit={COMMIT_EXCEPT_BILLING}\n pending={busy}\n aria-label={labels.removeItem(name)}\n onClick={onConfirm}\n onKeyDown={escapeLeaves}\n >\n {labels.remove}\n </Button>\n <Button ref={cancelRef} type=\"button\" size=\"sm\" variant=\"ghost\" onClick={leave} onKeyDown={escapeLeaves}>\n {cancelLabel}\n </Button>\n </div>\n </li>\n );\n }\n\n return (\n <li {...row} className=\"space-y-1 py-2\">\n <div className=\"flex items-center gap-3\">\n <MonitorSmartphone aria-hidden className=\"size-4 shrink-0 text-muted\" />\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex flex-wrap items-center gap-x-2 gap-y-1\">\n <span className=\"min-w-0 break-words text-sm font-medium\">{name}</span>\n {current && (\n <Chip size=\"sm\" tone=\"brand\">\n {labels.thisDevice}\n </Chip>\n )}\n </div>\n <div className={CARD_DESCRIPTION_CLASS}>\n {labels.added(added)}\n {\" · \"}\n {lastDelivery ? labels.lastDelivery(lastDelivery) : labels.noDelivery}\n </div>\n {device.failure_count > 0 && <div className=\"text-xs text-warning\">{labels.failures(device.failure_count)}</div>}\n </div>\n <Button\n ref={removeRef}\n type=\"button\"\n size=\"sm\"\n variant=\"ghost\"\n commit={COMMIT_EXCEPT_BILLING}\n disabled={blocked}\n aria-label={labels.removeItem(name)}\n onClick={() => {\n setReturning(false);\n onAsk();\n }}\n >\n {labels.remove}\n </Button>\n </div>\n {types}\n </li>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/notifications/push-devices-setting.tsx"],"sourcesContent":["import { useEffect, useRef, useState } from \"react\";\nimport type { ComponentPropsWithRef, KeyboardEvent, ReactNode } from \"react\";\nimport { MonitorSmartphone } from \"lucide-react\";\n\nimport { useKitLabels, useKitLocale, type LabelOverride } from \"../i18n/kit-labels\";\nimport { DEFAULT_COMMON_LABELS } from \"../i18n/shared-labels\";\nimport { cn } from \"../lib/cn\";\nimport { formatDate } from \"../lib/format\";\nimport { Button } from \"../components/button\";\nimport { Card } from \"../components/card\";\nimport { Chip } from \"../components/chip\";\nimport { Disclosure } from \"../components/disclosure\";\nimport { Skeleton } from \"../components/skeleton\";\nimport { Switch } from \"../components/switch\";\nimport { COMMIT_EXCEPT_BILLING } from \"../components/write-lock\";\nimport { CARD_DESCRIPTION_CLASS } from \"../settings/settings-card-classes\";\nimport { SettingsCardTitle } from \"../settings/settings-heading\";\nimport { settle, useMounted } from \"../account/account-parts\";\nimport { describeFailure, FailureLine, useNotificationsLabels, useWriteOverlay } from \"./notification-parts\";\nimport type { NotificationsLabels } from \"./notifications-labels\";\nimport type { NotificationTypeEntry, NotificationTypesValue, PushDeviceItem } from \"./notification-types\";\nimport { notifyPushSubscriptionChanged } from \"./use-push-device\";\n\n/**\n * A name for a stored user agent: \"Chrome · Android\", \"Firefox · Windows\" (keksdose\n * `push-devices-card.tsx:19-45`). Best effort: an agent it does not recognise comes back\n * as it is, cut at 60 characters so a token soup cannot blow up the row; `null` for none.\n * The order matters — Edge and Opera say \"Chrome\" too, and Chrome says \"Safari\".\n */\nexport function describeUserAgent(ua: string | null): string | null {\n if (!ua) return null;\n const browser = /edg(?:e|a|ios)?\\//i.test(ua)\n ? \"Edge\"\n : /opr\\/|opera/i.test(ua)\n ? \"Opera\"\n : /firefox|fxios/i.test(ua)\n ? \"Firefox\"\n : /chrome|crios/i.test(ua)\n ? \"Chrome\"\n : /safari/i.test(ua)\n ? \"Safari\"\n : null;\n const os = /windows/i.test(ua)\n ? \"Windows\"\n : /android/i.test(ua)\n ? \"Android\"\n : /iphone|ipad|ipod/i.test(ua)\n ? \"iOS\"\n : /mac os x|macintosh/i.test(ua)\n ? \"macOS\"\n : /linux/i.test(ua)\n ? \"Linux\"\n : null;\n if (!browser && !os) return ua.length > 60 ? `${ua.slice(0, 60)}…` : ua;\n return [browser, os].filter(Boolean).join(\" · \");\n}\n\nexport interface PushDevicesSettingProps<Id extends string | number = string> extends Omit<\n ComponentPropsWithRef<\"div\">,\n \"children\"\n> {\n /** `GET /notifications/devices`, oldest first; `undefined` while the first load runs. */\n devices: readonly PushDeviceItem<Id>[] | undefined;\n loading?: boolean;\n /** This browser's endpoint (`usePushDevice().currentEndpoint`): its row says \"This\n * device\", and removing it also unsubscribes the browser. */\n currentEndpoint?: string | null;\n /** `DELETE /notifications/devices/{id}`. Asked first; refresh `devices` once it\n * resolves. */\n onRemove: (id: Id) => Promise<unknown> | void;\n /** The dates' text. Default: the provider's locale, medium date. */\n formatDate?: (date: Date) => string;\n /** Overrides the provider's locale for the default format. */\n locale?: string;\n /** The app's own words for a failed removal, or `undefined` for the kit's `failed`. */\n describeError?: (error: unknown) => ReactNode | undefined;\n /**\n * 0.46 (§10.2): \"Types on this device\" on each row — a switch per type whose account\n * `push` is on, all on until the device mutes one. Save the device's WHOLE list:\n * `PATCH /notifications/devices/{id} {\"muted_types\": [...]}`; refresh `devices` once it\n * resolves. Left out, no row has the disclosure.\n */\n onMutedTypesChange?: (id: Id, mutedTypes: string[]) => Promise<unknown> | void;\n /** The app's words for its types (the types card's `types`), for the disclosure. */\n types?: readonly NotificationTypeEntry[];\n /** The settings `GET`'s `types` (the types card's `value`): which types have push on. */\n typeSwitches?: NotificationTypesValue;\n labels?: LabelOverride<NotificationsLabels>;\n}\n\n/** Tear down this browser's subscription after its server row went: otherwise the\n * browser still holds one, and the push card would say \"on\" for a device nothing can\n * reach. Best effort — the row is already gone. */\nasync function unsubscribeThisBrowser(): Promise<void> {\n try {\n const registration = await navigator.serviceWorker?.getRegistration();\n const subscription = await registration?.pushManager.getSubscription();\n await subscription?.unsubscribe();\n } catch {\n /* the server row is gone either way */\n }\n notifyPushSubscriptionChanged();\n}\n\n/**\n * Every device that gets push for the account (docs/notifications-harmonization.md §3.5,\n * §4.2) — keksdose's devices card: the browser and system read from the stored user\n * agent, \"This device\" on this browser's row, when it was added and last delivered to,\n * the failures in a row in the warning tone, and \"Remove\".\n *\n * **Remove asks first, in the row**, as `PasskeysSetting` does: the card must work in an\n * app without a `ConfirmProvider`, and the question belongs beside the device it names.\n * Focus moves to Cancel. **Removing this device** also unsubscribes the browser and fires\n * `PUSH_SUBSCRIPTION_CHANGED` (keksdose `push-devices-card.tsx:83-97`), so the push card\n * stops saying \"on\".\n *\n * **\"Types on this device\"** (0.46, §10.2, with `onMutedTypesChange`): a disclosure under\n * each row with a switch per type the account gets by push; turning one off mutes it on\n * that device only — a device can mute types, never add them. Each flip saves the\n * device's whole `muted_types` list.\n *\n * Under a write lock Remove and the type switches are `commit` controls exempt from\n * billing.\n */\nexport function PushDevicesSetting<Id extends string | number = string>({\n devices,\n loading,\n currentEndpoint,\n onRemove,\n formatDate: formatDateProp,\n locale: localeProp,\n describeError,\n onMutedTypesChange,\n types,\n typeSwitches,\n labels: labelsProp,\n className,\n ...rest\n}: PushDevicesSettingProps<Id>) {\n const labels = useNotificationsLabels(labelsProp);\n const mutedOverlay = useWriteOverlay<string[]>(devices);\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const locale = useKitLocale(localeProp);\n const [asking, setAsking] = useState<Id | null>(null);\n const [busyId, setBusyId] = useState<Id | null>(null);\n const [failure, setFailure] = useState<ReactNode>(null);\n const mounted = useMounted();\n\n const dateText = (iso: string): string => {\n const date = new Date(iso);\n if (Number.isNaN(date.getTime())) return iso;\n return formatDateProp ? formatDateProp(date) : formatDate(date, \"medium\", { locale });\n };\n\n const remove = (device: PushDeviceItem<Id>) => {\n if (busyId !== null) return;\n setBusyId(device.id);\n setFailure(null);\n const isThis = Boolean(currentEndpoint) && device.endpoint === currentEndpoint;\n settle(() => onRemove(device.id))\n .then(async () => {\n if (isThis) await unsubscribeThisBrowser();\n if (!mounted.current) return;\n setBusyId(null);\n setAsking(null);\n })\n .catch((error: unknown) => {\n if (!mounted.current) return;\n setBusyId(null);\n setFailure(describeFailure(describeError?.(error), labels.failed));\n });\n };\n\n // The types a device can mute: those the account gets by push, in the app's order.\n const pushTypes = (types ?? []).filter((entry) => typeSwitches?.[entry.id]?.push === true);\n const mutedOf = (device: PushDeviceItem<Id>): string[] =>\n mutedOverlay.get(String(device.id)) ??\n // Ids the registry dropped (no longer in the settings answer) are ignored.\n (device.muted_types ?? []).filter((id) => !typeSwitches || id in typeSwitches);\n const setMuted = (device: PushDeviceItem<Id>, id: string, on: boolean) => {\n const key = String(device.id);\n if (!onMutedTypesChange || mutedOverlay.pending(key)) return;\n const current = mutedOf(device);\n const next = on ? current.filter((muted) => muted !== id) : [...current.filter((muted) => muted !== id), id];\n setFailure(null);\n mutedOverlay.set(key, next);\n settle(() => onMutedTypesChange(device.id, next)).then(\n () => {\n if (mounted.current) mutedOverlay.settle(key, true);\n },\n (error: unknown) => {\n if (!mounted.current) return;\n mutedOverlay.settle(key, false);\n setFailure(describeFailure(describeError?.(error), labels.failed));\n },\n );\n };\n const typesOf = (device: PushDeviceItem<Id>, name: string): ReactNode => {\n if (!onMutedTypesChange) return null;\n const muted = mutedOf(device);\n return (\n <Disclosure\n variant=\"bare\"\n title={labels.deviceTypes}\n // Every row has one: the name says whose (it starts with the visible words).\n triggerProps={{ \"aria-label\": `${labels.deviceTypes}: ${name}` }}\n className=\"text-sm\"\n data-device-types=\"\"\n >\n <div className=\"space-y-2 pt-1 ps-6\">\n {pushTypes.length === 0 ? (\n <p className={CARD_DESCRIPTION_CLASS}>{labels.deviceTypesEmpty}</p>\n ) : (\n <>\n <p className={CARD_DESCRIPTION_CLASS}>{labels.deviceTypesDescription}</p>\n {pushTypes.map((entry) => (\n <Switch\n key={entry.id}\n size=\"sm\"\n label={entry.label}\n checked={!muted.includes(entry.id)}\n commit={COMMIT_EXCEPT_BILLING}\n onCheckedChange={(on) => setMuted(device, entry.id, on)}\n />\n ))}\n </>\n )}\n </div>\n </Disclosure>\n );\n };\n\n let body: ReactNode;\n if (loading || devices === undefined) {\n body = <Skeleton lines={2} label={labels.devicesLoading} />;\n } else if (devices.length === 0) {\n body = <p className={CARD_DESCRIPTION_CLASS}>{labels.devicesEmpty}</p>;\n } else {\n body = (\n <ul aria-label={labels.devicesTitle} className=\"divide-y divide-subtle\">\n {devices.map((device) => (\n <DeviceRow\n key={device.id}\n device={device}\n name={describeUserAgent(device.user_agent) ?? labels.unknownDevice}\n current={Boolean(currentEndpoint) && device.endpoint === currentEndpoint}\n added={dateText(device.created_at)}\n lastDelivery={device.last_success_at ? dateText(device.last_success_at) : null}\n asking={asking === device.id}\n busy={busyId === device.id}\n blocked={busyId !== null && busyId !== device.id}\n onAsk={() => setAsking(device.id)}\n onCancel={() => setAsking(null)}\n onConfirm={() => remove(device)}\n labels={labels}\n cancelLabel={common.cancel}\n types={typesOf(device, describeUserAgent(device.user_agent) ?? labels.unknownDevice)}\n />\n ))}\n </ul>\n );\n }\n\n return (\n <Card {...rest} className={cn(\"p-4 space-y-3\", className)}>\n <div>\n <SettingsCardTitle>{labels.devicesTitle}</SettingsCardTitle>\n <div className={CARD_DESCRIPTION_CLASS}>{labels.devicesDescription}</div>\n </div>\n {body}\n <FailureLine>{failure}</FailureLine>\n </Card>\n );\n}\n\nfunction DeviceRow<Id extends string | number>({\n device,\n name,\n current,\n added,\n lastDelivery,\n asking,\n busy,\n blocked,\n onAsk,\n onCancel,\n onConfirm,\n labels,\n cancelLabel,\n types,\n}: {\n device: PushDeviceItem<Id>;\n name: string;\n current: boolean;\n added: string;\n lastDelivery: string | null;\n asking: boolean;\n busy: boolean;\n blocked: boolean;\n onAsk: () => void;\n onCancel: () => void;\n onConfirm: () => void;\n labels: NotificationsLabels;\n cancelLabel: string;\n /** \"Types on this device\", under the row (0.46). */\n types: ReactNode;\n}) {\n const cancelRef = useRef<HTMLButtonElement>(null);\n const removeRef = useRef<HTMLButtonElement>(null);\n // Which way the confirm closed: back to Remove, so focus does not fall to <body>.\n const [returning, setReturning] = useState(false);\n useEffect(() => {\n if (asking) cancelRef.current?.focus();\n else if (returning) removeRef.current?.focus();\n }, [asking, returning]);\n\n const leave = () => {\n setReturning(true);\n onCancel();\n };\n const escapeLeaves = (event: KeyboardEvent<HTMLButtonElement>) => {\n if (event.key === \"Escape\") {\n event.preventDefault();\n event.stopPropagation();\n leave();\n }\n };\n\n const row = { \"data-push-device-id\": String(device.id), \"data-current\": current || undefined };\n\n if (asking) {\n return (\n <li {...row} className=\"space-y-2 py-2\">\n <p role=\"alert\" className=\"text-sm\">\n {labels.removePrompt}\n </p>\n <div className=\"flex flex-wrap gap-2\">\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"danger\"\n commit={COMMIT_EXCEPT_BILLING}\n pending={busy}\n aria-label={labels.removeItem(name)}\n onClick={onConfirm}\n onKeyDown={escapeLeaves}\n >\n {labels.remove}\n </Button>\n <Button ref={cancelRef} type=\"button\" size=\"sm\" variant=\"ghost\" onClick={leave} onKeyDown={escapeLeaves}>\n {cancelLabel}\n </Button>\n </div>\n </li>\n );\n }\n\n return (\n <li {...row} className=\"space-y-1 py-2\">\n <div className=\"flex items-center gap-3\">\n <MonitorSmartphone aria-hidden className=\"size-4 shrink-0 text-muted\" />\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex flex-wrap items-center gap-x-2 gap-y-1\">\n <span className=\"min-w-0 break-words text-sm font-medium\">{name}</span>\n {current && (\n <Chip size=\"sm\" tone=\"brand\">\n {labels.thisDevice}\n </Chip>\n )}\n </div>\n <div className={CARD_DESCRIPTION_CLASS}>\n {labels.added(added)}\n {\" · \"}\n {lastDelivery ? labels.lastDelivery(lastDelivery) : labels.noDelivery}\n </div>\n {device.failure_count > 0 && (\n <div className=\"text-xs text-warning\">{labels.failures(device.failure_count)}</div>\n )}\n </div>\n <Button\n ref={removeRef}\n type=\"button\"\n size=\"sm\"\n variant=\"ghost\"\n commit={COMMIT_EXCEPT_BILLING}\n disabled={blocked}\n aria-label={labels.removeItem(name)}\n onClick={() => {\n setReturning(false);\n onAsk();\n }}\n >\n {labels.remove}\n </Button>\n </div>\n {types}\n </li>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/notifications/push-worker.ts"],"sourcesContent":["/**\n * The service worker's half of Web Push that the kit can own (0.46,\n * docs/notifications-harmonization.md NT:1028): re-registering a subscription the\n * browser ROTATED. A push service may replace a subscription (expiry, a key rotation, a\n * browser update) and fires `pushsubscriptionchange` in the worker; until 0.46 the\n * server kept sending to the old endpoint, got 410, deleted the device, and the person\n * had to turn push on again without knowing why.\n *\n * {@link handlePushSubscriptionChange} listens for the event in the app's worker and\n * calls the app's `replace` — `POST /notifications/devices/replace` (server-kit\n * `PushReplaceIn`): the OLD endpoint and the old subscription's `auth` secret, which only\n * this browser and the server hold, prove the device without a session (a worker has no\n * `Authorization` header to send), and the server moves that row to the new endpoint in\n * place — same account, same device. Open pages are told with a\n * {@link PUSH_SUBSCRIPTION_CHANGED} message, and every mounted `usePushDevice` re-reads.\n *\n * Dependency-free (no React), so an app's worker bundle takes only this file — through\n * the worker-safe subpath `@eifi1/ui-kit/push-worker` (0.46, §10.6 rule 5), not the main\n * barrel, which reaches React and the components:\n *\n * // sw.ts\n * import { handlePushSubscriptionChange } from \"@eifi1/ui-kit/push-worker\";\n * handlePushSubscriptionChange(self, {\n * pushKey: () => fetch(\"/api/notifications/push-key\").then((r) => r.json()).then((b) => b.key),\n * replace: (body) => fetch(\"/api/notifications/devices/replace\", {\n * method: \"POST\", headers: { \"Content-Type\": \"application/json\" }, body: JSON.stringify(body),\n * }).then((r) => { if (!r.ok) throw new Error(String(r.status)); }),\n * });\n */\n\n/** What `register` sends: `POST /notifications/devices` (§3.5). */\nexport interface PushSubscriptionInput {\n endpoint: string;\n keys: { p256dh: string; auth: string };\n user_agent: string | null;\n}\n\n/** What `replace` sends: `POST /notifications/devices/replace` (server-kit\n * `PushReplaceIn`). The endpoint never goes in a URL. */\nexport interface PushReplaceInput {\n /** The rotated-away subscription's endpoint. */\n old_endpoint: string;\n /** Its `keys.auth` as registered — the proof that this browser held it. */\n old_auth: string;\n /** The browser's new subscription. */\n subscription: PushSubscriptionInput;\n}\n\n/** The message a worker posts to its pages after a rotation, and the window event the\n * hooks re-read on — the same name. */\nexport const PUSH_SUBSCRIPTION_CHANGED = \"eifi1:push-subscription-changed\";\n\n/** A VAPID public key (base64url) as the bytes `PushManager.subscribe` wants. */\nexport function keyBytes(key: string): Uint8Array<ArrayBuffer> {\n const base64 = key.replace(/-/g, \"+\").replace(/_/g, \"/\") + \"=\".repeat((4 - (key.length % 4)) % 4);\n const raw = atob(base64);\n const bytes = new Uint8Array(new ArrayBuffer(raw.length));\n for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);\n return bytes;\n}\n\n/** A subscription's register body, or `null` when the browser handed back an incomplete\n * one. */\nexport function pushSubscriptionInput(subscription: PushSubscription): PushSubscriptionInput | null {\n const json = subscription.toJSON();\n const p256dh = json.keys?.p256dh;\n const auth = json.keys?.auth;\n if (!json.endpoint || !p256dh || !auth) return null;\n return {\n endpoint: json.endpoint,\n keys: { p256dh, auth },\n user_agent: (typeof navigator !== \"undefined\" && navigator.userAgent) || null,\n };\n}\n\n/** The `pushsubscriptionchange` event as the worker receives it (not in TypeScript's\n * DOM lib). Either subscription may be missing: Chrome often hands neither. */\nexport interface PushSubscriptionChangeEventLike {\n readonly oldSubscription?: PushSubscription | null;\n readonly newSubscription?: PushSubscription | null;\n waitUntil?: (promise: Promise<unknown>) => void;\n}\n\nexport interface PushSubscriptionChangeOptions {\n /** `GET /notifications/push-key` → its `key` — asked only when the browser handed no\n * new subscription and the old one does not carry its key. `\"\"`: push is off. */\n pushKey: () => Promise<string>;\n /** `POST /notifications/devices/replace`. A rejection is the outcome `\"failed\"`. */\n replace: (input: PushReplaceInput) => Promise<unknown>;\n}\n\n/**\n * What a rotation came to: `\"replaced\"` — the server moved the device; `\"no_previous\"` —\n * the browser named no old subscription (or one without its keys), so there is nothing\n * to prove the device with and the page shows push as off; `\"push_off\"` — the server has\n * no key; `\"incomplete\"` — the new subscription has no keys; `\"failed\"` — `replace` (or\n * the browser's subscribe) threw.\n */\nexport type PushResubscribeOutcome = \"replaced\" | \"no_previous\" | \"push_off\" | \"incomplete\" | \"failed\";\n\n/**\n * Re-register after `pushsubscriptionchange`: the browser's new subscription — or, when\n * it handed none, the one it holds now, or a fresh one under the old subscription's key\n * (else `pushKey()`) — sent to `replace` with the old endpoint and its `auth`.\n */\nexport async function resubscribeAfterChange(\n event: PushSubscriptionChangeEventLike,\n pushManager: PushManager,\n options: PushSubscriptionChangeOptions,\n): Promise<PushResubscribeOutcome> {\n const old = event.oldSubscription;\n const oldAuth = old?.toJSON().keys?.auth;\n if (!old?.endpoint || !oldAuth) return \"no_previous\";\n try {\n let next = event.newSubscription ?? (await pushManager.getSubscription());\n if (!next || next.endpoint === old.endpoint) {\n const kept = old.options?.applicationServerKey;\n const key = kept ? null : await options.pushKey();\n if (key === \"\") return \"push_off\";\n next = await pushManager.subscribe({\n userVisibleOnly: true,\n applicationServerKey: kept ?? keyBytes(key ?? \"\"),\n });\n }\n const subscription = pushSubscriptionInput(next);\n if (!subscription) return \"incomplete\";\n await options.replace({ old_endpoint: old.endpoint, old_auth: oldAuth, subscription });\n return \"replaced\";\n } catch {\n return \"failed\";\n }\n}\n\n/** The parts of a `ServiceWorkerGlobalScope` this needs. */\nexport interface PushWorkerScope {\n addEventListener: (type: \"pushsubscriptionchange\"
|
|
1
|
+
{"version":3,"sources":["../../src/notifications/push-worker.ts"],"sourcesContent":["/**\n * The service worker's half of Web Push that the kit can own (0.46,\n * docs/notifications-harmonization.md NT:1028): re-registering a subscription the\n * browser ROTATED. A push service may replace a subscription (expiry, a key rotation, a\n * browser update) and fires `pushsubscriptionchange` in the worker; until 0.46 the\n * server kept sending to the old endpoint, got 410, deleted the device, and the person\n * had to turn push on again without knowing why.\n *\n * {@link handlePushSubscriptionChange} listens for the event in the app's worker and\n * calls the app's `replace` — `POST /notifications/devices/replace` (server-kit\n * `PushReplaceIn`): the OLD endpoint and the old subscription's `auth` secret, which only\n * this browser and the server hold, prove the device without a session (a worker has no\n * `Authorization` header to send), and the server moves that row to the new endpoint in\n * place — same account, same device. Open pages are told with a\n * {@link PUSH_SUBSCRIPTION_CHANGED} message, and every mounted `usePushDevice` re-reads.\n *\n * Dependency-free (no React), so an app's worker bundle takes only this file — through\n * the worker-safe subpath `@eifi1/ui-kit/push-worker` (0.46, §10.6 rule 5), not the main\n * barrel, which reaches React and the components:\n *\n * // sw.ts\n * import { handlePushSubscriptionChange } from \"@eifi1/ui-kit/push-worker\";\n * handlePushSubscriptionChange(self, {\n * pushKey: () => fetch(\"/api/notifications/push-key\").then((r) => r.json()).then((b) => b.key),\n * replace: (body) => fetch(\"/api/notifications/devices/replace\", {\n * method: \"POST\", headers: { \"Content-Type\": \"application/json\" }, body: JSON.stringify(body),\n * }).then((r) => { if (!r.ok) throw new Error(String(r.status)); }),\n * });\n */\n\n/** What `register` sends: `POST /notifications/devices` (§3.5). */\nexport interface PushSubscriptionInput {\n endpoint: string;\n keys: { p256dh: string; auth: string };\n user_agent: string | null;\n}\n\n/** What `replace` sends: `POST /notifications/devices/replace` (server-kit\n * `PushReplaceIn`). The endpoint never goes in a URL. */\nexport interface PushReplaceInput {\n /** The rotated-away subscription's endpoint. */\n old_endpoint: string;\n /** Its `keys.auth` as registered — the proof that this browser held it. */\n old_auth: string;\n /** The browser's new subscription. */\n subscription: PushSubscriptionInput;\n}\n\n/** The message a worker posts to its pages after a rotation, and the window event the\n * hooks re-read on — the same name. */\nexport const PUSH_SUBSCRIPTION_CHANGED = \"eifi1:push-subscription-changed\";\n\n/** A VAPID public key (base64url) as the bytes `PushManager.subscribe` wants. */\nexport function keyBytes(key: string): Uint8Array<ArrayBuffer> {\n const base64 = key.replace(/-/g, \"+\").replace(/_/g, \"/\") + \"=\".repeat((4 - (key.length % 4)) % 4);\n const raw = atob(base64);\n const bytes = new Uint8Array(new ArrayBuffer(raw.length));\n for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);\n return bytes;\n}\n\n/** A subscription's register body, or `null` when the browser handed back an incomplete\n * one. */\nexport function pushSubscriptionInput(subscription: PushSubscription): PushSubscriptionInput | null {\n const json = subscription.toJSON();\n const p256dh = json.keys?.p256dh;\n const auth = json.keys?.auth;\n if (!json.endpoint || !p256dh || !auth) return null;\n return {\n endpoint: json.endpoint,\n keys: { p256dh, auth },\n user_agent: (typeof navigator !== \"undefined\" && navigator.userAgent) || null,\n };\n}\n\n/** The `pushsubscriptionchange` event as the worker receives it (not in TypeScript's\n * DOM lib). Either subscription may be missing: Chrome often hands neither. */\nexport interface PushSubscriptionChangeEventLike {\n readonly oldSubscription?: PushSubscription | null;\n readonly newSubscription?: PushSubscription | null;\n waitUntil?: (promise: Promise<unknown>) => void;\n}\n\nexport interface PushSubscriptionChangeOptions {\n /** `GET /notifications/push-key` → its `key` — asked only when the browser handed no\n * new subscription and the old one does not carry its key. `\"\"`: push is off. */\n pushKey: () => Promise<string>;\n /** `POST /notifications/devices/replace`. A rejection is the outcome `\"failed\"`. */\n replace: (input: PushReplaceInput) => Promise<unknown>;\n}\n\n/**\n * What a rotation came to: `\"replaced\"` — the server moved the device; `\"no_previous\"` —\n * the browser named no old subscription (or one without its keys), so there is nothing\n * to prove the device with and the page shows push as off; `\"push_off\"` — the server has\n * no key; `\"incomplete\"` — the new subscription has no keys; `\"failed\"` — `replace` (or\n * the browser's subscribe) threw.\n */\nexport type PushResubscribeOutcome = \"replaced\" | \"no_previous\" | \"push_off\" | \"incomplete\" | \"failed\";\n\n/**\n * Re-register after `pushsubscriptionchange`: the browser's new subscription — or, when\n * it handed none, the one it holds now, or a fresh one under the old subscription's key\n * (else `pushKey()`) — sent to `replace` with the old endpoint and its `auth`.\n */\nexport async function resubscribeAfterChange(\n event: PushSubscriptionChangeEventLike,\n pushManager: PushManager,\n options: PushSubscriptionChangeOptions,\n): Promise<PushResubscribeOutcome> {\n const old = event.oldSubscription;\n const oldAuth = old?.toJSON().keys?.auth;\n if (!old?.endpoint || !oldAuth) return \"no_previous\";\n try {\n let next = event.newSubscription ?? (await pushManager.getSubscription());\n if (!next || next.endpoint === old.endpoint) {\n const kept = old.options?.applicationServerKey;\n const key = kept ? null : await options.pushKey();\n if (key === \"\") return \"push_off\";\n next = await pushManager.subscribe({\n userVisibleOnly: true,\n applicationServerKey: kept ?? keyBytes(key ?? \"\"),\n });\n }\n const subscription = pushSubscriptionInput(next);\n if (!subscription) return \"incomplete\";\n await options.replace({ old_endpoint: old.endpoint, old_auth: oldAuth, subscription });\n return \"replaced\";\n } catch {\n return \"failed\";\n }\n}\n\n/** The parts of a `ServiceWorkerGlobalScope` this needs. */\nexport interface PushWorkerScope {\n addEventListener: (\n type: \"pushsubscriptionchange\",\n listener: (event: PushSubscriptionChangeEventLike) => void,\n ) => void;\n registration: { pushManager: PushManager };\n clients?: {\n matchAll: (options?: {\n type?: \"window\";\n includeUncontrolled?: boolean;\n }) => Promise<readonly { postMessage: (message: unknown) => void }[]>;\n };\n}\n\n/**\n * Listen for `pushsubscriptionchange` in the app's service worker (`self`) and\n * re-register through {@link resubscribeAfterChange}, holding the worker alive until it\n * is done (`waitUntil`). Then every open page gets `{ type: PUSH_SUBSCRIPTION_CHANGED,\n * outcome }`; `usePushDevice` re-reads on it. `onOutcome` is for the app's own logging.\n */\nexport function handlePushSubscriptionChange(\n scope: PushWorkerScope,\n options: PushSubscriptionChangeOptions & { onOutcome?: (outcome: PushResubscribeOutcome) => void },\n): void {\n scope.addEventListener(\"pushsubscriptionchange\", (event) => {\n const done = resubscribeAfterChange(event, scope.registration.pushManager, options).then(async (outcome) => {\n options.onOutcome?.(outcome);\n const pages = (await scope.clients?.matchAll({ type: \"window\", includeUncontrolled: true })) ?? [];\n for (const page of pages) page.postMessage({ type: PUSH_SUBSCRIPTION_CHANGED, outcome });\n return outcome;\n });\n event.waitUntil?.(done);\n });\n}\n"],"mappings":";AAkDO,MAAM,4BAA4B;AAGlC,SAAS,SAAS,KAAsC;AAC7D,QAAM,SAAS,IAAI,QAAQ,MAAM,GAAG,EAAE,QAAQ,MAAM,GAAG,IAAI,IAAI,QAAQ,IAAK,IAAI,SAAS,KAAM,CAAC;AAChG,QAAM,MAAM,KAAK,MAAM;AACvB,QAAM,QAAQ,IAAI,WAAW,IAAI,YAAY,IAAI,MAAM,CAAC;AACxD,WAAS,IAAI,GAAG,IAAI,IAAI,QAAQ,IAAK,OAAM,CAAC,IAAI,IAAI,WAAW,CAAC;AAChE,SAAO;AACT;AAIO,SAAS,sBAAsB,cAA8D;AAClG,QAAM,OAAO,aAAa,OAAO;AACjC,QAAM,SAAS,KAAK,MAAM;AAC1B,QAAM,OAAO,KAAK,MAAM;AACxB,MAAI,CAAC,KAAK,YAAY,CAAC,UAAU,CAAC,KAAM,QAAO;AAC/C,SAAO;AAAA,IACL,UAAU,KAAK;AAAA,IACf,MAAM,EAAE,QAAQ,KAAK;AAAA,IACrB,YAAa,OAAO,cAAc,eAAe,UAAU,aAAc;AAAA,EAC3E;AACF;AAgCA,eAAsB,uBACpB,OACA,aACA,SACiC;AACjC,QAAM,MAAM,MAAM;AAClB,QAAM,UAAU,KAAK,OAAO,EAAE,MAAM;AACpC,MAAI,CAAC,KAAK,YAAY,CAAC,QAAS,QAAO;AACvC,MAAI;AACF,QAAI,OAAO,MAAM,mBAAoB,MAAM,YAAY,gBAAgB;AACvE,QAAI,CAAC,QAAQ,KAAK,aAAa,IAAI,UAAU;AAC3C,YAAM,OAAO,IAAI,SAAS;AAC1B,YAAM,MAAM,OAAO,OAAO,MAAM,QAAQ,QAAQ;AAChD,UAAI,QAAQ,GAAI,QAAO;AACvB,aAAO,MAAM,YAAY,UAAU;AAAA,QACjC,iBAAiB;AAAA,QACjB,sBAAsB,QAAQ,SAAS,OAAO,EAAE;AAAA,MAClD,CAAC;AAAA,IACH;AACA,UAAM,eAAe,sBAAsB,IAAI;AAC/C,QAAI,CAAC,aAAc,QAAO;AAC1B,UAAM,QAAQ,QAAQ,EAAE,cAAc,IAAI,UAAU,UAAU,SAAS,aAAa,CAAC;AACrF,WAAO;AAAA,EACT,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAuBO,SAAS,6BACd,OACA,SACM;AACN,QAAM,iBAAiB,0BAA0B,CAAC,UAAU;AAC1D,UAAM,OAAO,uBAAuB,OAAO,MAAM,aAAa,aAAa,OAAO,EAAE,KAAK,OAAO,YAAY;AAC1G,cAAQ,YAAY,OAAO;AAC3B,YAAM,QAAS,MAAM,MAAM,SAAS,SAAS,EAAE,MAAM,UAAU,qBAAqB,KAAK,CAAC,KAAM,CAAC;AACjG,iBAAW,QAAQ,MAAO,MAAK,YAAY,EAAE,MAAM,2BAA2B,QAAQ,CAAC;AACvF,aAAO;AAAA,IACT,CAAC;AACD,UAAM,YAAY,IAAI;AAAA,EACxB,CAAC;AACH;","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/notifications/quiet-hours-setting.tsx"],"sourcesContent":["import { useId, useState } from \"react\";\nimport type { ComponentPropsWithRef, ReactNode } from \"react\";\n\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport { Card } from \"../components/card\";\nimport { TimeInput } from \"../components/time-input\";\nimport { COMMIT_EXCEPT_BILLING } from \"../components/write-lock\";\nimport { CARD_DESCRIPTION_CLASS } from \"../settings/settings-card-classes\";\nimport { SettingsCardTitle } from \"../settings/settings-heading\";\nimport { settle, useMounted } from \"../account/account-parts\";\nimport { describeFailure, FailureLine, useNotificationsLabels, useWriteOverlay } from \"./notification-parts\";\nimport type { NotificationRegionLabels, NotificationsLabels } from \"./notifications-labels\";\nimport type { NotificationSettingsPatch } from \"./notification-types\";\n\n/** The zone the server applies while no device ever reported one (§10.3): every app's. */\nconst DEFAULT_FALLBACK_ZONE = \"Europe/Zurich\";\n\n/** A zone for a sentence: \"Europe / Zurich\", the region in the kit's words (`labels.regions`);\n * a region the labels do not know keeps its IANA name, a zone with none (\"UTC\") is itself. */\nfunction zoneName(zone: string, regions: NotificationRegionLabels): string {\n const slash = zone.indexOf(\"/\");\n if (slash < 0) return zone;\n const region = zone.slice(0, slash);\n const named = Object.prototype.hasOwnProperty.call(regions, region)\n ? regions[region as keyof NotificationRegionLabels]\n : region;\n return `${named} / ${zone.slice(slash + 1).replace(/_/g, \" \").replace(/\\//g, \" / \")}`;\n}\n\nexport interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\" | \"onChange\"> {\n /** The `GET`'s `quiet_hours_start`, `\"HH:MM\"`, or `null`. */\n start: string | null;\n /** The `GET`'s `quiet_hours_end`. */\n end: string | null;\n /**\n * 0.46 (§10.3, amended 2026-10-20: automatic only): the account's zone — `/auth/me`'s\n * `device_timezone`, the zone of the device last used; `null` while no device has\n * reported one, and the card then names `fallbackZone`, the zone the server applies.\n * Shown read-only once a time is set (\"In your time zone (Europe / Zurich), which follows\n * the device you last used.\"). No picker: the zone is never chosen.\n */\n zone?: string | null;\n /** 0.46 (§10.4): the app queues a held push and sends it when the window ends (it runs\n * the notifications tick); the description says so. Default `false`: dropped. */\n held?: boolean;\n /**\n * Save one change — `PATCH /notifications/settings`: a time, or `null` when cleared.\n * Never a zone. The field shows the new value while the call runs and goes back if it\n * rejects.\n */\n onChange: (patch: NotificationSettingsPatch) => Promise<unknown> | void;\n /** The zone named while `zone` names none: the one the server\n * applies. Default `\"Europe/Zurich\"`, every app's. */\n fallbackZone?: string;\n /** The app keeps an inbox, and the description says a held push still reaches it. */\n inbox?: boolean;\n disabled?: boolean;\n /** The app's own words for a failed save, or `undefined` for the kit's `failed`. */\n describeError?: (error: unknown) => ReactNode | undefined;\n labels?: LabelOverride<NotificationsLabels>;\n}\n\ntype QuietField = \"quiet_hours_start\" | \"quiet_hours_end\";\n\n/**\n * Quiet hours (docs/notifications-harmonization.md §2.4, §2.6, §4.2): a window of local\n * times in the account's zone, which may cross midnight, when no push of a machine-sent\n * type is sent. A held push is dropped — or, with `held` (0.46, §10.4), sent when the\n * window ends; the inbox row and the mail go as usual. keksdose's block, as a card of its\n * own.\n *\n * **0.46 (§10.3, amended 2026-10-20, Marcel): the zone is the account's, automatic\n * only** — the zone of the device last used, else `Europe/Zurich`. The card names it\n * read-only and never sends one; there is no picker and no \"Use it\".\n *\n * - **Two times**, each saved as it is entered; a cleared time sends `null`. While only\n * one is set the card says so: the window fails open until both are.\n * - **The zone** shows while a time is set: `zone`, else `fallbackZone`.\n *\n * Under a write lock the fields are `commit` controls exempt from billing.\n */\nexport function QuietHoursSetting({\n start,\n end,\n zone,\n held = false,\n onChange,\n fallbackZone = DEFAULT_FALLBACK_ZONE,\n inbox = false,\n disabled = false,\n describeError,\n labels: labelsProp,\n className,\n ...rest\n}: QuietHoursSettingProps) {\n const labels = useNotificationsLabels(labelsProp);\n const overlay = useWriteOverlay<string | null>(`${start}|${end}`);\n const [failure, setFailure] = useState<ReactNode>(null);\n const mounted = useMounted();\n const id = useId();\n\n const shown = (field: QuietField, server: string | null) =>\n overlay.has(field) ? (overlay.get(field) ?? null) : server;\n const startShown = shown(\"quiet_hours_start\", start);\n const endShown = shown(\"quiet_hours_end\", end);\n const accountZone = zone ?? fallbackZone;\n\n const save = (patch: NotificationSettingsPatch) => {\n const fields = Object.keys(patch) as QuietField[];\n setFailure(null);\n for (const field of fields) overlay.set(field, patch[field] ?? null);\n settle(() => onChange(patch)).then(\n () => {\n if (mounted.current) for (const field of fields) overlay.settle(field, true);\n },\n (error: unknown) => {\n if (!mounted.current) return;\n for (const field of fields) overlay.settle(field, false);\n setFailure(describeFailure(describeError?.(error), labels.failed));\n },\n );\n };\n\n const saveTime = (field: \"quiet_hours_start\" | \"quiet_hours_end\", value: string) => {\n const next = value || null;\n if (next === shown(field, field === \"quiet_hours_start\" ? start : end)) return;\n save({ [field]: next });\n };\n\n const anySet = Boolean(startShown || endShown);\n const halfSet = Boolean(startShown) !== Boolean(endShown);\n\n return (\n <Card {...rest} className={cn(\"p-4 space-y-3\", className)}>\n <div>\n <SettingsCardTitle>{labels.quietTitle}</SettingsCardTitle>\n <div className={CARD_DESCRIPTION_CLASS}>{labels.quietDescription(inbox, held)}</div>\n </div>\n <div className=\"flex flex-wrap items-end gap-3\">\n <TimeInput\n id={`${id}-start`}\n label={labels.quietFrom}\n value={startShown ?? \"\"}\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n onValueChange={(value) => saveTime(\"quiet_hours_start\", value)}\n className=\"w-36\"\n />\n <TimeInput\n id={`${id}-end`}\n label={labels.quietUntil}\n value={endShown ?? \"\"}\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n onValueChange={(value) => saveTime(\"quiet_hours_end\", value)}\n className=\"w-36\"\n />\n </div>\n {halfSet && (\n <p className=\"text-xs text-warning\" data-quiet-incomplete=\"\">\n {labels.quietIncomplete}\n </p>\n )}\n {anySet && (\n <p className={CARD_DESCRIPTION_CLASS} data-quiet-zone=\"\">\n {labels.quietZone(zoneName(accountZone, labels.regions))}\n </p>\n )}\n <FailureLine>{failure}</FailureLine>\n </Card>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/notifications/quiet-hours-setting.tsx"],"sourcesContent":["import { useId, useState } from \"react\";\nimport type { ComponentPropsWithRef, ReactNode } from \"react\";\n\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { cn } from \"../lib/cn\";\nimport { Card } from \"../components/card\";\nimport { TimeInput } from \"../components/time-input\";\nimport { COMMIT_EXCEPT_BILLING } from \"../components/write-lock\";\nimport { CARD_DESCRIPTION_CLASS } from \"../settings/settings-card-classes\";\nimport { SettingsCardTitle } from \"../settings/settings-heading\";\nimport { settle, useMounted } from \"../account/account-parts\";\nimport { describeFailure, FailureLine, useNotificationsLabels, useWriteOverlay } from \"./notification-parts\";\nimport type { NotificationRegionLabels, NotificationsLabels } from \"./notifications-labels\";\nimport type { NotificationSettingsPatch } from \"./notification-types\";\n\n/** The zone the server applies while no device ever reported one (§10.3): every app's. */\nconst DEFAULT_FALLBACK_ZONE = \"Europe/Zurich\";\n\n/** A zone for a sentence: \"Europe / Zurich\", the region in the kit's words (`labels.regions`);\n * a region the labels do not know keeps its IANA name, a zone with none (\"UTC\") is itself. */\nfunction zoneName(zone: string, regions: NotificationRegionLabels): string {\n const slash = zone.indexOf(\"/\");\n if (slash < 0) return zone;\n const region = zone.slice(0, slash);\n const named = Object.prototype.hasOwnProperty.call(regions, region)\n ? regions[region as keyof NotificationRegionLabels]\n : region;\n return `${named} / ${zone\n .slice(slash + 1)\n .replace(/_/g, \" \")\n .replace(/\\//g, \" / \")}`;\n}\n\nexport interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\" | \"onChange\"> {\n /** The `GET`'s `quiet_hours_start`, `\"HH:MM\"`, or `null`. */\n start: string | null;\n /** The `GET`'s `quiet_hours_end`. */\n end: string | null;\n /**\n * 0.46 (§10.3, amended 2026-10-20: automatic only): the account's zone — `/auth/me`'s\n * `device_timezone`, the zone of the device last used; `null` while no device has\n * reported one, and the card then names `fallbackZone`, the zone the server applies.\n * Shown read-only once a time is set (\"In your time zone (Europe / Zurich), which follows\n * the device you last used.\"). No picker: the zone is never chosen.\n */\n zone?: string | null;\n /** 0.46 (§10.4): the app queues a held push and sends it when the window ends (it runs\n * the notifications tick); the description says so. Default `false`: dropped. */\n held?: boolean;\n /**\n * Save one change — `PATCH /notifications/settings`: a time, or `null` when cleared.\n * Never a zone. The field shows the new value while the call runs and goes back if it\n * rejects.\n */\n onChange: (patch: NotificationSettingsPatch) => Promise<unknown> | void;\n /** The zone named while `zone` names none: the one the server\n * applies. Default `\"Europe/Zurich\"`, every app's. */\n fallbackZone?: string;\n /** The app keeps an inbox, and the description says a held push still reaches it. */\n inbox?: boolean;\n disabled?: boolean;\n /** The app's own words for a failed save, or `undefined` for the kit's `failed`. */\n describeError?: (error: unknown) => ReactNode | undefined;\n labels?: LabelOverride<NotificationsLabels>;\n}\n\ntype QuietField = \"quiet_hours_start\" | \"quiet_hours_end\";\n\n/**\n * Quiet hours (docs/notifications-harmonization.md §2.4, §2.6, §4.2): a window of local\n * times in the account's zone, which may cross midnight, when no push of a machine-sent\n * type is sent. A held push is dropped — or, with `held` (0.46, §10.4), sent when the\n * window ends; the inbox row and the mail go as usual. keksdose's block, as a card of its\n * own.\n *\n * **0.46 (§10.3, amended 2026-10-20, Marcel): the zone is the account's, automatic\n * only** — the zone of the device last used, else `Europe/Zurich`. The card names it\n * read-only and never sends one; there is no picker and no \"Use it\".\n *\n * - **Two times**, each saved as it is entered; a cleared time sends `null`. While only\n * one is set the card says so: the window fails open until both are.\n * - **The zone** shows while a time is set: `zone`, else `fallbackZone`.\n *\n * Under a write lock the fields are `commit` controls exempt from billing.\n */\nexport function QuietHoursSetting({\n start,\n end,\n zone,\n held = false,\n onChange,\n fallbackZone = DEFAULT_FALLBACK_ZONE,\n inbox = false,\n disabled = false,\n describeError,\n labels: labelsProp,\n className,\n ...rest\n}: QuietHoursSettingProps) {\n const labels = useNotificationsLabels(labelsProp);\n const overlay = useWriteOverlay<string | null>(`${start}|${end}`);\n const [failure, setFailure] = useState<ReactNode>(null);\n const mounted = useMounted();\n const id = useId();\n\n const shown = (field: QuietField, server: string | null) =>\n overlay.has(field) ? (overlay.get(field) ?? null) : server;\n const startShown = shown(\"quiet_hours_start\", start);\n const endShown = shown(\"quiet_hours_end\", end);\n const accountZone = zone ?? fallbackZone;\n\n const save = (patch: NotificationSettingsPatch) => {\n const fields = Object.keys(patch) as QuietField[];\n setFailure(null);\n for (const field of fields) overlay.set(field, patch[field] ?? null);\n settle(() => onChange(patch)).then(\n () => {\n if (mounted.current) for (const field of fields) overlay.settle(field, true);\n },\n (error: unknown) => {\n if (!mounted.current) return;\n for (const field of fields) overlay.settle(field, false);\n setFailure(describeFailure(describeError?.(error), labels.failed));\n },\n );\n };\n\n const saveTime = (field: \"quiet_hours_start\" | \"quiet_hours_end\", value: string) => {\n const next = value || null;\n if (next === shown(field, field === \"quiet_hours_start\" ? start : end)) return;\n save({ [field]: next });\n };\n\n const anySet = Boolean(startShown || endShown);\n const halfSet = Boolean(startShown) !== Boolean(endShown);\n\n return (\n <Card {...rest} className={cn(\"p-4 space-y-3\", className)}>\n <div>\n <SettingsCardTitle>{labels.quietTitle}</SettingsCardTitle>\n <div className={CARD_DESCRIPTION_CLASS}>{labels.quietDescription(inbox, held)}</div>\n </div>\n <div className=\"flex flex-wrap items-end gap-3\">\n <TimeInput\n id={`${id}-start`}\n label={labels.quietFrom}\n value={startShown ?? \"\"}\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n onValueChange={(value) => saveTime(\"quiet_hours_start\", value)}\n className=\"w-36\"\n />\n <TimeInput\n id={`${id}-end`}\n label={labels.quietUntil}\n value={endShown ?? \"\"}\n disabled={disabled}\n commit={COMMIT_EXCEPT_BILLING}\n onValueChange={(value) => saveTime(\"quiet_hours_end\", value)}\n className=\"w-36\"\n />\n </div>\n {halfSet && (\n <p className=\"text-xs text-warning\" data-quiet-incomplete=\"\">\n {labels.quietIncomplete}\n </p>\n )}\n {anySet && (\n <p className={CARD_DESCRIPTION_CLASS} data-quiet-zone=\"\">\n {labels.quietZone(zoneName(accountZone, labels.regions))}\n </p>\n )}\n <FailureLine>{failure}</FailureLine>\n </Card>\n );\n}\n"],"mappings":";AA0IM,SACE,KADF;AA1IN,SAAS,OAAO,gBAAgB;AAIhC,SAAS,UAAU;AACnB,SAAS,YAAY;AACrB,SAAS,iBAAiB;AAC1B,SAAS,6BAA6B;AACtC,SAAS,8BAA8B;AACvC,SAAS,yBAAyB;AAClC,SAAS,QAAQ,kBAAkB;AACnC,SAAS,iBAAiB,aAAa,wBAAwB,uBAAuB;AAKtF,MAAM,wBAAwB;AAI9B,SAAS,SAAS,MAAc,SAA2C;AACzE,QAAM,QAAQ,KAAK,QAAQ,GAAG;AAC9B,MAAI,QAAQ,EAAG,QAAO;AACtB,QAAM,SAAS,KAAK,MAAM,GAAG,KAAK;AAClC,QAAM,QAAQ,OAAO,UAAU,eAAe,KAAK,SAAS,MAAM,IAC9D,QAAQ,MAAwC,IAChD;AACJ,SAAO,GAAG,KAAK,MAAM,KAClB,MAAM,QAAQ,CAAC,EACf,QAAQ,MAAM,GAAG,EACjB,QAAQ,OAAO,KAAK,CAAC;AAC1B;AAsDO,SAAS,kBAAkB;AAAA,EAChC;AAAA,EACA;AAAA,EACA;AAAA,EACA,OAAO;AAAA,EACP;AAAA,EACA,eAAe;AAAA,EACf,QAAQ;AAAA,EACR,WAAW;AAAA,EACX;AAAA,EACA,QAAQ;AAAA,EACR;AAAA,EACA,GAAG;AACL,GAA2B;AACzB,QAAM,SAAS,uBAAuB,UAAU;AAChD,QAAM,UAAU,gBAA+B,GAAG,KAAK,IAAI,GAAG,EAAE;AAChE,QAAM,CAAC,SAAS,UAAU,IAAI,SAAoB,IAAI;AACtD,QAAM,UAAU,WAAW;AAC3B,QAAM,KAAK,MAAM;AAEjB,QAAM,QAAQ,CAAC,OAAmB,WAChC,QAAQ,IAAI,KAAK,IAAK,QAAQ,IAAI,KAAK,KAAK,OAAQ;AACtD,QAAM,aAAa,MAAM,qBAAqB,KAAK;AACnD,QAAM,WAAW,MAAM,mBAAmB,GAAG;AAC7C,QAAM,cAAc,QAAQ;AAE5B,QAAM,OAAO,CAAC,UAAqC;AACjD,UAAM,SAAS,OAAO,KAAK,KAAK;AAChC,eAAW,IAAI;AACf,eAAW,SAAS,OAAQ,SAAQ,IAAI,OAAO,MAAM,KAAK,KAAK,IAAI;AACnE,WAAO,MAAM,SAAS,KAAK,CAAC,EAAE;AAAA,MAC5B,MAAM;AACJ,YAAI,QAAQ,QAAS,YAAW,SAAS,OAAQ,SAAQ,OAAO,OAAO,IAAI;AAAA,MAC7E;AAAA,MACA,CAAC,UAAmB;AAClB,YAAI,CAAC,QAAQ,QAAS;AACtB,mBAAW,SAAS,OAAQ,SAAQ,OAAO,OAAO,KAAK;AACvD,mBAAW,gBAAgB,gBAAgB,KAAK,GAAG,OAAO,MAAM,CAAC;AAAA,MACnE;AAAA,IACF;AAAA,EACF;AAEA,QAAM,WAAW,CAAC,OAAgD,UAAkB;AAClF,UAAM,OAAO,SAAS;AACtB,QAAI,SAAS,MAAM,OAAO,UAAU,sBAAsB,QAAQ,GAAG,EAAG;AACxE,SAAK,EAAE,CAAC,KAAK,GAAG,KAAK,CAAC;AAAA,EACxB;AAEA,QAAM,SAAS,QAAQ,cAAc,QAAQ;AAC7C,QAAM,UAAU,QAAQ,UAAU,MAAM,QAAQ,QAAQ;AAExD,SACE,qBAAC,QAAM,GAAG,MAAM,WAAW,GAAG,iBAAiB,SAAS,GACtD;AAAA,yBAAC,SACC;AAAA,0BAAC,qBAAmB,iBAAO,YAAW;AAAA,MACtC,oBAAC,SAAI,WAAW,wBAAyB,iBAAO,iBAAiB,OAAO,IAAI,GAAE;AAAA,OAChF;AAAA,IACA,qBAAC,SAAI,WAAU,kCACb;AAAA;AAAA,QAAC;AAAA;AAAA,UACC,IAAI,GAAG,EAAE;AAAA,UACT,OAAO,OAAO;AAAA,UACd,OAAO,cAAc;AAAA,UACrB;AAAA,UACA,QAAQ;AAAA,UACR,eAAe,CAAC,UAAU,SAAS,qBAAqB,KAAK;AAAA,UAC7D,WAAU;AAAA;AAAA,MACZ;AAAA,MACA;AAAA,QAAC;AAAA;AAAA,UACC,IAAI,GAAG,EAAE;AAAA,UACT,OAAO,OAAO;AAAA,UACd,OAAO,YAAY;AAAA,UACnB;AAAA,UACA,QAAQ;AAAA,UACR,eAAe,CAAC,UAAU,SAAS,mBAAmB,KAAK;AAAA,UAC3D,WAAU;AAAA;AAAA,MACZ;AAAA,OACF;AAAA,IACC,WACC,oBAAC,OAAE,WAAU,wBAAuB,yBAAsB,IACvD,iBAAO,iBACV;AAAA,IAED,UACC,oBAAC,OAAE,WAAW,wBAAwB,mBAAgB,IACnD,iBAAO,UAAU,SAAS,aAAa,OAAO,OAAO,CAAC,GACzD;AAAA,IAEF,oBAAC,eAAa,mBAAQ;AAAA,KACxB;AAEJ;","names":[]}
|
|
@@ -148,7 +148,10 @@ function usePushDevice(options) {
|
|
|
148
148
|
if (mounted.current) setConfigured(true);
|
|
149
149
|
const registration = await activeRegistration();
|
|
150
150
|
const subscribe = (reuse) => within(
|
|
151
|
-
(async () => (reuse ? await registration.pushManager.getSubscription() : null) ?? await registration.pushManager.subscribe({
|
|
151
|
+
(async () => (reuse ? await registration.pushManager.getSubscription() : null) ?? await registration.pushManager.subscribe({
|
|
152
|
+
userVisibleOnly: true,
|
|
153
|
+
applicationServerKey: keyBytes(key)
|
|
154
|
+
}))(),
|
|
152
155
|
PUSH_TIMEOUT_MS,
|
|
153
156
|
"push_timeout"
|
|
154
157
|
);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/notifications/use-push-device.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport { useMediaQuery } from \"../hooks/use-media-query\";\nimport { httpStatusOf } from \"../auth/auth-errors\";\nimport { PUSH_SUBSCRIPTION_CHANGED, keyBytes, pushSubscriptionInput as inputOf } from \"./push-worker\";\nimport type { PushSubscriptionInput } from \"./push-worker\";\n\nexport { PUSH_SUBSCRIPTION_CHANGED };\nexport type { PushSubscriptionInput };\n\n/**\n * This browser's Web Push subscription for the signed-in account\n * (docs/notifications-harmonization.md §2.7, §3.5, §4.2) — keksdose's\n * `shared/push/use-push.ts`, lifted. The service worker with its `push` and\n * `notificationclick` handlers stays the app's; this hook owns the browser's side:\n * permission, subscribe with the server's VAPID key, register the subscription, and\n * unsubscribe. The kit makes no request of its own: the app hands it one function per\n * endpoint.\n */\n\n/** The browser's notification permission, or `\"unsupported\"` where it has no push. */\nexport type PushPermission = NotificationPermission | \"unsupported\";\n\n/** Why turning push on or off could not finish — a {@link PushDeviceError}'s `code`. */\nexport type PushDeviceErrorCode = \"no_service_worker\" | \"push_timeout\";\n\n/**\n * The two failures the card has words of its own for: no service worker registered (or\n * one not ready in 10 s) — \"reload and try again\" — and a browser push service that did\n * not answer in 15 s — \"check your connection\". Anything else is the app's request\n * failing, rejected as it came.\n */\nexport class PushDeviceError extends Error {\n readonly code: PushDeviceErrorCode;\n constructor(code: PushDeviceErrorCode) {\n super(code);\n this.name = \"PushDeviceError\";\n this.code = code;\n }\n}\n\nexport interface UsePushDeviceOptions {\n /** `GET /notifications/push-key` → its `key`; `\"\"` when push is off on the server. */\n pushKey: () => Promise<string>;\n /** `POST /notifications/devices`. A 409 (another account's endpoint) is recovered\n * once: the hook unsubscribes and registers a fresh endpoint. */\n register: (subscription: PushSubscriptionInput) => Promise<unknown>;\n /** `POST /notifications/devices/unregister {endpoint}` — the endpoint never goes in a\n * URL. */\n unregister: (endpoint: string) => Promise<unknown>;\n /** `GET /notifications/devices`: the account's own list, which says whether THIS\n * account holds this browser's subscription. */\n devices: () => Promise<readonly { endpoint: string }[]>;\n /** `POST /notifications/test` → how many devices the push service accepted it for. */\n test: () => Promise<{ sent: number }>;\n /** Whether `register` refused another account's endpoint. Default: HTTP 409. */\n isEndpointTaken?: (error: unknown) => boolean;\n /** After an enable or a disable: the app refreshes its device queries. */\n onChange?: () => void;\n}\n\n/** What {@link usePushDevice} answers. */\nexport interface PushDevice {\n /** The browser exposes service workers, `PushManager` and `Notification`. */\n supported: boolean;\n /** The server has VAPID keys; `undefined` until `pushKey` answered (or when it failed). */\n configured: boolean | undefined;\n /** Running as an installed app: `display-mode: standalone`, or iOS's\n * `navigator.standalone`. */\n installed: boolean;\n /** iPhone or iPad — where push reaches only the installed app. */\n ios: boolean;\n permission: PushPermission;\n /** THIS account holds this browser's subscription. An unreachable device list falls\n * back to the browser's own answer. */\n subscribed: boolean;\n /** This browser holds a subscription that belongs to another account (two accounts in\n * one browser profile): `subscribed` is false, and turning on mints a fresh one. */\n foreignSubscription: boolean;\n /** This browser's subscription endpoint, or `null` — marks \"This device\" in a list. */\n currentEndpoint: string | null;\n /** An enable or a disable is running. */\n busy: boolean;\n /** Ask for permission (the browser's one prompt), subscribe and register. `false` when\n * the permission was not granted or push is off on the server; rejects with a\n * {@link PushDeviceError} or the app's request's error. */\n enable: () => Promise<boolean>;\n /** Tell the server first, then the browser, so a dropped device stops at once. */\n disable: () => Promise<void>;\n sendTest: () => Promise<{ sent: number }>;\n}\n\n/*\n * PUSH_SUBSCRIPTION_CHANGED (push-worker.ts): the window event every mounted\n * {@link usePushDevice} re-reads its state on. Fired by every enable and disable, and by\n * `PushDevicesSetting` when it removes this device — so an app's own card on the same\n * hook (keksdose's check-up) stays in step. The hook that fires it does not re-read: it\n * knows what it changed. Since 0.46 also the message a service worker posts after a\n * rotated subscription was re-registered (`handlePushSubscriptionChange`). Namespaced\n * for the kit; keksdose's was `\"keksdose:push-subscription-changed\"`.\n */\n\n/** Tell every {@link usePushDevice} that this browser's subscription changed. */\nexport function notifyPushSubscriptionChanged(): void {\n if (typeof window !== \"undefined\") window.dispatchEvent(new Event(PUSH_SUBSCRIPTION_CHANGED));\n}\n\n/** Whether this browser has the APIs push needs. iOS exposes them only to an INSTALLED\n * app (Add to Home Screen). */\nexport function pushSupported(): boolean {\n return (\n typeof window !== \"undefined\" &&\n typeof navigator !== \"undefined\" &&\n \"serviceWorker\" in navigator &&\n \"PushManager\" in window &&\n \"Notification\" in window\n );\n}\n\n/** iPhone or iPad. iPadOS 13+ reports itself as a Mac; the touch points tell it apart. */\nfunction isIos(): boolean {\n if (typeof navigator === \"undefined\") return false;\n const ua = navigator.userAgent;\n return /iphone|ipad|ipod/i.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);\n}\n\nconst READY_TIMEOUT_MS = 10_000;\nconst PUSH_TIMEOUT_MS = 15_000;\n\n/** `promise`, or a {@link PushDeviceError} after `ms`. The timer is cleared either way. */\nfunction within<T>(promise: Promise<T>, ms: number, code: PushDeviceErrorCode): Promise<T> {\n let timer: ReturnType<typeof setTimeout> | undefined;\n return Promise.race([\n promise,\n new Promise<never>((_, reject) => {\n timer = setTimeout(() => reject(new PushDeviceError(code)), ms);\n }),\n ]).finally(() => clearTimeout(timer));\n}\n\n/**\n * The active registration. `navigator.serviceWorker.ready` never settles while the origin\n * has no registration, so a missing one fails at once, and one stuck installing fails\n * after 10 s (keksdose: enable() hung with the button latched before its worker was\n * served).\n */\nasync function activeRegistration(): Promise<ServiceWorkerRegistration> {\n const existing = await navigator.serviceWorker.getRegistration();\n if (!existing) throw new PushDeviceError(\"no_service_worker\");\n return within(navigator.serviceWorker.ready, READY_TIMEOUT_MS, \"no_service_worker\");\n}\n\nconst defaultEndpointTaken = (error: unknown) => httpStatusOf(error) === 409;\n\n/**\n * This browser's push for the signed-in account. Hand it one function per endpoint\n * (§3.8); {@link PushDeviceSetting} draws it, and an app's own card may use it too.\n *\n * **\"Subscribed\" means this account**, not \"this browser holds a subscription\": a\n * browser profile's subscription has no account in it, so a second account in the same\n * profile would read the first's as its own (keksdose). The endpoint is checked against\n * the account's own `devices()`; when that list cannot be read (offline), the browser's\n * answer stands — a wrong \"on\" costs what it did before the check, a wrong \"off\" invites\n * re-subscribing a device that is fine.\n *\n * **A 409 from `register`** — the endpoint belongs to another account, and the server\n * will not re-home what it cannot prove this browser holds — unsubscribes and registers\n * a fresh endpoint, once.\n *\n * **Timeouts:** no registration fails at once and a worker not ready in 10 s fails, both\n * `PushDeviceError(\"no_service_worker\")`; the browser's subscribe and unsubscribe are cut\n * at 15 s, `PushDeviceError(\"push_timeout\")` — so `busy` can never latch.\n *\n * Every mounted hook re-reads its state on {@link PUSH_SUBSCRIPTION_CHANGED}.\n */\nexport function usePushDevice(options: UsePushDeviceOptions): PushDevice {\n // The app's functions are usually new on every render; the effects read the latest.\n const latest = useRef(options);\n useEffect(() => {\n latest.current = options;\n });\n\n const supported = pushSupported();\n const ios = isIos();\n const standalone = useMediaQuery(\"(display-mode: standalone)\", false);\n const installed =\n standalone ||\n (typeof navigator !== \"undefined\" && (navigator as Navigator & { standalone?: boolean }).standalone === true);\n\n const [permission, setPermission] = useState<PushPermission>(() =>\n supported ? Notification.permission : \"unsupported\",\n );\n const [configured, setConfigured] = useState<boolean | undefined>(undefined);\n const [subscribed, setSubscribed] = useState(false);\n const [foreignSubscription, setForeignSubscription] = useState(false);\n const [currentEndpoint, setCurrentEndpoint] = useState<string | null>(null);\n const [busy, setBusy] = useState(false);\n const mounted = useRef(true);\n // Set while this hook fires the event itself: it already knows what changed, and a\n // re-read could only be worse — an app whose `devices()` answers from a cache would\n // hand back the list from before the change.\n const announcing = useRef(false);\n const announce = useCallback(() => {\n announcing.current = true;\n try {\n notifyPushSubscriptionChanged();\n } finally {\n announcing.current = false;\n }\n latest.current.onChange?.();\n }, []);\n useEffect(() => {\n mounted.current = true;\n return () => {\n mounted.current = false;\n };\n }, []);\n\n // Whether the server has keys (\"\" → push is off there). Once per mount.\n useEffect(() => {\n if (!supported) return;\n let cancelled = false;\n latest.current.pushKey().then(\n (key) => {\n if (!cancelled) setConfigured(key !== \"\");\n },\n () => {\n /* unknown: the card shows the ordinary states */\n },\n );\n return () => {\n cancelled = true;\n };\n }, [supported]);\n\n // What this browser holds, and whether it is this account's — at mount, and whenever\n // a subscription changes anywhere in the app.\n useEffect(() => {\n if (!supported) return;\n let cancelled = false;\n const sync = async () => {\n try {\n const registration = await activeRegistration();\n const existing = await registration.pushManager.getSubscription();\n if (cancelled) return;\n // Read again: the person may have changed it in the browser's settings.\n setPermission(Notification.permission);\n if (!existing) {\n setSubscribed(false);\n setForeignSubscription(false);\n setCurrentEndpoint(null);\n return;\n }\n let mine: boolean;\n try {\n const devices = await latest.current.devices();\n mine = devices.some((device) => device.endpoint === existing.endpoint);\n } catch {\n mine = true;\n }\n if (cancelled) return;\n setCurrentEndpoint(existing.endpoint);\n setSubscribed(mine);\n setForeignSubscription(!mine);\n } catch {\n /* no active service worker yet: not subscribed */\n }\n };\n const heard = () => {\n if (!announcing.current) void sync();\n };\n // The worker's word after it re-registered a rotated subscription (0.46).\n const fromWorker = (event: MessageEvent) => {\n if ((event.data as { type?: unknown } | null)?.type === PUSH_SUBSCRIPTION_CHANGED) void sync();\n };\n const worker = navigator.serviceWorker as ServiceWorkerContainer | undefined;\n void sync();\n window.addEventListener(PUSH_SUBSCRIPTION_CHANGED, heard);\n worker?.addEventListener?.(\"message\", fromWorker);\n return () => {\n cancelled = true;\n window.removeEventListener(PUSH_SUBSCRIPTION_CHANGED, heard);\n worker?.removeEventListener?.(\"message\", fromWorker);\n };\n }, [supported]);\n\n const enable = useCallback(async (): Promise<boolean> => {\n if (!supported) return false;\n const { pushKey, register, isEndpointTaken = defaultEndpointTaken } = latest.current;\n setBusy(true);\n try {\n const granted = await Notification.requestPermission();\n if (mounted.current) setPermission(granted);\n if (granted !== \"granted\") return false;\n\n const key = await pushKey();\n if (!key) {\n if (mounted.current) setConfigured(false);\n return false;\n }\n if (mounted.current) setConfigured(true);\n\n const registration = await activeRegistration();\n // The one place the browser is asked for a subscription, so the timeout and the\n // `{userVisibleOnly, applicationServerKey}` literal are on both paths.\n const subscribe = (reuse: boolean) =>\n within(\n (async () =>\n (reuse ? await registration.pushManager.getSubscription() : null) ??\n (await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: keyBytes(key) })))(),\n PUSH_TIMEOUT_MS,\n \"push_timeout\",\n );\n\n let subscription = await subscribe(true);\n let input = inputOf(subscription);\n if (!input) return false;\n try {\n await register(input);\n } catch (error) {\n if (!isEndpointTaken(error)) throw error;\n await within(subscription.unsubscribe(), PUSH_TIMEOUT_MS, \"push_timeout\");\n subscription = await subscribe(false);\n input = inputOf(subscription);\n if (!input) return false;\n await register(input);\n }\n if (mounted.current) {\n setSubscribed(true);\n setForeignSubscription(false);\n setCurrentEndpoint(input.endpoint);\n }\n announce();\n return true;\n } finally {\n if (mounted.current) setBusy(false);\n }\n }, [supported, announce]);\n\n const disable = useCallback(async (): Promise<void> => {\n if (!supported) return;\n setBusy(true);\n try {\n const registration = await activeRegistration();\n const subscription = await registration.pushManager.getSubscription();\n if (subscription) {\n // The server first: a dropped device stops receiving even if the browser's\n // unsubscribe below fails.\n await latest.current.unregister(subscription.endpoint);\n await within(subscription.unsubscribe(), PUSH_TIMEOUT_MS, \"push_timeout\");\n }\n if (mounted.current) {\n setSubscribed(false);\n setForeignSubscription(false);\n setCurrentEndpoint(null);\n }\n announce();\n } finally {\n if (mounted.current) setBusy(false);\n }\n }, [supported, announce]);\n\n const sendTest = useCallback(() => latest.current.test(), []);\n\n return {\n supported,\n configured,\n installed,\n ios,\n permission,\n subscribed,\n foreignSubscription,\n currentEndpoint,\n busy,\n enable,\n disable,\n sendTest,\n };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/notifications/use-push-device.ts"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from \"react\";\n\nimport { useMediaQuery } from \"../hooks/use-media-query\";\nimport { httpStatusOf } from \"../auth/auth-errors\";\nimport { PUSH_SUBSCRIPTION_CHANGED, keyBytes, pushSubscriptionInput as inputOf } from \"./push-worker\";\nimport type { PushSubscriptionInput } from \"./push-worker\";\n\nexport { PUSH_SUBSCRIPTION_CHANGED };\nexport type { PushSubscriptionInput };\n\n/**\n * This browser's Web Push subscription for the signed-in account\n * (docs/notifications-harmonization.md §2.7, §3.5, §4.2) — keksdose's\n * `shared/push/use-push.ts`, lifted. The service worker with its `push` and\n * `notificationclick` handlers stays the app's; this hook owns the browser's side:\n * permission, subscribe with the server's VAPID key, register the subscription, and\n * unsubscribe. The kit makes no request of its own: the app hands it one function per\n * endpoint.\n */\n\n/** The browser's notification permission, or `\"unsupported\"` where it has no push. */\nexport type PushPermission = NotificationPermission | \"unsupported\";\n\n/** Why turning push on or off could not finish — a {@link PushDeviceError}'s `code`. */\nexport type PushDeviceErrorCode = \"no_service_worker\" | \"push_timeout\";\n\n/**\n * The two failures the card has words of its own for: no service worker registered (or\n * one not ready in 10 s) — \"reload and try again\" — and a browser push service that did\n * not answer in 15 s — \"check your connection\". Anything else is the app's request\n * failing, rejected as it came.\n */\nexport class PushDeviceError extends Error {\n readonly code: PushDeviceErrorCode;\n constructor(code: PushDeviceErrorCode) {\n super(code);\n this.name = \"PushDeviceError\";\n this.code = code;\n }\n}\n\nexport interface UsePushDeviceOptions {\n /** `GET /notifications/push-key` → its `key`; `\"\"` when push is off on the server. */\n pushKey: () => Promise<string>;\n /** `POST /notifications/devices`. A 409 (another account's endpoint) is recovered\n * once: the hook unsubscribes and registers a fresh endpoint. */\n register: (subscription: PushSubscriptionInput) => Promise<unknown>;\n /** `POST /notifications/devices/unregister {endpoint}` — the endpoint never goes in a\n * URL. */\n unregister: (endpoint: string) => Promise<unknown>;\n /** `GET /notifications/devices`: the account's own list, which says whether THIS\n * account holds this browser's subscription. */\n devices: () => Promise<readonly { endpoint: string }[]>;\n /** `POST /notifications/test` → how many devices the push service accepted it for. */\n test: () => Promise<{ sent: number }>;\n /** Whether `register` refused another account's endpoint. Default: HTTP 409. */\n isEndpointTaken?: (error: unknown) => boolean;\n /** After an enable or a disable: the app refreshes its device queries. */\n onChange?: () => void;\n}\n\n/** What {@link usePushDevice} answers. */\nexport interface PushDevice {\n /** The browser exposes service workers, `PushManager` and `Notification`. */\n supported: boolean;\n /** The server has VAPID keys; `undefined` until `pushKey` answered (or when it failed). */\n configured: boolean | undefined;\n /** Running as an installed app: `display-mode: standalone`, or iOS's\n * `navigator.standalone`. */\n installed: boolean;\n /** iPhone or iPad — where push reaches only the installed app. */\n ios: boolean;\n permission: PushPermission;\n /** THIS account holds this browser's subscription. An unreachable device list falls\n * back to the browser's own answer. */\n subscribed: boolean;\n /** This browser holds a subscription that belongs to another account (two accounts in\n * one browser profile): `subscribed` is false, and turning on mints a fresh one. */\n foreignSubscription: boolean;\n /** This browser's subscription endpoint, or `null` — marks \"This device\" in a list. */\n currentEndpoint: string | null;\n /** An enable or a disable is running. */\n busy: boolean;\n /** Ask for permission (the browser's one prompt), subscribe and register. `false` when\n * the permission was not granted or push is off on the server; rejects with a\n * {@link PushDeviceError} or the app's request's error. */\n enable: () => Promise<boolean>;\n /** Tell the server first, then the browser, so a dropped device stops at once. */\n disable: () => Promise<void>;\n sendTest: () => Promise<{ sent: number }>;\n}\n\n/*\n * PUSH_SUBSCRIPTION_CHANGED (push-worker.ts): the window event every mounted\n * {@link usePushDevice} re-reads its state on. Fired by every enable and disable, and by\n * `PushDevicesSetting` when it removes this device — so an app's own card on the same\n * hook (keksdose's check-up) stays in step. The hook that fires it does not re-read: it\n * knows what it changed. Since 0.46 also the message a service worker posts after a\n * rotated subscription was re-registered (`handlePushSubscriptionChange`). Namespaced\n * for the kit; keksdose's was `\"keksdose:push-subscription-changed\"`.\n */\n\n/** Tell every {@link usePushDevice} that this browser's subscription changed. */\nexport function notifyPushSubscriptionChanged(): void {\n if (typeof window !== \"undefined\") window.dispatchEvent(new Event(PUSH_SUBSCRIPTION_CHANGED));\n}\n\n/** Whether this browser has the APIs push needs. iOS exposes them only to an INSTALLED\n * app (Add to Home Screen). */\nexport function pushSupported(): boolean {\n return (\n typeof window !== \"undefined\" &&\n typeof navigator !== \"undefined\" &&\n \"serviceWorker\" in navigator &&\n \"PushManager\" in window &&\n \"Notification\" in window\n );\n}\n\n/** iPhone or iPad. iPadOS 13+ reports itself as a Mac; the touch points tell it apart. */\nfunction isIos(): boolean {\n if (typeof navigator === \"undefined\") return false;\n const ua = navigator.userAgent;\n return /iphone|ipad|ipod/i.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);\n}\n\nconst READY_TIMEOUT_MS = 10_000;\nconst PUSH_TIMEOUT_MS = 15_000;\n\n/** `promise`, or a {@link PushDeviceError} after `ms`. The timer is cleared either way. */\nfunction within<T>(promise: Promise<T>, ms: number, code: PushDeviceErrorCode): Promise<T> {\n let timer: ReturnType<typeof setTimeout> | undefined;\n return Promise.race([\n promise,\n new Promise<never>((_, reject) => {\n timer = setTimeout(() => reject(new PushDeviceError(code)), ms);\n }),\n ]).finally(() => clearTimeout(timer));\n}\n\n/**\n * The active registration. `navigator.serviceWorker.ready` never settles while the origin\n * has no registration, so a missing one fails at once, and one stuck installing fails\n * after 10 s (keksdose: enable() hung with the button latched before its worker was\n * served).\n */\nasync function activeRegistration(): Promise<ServiceWorkerRegistration> {\n const existing = await navigator.serviceWorker.getRegistration();\n if (!existing) throw new PushDeviceError(\"no_service_worker\");\n return within(navigator.serviceWorker.ready, READY_TIMEOUT_MS, \"no_service_worker\");\n}\n\nconst defaultEndpointTaken = (error: unknown) => httpStatusOf(error) === 409;\n\n/**\n * This browser's push for the signed-in account. Hand it one function per endpoint\n * (§3.8); {@link PushDeviceSetting} draws it, and an app's own card may use it too.\n *\n * **\"Subscribed\" means this account**, not \"this browser holds a subscription\": a\n * browser profile's subscription has no account in it, so a second account in the same\n * profile would read the first's as its own (keksdose). The endpoint is checked against\n * the account's own `devices()`; when that list cannot be read (offline), the browser's\n * answer stands — a wrong \"on\" costs what it did before the check, a wrong \"off\" invites\n * re-subscribing a device that is fine.\n *\n * **A 409 from `register`** — the endpoint belongs to another account, and the server\n * will not re-home what it cannot prove this browser holds — unsubscribes and registers\n * a fresh endpoint, once.\n *\n * **Timeouts:** no registration fails at once and a worker not ready in 10 s fails, both\n * `PushDeviceError(\"no_service_worker\")`; the browser's subscribe and unsubscribe are cut\n * at 15 s, `PushDeviceError(\"push_timeout\")` — so `busy` can never latch.\n *\n * Every mounted hook re-reads its state on {@link PUSH_SUBSCRIPTION_CHANGED}.\n */\nexport function usePushDevice(options: UsePushDeviceOptions): PushDevice {\n // The app's functions are usually new on every render; the effects read the latest.\n const latest = useRef(options);\n useEffect(() => {\n latest.current = options;\n });\n\n const supported = pushSupported();\n const ios = isIos();\n const standalone = useMediaQuery(\"(display-mode: standalone)\", false);\n const installed =\n standalone ||\n (typeof navigator !== \"undefined\" && (navigator as Navigator & { standalone?: boolean }).standalone === true);\n\n const [permission, setPermission] = useState<PushPermission>(() =>\n supported ? Notification.permission : \"unsupported\",\n );\n const [configured, setConfigured] = useState<boolean | undefined>(undefined);\n const [subscribed, setSubscribed] = useState(false);\n const [foreignSubscription, setForeignSubscription] = useState(false);\n const [currentEndpoint, setCurrentEndpoint] = useState<string | null>(null);\n const [busy, setBusy] = useState(false);\n const mounted = useRef(true);\n // Set while this hook fires the event itself: it already knows what changed, and a\n // re-read could only be worse — an app whose `devices()` answers from a cache would\n // hand back the list from before the change.\n const announcing = useRef(false);\n const announce = useCallback(() => {\n announcing.current = true;\n try {\n notifyPushSubscriptionChanged();\n } finally {\n announcing.current = false;\n }\n latest.current.onChange?.();\n }, []);\n useEffect(() => {\n mounted.current = true;\n return () => {\n mounted.current = false;\n };\n }, []);\n\n // Whether the server has keys (\"\" → push is off there). Once per mount.\n useEffect(() => {\n if (!supported) return;\n let cancelled = false;\n latest.current.pushKey().then(\n (key) => {\n if (!cancelled) setConfigured(key !== \"\");\n },\n () => {\n /* unknown: the card shows the ordinary states */\n },\n );\n return () => {\n cancelled = true;\n };\n }, [supported]);\n\n // What this browser holds, and whether it is this account's — at mount, and whenever\n // a subscription changes anywhere in the app.\n useEffect(() => {\n if (!supported) return;\n let cancelled = false;\n const sync = async () => {\n try {\n const registration = await activeRegistration();\n const existing = await registration.pushManager.getSubscription();\n if (cancelled) return;\n // Read again: the person may have changed it in the browser's settings.\n setPermission(Notification.permission);\n if (!existing) {\n setSubscribed(false);\n setForeignSubscription(false);\n setCurrentEndpoint(null);\n return;\n }\n let mine: boolean;\n try {\n const devices = await latest.current.devices();\n mine = devices.some((device) => device.endpoint === existing.endpoint);\n } catch {\n mine = true;\n }\n if (cancelled) return;\n setCurrentEndpoint(existing.endpoint);\n setSubscribed(mine);\n setForeignSubscription(!mine);\n } catch {\n /* no active service worker yet: not subscribed */\n }\n };\n const heard = () => {\n if (!announcing.current) void sync();\n };\n // The worker's word after it re-registered a rotated subscription (0.46).\n const fromWorker = (event: MessageEvent) => {\n if ((event.data as { type?: unknown } | null)?.type === PUSH_SUBSCRIPTION_CHANGED) void sync();\n };\n const worker = navigator.serviceWorker as ServiceWorkerContainer | undefined;\n void sync();\n window.addEventListener(PUSH_SUBSCRIPTION_CHANGED, heard);\n worker?.addEventListener?.(\"message\", fromWorker);\n return () => {\n cancelled = true;\n window.removeEventListener(PUSH_SUBSCRIPTION_CHANGED, heard);\n worker?.removeEventListener?.(\"message\", fromWorker);\n };\n }, [supported]);\n\n const enable = useCallback(async (): Promise<boolean> => {\n if (!supported) return false;\n const { pushKey, register, isEndpointTaken = defaultEndpointTaken } = latest.current;\n setBusy(true);\n try {\n const granted = await Notification.requestPermission();\n if (mounted.current) setPermission(granted);\n if (granted !== \"granted\") return false;\n\n const key = await pushKey();\n if (!key) {\n if (mounted.current) setConfigured(false);\n return false;\n }\n if (mounted.current) setConfigured(true);\n\n const registration = await activeRegistration();\n // The one place the browser is asked for a subscription, so the timeout and the\n // `{userVisibleOnly, applicationServerKey}` literal are on both paths.\n const subscribe = (reuse: boolean) =>\n within(\n (async () =>\n (reuse ? await registration.pushManager.getSubscription() : null) ??\n (await registration.pushManager.subscribe({\n userVisibleOnly: true,\n applicationServerKey: keyBytes(key),\n })))(),\n PUSH_TIMEOUT_MS,\n \"push_timeout\",\n );\n\n let subscription = await subscribe(true);\n let input = inputOf(subscription);\n if (!input) return false;\n try {\n await register(input);\n } catch (error) {\n if (!isEndpointTaken(error)) throw error;\n await within(subscription.unsubscribe(), PUSH_TIMEOUT_MS, \"push_timeout\");\n subscription = await subscribe(false);\n input = inputOf(subscription);\n if (!input) return false;\n await register(input);\n }\n if (mounted.current) {\n setSubscribed(true);\n setForeignSubscription(false);\n setCurrentEndpoint(input.endpoint);\n }\n announce();\n return true;\n } finally {\n if (mounted.current) setBusy(false);\n }\n }, [supported, announce]);\n\n const disable = useCallback(async (): Promise<void> => {\n if (!supported) return;\n setBusy(true);\n try {\n const registration = await activeRegistration();\n const subscription = await registration.pushManager.getSubscription();\n if (subscription) {\n // The server first: a dropped device stops receiving even if the browser's\n // unsubscribe below fails.\n await latest.current.unregister(subscription.endpoint);\n await within(subscription.unsubscribe(), PUSH_TIMEOUT_MS, \"push_timeout\");\n }\n if (mounted.current) {\n setSubscribed(false);\n setForeignSubscription(false);\n setCurrentEndpoint(null);\n }\n announce();\n } finally {\n if (mounted.current) setBusy(false);\n }\n }, [supported, announce]);\n\n const sendTest = useCallback(() => latest.current.test(), []);\n\n return {\n supported,\n configured,\n installed,\n ios,\n permission,\n subscribed,\n foreignSubscription,\n currentEndpoint,\n busy,\n enable,\n disable,\n sendTest,\n };\n}\n"],"mappings":"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","names":[]}
|
|
@@ -29,4 +29,4 @@ export interface PwaUpdatePromptProps {
|
|
|
29
29
|
hold?: boolean;
|
|
30
30
|
labels?: LabelOverride<PwaUpdatePromptLabels>;
|
|
31
31
|
}
|
|
32
|
-
export declare function PwaUpdatePrompt({ needRefresh, onReload, onDismiss, hold, labels: labelsProp }: PwaUpdatePromptProps): null;
|
|
32
|
+
export declare function PwaUpdatePrompt({ needRefresh, onReload, onDismiss, hold, labels: labelsProp, }: PwaUpdatePromptProps): null;
|
|
@@ -4,7 +4,13 @@ import { useKitLabels } from "../i18n/kit-labels.js";
|
|
|
4
4
|
import { toast } from "../components/toast.js";
|
|
5
5
|
import { DEFAULT_PWA_UPDATE_PROMPT_LABELS } from "./pwa-update-prompt-labels.js";
|
|
6
6
|
const PWA_UPDATE_TOAST_ID = "pwa-update";
|
|
7
|
-
function PwaUpdatePrompt({
|
|
7
|
+
function PwaUpdatePrompt({
|
|
8
|
+
needRefresh,
|
|
9
|
+
onReload,
|
|
10
|
+
onDismiss,
|
|
11
|
+
hold = false,
|
|
12
|
+
labels: labelsProp
|
|
13
|
+
}) {
|
|
8
14
|
const labels = useKitLabels("pwaUpdate", DEFAULT_PWA_UPDATE_PROMPT_LABELS, labelsProp);
|
|
9
15
|
const handlers = useRef({ onReload, onDismiss });
|
|
10
16
|
useEffect(() => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/offline/pwa-update-prompt.tsx"],"sourcesContent":["import { useEffect, useRef } from \"react\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { toast } from \"../components/toast\";\nimport { DEFAULT_PWA_UPDATE_PROMPT_LABELS } from \"./pwa-update-prompt-labels\";\n\n/**\n * \"A new version is available. — Reload\": keksdose's service-worker toast\n * (app/pwa-update-prompt.tsx) as a kit component (docs/local-first-harmonization.md §1.5\n * D8). The app keeps `useRegisterSW` (vite-plugin-pwa, `registerType: \"prompt\"`) and hands\n * its state in: `needRefresh` from it, `onReload` = `updateServiceWorker(true)`,\n * `onDismiss` = `setNeedRefresh(false)`.\n *\n * `hold` defers the prompt while a reload would hurt — a handover being signed, a tour\n * running. Nothing shows while it holds; the toast appears once it drops. A hold that\n * starts while the toast is up takes it down again until it drops.\n *\n * Renders nothing itself: the toast needs a mounted `<Toaster>`.\n */\nexport interface PwaUpdatePromptLabels {\n updateAvailable: string;\n reload: string;\n}\n\n// Defined in ./pwa-update-prompt-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_PWA_UPDATE_PROMPT_LABELS };\n\nexport interface PwaUpdatePromptProps {\n /** A new service worker is waiting. */\n needRefresh: boolean;\n /** Activates it and reloads — `updateServiceWorker(true)`. */\n onReload: () => void;\n /** The toast was closed without reloading. */\n onDismiss?: () => void;\n /** Wait: a handover is open, a tour runs. */\n hold?: boolean;\n labels?: LabelOverride<PwaUpdatePromptLabels>;\n}\n\n/** The toast's id: one prompt at a time, replaced in place. */\nconst PWA_UPDATE_TOAST_ID = \"pwa-update\";\n\nexport function PwaUpdatePrompt({
|
|
1
|
+
{"version":3,"sources":["../../src/offline/pwa-update-prompt.tsx"],"sourcesContent":["import { useEffect, useRef } from \"react\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { toast } from \"../components/toast\";\nimport { DEFAULT_PWA_UPDATE_PROMPT_LABELS } from \"./pwa-update-prompt-labels\";\n\n/**\n * \"A new version is available. — Reload\": keksdose's service-worker toast\n * (app/pwa-update-prompt.tsx) as a kit component (docs/local-first-harmonization.md §1.5\n * D8). The app keeps `useRegisterSW` (vite-plugin-pwa, `registerType: \"prompt\"`) and hands\n * its state in: `needRefresh` from it, `onReload` = `updateServiceWorker(true)`,\n * `onDismiss` = `setNeedRefresh(false)`.\n *\n * `hold` defers the prompt while a reload would hurt — a handover being signed, a tour\n * running. Nothing shows while it holds; the toast appears once it drops. A hold that\n * starts while the toast is up takes it down again until it drops.\n *\n * Renders nothing itself: the toast needs a mounted `<Toaster>`.\n */\nexport interface PwaUpdatePromptLabels {\n updateAvailable: string;\n reload: string;\n}\n\n// Defined in ./pwa-update-prompt-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_PWA_UPDATE_PROMPT_LABELS };\n\nexport interface PwaUpdatePromptProps {\n /** A new service worker is waiting. */\n needRefresh: boolean;\n /** Activates it and reloads — `updateServiceWorker(true)`. */\n onReload: () => void;\n /** The toast was closed without reloading. */\n onDismiss?: () => void;\n /** Wait: a handover is open, a tour runs. */\n hold?: boolean;\n labels?: LabelOverride<PwaUpdatePromptLabels>;\n}\n\n/** The toast's id: one prompt at a time, replaced in place. */\nconst PWA_UPDATE_TOAST_ID = \"pwa-update\";\n\nexport function PwaUpdatePrompt({\n needRefresh,\n onReload,\n onDismiss,\n hold = false,\n labels: labelsProp,\n}: PwaUpdatePromptProps) {\n const labels = useKitLabels(\"pwaUpdate\", DEFAULT_PWA_UPDATE_PROMPT_LABELS, labelsProp);\n // The callbacks are read at click time, so a new function each render does not\n // re-raise the toast.\n const handlers = useRef({ onReload, onDismiss });\n useEffect(() => {\n handlers.current = { onReload, onDismiss };\n });\n\n const show = needRefresh && !hold;\n useEffect(() => {\n if (!show) return;\n // sonner calls `onDismiss` for a programmatic dismiss too: the one this effect makes\n // when a hold starts must not tell the app the person waved the update away.\n let ours = false;\n toast(labels.updateAvailable, {\n id: PWA_UPDATE_TOAST_ID,\n duration: Infinity,\n action: { label: labels.reload, onClick: () => handlers.current.onReload() },\n onDismiss: () => {\n if (!ours) handlers.current.onDismiss?.();\n },\n });\n return () => {\n ours = true;\n toast.dismiss(PWA_UPDATE_TOAST_ID);\n };\n }, [show, labels.updateAvailable, labels.reload]);\n\n return null;\n}\n"],"mappings":";AAAA,SAAS,WAAW,cAAc;AAClC,SAAS,oBAAwC;AACjD,SAAS,aAAa;AACtB,SAAS,wCAAwC;AAoCjD,MAAM,sBAAsB;AAErB,SAAS,gBAAgB;AAAA,EAC9B;AAAA,EACA;AAAA,EACA;AAAA,EACA,OAAO;AAAA,EACP,QAAQ;AACV,GAAyB;AACvB,QAAM,SAAS,aAAa,aAAa,kCAAkC,UAAU;AAGrF,QAAM,WAAW,OAAO,EAAE,UAAU,UAAU,CAAC;AAC/C,YAAU,MAAM;AACd,aAAS,UAAU,EAAE,UAAU,UAAU;AAAA,EAC3C,CAAC;AAED,QAAM,OAAO,eAAe,CAAC;AAC7B,YAAU,MAAM;AACd,QAAI,CAAC,KAAM;AAGX,QAAI,OAAO;AACX,UAAM,OAAO,iBAAiB;AAAA,MAC5B,IAAI;AAAA,MACJ,UAAU;AAAA,MACV,QAAQ,EAAE,OAAO,OAAO,QAAQ,SAAS,MAAM,SAAS,QAAQ,SAAS,EAAE;AAAA,MAC3E,WAAW,MAAM;AACf,YAAI,CAAC,KAAM,UAAS,QAAQ,YAAY;AAAA,MAC1C;AAAA,IACF,CAAC;AACD,WAAO,MAAM;AACX,aAAO;AACP,YAAM,QAAQ,mBAAmB;AAAA,IACnC;AAAA,EACF,GAAG,CAAC,MAAM,OAAO,iBAAiB,OAAO,MAAM,CAAC;AAEhD,SAAO;AACT;","names":[]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/offline/query-persistence.ts"],"sourcesContent":["import { createAsyncStoragePersister } from \"@tanstack/query-async-storage-persister\";\nimport type { QueryClient } from \"@tanstack/react-query\";\nimport type { PersistedClient, PersistQueryClientOptions } from \"@tanstack/react-query-persist-client\";\nimport { del, get, keys, set } from \"idb-keyval\";\n\n/**\n * The persisted TanStack Query cache, keksdose's policy as a kit helper\n * (docs/local-first-harmonization.md §1.5 D4, D5, D7; §1.6). keksdose's\n * shared/state/query-client.ts is the canon: an IndexedDB persister, a deny-list, the\n * plain-JSON guard on write (`shouldDehydrateQuery`) and its twin on restore that keeps\n * paused mutations. On top: a key per account (and company), a scrub list that removes\n * secret fields from payloads that are otherwise wanted offline, and `removeStale` for\n * another account's leftovers.\n *\n * The only module of the kit that imports TanStack and idb-keyval: both are optional peers\n * of `@eifi1/ui-kit/offline` and never reached from the main barrel (packaging-contract).\n */\n\n/** A field name the scrub removes: an exact name, or a pattern (`/^signature_/`). */\nexport type ScrubField = string | RegExp;\n\n/**\n * Removed from every query's data, at any depth, before it is persisted (D4, D7): bank\n * account numbers and the handover protocol's signatures. A page restored offline shows\n * such a field as missing (\"Needs a connection\").\n */\nexport const DEFAULT_SCRUB_FIELDS: readonly ScrubField[] = Object.freeze([\n \"iban\",\n \"qr_iban\",\n \"default_qr_iban\",\n /^signature_/,\n]);\n\n/** The storage the persister writes to. The default is IndexedDB through idb-keyval. */\nexport interface QueryPersistenceStorage {\n getItem: (key: string) => Promise<string | null | undefined>;\n setItem: (key: string, value: string) => Promise<unknown>;\n removeItem: (key: string) => Promise<unknown>;\n /** Every key in the store — what `removeStale` walks. */\n keys: () => Promise<readonly unknown[]>;\n}\n\nexport interface QueryPersistenceOptions {\n /** The app's name, the key's first part: `kastlan` → `kastlan-query-cache-…`. */\n app: string;\n /** The signed-in account. One cache per account (D5). */\n user: string | number;\n /** The acting company, where the app has several (kastlan): the switch changes the key,\n * so no wipe and no cross-company leak — provided the app switches as\n * {@link QueryPersistence.leave} says (leave, then remount the provider per key). */\n company?: string | number | null;\n /** The app's own rule, beside `denyRoots` — keksdose denies one exact key and every key\n * whose second segment is `\"admin\"`. `false` keeps the query out. */\n shouldPersistKey?: (queryKey: readonly unknown[]) => boolean;\n /** Query roots (`queryKey[0]`) that never reach the disk: admin, billing, reports… */\n denyRoots?: readonly string[];\n /** Replaces {@link DEFAULT_SCRUB_FIELDS}; spread it to add to it. `[]` scrubs nothing.\n * A paused mutation whose variables carry one of these fields is never persisted (a\n * scrubbed mutation would send something else), and one an older build wrote is dropped\n * on restore. */\n scrubFields?: readonly ScrubField[];\n /** Told when a query's data is not plain JSON and is therefore not persisted. Default:\n * `console.error`, as keksdose's `cacheSafeOrWarn`. */\n onUnsafe?: (queryKey: readonly unknown[], data: unknown) => void;\n /** Changes with each deploy (the app version), so a new build discards the old cache. */\n buster: string;\n /** How long a persisted cache is good for, in ms (keksdose: 24 h). The QueryClient's\n * `gcTime` must be at least this, or restore drops every query. */\n maxAge: number;\n /** Default: IndexedDB (idb-keyval). Tests pass an in-memory one. */\n storage?: QueryPersistenceStorage;\n /** Default 1000 ms, as keksdose. */\n throttleTime?: number;\n /**\n * 0.46 (kastlan K1): whether paused mutations are written with the cache. Default `true`\n * (keksdose: an offline write waits in TanStack's queue and is sent after a reload).\n * `false` keeps every mutation off the disk, and drops any an older build wrote on\n * restore — for an app that queues offline writes in its own outbox (kastlan's Dexie\n * outbox), instead of overriding `dehydrateOptions` by hand.\n */\n persistMutations?: boolean;\n}\n\n/**\n * One persisted cache, for one key. **Mount it per key**: the provider subscribes once and\n * ignores a changed `persistOptions`, so give it the key as its React key —\n *\n * ```tsx\n * <PersistQueryClientProvider key={persistence.key} client={queryClient} persistOptions={persistence.persistOptions}>\n * ```\n *\n * — and create the persistence once the account (and company) is known (`useMemo` on\n * them); before sign-in, mount the plain `QueryClientProvider`. On a company switch, call\n * {@link QueryPersistence.leave} on the old one BEFORE the new key mounts, instead of a bare\n * `queryClient.clear()`: otherwise the old subscription saves the emptied (or the next\n * company's) cache under the old key.\n */\nexport interface QueryPersistence {\n /** `<app>-query-cache-<user>[-<company>]`. */\n key: string;\n /** Everything `<PersistQueryClientProvider persistOptions>` needs. */\n persistOptions: Omit<PersistQueryClientOptions, \"queryClient\">;\n /** Sign-out: stops writing under this key, empties the in-memory cache and deletes this\n * key's persisted copy. Best-effort; never throws. */\n clear: (queryClient: QueryClient) => Promise<void>;\n /** The company switch (or any change of key without a sign-out): stops writing under this\n * key — a save already scheduled is dropped too — and empties the in-memory cache, so\n * the next key's provider starts clean. The persisted copy stays for the way back. A\n * provider that mounts this persistence again re-arms it with its restore. Never throws. */\n leave: (queryClient: QueryClient) => Promise<void>;\n /** At sign-in: deletes this app's OTHER persisted caches older than `maxAge` (another\n * account's, another company's). Other apps' keys and fresh ones stay. Never throws. */\n removeStale: () => Promise<void>;\n}\n\nconst idbStorage: QueryPersistenceStorage = {\n getItem: (key) => get<string>(key),\n setItem: (key, value) => set(key, value),\n removeItem: (key) => del(key),\n keys: () => keys(),\n};\n\nexport interface RemoveStaleQueryCopiesOptions {\n /** Default: IndexedDB (idb-keyval), the store {@link createQueryPersistence} writes to. */\n storage?: QueryPersistenceStorage;\n /** One key to spare whatever its age — the signed-in account's own\n * (`QueryPersistence.removeStale` passes its key). */\n keep?: string;\n}\n\n/**\n * 0.46 (kastlan K2): deletes this app's persisted query caches (`<app>-query-cache-…`)\n * older than `maxAge`, with no persistence at hand — for the sign-in page, where no\n * account is known. A session that ended with no user known (a refresh token refused at\n * boot) cannot name its key for `clear`; this sweeps its copy once it is stale, without\n * waiting for someone's next sign-in. Other apps' keys, other stores' keys and fresh\n * caches stay; an unreadable one is stale. Never throws.\n * {@link QueryPersistence.removeStale} is this with `keep` = its own key.\n */\nexport async function removeStaleQueryCopies(\n app: string,\n maxAge: number,\n { storage = idbStorage, keep }: RemoveStaleQueryCopiesOptions = {},\n): Promise<void> {\n const prefix = `${app}-query-cache-`;\n try {\n const now = Date.now();\n for (const k of await storage.keys()) {\n if (typeof k !== \"string\" || !k.startsWith(prefix) || k === keep) continue;\n let timestamp = 0;\n try {\n const raw = await storage.getItem(k);\n const parsed: unknown = raw ? JSON.parse(raw) : null;\n if (parsed && typeof parsed === \"object\" && typeof (parsed as PersistedClient).timestamp === \"number\") {\n timestamp = (parsed as PersistedClient).timestamp;\n }\n } catch {\n /* unreadable: stale */\n }\n if (now - timestamp > maxAge) await storage.removeItem(k);\n }\n } catch {\n /* best-effort */\n }\n}\n\n/**\n * True when `data` still means the same thing after JSON.stringify and JSON.parse —\n * keksdose's `isCacheSafe` (query-client.ts:165). Class instances, Map/Set/Date/Blob, a\n * non-finite number, a function: all refused. An `undefined` property survives.\n */\nexport function isCacheSafe(data: unknown, depth = 0): boolean {\n if (depth > 12) return false;\n if (data === null) return true;\n const t = typeof data;\n if (t === \"string\" || t === \"boolean\") return true;\n if (t === \"number\") return Number.isFinite(data as number);\n if (t !== \"object\") return false;\n if (Array.isArray(data)) return data.every((v) => isCacheSafe(v, depth + 1));\n const proto: unknown = Object.getPrototypeOf(data);\n if (proto !== Object.prototype && proto !== null) return false;\n return Object.values(data as Record<string, unknown>).every((v) => v === undefined || isCacheSafe(v, depth + 1));\n}\n\n/** A copy of `data` without the scrubbed fields, at any depth. The input is not touched. */\nfunction scrub(data: unknown, fields: readonly ScrubField[]): unknown {\n if (fields.length === 0 || data === null || typeof data !== \"object\") return data;\n if (Array.isArray(data)) return data.map((v) => scrub(v, fields));\n const out: Record<string, unknown> = {};\n for (const [name, value] of Object.entries(data as Record<string, unknown>)) {\n if (fields.some((f) => (typeof f === \"string\" ? f === name : f.test(name)))) continue;\n out[name] = scrub(value, fields);\n }\n return out;\n}\n\nconst defaultOnUnsafe = (queryKey: readonly unknown[]) => {\n console.error(\n `[query-cache] refusing to persist ${JSON.stringify(queryKey)}: the value is not plain JSON ` +\n `(a class instance, Map, Set, Date or Blob). Return plain data from the queryFn and build ` +\n `the rich object at the call site.`,\n );\n};\n\nexport function createQueryPersistence({\n app,\n user,\n company,\n shouldPersistKey,\n denyRoots = [],\n scrubFields = DEFAULT_SCRUB_FIELDS,\n onUnsafe = defaultOnUnsafe,\n buster,\n maxAge,\n storage = idbStorage,\n throttleTime = 1000,\n persistMutations = true,\n}: QueryPersistenceOptions): QueryPersistence {\n const prefix = `${app}-query-cache-`;\n const key = `${prefix}${user}${company === undefined || company === null || company === \"\" ? \"\" : `-${company}`}`;\n const denied = new Set(denyRoots);\n // Set by `leave` and `clear`: a write under this key after the person left it (a\n // throttled save of the emptied cache) is dropped. A restore re-arms it.\n let retired = false;\n\n const keyAllowed = (queryKey: readonly unknown[]) =>\n !denied.has(String(queryKey[0])) && (shouldPersistKey ? shouldPersistKey(queryKey) : true);\n\n /** The write side: a success, an allowed key, plain JSON (or the warning). */\n const shouldDehydrateQuery = (query: {\n queryKey: readonly unknown[];\n state: { status: string; data?: unknown };\n }): boolean => {\n if (query.state.status !== \"success\" || !keyAllowed(query.queryKey)) return false;\n if (isCacheSafe(query.state.data)) return true;\n onUnsafe(query.queryKey, query.state.data);\n return false;\n };\n\n /** One persisted query entry, scrubbed; `null` when it may not be restored. */\n const restorable = (entry: unknown): unknown => {\n if (!entry || typeof entry !== \"object\") return null;\n const { queryKey, state } = entry as { queryKey?: unknown; state?: { data?: unknown } };\n if (!Array.isArray(queryKey) || !keyAllowed(queryKey)) return null;\n if (!isCacheSafe(state?.data)) return null;\n return withScrubbedData(entry);\n };\n\n /** Whether `value` has a scrubbed field's name anywhere in it. */\n const carriesScrubbed = (value: unknown, depth = 0): boolean => {\n if (scrubFields.length === 0 || depth > 12 || value === null || typeof value !== \"object\") return false;\n if (Array.isArray(value)) return value.some((v) => carriesScrubbed(v, depth + 1));\n return Object.entries(value as Record<string, unknown>).some(\n ([name, v]) =>\n scrubFields.some((f) => (typeof f === \"string\" ? f === name : f.test(name))) || carriesScrubbed(v, depth + 1),\n );\n };\n\n /** The write side for mutations: TanStack's default (paused ones), minus any whose\n * variables carry a scrubbed field (S6); none at all with `persistMutations: false`. */\n const shouldDehydrateMutation = (mutation: { state: { isPaused: boolean; variables?: unknown } }): boolean =>\n persistMutations && mutation.state.isPaused && !carriesScrubbed(mutation.state.variables);\n\n const withScrubbedData = (entry: unknown): unknown => {\n const query = entry as { state?: { data?: unknown } };\n if (!query.state || !(\"data\" in query.state)) return entry;\n return { ...query, state: { ...query.state, data: scrub(query.state.data, scrubFields) } };\n };\n\n // The scrub on the way OUT: the in-memory cache keeps the field (the page that is open\n // shows it), the disk never sees it. Mutations are a paused write queue and are kept\n // whole — scrubbing one would change what it sends — so one carrying a scrubbed field is\n // not written at all (`shouldDehydrateMutation`).\n const serialize = (client: PersistedClient): string =>\n JSON.stringify({\n ...client,\n clientState: { ...client.clientState, queries: client.clientState.queries.map(withScrubbedData) },\n });\n\n // The twin on the way IN (keksdose's `sanitizePersistedCache`, :264-298): an older build\n // wrote what this one may deny. Never throws; an unreadable blob comes back with\n // `timestamp: 0`, which the restore treats as unusable and removes.\n const deserialize = (raw: string): PersistedClient => {\n const empty: PersistedClient = { timestamp: 0, buster: \"\", clientState: { mutations: [], queries: [] } };\n try {\n const parsed: unknown = JSON.parse(raw);\n if (!parsed || typeof parsed !== \"object\") return empty;\n const { timestamp, buster: stored, clientState } = parsed as Partial<PersistedClient>;\n if (typeof timestamp !== \"number\" || typeof stored !== \"string\") return empty;\n if (!clientState || typeof clientState !== \"object\" || !Array.isArray(clientState.queries)) return empty;\n return {\n timestamp,\n buster: stored,\n clientState: {\n mutations: persistMutations && Array.isArray(clientState.mutations)\n ? clientState.mutations.filter(\n (m) => !carriesScrubbed((m as { state?: { variables?: unknown } } | null)?.state?.variables),\n )\n : [],\n queries: clientState.queries.map(restorable).filter((q) => q !== null) as PersistedClient[\"clientState\"][\"queries\"],\n },\n };\n } catch {\n return empty;\n }\n };\n\n const persister = createAsyncStoragePersister({\n storage: {\n // Only the provider's restore reads: mounting this persistence again re-arms it.\n getItem: async (k) => {\n retired = false;\n return (await storage.getItem(k)) ?? null;\n },\n setItem: async (k, v) => {\n if (!retired) await storage.setItem(k, v);\n },\n removeItem: async (k) => {\n await storage.removeItem(k);\n },\n },\n key,\n throttleTime,\n serialize,\n deserialize,\n });\n\n const leave = async (queryClient: QueryClient) => {\n retired = true;\n try {\n queryClient.clear();\n } catch {\n /* best-effort */\n }\n };\n\n const clear = async (queryClient: QueryClient) => {\n retired = true;\n try {\n queryClient.clear();\n await persister.removeClient();\n } catch {\n /* best-effort: private mode / IndexedDB unavailable */\n }\n };\n\n const removeStale = () => removeStaleQueryCopies(app, maxAge, { storage, keep: key });\n\n return {\n key,\n persistOptions: { persister, maxAge, buster, dehydrateOptions: { shouldDehydrateQuery, shouldDehydrateMutation } },\n clear,\n leave,\n removeStale,\n };\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/offline/query-persistence.ts"],"sourcesContent":["import { createAsyncStoragePersister } from \"@tanstack/query-async-storage-persister\";\nimport type { QueryClient } from \"@tanstack/react-query\";\nimport type { PersistedClient, PersistQueryClientOptions } from \"@tanstack/react-query-persist-client\";\nimport { del, get, keys, set } from \"idb-keyval\";\n\n/**\n * The persisted TanStack Query cache, keksdose's policy as a kit helper\n * (docs/local-first-harmonization.md §1.5 D4, D5, D7; §1.6). keksdose's\n * shared/state/query-client.ts is the canon: an IndexedDB persister, a deny-list, the\n * plain-JSON guard on write (`shouldDehydrateQuery`) and its twin on restore that keeps\n * paused mutations. On top: a key per account (and company), a scrub list that removes\n * secret fields from payloads that are otherwise wanted offline, and `removeStale` for\n * another account's leftovers.\n *\n * The only module of the kit that imports TanStack and idb-keyval: both are optional peers\n * of `@eifi1/ui-kit/offline` and never reached from the main barrel (packaging-contract).\n */\n\n/** A field name the scrub removes: an exact name, or a pattern (`/^signature_/`). */\nexport type ScrubField = string | RegExp;\n\n/**\n * Removed from every query's data, at any depth, before it is persisted (D4, D7): bank\n * account numbers and the handover protocol's signatures. A page restored offline shows\n * such a field as missing (\"Needs a connection\").\n */\nexport const DEFAULT_SCRUB_FIELDS: readonly ScrubField[] = Object.freeze([\n \"iban\",\n \"qr_iban\",\n \"default_qr_iban\",\n /^signature_/,\n]);\n\n/** The storage the persister writes to. The default is IndexedDB through idb-keyval. */\nexport interface QueryPersistenceStorage {\n getItem: (key: string) => Promise<string | null | undefined>;\n setItem: (key: string, value: string) => Promise<unknown>;\n removeItem: (key: string) => Promise<unknown>;\n /** Every key in the store — what `removeStale` walks. */\n keys: () => Promise<readonly unknown[]>;\n}\n\nexport interface QueryPersistenceOptions {\n /** The app's name, the key's first part: `kastlan` → `kastlan-query-cache-…`. */\n app: string;\n /** The signed-in account. One cache per account (D5). */\n user: string | number;\n /** The acting company, where the app has several (kastlan): the switch changes the key,\n * so no wipe and no cross-company leak — provided the app switches as\n * {@link QueryPersistence.leave} says (leave, then remount the provider per key). */\n company?: string | number | null;\n /** The app's own rule, beside `denyRoots` — keksdose denies one exact key and every key\n * whose second segment is `\"admin\"`. `false` keeps the query out. */\n shouldPersistKey?: (queryKey: readonly unknown[]) => boolean;\n /** Query roots (`queryKey[0]`) that never reach the disk: admin, billing, reports… */\n denyRoots?: readonly string[];\n /** Replaces {@link DEFAULT_SCRUB_FIELDS}; spread it to add to it. `[]` scrubs nothing.\n * A paused mutation whose variables carry one of these fields is never persisted (a\n * scrubbed mutation would send something else), and one an older build wrote is dropped\n * on restore. */\n scrubFields?: readonly ScrubField[];\n /** Told when a query's data is not plain JSON and is therefore not persisted. Default:\n * `console.error`, as keksdose's `cacheSafeOrWarn`. */\n onUnsafe?: (queryKey: readonly unknown[], data: unknown) => void;\n /** Changes with each deploy (the app version), so a new build discards the old cache. */\n buster: string;\n /** How long a persisted cache is good for, in ms (keksdose: 24 h). The QueryClient's\n * `gcTime` must be at least this, or restore drops every query. */\n maxAge: number;\n /** Default: IndexedDB (idb-keyval). Tests pass an in-memory one. */\n storage?: QueryPersistenceStorage;\n /** Default 1000 ms, as keksdose. */\n throttleTime?: number;\n /**\n * 0.46 (kastlan K1): whether paused mutations are written with the cache. Default `true`\n * (keksdose: an offline write waits in TanStack's queue and is sent after a reload).\n * `false` keeps every mutation off the disk, and drops any an older build wrote on\n * restore — for an app that queues offline writes in its own outbox (kastlan's Dexie\n * outbox), instead of overriding `dehydrateOptions` by hand.\n */\n persistMutations?: boolean;\n}\n\n/**\n * One persisted cache, for one key. **Mount it per key**: the provider subscribes once and\n * ignores a changed `persistOptions`, so give it the key as its React key —\n *\n * ```tsx\n * <PersistQueryClientProvider key={persistence.key} client={queryClient} persistOptions={persistence.persistOptions}>\n * ```\n *\n * — and create the persistence once the account (and company) is known (`useMemo` on\n * them); before sign-in, mount the plain `QueryClientProvider`. On a company switch, call\n * {@link QueryPersistence.leave} on the old one BEFORE the new key mounts, instead of a bare\n * `queryClient.clear()`: otherwise the old subscription saves the emptied (or the next\n * company's) cache under the old key.\n */\nexport interface QueryPersistence {\n /** `<app>-query-cache-<user>[-<company>]`. */\n key: string;\n /** Everything `<PersistQueryClientProvider persistOptions>` needs. */\n persistOptions: Omit<PersistQueryClientOptions, \"queryClient\">;\n /** Sign-out: stops writing under this key, empties the in-memory cache and deletes this\n * key's persisted copy. Best-effort; never throws. */\n clear: (queryClient: QueryClient) => Promise<void>;\n /** The company switch (or any change of key without a sign-out): stops writing under this\n * key — a save already scheduled is dropped too — and empties the in-memory cache, so\n * the next key's provider starts clean. The persisted copy stays for the way back. A\n * provider that mounts this persistence again re-arms it with its restore. Never throws. */\n leave: (queryClient: QueryClient) => Promise<void>;\n /** At sign-in: deletes this app's OTHER persisted caches older than `maxAge` (another\n * account's, another company's). Other apps' keys and fresh ones stay. Never throws. */\n removeStale: () => Promise<void>;\n}\n\nconst idbStorage: QueryPersistenceStorage = {\n getItem: (key) => get<string>(key),\n setItem: (key, value) => set(key, value),\n removeItem: (key) => del(key),\n keys: () => keys(),\n};\n\nexport interface RemoveStaleQueryCopiesOptions {\n /** Default: IndexedDB (idb-keyval), the store {@link createQueryPersistence} writes to. */\n storage?: QueryPersistenceStorage;\n /** One key to spare whatever its age — the signed-in account's own\n * (`QueryPersistence.removeStale` passes its key). */\n keep?: string;\n}\n\n/**\n * 0.46 (kastlan K2): deletes this app's persisted query caches (`<app>-query-cache-…`)\n * older than `maxAge`, with no persistence at hand — for the sign-in page, where no\n * account is known. A session that ended with no user known (a refresh token refused at\n * boot) cannot name its key for `clear`; this sweeps its copy once it is stale, without\n * waiting for someone's next sign-in. Other apps' keys, other stores' keys and fresh\n * caches stay; an unreadable one is stale. Never throws.\n * {@link QueryPersistence.removeStale} is this with `keep` = its own key.\n */\nexport async function removeStaleQueryCopies(\n app: string,\n maxAge: number,\n { storage = idbStorage, keep }: RemoveStaleQueryCopiesOptions = {},\n): Promise<void> {\n const prefix = `${app}-query-cache-`;\n try {\n const now = Date.now();\n for (const k of await storage.keys()) {\n if (typeof k !== \"string\" || !k.startsWith(prefix) || k === keep) continue;\n let timestamp = 0;\n try {\n const raw = await storage.getItem(k);\n const parsed: unknown = raw ? JSON.parse(raw) : null;\n if (parsed && typeof parsed === \"object\" && typeof (parsed as PersistedClient).timestamp === \"number\") {\n timestamp = (parsed as PersistedClient).timestamp;\n }\n } catch {\n /* unreadable: stale */\n }\n if (now - timestamp > maxAge) await storage.removeItem(k);\n }\n } catch {\n /* best-effort */\n }\n}\n\n/**\n * True when `data` still means the same thing after JSON.stringify and JSON.parse —\n * keksdose's `isCacheSafe` (query-client.ts:165). Class instances, Map/Set/Date/Blob, a\n * non-finite number, a function: all refused. An `undefined` property survives.\n */\nexport function isCacheSafe(data: unknown, depth = 0): boolean {\n if (depth > 12) return false;\n if (data === null) return true;\n const t = typeof data;\n if (t === \"string\" || t === \"boolean\") return true;\n if (t === \"number\") return Number.isFinite(data as number);\n if (t !== \"object\") return false;\n if (Array.isArray(data)) return data.every((v) => isCacheSafe(v, depth + 1));\n const proto: unknown = Object.getPrototypeOf(data);\n if (proto !== Object.prototype && proto !== null) return false;\n return Object.values(data as Record<string, unknown>).every((v) => v === undefined || isCacheSafe(v, depth + 1));\n}\n\n/** A copy of `data` without the scrubbed fields, at any depth. The input is not touched. */\nfunction scrub(data: unknown, fields: readonly ScrubField[]): unknown {\n if (fields.length === 0 || data === null || typeof data !== \"object\") return data;\n if (Array.isArray(data)) return data.map((v) => scrub(v, fields));\n const out: Record<string, unknown> = {};\n for (const [name, value] of Object.entries(data as Record<string, unknown>)) {\n if (fields.some((f) => (typeof f === \"string\" ? f === name : f.test(name)))) continue;\n out[name] = scrub(value, fields);\n }\n return out;\n}\n\nconst defaultOnUnsafe = (queryKey: readonly unknown[]) => {\n console.error(\n `[query-cache] refusing to persist ${JSON.stringify(queryKey)}: the value is not plain JSON ` +\n `(a class instance, Map, Set, Date or Blob). Return plain data from the queryFn and build ` +\n `the rich object at the call site.`,\n );\n};\n\nexport function createQueryPersistence({\n app,\n user,\n company,\n shouldPersistKey,\n denyRoots = [],\n scrubFields = DEFAULT_SCRUB_FIELDS,\n onUnsafe = defaultOnUnsafe,\n buster,\n maxAge,\n storage = idbStorage,\n throttleTime = 1000,\n persistMutations = true,\n}: QueryPersistenceOptions): QueryPersistence {\n const prefix = `${app}-query-cache-`;\n const key = `${prefix}${user}${company === undefined || company === null || company === \"\" ? \"\" : `-${company}`}`;\n const denied = new Set(denyRoots);\n // Set by `leave` and `clear`: a write under this key after the person left it (a\n // throttled save of the emptied cache) is dropped. A restore re-arms it.\n let retired = false;\n\n const keyAllowed = (queryKey: readonly unknown[]) =>\n !denied.has(String(queryKey[0])) && (shouldPersistKey ? shouldPersistKey(queryKey) : true);\n\n /** The write side: a success, an allowed key, plain JSON (or the warning). */\n const shouldDehydrateQuery = (query: {\n queryKey: readonly unknown[];\n state: { status: string; data?: unknown };\n }): boolean => {\n if (query.state.status !== \"success\" || !keyAllowed(query.queryKey)) return false;\n if (isCacheSafe(query.state.data)) return true;\n onUnsafe(query.queryKey, query.state.data);\n return false;\n };\n\n /** One persisted query entry, scrubbed; `null` when it may not be restored. */\n const restorable = (entry: unknown): unknown => {\n if (!entry || typeof entry !== \"object\") return null;\n const { queryKey, state } = entry as { queryKey?: unknown; state?: { data?: unknown } };\n if (!Array.isArray(queryKey) || !keyAllowed(queryKey)) return null;\n if (!isCacheSafe(state?.data)) return null;\n return withScrubbedData(entry);\n };\n\n /** Whether `value` has a scrubbed field's name anywhere in it. */\n const carriesScrubbed = (value: unknown, depth = 0): boolean => {\n if (scrubFields.length === 0 || depth > 12 || value === null || typeof value !== \"object\") return false;\n if (Array.isArray(value)) return value.some((v) => carriesScrubbed(v, depth + 1));\n return Object.entries(value as Record<string, unknown>).some(\n ([name, v]) =>\n scrubFields.some((f) => (typeof f === \"string\" ? f === name : f.test(name))) || carriesScrubbed(v, depth + 1),\n );\n };\n\n /** The write side for mutations: TanStack's default (paused ones), minus any whose\n * variables carry a scrubbed field (S6); none at all with `persistMutations: false`. */\n const shouldDehydrateMutation = (mutation: { state: { isPaused: boolean; variables?: unknown } }): boolean =>\n persistMutations && mutation.state.isPaused && !carriesScrubbed(mutation.state.variables);\n\n const withScrubbedData = (entry: unknown): unknown => {\n const query = entry as { state?: { data?: unknown } };\n if (!query.state || !(\"data\" in query.state)) return entry;\n return { ...query, state: { ...query.state, data: scrub(query.state.data, scrubFields) } };\n };\n\n // The scrub on the way OUT: the in-memory cache keeps the field (the page that is open\n // shows it), the disk never sees it. Mutations are a paused write queue and are kept\n // whole — scrubbing one would change what it sends — so one carrying a scrubbed field is\n // not written at all (`shouldDehydrateMutation`).\n const serialize = (client: PersistedClient): string =>\n JSON.stringify({\n ...client,\n clientState: { ...client.clientState, queries: client.clientState.queries.map(withScrubbedData) },\n });\n\n // The twin on the way IN (keksdose's `sanitizePersistedCache`, :264-298): an older build\n // wrote what this one may deny. Never throws; an unreadable blob comes back with\n // `timestamp: 0`, which the restore treats as unusable and removes.\n const deserialize = (raw: string): PersistedClient => {\n const empty: PersistedClient = { timestamp: 0, buster: \"\", clientState: { mutations: [], queries: [] } };\n try {\n const parsed: unknown = JSON.parse(raw);\n if (!parsed || typeof parsed !== \"object\") return empty;\n const { timestamp, buster: stored, clientState } = parsed as Partial<PersistedClient>;\n if (typeof timestamp !== \"number\" || typeof stored !== \"string\") return empty;\n if (!clientState || typeof clientState !== \"object\" || !Array.isArray(clientState.queries)) return empty;\n return {\n timestamp,\n buster: stored,\n clientState: {\n mutations:\n persistMutations && Array.isArray(clientState.mutations)\n ? clientState.mutations.filter(\n (m) => !carriesScrubbed((m as { state?: { variables?: unknown } } | null)?.state?.variables),\n )\n : [],\n queries: clientState.queries\n .map(restorable)\n .filter((q) => q !== null) as PersistedClient[\"clientState\"][\"queries\"],\n },\n };\n } catch {\n return empty;\n }\n };\n\n const persister = createAsyncStoragePersister({\n storage: {\n // Only the provider's restore reads: mounting this persistence again re-arms it.\n getItem: async (k) => {\n retired = false;\n return (await storage.getItem(k)) ?? null;\n },\n setItem: async (k, v) => {\n if (!retired) await storage.setItem(k, v);\n },\n removeItem: async (k) => {\n await storage.removeItem(k);\n },\n },\n key,\n throttleTime,\n serialize,\n deserialize,\n });\n\n const leave = async (queryClient: QueryClient) => {\n retired = true;\n try {\n queryClient.clear();\n } catch {\n /* best-effort */\n }\n };\n\n const clear = async (queryClient: QueryClient) => {\n retired = true;\n try {\n queryClient.clear();\n await persister.removeClient();\n } catch {\n /* best-effort: private mode / IndexedDB unavailable */\n }\n };\n\n const removeStale = () => removeStaleQueryCopies(app, maxAge, { storage, keep: key });\n\n return {\n key,\n persistOptions: { persister, maxAge, buster, dehydrateOptions: { shouldDehydrateQuery, shouldDehydrateMutation } },\n clear,\n leave,\n removeStale,\n };\n}\n"],"mappings":"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","names":[]}
|
package/dist/offline.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { createQueryPersistence, DEFAULT_SCRUB_FIELDS, isCacheSafe, removeStaleQueryCopies } from "./offline/query-persistence.js";
|
|
1
|
+
export { createQueryPersistence, DEFAULT_SCRUB_FIELDS, isCacheSafe, removeStaleQueryCopies, } from "./offline/query-persistence.js";
|
|
2
2
|
export type { QueryPersistence, QueryPersistenceOptions, QueryPersistenceStorage, RemoveStaleQueryCopiesOptions, ScrubField, } from "./offline/query-persistence.js";
|
|
3
3
|
export { SignOutPendingDialog, DEFAULT_SIGN_OUT_PENDING_DIALOG_LABELS } from "./offline/sign-out-pending-dialog.js";
|
|
4
4
|
export type { SignOutPendingDialogProps, SignOutPendingDialogLabels } from "./offline/sign-out-pending-dialog.js";
|
package/dist/offline.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
createQueryPersistence,
|
|
4
|
+
DEFAULT_SCRUB_FIELDS,
|
|
5
|
+
isCacheSafe,
|
|
6
|
+
removeStaleQueryCopies
|
|
7
|
+
} from "./offline/query-persistence.js";
|
|
3
8
|
import { SignOutPendingDialog, DEFAULT_SIGN_OUT_PENDING_DIALOG_LABELS } from "./offline/sign-out-pending-dialog.js";
|
|
4
9
|
import { PwaUpdatePrompt, DEFAULT_PWA_UPDATE_PROMPT_LABELS } from "./offline/pwa-update-prompt.js";
|
|
5
10
|
import { createServiceWorkerUpdates, useServiceWorkerUpdate } from "./offline/service-worker-updates.js";
|
package/dist/offline.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/offline.ts"],"sourcesContent":["// `@eifi1/ui-kit/offline` — local-first phase 1 (docs/local-first-harmonization.md §1.6,\n// 0.45): the persisted query cache with its scrub list, the sign-out question while changes\n// wait, the service-worker update prompt, and the shared-computer choice.\n//\n// The only entry point that requires `@tanstack/react-query`,\n// `@tanstack/react-query-persist-client`, `@tanstack/query-async-storage-persister` and\n// `idb-keyval` — optional peers for that reason. NOT re-exported from the main barrel, which\n// would make them everyone's install; the packaging contract test holds that line.\nexport {
|
|
1
|
+
{"version":3,"sources":["../src/offline.ts"],"sourcesContent":["// `@eifi1/ui-kit/offline` — local-first phase 1 (docs/local-first-harmonization.md §1.6,\n// 0.45): the persisted query cache with its scrub list, the sign-out question while changes\n// wait, the service-worker update prompt, and the shared-computer choice.\n//\n// The only entry point that requires `@tanstack/react-query`,\n// `@tanstack/react-query-persist-client`, `@tanstack/query-async-storage-persister` and\n// `idb-keyval` — optional peers for that reason. NOT re-exported from the main barrel, which\n// would make them everyone's install; the packaging contract test holds that line.\nexport {\n createQueryPersistence,\n DEFAULT_SCRUB_FIELDS,\n isCacheSafe,\n removeStaleQueryCopies,\n} from \"./offline/query-persistence\";\nexport type {\n QueryPersistence,\n QueryPersistenceOptions,\n QueryPersistenceStorage,\n RemoveStaleQueryCopiesOptions,\n ScrubField,\n} from \"./offline/query-persistence\";\nexport { SignOutPendingDialog, DEFAULT_SIGN_OUT_PENDING_DIALOG_LABELS } from \"./offline/sign-out-pending-dialog\";\nexport type { SignOutPendingDialogProps, SignOutPendingDialogLabels } from \"./offline/sign-out-pending-dialog\";\nexport { PwaUpdatePrompt, DEFAULT_PWA_UPDATE_PROMPT_LABELS } from \"./offline/pwa-update-prompt\";\nexport type { PwaUpdatePromptProps, PwaUpdatePromptLabels } from \"./offline/pwa-update-prompt\";\n// 0.46 (kastlan K3): the service worker registered once at start, its update state as a store.\nexport { createServiceWorkerUpdates, useServiceWorkerUpdate } from \"./offline/service-worker-updates\";\nexport type { RegisterServiceWorker, ServiceWorkerUpdates } from \"./offline/service-worker-updates\";\nexport {\n SharedComputerChoice,\n DEFAULT_SHARED_COMPUTER_LABELS,\n isSharedComputer,\n setSharedComputer,\n} from \"./offline/shared-computer\";\nexport type { SharedComputerChoiceProps, SharedComputerLabels, SharedComputerStorage } from \"./offline/shared-computer\";\n// 0.46 (§2.3): the leaf utilities, shared with `@eifi1/ui-kit/sync` from one module that imports no peer.\nexport { isOfflineError, readLocalFirst, stampAfter, uuid5, writeLocalFirst } from \"./offline/local-first\";\n"],"mappings":";AAQA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAQP,SAAS,sBAAsB,8CAA8C;AAE7E,SAAS,iBAAiB,wCAAwC;AAGlE,SAAS,4BAA4B,8BAA8B;AAEnE;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAGP,SAAS,gBAAgB,gBAAgB,YAAY,OAAO,uBAAuB;","names":[]}
|
package/dist/pay/pay.css
CHANGED
|
@@ -42,7 +42,14 @@ body {
|
|
|
42
42
|
min-height: 100vh;
|
|
43
43
|
background: var(--pay-bg);
|
|
44
44
|
color: var(--pay-text);
|
|
45
|
-
font-family:
|
|
45
|
+
font-family:
|
|
46
|
+
system-ui,
|
|
47
|
+
-apple-system,
|
|
48
|
+
"Segoe UI",
|
|
49
|
+
Roboto,
|
|
50
|
+
"Helvetica Neue",
|
|
51
|
+
Arial,
|
|
52
|
+
sans-serif;
|
|
46
53
|
font-size: 1rem;
|
|
47
54
|
line-height: 1.5;
|
|
48
55
|
}
|
package/dist/rhf/fields.d.ts
CHANGED
|
@@ -409,8 +409,7 @@ export type RhfSelectProps<TFieldValues extends FieldValues = FieldValues, TName
|
|
|
409
409
|
};
|
|
410
410
|
/** A native {@link Select}, from `options` or `<option>` children. */
|
|
411
411
|
export declare function RhfSelect<TFieldValues extends FieldValues = FieldValues, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TTransformed = TFieldValues>({ name, control, rules, label, hint, help, required, disabled, excludeWhenDisabled, className, options, children, placeholder, valueAsNumber, selectClassName, ...selectProps }: RhfSelectProps<TFieldValues, TName, TTransformed>): import("react").JSX.Element;
|
|
412
|
-
export type RhfCheckboxProps<TFieldValues extends FieldValues = FieldValues, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TTransformed = TFieldValues> =
|
|
413
|
-
Omit<RhfFieldBaseProps<TFieldValues, TName, TTransformed>, "help"> & Omit<CheckboxProps, BoundByForm | "checked" | "defaultChecked" | "onCheckedChange" | "description">;
|
|
412
|
+
export type RhfCheckboxProps<TFieldValues extends FieldValues = FieldValues, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TTransformed = TFieldValues> = Omit<RhfFieldBaseProps<TFieldValues, TName, TTransformed>, "help"> & Omit<CheckboxProps, BoundByForm | "checked" | "defaultChecked" | "onCheckedChange" | "description">;
|
|
414
413
|
/**
|
|
415
414
|
* The kit's {@link Checkbox}, label beside the box and `hint` under it (the
|
|
416
415
|
* checkbox's own `description`). Stores a boolean. `required` is the native one here
|
package/dist/rhf/fields.js
CHANGED
|
@@ -522,16 +522,7 @@ function RangeBody({
|
|
|
522
522
|
messages.map((message, i) => (
|
|
523
523
|
// FormMessage's look and its rule: no `role="alert"` — read when focus reaches
|
|
524
524
|
// the trigger, which names these in its description.
|
|
525
|
-
/* @__PURE__ */ jsx(
|
|
526
|
-
"p",
|
|
527
|
-
{
|
|
528
|
-
id: messageIds[i],
|
|
529
|
-
"data-slot": "form-message",
|
|
530
|
-
className: "text-caption leading-tight text-danger",
|
|
531
|
-
children: message
|
|
532
|
-
},
|
|
533
|
-
message
|
|
534
|
-
)
|
|
525
|
+
/* @__PURE__ */ jsx("p", { id: messageIds[i], "data-slot": "form-message", className: "text-caption leading-tight text-danger", children: message }, message)
|
|
535
526
|
))
|
|
536
527
|
] });
|
|
537
528
|
}
|