@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.
Files changed (48) hide show
  1. package/.depcheckrc +14 -0
  2. package/.eslintrc.js +20 -0
  3. package/LICENSE +122 -0
  4. package/README.md +548 -0
  5. package/package.json +42 -1
  6. package/project.json +24 -0
  7. package/src/getIsNotificationServiceLocalOverrideEnabled.ts +7 -0
  8. package/src/global.d.ts +2 -0
  9. package/src/index.ts +41 -0
  10. package/src/notification-data-source/NotificationDataSource.ts +10 -0
  11. package/src/notification-data-source/getNotificationQueryOptions.ts +85 -0
  12. package/src/notification-data-source/implementations/createIntervalNotificationDataSource.ts +77 -0
  13. package/src/notification-data-source/implementations/createLocalTriggerDataSource.test.ts +492 -0
  14. package/src/notification-data-source/implementations/createLocalTriggerDataSource.ts +177 -0
  15. package/src/notification-data-source/implementations/createNotificationDataSource.ts +19 -0
  16. package/src/notification-data-source/implementations/createPollingNotificationDataSource.test.ts +398 -0
  17. package/src/notification-data-source/implementations/createPollingNotificationDataSource.ts +74 -0
  18. package/src/notification-data-source/implementations/createReactiveDataSource.ts +113 -0
  19. package/src/notification-data-source/types/ReactiveCondition.ts +60 -0
  20. package/src/notification-processor/NotificationProcessor.ts +26 -0
  21. package/src/notification-processor/implementations/createBaseNotificationProcessor.test.ts +854 -0
  22. package/src/notification-processor/implementations/createBaseNotificationProcessor.ts +254 -0
  23. package/src/notification-processor/implementations/createNotificationProcessor.test.ts +130 -0
  24. package/src/notification-processor/implementations/createNotificationProcessor.ts +15 -0
  25. package/src/notification-renderer/NotificationRenderer.ts +8 -0
  26. package/src/notification-renderer/components/BannerTemplate.tsx +188 -0
  27. package/src/notification-renderer/components/InlineBannerNotification.tsx +123 -0
  28. package/src/notification-renderer/implementations/createNotificationRenderer.ts +16 -0
  29. package/src/notification-renderer/utils/iconUtils.ts +103 -0
  30. package/src/notification-service/NotificationService.ts +49 -0
  31. package/src/notification-service/implementations/createNotificationService.test.ts +1092 -0
  32. package/src/notification-service/implementations/createNotificationService.ts +368 -0
  33. package/src/notification-telemetry/NotificationTelemetry.ts +44 -0
  34. package/src/notification-telemetry/implementations/createNotificationTelemetry.test.ts +99 -0
  35. package/src/notification-telemetry/implementations/createNotificationTelemetry.ts +33 -0
  36. package/src/notification-tracker/NotificationTracker.ts +14 -0
  37. package/src/notification-tracker/implementations/createApiNotificationTracker.test.ts +465 -0
  38. package/src/notification-tracker/implementations/createApiNotificationTracker.ts +154 -0
  39. package/src/notification-tracker/implementations/createNoopNotificationTracker.ts +44 -0
  40. package/src/notification-tracker/implementations/createNotificationTracker.ts +31 -0
  41. package/src/utils/formatNotificationType.test.ts +25 -0
  42. package/src/utils/formatNotificationType.ts +25 -0
  43. package/tsconfig.json +34 -0
  44. package/tsconfig.lint.json +8 -0
  45. package/vitest-setup.ts +1 -0
  46. package/vitest.config.ts +14 -0
  47. package/index.d.ts +0 -1
  48. package/index.js +0 -1
