@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,177 @@
1
+ import { type InAppNotification } from '@luxexchange/api'
2
+ import { createNotificationDataSource } from '@luxexchange/notifications/src/notification-data-source/implementations/createNotificationDataSource'
3
+ import { type NotificationDataSource } from '@luxexchange/notifications/src/notification-data-source/NotificationDataSource'
4
+ import { type NotificationTracker } from '@luxexchange/notifications/src/notification-tracker/NotificationTracker'
5
+ import { getLogger } from '@luxfi/utilities/src/logger/logger'
6
+
7
+ /**
8
+ * Configuration for a single trigger condition.
9
+ * Each trigger represents a local notification that should be shown when conditions are met.
10
+ */
11
+ export interface TriggerCondition {
12
+ /**
13
+ * Unique ID for this notification.
14
+ * Must use 'local:' prefix to distinguish from backend-generated notifications
15
+ * and prevent API tracker from trying to acknowledge them server-side.
16
+ */
17
+ id: string
18
+
19
+ /**
20
+ * Check if the notification should be shown now.
21
+ * Called on each poll interval.
22
+ */
23
+ shouldShow: () => boolean | Promise<boolean>
24
+
25
+ /**
26
+ * Create the notification object to be rendered.
27
+ * Only called when shouldShow returns true.
28
+ */
29
+ createNotification: () => InAppNotification
30
+
31
+ /**
32
+ * Optional callback when user acknowledges the notification.
33
+ * Use this to update Redux state or perform other side effects.
34
+ */
35
+ onAcknowledge?: () => void
36
+ }
37
+
38
+ export interface CreateLocalTriggerDataSourceContext {
39
+ /** Array of trigger conditions to evaluate */
40
+ triggers: TriggerCondition[]
41
+
42
+ /** Tracker for checking/storing processed state */
43
+ tracker: NotificationTracker
44
+
45
+ /** How often to check triggers in milliseconds (default: 5000ms) */
46
+ pollIntervalMs?: number
47
+
48
+ /** Source identifier for telemetry */
49
+ source?: string
50
+
51
+ /** File tag for logging */
52
+ logFileTag?: string
53
+ }
54
+
55
+ const DEFAULT_POLL_INTERVAL_MS = 5000
56
+ const DEFAULT_SOURCE = 'local_triggers'
57
+ const DEFAULT_LOG_FILE_TAG = 'createLocalTriggerDataSource'
58
+
59
+ /**
60
+ * Creates a data source for condition-based local notifications.
61
+ *
62
+ * Unlike API-based data sources, this polls local state (e.g., Redux selectors)
63
+ * to determine when to show notifications. Useful for modals that should auto-open
64
+ * based on user state or behavior (e.g., app rating prompts, backup reminders).
65
+ *
66
+ * @example
67
+ * ```typescript
68
+ * const localTriggers = createLocalTriggerDataSource({
69
+ * triggers: [
70
+ * {
71
+ * id: 'local:app_rating',
72
+ * shouldShow: () => appRatingSelector(getState()).shouldPrompt,
73
+ * createNotification: () => createAppRatingNotification(),
74
+ * onAcknowledge: () => dispatch(setAppRating({})),
75
+ * },
76
+ * ],
77
+ * tracker,
78
+ * pollIntervalMs: 5000,
79
+ * })
80
+ * ```
81
+ */
82
+ export function createLocalTriggerDataSource(ctx: CreateLocalTriggerDataSourceContext): NotificationDataSource {
83
+ const {
84
+ triggers,
85
+ tracker,
86
+ pollIntervalMs = DEFAULT_POLL_INTERVAL_MS,
87
+ source = DEFAULT_SOURCE,
88
+ logFileTag = DEFAULT_LOG_FILE_TAG,
89
+ } = ctx
90
+
91
+ let intervalId: ReturnType<typeof setInterval> | null = null
92
+ let currentCallback: ((notifications: InAppNotification[], source: string) => void) | null = null
93
+
94
+ const checkTriggers = async (): Promise<InAppNotification[]> => {
95
+ const notifications: InAppNotification[] = []
96
+
97
+ for (const trigger of triggers) {
98
+ try {
99
+ // Skip if already processed
100
+ const isProcessed = await tracker.isProcessed(trigger.id)
101
+ if (isProcessed) {
102
+ continue
103
+ }
104
+
105
+ // Check if trigger condition is met
106
+ const shouldShow = await Promise.resolve(trigger.shouldShow())
107
+ if (shouldShow) {
108
+ notifications.push(trigger.createNotification())
109
+ }
110
+ } catch (error) {
111
+ getLogger().error(error, {
112
+ tags: { file: logFileTag, function: 'checkTriggers' },
113
+ extra: { triggerId: trigger.id },
114
+ })
115
+ }
116
+ }
117
+
118
+ return notifications
119
+ }
120
+
121
+ const pollAndEmit = async (logFunctionTag: string): Promise<void> => {
122
+ if (!currentCallback) {
123
+ return
124
+ }
125
+
126
+ try {
127
+ const notifications = await checkTriggers()
128
+ currentCallback(notifications, source)
129
+ } catch (error) {
130
+ getLogger().error(error, {
131
+ tags: { file: logFileTag, function: logFunctionTag },
132
+ })
133
+ }
134
+ }
135
+
136
+ const start = (onNotifications: (notifications: InAppNotification[], source: string) => void): void => {
137
+ if (intervalId) {
138
+ return
139
+ }
140
+
141
+ currentCallback = onNotifications
142
+
143
+ // Check immediately on start
144
+ pollAndEmit('start').catch((error) => {
145
+ getLogger().error(error, {
146
+ tags: { file: logFileTag, function: 'start' },
147
+ })
148
+ })
149
+
150
+ // Then poll at interval
151
+ intervalId = setInterval(() => {
152
+ pollAndEmit('setInterval').catch((error) => {
153
+ getLogger().error(error, {
154
+ tags: { file: logFileTag, function: 'setInterval' },
155
+ })
156
+ })
157
+ }, pollIntervalMs)
158
+ }
159
+
160
+ const stop = async (): Promise<void> => {
161
+ if (intervalId) {
162
+ clearInterval(intervalId)
163
+ intervalId = null
164
+ }
165
+ currentCallback = null
166
+ }
167
+
168
+ return createNotificationDataSource({ start, stop })
169
+ }
170
+
171
+ /**
172
+ * Get a trigger by notification ID.
173
+ * Useful for finding the trigger's callbacks when a notification is interacted with.
174
+ */
175
+ export function getTriggerById(triggers: TriggerCondition[], notificationId: string): TriggerCondition | undefined {
176
+ return triggers.find((t) => t.id === notificationId)
177
+ }
@@ -0,0 +1,19 @@
1
+ import { type InAppNotification } from '@luxfi/api'
2
+ import { type NotificationDataSource } from '@luxfi/notifications/src/notification-data-source/NotificationDataSource'
3
+
4
+ /**
5
+ * Basic implementation of the NotificationDataSource interface.
6
+ */
7
+ export function createNotificationDataSource(ctx: {
8
+ start: (onNotifications: (notifications: InAppNotification[], source: string) => void) => void
9
+ stop: () => Promise<void>
10
+ }): NotificationDataSource {
11
+ return {
12
+ start: (onNotifications: (notifications: InAppNotification[], source: string) => void): void => {
13
+ ctx.start(onNotifications)
14
+ },
15
+ stop: async (): Promise<void> => {
16
+ await ctx.stop()
17
+ },
18
+ }
19
+ }
@@ -0,0 +1,398 @@
1
+ import { QueryClient } from '@tanstack/react-query'
2
+ import {
3
+ Content,
4
+ GetNotificationsResponse,
5
+ Metadata,
6
+ Notification,
7
+ PlatformType,
8
+ } from '@uniswap/client-notification-service/dist/uniswap/notificationservice/v1/api_pb'
9
+ import type { InAppNotification, NotificationsApiClient } from '@luxexchange/api'
10
+ import { ContentStyle } from '@luxexchange/api'
11
+ import { getNotificationQueryOptions } from '@luxexchange/notifications/src/notification-data-source/getNotificationQueryOptions'
12
+ import { createPollingNotificationDataSource } from '@luxexchange/notifications/src/notification-data-source/implementations/createPollingNotificationDataSource'
13
+ import { ReactQueryCacheKey } from '@luxfi/utilities/src/reactQuery/cache'
14
+ import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest'
15
+
16
+ function createMockNotification(id: string): InAppNotification {
17
+ return new Notification({
18
+ id,
19
+ metadata: new Metadata({ owner: 'foo', business: 'bar' }),
20
+ content: new Content({ style: ContentStyle.MODAL, title: 'Hello' }),
21
+ })
22
+ }
23
+
24
+ describe('createPollingNotificationDataSource', () => {
25
+ let queryClient: QueryClient
26
+ let mockApiClient: NotificationsApiClient
27
+ let mockNotifications: InAppNotification[]
28
+
29
+ beforeEach(() => {
30
+ vi.clearAllMocks()
31
+ queryClient = new QueryClient()
32
+
33
+ mockNotifications = [createMockNotification('1'), createMockNotification('2')]
34
+
35
+ mockApiClient = {
36
+ getNotifications: vi.fn().mockResolvedValue(new GetNotificationsResponse({ notifications: mockNotifications })),
37
+ ackNotification: vi.fn().mockResolvedValue(undefined),
38
+ }
39
+ })
40
+
41
+ afterEach(() => {
42
+ queryClient.clear()
43
+ })
44
+
45
+ it('creates a notification data source with start and stop methods', () => {
46
+ const testQueryClient = new QueryClient({
47
+ defaultOptions: { queries: { retry: false } },
48
+ })
49
+
50
+ const dataSource = createPollingNotificationDataSource({
51
+ queryClient: testQueryClient,
52
+ queryOptions: getNotificationQueryOptions({
53
+ apiClient: mockApiClient,
54
+ getPlatformType: () => PlatformType.WEB,
55
+ }),
56
+ })
57
+
58
+ expect(dataSource).toBeDefined()
59
+ expect(typeof dataSource.start).toBe('function')
60
+ expect(typeof dataSource.stop).toBe('function')
61
+ })
62
+
63
+ it('calls onNotifications when notifications are received', async () => {
64
+ const testQueryClient = new QueryClient({
65
+ defaultOptions: { queries: { retry: false } },
66
+ })
67
+
68
+ const dataSource = createPollingNotificationDataSource({
69
+ queryClient: testQueryClient,
70
+ queryOptions: getNotificationQueryOptions({
71
+ apiClient: mockApiClient,
72
+ getPlatformType: () => PlatformType.WEB,
73
+ }),
74
+ })
75
+
76
+ const onNotifications = vi.fn()
77
+ dataSource.start(onNotifications)
78
+
79
+ // Wait for the query to resolve
80
+ await vi.waitFor(() => {
81
+ expect(onNotifications).toHaveBeenCalledWith(mockNotifications, 'polling_api')
82
+ })
83
+
84
+ await dataSource.stop()
85
+ })
86
+
87
+ it('calls apiClient.getNotifications when started', async () => {
88
+ const testQueryClient = new QueryClient({
89
+ defaultOptions: { queries: { retry: false } },
90
+ })
91
+
92
+ const dataSource = createPollingNotificationDataSource({
93
+ queryClient: testQueryClient,
94
+ queryOptions: getNotificationQueryOptions({
95
+ apiClient: mockApiClient,
96
+ getPlatformType: () => PlatformType.WEB,
97
+ }),
98
+ })
99
+
100
+ const onNotifications = vi.fn()
101
+ dataSource.start(onNotifications)
102
+
103
+ await vi.waitFor(() => {
104
+ expect(mockApiClient.getNotifications).toHaveBeenCalled()
105
+ })
106
+
107
+ await dataSource.stop()
108
+ })
109
+
110
+ it.each([
111
+ ['WEB', PlatformType.WEB],
112
+ ['MOBILE', PlatformType.MOBILE],
113
+ ['EXTENSION', PlatformType.EXTENSION],
114
+ ] as const)(
115
+ 'calls apiClient.getNotifications with platform_type %s when getPlatformType returns that platform',
116
+ async (_label, platformType) => {
117
+ const testQueryClient = new QueryClient({
118
+ defaultOptions: { queries: { retry: false } },
119
+ })
120
+
121
+ const dataSource = createPollingNotificationDataSource({
122
+ queryClient: testQueryClient,
123
+ queryOptions: getNotificationQueryOptions({
124
+ apiClient: mockApiClient,
125
+ getPlatformType: () => platformType,
126
+ }),
127
+ })
128
+
129
+ const onNotifications = vi.fn()
130
+ dataSource.start(onNotifications)
131
+
132
+ await vi.waitFor(() => {
133
+ expect(mockApiClient.getNotifications).toHaveBeenCalledWith({ platform_type: platformType })
134
+ })
135
+
136
+ await dataSource.stop()
137
+ },
138
+ )
139
+
140
+ it('does not start twice if already active', async () => {
141
+ const testQueryClient = new QueryClient({
142
+ defaultOptions: { queries: { retry: false } },
143
+ })
144
+
145
+ const dataSource = createPollingNotificationDataSource({
146
+ queryClient: testQueryClient,
147
+ queryOptions: getNotificationQueryOptions({
148
+ apiClient: mockApiClient,
149
+ getPlatformType: () => PlatformType.WEB,
150
+ }),
151
+ })
152
+
153
+ const onNotifications = vi.fn()
154
+ dataSource.start(onNotifications)
155
+
156
+ // Try to start again
157
+ dataSource.start(onNotifications)
158
+
159
+ // Wait a bit to ensure no double calls
160
+ await vi.waitFor(() => {
161
+ expect(mockApiClient.getNotifications).toHaveBeenCalledTimes(1)
162
+ })
163
+
164
+ await dataSource.stop()
165
+ })
166
+
167
+ it('handles API errors gracefully', async () => {
168
+ const testQueryClient = new QueryClient({
169
+ defaultOptions: { queries: { retry: false } },
170
+ })
171
+
172
+ const error = new Error('API Error')
173
+ mockApiClient.getNotifications = vi.fn().mockRejectedValue(error)
174
+
175
+ const dataSource = createPollingNotificationDataSource({
176
+ queryClient: testQueryClient,
177
+ queryOptions: getNotificationQueryOptions({
178
+ apiClient: mockApiClient,
179
+ getPlatformType: () => PlatformType.WEB,
180
+ }),
181
+ })
182
+
183
+ const onNotifications = vi.fn()
184
+ dataSource.start(onNotifications)
185
+
186
+ // Wait for error handling
187
+ await vi.waitFor(() => {
188
+ expect(mockApiClient.getNotifications).toHaveBeenCalled()
189
+ })
190
+
191
+ // onNotifications should not be called on error
192
+ expect(onNotifications).not.toHaveBeenCalled()
193
+
194
+ await dataSource.stop()
195
+ })
196
+
197
+ it('stops polling when stop is called', async () => {
198
+ const testQueryClient = new QueryClient({
199
+ defaultOptions: { queries: { retry: false } },
200
+ })
201
+
202
+ const dataSource = createPollingNotificationDataSource({
203
+ queryClient: testQueryClient,
204
+ queryOptions: getNotificationQueryOptions({
205
+ apiClient: mockApiClient,
206
+ getPlatformType: () => PlatformType.WEB,
207
+ pollIntervalMs: 100, // Short interval for testing
208
+ }),
209
+ })
210
+
211
+ const onNotifications = vi.fn()
212
+ dataSource.start(onNotifications)
213
+
214
+ await vi.waitFor(() => {
215
+ expect(onNotifications).toHaveBeenCalled()
216
+ })
217
+
218
+ const callCountBeforeStop = (mockApiClient.getNotifications as Mock).mock.calls.length
219
+
220
+ await dataSource.stop()
221
+
222
+ // Wait a bit longer than poll interval
223
+ await new Promise((resolve) => setTimeout(resolve, 200))
224
+
225
+ // Should not have made additional API calls after stop
226
+ expect((mockApiClient.getNotifications as Mock).mock.calls.length).toBe(callCountBeforeStop)
227
+ })
228
+
229
+ it('can be started again after stopping', async () => {
230
+ const testQueryClient = new QueryClient({
231
+ defaultOptions: { queries: { retry: false } },
232
+ })
233
+
234
+ const dataSource = createPollingNotificationDataSource({
235
+ queryClient: testQueryClient,
236
+ queryOptions: getNotificationQueryOptions({
237
+ apiClient: mockApiClient,
238
+ getPlatformType: () => PlatformType.WEB,
239
+ }),
240
+ })
241
+
242
+ const onNotifications = vi.fn()
243
+
244
+ // Start, wait, then stop
245
+ dataSource.start(onNotifications)
246
+ await vi.waitFor(() => {
247
+ expect(onNotifications).toHaveBeenCalledWith(mockNotifications, 'polling_api')
248
+ })
249
+ await dataSource.stop()
250
+
251
+ // Wait a bit for cleanup to complete
252
+ await new Promise((resolve) => setTimeout(resolve, 50))
253
+
254
+ // Clear the query cache completely
255
+ testQueryClient.clear()
256
+
257
+ // Clear mocks
258
+ vi.clearAllMocks()
259
+ mockApiClient.getNotifications = vi
260
+ .fn()
261
+ .mockResolvedValue(new GetNotificationsResponse({ notifications: mockNotifications }))
262
+
263
+ // Start again with a new callback
264
+ const onNotifications2 = vi.fn()
265
+ dataSource.start(onNotifications2)
266
+ await vi.waitFor(() => {
267
+ expect(onNotifications2).toHaveBeenCalled()
268
+ })
269
+
270
+ await dataSource.stop()
271
+ })
272
+
273
+ it('uses custom poll interval when provided', async () => {
274
+ const testQueryClient = new QueryClient({
275
+ defaultOptions: { queries: { retry: false } },
276
+ })
277
+
278
+ const customPollInterval = 100
279
+ const dataSource = createPollingNotificationDataSource({
280
+ queryClient: testQueryClient,
281
+ queryOptions: getNotificationQueryOptions({
282
+ apiClient: mockApiClient,
283
+ getPlatformType: () => PlatformType.WEB,
284
+ pollIntervalMs: customPollInterval,
285
+ }),
286
+ })
287
+
288
+ const onNotifications = vi.fn()
289
+ dataSource.start(onNotifications)
290
+
291
+ // Wait for initial call
292
+ await vi.waitFor(() => {
293
+ expect(mockApiClient.getNotifications).toHaveBeenCalled()
294
+ })
295
+
296
+ // Verify the data source was created successfully with custom interval
297
+ // Note: Testing actual polling behavior is complex due to React Query's internal timing
298
+ // The main goal is to ensure the custom interval is accepted without errors
299
+ expect(dataSource).toBeDefined()
300
+ expect(typeof dataSource.start).toBe('function')
301
+ expect(typeof dataSource.stop).toBe('function')
302
+
303
+ await dataSource.stop()
304
+ })
305
+
306
+ it('passes empty array to onNotifications when API returns empty array', async () => {
307
+ const testQueryClient = new QueryClient({
308
+ defaultOptions: { queries: { retry: false } },
309
+ })
310
+
311
+ mockApiClient.getNotifications = vi.fn().mockResolvedValue(new GetNotificationsResponse({ notifications: [] }))
312
+
313
+ const dataSource = createPollingNotificationDataSource({
314
+ queryClient: testQueryClient,
315
+ queryOptions: getNotificationQueryOptions({
316
+ apiClient: mockApiClient,
317
+ getPlatformType: () => PlatformType.WEB,
318
+ }),
319
+ })
320
+
321
+ const onNotifications = vi.fn()
322
+ dataSource.start(onNotifications)
323
+
324
+ await vi.waitFor(() => {
325
+ expect(onNotifications).toHaveBeenCalledWith([], 'polling_api')
326
+ })
327
+
328
+ await dataSource.stop()
329
+ })
330
+
331
+ it('cancels queries when stopped', async () => {
332
+ const testQueryClient = new QueryClient({
333
+ defaultOptions: { queries: { retry: false } },
334
+ })
335
+
336
+ const dataSource = createPollingNotificationDataSource({
337
+ queryClient: testQueryClient,
338
+ queryOptions: getNotificationQueryOptions({
339
+ apiClient: mockApiClient,
340
+ getPlatformType: () => PlatformType.WEB,
341
+ }),
342
+ })
343
+
344
+ const cancelQueriesSpy = vi.spyOn(testQueryClient, 'cancelQueries')
345
+
346
+ const onNotifications = vi.fn()
347
+ dataSource.start(onNotifications)
348
+
349
+ await vi.waitFor(() => {
350
+ expect(onNotifications).toHaveBeenCalled()
351
+ })
352
+
353
+ await dataSource.stop()
354
+
355
+ expect(cancelQueriesSpy).toHaveBeenCalledWith({ queryKey: [ReactQueryCacheKey.Notifications] })
356
+
357
+ cancelQueriesSpy.mockRestore()
358
+ })
359
+
360
+ it('retries failed requests according to retry configuration', async () => {
361
+ let callCount = 0
362
+ mockApiClient.getNotifications = vi.fn().mockImplementation(() => {
363
+ callCount++
364
+ if (callCount <= 2) {
365
+ return Promise.reject(new Error('Temporary error'))
366
+ }
367
+ return Promise.resolve(new GetNotificationsResponse({ notifications: mockNotifications }))
368
+ })
369
+
370
+ // Create a fresh QueryClient to test actual retry behavior from queryOptions
371
+ const retryQueryClient = new QueryClient()
372
+
373
+ const dataSource = createPollingNotificationDataSource({
374
+ queryClient: retryQueryClient,
375
+ queryOptions: getNotificationQueryOptions({
376
+ apiClient: mockApiClient,
377
+ getPlatformType: () => PlatformType.WEB,
378
+ }),
379
+ })
380
+
381
+ const onNotifications = vi.fn()
382
+ dataSource.start(onNotifications)
383
+
384
+ // Should eventually succeed after retries
385
+ await vi.waitFor(
386
+ () => {
387
+ expect(onNotifications).toHaveBeenCalled()
388
+ },
389
+ { timeout: 10000 },
390
+ )
391
+
392
+ // Should have made multiple attempts (initial + 2 retries)
393
+ expect(callCount).toBe(3)
394
+
395
+ await dataSource.stop()
396
+ retryQueryClient.clear()
397
+ })
398
+ })
@@ -0,0 +1,74 @@
1
+ import { type QueryClient, type QueryKey, QueryObserver } from '@tanstack/react-query'
2
+ import { type InAppNotification } from '@luxfi/api'
3
+ import { createNotificationDataSource } from '@luxfi/notifications/src/notification-data-source/implementations/createNotificationDataSource'
4
+ import { type NotificationDataSource } from '@luxfi/notifications/src/notification-data-source/NotificationDataSource'
5
+ import { getLogger } from '@luxfi/utilities/src/logger/logger'
6
+ import { type QueryOptionsResult } from '@luxfi/utilities/src/reactQuery/queryOptions'
7
+
8
+ interface CreatePollingNotificationDataSourceContext<TQueryKey extends QueryKey = QueryKey> {
9
+ queryClient: QueryClient
10
+ queryOptions: QueryOptionsResult<InAppNotification[], Error, InAppNotification[], TQueryKey>
11
+ }
12
+
13
+ /**
14
+ * Creates a polling notification data source using React Query.
15
+ * This handles the lifecycle orchestration of the query (start/stop).
16
+ */
17
+ export function createPollingNotificationDataSource<TQueryKey extends QueryKey = QueryKey>(
18
+ ctx: CreatePollingNotificationDataSourceContext<TQueryKey>,
19
+ ): NotificationDataSource {
20
+ const { queryClient, queryOptions } = ctx
21
+
22
+ let observer: QueryObserver<InAppNotification[], Error, InAppNotification[], InAppNotification[], TQueryKey> | null =
23
+ null
24
+ let unsubscribe: (() => void) | null = null
25
+ let isActive = false
26
+
27
+ const start = async (
28
+ onNotifications: (notifications: InAppNotification[], source: string) => void,
29
+ ): Promise<void> => {
30
+ if (isActive) {
31
+ return // Prevent multiple starts
32
+ }
33
+
34
+ isActive = true
35
+
36
+ observer = new QueryObserver<InAppNotification[], Error, InAppNotification[], InAppNotification[], TQueryKey>(
37
+ queryClient,
38
+ queryOptions,
39
+ )
40
+
41
+ unsubscribe = observer.subscribe((result) => {
42
+ // Only trigger callback when we have successful data
43
+ // Check both result.data exists AND status is success to avoid partial states
44
+ if (result.data && result.status === 'success') {
45
+ onNotifications(result.data, 'polling_api')
46
+ } else if (result.error) {
47
+ getLogger().error(result.error, {
48
+ tags: { file: 'createPollingNotificationDataSource', function: 'subscribe' },
49
+ })
50
+ }
51
+ })
52
+ }
53
+
54
+ const stop = async (): Promise<void> => {
55
+ if (unsubscribe) {
56
+ unsubscribe()
57
+ unsubscribe = null
58
+ }
59
+
60
+ observer = null
61
+
62
+ try {
63
+ await queryClient.cancelQueries({ queryKey: queryOptions.queryKey })
64
+ } catch (error) {
65
+ getLogger().error(error, {
66
+ tags: { file: 'createPollingNotificationDataSource', function: 'stop' },
67
+ })
68
+ } finally {
69
+ isActive = false
70
+ }
71
+ }
72
+
73
+ return createNotificationDataSource({ start, stop })
74
+ }