@flamingo-stack/openframe-frontend-core 0.0.210 → 0.0.212-snapshot.20260528112413

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 (128) hide show
  1. package/dist/{chunk-VBFOCTMD.cjs → chunk-35XIT2CF.cjs} +17 -17
  2. package/dist/{chunk-VBFOCTMD.cjs.map → chunk-35XIT2CF.cjs.map} +1 -1
  3. package/dist/{chunk-ATEUJQKU.js → chunk-3JWIJJ44.js} +2 -2
  4. package/dist/chunk-CZR7ARBA.js +698 -0
  5. package/dist/chunk-CZR7ARBA.js.map +1 -0
  6. package/dist/{chunk-UYQOPC57.js → chunk-HICZPTRR.js} +4 -351
  7. package/dist/chunk-HICZPTRR.js.map +1 -0
  8. package/dist/{chunk-WJBPLMBX.js → chunk-IK2X5YJU.js} +3 -3
  9. package/dist/{chunk-MDTIOPVS.cjs → chunk-OTKJASSX.cjs} +26 -26
  10. package/dist/{chunk-MDTIOPVS.cjs.map → chunk-OTKJASSX.cjs.map} +1 -1
  11. package/dist/chunk-OZ3GH6OQ.cjs +698 -0
  12. package/dist/chunk-OZ3GH6OQ.cjs.map +1 -0
  13. package/dist/{chunk-6RZYJICV.cjs → chunk-P5EE2VJX.cjs} +1 -1
  14. package/dist/chunk-P5EE2VJX.cjs.map +1 -0
  15. package/dist/{chunk-EH3RWVF3.cjs → chunk-WT5JV2GS.cjs} +8 -355
  16. package/dist/chunk-WT5JV2GS.cjs.map +1 -0
  17. package/dist/{chunk-TWKPYZNQ.cjs → chunk-ZDF6F7ED.cjs} +569 -694
  18. package/dist/chunk-ZDF6F7ED.cjs.map +1 -0
  19. package/dist/{chunk-7L4DWM7P.js → chunk-ZG2YY5E7.js} +1 -1
  20. package/dist/chunk-ZG2YY5E7.js.map +1 -0
  21. package/dist/{chunk-R5RNRH62.js → chunk-ZTJVRSN5.js} +422 -547
  22. package/dist/chunk-ZTJVRSN5.js.map +1 -0
  23. package/dist/components/chat/hooks/use-chat-identity.d.ts +3 -3
  24. package/dist/components/chat/hooks/use-chat-identity.d.ts.map +1 -1
  25. package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts.map +1 -1
  26. package/dist/components/chat/hooks/use-nats-dialog-subscription.d.ts +0 -9
  27. package/dist/components/chat/hooks/use-nats-dialog-subscription.d.ts.map +1 -1
  28. package/dist/components/chat/index.cjs +6 -5
  29. package/dist/components/chat/index.cjs.map +1 -1
  30. package/dist/components/chat/index.js +5 -4
  31. package/dist/components/contact/index.cjs +7 -6
  32. package/dist/components/contact/index.cjs.map +1 -1
  33. package/dist/components/contact/index.js +6 -5
  34. package/dist/components/features/index.cjs +6 -5
  35. package/dist/components/features/index.cjs.map +1 -1
  36. package/dist/components/features/index.js +5 -4
  37. package/dist/components/features/notifications/index.d.ts +2 -2
  38. package/dist/components/features/notifications/index.d.ts.map +1 -1
  39. package/dist/components/features/notifications/notifications-context.d.ts +16 -1
  40. package/dist/components/features/notifications/notifications-context.d.ts.map +1 -1
  41. package/dist/components/features/notifications/types.d.ts +4 -0
  42. package/dist/components/features/notifications/types.d.ts.map +1 -1
  43. package/dist/components/footer-waitlist-button.d.ts +21 -2
  44. package/dist/components/footer-waitlist-button.d.ts.map +1 -1
  45. package/dist/components/index.cjs +93 -96
  46. package/dist/components/index.cjs.map +1 -1
  47. package/dist/components/index.js +17 -20
  48. package/dist/components/index.js.map +1 -1
  49. package/dist/components/navigation/app-header.d.ts.map +1 -1
  50. package/dist/components/navigation/index.cjs +6 -5
  51. package/dist/components/navigation/index.cjs.map +1 -1
  52. package/dist/components/navigation/index.js +5 -4
  53. package/dist/components/navigation/sticky-section-nav.d.ts.map +1 -1
  54. package/dist/components/tickets/help-center-card.d.ts.map +1 -1
  55. package/dist/components/tickets/index.cjs +144 -102
  56. package/dist/components/tickets/index.cjs.map +1 -1
  57. package/dist/components/tickets/index.js +98 -56
  58. package/dist/components/tickets/index.js.map +1 -1
  59. package/dist/components/tickets/ticket-row.d.ts.map +1 -1
  60. package/dist/components/ui/index.cjs +6 -5
  61. package/dist/components/ui/index.cjs.map +1 -1
  62. package/dist/components/ui/index.js +5 -4
  63. package/dist/contexts/chat-runtime-context.d.ts +6 -3
  64. package/dist/contexts/chat-runtime-context.d.ts.map +1 -1
  65. package/dist/contexts/index.cjs +2 -2
  66. package/dist/contexts/index.js +1 -1
  67. package/dist/embed-shims/index.cjs +3 -3
  68. package/dist/embed-shims/index.cjs.map +1 -1
  69. package/dist/embed-shims/index.js +4 -4
  70. package/dist/hooks/index.cjs +3 -2
  71. package/dist/hooks/index.cjs.map +1 -1
  72. package/dist/hooks/index.js +2 -1
  73. package/dist/index.cjs +8 -5
  74. package/dist/index.cjs.map +1 -1
  75. package/dist/index.js +7 -4
  76. package/dist/nats/index.cjs +28 -346
  77. package/dist/nats/index.cjs.map +1 -1
  78. package/dist/nats/index.d.ts +3 -0
  79. package/dist/nats/index.d.ts.map +1 -1
  80. package/dist/nats/index.js +30 -346
  81. package/dist/nats/index.js.map +1 -1
  82. package/dist/nats/nats-provider.d.ts +28 -0
  83. package/dist/nats/nats-provider.d.ts.map +1 -0
  84. package/dist/nats/nats.d.ts +1 -0
  85. package/dist/nats/nats.d.ts.map +1 -1
  86. package/dist/nats/shared-connection.d.ts +73 -0
  87. package/dist/nats/shared-connection.d.ts.map +1 -0
  88. package/dist/nats/use-nats-subscription.d.ts +18 -0
  89. package/dist/nats/use-nats-subscription.d.ts.map +1 -0
  90. package/dist/utils/index.cjs +10 -0
  91. package/dist/utils/index.cjs.map +1 -1
  92. package/dist/utils/index.d.ts +1 -0
  93. package/dist/utils/index.d.ts.map +1 -1
  94. package/dist/utils/index.js +10 -1
  95. package/dist/utils/index.js.map +1 -1
  96. package/dist/utils/scroll-into-view.d.ts +63 -0
  97. package/dist/utils/scroll-into-view.d.ts.map +1 -0
  98. package/package.json +1 -1
  99. package/src/components/chat/hooks/use-chat-identity.ts +8 -7
  100. package/src/components/chat/hooks/use-jetstream-dialog-subscription.ts +60 -207
  101. package/src/components/chat/hooks/use-nats-dialog-subscription.ts +71 -214
  102. package/src/components/features/notifications/index.ts +2 -1
  103. package/src/components/features/notifications/notifications-context.tsx +104 -6
  104. package/src/components/features/notifications/types.ts +5 -0
  105. package/src/components/footer-waitlist-button.tsx +33 -16
  106. package/src/components/navigation/app-header.tsx +7 -9
  107. package/src/components/navigation/sticky-section-nav.tsx +6 -4
  108. package/src/components/tickets/help-center-card.tsx +55 -1
  109. package/src/components/tickets/help-center-list.tsx +9 -1
  110. package/src/components/tickets/ticket-detail-drawer.tsx +19 -4
  111. package/src/components/tickets/ticket-row.tsx +30 -19
  112. package/src/contexts/chat-runtime-context.tsx +6 -3
  113. package/src/nats/index.ts +3 -0
  114. package/src/nats/nats-provider.tsx +146 -0
  115. package/src/nats/nats.ts +2 -0
  116. package/src/nats/shared-connection.ts +285 -0
  117. package/src/nats/use-nats-subscription.ts +99 -0
  118. package/src/stories/EmbeddableChat.stories.tsx +1 -1
  119. package/src/utils/index.ts +12 -0
  120. package/src/utils/scroll-into-view.ts +74 -0
  121. package/dist/chunk-6RZYJICV.cjs.map +0 -1
  122. package/dist/chunk-7L4DWM7P.js.map +0 -1
  123. package/dist/chunk-EH3RWVF3.cjs.map +0 -1
  124. package/dist/chunk-R5RNRH62.js.map +0 -1
  125. package/dist/chunk-TWKPYZNQ.cjs.map +0 -1
  126. package/dist/chunk-UYQOPC57.js.map +0 -1
  127. /package/dist/{chunk-ATEUJQKU.js.map → chunk-3JWIJJ44.js.map} +0 -0
  128. /package/dist/{chunk-WJBPLMBX.js.map → chunk-IK2X5YJU.js.map} +0 -0