@@ -0,0 +1,492 @@
1
+ import {
2
+ Content,
3
+ Metadata,
4
+ Notification,
5
+ } from '@luxamm/client-notification-service/dist/lx/notificationservice/v1/api_pb'
6
+ import type { InAppNotification } from '@l.x/api'
7
+ import { ContentStyle } from '@l.x/api'
8
+ import {
9
+ createLocalTriggerDataSource,
10
+ getTriggerById,
11
+ type TriggerCondition,
12
+ } from '@l.x/notifications/src/notification-data-source/implementations/createLocalTriggerDataSource'
13
+ import { type NotificationTracker } from '@l.x/notifications/src/notification-tracker/NotificationTracker'
14
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
15
+
16
+ function createMockNotification(id: string): InAppNotification {
17
+ return new Notification({
18
+ id,
19
+ metadata: new Metadata({ owner: 'test', business: 'test' }),
20
+ content: new Content({ style: ContentStyle.MODAL, title: 'Test Notification' }),
21
+ })
22
+ }
23
+
24
+ function createMockTracker(processedIds: Set<string> = new Set()): NotificationTracker {
25
+ return {
26
+ isProcessed: vi.fn().mockImplementation((id: string) => Promise.resolve(processedIds.has(id))),
27
+ getProcessedIds: vi.fn().mockResolvedValue(processedIds),
28
+ track: vi.fn().mockResolvedValue(undefined),
29
+ cleanup: vi.fn().mockResolvedValue(undefined),
30
+ }
31
+ }
32
+
33
+ describe('createLocalTriggerDataSource', () => {
34
+ let mockTracker: NotificationTracker
35
+
36
+ beforeEach(() => {
37
+ vi.useFakeTimers()
38
+ mockTracker = createMockTracker()
39
+ })
40
+
41
+ afterEach(() => {
42
+ vi.useRealTimers()
43
+ vi.clearAllMocks()
44
+ })
45
+
46
+ it('creates a notification data source with start and stop methods', () => {
47
+ const dataSource = createLocalTriggerDataSource({
48
+ triggers: [],
49
+ tracker: mockTracker,
50
+ })
51
+
52
+ expect(dataSource).toBeDefined()
53
+ expect(typeof dataSource.start).toBe('function')
54
+ expect(typeof dataSource.stop).toBe('function')
55
+ })
56
+
57
+ it('checks triggers immediately on start', async () => {
58
+ const mockNotification = createMockNotification('local:test')
59
+ const trigger: TriggerCondition = {
60
+ id: 'local:test',
61
+ shouldShow: vi.fn().mockReturnValue(true),
62
+ createNotification: vi.fn().mockReturnValue(mockNotification),
63
+ }
64
+
65
+ const dataSource = createLocalTriggerDataSource({
66
+ triggers: [trigger],
67
+ tracker: mockTracker,
68
+ })
69
+
70
+ const onNotifications = vi.fn()
71
+ dataSource.start(onNotifications)
72
+
73
+ // Allow the initial async poll to complete
74
+ await vi.advanceTimersByTimeAsync(0)
75
+
76
+ expect(trigger.shouldShow).toHaveBeenCalled()
77
+ expect(onNotifications).toHaveBeenCalledWith([mockNotification], 'local_triggers')
78
+
79
+ await dataSource.stop()
80
+ })
81
+
82
+ it('polls triggers at the specified interval', async () => {
83
+ const trigger: TriggerCondition = {
84
+ id: 'local:test',
85
+ shouldShow: vi.fn().mockReturnValue(false),
86
+ createNotification: vi.fn(),
87
+ }
88
+
89
+ const dataSource = createLocalTriggerDataSource({
90
+ triggers: [trigger],
91
+ tracker: mockTracker,
92
+ pollIntervalMs: 1000,
93
+ })
94
+
95
+ const onNotifications = vi.fn()
96
+ dataSource.start(onNotifications)
97
+
98
+ // Initial check (immediate poll on start)
99
+ await vi.advanceTimersByTimeAsync(0)
100
+ expect(trigger.shouldShow).toHaveBeenCalledTimes(1)
101
+
102
+ // Advance by poll interval
103
+ await vi.advanceTimersByTimeAsync(1000)
104
+ expect(trigger.shouldShow).toHaveBeenCalledTimes(2)
105
+
106
+ // Advance again
107
+ await vi.advanceTimersByTimeAsync(1000)
108
+ expect(trigger.shouldShow).toHaveBeenCalledTimes(3)
109
+
110
+ await dataSource.stop()
111
+ })
112
+
113
+ it('skips triggers that are already processed', async () => {
114
+ const processedTracker = createMockTracker(new Set(['local:processed']))
115
+
116
+ const processedTrigger: TriggerCondition = {
117
+ id: 'local:processed',
118
+ shouldShow: vi.fn().mockReturnValue(true),
119
+ createNotification: vi.fn(),
120
+ }
121
+
122
+ const unprocessedTrigger: TriggerCondition = {
123
+ id: 'local:unprocessed',
124
+ shouldShow: vi.fn().mockReturnValue(true),
125
+ createNotification: vi.fn().mockReturnValue(createMockNotification('local:unprocessed')),
126
+ }
127
+
128
+ const dataSource = createLocalTriggerDataSource({
129
+ triggers: [processedTrigger, unprocessedTrigger],
130
+ tracker: processedTracker,
131
+ })
132
+
133
+ const onNotifications = vi.fn()
134
+ dataSource.start(onNotifications)
135
+
136
+ await vi.advanceTimersByTimeAsync(0)
137
+
138
+ // Processed trigger should not have shouldShow called
139
+ expect(processedTrigger.shouldShow).not.toHaveBeenCalled()
140
+ // Unprocessed trigger should be evaluated
141
+ expect(unprocessedTrigger.shouldShow).toHaveBeenCalled()
142
+ expect(unprocessedTrigger.createNotification).toHaveBeenCalled()
143
+
144
+ await dataSource.stop()
145
+ })
146
+
147
+ it('does not create notification when shouldShow returns false', async () => {
148
+ const trigger: TriggerCondition = {
149
+ id: 'local:test',
150
+ shouldShow: vi.fn().mockReturnValue(false),
151
+ createNotification: vi.fn(),
152
+ }
153
+
154
+ const dataSource = createLocalTriggerDataSource({
155
+ triggers: [trigger],
156
+ tracker: mockTracker,
157
+ })
158
+
159
+ const onNotifications = vi.fn()
160
+ dataSource.start(onNotifications)
161
+
162
+ await vi.advanceTimersByTimeAsync(0)
163
+
164
+ expect(trigger.shouldShow).toHaveBeenCalled()
165
+ expect(trigger.createNotification).not.toHaveBeenCalled()
166
+ expect(onNotifications).toHaveBeenCalledWith([], 'local_triggers')
167
+
168
+ await dataSource.stop()
169
+ })
170
+
171
+ it('handles async shouldShow functions', async () => {
172
+ const mockNotification = createMockNotification('local:async')
173
+ const trigger: TriggerCondition = {
174
+ id: 'local:async',
175
+ shouldShow: vi.fn().mockResolvedValue(true),
176
+ createNotification: vi.fn().mockReturnValue(mockNotification),
177
+ }
178
+
179
+ const dataSource = createLocalTriggerDataSource({
180
+ triggers: [trigger],
181
+ tracker: mockTracker,
182
+ })
183
+
184
+ const onNotifications = vi.fn()
185
+ dataSource.start(onNotifications)
186
+
187
+ await vi.advanceTimersByTimeAsync(0)
188
+
189
+ expect(trigger.shouldShow).toHaveBeenCalled()
190
+ expect(onNotifications).toHaveBeenCalledWith([mockNotification], 'local_triggers')
191
+
192
+ await dataSource.stop()
193
+ })
194
+
195
+ it('handles multiple triggers', async () => {
196
+ const notification1 = createMockNotification('local:trigger1')
197
+ const notification2 = createMockNotification('local:trigger2')
198
+
199
+ const trigger1: TriggerCondition = {
200
+ id: 'local:trigger1',
201
+ shouldShow: vi.fn().mockReturnValue(true),
202
+ createNotification: vi.fn().mockReturnValue(notification1),
203
+ }
204
+
205
+ const trigger2: TriggerCondition = {
206
+ id: 'local:trigger2',
207
+ shouldShow: vi.fn().mockReturnValue(true),
208
+ createNotification: vi.fn().mockReturnValue(notification2),
209
+ }
210
+
211
+ const trigger3: TriggerCondition = {
212
+ id: 'local:trigger3',
213
+ shouldShow: vi.fn().mockReturnValue(false),
214
+ createNotification: vi.fn(),
215
+ }
216
+
217
+ const dataSource = createLocalTriggerDataSource({
218
+ triggers: [trigger1, trigger2, trigger3],
219
+ tracker: mockTracker,
220
+ })
221
+
222
+ const onNotifications = vi.fn()
223
+ dataSource.start(onNotifications)
224
+
225
+ await vi.advanceTimersByTimeAsync(0)
226
+
227
+ expect(onNotifications).toHaveBeenCalledWith([notification1, notification2], 'local_triggers')
228
+
229
+ await dataSource.stop()
230
+ })
231
+
232
+ it('handles errors in individual triggers gracefully', async () => {
233
+ const goodNotification = createMockNotification('local:good')
234
+
235
+ const errorTrigger: TriggerCondition = {
236
+ id: 'local:error',
237
+ shouldShow: vi.fn().mockImplementation(() => {
238
+ throw new Error('Trigger error')
239
+ }),
240
+ createNotification: vi.fn(),
241
+ }
242
+
243
+ const goodTrigger: TriggerCondition = {
244
+ id: 'local:good',
245
+ shouldShow: vi.fn().mockReturnValue(true),
246
+ createNotification: vi.fn().mockReturnValue(goodNotification),
247
+ }
248
+
249
+ const dataSource = createLocalTriggerDataSource({
250
+ triggers: [errorTrigger, goodTrigger],
251
+ tracker: mockTracker,
252
+ })
253
+
254
+ const onNotifications = vi.fn()
255
+ dataSource.start(onNotifications)
256
+
257
+ await vi.advanceTimersByTimeAsync(0)
258
+
259
+ // Should still emit good notification despite error in first trigger
260
+ expect(onNotifications).toHaveBeenCalledWith([goodNotification], 'local_triggers')
261
+
262
+ await dataSource.stop()
263
+ })
264
+
265
+ it('handles errors in tracker.isProcessed gracefully', async () => {
266
+ const errorTracker = createMockTracker()
267
+ errorTracker.isProcessed = vi.fn().mockRejectedValue(new Error('Tracker error'))
268
+
269
+ const trigger: TriggerCondition = {
270
+ id: 'local:test',
271
+ shouldShow: vi.fn().mockReturnValue(true),
272
+ createNotification: vi.fn(),
273
+ }
274
+
275
+ const dataSource = createLocalTriggerDataSource({
276
+ triggers: [trigger],
277
+ tracker: errorTracker,
278
+ })
279
+
280
+ const onNotifications = vi.fn()
281
+ dataSource.start(onNotifications)
282
+
283
+ await vi.advanceTimersByTimeAsync(0)
284
+
285
+ // Should emit empty array on error
286
+ expect(onNotifications).toHaveBeenCalledWith([], 'local_triggers')
287
+
288
+ await dataSource.stop()
289
+ })
290
+
291
+ it('does not start twice if already active', async () => {
292
+ const trigger: TriggerCondition = {
293
+ id: 'local:test',
294
+ shouldShow: vi.fn().mockReturnValue(false),
295
+ createNotification: vi.fn(),
296
+ }
297
+
298
+ const dataSource = createLocalTriggerDataSource({
299
+ triggers: [trigger],
300
+ tracker: mockTracker,
301
+ })
302
+
303
+ const onNotifications = vi.fn()
304
+ dataSource.start(onNotifications)
305
+
306
+ // Try to start again
307
+ dataSource.start(onNotifications)
308
+
309
+ await vi.advanceTimersByTimeAsync(0)
310
+
311
+ // Should only call shouldShow once from the initial start
312
+ expect(trigger.shouldShow).toHaveBeenCalledTimes(1)
313
+
314
+ await dataSource.stop()
315
+ })
316
+
317
+ it('stops polling when stop is called', async () => {
318
+ const trigger: TriggerCondition = {
319
+ id: 'local:test',
320
+ shouldShow: vi.fn().mockReturnValue(false),
321
+ createNotification: vi.fn(),
322
+ }
323
+
324
+ const dataSource = createLocalTriggerDataSource({
325
+ triggers: [trigger],
326
+ tracker: mockTracker,
327
+ pollIntervalMs: 100,
328
+ })
329
+
330
+ const onNotifications = vi.fn()
331
+ dataSource.start(onNotifications)
332
+
333
+ await vi.advanceTimersByTimeAsync(0)
334
+ const callCountBeforeStop = (trigger.shouldShow as ReturnType<typeof vi.fn>).mock.calls.length
335
+
336
+ await dataSource.stop()
337
+
338
+ // Advance timers after stop
339
+ await vi.advanceTimersByTimeAsync(500)
340
+
341
+ // Should not have additional calls
342
+ expect(trigger.shouldShow).toHaveBeenCalledTimes(callCountBeforeStop)
343
+ })
344
+
345
+ it('can be started again after stopping', async () => {
346
+ const trigger: TriggerCondition = {
347
+ id: 'local:test',
348
+ shouldShow: vi.fn().mockReturnValue(false),
349
+ createNotification: vi.fn(),
350
+ }
351
+
352
+ const dataSource = createLocalTriggerDataSource({
353
+ triggers: [trigger],
354
+ tracker: mockTracker,
355
+ })
356
+
357
+ const onNotifications = vi.fn()
358
+
359
+ // Start, check, stop
360
+ dataSource.start(onNotifications)
361
+ await vi.advanceTimersByTimeAsync(0)
362
+ await dataSource.stop()
363
+
364
+ vi.clearAllMocks()
365
+
366
+ // Start again
367
+ dataSource.start(onNotifications)
368
+ await vi.advanceTimersByTimeAsync(0)
369
+
370
+ expect(trigger.shouldShow).toHaveBeenCalled()
371
+
372
+ await dataSource.stop()
373
+ })
374
+
375
+ it('uses custom source name', async () => {
376
+ const trigger: TriggerCondition = {
377
+ id: 'local:test',
378
+ shouldShow: vi.fn().mockReturnValue(false),
379
+ createNotification: vi.fn(),
380
+ }
381
+
382
+ const dataSource = createLocalTriggerDataSource({
383
+ triggers: [trigger],
384
+ tracker: mockTracker,
385
+ source: 'custom_source',
386
+ })
387
+
388
+ const onNotifications = vi.fn()
389
+ dataSource.start(onNotifications)
390
+
391
+ await vi.advanceTimersByTimeAsync(0)
392
+
393
+ expect(onNotifications).toHaveBeenCalledWith([], 'custom_source')
394
+
395
+ await dataSource.stop()
396
+ })
397
+
398
+ it('uses default poll interval of 5000ms', async () => {
399
+ const trigger: TriggerCondition = {
400
+ id: 'local:test',
401
+ shouldShow: vi.fn().mockReturnValue(false),
402
+ createNotification: vi.fn(),
403
+ }
404
+
405
+ const dataSource = createLocalTriggerDataSource({
406
+ triggers: [trigger],
407
+ tracker: mockTracker,
408
+ })
409
+
410
+ const onNotifications = vi.fn()
411
+ dataSource.start(onNotifications)
412
+
413
+ // Initial check
414
+ await vi.advanceTimersByTimeAsync(0)
415
+ expect(trigger.shouldShow).toHaveBeenCalledTimes(1)
416
+
417
+ // Advance less than default interval
418
+ await vi.advanceTimersByTimeAsync(4000)
419
+ expect(trigger.shouldShow).toHaveBeenCalledTimes(1)
420
+
421
+ // Advance to complete the default interval
422
+ await vi.advanceTimersByTimeAsync(1000)
423
+ expect(trigger.shouldShow).toHaveBeenCalledTimes(2)
424
+
425
+ await dataSource.stop()
426
+ })
427
+
428
+ it('does not call onNotifications after stop even if polling was in progress', async () => {
429
+ const trigger: TriggerCondition = {
430
+ id: 'local:test',
431
+ shouldShow: vi.fn().mockReturnValue(true),
432
+ createNotification: vi.fn().mockReturnValue(createMockNotification('local:test')),
433
+ }
434
+
435
+ const dataSource = createLocalTriggerDataSource({
436
+ triggers: [trigger],
437
+ tracker: mockTracker,
438
+ })
439
+
440
+ const onNotifications = vi.fn()
441
+ dataSource.start(onNotifications)
442
+
443
+ // Stop immediately before any async operations complete
444
+ await dataSource.stop()
445
+
446
+ // Clear any pending timers
447
+ await vi.runAllTimersAsync()
448
+
449
+ // The callback should not be set after stop, so no additional calls
450
+ // (Initial call may or may not have happened depending on timing)
451
+ const callCount = onNotifications.mock.calls.length
452
+ await vi.advanceTimersByTimeAsync(10000)
453
+ expect(onNotifications).toHaveBeenCalledTimes(callCount)
454
+ })
455
+ })
456
+
457
+ describe('getTriggerById', () => {
458
+ it('returns the trigger with matching ID', () => {
459
+ const trigger1: TriggerCondition = {
460
+ id: 'local:trigger1',
461
+ shouldShow: () => true,
462
+ createNotification: () => createMockNotification('local:trigger1'),
463
+ }
464
+
465
+ const trigger2: TriggerCondition = {
466
+ id: 'local:trigger2',
467
+ shouldShow: () => false,
468
+ createNotification: () => createMockNotification('local:trigger2'),
469
+ }
470
+
471
+ const triggers = [trigger1, trigger2]
472
+
473
+ expect(getTriggerById(triggers, 'local:trigger1')).toBe(trigger1)
474
+ expect(getTriggerById(triggers, 'local:trigger2')).toBe(trigger2)
475
+ })
476
+
477
+ it('returns undefined when no trigger matches', () => {
478
+ const triggers: TriggerCondition[] = [
479
+ {
480
+ id: 'local:trigger1',
481
+ shouldShow: () => true,
482
+ createNotification: () => createMockNotification('local:trigger1'),
483
+ },
484
+ ]
485
+
486
+ expect(getTriggerById(triggers, 'local:nonexistent')).toBeUndefined()
487
+ })
488
+
489
+ it('returns undefined for empty triggers array', () => {
490
+ expect(getTriggerById([], 'local:any')).toBeUndefined()
491
+ })
492
+ })
@@ -0,0 +1,177 @@
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 NotificationTracker } from '@l.x/notifications/src/notification-tracker/NotificationTracker'
5
+ import { getLogger } from '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 '@l.x/api'
2
+ import { type NotificationDataSource } from '@l.x/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
+ }