@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,239 @@
|
|
|
1
|
+
import { ContentStyle, type InAppNotification, OnClickAction } from '@luxexchange/api'
|
|
2
|
+
import { createNotificationProcessor } from '@luxexchange/notifications/src/notification-processor/implementations/createNotificationProcessor'
|
|
3
|
+
import {
|
|
4
|
+
type NotificationProcessor,
|
|
5
|
+
type NotificationProcessorResult,
|
|
6
|
+
} from '@luxexchange/notifications/src/notification-processor/NotificationProcessor'
|
|
7
|
+
import { type NotificationTracker } from '@luxexchange/notifications/src/notification-tracker/NotificationTracker'
|
|
8
|
+
import { getLogger } from '@luxfi/utilities/src/logger/logger'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Creates a base notification processor that implements style-based deduplication and limiting,
|
|
12
|
+
* as well as separating primary and chained notifications using topological sorting.
|
|
13
|
+
*
|
|
14
|
+
* Processing rules:
|
|
15
|
+
* 1. Builds a dependency graph of notifications based on POPUP actions
|
|
16
|
+
* 2. Uses topological sort to identify root notifications (those with no incoming edges)
|
|
17
|
+
* 3. Filters out notifications that have already been processed (tracked)
|
|
18
|
+
* 4. Limits the number of primary notifications per content style (LOWER_LEFT_BANNER: 3, others: 1)
|
|
19
|
+
* 5. Returns primary notifications for immediate rendering and chained notifications for later
|
|
20
|
+
*
|
|
21
|
+
* This properly handles notification chains of any length (A → B → C → D → ...).
|
|
22
|
+
*
|
|
23
|
+
* @param tracker - The NotificationTracker to check which notifications have been processed
|
|
24
|
+
* @returns A NotificationProcessor that applies these rules
|
|
25
|
+
*/
|
|
26
|
+
export function createBaseNotificationProcessor(tracker: NotificationTracker): NotificationProcessor {
|
|
27
|
+
return createNotificationProcessor({
|
|
28
|
+
process: async (notifications: InAppNotification[]): Promise<NotificationProcessorResult> => {
|
|
29
|
+
const processedIds = await tracker.getProcessedIds()
|
|
30
|
+
|
|
31
|
+
// Step 1: Build dependency graph and identify roots using topological analysis
|
|
32
|
+
const { roots, nonRoots } = identifyRootsAndChained(notifications)
|
|
33
|
+
|
|
34
|
+
// Step 2: Separate primary (roots) and chained (non-roots) notifications
|
|
35
|
+
const primaryNotifications: InAppNotification[] = []
|
|
36
|
+
const chainedNotifications: InAppNotification[] = []
|
|
37
|
+
|
|
38
|
+
for (const notification of notifications) {
|
|
39
|
+
if (roots.has(notification.id)) {
|
|
40
|
+
primaryNotifications.push(notification)
|
|
41
|
+
} else if (nonRoots.has(notification.id)) {
|
|
42
|
+
chainedNotifications.push(notification)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Step 3: Filter out notifications that are locally tracked or don't have DISMISS action
|
|
47
|
+
const filteredPrimary = primaryNotifications.filter((notification) => {
|
|
48
|
+
if (processedIds.has(notification.id)) {
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
if (!hasDismissAction(notification)) {
|
|
52
|
+
getLogger().warn(
|
|
53
|
+
'createBaseNotificationProcessor',
|
|
54
|
+
'process',
|
|
55
|
+
`Filtering out notification ${notification.id} - no DISMISS action found in any click configuration`,
|
|
56
|
+
{ notification },
|
|
57
|
+
)
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
return true
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const filteredChained = chainedNotifications.filter((notification) => {
|
|
64
|
+
if (processedIds.has(notification.id)) {
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
if (!hasDismissAction(notification)) {
|
|
68
|
+
getLogger().warn(
|
|
69
|
+
'createBaseNotificationProcessor',
|
|
70
|
+
'process',
|
|
71
|
+
`Filtering out notification ${notification.id} - no DISMISS action found in any click configuration`,
|
|
72
|
+
{ notification },
|
|
73
|
+
)
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
return true
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// Step 4: Limit the number of primary notifications per content style
|
|
80
|
+
const limitedPrimary = limitNotifications(filteredPrimary)
|
|
81
|
+
|
|
82
|
+
// Step 5: Convert chained notifications to a Map for fast lookup
|
|
83
|
+
const chainedMap = new Map<string, InAppNotification>()
|
|
84
|
+
for (const notification of filteredChained) {
|
|
85
|
+
chainedMap.set(notification.id, notification)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
primary: limitedPrimary,
|
|
90
|
+
chained: chainedMap,
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Checks if a notification has at least one DISMISS action in any of its click configurations.
|
|
98
|
+
* Every notification must have a way to be dismissed.
|
|
99
|
+
*/
|
|
100
|
+
function hasDismissAction(notification: InAppNotification): boolean {
|
|
101
|
+
// Check all buttons for DISMISS action
|
|
102
|
+
const buttons = notification.content?.buttons ?? []
|
|
103
|
+
for (const button of buttons) {
|
|
104
|
+
if (button.onClick?.onClick.includes(OnClickAction.DISMISS)) {
|
|
105
|
+
return true
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Check background click for DISMISS action
|
|
110
|
+
const backgroundOnClick = notification.content?.background?.backgroundOnClick
|
|
111
|
+
if (backgroundOnClick?.onClick.includes(OnClickAction.DISMISS)) {
|
|
112
|
+
return true
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Check onDismissClick for DISMISS action (close button)
|
|
116
|
+
const onDismissClick = notification.content?.onDismissClick
|
|
117
|
+
if (onDismissClick?.onClick.includes(OnClickAction.DISMISS)) {
|
|
118
|
+
return true
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Identifies root notifications and chained notifications using graph analysis.
|
|
126
|
+
*
|
|
127
|
+
* Builds a dependency graph where:
|
|
128
|
+
* - Nodes are notifications
|
|
129
|
+
* - Edges represent POPUP actions (A → B means A has a button/background that pops up B)
|
|
130
|
+
*
|
|
131
|
+
* Root notifications are those with no incoming edges (not referenced by any POPUP action).
|
|
132
|
+
* Chained notifications are those with at least one incoming edge.
|
|
133
|
+
*
|
|
134
|
+
* This properly handles chains of any length: A → B → C → D
|
|
135
|
+
* - A is a root (no incoming edges)
|
|
136
|
+
* - B, C, D are chained (have incoming edges)
|
|
137
|
+
*
|
|
138
|
+
* @returns Object with two sets: roots (primary notifications) and nonRoots (chained notifications)
|
|
139
|
+
*/
|
|
140
|
+
function identifyRootsAndChained(notifications: InAppNotification[]): { roots: Set<string>; nonRoots: Set<string> } {
|
|
141
|
+
const allIds = new Set<string>(notifications.map((n) => n.id))
|
|
142
|
+
const hasIncomingEdge = new Set<string>()
|
|
143
|
+
|
|
144
|
+
for (const notification of notifications) {
|
|
145
|
+
const popupTargets = extractPopupTargets(notification)
|
|
146
|
+
|
|
147
|
+
for (const targetId of popupTargets) {
|
|
148
|
+
if (allIds.has(targetId)) {
|
|
149
|
+
hasIncomingEdge.add(targetId)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const roots = new Set<string>()
|
|
155
|
+
const nonRoots = new Set<string>()
|
|
156
|
+
|
|
157
|
+
for (const notification of notifications) {
|
|
158
|
+
if (hasIncomingEdge.has(notification.id)) {
|
|
159
|
+
nonRoots.add(notification.id)
|
|
160
|
+
} else {
|
|
161
|
+
roots.add(notification.id)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return { roots, nonRoots }
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Extracts all notification IDs that are targeted by POPUP actions in this notification.
|
|
170
|
+
* Checks both button clicks and background clicks.
|
|
171
|
+
*/
|
|
172
|
+
function extractPopupTargets(notification: InAppNotification): string[] {
|
|
173
|
+
const targets: string[] = []
|
|
174
|
+
|
|
175
|
+
const buttons = notification.content?.buttons ?? []
|
|
176
|
+
for (const button of buttons) {
|
|
177
|
+
const target = getPopupTarget(button.onClick)
|
|
178
|
+
if (target) {
|
|
179
|
+
targets.push(target)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const backgroundTarget = getPopupTarget(notification.content?.background?.backgroundOnClick)
|
|
184
|
+
if (backgroundTarget) {
|
|
185
|
+
targets.push(backgroundTarget)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return targets
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Gets the target notification ID if this onClick action includes POPUP.
|
|
193
|
+
*/
|
|
194
|
+
function getPopupTarget(onClick: { onClick: OnClickAction[]; onClickLink?: string } | undefined): string | undefined {
|
|
195
|
+
if (!onClick?.onClick.includes(OnClickAction.POPUP)) {
|
|
196
|
+
return undefined
|
|
197
|
+
}
|
|
198
|
+
return onClick.onClickLink
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Limits the number of notifications per content style.
|
|
203
|
+
* - LOWER_LEFT_BANNER: up to 3 notifications
|
|
204
|
+
* - SYSTEM_BANNER: 1 notification (sticky system alerts)
|
|
205
|
+
* - All other styles: 1 notification each
|
|
206
|
+
*/
|
|
207
|
+
function limitNotifications(notifications: InAppNotification[]): InAppNotification[] {
|
|
208
|
+
const groupedByStyle = new Map<number, InAppNotification[]>()
|
|
209
|
+
|
|
210
|
+
for (const notification of notifications) {
|
|
211
|
+
const style = notification.content?.style ?? ContentStyle.UNSPECIFIED
|
|
212
|
+
const group = groupedByStyle.get(style) ?? []
|
|
213
|
+
group.push(notification)
|
|
214
|
+
groupedByStyle.set(style, group)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const limited: InAppNotification[] = []
|
|
218
|
+
|
|
219
|
+
for (const [style, group] of groupedByStyle.entries()) {
|
|
220
|
+
const limit = getStyleLimit(style)
|
|
221
|
+
limited.push(...group.slice(0, limit))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return limited
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Returns the maximum number of concurrent notifications for a given content style.
|
|
229
|
+
*/
|
|
230
|
+
function getStyleLimit(style: number): number {
|
|
231
|
+
if (style === ContentStyle.LOWER_LEFT_BANNER) {
|
|
232
|
+
return 3
|
|
233
|
+
}
|
|
234
|
+
if (style === ContentStyle.SYSTEM_BANNER) {
|
|
235
|
+
return 1
|
|
236
|
+
}
|
|
237
|
+
// Default: 1 for MODAL, UNSPECIFIED, and any future styles
|
|
238
|
+
return 1
|
|
239
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { InAppNotification } from '@luxfi/api'
|
|
2
|
+
import { createNotificationProcessor } from '@luxfi/notifications/src/notification-processor/implementations/createNotificationProcessor'
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
describe('createNotificationProcessor', () => {
|
|
6
|
+
it('creates a notification processor with process method', () => {
|
|
7
|
+
const mockProcess = vi.fn()
|
|
8
|
+
const processor = createNotificationProcessor({
|
|
9
|
+
process: mockProcess,
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
expect(processor).toBeDefined()
|
|
13
|
+
expect(typeof processor.process).toBe('function')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('delegates process call to injected process function', async () => {
|
|
17
|
+
const mockNotifications: InAppNotification[] = [
|
|
18
|
+
{
|
|
19
|
+
id: 'test-notif-1-id',
|
|
20
|
+
metaData: {},
|
|
21
|
+
notificationName: 'test-notif-1',
|
|
22
|
+
timestamp: 1000,
|
|
23
|
+
content: { style: 'CONTENT_STYLE_MODAL', title: 'test-notif-1-title' },
|
|
24
|
+
userId: 'user-1',
|
|
25
|
+
} as InAppNotification,
|
|
26
|
+
]
|
|
27
|
+
const mockResult: InAppNotification[] = [
|
|
28
|
+
{
|
|
29
|
+
id: 'result-notif-id',
|
|
30
|
+
metaData: {},
|
|
31
|
+
notificationName: 'result-notif',
|
|
32
|
+
timestamp: 2000,
|
|
33
|
+
content: { style: 'CONTENT_STYLE_MODAL', title: 'result-notif-title' },
|
|
34
|
+
userId: 'user-1',
|
|
35
|
+
} as InAppNotification,
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
const mockProcess = vi.fn().mockResolvedValue(mockResult)
|
|
39
|
+
const processor = createNotificationProcessor({
|
|
40
|
+
process: mockProcess,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const result = await processor.process(mockNotifications)
|
|
44
|
+
|
|
45
|
+
expect(mockProcess).toHaveBeenCalledWith(mockNotifications)
|
|
46
|
+
expect(result).toBe(mockResult)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('preserves the exact arguments passed to process method', async () => {
|
|
50
|
+
const notifications: InAppNotification[] = [
|
|
51
|
+
{
|
|
52
|
+
id: 'notif-1-id',
|
|
53
|
+
metaData: {},
|
|
54
|
+
notificationName: 'notif-1',
|
|
55
|
+
timestamp: 1000,
|
|
56
|
+
content: { style: 'CONTENT_STYLE_MODAL', title: 'notif-1-title' },
|
|
57
|
+
userId: 'user-1',
|
|
58
|
+
} as InAppNotification,
|
|
59
|
+
{
|
|
60
|
+
id: 'notif-2-id',
|
|
61
|
+
metaData: {},
|
|
62
|
+
notificationName: 'notif-2',
|
|
63
|
+
timestamp: 2000,
|
|
64
|
+
content: { style: 'CONTENT_STYLE_BANNER', title: 'notif-2-title' },
|
|
65
|
+
userId: 'user-1',
|
|
66
|
+
} as InAppNotification,
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
let capturedNotifications: InAppNotification[] | undefined
|
|
70
|
+
|
|
71
|
+
const mockProcess = vi.fn(async (notifs) => {
|
|
72
|
+
capturedNotifications = notifs
|
|
73
|
+
return []
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const processor = createNotificationProcessor({
|
|
77
|
+
process: mockProcess,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
await processor.process(notifications)
|
|
81
|
+
|
|
82
|
+
expect(capturedNotifications).toBe(notifications)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('returns empty array when injected process returns empty array', async () => {
|
|
86
|
+
const mockProcess = vi.fn().mockResolvedValue([])
|
|
87
|
+
const processor = createNotificationProcessor({
|
|
88
|
+
process: mockProcess,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const result = await processor.process([])
|
|
92
|
+
|
|
93
|
+
expect(result).toEqual([])
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('handles multiple calls with different arguments', async () => {
|
|
97
|
+
const mockProcess = vi.fn(async (notifications) => notifications)
|
|
98
|
+
const processor = createNotificationProcessor({
|
|
99
|
+
process: mockProcess,
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const notifs1: InAppNotification[] = [
|
|
103
|
+
{
|
|
104
|
+
id: 'notif-1-id',
|
|
105
|
+
metaData: {},
|
|
106
|
+
notificationName: 'notif-1',
|
|
107
|
+
timestamp: 1000,
|
|
108
|
+
content: { style: 'CONTENT_STYLE_MODAL', title: 'notif-1-title' },
|
|
109
|
+
userId: 'user-1',
|
|
110
|
+
} as InAppNotification,
|
|
111
|
+
]
|
|
112
|
+
const notifs2: InAppNotification[] = [
|
|
113
|
+
{
|
|
114
|
+
id: 'notif-2-id',
|
|
115
|
+
metaData: {},
|
|
116
|
+
notificationName: 'notif-2',
|
|
117
|
+
timestamp: 2000,
|
|
118
|
+
content: { style: 'CONTENT_STYLE_BANNER', title: 'notif-2-title' },
|
|
119
|
+
userId: 'user-1',
|
|
120
|
+
} as InAppNotification,
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
const result1 = await processor.process(notifs1)
|
|
124
|
+
const result2 = await processor.process(notifs2)
|
|
125
|
+
|
|
126
|
+
expect(mockProcess).toHaveBeenCalledTimes(2)
|
|
127
|
+
expect(result1).toEqual(notifs1)
|
|
128
|
+
expect(result2).toEqual(notifs2)
|
|
129
|
+
})
|
|
130
|
+
})
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { type InAppNotification } from '@luxfi/api'
|
|
2
|
+
import {
|
|
3
|
+
type NotificationProcessor,
|
|
4
|
+
type NotificationProcessorResult,
|
|
5
|
+
} from '@luxfi/notifications/src/notification-processor/NotificationProcessor'
|
|
6
|
+
|
|
7
|
+
export function createNotificationProcessor(ctx: {
|
|
8
|
+
process: (notifications: InAppNotification[]) => Promise<NotificationProcessorResult>
|
|
9
|
+
}): NotificationProcessor {
|
|
10
|
+
return {
|
|
11
|
+
process: async (notifications: InAppNotification[]): Promise<NotificationProcessorResult> => {
|
|
12
|
+
return ctx.process(notifications)
|
|
13
|
+
},
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type InAppNotification } from '@luxfi/api'
|
|
2
|
+
|
|
3
|
+
export interface NotificationRenderer {
|
|
4
|
+
// Render a notification and return cleanup function
|
|
5
|
+
render(notification: InAppNotification): () => void
|
|
6
|
+
// Check if a notification type can be rendered (e.g., only one modal at a time)
|
|
7
|
+
canRender(notification: InAppNotification): boolean
|
|
8
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { ReactNode } from 'react'
|
|
2
|
+
import { Button, Flex, IconButton, styled, Text, useIsDarkMode } from '@luxfi/ui/src'
|
|
3
|
+
import { X } from '@luxfi/ui/src/components/icons/X'
|
|
4
|
+
import { zIndexes } from '@luxfi/ui/src/theme'
|
|
5
|
+
|
|
6
|
+
const BANNER_WIDTH = 260
|
|
7
|
+
const BANNER_HEIGHT = 150
|
|
8
|
+
const GRADIENT_BACKGROUND_HEIGHT = 64 // Vertical midpoint of the thumbnail
|
|
9
|
+
const ICON_SIZE = 40
|
|
10
|
+
|
|
11
|
+
const BannerContainer = styled(Flex, {
|
|
12
|
+
borderRadius: '$rounded16',
|
|
13
|
+
minHeight: BANNER_HEIGHT,
|
|
14
|
+
shadowColor: '$shadowColor',
|
|
15
|
+
shadowOffset: { width: 0, height: 4 },
|
|
16
|
+
shadowOpacity: 0.4,
|
|
17
|
+
shadowRadius: 10,
|
|
18
|
+
overflow: 'hidden',
|
|
19
|
+
padding: '$spacing16',
|
|
20
|
+
backgroundColor: '$surface1',
|
|
21
|
+
borderWidth: 1,
|
|
22
|
+
borderColor: '$surface3',
|
|
23
|
+
variants: {
|
|
24
|
+
clickable: {
|
|
25
|
+
true: {
|
|
26
|
+
cursor: 'pointer',
|
|
27
|
+
},
|
|
28
|
+
false: {
|
|
29
|
+
cursor: 'default',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
} as const,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const GradientBackground = styled(Flex, {
|
|
36
|
+
position: 'absolute',
|
|
37
|
+
top: 0,
|
|
38
|
+
left: 0,
|
|
39
|
+
right: 0,
|
|
40
|
+
width: '100%',
|
|
41
|
+
height: GRADIENT_BACKGROUND_HEIGHT,
|
|
42
|
+
backgroundSize: 'cover',
|
|
43
|
+
backgroundPosition: 'center',
|
|
44
|
+
backgroundRepeat: 'no-repeat',
|
|
45
|
+
mask: 'linear-gradient(180deg, rgba(0,0,0,0.48) 0%, rgba(0,0,0,0) 100%)',
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const IconContainer = styled(Flex, {
|
|
49
|
+
width: ICON_SIZE,
|
|
50
|
+
height: ICON_SIZE,
|
|
51
|
+
backgroundSize: 'cover',
|
|
52
|
+
backgroundPosition: 'center',
|
|
53
|
+
backgroundRepeat: 'no-repeat',
|
|
54
|
+
borderRadius: '$rounded6',
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const ContentWrapper = styled(Flex, {
|
|
58
|
+
flex: 1,
|
|
59
|
+
justifyContent: 'space-between',
|
|
60
|
+
paddingTop: 16,
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
function BannerXButton({ handleClose }: { handleClose: () => void }): JSX.Element {
|
|
64
|
+
return (
|
|
65
|
+
<Flex row centered position="absolute" right={8} top={8} zIndex={zIndexes.mask}>
|
|
66
|
+
<IconButton
|
|
67
|
+
size="xxsmall"
|
|
68
|
+
emphasis="secondary"
|
|
69
|
+
icon={<X />}
|
|
70
|
+
onPress={(e) => {
|
|
71
|
+
e.stopPropagation()
|
|
72
|
+
handleClose()
|
|
73
|
+
}}
|
|
74
|
+
/>
|
|
75
|
+
</Flex>
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface BannerTemplateButton {
|
|
80
|
+
text: string
|
|
81
|
+
onPress: () => void
|
|
82
|
+
isPrimary?: boolean
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface BannerTemplateProps {
|
|
86
|
+
backgroundImageUrl?: string
|
|
87
|
+
/** Optional dark mode variant for backgroundImageUrl. Falls back to backgroundImageUrl if not provided. */
|
|
88
|
+
darkModeBackgroundImageUrl?: string
|
|
89
|
+
/** Pre-rendered icon element. When provided, takes priority over iconUrl. */
|
|
90
|
+
icon?: ReactNode
|
|
91
|
+
iconUrl?: string
|
|
92
|
+
/** Optional dark mode variant for iconUrl. Falls back to iconUrl if not provided. */
|
|
93
|
+
darkModeIconUrl?: string
|
|
94
|
+
title: string
|
|
95
|
+
subtitle?: string
|
|
96
|
+
onClose: () => void
|
|
97
|
+
onPress?: () => void
|
|
98
|
+
children?: ReactNode
|
|
99
|
+
/** Override the default banner width. Use '100%' for full-width. */
|
|
100
|
+
width?: number | string
|
|
101
|
+
/** Optional button to display below the content */
|
|
102
|
+
button?: BannerTemplateButton
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* BannerTemplate component
|
|
107
|
+
*
|
|
108
|
+
* A reusable template for rendering lower-banner notifications.
|
|
109
|
+
* Extracted from SolanaPromoBanner to be shared across the notification system.
|
|
110
|
+
*
|
|
111
|
+
* Features:
|
|
112
|
+
* - Fixed position in lower-left corner
|
|
113
|
+
* - Optional background image with gradient overlay
|
|
114
|
+
* - Optional icon
|
|
115
|
+
* - Title and subtitle text (or custom children)
|
|
116
|
+
* - Dismiss button
|
|
117
|
+
* - Click handler support
|
|
118
|
+
*/
|
|
119
|
+
export function BannerTemplate({
|
|
120
|
+
backgroundImageUrl,
|
|
121
|
+
darkModeBackgroundImageUrl,
|
|
122
|
+
icon,
|
|
123
|
+
iconUrl,
|
|
124
|
+
darkModeIconUrl,
|
|
125
|
+
title,
|
|
126
|
+
subtitle,
|
|
127
|
+
onClose,
|
|
128
|
+
onPress,
|
|
129
|
+
children,
|
|
130
|
+
width,
|
|
131
|
+
button,
|
|
132
|
+
}: BannerTemplateProps): JSX.Element {
|
|
133
|
+
const isDarkMode = useIsDarkMode()
|
|
134
|
+
const effectiveBackgroundUrl =
|
|
135
|
+
isDarkMode && darkModeBackgroundImageUrl ? darkModeBackgroundImageUrl : backgroundImageUrl
|
|
136
|
+
const effectiveIconUrl = isDarkMode && darkModeIconUrl ? darkModeIconUrl : iconUrl
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<BannerContainer pointerEvents="auto" width={width ?? BANNER_WIDTH} clickable={!!onPress} onPress={onPress}>
|
|
140
|
+
<BannerXButton handleClose={onClose} />
|
|
141
|
+
|
|
142
|
+
{effectiveBackgroundUrl && <GradientBackground backgroundImage={`url(${effectiveBackgroundUrl})`} />}
|
|
143
|
+
|
|
144
|
+
<ContentWrapper>
|
|
145
|
+
<Flex gap="$spacing8">
|
|
146
|
+
{icon ? (
|
|
147
|
+
<Flex centered width={ICON_SIZE} height={ICON_SIZE}>
|
|
148
|
+
{icon}
|
|
149
|
+
</Flex>
|
|
150
|
+
) : effectiveIconUrl ? (
|
|
151
|
+
<IconContainer backgroundImage={`url(${effectiveIconUrl})`} />
|
|
152
|
+
) : (
|
|
153
|
+
<Flex width={ICON_SIZE} height={ICON_SIZE} backgroundColor="transparent" />
|
|
154
|
+
)}
|
|
155
|
+
|
|
156
|
+
{children || (
|
|
157
|
+
<Flex gap="$spacing4">
|
|
158
|
+
<Text variant="body3" color="$neutral1">
|
|
159
|
+
{title}
|
|
160
|
+
</Text>
|
|
161
|
+
{subtitle && (
|
|
162
|
+
<Text variant="body4" color="$neutral2">
|
|
163
|
+
{subtitle}
|
|
164
|
+
</Text>
|
|
165
|
+
)}
|
|
166
|
+
</Flex>
|
|
167
|
+
)}
|
|
168
|
+
</Flex>
|
|
169
|
+
|
|
170
|
+
{button && (
|
|
171
|
+
<Flex marginTop="$spacing12">
|
|
172
|
+
<Button
|
|
173
|
+
size="medium"
|
|
174
|
+
emphasis={button.isPrimary ? 'primary' : 'secondary'}
|
|
175
|
+
minHeight="$spacing36"
|
|
176
|
+
onPress={(e) => {
|
|
177
|
+
e.stopPropagation()
|
|
178
|
+
button.onPress()
|
|
179
|
+
}}
|
|
180
|
+
>
|
|
181
|
+
{button.text}
|
|
182
|
+
</Button>
|
|
183
|
+
</Flex>
|
|
184
|
+
)}
|
|
185
|
+
</ContentWrapper>
|
|
186
|
+
</BannerContainer>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { BackgroundType } from '@uniswap/client-notification-service/dist/uniswap/notificationservice/v1/api_pb'
|
|
2
|
+
import type { InAppNotification } from '@luxexchange/api'
|
|
3
|
+
import {
|
|
4
|
+
BannerTemplate,
|
|
5
|
+
type BannerTemplateButton,
|
|
6
|
+
} from '@luxexchange/notifications/src/notification-renderer/components/BannerTemplate'
|
|
7
|
+
import { parseCustomIconLink } from '@luxexchange/notifications/src/notification-renderer/utils/iconUtils'
|
|
8
|
+
import { type NotificationClickTarget } from '@luxexchange/notifications/src/notification-service/NotificationService'
|
|
9
|
+
import { memo, useMemo } from 'react'
|
|
10
|
+
import { useTranslation } from 'react-i18next'
|
|
11
|
+
|
|
12
|
+
const BANNER_ICON_SIZE = 40
|
|
13
|
+
|
|
14
|
+
interface InlineBannerNotificationProps {
|
|
15
|
+
notification: InAppNotification
|
|
16
|
+
onNotificationClick?: (notificationId: string, target: NotificationClickTarget) => void
|
|
17
|
+
/** Override the default banner width. Use '100%' for full-width. */
|
|
18
|
+
width?: number | string
|
|
19
|
+
/** Enable button rendering. Defaults to false (web only). */
|
|
20
|
+
renderButton?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* InlineBannerNotification component
|
|
25
|
+
*
|
|
26
|
+
* A wrapper around BannerTemplate for rendering notification API-driven banners.
|
|
27
|
+
* Can be used inline (extension/mobile) or in fixed position (web).
|
|
28
|
+
* Delegates click handling to the NotificationService.
|
|
29
|
+
*
|
|
30
|
+
* Features:
|
|
31
|
+
* - Maps notification API types to BannerTemplate props
|
|
32
|
+
* - Delegates click actions to NotificationService via onNotificationClick
|
|
33
|
+
* - Extracts background images and icons from notification content
|
|
34
|
+
*
|
|
35
|
+
* Notification API Type Mapping:
|
|
36
|
+
* - content.title → Banner title
|
|
37
|
+
* - content.subtitle → Banner description
|
|
38
|
+
* - content.background.link → Background image URL (when backgroundType is IMAGE)
|
|
39
|
+
* - content.background.backgroundOnClick → Handled by NotificationService
|
|
40
|
+
*/
|
|
41
|
+
export const InlineBannerNotification = memo(function InlineBannerNotification({
|
|
42
|
+
notification,
|
|
43
|
+
onNotificationClick,
|
|
44
|
+
width,
|
|
45
|
+
renderButton = false,
|
|
46
|
+
}: InlineBannerNotificationProps) {
|
|
47
|
+
const { t, i18n } = useTranslation()
|
|
48
|
+
const content = notification.content
|
|
49
|
+
|
|
50
|
+
const handleClose = (): void => {
|
|
51
|
+
onNotificationClick?.(notification.id, { type: 'dismiss' })
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const hasBackgroundClick = !!content?.background?.backgroundOnClick
|
|
55
|
+
|
|
56
|
+
const handleBannerPress = (): void => {
|
|
57
|
+
onNotificationClick?.(notification.id, { type: 'background' })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const backgroundImageUrl = useMemo(() => {
|
|
61
|
+
const background = content?.background
|
|
62
|
+
if (background && background.backgroundType === BackgroundType.IMAGE && background.link) {
|
|
63
|
+
return background.link
|
|
64
|
+
}
|
|
65
|
+
return undefined
|
|
66
|
+
}, [content?.background])
|
|
67
|
+
|
|
68
|
+
const darkModeBackgroundImageUrl = useMemo(() => {
|
|
69
|
+
const background = content?.background
|
|
70
|
+
if (background && background.backgroundType === BackgroundType.IMAGE && background.darkModeLink) {
|
|
71
|
+
return background.darkModeLink
|
|
72
|
+
}
|
|
73
|
+
return undefined
|
|
74
|
+
}, [content?.background])
|
|
75
|
+
|
|
76
|
+
const customIcon = useMemo(() => {
|
|
77
|
+
const parsed = parseCustomIconLink(content?.iconLink)
|
|
78
|
+
if (!parsed.IconComponent) {
|
|
79
|
+
return undefined
|
|
80
|
+
}
|
|
81
|
+
const Icon = parsed.IconComponent
|
|
82
|
+
return <Icon size={BANNER_ICON_SIZE} color={parsed.colorToken ?? '$neutral1'} />
|
|
83
|
+
}, [content?.iconLink])
|
|
84
|
+
|
|
85
|
+
const button = useMemo((): BannerTemplateButton | undefined => {
|
|
86
|
+
if (!renderButton) {
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
91
|
+
const firstButton = content?.buttons?.[0]
|
|
92
|
+
if (!firstButton) {
|
|
93
|
+
return undefined
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Translate known i18n keys, otherwise use raw text
|
|
97
|
+
const buttonText = i18n.exists(firstButton.text) ? t(firstButton.text) : firstButton.text
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
text: buttonText,
|
|
101
|
+
isPrimary: firstButton.isPrimary ?? false,
|
|
102
|
+
onPress: (): void => {
|
|
103
|
+
onNotificationClick?.(notification.id, { type: 'button', index: 0 })
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
}, [renderButton, content?.buttons, notification.id, onNotificationClick, t, i18n])
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<BannerTemplate
|
|
110
|
+
backgroundImageUrl={backgroundImageUrl}
|
|
111
|
+
darkModeBackgroundImageUrl={darkModeBackgroundImageUrl}
|
|
112
|
+
icon={customIcon}
|
|
113
|
+
iconUrl={customIcon ? undefined : content?.iconLink}
|
|
114
|
+
darkModeIconUrl={customIcon ? undefined : content?.darkModeIconLink}
|
|
115
|
+
title={content?.title ?? ''}
|
|
116
|
+
subtitle={content?.subtitle ?? ''}
|
|
117
|
+
width={width}
|
|
118
|
+
button={button}
|
|
119
|
+
onClose={handleClose}
|
|
120
|
+
onPress={hasBackgroundClick ? handleBannerPress : undefined}
|
|
121
|
+
/>
|
|
122
|
+
)
|
|
123
|
+
})
|