@luxexchange/notifications 1.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.
Files changed (45) hide show
  1. package/.depcheckrc +14 -0
  2. package/.eslintrc.js +20 -0
  3. package/README.md +548 -0
  4. package/package.json +42 -0
  5. package/project.json +30 -0
  6. package/src/getIsNotificationServiceLocalOverrideEnabled.ts +7 -0
  7. package/src/global.d.ts +2 -0
  8. package/src/index.ts +41 -0
  9. package/src/notification-data-source/NotificationDataSource.ts +8 -0
  10. package/src/notification-data-source/getNotificationQueryOptions.ts +85 -0
  11. package/src/notification-data-source/implementations/createIntervalNotificationDataSource.ts +73 -0
  12. package/src/notification-data-source/implementations/createLocalTriggerDataSource.test.ts +492 -0
  13. package/src/notification-data-source/implementations/createLocalTriggerDataSource.ts +177 -0
  14. package/src/notification-data-source/implementations/createNotificationDataSource.ts +19 -0
  15. package/src/notification-data-source/implementations/createPollingNotificationDataSource.test.ts +398 -0
  16. package/src/notification-data-source/implementations/createPollingNotificationDataSource.ts +74 -0
  17. package/src/notification-data-source/implementations/createReactiveDataSource.ts +113 -0
  18. package/src/notification-data-source/types/ReactiveCondition.ts +60 -0
  19. package/src/notification-processor/NotificationProcessor.ts +26 -0
  20. package/src/notification-processor/implementations/createBaseNotificationProcessor.test.ts +854 -0
  21. package/src/notification-processor/implementations/createBaseNotificationProcessor.ts +239 -0
  22. package/src/notification-processor/implementations/createNotificationProcessor.test.ts +130 -0
  23. package/src/notification-processor/implementations/createNotificationProcessor.ts +15 -0
  24. package/src/notification-renderer/NotificationRenderer.ts +8 -0
  25. package/src/notification-renderer/components/BannerTemplate.tsx +188 -0
  26. package/src/notification-renderer/components/InlineBannerNotification.tsx +123 -0
  27. package/src/notification-renderer/implementations/createNotificationRenderer.ts +16 -0
  28. package/src/notification-renderer/utils/iconUtils.ts +103 -0
  29. package/src/notification-service/NotificationService.ts +47 -0
  30. package/src/notification-service/implementations/createNotificationService.test.ts +1092 -0
  31. package/src/notification-service/implementations/createNotificationService.ts +364 -0
  32. package/src/notification-telemetry/NotificationTelemetry.ts +44 -0
  33. package/src/notification-telemetry/implementations/createNotificationTelemetry.test.ts +99 -0
  34. package/src/notification-telemetry/implementations/createNotificationTelemetry.ts +33 -0
  35. package/src/notification-tracker/NotificationTracker.ts +14 -0
  36. package/src/notification-tracker/implementations/createApiNotificationTracker.test.ts +465 -0
  37. package/src/notification-tracker/implementations/createApiNotificationTracker.ts +154 -0
  38. package/src/notification-tracker/implementations/createNoopNotificationTracker.ts +44 -0
  39. package/src/notification-tracker/implementations/createNotificationTracker.ts +31 -0
  40. package/src/utils/formatNotificationType.test.ts +25 -0
  41. package/src/utils/formatNotificationType.ts +25 -0
  42. package/tsconfig.json +24 -0
  43. package/tsconfig.lint.json +8 -0
  44. package/vitest-setup.ts +1 -0
  45. package/vitest.config.ts +14 -0
