@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.
- package/dist/{chunk-VBFOCTMD.cjs → chunk-35XIT2CF.cjs} +17 -17
- package/dist/{chunk-VBFOCTMD.cjs.map → chunk-35XIT2CF.cjs.map} +1 -1
- package/dist/{chunk-ATEUJQKU.js → chunk-3JWIJJ44.js} +2 -2
- package/dist/chunk-CZR7ARBA.js +698 -0
- package/dist/chunk-CZR7ARBA.js.map +1 -0
- package/dist/{chunk-UYQOPC57.js → chunk-HICZPTRR.js} +4 -351
- package/dist/chunk-HICZPTRR.js.map +1 -0
- package/dist/{chunk-WJBPLMBX.js → chunk-IK2X5YJU.js} +3 -3
- package/dist/{chunk-MDTIOPVS.cjs → chunk-OTKJASSX.cjs} +26 -26
- package/dist/{chunk-MDTIOPVS.cjs.map → chunk-OTKJASSX.cjs.map} +1 -1
- package/dist/chunk-OZ3GH6OQ.cjs +698 -0
- package/dist/chunk-OZ3GH6OQ.cjs.map +1 -0
- package/dist/{chunk-6RZYJICV.cjs → chunk-P5EE2VJX.cjs} +1 -1
- package/dist/chunk-P5EE2VJX.cjs.map +1 -0
- package/dist/{chunk-EH3RWVF3.cjs → chunk-WT5JV2GS.cjs} +8 -355
- package/dist/chunk-WT5JV2GS.cjs.map +1 -0
- package/dist/{chunk-TWKPYZNQ.cjs → chunk-ZDF6F7ED.cjs} +569 -694
- package/dist/chunk-ZDF6F7ED.cjs.map +1 -0
- package/dist/{chunk-7L4DWM7P.js → chunk-ZG2YY5E7.js} +1 -1
- package/dist/chunk-ZG2YY5E7.js.map +1 -0
- package/dist/{chunk-R5RNRH62.js → chunk-ZTJVRSN5.js} +422 -547
- package/dist/chunk-ZTJVRSN5.js.map +1 -0
- package/dist/components/chat/hooks/use-chat-identity.d.ts +3 -3
- package/dist/components/chat/hooks/use-chat-identity.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-jetstream-dialog-subscription.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-nats-dialog-subscription.d.ts +0 -9
- package/dist/components/chat/hooks/use-nats-dialog-subscription.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +6 -5
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.js +5 -4
- package/dist/components/contact/index.cjs +7 -6
- package/dist/components/contact/index.cjs.map +1 -1
- package/dist/components/contact/index.js +6 -5
- package/dist/components/features/index.cjs +6 -5
- package/dist/components/features/index.cjs.map +1 -1
- package/dist/components/features/index.js +5 -4
- package/dist/components/features/notifications/index.d.ts +2 -2
- package/dist/components/features/notifications/index.d.ts.map +1 -1
- package/dist/components/features/notifications/notifications-context.d.ts +16 -1
- package/dist/components/features/notifications/notifications-context.d.ts.map +1 -1
- package/dist/components/features/notifications/types.d.ts +4 -0
- package/dist/components/features/notifications/types.d.ts.map +1 -1
- package/dist/components/footer-waitlist-button.d.ts +21 -2
- package/dist/components/footer-waitlist-button.d.ts.map +1 -1
- package/dist/components/index.cjs +93 -96
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +17 -20
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/app-header.d.ts.map +1 -1
- package/dist/components/navigation/index.cjs +6 -5
- package/dist/components/navigation/index.cjs.map +1 -1
- package/dist/components/navigation/index.js +5 -4
- package/dist/components/navigation/sticky-section-nav.d.ts.map +1 -1
- package/dist/components/tickets/help-center-card.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +144 -102
- package/dist/components/tickets/index.cjs.map +1 -1
- package/dist/components/tickets/index.js +98 -56
- package/dist/components/tickets/index.js.map +1 -1
- package/dist/components/tickets/ticket-row.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +6 -5
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +5 -4
- package/dist/contexts/chat-runtime-context.d.ts +6 -3
- package/dist/contexts/chat-runtime-context.d.ts.map +1 -1
- package/dist/contexts/index.cjs +2 -2
- package/dist/contexts/index.js +1 -1
- package/dist/embed-shims/index.cjs +3 -3
- package/dist/embed-shims/index.cjs.map +1 -1
- package/dist/embed-shims/index.js +4 -4
- package/dist/hooks/index.cjs +3 -2
- package/dist/hooks/index.cjs.map +1 -1
- package/dist/hooks/index.js +2 -1
- package/dist/index.cjs +8 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +7 -4
- package/dist/nats/index.cjs +28 -346
- package/dist/nats/index.cjs.map +1 -1
- package/dist/nats/index.d.ts +3 -0
- package/dist/nats/index.d.ts.map +1 -1
- package/dist/nats/index.js +30 -346
- package/dist/nats/index.js.map +1 -1
- package/dist/nats/nats-provider.d.ts +28 -0
- package/dist/nats/nats-provider.d.ts.map +1 -0
- package/dist/nats/nats.d.ts +1 -0
- package/dist/nats/nats.d.ts.map +1 -1
- package/dist/nats/shared-connection.d.ts +73 -0
- package/dist/nats/shared-connection.d.ts.map +1 -0
- package/dist/nats/use-nats-subscription.d.ts +18 -0
- package/dist/nats/use-nats-subscription.d.ts.map +1 -0
- package/dist/utils/index.cjs +10 -0
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +10 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/scroll-into-view.d.ts +63 -0
- package/dist/utils/scroll-into-view.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/components/chat/hooks/use-chat-identity.ts +8 -7
- package/src/components/chat/hooks/use-jetstream-dialog-subscription.ts +60 -207
- package/src/components/chat/hooks/use-nats-dialog-subscription.ts +71 -214
- package/src/components/features/notifications/index.ts +2 -1
- package/src/components/features/notifications/notifications-context.tsx +104 -6
- package/src/components/features/notifications/types.ts +5 -0
- package/src/components/footer-waitlist-button.tsx +33 -16
- package/src/components/navigation/app-header.tsx +7 -9
- package/src/components/navigation/sticky-section-nav.tsx +6 -4
- package/src/components/tickets/help-center-card.tsx +55 -1
- package/src/components/tickets/help-center-list.tsx +9 -1
- package/src/components/tickets/ticket-detail-drawer.tsx +19 -4
- package/src/components/tickets/ticket-row.tsx +30 -19
- package/src/contexts/chat-runtime-context.tsx +6 -3
- package/src/nats/index.ts +3 -0
- package/src/nats/nats-provider.tsx +146 -0
- package/src/nats/nats.ts +2 -0
- package/src/nats/shared-connection.ts +285 -0
- package/src/nats/use-nats-subscription.ts +99 -0
- package/src/stories/EmbeddableChat.stories.tsx +1 -1
- package/src/utils/index.ts +12 -0
- package/src/utils/scroll-into-view.ts +74 -0
- package/dist/chunk-6RZYJICV.cjs.map +0 -1
- package/dist/chunk-7L4DWM7P.js.map +0 -1
- package/dist/chunk-EH3RWVF3.cjs.map +0 -1
- package/dist/chunk-R5RNRH62.js.map +0 -1
- package/dist/chunk-TWKPYZNQ.cjs.map +0 -1
- package/dist/chunk-UYQOPC57.js.map +0 -1
- /package/dist/{chunk-ATEUJQKU.js.map → chunk-3JWIJJ44.js.map} +0 -0
- /package/dist/{chunk-WJBPLMBX.js.map → chunk-IK2X5YJU.js.map} +0 -0
|
@@ -1,35 +1,22 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
108
|
+
releaseSharedClient(currentWsUrlRef.current)
|
|
166
109
|
clientRef.current = null
|
|
167
110
|
setIsConnected(false)
|
|
168
111
|
}
|
|
169
112
|
|
|
170
113
|
currentWsUrlRef.current = wsUrl
|
|
171
|
-
const
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
if (!closed && shared === sharedConn) {
|
|
230
|
-
scheduleRetry()
|
|
231
|
-
}
|
|
130
|
+
// ignore
|
|
232
131
|
}
|
|
233
|
-
}
|
|
132
|
+
})
|
|
133
|
+
subscriptionRefs.current.clear()
|
|
134
|
+
lastSubscribedDialogIdRef.current = null
|
|
135
|
+
abortControllerRef.current?.abort()
|
|
136
|
+
abortControllerRef.current = null
|
|
234
137
|
}
|
|
235
138
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
+
lifecycle.stop()
|
|
289
171
|
setIsConnected(false)
|
|
290
172
|
setIsSubscribed(false)
|
|
291
|
-
|
|
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
|
-
|
|
176
|
+
releaseSharedClient(currentWsUrlRef.current)
|
|
311
177
|
clientRef.current = null
|
|
312
178
|
currentWsUrlRef.current = ''
|
|
313
179
|
}
|
|
314
180
|
}
|
|
315
|
-
}, [enabled,
|
|
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
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
}
|