@l.x/notifications 1.0.3 → 1.0.5
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/.depcheckrc +14 -0
- package/.eslintrc.js +20 -0
- package/LICENSE +122 -0
- package/README.md +548 -0
- package/package.json +42 -1
- package/project.json +24 -0
- package/src/getIsNotificationServiceLocalOverrideEnabled.ts +7 -0
- package/src/global.d.ts +2 -0
- package/src/index.ts +41 -0
- package/src/notification-data-source/NotificationDataSource.ts +10 -0
- package/src/notification-data-source/getNotificationQueryOptions.ts +85 -0
- package/src/notification-data-source/implementations/createIntervalNotificationDataSource.ts +77 -0
- package/src/notification-data-source/implementations/createLocalTriggerDataSource.test.ts +492 -0
- package/src/notification-data-source/implementations/createLocalTriggerDataSource.ts +177 -0
- package/src/notification-data-source/implementations/createNotificationDataSource.ts +19 -0
- package/src/notification-data-source/implementations/createPollingNotificationDataSource.test.ts +398 -0
- package/src/notification-data-source/implementations/createPollingNotificationDataSource.ts +74 -0
- package/src/notification-data-source/implementations/createReactiveDataSource.ts +113 -0
- package/src/notification-data-source/types/ReactiveCondition.ts +60 -0
- package/src/notification-processor/NotificationProcessor.ts +26 -0
- package/src/notification-processor/implementations/createBaseNotificationProcessor.test.ts +854 -0
- package/src/notification-processor/implementations/createBaseNotificationProcessor.ts +254 -0
- package/src/notification-processor/implementations/createNotificationProcessor.test.ts +130 -0
- package/src/notification-processor/implementations/createNotificationProcessor.ts +15 -0
- package/src/notification-renderer/NotificationRenderer.ts +8 -0
- package/src/notification-renderer/components/BannerTemplate.tsx +188 -0
- package/src/notification-renderer/components/InlineBannerNotification.tsx +123 -0
- package/src/notification-renderer/implementations/createNotificationRenderer.ts +16 -0
- package/src/notification-renderer/utils/iconUtils.ts +103 -0
- package/src/notification-service/NotificationService.ts +49 -0
- package/src/notification-service/implementations/createNotificationService.test.ts +1092 -0
- package/src/notification-service/implementations/createNotificationService.ts +368 -0
- package/src/notification-telemetry/NotificationTelemetry.ts +44 -0
- package/src/notification-telemetry/implementations/createNotificationTelemetry.test.ts +99 -0
- package/src/notification-telemetry/implementations/createNotificationTelemetry.ts +33 -0
- package/src/notification-tracker/NotificationTracker.ts +14 -0
- package/src/notification-tracker/implementations/createApiNotificationTracker.test.ts +465 -0
- package/src/notification-tracker/implementations/createApiNotificationTracker.ts +154 -0
- package/src/notification-tracker/implementations/createNoopNotificationTracker.ts +44 -0
- package/src/notification-tracker/implementations/createNotificationTracker.ts +31 -0
- package/src/utils/formatNotificationType.test.ts +25 -0
- package/src/utils/formatNotificationType.ts +25 -0
- package/tsconfig.json +34 -0
- package/tsconfig.lint.json +8 -0
- package/vitest-setup.ts +1 -0
- package/vitest.config.ts +14 -0
- package/index.d.ts +0 -1
- package/index.js +0 -1
package/src/notification-data-source/implementations/createPollingNotificationDataSource.test.ts
ADDED
|
@@ -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 '@luxamm/client-notification-service/dist/lx/notificationservice/v1/api_pb'
|
|
9
|
+
import type { InAppNotification, NotificationsApiClient } from '@l.x/api'
|
|
10
|
+
import { ContentStyle } from '@l.x/api'
|
|
11
|
+
import { getNotificationQueryOptions } from '@l.x/notifications/src/notification-data-source/getNotificationQueryOptions'
|
|
12
|
+
import { createPollingNotificationDataSource } from '@l.x/notifications/src/notification-data-source/implementations/createPollingNotificationDataSource'
|
|
13
|
+
import { ReactQueryCacheKey } from '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 '@l.x/api'
|
|
3
|
+
import { createNotificationDataSource } from '@l.x/notifications/src/notification-data-source/implementations/createNotificationDataSource'
|
|
4
|
+
import { type NotificationDataSource } from '@l.x/notifications/src/notification-data-source/NotificationDataSource'
|
|
5
|
+
import { getLogger } from 'utilities/src/logger/logger'
|
|
6
|
+
import { type QueryOptionsResult } from '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
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { type InAppNotification } from '@l.x/api'
|
|
2
|
+
import { createNotificationDataSource } from '@l.x/notifications/src/notification-data-source/implementations/createNotificationDataSource'
|
|
3
|
+
import { type NotificationDataSource } from '@l.x/notifications/src/notification-data-source/NotificationDataSource'
|
|
4
|
+
import { type ReactiveCondition } from '@l.x/notifications/src/notification-data-source/types/ReactiveCondition'
|
|
5
|
+
import { type NotificationTracker } from '@l.x/notifications/src/notification-tracker/NotificationTracker'
|
|
6
|
+
import { getLogger } from 'utilities/src/logger/logger'
|
|
7
|
+
|
|
8
|
+
export interface CreateReactiveDataSourceContext<TState> {
|
|
9
|
+
/** The reactive condition that determines when to show the notification */
|
|
10
|
+
condition: ReactiveCondition<TState>
|
|
11
|
+
|
|
12
|
+
/** Tracker for checking/storing processed state */
|
|
13
|
+
tracker: NotificationTracker
|
|
14
|
+
|
|
15
|
+
/** Source identifier for telemetry (default: 'reactive') */
|
|
16
|
+
source?: string
|
|
17
|
+
|
|
18
|
+
/** File tag for logging (default: 'createReactiveDataSource') */
|
|
19
|
+
logFileTag?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const DEFAULT_SOURCE = 'reactive'
|
|
23
|
+
const DEFAULT_LOG_FILE_TAG = 'createReactiveDataSource'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Creates a data source for reactive, state-driven notifications.
|
|
27
|
+
*
|
|
28
|
+
* Unlike polling-based data sources, this subscribes to state changes and
|
|
29
|
+
* immediately re-evaluates whether to show/hide the notification. The notification
|
|
30
|
+
* is emitted when shouldShow returns true and removed when it returns false.
|
|
31
|
+
*
|
|
32
|
+
* Key behaviors:
|
|
33
|
+
* - Subscribes to condition's state changes on start()
|
|
34
|
+
* - When state changes, checks shouldShow(state)
|
|
35
|
+
* - Emits [notification] when shouldShow is true and not already processed
|
|
36
|
+
* - Emits [] when shouldShow is false (hides the notification)
|
|
37
|
+
* - Checks tracker.isProcessed to prevent showing dismissed notifications
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```typescript
|
|
41
|
+
* const offlineDataSource = createReactiveDataSource({
|
|
42
|
+
* condition: createOfflineCondition({ getState }),
|
|
43
|
+
* tracker,
|
|
44
|
+
* })
|
|
45
|
+
*
|
|
46
|
+
* notificationService.registerDataSource(offlineDataSource)
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function createReactiveDataSource<TState>(ctx: CreateReactiveDataSourceContext<TState>): NotificationDataSource {
|
|
50
|
+
const { condition, tracker, source = DEFAULT_SOURCE, logFileTag = DEFAULT_LOG_FILE_TAG } = ctx
|
|
51
|
+
|
|
52
|
+
let unsubscribe: (() => void) | null = null
|
|
53
|
+
let currentCallback: ((notifications: InAppNotification[], source: string) => void) | null = null
|
|
54
|
+
|
|
55
|
+
const emitNotifications = async (state: TState): Promise<void> => {
|
|
56
|
+
if (!currentCallback) {
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
// Check if notification was already dismissed/processed
|
|
62
|
+
const isProcessed = await tracker.isProcessed(condition.notificationId)
|
|
63
|
+
if (isProcessed) {
|
|
64
|
+
// Already dismissed, emit empty array
|
|
65
|
+
currentCallback([], source)
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Evaluate the condition
|
|
70
|
+
const shouldShow = condition.shouldShow(state)
|
|
71
|
+
|
|
72
|
+
if (shouldShow) {
|
|
73
|
+
const notification = condition.createNotification(state)
|
|
74
|
+
currentCallback([notification], source)
|
|
75
|
+
} else {
|
|
76
|
+
// Condition not met, emit empty array to hide
|
|
77
|
+
currentCallback([], source)
|
|
78
|
+
}
|
|
79
|
+
} catch (error) {
|
|
80
|
+
getLogger().error(error, {
|
|
81
|
+
tags: { file: logFileTag, function: 'emitNotifications' },
|
|
82
|
+
extra: { notificationId: condition.notificationId },
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const start = (onNotifications: (notifications: InAppNotification[], source: string) => void): void => {
|
|
88
|
+
if (unsubscribe) {
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
currentCallback = onNotifications
|
|
93
|
+
|
|
94
|
+
// Subscribe to state changes
|
|
95
|
+
unsubscribe = condition.subscribe((state: TState) => {
|
|
96
|
+
emitNotifications(state).catch((error) => {
|
|
97
|
+
getLogger().error(error, {
|
|
98
|
+
tags: { file: logFileTag, function: 'subscribe' },
|
|
99
|
+
})
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const stop = async (): Promise<void> => {
|
|
105
|
+
if (unsubscribe) {
|
|
106
|
+
unsubscribe()
|
|
107
|
+
unsubscribe = null
|
|
108
|
+
}
|
|
109
|
+
currentCallback = null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return createNotificationDataSource({ start, stop })
|
|
113
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { type InAppNotification } from '@l.x/api'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A reactive condition for state-driven notifications.
|
|
5
|
+
*
|
|
6
|
+
* Unlike polling-based TriggerCondition, ReactiveCondition uses push-based updates
|
|
7
|
+
* via a subscribe mechanism. The data source subscribes to state changes and
|
|
8
|
+
* immediately re-evaluates whether to show/hide the notification.
|
|
9
|
+
*
|
|
10
|
+
* This is ideal for conditions that:
|
|
11
|
+
* - Need instant response to state changes (e.g., network status)
|
|
12
|
+
* - Already have observable state (e.g., NetInfo, Redux store subscriptions)
|
|
13
|
+
* - Should show/hide without polling delay
|
|
14
|
+
*
|
|
15
|
+
* @template TState - The shape of the state that drives the condition
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```typescript
|
|
19
|
+
* const offlineCondition: ReactiveCondition<OfflineState> = {
|
|
20
|
+
* notificationId: 'local:session:offline',
|
|
21
|
+
* subscribe: (onStateChange) => {
|
|
22
|
+
* return NetInfo.addEventListener((state) => {
|
|
23
|
+
* onStateChange({ isConnected: state.isConnected })
|
|
24
|
+
* })
|
|
25
|
+
* },
|
|
26
|
+
* shouldShow: (state) => state.isConnected === false,
|
|
27
|
+
* createNotification: (state) => new Notification({ ... })
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export interface ReactiveCondition<TState> {
|
|
32
|
+
/**
|
|
33
|
+
* Unique notification ID.
|
|
34
|
+
* Must use 'local:' prefix to distinguish from backend-generated notifications.
|
|
35
|
+
* Use 'local:session:' prefix for session-scoped notifications that reset on app restart.
|
|
36
|
+
*/
|
|
37
|
+
notificationId: string
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Subscribe to state changes.
|
|
41
|
+
* @param onStateChange - Callback to invoke when state changes
|
|
42
|
+
* @returns Unsubscribe function to stop receiving updates
|
|
43
|
+
*/
|
|
44
|
+
subscribe: (onStateChange: (state: TState) => void) => () => void
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Check if the notification should be shown based on current state.
|
|
48
|
+
* @param state - The current state
|
|
49
|
+
* @returns true if the notification should be visible
|
|
50
|
+
*/
|
|
51
|
+
shouldShow: (state: TState) => boolean
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create the notification object to be rendered.
|
|
55
|
+
* Called when shouldShow returns true.
|
|
56
|
+
* @param state - The current state (may be useful for dynamic notification content)
|
|
57
|
+
* @returns The notification to display
|
|
58
|
+
*/
|
|
59
|
+
createNotification: (state: TState) => InAppNotification
|
|
60
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type InAppNotification } from '@l.x/api'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Result of processing notifications, separating primary notifications
|
|
5
|
+
* from chained notifications that should be shown later
|
|
6
|
+
*/
|
|
7
|
+
export interface NotificationProcessorResult {
|
|
8
|
+
/**
|
|
9
|
+
* Primary notifications that should be rendered immediately
|
|
10
|
+
*/
|
|
11
|
+
primary: InAppNotification[]
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Chained notifications that should be stored for later triggering
|
|
15
|
+
*/
|
|
16
|
+
chained: Map<string, InAppNotification>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface NotificationProcessor {
|
|
20
|
+
/**
|
|
21
|
+
* Process incoming notifications against current state
|
|
22
|
+
* Separates primary notifications (to be shown immediately) from chained notifications
|
|
23
|
+
* (to be shown when triggered by another notification's POPUP action)
|
|
24
|
+
*/
|
|
25
|
+
process(notifications: InAppNotification[]): Promise<NotificationProcessorResult>
|
|
26
|
+
}
|