@@ -0,0 +1,364 @@
1
+ import { type InAppNotification, OnClickAction } from '@luxfi/api'
2
+ import {
3
+ type NotificationClickTarget,
4
+ type NotificationService,
5
+ type NotificationServiceConfig,
6
+ } from '@luxfi/notifications/src/notification-service/NotificationService'
7
+ import ms from 'ms'
8
+ import { getLogger } from '@luxfi/utilities/src/logger/logger'
9
+
10
+ // Module-level singletons to track notification telemetry across service recreations.
11
+ // This prevents duplicate telemetry events when the service is destroyed
12
+ // and recreated (e.g., during navigation in the extension sidebar).
13
+ const receivedNotifications = new Set<string>()
14
+ const shownNotifications = new Set<string>()
15
+
16
+ export function createNotificationService(config: NotificationServiceConfig): NotificationService {
17
+ const { dataSources, tracker, processor, renderer, telemetry, onNavigate } = config
18
+
19
+ const activeRenders = new Map<string, () => void>()
20
+ const activeNotifications = new Map<string, InAppNotification>()
21
+ const chainedNotifications = new Map<string, InAppNotification>()
22
+
23
+ const CLEANUP_OLDER_THAN_MS = ms('30d') // Clean up entries older than 30 days
24
+
25
+ /**
26
+ * Renders a single notification if possible
27
+ */
28
+ function renderNotification(notification: InAppNotification): void {
29
+ if (!renderer.canRender(notification)) {
30
+ return
31
+ }
32
+
33
+ if (activeRenders.has(notification.id)) {
34
+ return
35
+ }
36
+
37
+ const cleanup = renderer.render(notification)
38
+ activeRenders.set(notification.id, cleanup)
39
+ activeNotifications.set(notification.id, notification)
40
+ }
41
+
42
+ async function handleNotifications(notifications: InAppNotification[]): Promise<void> {
43
+ const result = await processor.process(notifications)
44
+
45
+ for (const [id, notification] of result.chained.entries()) {
46
+ chainedNotifications.set(id, notification)
47
+ }
48
+
49
+ for (const notification of result.primary) {
50
+ renderNotification(notification)
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Get the onClick configuration for a notification based on what was clicked
56
+ */
57
+ function getOnClick(
58
+ notification: InAppNotification,
59
+ target: NotificationClickTarget,
60
+ ): { onClick: OnClickAction[]; onClickLink?: string } | undefined {
61
+ if (target.type === 'button') {
62
+ const buttons = notification.content?.buttons ?? []
63
+ if (target.index < 0 || target.index >= buttons.length) {
64
+ getLogger().warn('NotificationService', 'getOnClick', `Invalid button index: ${target.index}`)
65
+ return undefined
66
+ }
67
+ const button = buttons[target.index]
68
+
69
+ if (button?.onClick) {
70
+ return {
71
+ onClick: button.onClick.onClick,
72
+ onClickLink: button.onClick.onClickLink,
73
+ }
74
+ }
75
+ }
76
+ if (target.type === 'background') {
77
+ const backgroundOnClick = notification.content?.background?.backgroundOnClick
78
+ if (backgroundOnClick) {
79
+ return {
80
+ onClick: backgroundOnClick.onClick,
81
+ onClickLink: backgroundOnClick.onClickLink,
82
+ }
83
+ }
84
+ }
85
+ if (target.type === 'dismiss') {
86
+ // Check if notification specifies custom dismiss behavior
87
+ const onDismissClick = notification.content?.onDismissClick
88
+ if (onDismissClick) {
89
+ return {
90
+ onClick: onDismissClick.onClick,
91
+ onClickLink: onDismissClick.onClickLink,
92
+ }
93
+ }
94
+ // Fallback to simple DISMISS if not specified
95
+ // The processor validates that notifications have DISMISS somewhere,
96
+ // so this fallback should rarely be used
97
+ return {
98
+ onClick: [OnClickAction.DISMISS],
99
+ }
100
+ }
101
+ return undefined
102
+ }
103
+
104
+ /**
105
+ * Internal method to handle dismissing a notification
106
+ * Cleans up the render without tracking (tracking only happens on ACK)
107
+ */
108
+ async function handleDismiss(notificationId: string): Promise<void> {
109
+ const cleanup = activeRenders.get(notificationId)
110
+ if (cleanup) {
111
+ cleanup()
112
+ activeRenders.delete(notificationId)
113
+ }
114
+
115
+ activeNotifications.delete(notificationId)
116
+ shownNotifications.delete(notificationId)
117
+ }
118
+
119
+ /**
120
+ * Gets all downstream notification IDs in the chain starting from a notification
121
+ * Checks all possible click targets: buttons, background, and dismiss button
122
+ *
123
+ * @param notification - The notification object to start traversing from
124
+ * @returns Array of downstream notification IDs
125
+ */
126
+ function getDownstreamNotificationIds(notification: InAppNotification): string[] {
127
+ const visited = new Set<string>()
128
+ const downstream: string[] = []
129
+
130
+ function traverse(currentNotification: InAppNotification): void {
131
+ if (visited.has(currentNotification.id)) {
132
+ return
133
+ }
134
+ visited.add(currentNotification.id)
135
+
136
+ // Extract popup targets from buttons
137
+ const buttons = currentNotification.content?.buttons ?? []
138
+ for (const button of buttons) {
139
+ if (button.onClick?.onClick.includes(OnClickAction.POPUP) && button.onClick.onClickLink) {
140
+ const targetId = button.onClick.onClickLink
141
+ // Only add to downstream if the notification actually exists
142
+ const nextNotification = activeNotifications.get(targetId) ?? chainedNotifications.get(targetId)
143
+ if (nextNotification && !visited.has(targetId)) {
144
+ downstream.push(targetId)
145
+ traverse(nextNotification)
146
+ }
147
+ }
148
+ }
149
+
150
+ // Extract popup target from background click
151
+ const backgroundOnClick = currentNotification.content?.background?.backgroundOnClick
152
+ if (backgroundOnClick?.onClick.includes(OnClickAction.POPUP) && backgroundOnClick.onClickLink) {
153
+ const targetId = backgroundOnClick.onClickLink
154
+ // Only add to downstream if the notification actually exists
155
+ const nextNotification = activeNotifications.get(targetId) ?? chainedNotifications.get(targetId)
156
+ if (nextNotification && !visited.has(targetId)) {
157
+ downstream.push(targetId)
158
+ traverse(nextNotification)
159
+ }
160
+ }
161
+
162
+ // Extract popup target from dismiss button click
163
+ const onDismissClick = currentNotification.content?.onDismissClick
164
+ if (onDismissClick?.onClick.includes(OnClickAction.POPUP) && onDismissClick.onClickLink) {
165
+ const targetId = onDismissClick.onClickLink
166
+ // Only add to downstream if the notification actually exists
167
+ const nextNotification = activeNotifications.get(targetId) ?? chainedNotifications.get(targetId)
168
+ if (nextNotification && !visited.has(targetId)) {
169
+ downstream.push(targetId)
170
+ traverse(nextNotification)
171
+ }
172
+ }
173
+ }
174
+
175
+ traverse(notification)
176
+ return downstream
177
+ }
178
+
179
+ /**
180
+ * Internal method to handle acknowledging a notification
181
+ * Tracks the notification as acknowledged/processed, along with all downstream chained notifications
182
+ *
183
+ * @param notification - The notification object
184
+ */
185
+ async function handleAcknowledge(notification: InAppNotification): Promise<void> {
186
+ const timestamp = Date.now()
187
+
188
+ await tracker.track(notification.id, { timestamp })
189
+
190
+ const downstreamIds = getDownstreamNotificationIds(notification)
191
+ await Promise.all(downstreamIds.map(async (downstreamId) => tracker.track(downstreamId, { timestamp })))
192
+ }
193
+
194
+ return {
195
+ async initialize(): Promise<void> {
196
+ for (const dataSource of dataSources) {
197
+ dataSource.start((notifications, source) => {
198
+ for (const notification of notifications) {
199
+ if (!receivedNotifications.has(notification.id)) {
200
+ receivedNotifications.add(notification.id)
201
+ telemetry?.onNotificationReceived({
202
+ notificationId: notification.id,
203
+ type: notification.content?.style,
204
+ source,
205
+ timestamp: Date.now(),
206
+ })
207
+ }
208
+ }
209
+
210
+ handleNotifications(notifications).catch((error) => {
211
+ getLogger().error(error, {
212
+ tags: {
213
+ file: 'createNotificationService',
214
+ function: 'handleNotifications',
215
+ },
216
+ })
217
+ })
218
+ })
219
+ }
220
+
221
+ // Clean up old tracked notifications on startup
222
+ const cleanupThreshold = Date.now() - CLEANUP_OLDER_THAN_MS
223
+ tracker.cleanup?.(cleanupThreshold).catch((error) => {
224
+ getLogger().error(error, {
225
+ tags: {
226
+ file: 'createNotificationService',
227
+ function: 'initialize',
228
+ },
229
+ })
230
+ })
231
+ },
232
+
233
+ onRenderFailed(notificationId: string): void {
234
+ // Clean up the failed render without marking as processed
235
+ const cleanup = activeRenders.get(notificationId)
236
+ if (cleanup) {
237
+ cleanup()
238
+ activeRenders.delete(notificationId)
239
+ }
240
+
241
+ activeNotifications.delete(notificationId)
242
+ shownNotifications.delete(notificationId)
243
+ },
244
+
245
+ onNotificationClick(notificationId: string, target: NotificationClickTarget): void {
246
+ const notification = activeNotifications.get(notificationId)
247
+ if (!notification) {
248
+ getLogger().warn('NotificationService', 'onNotificationClick', `Notification not found: ${notificationId}`)
249
+ return
250
+ }
251
+
252
+ telemetry?.onNotificationInteracted({
253
+ notificationId,
254
+ type: notification.content?.style,
255
+ action: target.type,
256
+ })
257
+
258
+ const onClickConfig = getOnClick(notification, target)
259
+ if (!onClickConfig) {
260
+ return
261
+ }
262
+
263
+ for (const action of onClickConfig.onClick) {
264
+ switch (action) {
265
+ case OnClickAction.EXTERNAL_LINK:
266
+ if (onClickConfig.onClickLink) {
267
+ if (onNavigate) {
268
+ onNavigate(onClickConfig.onClickLink)
269
+ } else {
270
+ getLogger().warn(
271
+ 'NotificationService',
272
+ 'onNotificationClick',
273
+ 'onNavigate handler not provided, cannot navigate to link',
274
+ )
275
+ }
276
+ }
277
+ break
278
+ case OnClickAction.POPUP:
279
+ if (onClickConfig.onClickLink) {
280
+ const chainedNotification = chainedNotifications.get(onClickConfig.onClickLink)
281
+ if (chainedNotification) {
282
+ renderNotification(chainedNotification)
283
+ chainedNotifications.delete(onClickConfig.onClickLink)
284
+ }
285
+ }
286
+ break
287
+ case OnClickAction.DISMISS:
288
+ handleDismiss(notificationId).catch((error: unknown) => {
289
+ getLogger().error(error, {
290
+ tags: {
291
+ file: 'createNotificationService',
292
+ function: 'onNotificationClick',
293
+ },
294
+ })
295
+ })
296
+ break
297
+ case OnClickAction.ACK:
298
+ handleAcknowledge(notification).catch((error: unknown) => {
299
+ getLogger().error(error, {
300
+ tags: {
301
+ file: 'createNotificationService',
302
+ function: 'onNotificationClick',
303
+ },
304
+ })
305
+ })
306
+ break
307
+ case OnClickAction.UNSPECIFIED:
308
+ break
309
+ }
310
+ }
311
+ },
312
+
313
+ onNotificationShown(notificationId: string): void {
314
+ if (shownNotifications.has(notificationId)) {
315
+ return
316
+ }
317
+
318
+ const notification = activeNotifications.get(notificationId)
319
+ if (!notification) {
320
+ getLogger().warn('NotificationService', 'onNotificationShown', `Notification not found: ${notificationId}`)
321
+ return
322
+ }
323
+
324
+ shownNotifications.add(notificationId)
325
+ telemetry?.onNotificationShown({
326
+ notificationId,
327
+ type: notification.content?.style,
328
+ timestamp: Date.now(),
329
+ })
330
+ },
331
+
332
+ destroy(): void {
333
+ // Clean up old tracked notifications on teardown
334
+ const cleanupThreshold = Date.now() - CLEANUP_OLDER_THAN_MS
335
+ tracker.cleanup?.(cleanupThreshold).catch((error) => {
336
+ getLogger().error(error, {
337
+ tags: {
338
+ file: 'createNotificationService',
339
+ function: 'destroy',
340
+ },
341
+ })
342
+ })
343
+
344
+ for (const dataSource of dataSources) {
345
+ dataSource.stop().catch((error) => {
346
+ getLogger().error(error, {
347
+ tags: {
348
+ file: 'createNotificationService',
349
+ function: 'destroy',
350
+ },
351
+ })
352
+ })
353
+ }
354
+
355
+ for (const cleanup of activeRenders.values()) {
356
+ cleanup()
357
+ }
358
+ activeRenders.clear()
359
+ // Note: receivedNotifications and shownNotifications are intentionally NOT cleared here.
360
+ // They are module-level singletons that persist across service recreations
361
+ // to prevent duplicate telemetry events.
362
+ },
363
+ }
364
+ }
@@ -0,0 +1,44 @@
1
+ import { type ContentStyle } from '@luxfi/api'
2
+
3
+ /**
4
+ * Telemetry interface for tracking notification lifecycle events
5
+ * This interface is injected by the callsite (e.g. web) to allow
6
+ * platform-specific analytics implementations without coupling
7
+ * the notification system to any specific analytics provider.
8
+ */
9
+ export interface NotificationTelemetry {
10
+ /**
11
+ * Called when a notification is fetched and processed from a data source
12
+ */
13
+ onNotificationReceived(params: {
14
+ notificationId: string
15
+ type: ContentStyle | undefined
16
+ source: string // 'backend' | 'websocket' | 'legacy'
17
+ timestamp: number
18
+ }): void
19
+
20
+ /**
21
+ * Called when a notification is rendered to the user
22
+ */
23
+ onNotificationShown(params: { notificationId: string; type: ContentStyle | undefined; timestamp: number }): void
24
+
25
+ /**
26
+ * Called when a user interacts with a notification (clicks, etc.)
27
+ */
28
+ onNotificationInteracted(params: {
29
+ notificationId: string
30
+ type: ContentStyle | undefined
31
+ action: string // 'button' | 'background' | 'dismiss'
32
+ }): void
33
+ }
34
+
35
+ /**
36
+ * No-op implementation for testing or when telemetry is disabled
37
+ */
38
+ export function createNoopNotificationTelemetry(): NotificationTelemetry {
39
+ return {
40
+ onNotificationReceived: (): void => {},
41
+ onNotificationShown: (): void => {},
42
+ onNotificationInteracted: (): void => {},
43
+ }
44
+ }
@@ -0,0 +1,99 @@
1
+ import { ContentStyle } from '@luxfi/api'
2
+ import { createNotificationTelemetry } from '@luxfi/notifications/src/notification-telemetry/implementations/createNotificationTelemetry'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+
5
+ describe('createNotificationTelemetry', () => {
6
+ it('should format ContentStyle.MODAL to human-readable string', () => {
7
+ const onNotificationReceived = vi.fn()
8
+ const onNotificationShown = vi.fn()
9
+ const onNotificationInteracted = vi.fn()
10
+
11
+ const telemetry = createNotificationTelemetry({
12
+ onNotificationReceived,
13
+ onNotificationShown,
14
+ onNotificationInteracted,
15
+ })
16
+
17
+ telemetry.onNotificationReceived({
18
+ notificationId: 'test-id',
19
+ type: ContentStyle.MODAL,
20
+ source: 'backend',
21
+ timestamp: 123456,
22
+ })
23
+
24
+ expect(onNotificationReceived).toHaveBeenCalledWith({
25
+ notificationId: 'test-id',
26
+ type: 'modal',
27
+ source: 'backend',
28
+ timestamp: 123456,
29
+ })
30
+ })
31
+
32
+ it('should format ContentStyle.LOWER_LEFT_BANNER to human-readable string', () => {
33
+ const onNotificationShown = vi.fn()
34
+
35
+ const telemetry = createNotificationTelemetry({
36
+ onNotificationReceived: vi.fn(),
37
+ onNotificationShown,
38
+ onNotificationInteracted: vi.fn(),
39
+ })
40
+
41
+ telemetry.onNotificationShown({
42
+ notificationId: 'test-id',
43
+ type: ContentStyle.LOWER_LEFT_BANNER,
44
+ timestamp: 123456,
45
+ })
46
+
47
+ expect(onNotificationShown).toHaveBeenCalledWith({
48
+ notificationId: 'test-id',
49
+ type: 'lower_left_banner',
50
+ timestamp: 123456,
51
+ })
52
+ })
53
+
54
+ it('should format ContentStyle.UNSPECIFIED to human-readable string', () => {
55
+ const onNotificationInteracted = vi.fn()
56
+
57
+ const telemetry = createNotificationTelemetry({
58
+ onNotificationReceived: vi.fn(),
59
+ onNotificationShown: vi.fn(),
60
+ onNotificationInteracted,
61
+ })
62
+
63
+ telemetry.onNotificationInteracted({
64
+ notificationId: 'test-id',
65
+ type: ContentStyle.UNSPECIFIED,
66
+ action: 'dismiss',
67
+ })
68
+
69
+ expect(onNotificationInteracted).toHaveBeenCalledWith({
70
+ notificationId: 'test-id',
71
+ type: 'unspecified',
72
+ action: 'dismiss',
73
+ })
74
+ })
75
+
76
+ it('should format undefined type to "unknown"', () => {
77
+ const onNotificationReceived = vi.fn()
78
+
79
+ const telemetry = createNotificationTelemetry({
80
+ onNotificationReceived,
81
+ onNotificationShown: vi.fn(),
82
+ onNotificationInteracted: vi.fn(),
83
+ })
84
+
85
+ telemetry.onNotificationReceived({
86
+ notificationId: 'test-id',
87
+ type: undefined,
88
+ source: 'legacy',
89
+ timestamp: 123456,
90
+ })
91
+
92
+ expect(onNotificationReceived).toHaveBeenCalledWith({
93
+ notificationId: 'test-id',
94
+ type: 'unknown',
95
+ source: 'legacy',
96
+ timestamp: 123456,
97
+ })
98
+ })
99
+ })
@@ -0,0 +1,33 @@
1
+ import { type NotificationTelemetry } from '@luxfi/notifications/src/notification-telemetry/NotificationTelemetry'
2
+ import { formatNotificationType } from '@luxfi/notifications/src/utils/formatNotificationType'
3
+
4
+ /**
5
+ * Basic implementation of the NotificationTelemetry interface.
6
+ * This factory function allows callsites to inject their own telemetry implementations.
7
+ */
8
+ export function createNotificationTelemetry(ctx: {
9
+ onNotificationReceived: (params: { notificationId: string; type: string; source: string; timestamp: number }) => void
10
+ onNotificationShown: (params: { notificationId: string; type: string; timestamp: number }) => void
11
+ onNotificationInteracted: (params: { notificationId: string; type: string; action: string }) => void
12
+ }): NotificationTelemetry {
13
+ return {
14
+ onNotificationReceived: (params): void => {
15
+ ctx.onNotificationReceived({
16
+ ...params,
17
+ type: formatNotificationType(params.type),
18
+ })
19
+ },
20
+ onNotificationShown: (params): void => {
21
+ ctx.onNotificationShown({
22
+ ...params,
23
+ type: formatNotificationType(params.type),
24
+ })
25
+ },
26
+ onNotificationInteracted: (params): void => {
27
+ ctx.onNotificationInteracted({
28
+ ...params,
29
+ type: formatNotificationType(params.type),
30
+ })
31
+ },
32
+ }
33
+ }
@@ -0,0 +1,14 @@
1
+ export interface TrackingMetadata {
2
+ timestamp: number
3
+ }
4
+
5
+ export interface NotificationTracker {
6
+ // Check if a notification has been processed
7
+ isProcessed(notificationId: string): Promise<boolean>
8
+ // Get all processed notification IDs
9
+ getProcessedIds(): Promise<Set<string>>
10
+ // Track notification as processed (acknowledged)
11
+ track(notificationId: string, metadata: TrackingMetadata): Promise<void>
12
+ // Optional cleanup for old entries
13
+ cleanup?(olderThan: number): Promise<void>
14
+ }