@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,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
+ }