@@ -1,35 +1,22 @@
1
1
  'use client'
2
2
 
3
- import { useCallback, useEffect, useRef, useState } from 'react'
4
- import { createNatsClient, type NatsClient, type NatsSubscriptionHandle } from '../../../nats'
3
+ import { useEffect, useRef, useState } from 'react'
4
+ import {
5
+ acquireClient as acquireSharedClient,
6
+ releaseClient as releaseSharedClient,
7
+ startConnectionLifecycle,
8
+ type NatsClient,
9
+ type NatsStatus,
10
+ type NatsSubscriptionHandle,
11
+ type SharedConnection,
12
+ } from '../../../nats'
5
13
  import {
6
14
  type NatsConnectionSource,
7
15
  type NatsMessageType,
8
16
  type UseNatsDialogSubscriptionOptions,
9
17
  type UseNatsDialogSubscriptionReturn,
10
- NETWORK_CONFIG,
11
18
  } from '../types'
12
19
 
13
- type SharedConnection = {
14
- wsUrl: string
15
- client: NatsClient
16
- connectPromise: Promise<void> | null
17
- refCount: number
18
- closeTimer: ReturnType<typeof setTimeout> | null
19
- retryTimer: ReturnType<typeof setTimeout> | null
20
- }
21
-
22
- let shared: SharedConnection | null = null
23
-
24
- /**
25
- * Hook for managing NATS dialog subscriptions.
26
- *
27
- * This hook handles:
28
- * - Connecting to NATS WebSocket
29
- * - Subscribing to dialog topics (message and/or admin-message)
30
- * - Reconnection and error handling
31
- * - Shared connection management
32
- */
33
20
  export function useNatsDialogSubscription({
34
21
  enabled,
35
22
  dialogId,
@@ -49,8 +36,7 @@ export function useNatsDialogSubscription({
49
36
 
50
37
  const clientRef = useRef<NatsClient | null>(null)
51
38
  const subscriptionRefs = useRef<Map<NatsMessageType, NatsSubscriptionHandle | null>>(new Map())
52
-
53
- // Stable refs for callbacks
39
+
54
40
  const onEventRef = useRef(onEvent)
55
41
  useEffect(() => {
56
42
  onEventRef.current = onEvent
@@ -88,66 +74,24 @@ export function useNatsDialogSubscription({
88
74
  reconnectionBackoffRef.current = reconnectionBackoff
89
75
  }, [reconnectionBackoff])
90
76
 
91
- const acquireClient = useCallback((url: string): SharedConnection => {
92
- if (shared?.wsUrl !== url) {
93
- // Close existing connection if URL changed
94
- if (shared) {
95
- shared.closeTimer && clearTimeout(shared.closeTimer)
96
- const old = shared
97
- shared = null
98
- void old.client.close().catch(() => {})
99
- }
100
-
101
- const { name = 'openframe-frontend', user = 'machine', pass = '' } = clientConfig
102
-
103
- const client = createNatsClient({
104
- servers: url,
105
- name,
106
- user,
107
- pass,
108
- connectTimeoutMs: NETWORK_CONFIG.CONNECT_TIMEOUT_MS,
109
- reconnect: false,
110
- pingIntervalMs: NETWORK_CONFIG.PING_INTERVAL_MS,
111
- maxPingOut: NETWORK_CONFIG.MAX_PING_OUT,
112
- })
113
- shared = { wsUrl: url, client, connectPromise: null, refCount: 0, closeTimer: null, retryTimer: null }
114
- }
115
-
116
- shared.refCount += 1
117
- shared.closeTimer && clearTimeout(shared.closeTimer)
118
- shared.closeTimer = null
119
- return shared
77
+ const clientConfigRef = useRef(clientConfig)
78
+ useEffect(() => {
79
+ clientConfigRef.current = clientConfig
120
80
  }, [clientConfig])
121
81
 
122
- const releaseClient = useCallback((url: string) => {
123
- if (!shared || shared.wsUrl !== url) return
124
-
125
- shared.refCount = Math.max(0, shared.refCount - 1)
126
- if (shared.refCount > 0) return
127
-
128
- shared.closeTimer = setTimeout(() => {
129
- const s = shared
130
- shared = null
131
- if (s) {
132
- if (s.retryTimer) {
133
- clearTimeout(s.retryTimer)
134
- s.retryTimer = null
135
- }
136
- void s.client.close().catch(() => {})
137
- }
138
- }, NETWORK_CONFIG.SHARED_CLOSE_DELAY_MS)
139
- }, [])
140
-
141
- // Store the current WebSocket URL to prevent unnecessary reconnections
142
82
  const currentWsUrlRef = useRef<string>('')
143
-
144
- // Connection effect
83
+
84
+ // Resolve the URL synchronously each render so the effect's dep is the URL string
85
+ // itself, not the (often inline-allocated) getNatsWsUrl callback identity. Without
86
+ // this the effect re-runs on every render that produces a new callback identity —
87
+ // e.g. every silent token rotation when `useNatsAppConfig` rebuilds getWsUrl —
88
+ // tearing the WS down and reacquiring even though the resolved URL hasn't changed.
89
+ const wsUrl = getNatsWsUrl()
90
+
145
91
  useEffect(() => {
146
- const wsUrl = getNatsWsUrl()
147
92
  if (!enabled || !wsUrl) {
148
- // Clean up if disabled or no URL
149
93
  if (currentWsUrlRef.current && clientRef.current) {
150
- releaseClient(currentWsUrlRef.current)
94
+ releaseSharedClient(currentWsUrlRef.current)
151
95
  clientRef.current = null
152
96
  currentWsUrlRef.current = ''
153
97
  setIsConnected(false)
@@ -160,159 +104,81 @@ export function useNatsDialogSubscription({
160
104
  return
161
105
  }
162
106
 
163
- // Clean up existing connection if URL changed
164
107
  if (currentWsUrlRef.current && currentWsUrlRef.current !== wsUrl && clientRef.current) {
165
- releaseClient(currentWsUrlRef.current)
108
+ releaseSharedClient(currentWsUrlRef.current)
166
109
  clientRef.current = null
167
110
  setIsConnected(false)
168
111
  }
169
112
 
170
113
  currentWsUrlRef.current = wsUrl
171
- const sharedConn = acquireClient(wsUrl)
114
+ const cfg = clientConfigRef.current
115
+ const sharedConn = acquireSharedClient(wsUrl, {
116
+ name: cfg.name ?? 'openframe-frontend',
117
+ user: cfg.user ?? 'machine',
118
+ pass: cfg.pass ?? '',
119
+ })
172
120
  const client = sharedConn.client
173
121
 
174
122
  clientRef.current = client
175
- setIsConnected(false)
176
-
177
- let closed = false
178
- let retryAttempt = 0
179
-
180
- function scheduleRetry() {
181
- if (closed) return
182
- if (shared !== sharedConn) return
183
-
184
- if (sharedConn.retryTimer) {
185
- clearTimeout(sharedConn.retryTimer)
186
- sharedConn.retryTimer = null
187
- }
188
-
189
- const cfg = reconnectionBackoffRef.current ?? {}
190
- const fastRetries = cfg.fastRetries ?? 0
191
- const fastDelay = cfg.fastRetryDelayMs ?? NETWORK_CONFIG.RETRY_INITIAL_DELAY_MS
192
- const baseDelay = cfg.initialDelayMs ?? NETWORK_CONFIG.RETRY_INITIAL_DELAY_MS
193
- const maxDelay = cfg.maxDelayMs ?? NETWORK_CONFIG.RETRY_MAX_DELAY_MS
194
- const multiplier = cfg.multiplier ?? NETWORK_CONFIG.RETRY_BACKOFF_MULTIPLIER
195
-
196
- const delay = retryAttempt < fastRetries
197
- ? fastDelay
198
- : Math.min(baseDelay * (multiplier ** (retryAttempt - fastRetries)), maxDelay)
199
- const jitteredDelay = delay * (0.5 + Math.random() * 0.5)
200
- retryAttempt++
201
-
202
- sharedConn.retryTimer = setTimeout(async () => {
203
- sharedConn.retryTimer = null
204
- if (closed) return
205
- if (shared !== sharedConn) return
206
-
207
- try {
208
- await onBeforeReconnectRef.current?.()
209
- } catch {
210
- // Token refresh failed; still try to reconnect
211
- }
212
-
213
- if (closed) return
214
- if (shared !== sharedConn) return
215
-
216
- const freshUrl = getNatsWsUrlRef.current()
217
- if (freshUrl !== wsUrl) return
123
+ setIsConnected(client.isConnected())
218
124
 
125
+ const tearDownSubscriptions = () => {
126
+ subscriptionRefs.current.forEach((sub) => {
219
127
  try {
220
- sharedConn.connectPromise = null
221
- sharedConn.connectPromise = client.connect()
222
- await sharedConn.connectPromise
223
- if (!closed && shared === sharedConn) {
224
- retryAttempt = 0
225
- setIsConnected(true)
226
- }
128
+ sub?.unsubscribe()
227
129
  } catch {
228
- sharedConn.connectPromise = null
229
- if (!closed && shared === sharedConn) {
230
- scheduleRetry()
231
- }
130
+ // ignore
232
131
  }
233
- }, jitteredDelay)
132
+ })
133
+ subscriptionRefs.current.clear()
134
+ lastSubscribedDialogIdRef.current = null
135
+ abortControllerRef.current?.abort()
136
+ abortControllerRef.current = null
234
137
  }
235
138
 
236
- const unsubscribeStatus = client.onStatus((event) => {
237
- const connected = event.status === 'connected'
238
- const disconnected = ['closed', 'disconnected', 'error'].includes(event.status)
239
- if (connected) {
240
- setIsConnected(true)
241
- if (hadConnectionBeforeRef.current) {
242
- setReconnectionCount(c => c + 1)
243
- }
244
- hadConnectionBeforeRef.current = true
245
- retryAttempt = 0
246
- onConnectRef.current?.()
247
- }
248
- if (disconnected) {
249
- setIsConnected(false)
250
- setIsSubscribed(false)
251
-
252
- subscriptionRefs.current.forEach((sub) => {
253
- try {
254
- sub?.unsubscribe()
255
- } catch {
256
- // ignore
257
- }
258
- })
259
- subscriptionRefs.current.clear()
260
- lastSubscribedDialogIdRef.current = null
261
- abortControllerRef.current?.abort()
262
- abortControllerRef.current = null
263
-
264
- onDisconnectRef.current?.()
265
- scheduleRetry()
266
- }
267
- })
268
-
269
- ;(async () => {
270
- try {
271
- sharedConn.connectPromise ||= client.connect()
272
- await sharedConn.connectPromise
273
- if (!closed) {
139
+ // This hook treats 'error' as a disconnect (pre-existing behaviour); JetStream's
140
+ // hook does not it skips retry on protocol errors.
141
+ const isDisconnectStatus = (status: NatsStatus) =>
142
+ status === 'closed' || status === 'disconnected' || status === 'error'
143
+
144
+ const lifecycle = startConnectionLifecycle({
145
+ conn: sharedConn,
146
+ wsUrl,
147
+ onBeforeReconnect: () => onBeforeReconnectRef.current?.(),
148
+ backoff: reconnectionBackoffRef.current,
149
+ getFreshUrl: () => getNatsWsUrlRef.current(),
150
+ shouldRetryOn: isDisconnectStatus,
151
+ onStatusChange: (status) => {
152
+ if (status === 'connected') {
274
153
  setIsConnected(true)
154
+ if (hadConnectionBeforeRef.current) {
155
+ setReconnectionCount((c) => c + 1)
156
+ }
275
157
  hadConnectionBeforeRef.current = true
158
+ onConnectRef.current?.()
276
159
  }
277
- } catch {
278
- sharedConn.connectPromise = null
279
- if (!closed) {
160
+ if (isDisconnectStatus(status)) {
280
161
  setIsConnected(false)
162
+ setIsSubscribed(false)
163
+ tearDownSubscriptions()
281
164
  onDisconnectRef.current?.()
282
- scheduleRetry()
283
165
  }
284
- }
285
- })()
166
+ },
167
+ })
286
168
 
287
169
  return () => {
288
- closed = true
170
+ lifecycle.stop()
289
171
  setIsConnected(false)
290
172
  setIsSubscribed(false)
291
- unsubscribeStatus()
292
-
293
- if (sharedConn.retryTimer) {
294
- clearTimeout(sharedConn.retryTimer)
295
- sharedConn.retryTimer = null
296
- }
297
-
298
- // Unsubscribe all subscriptions
299
- subscriptionRefs.current.forEach((sub) => {
300
- try {
301
- sub?.unsubscribe()
302
- } catch {
303
- // ignore
304
- }
305
- })
306
- subscriptionRefs.current.clear()
307
- lastSubscribedDialogIdRef.current = null
173
+ tearDownSubscriptions()
308
174
 
309
175
  if (clientRef.current && currentWsUrlRef.current) {
310
- releaseClient(currentWsUrlRef.current)
176
+ releaseSharedClient(currentWsUrlRef.current)
311
177
  clientRef.current = null
312
178
  currentWsUrlRef.current = ''
313
179
  }
314
180
  }
315
- }, [enabled, getNatsWsUrl, acquireClient, releaseClient])
181
+ }, [enabled, wsUrl])
316
182
 
317
183
  const topicsKey = topics.join(',')
318
184
  const lastSubscribedDialogIdRef = useRef<string | null>(null)
@@ -322,16 +188,13 @@ export function useNatsDialogSubscription({
322
188
  isConnectedRef.current = isConnected
323
189
  }, [isConnected])
324
190
 
325
- // Track subscription state separately from dialog changes
326
191
  const currentDialogIdRef = useRef<string | null>(null)
327
192
  const abortControllerRef = useRef<AbortController | null>(null)
328
-
329
- // Handle dialog changes and subscription lifecycle
193
+
330
194
  useEffect(() => {
331
195
  currentDialogIdRef.current = dialogId
332
-
196
+
333
197
  if (!enabled || !dialogId) {
334
- // Clean up if disabled or no dialog
335
198
  if (subscriptionRefs.current.size > 0) {
336
199
  setIsSubscribed(false)
337
200
  subscriptionRefs.current.forEach((sub) => {
@@ -357,7 +220,6 @@ export function useNatsDialogSubscription({
357
220
  return
358
221
  }
359
222
 
360
- // Clean up any existing subscriptions before creating new ones
361
223
  if (subscriptionRefs.current.size > 0) {
362
224
  subscriptionRefs.current.forEach((sub) => {
363
225
  try {
@@ -369,8 +231,7 @@ export function useNatsDialogSubscription({
369
231
  subscriptionRefs.current.clear()
370
232
  abortControllerRef.current?.abort()
371
233
  }
372
-
373
- // Create new abort controller for this subscription set
234
+
374
235
  abortControllerRef.current = new AbortController()
375
236
  const abort = abortControllerRef.current
376
237
 
@@ -397,7 +258,6 @@ export function useNatsDialogSubscription({
397
258
  }
398
259
  }
399
260
 
400
- // Subscribe to all configured topics
401
261
  topics.forEach((topic) => {
402
262
  const subscription = client.subscribeBytes(
403
263
  `chat.${dialogId}.${topic}`,
@@ -431,8 +291,7 @@ export function useNatsDialogSubscription({
431
291
  abortControllerRef.current = null
432
292
  }
433
293
  }, [enabled, dialogId, topicsKey, topics])
434
-
435
- // Separate effect to handle connection state changes
294
+
436
295
  useEffect(() => {
437
296
  if (!enabled || !currentDialogIdRef.current || !isConnected) {
438
297
  return
@@ -461,7 +320,6 @@ export function useNatsDialogSubscription({
461
320
  }
462
321
  }
463
322
 
464
- // Subscribe to all configured topics
465
323
  topics.forEach((topic) => {
466
324
  const subscription = client.subscribeBytes(
467
325
  `chat.${dialogId}.${topic}`,
@@ -475,7 +333,6 @@ export function useNatsDialogSubscription({
475
333
  setIsSubscribed(true)
476
334
  onSubscribedRef.current?.()
477
335
  } else if (subscriptionRefs.current.size > 0) {
478
- // We have subscriptions, just update the state
479
336
  setIsSubscribed(true)
480
337
  }
481
338
  }, [isConnected, enabled, topics, topicsKey])
@@ -3,7 +3,7 @@ export {
3
3
  useNotifications,
4
4
  useOptionalNotifications,
5
5
  } from './notifications-context'
6
- export type { NotificationsProviderProps } from './notifications-context'
6
+ export type { NotificationsProviderProps, NotificationsActions } from './notifications-context'
7
7
  export { NotificationDrawer } from './notification-drawer'
8
8
  export type { NotificationDrawerProps } from './notification-drawer'
9
9
  export { NotificationTile } from './notification-tile'
@@ -11,5 +11,6 @@ export type { NotificationTileProps } from './notification-tile'
11
11
  export type {
12
12
  Notification,
13
13
  NotificationVariant,
14
+ NotificationSeverity,
14
15
  AddNotificationInput,
15
16
  } from './types'
@@ -9,6 +9,8 @@ interface NotificationsContextValue {
9
9
  isOpen: boolean
10
10
  showPopups: boolean
11
11
  addNotification: (input: AddNotificationInput) => string
12
+ upsertNotification: (input: AddNotificationInput & { id: string }) => string
13
+ setNotifications: (list: Notification[]) => void
12
14
  markRead: (id: string) => void
13
15
  markAllRead: () => void
14
16
  markSettled: (id: string) => void
@@ -23,6 +25,17 @@ interface NotificationsContextValue {
23
25
 
24
26
  const NotificationsContext = React.createContext<NotificationsContextValue | null>(null)
25
27
 
28
+ export interface NotificationsActions {
29
+ /** Called after the local reducer marks an item read; persist server-side. */
30
+ onMarkRead?: (id: string) => void | Promise<void>
31
+ /** Called after the local reducer marks all read; persist server-side. */
32
+ onMarkAllRead?: () => void | Promise<void>
33
+ /** Called after the local reducer removes an item; persist server-side. */
34
+ onRemove?: (id: string) => void | Promise<void>
35
+ /** Called after the local reducer marks an item settled; optional persistence. */
36
+ onMarkSettled?: (id: string) => void | Promise<void>
37
+ }
38
+
26
39
  export interface NotificationsProviderProps {
27
40
  children: React.ReactNode
28
41
  initialNotifications?: Notification[]
@@ -30,22 +43,63 @@ export interface NotificationsProviderProps {
30
43
  defaultShowPopups?: boolean
31
44
  onShowPopupsChange?: (value: boolean) => void
32
45
  onHistoryClick?: () => void
46
+ actions?: NotificationsActions
33
47
  }
34
48
 
35
49
  type Action =
36
50
  | { type: 'add'; notification: Notification; max: number }
51
+ | { type: 'upsert'; notification: Notification; max: number }
52
+ | { type: 'set'; notifications: Notification[] }
37
53
  | { type: 'markRead'; id: string }
38
54
  | { type: 'markAllRead' }
39
55
  | { type: 'markSettled'; id: string }
40
56
  | { type: 'remove'; id: string }
41
57
  | { type: 'clear' }
42
58
 
59
+ function mergeNotification(base: Notification, incoming: Notification): Notification {
60
+ const out: Notification = { ...base }
61
+ for (const key of Object.keys(incoming) as (keyof Notification)[]) {
62
+ const value = incoming[key]
63
+ if (value !== undefined) (out as unknown as Record<string, unknown>)[key] = value
64
+ }
65
+
66
+ out.read = (incoming.read ?? false) || (base.read ?? false) || false
67
+ out.settled = (incoming.settled ?? false) || (base.settled ?? false) || false
68
+ return out
69
+ }
70
+
43
71
  function reducer(state: Notification[], action: Action): Notification[] {
44
72
  switch (action.type) {
45
73
  case 'add': {
46
- const next = [action.notification, ...state.filter((n) => n.id !== action.notification.id)]
74
+ const existing = state.find((n) => n.id === action.notification.id)
75
+ if (existing) {
76
+ const merged = mergeNotification(existing, action.notification)
77
+ return [merged, ...state.filter((n) => n.id !== action.notification.id)]
78
+ }
79
+ const next = [action.notification, ...state]
47
80
  return next.length > action.max ? next.slice(0, action.max) : next
48
81
  }
82
+ case 'upsert': {
83
+ const existing = state.find((n) => n.id === action.notification.id)
84
+ if (existing) {
85
+ const merged = mergeNotification(existing, action.notification)
86
+ return state.map((n) => (n.id === action.notification.id ? merged : n))
87
+ }
88
+ const next = [action.notification, ...state]
89
+ return next.length > action.max ? next.slice(0, action.max) : next
90
+ }
91
+ case 'set': {
92
+ const existingById = new Map(state.map((n) => [n.id, n]))
93
+ return action.notifications.map((incoming) => {
94
+ const existing = existingById.get(incoming.id)
95
+ if (!existing) return incoming
96
+ return {
97
+ ...incoming,
98
+ read: incoming.read || existing.read,
99
+ settled: incoming.settled || existing.settled,
100
+ }
101
+ })
102
+ }
49
103
  case 'markRead':
50
104
  return state.map((n) => (n.id === action.id ? { ...n, read: true } : n))
51
105
  case 'markAllRead':
@@ -75,11 +129,17 @@ export function NotificationsProvider({
75
129
  defaultShowPopups = true,
76
130
  onShowPopupsChange,
77
131
  onHistoryClick,
132
+ actions,
78
133
  }: NotificationsProviderProps) {
79
134
  const [notifications, dispatch] = React.useReducer(reducer, initialNotifications)
80
135
  const [isOpen, setIsOpen] = React.useState(false)
81
136
  const [showPopups, setShowPopupsState] = React.useState(defaultShowPopups)
82
137
 
138
+ const actionsRef = React.useRef(actions)
139
+ React.useEffect(() => {
140
+ actionsRef.current = actions
141
+ }, [actions])
142
+
83
143
  const addNotification = React.useCallback(
84
144
  (input: AddNotificationInput) => {
85
145
  const id = input.id ?? generateId()
@@ -87,7 +147,7 @@ export function NotificationsProvider({
87
147
  ...input,
88
148
  id,
89
149
  createdAt: input.createdAt ?? Date.now(),
90
- read: false,
150
+ read: input.read ?? false,
91
151
  }
92
152
  dispatch({ type: 'add', notification, max: maxNotifications })
93
153
  return id
@@ -95,10 +155,44 @@ export function NotificationsProvider({
95
155
  [maxNotifications],
96
156
  )
97
157
 
98
- const markRead = React.useCallback((id: string) => dispatch({ type: 'markRead', id }), [])
99
- const markAllRead = React.useCallback(() => dispatch({ type: 'markAllRead' }), [])
100
- const markSettled = React.useCallback((id: string) => dispatch({ type: 'markSettled', id }), [])
101
- const remove = React.useCallback((id: string) => dispatch({ type: 'remove', id }), [])
158
+ const upsertNotification = React.useCallback(
159
+ (input: AddNotificationInput & { id: string }) => {
160
+ const notification: Notification = {
161
+ ...input,
162
+ id: input.id,
163
+ createdAt: input.createdAt ?? Date.now(),
164
+ read: input.read ?? false,
165
+ }
166
+ dispatch({ type: 'upsert', notification, max: maxNotifications })
167
+ return input.id
168
+ },
169
+ [maxNotifications],
170
+ )
171
+
172
+ const setNotifications = React.useCallback((list: Notification[]) => {
173
+ dispatch({ type: 'set', notifications: list })
174
+ }, [])
175
+
176
+ const markRead = React.useCallback((id: string) => {
177
+ dispatch({ type: 'markRead', id })
178
+ void actionsRef.current?.onMarkRead?.(id)
179
+ }, [])
180
+
181
+ const markAllRead = React.useCallback(() => {
182
+ dispatch({ type: 'markAllRead' })
183
+ void actionsRef.current?.onMarkAllRead?.()
184
+ }, [])
185
+
186
+ const markSettled = React.useCallback((id: string) => {
187
+ dispatch({ type: 'markSettled', id })
188
+ void actionsRef.current?.onMarkSettled?.(id)
189
+ }, [])
190
+
191
+ const remove = React.useCallback((id: string) => {
192
+ dispatch({ type: 'remove', id })
193
+ void actionsRef.current?.onRemove?.(id)
194
+ }, [])
195
+
102
196
  const clear = React.useCallback(() => dispatch({ type: 'clear' }), [])
103
197
 
104
198
  const open = React.useCallback(() => setIsOpen(true), [])
@@ -125,6 +219,8 @@ export function NotificationsProvider({
125
219
  isOpen,
126
220
  showPopups,
127
221
  addNotification,
222
+ upsertNotification,
223
+ setNotifications,
128
224
  markRead,
129
225
  markAllRead,
130
226
  markSettled,
@@ -142,6 +238,8 @@ export function NotificationsProvider({
142
238
  isOpen,
143
239
  showPopups,
144
240
  addNotification,
241
+ upsertNotification,
242
+ setNotifications,
145
243
  markRead,
146
244
  markAllRead,
147
245
  markSettled,
@@ -2,6 +2,8 @@ import type { ReactNode } from 'react'
2
2
 
3
3
  export type NotificationVariant = 'default' | 'success' | 'warning' | 'error' | 'info'
4
4
 
5
+ export type NotificationSeverity = 'INFO' | 'WARNING' | 'DANGER'
6
+
5
7
  export interface Notification {
6
8
  id: string
7
9
  variant?: NotificationVariant
@@ -10,6 +12,8 @@ export interface Notification {
10
12
  createdAt: number
11
13
  read?: boolean
12
14
  settled?: boolean
15
+ severity?: NotificationSeverity
16
+ category?: string
13
17
  meta?: Record<string, unknown>
14
18
  }
15
19
 
@@ -17,4 +21,5 @@ export type AddNotificationInput =
17
21
  Omit<Notification, 'id' | 'createdAt' | 'read' | 'settled'> & {
18
22
  id?: string
19
23
  createdAt?: number
24
+ read?: boolean
20
25
  }