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