@micha.bigler/ui-core-micha 3.14.3 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +7 -0
- package/dist/auth/UserMenu.js +1 -1
- package/dist/auth/apiClient.js +7 -6
- package/dist/chat/ChatList.js +1 -1
- package/dist/chat/ChatThread.js +2 -1
- package/dist/chat/ChatWorkspace.js +1 -1
- package/dist/chat/ModelPicker.js +2 -5
- package/dist/chat/kitSxRegistry.js +9 -0
- package/dist/chat/useChatSocket.js +1 -1
- package/dist/components/LoginForm.js +1 -1
- package/dist/components/SecurityComponent.js +1 -1
- package/dist/components/UserListComponent.js +2 -8
- package/dist/components/charts/ChartFrame.js +1 -1
- package/dist/components/charts/ScatterChart.js +2 -1
- package/dist/components/charts/kitSxRegistry.js +6 -0
- package/dist/entries/charts.js +6 -0
- package/dist/entries/chat.js +8 -0
- package/dist/entries/markdown.js +1 -0
- package/dist/index.js +0 -14
- package/dist/layout/SectionNav.js +1 -1
- package/dist/messaging/AttachmentList.js +2 -2
- package/dist/messaging/Composer.js +2 -1
- package/dist/messaging/ConversationLaunchers.js +1 -4
- package/dist/messaging/ConversationList.js +1 -1
- package/dist/messaging/DirectMessageLauncher.js +1 -1
- package/dist/messaging/MessagingProvider.js +0 -0
- package/dist/messaging/PollCard.js +7 -5
- package/dist/messaging/ReadTicks.js +2 -2
- package/dist/messaging/Thread.js +5 -3
- package/dist/notifications/NotificationBell.js +4 -7
- package/dist/notifications/NotificationSettings.js +1 -1
- package/dist/onboarding/OnboardingProvider.js +3 -6
- package/dist/pages/AccountPage.js +1 -2
- package/dist/pages/LoginPage.js +2 -3
- package/dist/pages/PasswordChangePage.js +2 -3
- package/dist/pages/PasswordInvitePage.js +2 -3
- package/dist/pages/PasswordResetRequestPage.js +2 -3
- package/dist/pages/SignUpPage.js +2 -3
- package/dist/pages/SignupConfirmPage.js +2 -3
- package/dist/theme/createAppTheme.js +5 -4
- package/dist/theme/kitSxEntry.js +11 -0
- package/dist/theme/kitSxRegistry.js +2 -15
- package/dist/theme/themeCompleteness.js +3 -12
- package/dist/utils/auth-errors.js +4 -3
- package/package.json +41 -16
- package/.github/workflows/publish.yml +0 -126
- package/DESIGN.md +0 -60
- package/WORK_ORDERS.md +0 -148
- package/dev/entries.jsx +0 -721
- package/dev/fixtures.js +0 -26
- package/dev/main.jsx +0 -75
- package/dev/mockTransport.jsx +0 -128
- package/docs/CHART-LAYOUT.md +0 -157
- package/docs/DARK-TOKENS.md +0 -140
- package/docs/TYPE-SCALE.md +0 -111
- package/docs/messaging-deviations.md +0 -118
- package/index.html +0 -12
- package/renovate.json +0 -10
- package/scripts/chart-api-census.mjs +0 -415
- package/src/auth/AuthContext.jsx +0 -158
- package/src/auth/ReauthModal.jsx +0 -82
- package/src/auth/UserMenu.jsx +0 -121
- package/src/auth/apiClient.jsx +0 -232
- package/src/auth/authApi.jsx +0 -539
- package/src/auth/authConfig.jsx +0 -34
- package/src/auth/reauth.js +0 -47
- package/src/chat/ChatComposer.jsx +0 -36
- package/src/chat/ChatList.jsx +0 -87
- package/src/chat/ChatThread.jsx +0 -128
- package/src/chat/ChatWorkspace.jsx +0 -244
- package/src/chat/MarkdownReply.jsx +0 -22
- package/src/chat/ModelPicker.jsx +0 -85
- package/src/chat/api.js +0 -18
- package/src/chat/useChatSocket.js +0 -32
- package/src/chat/wire.js +0 -7
- package/src/components/AccessCodeManager.jsx +0 -288
- package/src/components/AccessCodeSingleUseToggle.jsx +0 -92
- package/src/components/AllowedEmailDomainsManager.jsx +0 -92
- package/src/components/AuthFactorRequirementCard.jsx +0 -95
- package/src/components/BulkInviteCsvTab.jsx +0 -198
- package/src/components/EmptyState.jsx +0 -38
- package/src/components/LoginForm.jsx +0 -159
- package/src/components/MFAComponent.jsx +0 -350
- package/src/components/MfaLoginComponent.jsx +0 -252
- package/src/components/MobileBottomNav.jsx +0 -82
- package/src/components/PasskeysComponent.jsx +0 -252
- package/src/components/PasswordChangeForm.jsx +0 -87
- package/src/components/PasswordResetRequestForm.jsx +0 -43
- package/src/components/PasswordSetForm.jsx +0 -97
- package/src/components/ProfileComponent.jsx +0 -348
- package/src/components/QrSignupManager.jsx +0 -404
- package/src/components/QrSignupValidityManager.jsx +0 -106
- package/src/components/RegistrationMethodsManager.jsx +0 -150
- package/src/components/SecurityComponent.jsx +0 -141
- package/src/components/SocialLoginButtons.jsx +0 -83
- package/src/components/SoftChip.jsx +0 -93
- package/src/components/StatTile.jsx +0 -39
- package/src/components/SupportRecoveryRequestsTab.jsx +0 -482
- package/src/components/TurnstileWidget.jsx +0 -99
- package/src/components/UserInviteComponent.jsx +0 -76
- package/src/components/UserListComponent.jsx +0 -618
- package/src/components/charts/BarChart.jsx +0 -102
- package/src/components/charts/ChartFrame.jsx +0 -214
- package/src/components/charts/LineChart.jsx +0 -126
- package/src/components/charts/ScatterChart.jsx +0 -299
- package/src/components/charts/TimeSeriesChart.jsx +0 -300
- package/src/components/charts/chartDefaults.js +0 -644
- package/src/components/charts/chartLabels.js +0 -36
- package/src/components/charts/exportChart.js +0 -258
- package/src/components/charts/formatters.js +0 -39
- package/src/components/charts/palette.js +0 -33
- package/src/components/charts/yearTickInterval.js +0 -7
- package/src/i18n/authTranslations.ts +0 -2651
- package/src/i18n/chartsTranslations.ts +0 -91
- package/src/i18n/chatTranslations.ts +0 -55
- package/src/i18n/messagingTranslations.ts +0 -120
- package/src/i18n/notificationsTranslations.ts +0 -32
- package/src/i18n/onboardingTranslations.ts +0 -29
- package/src/i18n/sectionNavTranslations.ts +0 -20
- package/src/i18n/uiCoreTranslations.js +0 -29
- package/src/i18n/userMenuTranslations.ts +0 -5
- package/src/index.js +0 -158
- package/src/layout/PageLayout.jsx +0 -36
- package/src/layout/SectionNav.jsx +0 -320
- package/src/messaging/AttachmentList.jsx +0 -145
- package/src/messaging/Composer.jsx +0 -197
- package/src/messaging/ConversationLaunchers.jsx +0 -51
- package/src/messaging/ConversationList.jsx +0 -109
- package/src/messaging/DirectMessageLauncher.jsx +0 -112
- package/src/messaging/MessageBubble.jsx +0 -183
- package/src/messaging/MessagingProvider.jsx +0 -0
- package/src/messaging/MessagingScopeConfig.jsx +0 -24
- package/src/messaging/PollCard.jsx +0 -163
- package/src/messaging/ReactionBar.jsx +0 -43
- package/src/messaging/ReadTicks.jsx +0 -82
- package/src/messaging/Thread.jsx +0 -195
- package/src/messaging/api.js +0 -48
- package/src/messaging/composerImageCompression.js +0 -35
- package/src/messaging/linkifyText.jsx +0 -37
- package/src/notifications/NotificationBell.jsx +0 -84
- package/src/notifications/NotificationSettings.jsx +0 -330
- package/src/notifications/NotificationsProvider.jsx +0 -224
- package/src/notifications/PopupSurface.jsx +0 -105
- package/src/notifications/api.js +0 -55
- package/src/notifications/feedApi.js +0 -18
- package/src/notifications/realtime.jsx +0 -138
- package/src/notifications/serviceWorker/sw.js +0 -50
- package/src/onboarding/OnboardingProvider.jsx +0 -326
- package/src/onboarding/OnboardingWizard.jsx +0 -61
- package/src/onboarding/WizardDialogShell.jsx +0 -51
- package/src/onboarding/api.js +0 -13
- package/src/onboarding/stepSelection.js +0 -16
- package/src/onboarding/steps/BrowserPushStep.jsx +0 -121
- package/src/onboarding/steps/CompleteNameStep.jsx +0 -46
- package/src/onboarding/steps/CookieConsentStep.jsx +0 -133
- package/src/onboarding/steps/PwaInstallStep.jsx +0 -47
- package/src/pages/AccountPage.jsx +0 -394
- package/src/pages/LoginPage.jsx +0 -343
- package/src/pages/PasswordChangePage.jsx +0 -62
- package/src/pages/PasswordInvitePage.jsx +0 -177
- package/src/pages/PasswordResetRequestPage.jsx +0 -68
- package/src/pages/SignUpPage.jsx +0 -268
- package/src/pages/SignupConfirmPage.jsx +0 -155
- package/src/testing/checkKitIntegration.js +0 -233
- package/src/theme/createAppTheme.js +0 -423
- package/src/theme/fonts.js +0 -3
- package/src/theme/index.js +0 -21
- package/src/theme/kitSxRegistry.js +0 -285
- package/src/theme/themeCompleteness.js +0 -1045
- package/src/theme/tokens.js +0 -478
- package/src/utils/auth-errors.js +0 -76
- package/src/utils/authService.js +0 -223
- package/src/utils/resolveErrorText.js +0 -28
- package/src/utils/webauthn.js +0 -51
- package/tests/AccountPage.profileStatements.test.jsx +0 -162
- package/tests/AccountPage.test.jsx +0 -200
- package/tests/AuthContext.test.jsx +0 -129
- package/tests/BarChart.test.jsx +0 -112
- package/tests/BrowserPushStep.test.jsx +0 -110
- package/tests/ChartFrame.test.jsx +0 -336
- package/tests/ConversationList.test.jsx +0 -144
- package/tests/CookieConsentStep.test.jsx +0 -69
- package/tests/EmptyState.test.jsx +0 -62
- package/tests/LineChart.test.jsx +0 -80
- package/tests/MessagingProvider.test.jsx +0 -50
- package/tests/MobileBottomNav.test.jsx +0 -319
- package/tests/NotificationBell.test.jsx +0 -60
- package/tests/NotificationSettings.test.jsx +0 -276
- package/tests/NotificationsProvider.test.jsx +0 -240
- package/tests/OnboardingProvider.test.jsx +0 -294
- package/tests/OnboardingWizard.test.jsx +0 -143
- package/tests/PasswordChangeForm.test.jsx +0 -70
- package/tests/PasswordInvitePage.test.jsx +0 -142
- package/tests/PasswordSetForm.test.jsx +0 -70
- package/tests/PopupSurface.test.jsx +0 -142
- package/tests/ProfileComponent.refresh.test.jsx +0 -194
- package/tests/QrSignupManager.registrationContext.test.jsx +0 -106
- package/tests/QrSignupManagerInfoBox.test.jsx +0 -49
- package/tests/ScatterChart.test.jsx +0 -270
- package/tests/SectionNav.test.jsx +0 -363
- package/tests/SignUpPage.test.jsx +0 -208
- package/tests/SoftChip.test.jsx +0 -105
- package/tests/StatTile.test.jsx +0 -57
- package/tests/Thread.test.jsx +0 -151
- package/tests/TimeSeriesChart.test.jsx +0 -367
- package/tests/UserListComponent.test.jsx +0 -192
- package/tests/UserMenu.test.jsx +0 -171
- package/tests/apiClientLanguage.test.js +0 -33
- package/tests/apiClientPublicPaths.test.js +0 -178
- package/tests/authApiLogin.test.js +0 -42
- package/tests/authApiRegistration.test.js +0 -31
- package/tests/authErrorCodeRendering.test.jsx +0 -77
- package/tests/authErrors.test.js +0 -74
- package/tests/authTranslations.test.js +0 -74
- package/tests/chart-api-census.test.js +0 -366
- package/tests/chartAxisTickBand.test.jsx +0 -95
- package/tests/chartDefaults.test.js +0 -902
- package/tests/chartLabels.test.js +0 -50
- package/tests/chartsPalette.test.js +0 -99
- package/tests/chatApi.test.js +0 -48
- package/tests/chatDriverNames.test.jsx +0 -71
- package/tests/chatExports.test.js +0 -3
- package/tests/chatList.test.jsx +0 -84
- package/tests/chatPicker.test.jsx +0 -82
- package/tests/chatSocket.test.jsx +0 -26
- package/tests/chatThread.test.jsx +0 -116
- package/tests/chatTranslations.test.js +0 -47
- package/tests/chatWorkspace.test.jsx +0 -234
- package/tests/checkKitIntegration.test.jsx +0 -219
- package/tests/createAppTheme.test.js +0 -132
- package/tests/createAppThemeDark.test.js +0 -127
- package/tests/createAppThemeLightGuard.test.js +0 -40
- package/tests/createAppThemePageContrast.test.js +0 -108
- package/tests/createAppThemeTypography.test.js +0 -53
- package/tests/errorCallSiteGuard.test.js +0 -32
- package/tests/exportChart.test.js +0 -313
- package/tests/feedApi.test.js +0 -46
- package/tests/fixtures/light-theme-baseline.json +0 -2234
- package/tests/i18nAggregate.test.js +0 -120
- package/tests/i18nGermanVariant.test.js +0 -65
- package/tests/loginPageErrorResolution.test.jsx +0 -102
- package/tests/messagingApi.test.js +0 -32
- package/tests/messagingChunk6.test.jsx +0 -116
- package/tests/messagingCompactBubble.test.jsx +0 -64
- package/tests/messagingComposer.test.jsx +0 -136
- package/tests/messagingComposerChunk5.test.jsx +0 -113
- package/tests/messagingContractConformance.test.js +0 -69
- package/tests/messagingDirectMessageLauncher.test.jsx +0 -151
- package/tests/messagingExports.test.js +0 -21
- package/tests/messagingInteractions.test.jsx +0 -136
- package/tests/messagingLastMessagePreview.test.jsx +0 -93
- package/tests/messagingLinkify.test.jsx +0 -51
- package/tests/messagingMessageActions.test.jsx +0 -163
- package/tests/messagingMsg6.test.jsx +0 -105
- package/tests/messagingMsg6f.test.jsx +0 -155
- package/tests/messagingMsg6g.test.jsx +0 -166
- package/tests/messagingMsg6h.test.jsx +0 -136
- package/tests/messagingMsg6i.test.jsx +0 -129
- package/tests/messagingPollRendering.test.jsx +0 -142
- package/tests/messagingReactionMerge.test.jsx +0 -77
- package/tests/messagingReadTicksTooltip.test.jsx +0 -36
- package/tests/messagingThreadReceipts.test.jsx +0 -178
- package/tests/messagingThreadingQuoting.test.jsx +0 -87
- package/tests/messagingUnreadLifecycle.test.jsx +0 -78
- package/tests/notificationsApi.test.js +0 -41
- package/tests/notificationsExports.test.js +0 -34
- package/tests/onboardingDescriptors.test.js +0 -132
- package/tests/packageTreeShaking.test.js +0 -126
- package/tests/realtime.test.jsx +0 -219
- package/tests/resolveErrorText.test.js +0 -72
- package/tests/stepSelection.test.js +0 -53
- package/tests/themeCompleteness.test.js +0 -626
- package/tests/timeSeriesLegend.test.js +0 -99
- package/tests/userScreenTranslations.test.jsx +0 -218
- package/tsconfig.build.json +0 -18
- package/vite.config.mjs +0 -6
- package/work-orders/AUTH-1.md +0 -74
- package/work-orders/AUTH-2.md +0 -74
- package/work-orders/AUTH-4.md +0 -183
- package/work-orders/AUTH-5.md +0 -154
- package/work-orders/AUTH-7.md +0 -181
- package/work-orders/AUTH-8.md +0 -184
- package/work-orders/CHART-1.md +0 -285
- package/work-orders/CHART-2.md +0 -222
- package/work-orders/CHART-3.md +0 -163
- package/work-orders/CHART-4.md +0 -203
- package/work-orders/CHART-5.md +0 -226
- package/work-orders/CHART-6.md +0 -428
- package/work-orders/CHART-7.md +0 -175
- package/work-orders/DX-1.md +0 -169
- package/work-orders/DX-2.md +0 -188
- package/work-orders/DX-3.md +0 -195
- package/work-orders/I18N-1.md +0 -254
- package/work-orders/I18N-3.md +0 -207
- package/work-orders/MSG-3.md +0 -375
- package/work-orders/MSG-3b.md +0 -1002
- package/work-orders/MSG-3c.md +0 -378
- package/work-orders/MSG-3d.md +0 -268
- package/work-orders/MSG-3e.md +0 -164
- package/work-orders/MSG-3f.md +0 -391
- package/work-orders/MSG-6e.md +0 -308
- package/work-orders/MSG-6f.md +0 -214
- package/work-orders/MSG-6g.md +0 -244
- package/work-orders/MSG-6h.md +0 -238
- package/work-orders/MSG-6i.md +0 -271
- package/work-orders/MSG-6j.md +0 -117
- package/work-orders/NOTIF-12.md +0 -214
- package/work-orders/NOTIF-13.md +0 -142
- package/work-orders/NOTIF-14.md +0 -151
- package/work-orders/PRIM-1.md +0 -177
- package/work-orders/SHELL-1.md +0 -255
- package/work-orders/SHELL-2.md +0 -197
- package/work-orders/SHELL-3.md +0 -359
- package/work-orders/SHELL-4.md +0 -351
- package/work-orders/SHELL-5.md +0 -345
- package/work-orders/SHELL-6.md +0 -210
- package/work-orders/THEME-1.md +0 -587
- package/work-orders/THEME-10.md +0 -303
- package/work-orders/THEME-11.md +0 -208
- package/work-orders/THEME-2.md +0 -339
- package/work-orders/THEME-3.md +0 -210
- package/work-orders/THEME-4.md +0 -271
- package/work-orders/THEME-5.md +0 -341
- package/work-orders/THEME-6.md +0 -247
- package/work-orders/THEME-7.md +0 -82
- package/work-orders/THEME-8.md +0 -211
- package/work-orders/THEME-9.md +0 -194
- package/work-orders/UCM-AUTH-10.md +0 -490
- package/work-orders/UCM-AUTH-11.md +0 -185
- package/work-orders/UCM-AUTH-12.md +0 -98
- package/work-orders/UCM-AUTH-13.md +0 -47
- package/work-orders/UCM-AUTH-9.md +0 -207
- package/work-orders/UCM-CHART-10.md +0 -150
- package/work-orders/UCM-CHART-11.md +0 -132
- package/work-orders/UCM-CHART-12.md +0 -348
- package/work-orders/UCM-CHART-13.md +0 -122
- package/work-orders/UCM-CHART-14.md +0 -198
- package/work-orders/UCM-CHART-15.md +0 -187
- package/work-orders/UCM-CHART-16.md +0 -169
- package/work-orders/UCM-CHART-17.md +0 -195
- package/work-orders/UCM-CHART-18.md +0 -200
- package/work-orders/UCM-CHART-8.md +0 -147
- package/work-orders/UCM-CHART-9.md +0 -265
- package/work-orders/UCM-CHAT-1.md +0 -190
- package/work-orders/UCM-CHAT-2.md +0 -75
- package/work-orders/UCM-DEP-1.md +0 -63
- package/work-orders/UCM-I18N-4.md +0 -226
- package/work-orders/UCM-I18N-5.md +0 -211
- package/work-orders/UCM-MSG-20.md +0 -192
- package/work-orders/UCM-PRIM-2.md +0 -114
- package/work-orders/UCM-SHELL-7.md +0 -134
- package/work-orders/UCM-SHELL-8.md +0 -125
- package/work-orders/UCM-TEST-1.md +0 -304
- package/work-orders/UCM-THEME-13.md +0 -69
- package/work-orders/UCM-THEME-14.md +0 -312
- package/work-orders/UCM-THEME-15.md +0 -356
- package/work-orders/UCM-THEME-16.md +0 -80
- package/work-orders/UCM-THEME-17.md +0 -111
- package/work-orders/UCM-THEME-18.md +0 -184
- package/work-orders/assets/AUTH-4-user-list-card.html +0 -407
- package/work-orders/assets/PRIM-1-dashboard-primitives.html +0 -624
- package/work-orders/assets/SHELL-2-account-navigation.html +0 -1159
- package/work-orders/assets/THEME-1-baseline-reference-sheet.html +0 -1352
- package/work-orders/assets/UCM-CHAT-1-ai-chat.html +0 -447
- package/work-orders/assets/UCM-THEME-13-dark-prototype.html +0 -390
- package/work-orders/assets/UCM-THEME-17-type-scale.html +0 -391
|
@@ -1,330 +0,0 @@
|
|
|
1
|
-
import React, { useEffect, useState } from 'react';
|
|
2
|
-
import { useTranslation } from 'react-i18next';
|
|
3
|
-
import Alert from '@mui/material/Alert';
|
|
4
|
-
import Box from '@mui/material/Box';
|
|
5
|
-
import CircularProgress from '@mui/material/CircularProgress';
|
|
6
|
-
import Divider from '@mui/material/Divider';
|
|
7
|
-
import FormControlLabel from '@mui/material/FormControlLabel';
|
|
8
|
-
import Switch from '@mui/material/Switch';
|
|
9
|
-
import Typography from '@mui/material/Typography';
|
|
10
|
-
import NotificationsIcon from '@mui/icons-material/Notifications';
|
|
11
|
-
import {
|
|
12
|
-
getNotificationPreferences,
|
|
13
|
-
getVapidPublicKey,
|
|
14
|
-
patchNotificationPreferences,
|
|
15
|
-
removePushSubscription,
|
|
16
|
-
savePushSubscription,
|
|
17
|
-
setNotificationCategorySubscription,
|
|
18
|
-
urlBase64ToUint8Array,
|
|
19
|
-
} from './api';
|
|
20
|
-
|
|
21
|
-
export const NOTIFICATION_SETTINGS_ALERT_SX = { mb: 2 };
|
|
22
|
-
export const NOTIFICATION_SETTINGS_HINT_ALERT_SX = { mb: 1.5 };
|
|
23
|
-
export const NOTIFICATION_SETTINGS_REACH_ALERT_SX = { mb: 1 };
|
|
24
|
-
export const NOTIFICATION_SETTINGS_FORM_CONTROL_SX = { alignItems: 'flex-start', ml: 0 };
|
|
25
|
-
export const NOTIFICATION_SETTINGS_PUSH_CONTROL_SX = {
|
|
26
|
-
alignItems: 'flex-start',
|
|
27
|
-
ml: 0,
|
|
28
|
-
mt: 1.5,
|
|
29
|
-
};
|
|
30
|
-
export const NOTIFICATION_SETTINGS_CATEGORY_CONTROL_SX = {
|
|
31
|
-
alignItems: 'center',
|
|
32
|
-
ml: 0,
|
|
33
|
-
display: 'flex',
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
function reachLabelKey(notificationType) {
|
|
37
|
-
const noActiveChannel = notificationType.active && notificationType.has_active_channel === false;
|
|
38
|
-
if (notificationType.active && notificationType.passive) {
|
|
39
|
-
// Scope C's bounded fallback: a "both" type still reaches the user passively
|
|
40
|
-
// (chip) when no active channel is configured -- it is not undelivered, so it
|
|
41
|
-
// must not show the "does not reach you" warning meant for active-only types.
|
|
42
|
-
return noActiveChannel ? 'NotificationSettings.REACH_PASSIVE' : 'NotificationSettings.REACH_BOTH';
|
|
43
|
-
}
|
|
44
|
-
if (notificationType.active) {
|
|
45
|
-
return noActiveChannel ? 'NotificationSettings.REACH_NO_ACTIVE_CHANNEL' : 'NotificationSettings.REACH_ACTIVE';
|
|
46
|
-
}
|
|
47
|
-
return 'NotificationSettings.REACH_PASSIVE';
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function getPushSupport() {
|
|
51
|
-
return typeof navigator !== 'undefined'
|
|
52
|
-
&& typeof window !== 'undefined'
|
|
53
|
-
&& 'serviceWorker' in navigator
|
|
54
|
-
&& 'PushManager' in window;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function getIosInstallState() {
|
|
58
|
-
if (typeof navigator === 'undefined' || typeof window === 'undefined') return false;
|
|
59
|
-
const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent || '');
|
|
60
|
-
const standalone = window.matchMedia?.('(display-mode: standalone)').matches
|
|
61
|
-
|| window.navigator.standalone === true;
|
|
62
|
-
return isIos && !standalone;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function NotificationSettings() {
|
|
66
|
-
const { t } = useTranslation();
|
|
67
|
-
const [preferences, setPreferences] = useState(null);
|
|
68
|
-
const [loading, setLoading] = useState(true);
|
|
69
|
-
const [savingEmail, setSavingEmail] = useState(false);
|
|
70
|
-
const [savingPush, setSavingPush] = useState(false);
|
|
71
|
-
const [savingPreview, setSavingPreview] = useState(false);
|
|
72
|
-
const [pushSubscribed, setPushSubscribed] = useState(false);
|
|
73
|
-
const [error, setError] = useState('');
|
|
74
|
-
const [conflict, setConflict] = useState('');
|
|
75
|
-
const [savingSubscription, setSavingSubscription] = useState(null);
|
|
76
|
-
|
|
77
|
-
const pushSupported = getPushSupport();
|
|
78
|
-
const iosNeedsInstall = getIosInstallState();
|
|
79
|
-
|
|
80
|
-
useEffect(() => {
|
|
81
|
-
let cancelled = false;
|
|
82
|
-
|
|
83
|
-
getNotificationPreferences()
|
|
84
|
-
.then((data) => {
|
|
85
|
-
if (!cancelled) setPreferences(data);
|
|
86
|
-
})
|
|
87
|
-
.catch(() => {
|
|
88
|
-
if (!cancelled) setError(t('NotificationSettings.LOAD_ERROR'));
|
|
89
|
-
})
|
|
90
|
-
.finally(() => {
|
|
91
|
-
if (!cancelled) setLoading(false);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
return () => { cancelled = true; };
|
|
95
|
-
}, [t]);
|
|
96
|
-
|
|
97
|
-
useEffect(() => {
|
|
98
|
-
let cancelled = false;
|
|
99
|
-
if (!pushSupported) return undefined;
|
|
100
|
-
|
|
101
|
-
navigator.serviceWorker.ready
|
|
102
|
-
.then((registration) => registration.pushManager.getSubscription())
|
|
103
|
-
.then((subscription) => {
|
|
104
|
-
if (!cancelled) setPushSubscribed(Boolean(subscription));
|
|
105
|
-
})
|
|
106
|
-
.catch(() => {
|
|
107
|
-
if (!cancelled) setPushSubscribed(false);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
return () => { cancelled = true; };
|
|
111
|
-
}, [pushSupported]);
|
|
112
|
-
|
|
113
|
-
const handleEmailToggle = async (event) => {
|
|
114
|
-
const email_opt_in = event.target.checked;
|
|
115
|
-
setSavingEmail(true);
|
|
116
|
-
setError('');
|
|
117
|
-
try {
|
|
118
|
-
const updated = await patchNotificationPreferences({ email_opt_in });
|
|
119
|
-
setPreferences(updated);
|
|
120
|
-
} catch {
|
|
121
|
-
setError(t('NotificationSettings.SAVE_ERROR'));
|
|
122
|
-
} finally {
|
|
123
|
-
setSavingEmail(false);
|
|
124
|
-
}
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
const handleEnablePush = async () => {
|
|
128
|
-
setSavingPush(true);
|
|
129
|
-
setError('');
|
|
130
|
-
setConflict('');
|
|
131
|
-
try {
|
|
132
|
-
const permission = await Notification.requestPermission();
|
|
133
|
-
if (permission !== 'granted') {
|
|
134
|
-
setError(t('NotificationSettings.PUSH_DENIED'));
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
const vapidPublicKey = await getVapidPublicKey();
|
|
138
|
-
const registration = await navigator.serviceWorker.ready;
|
|
139
|
-
const subscription = await registration.pushManager.subscribe({
|
|
140
|
-
userVisibleOnly: true,
|
|
141
|
-
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
|
|
142
|
-
});
|
|
143
|
-
await savePushSubscription(subscription.toJSON(), navigator.userAgent);
|
|
144
|
-
const updated = await patchNotificationPreferences({ push_opt_in: true });
|
|
145
|
-
setPreferences(updated);
|
|
146
|
-
setPushSubscribed(true);
|
|
147
|
-
} catch (requestError) {
|
|
148
|
-
if (requestError?.response?.status === 409) {
|
|
149
|
-
setConflict(t('NotificationSettings.PUSH_CONFLICT'));
|
|
150
|
-
} else {
|
|
151
|
-
setError(t('NotificationSettings.PUSH_ERROR'));
|
|
152
|
-
}
|
|
153
|
-
} finally {
|
|
154
|
-
setSavingPush(false);
|
|
155
|
-
}
|
|
156
|
-
};
|
|
157
|
-
|
|
158
|
-
const handlePreviewToggle = async (event) => {
|
|
159
|
-
const push_preview_opt_in = event.target.checked;
|
|
160
|
-
setSavingPreview(true);
|
|
161
|
-
setError('');
|
|
162
|
-
try {
|
|
163
|
-
const updated = await patchNotificationPreferences({ push_preview_opt_in });
|
|
164
|
-
setPreferences(updated);
|
|
165
|
-
} catch {
|
|
166
|
-
setError(t('NotificationSettings.SAVE_ERROR'));
|
|
167
|
-
} finally {
|
|
168
|
-
setSavingPreview(false);
|
|
169
|
-
}
|
|
170
|
-
};
|
|
171
|
-
|
|
172
|
-
const handleSubscriptionToggle = async (category, event) => {
|
|
173
|
-
const requested = event.target.checked;
|
|
174
|
-
setSavingSubscription(category);
|
|
175
|
-
setError('');
|
|
176
|
-
try {
|
|
177
|
-
const result = await setNotificationCategorySubscription(category, requested);
|
|
178
|
-
// Reflect what the server actually recorded, not the checkbox's optimistic
|
|
179
|
-
// value -- a 2xx response with a different `subscribed` (e.g. a server-side
|
|
180
|
-
// clamp) must not be silently overridden by what the user clicked.
|
|
181
|
-
const confirmed = result?.subscribed ?? requested;
|
|
182
|
-
setPreferences((current) => ({
|
|
183
|
-
...current,
|
|
184
|
-
subscribable_categories: current.subscribable_categories.map((row) => (
|
|
185
|
-
row.category === category ? { ...row, subscribed: confirmed } : row
|
|
186
|
-
)),
|
|
187
|
-
}));
|
|
188
|
-
} catch {
|
|
189
|
-
setError(t('NotificationSettings.SUBSCRIPTION_ERROR'));
|
|
190
|
-
} finally {
|
|
191
|
-
setSavingSubscription(null);
|
|
192
|
-
}
|
|
193
|
-
};
|
|
194
|
-
|
|
195
|
-
const handleDisablePush = async () => {
|
|
196
|
-
setSavingPush(true);
|
|
197
|
-
setError('');
|
|
198
|
-
setConflict('');
|
|
199
|
-
try {
|
|
200
|
-
const registration = await navigator.serviceWorker.ready;
|
|
201
|
-
const subscription = await registration.pushManager.getSubscription();
|
|
202
|
-
if (subscription) {
|
|
203
|
-
const endpoint = subscription.endpoint;
|
|
204
|
-
await subscription.unsubscribe();
|
|
205
|
-
await removePushSubscription({ endpoint });
|
|
206
|
-
}
|
|
207
|
-
setPushSubscribed(false);
|
|
208
|
-
} catch {
|
|
209
|
-
setError(t('NotificationSettings.PUSH_ERROR'));
|
|
210
|
-
} finally {
|
|
211
|
-
setSavingPush(false);
|
|
212
|
-
}
|
|
213
|
-
};
|
|
214
|
-
|
|
215
|
-
if (loading) {
|
|
216
|
-
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
return (
|
|
220
|
-
<Box sx={{ maxWidth: 520 }}>
|
|
221
|
-
<Typography variant="h6" gutterBottom sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
222
|
-
<NotificationsIcon />
|
|
223
|
-
{t('NotificationSettings.TITLE')}
|
|
224
|
-
</Typography>
|
|
225
|
-
<Typography variant="body2" color="text.secondary" gutterBottom>
|
|
226
|
-
{t('NotificationSettings.SUBTITLE')}
|
|
227
|
-
</Typography>
|
|
228
|
-
{error && <Alert severity="error" sx={NOTIFICATION_SETTINGS_ALERT_SX} onClose={() => setError('')}>{error}</Alert>}
|
|
229
|
-
{conflict && <Alert severity="warning" sx={NOTIFICATION_SETTINGS_ALERT_SX} onClose={() => setConflict('')}>{conflict}</Alert>}
|
|
230
|
-
|
|
231
|
-
<Box sx={{ py: 2 }}>
|
|
232
|
-
<FormControlLabel
|
|
233
|
-
control={<Switch checked={Boolean(preferences?.email_opt_in)} onChange={handleEmailToggle} disabled={savingEmail} />}
|
|
234
|
-
label={<Box><Typography variant="body1">{t('NotificationSettings.EMAIL_LABEL')}</Typography><Typography variant="caption" color="text.secondary">{t('NotificationSettings.EMAIL_HINT')}</Typography></Box>}
|
|
235
|
-
labelPlacement="end"
|
|
236
|
-
sx={NOTIFICATION_SETTINGS_FORM_CONTROL_SX}
|
|
237
|
-
/>
|
|
238
|
-
</Box>
|
|
239
|
-
|
|
240
|
-
<Divider />
|
|
241
|
-
|
|
242
|
-
<Box sx={{ py: 2 }}>
|
|
243
|
-
{iosNeedsInstall && <Alert severity="info" sx={NOTIFICATION_SETTINGS_HINT_ALERT_SX}>{t('NotificationSettings.IOS_HINT')}</Alert>}
|
|
244
|
-
{!pushSupported && !iosNeedsInstall && <Alert severity="warning" sx={NOTIFICATION_SETTINGS_HINT_ALERT_SX}>{t('NotificationSettings.PUSH_NOT_SUPPORTED')}</Alert>}
|
|
245
|
-
<FormControlLabel
|
|
246
|
-
control={(
|
|
247
|
-
<Switch
|
|
248
|
-
checked={pushSubscribed}
|
|
249
|
-
onChange={(event) => (event.target.checked ? handleEnablePush() : handleDisablePush())}
|
|
250
|
-
disabled={savingPush || !pushSupported || iosNeedsInstall}
|
|
251
|
-
/>
|
|
252
|
-
)}
|
|
253
|
-
label={<Box><Typography variant="body1">{t('NotificationSettings.PUSH_LABEL')}</Typography><Typography variant="caption" color="text.secondary">{t('NotificationSettings.PUSH_HINT')}</Typography></Box>}
|
|
254
|
-
labelPlacement="end"
|
|
255
|
-
sx={NOTIFICATION_SETTINGS_FORM_CONTROL_SX}
|
|
256
|
-
/>
|
|
257
|
-
<FormControlLabel
|
|
258
|
-
control={(
|
|
259
|
-
<Switch
|
|
260
|
-
checked={preferences?.push_preview_opt_in !== false}
|
|
261
|
-
onChange={handlePreviewToggle}
|
|
262
|
-
disabled={savingPreview || !preferences?.push_opt_in}
|
|
263
|
-
/>
|
|
264
|
-
)}
|
|
265
|
-
label={<Box><Typography variant="body1">{t('NotificationSettings.PUSH_PREVIEW_LABEL')}</Typography><Typography variant="caption" color="text.secondary">{t('NotificationSettings.PUSH_PREVIEW_HINT')}</Typography></Box>}
|
|
266
|
-
labelPlacement="end"
|
|
267
|
-
sx={NOTIFICATION_SETTINGS_PUSH_CONTROL_SX}
|
|
268
|
-
/>
|
|
269
|
-
</Box>
|
|
270
|
-
|
|
271
|
-
{preferences?.notification_types?.length > 0 && (
|
|
272
|
-
<>
|
|
273
|
-
<Divider />
|
|
274
|
-
<Box sx={{ py: 2 }}>
|
|
275
|
-
<Typography variant="subtitle2" gutterBottom>{t('NotificationSettings.REACH_TITLE')}</Typography>
|
|
276
|
-
{preferences.notification_types.map((notificationType) => {
|
|
277
|
-
const labelKey = reachLabelKey(notificationType);
|
|
278
|
-
const reachText = t(labelKey);
|
|
279
|
-
// Reserve Alert (a colored banner) for the one case that is actually a
|
|
280
|
-
// warning -- an active-only type nobody can currently reach. Routine
|
|
281
|
-
// reach description is static settings copy, not a status message.
|
|
282
|
-
if (labelKey === 'NotificationSettings.REACH_NO_ACTIVE_CHANNEL') {
|
|
283
|
-
return (
|
|
284
|
-
<Alert key={notificationType.key} severity="warning" sx={NOTIFICATION_SETTINGS_REACH_ALERT_SX}>
|
|
285
|
-
<strong>{notificationType.label}</strong> — {reachText}
|
|
286
|
-
</Alert>
|
|
287
|
-
);
|
|
288
|
-
}
|
|
289
|
-
return (
|
|
290
|
-
<Box key={notificationType.key} sx={{ py: 0.5 }}>
|
|
291
|
-
<Typography variant="body2">{notificationType.label}</Typography>
|
|
292
|
-
<Typography variant="caption" color="text.secondary">{reachText}</Typography>
|
|
293
|
-
</Box>
|
|
294
|
-
);
|
|
295
|
-
})}
|
|
296
|
-
</Box>
|
|
297
|
-
</>
|
|
298
|
-
)}
|
|
299
|
-
|
|
300
|
-
{preferences?.subscribable_categories?.length > 0 && (
|
|
301
|
-
<>
|
|
302
|
-
<Divider />
|
|
303
|
-
<Box sx={{ py: 2 }}>
|
|
304
|
-
<Typography variant="subtitle2" gutterBottom>{t('NotificationSettings.SUBSCRIPTIONS_TITLE')}</Typography>
|
|
305
|
-
<Typography variant="body2" color="text.secondary" gutterBottom>
|
|
306
|
-
{t('NotificationSettings.SUBSCRIPTIONS_SUBTITLE')}
|
|
307
|
-
</Typography>
|
|
308
|
-
{preferences.subscribable_categories.map((row) => (
|
|
309
|
-
<FormControlLabel
|
|
310
|
-
key={row.category}
|
|
311
|
-
control={(
|
|
312
|
-
<Switch
|
|
313
|
-
checked={Boolean(row.subscribed)}
|
|
314
|
-
onChange={(event) => handleSubscriptionToggle(row.category, event)}
|
|
315
|
-
disabled={savingSubscription === row.category}
|
|
316
|
-
/>
|
|
317
|
-
)}
|
|
318
|
-
label={row.label}
|
|
319
|
-
labelPlacement="end"
|
|
320
|
-
sx={NOTIFICATION_SETTINGS_CATEGORY_CONTROL_SX}
|
|
321
|
-
/>
|
|
322
|
-
))}
|
|
323
|
-
</Box>
|
|
324
|
-
</>
|
|
325
|
-
)}
|
|
326
|
-
</Box>
|
|
327
|
-
);
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
export default NotificationSettings;
|
|
@@ -1,224 +0,0 @@
|
|
|
1
|
-
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
2
|
-
|
|
3
|
-
import { AuthContext } from '../auth/AuthContext';
|
|
4
|
-
import { getNotificationFeed, getUnreadCount, markNotifications } from './feedApi';
|
|
5
|
-
import { DEFAULT_ENVELOPE, RealtimeContext, useRealtimeCore } from './realtime';
|
|
6
|
-
|
|
7
|
-
const NotificationsContext = createContext(null);
|
|
8
|
-
|
|
9
|
-
function normalizeNotification(notification) {
|
|
10
|
-
return {
|
|
11
|
-
...notification,
|
|
12
|
-
content: notification.content || {},
|
|
13
|
-
seen: Boolean(notification.seen ?? notification.seen_at),
|
|
14
|
-
dismissed: Boolean(notification.dismissed ?? notification.dismissed_at),
|
|
15
|
-
done: Boolean(notification.done ?? notification.done_at),
|
|
16
|
-
};
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function normalizeNotificationPush(data) {
|
|
20
|
-
return normalizeNotification({
|
|
21
|
-
id: data.id ?? data.recipient_id ?? data.notification_id,
|
|
22
|
-
notification_id: data.notification_id,
|
|
23
|
-
notification_type: data.type,
|
|
24
|
-
content: data.content || {},
|
|
25
|
-
created_at: data.created_at,
|
|
26
|
-
seen_at: null,
|
|
27
|
-
dismissed_at: null,
|
|
28
|
-
done_at: null,
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function getWebSocketUrl(wsUrlBase) {
|
|
33
|
-
if (wsUrlBase) {
|
|
34
|
-
return new URL('/ws/notifications/', wsUrlBase).toString();
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
38
|
-
return `${protocol}//${window.location.host}/ws/notifications/`;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function patchStatus(notification, status) {
|
|
42
|
-
const seen = Boolean(status.seen);
|
|
43
|
-
const dismissed = Boolean(status.dismissed);
|
|
44
|
-
const done = Boolean(status.done);
|
|
45
|
-
|
|
46
|
-
return {
|
|
47
|
-
...notification,
|
|
48
|
-
seen,
|
|
49
|
-
dismissed,
|
|
50
|
-
done,
|
|
51
|
-
seen_at: seen ? notification.seen_at || true : null,
|
|
52
|
-
dismissed_at: dismissed ? notification.dismissed_at || true : null,
|
|
53
|
-
done_at: done ? notification.done_at || true : null,
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
export function NotificationsProvider({ children, wsUrlBase }) {
|
|
58
|
-
const auth = useContext(AuthContext);
|
|
59
|
-
const authenticated = Boolean(auth?.user);
|
|
60
|
-
const [notifications, setNotifications] = useState([]);
|
|
61
|
-
const [unreadCount, setUnreadCount] = useState(0);
|
|
62
|
-
const notificationsRef = useRef([]);
|
|
63
|
-
|
|
64
|
-
const replaceNotifications = useCallback((nextNotifications) => {
|
|
65
|
-
notificationsRef.current = nextNotifications;
|
|
66
|
-
setNotifications(nextNotifications);
|
|
67
|
-
}, []);
|
|
68
|
-
|
|
69
|
-
const refresh = useCallback(async () => {
|
|
70
|
-
if (!authenticated) {
|
|
71
|
-
replaceNotifications([]);
|
|
72
|
-
setUnreadCount(0);
|
|
73
|
-
return { notifications: [], unreadCount: 0 };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const [feed, unread] = await Promise.all([getNotificationFeed(), getUnreadCount()]);
|
|
77
|
-
const nextNotifications = (feed.results || []).map(normalizeNotification);
|
|
78
|
-
const nextUnreadCount = unread.count || 0;
|
|
79
|
-
replaceNotifications(nextNotifications);
|
|
80
|
-
setUnreadCount(nextUnreadCount);
|
|
81
|
-
return { notifications: nextNotifications, unreadCount: nextUnreadCount };
|
|
82
|
-
}, [authenticated, replaceNotifications]);
|
|
83
|
-
|
|
84
|
-
// Seeded gates the socket connect, not just `authenticated`: the prior single-purpose
|
|
85
|
-
// socket owner awaited the initial REST feed/unread-count fetch before opening the
|
|
86
|
-
// WebSocket, so a live push could never race an empty/incomplete `notificationsRef`.
|
|
87
|
-
// Preserved here rather than connecting immediately, since `refresh()`'s
|
|
88
|
-
// `replaceNotifications(...)` is unconditional and would silently drop a message that
|
|
89
|
-
// arrived and patched state before the initial REST response landed.
|
|
90
|
-
const [seeded, setSeeded] = useState(false);
|
|
91
|
-
|
|
92
|
-
const { subscribe, onReconnect } = useRealtimeCore({
|
|
93
|
-
active: authenticated && seeded,
|
|
94
|
-
wsUrl: getWebSocketUrl(wsUrlBase),
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
const handleRealtimeMessage = useCallback((data) => {
|
|
98
|
-
if (data.type === 'notification.status') {
|
|
99
|
-
let unreadDelta = 0;
|
|
100
|
-
const nextNotifications = notificationsRef.current.map((notification) => {
|
|
101
|
-
if (notification.notification_id !== data.notification_id) return notification;
|
|
102
|
-
|
|
103
|
-
const wasUnread = !notification.seen && !notification.dismissed;
|
|
104
|
-
const patched = patchStatus(notification, data.status || {});
|
|
105
|
-
const isUnread = !patched.seen && !patched.dismissed;
|
|
106
|
-
if (wasUnread !== isUnread) {
|
|
107
|
-
unreadDelta += isUnread ? 1 : -1;
|
|
108
|
-
}
|
|
109
|
-
return patched;
|
|
110
|
-
});
|
|
111
|
-
replaceNotifications(nextNotifications);
|
|
112
|
-
if (unreadDelta) {
|
|
113
|
-
setUnreadCount((count) => Math.max(0, count + unreadDelta));
|
|
114
|
-
}
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
const pushedNotification = normalizeNotificationPush(data);
|
|
119
|
-
|
|
120
|
-
// NOTIF-12 D2: a notification routed to more than one channel (e.g. chip
|
|
121
|
-
// + popup) arrives as multiple WS messages sharing one notification_id.
|
|
122
|
-
// Fold repeats into the existing feed entry (last-write-wins on content)
|
|
123
|
-
// instead of appending a duplicate and double-incrementing the badge.
|
|
124
|
-
const existingIndex = pushedNotification.notification_id == null
|
|
125
|
-
? -1
|
|
126
|
-
: notificationsRef.current.findIndex(
|
|
127
|
-
(notification) => notification.notification_id === pushedNotification.notification_id,
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
if (existingIndex !== -1) {
|
|
131
|
-
const nextNotifications = notificationsRef.current.map((notification, index) => (
|
|
132
|
-
index === existingIndex
|
|
133
|
-
? { ...notification, notification_type: pushedNotification.notification_type, content: pushedNotification.content }
|
|
134
|
-
: notification
|
|
135
|
-
));
|
|
136
|
-
replaceNotifications(nextNotifications);
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
replaceNotifications([pushedNotification, ...notificationsRef.current]);
|
|
141
|
-
setUnreadCount((count) => count + 1);
|
|
142
|
-
}, [replaceNotifications]);
|
|
143
|
-
|
|
144
|
-
useEffect(() => {
|
|
145
|
-
if (!authenticated) return undefined;
|
|
146
|
-
return subscribe(DEFAULT_ENVELOPE, handleRealtimeMessage);
|
|
147
|
-
}, [authenticated, subscribe, handleRealtimeMessage]);
|
|
148
|
-
|
|
149
|
-
useEffect(() => {
|
|
150
|
-
if (!authenticated) {
|
|
151
|
-
replaceNotifications([]);
|
|
152
|
-
setUnreadCount(0);
|
|
153
|
-
setSeeded(false);
|
|
154
|
-
return undefined;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
let cancelled = false;
|
|
158
|
-
refresh()
|
|
159
|
-
.catch(() => {
|
|
160
|
-
// The live stream remains useful if the initial REST request is temporarily unavailable.
|
|
161
|
-
})
|
|
162
|
-
.finally(() => {
|
|
163
|
-
if (!cancelled) setSeeded(true);
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
return () => {
|
|
167
|
-
cancelled = true;
|
|
168
|
-
};
|
|
169
|
-
}, [authenticated, refresh, replaceNotifications]);
|
|
170
|
-
|
|
171
|
-
const mark = useCallback((action, ids) => {
|
|
172
|
-
const selectedIds = Array.isArray(ids) ? ids : [];
|
|
173
|
-
const selected = new Set(selectedIds);
|
|
174
|
-
const current = notificationsRef.current;
|
|
175
|
-
let unreadDelta = 0;
|
|
176
|
-
const nextNotifications = current.map((notification) => {
|
|
177
|
-
if (!selected.has(notification.id)) return notification;
|
|
178
|
-
|
|
179
|
-
const wasUnread = !notification.seen && !notification.dismissed;
|
|
180
|
-
if ((action === 'seen' || action === 'dismissed') && wasUnread) {
|
|
181
|
-
unreadDelta += 1;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return patchStatus(notification, {
|
|
185
|
-
seen: action === 'seen' ? true : notification.seen,
|
|
186
|
-
dismissed: action === 'dismissed' ? true : notification.dismissed,
|
|
187
|
-
done: action === 'done' ? true : notification.done,
|
|
188
|
-
});
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
replaceNotifications(nextNotifications);
|
|
192
|
-
if (unreadDelta) {
|
|
193
|
-
setUnreadCount((count) => Math.max(0, count - unreadDelta));
|
|
194
|
-
}
|
|
195
|
-
return markNotifications({ action, ids: selectedIds });
|
|
196
|
-
}, [replaceNotifications]);
|
|
197
|
-
|
|
198
|
-
const markSeen = useCallback((ids) => mark('seen', ids), [mark]);
|
|
199
|
-
const markDismissed = useCallback((ids) => mark('dismissed', ids), [mark]);
|
|
200
|
-
const markDone = useCallback((ids) => mark('done', ids), [mark]);
|
|
201
|
-
|
|
202
|
-
return (
|
|
203
|
-
<RealtimeContext.Provider value={{ subscribe, onReconnect }}>
|
|
204
|
-
<NotificationsContext.Provider value={{
|
|
205
|
-
notifications,
|
|
206
|
-
unreadCount,
|
|
207
|
-
markSeen,
|
|
208
|
-
markDismissed,
|
|
209
|
-
markDone,
|
|
210
|
-
refresh,
|
|
211
|
-
}}>
|
|
212
|
-
{children}
|
|
213
|
-
</NotificationsContext.Provider>
|
|
214
|
-
</RealtimeContext.Provider>
|
|
215
|
-
);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
export function useNotifications() {
|
|
219
|
-
const context = useContext(NotificationsContext);
|
|
220
|
-
if (!context) {
|
|
221
|
-
throw new Error('useNotifications must be used within a NotificationsProvider');
|
|
222
|
-
}
|
|
223
|
-
return context;
|
|
224
|
-
}
|
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
-
import { useTranslation } from 'react-i18next';
|
|
3
|
-
import Button from '@mui/material/Button';
|
|
4
|
-
import Stack from '@mui/material/Stack';
|
|
5
|
-
import Typography from '@mui/material/Typography';
|
|
6
|
-
import { useOnboarding } from '../onboarding/OnboardingProvider';
|
|
7
|
-
import { WizardDialogShell } from '../onboarding/WizardDialogShell';
|
|
8
|
-
import { useNotifications } from './NotificationsProvider';
|
|
9
|
-
import { DEFAULT_ENVELOPE, useRealtime } from './realtime';
|
|
10
|
-
|
|
11
|
-
export const POPUP_SURFACE_DISMISS_BUTTON_SX = { alignSelf: 'flex-end' };
|
|
12
|
-
|
|
13
|
-
function PopupStepComponent({ onDismiss, ctx }) {
|
|
14
|
-
const { t } = useTranslation();
|
|
15
|
-
const content = ctx?.content || {};
|
|
16
|
-
|
|
17
|
-
return (
|
|
18
|
-
<Stack spacing={2}>
|
|
19
|
-
{content.body_key && (
|
|
20
|
-
<Typography variant="body1">{t(content.body_key, content.params || {})}</Typography>
|
|
21
|
-
)}
|
|
22
|
-
<Button onClick={onDismiss} sx={POPUP_SURFACE_DISMISS_BUTTON_SX}>
|
|
23
|
-
{t('PopupSurface.CLOSE')}
|
|
24
|
-
</Button>
|
|
25
|
-
</Stack>
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const POPUP_STEP = { blocking: false, Component: PopupStepComponent };
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Renders popup-eligible notifications (NOTIF-12) through the onboarding
|
|
33
|
-
* wizard's dialog shell, reusing only the renderer (design D-F7) — status
|
|
34
|
-
* lives on NotificationRecipient via the existing feed/mark path, never in
|
|
35
|
-
* the onboarding progress store.
|
|
36
|
-
*
|
|
37
|
-
* Deliberately session-live only: it watches the raw realtime stream for
|
|
38
|
-
* `channel === "popup"` messages rather than the already-seeded feed list,
|
|
39
|
-
* since the feed carries no per-channel discriminator (see NotificationsProvider).
|
|
40
|
-
*/
|
|
41
|
-
export function PopupSurface() {
|
|
42
|
-
const { t } = useTranslation();
|
|
43
|
-
const { markSeen, markDismissed } = useNotifications();
|
|
44
|
-
const { subscribe } = useRealtime();
|
|
45
|
-
const onboarding = useOnboarding();
|
|
46
|
-
const [queue, setQueue] = useState([]);
|
|
47
|
-
const seenIdsRef = useRef(new Set());
|
|
48
|
-
|
|
49
|
-
const handleRealtimeMessage = useCallback((data) => {
|
|
50
|
-
if (data.type === 'notification.status') return;
|
|
51
|
-
if (data.channel !== 'popup') return;
|
|
52
|
-
setQueue((previous) => {
|
|
53
|
-
if (previous.some((item) => item.notification_id === data.notification_id)) return previous;
|
|
54
|
-
return [...previous, {
|
|
55
|
-
notification_id: data.notification_id,
|
|
56
|
-
// recipient_id is the id feed/mark/ actually resolves against
|
|
57
|
-
// (NotificationRecipient.pk, not Notification.pk). Fall back to
|
|
58
|
-
// notification_id only for an older dcm that predates it — matching
|
|
59
|
-
// NotificationsProvider's own normalizeNotificationPush precedent.
|
|
60
|
-
markId: data.recipient_id ?? data.notification_id,
|
|
61
|
-
content: data.content || {},
|
|
62
|
-
}];
|
|
63
|
-
});
|
|
64
|
-
}, []);
|
|
65
|
-
|
|
66
|
-
useEffect(() => subscribe(DEFAULT_ENVELOPE, handleRealtimeMessage), [subscribe, handleRealtimeMessage]);
|
|
67
|
-
|
|
68
|
-
// Any active onboarding step — blocking or not — must win: OnboardingWizard
|
|
69
|
-
// renders its own dialog for as long as onboarding.activeSteps is non-empty,
|
|
70
|
-
// and two independent MUI Dialogs must never be mounted at once.
|
|
71
|
-
const onboardingActive = Boolean(onboarding?.activeSteps?.length);
|
|
72
|
-
const activePopup = queue[0];
|
|
73
|
-
|
|
74
|
-
useEffect(() => {
|
|
75
|
-
if (!activePopup || onboardingActive) return;
|
|
76
|
-
if (activePopup.markId == null) return;
|
|
77
|
-
if (seenIdsRef.current.has(activePopup.notification_id)) return;
|
|
78
|
-
seenIdsRef.current.add(activePopup.notification_id);
|
|
79
|
-
markSeen([activePopup.markId]);
|
|
80
|
-
}, [activePopup, onboardingActive, markSeen]);
|
|
81
|
-
|
|
82
|
-
const dismissActivePopup = useCallback(() => {
|
|
83
|
-
if (!activePopup) return;
|
|
84
|
-
if (activePopup.markId != null) markDismissed([activePopup.markId]);
|
|
85
|
-
setQueue((previous) => previous.filter((item) => item.notification_id !== activePopup.notification_id));
|
|
86
|
-
}, [activePopup, markDismissed]);
|
|
87
|
-
|
|
88
|
-
if (!activePopup || onboardingActive) return null;
|
|
89
|
-
|
|
90
|
-
return (
|
|
91
|
-
<WizardDialogShell
|
|
92
|
-
step={POPUP_STEP}
|
|
93
|
-
stepIndex={0}
|
|
94
|
-
total={1}
|
|
95
|
-
onComplete={dismissActivePopup}
|
|
96
|
-
onDismiss={dismissActivePopup}
|
|
97
|
-
ctx={{ content: activePopup.content }}
|
|
98
|
-
title={activePopup.content.title_key
|
|
99
|
-
? t(activePopup.content.title_key, activePopup.content.params || {})
|
|
100
|
-
: t('PopupSurface.TITLE')}
|
|
101
|
-
/>
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export default PopupSurface;
|