@flamingo-stack/openframe-frontend-core 0.0.503 → 0.0.504-snapshot.20260801181709
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-SGWPW2AO.cjs → chunk-2A74VRAF.cjs} +18 -18
- package/dist/{chunk-SGWPW2AO.cjs.map → chunk-2A74VRAF.cjs.map} +1 -1
- package/dist/{chunk-LILQJ3UZ.cjs → chunk-3ENGUBJP.cjs} +1 -1
- package/dist/chunk-3ENGUBJP.cjs.map +1 -0
- package/dist/{chunk-AWDHRIL3.cjs → chunk-4ST2BOWJ.cjs} +75 -75
- package/dist/{chunk-AWDHRIL3.cjs.map → chunk-4ST2BOWJ.cjs.map} +1 -1
- package/dist/{chunk-SFJRMBJC.js → chunk-5SVNA7IU.js} +2 -2
- package/dist/{chunk-OAWFYZZT.js → chunk-7DOSAJDK.js} +3 -3
- package/dist/{chunk-J4T5ZBWG.cjs → chunk-BZRP54IO.cjs} +19 -19
- package/dist/{chunk-J4T5ZBWG.cjs.map → chunk-BZRP54IO.cjs.map} +1 -1
- package/dist/{chunk-BZF63JXP.js → chunk-CRJVO4KO.js} +6 -6
- package/dist/{chunk-5RIHLXEP.cjs → chunk-DDVQ5NKK.cjs} +33 -33
- package/dist/{chunk-5RIHLXEP.cjs.map → chunk-DDVQ5NKK.cjs.map} +1 -1
- package/dist/{chunk-HR6F6IP4.js → chunk-DIKJLAMY.js} +78 -43
- package/dist/chunk-DIKJLAMY.js.map +1 -0
- package/dist/{chunk-VO2ZONMR.js → chunk-FNTFWP2A.js} +3 -3
- package/dist/{chunk-2OXJ6NHI.cjs → chunk-GJZ42TTG.cjs} +7 -7
- package/dist/{chunk-2OXJ6NHI.cjs.map → chunk-GJZ42TTG.cjs.map} +1 -1
- package/dist/{chunk-JIZJDAXI.cjs → chunk-GXRVBKYX.cjs} +12 -12
- package/dist/{chunk-JIZJDAXI.cjs.map → chunk-GXRVBKYX.cjs.map} +1 -1
- package/dist/{chunk-F7AD5CUU.js → chunk-H3N4P3CG.js} +3 -3
- package/dist/chunk-H3N4P3CG.js.map +1 -0
- package/dist/{chunk-VCPTU4IW.js → chunk-HWER6KGU.js} +7 -7
- package/dist/{chunk-4SR2QHMB.js → chunk-ITVWX5HX.js} +3 -3
- package/dist/{chunk-FFXNRJ5S.cjs → chunk-LOBR7IC6.cjs} +6 -6
- package/dist/chunk-LOBR7IC6.cjs.map +1 -0
- package/dist/{chunk-72DF3QTA.cjs → chunk-MJQ263KS.cjs} +14 -14
- package/dist/{chunk-72DF3QTA.cjs.map → chunk-MJQ263KS.cjs.map} +1 -1
- package/dist/{chunk-FTOVZ7O2.js → chunk-NPO6CHCR.js} +7 -7
- package/dist/{chunk-XV5SXALY.js → chunk-NT5FX7GZ.js} +3 -3
- package/dist/{chunk-UCV4LFUG.cjs → chunk-NY75FKN6.cjs} +40 -40
- package/dist/{chunk-UCV4LFUG.cjs.map → chunk-NY75FKN6.cjs.map} +1 -1
- package/dist/{chunk-4UNQMYP7.cjs → chunk-O32Y4E4R.cjs} +3 -3
- package/dist/{chunk-4UNQMYP7.cjs.map → chunk-O32Y4E4R.cjs.map} +1 -1
- package/dist/{chunk-CLQEBWV7.js → chunk-QE55ROZW.js} +4 -4
- package/dist/{chunk-WHJGRRK2.js → chunk-QW7SW5HY.js} +52 -43
- package/dist/chunk-QW7SW5HY.js.map +1 -0
- package/dist/{chunk-JGXAVNZQ.js → chunk-R6CKL7IR.js} +2464 -1886
- package/dist/chunk-R6CKL7IR.js.map +1 -0
- package/dist/{chunk-L5CI57RM.cjs → chunk-T3BF6JTQ.cjs} +52 -43
- package/dist/chunk-T3BF6JTQ.cjs.map +1 -0
- package/dist/{chunk-G43CEISB.js → chunk-TA3VJ2QO.js} +4 -4
- package/dist/{chunk-7L2RVINX.cjs → chunk-TRS4CIAS.cjs} +35 -35
- package/dist/{chunk-7L2RVINX.cjs.map → chunk-TRS4CIAS.cjs.map} +1 -1
- package/dist/{chunk-DMXB3VE2.cjs → chunk-U3YLML53.cjs} +1837 -1259
- package/dist/chunk-U3YLML53.cjs.map +1 -0
- package/dist/{chunk-3T4SMJM5.cjs → chunk-VDKNUC3N.cjs} +181 -146
- package/dist/chunk-VDKNUC3N.cjs.map +1 -0
- package/dist/{chunk-RR3Z52X5.cjs → chunk-W3QCFQ56.cjs} +42 -42
- package/dist/{chunk-RR3Z52X5.cjs.map → chunk-W3QCFQ56.cjs.map} +1 -1
- package/dist/{chunk-VYEDTVIZ.js → chunk-XJPMTJAR.js} +4 -4
- package/dist/{chunk-6GT557KN.js → chunk-XKIA4XVE.js} +1 -1
- package/dist/chunk-XKIA4XVE.js.map +1 -0
- package/dist/components/case-studies/index.cjs +11 -11
- package/dist/components/case-studies/index.js +5 -5
- package/dist/components/chat/index.cjs +5 -5
- package/dist/components/chat/index.js +4 -4
- package/dist/components/contact/index.cjs +6 -6
- package/dist/components/contact/index.js +5 -5
- package/dist/components/docs/index.cjs +8 -8
- package/dist/components/docs/index.js +7 -7
- package/dist/components/embeds/index.cjs +6 -6
- package/dist/components/embeds/index.js +5 -5
- package/dist/components/faq/index.cjs +7 -7
- package/dist/components/faq/index.js +6 -6
- package/dist/components/features/index.cjs +5 -5
- package/dist/components/features/index.js +4 -4
- package/dist/components/help-center-pages/index.cjs +24 -24
- package/dist/components/help-center-pages/index.js +15 -15
- package/dist/components/index.cjs +169 -165
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +17 -13
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/app-header.d.ts +8 -0
- package/dist/components/navigation/app-header.d.ts.map +1 -1
- package/dist/components/navigation/header-button.d.ts +5 -1
- package/dist/components/navigation/header-button.d.ts.map +1 -1
- package/dist/components/navigation/header.d.ts.map +1 -1
- package/dist/components/navigation/index.cjs +9 -5
- package/dist/components/navigation/index.cjs.map +1 -1
- package/dist/components/navigation/index.d.ts +4 -0
- package/dist/components/navigation/index.d.ts.map +1 -1
- package/dist/components/navigation/index.js +8 -4
- package/dist/components/navigation/mingo-ai-button.d.ts.map +1 -1
- package/dist/components/navigation/navigation-sidebar-item.d.ts.map +1 -1
- package/dist/components/navigation/ticket-alerts-button.d.ts +29 -0
- package/dist/components/navigation/ticket-alerts-button.d.ts.map +1 -0
- package/dist/components/navigation/top-navigation.d.ts +5 -1
- package/dist/components/navigation/top-navigation.d.ts.map +1 -1
- package/dist/components/navigation/unread-dot.d.ts +19 -0
- package/dist/components/navigation/unread-dot.d.ts.map +1 -0
- package/dist/components/onboarding-guides/index.cjs +9 -9
- package/dist/components/onboarding-guides/index.js +8 -8
- package/dist/components/related-content/index.cjs +7 -7
- package/dist/components/related-content/index.js +6 -6
- package/dist/components/tickets/help-center-card.d.ts.map +1 -1
- package/dist/components/tickets/hooks/use-ticket-actions.d.ts.map +1 -1
- package/dist/components/tickets/hooks/use-ticket-engagements.d.ts +1 -9
- package/dist/components/tickets/hooks/use-ticket-engagements.d.ts.map +1 -1
- package/dist/components/tickets/hooks/use-tickets-list.d.ts +0 -7
- package/dist/components/tickets/hooks/use-tickets-list.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +15 -8
- package/dist/components/tickets/index.cjs.map +1 -1
- package/dist/components/tickets/index.d.ts +2 -1
- package/dist/components/tickets/index.d.ts.map +1 -1
- package/dist/components/tickets/index.js +14 -7
- package/dist/components/tickets/ticket-detail-drawer.d.ts.map +1 -1
- package/dist/components/tickets/ticket-live-provider.d.ts +58 -0
- package/dist/components/tickets/ticket-live-provider.d.ts.map +1 -0
- package/dist/components/tickets/types.d.ts +58 -11
- package/dist/components/tickets/types.d.ts.map +1 -1
- package/dist/components/ui/button/button.d.ts +6 -5
- package/dist/components/ui/button/button.d.ts.map +1 -1
- package/dist/components/ui/file-manager/index.cjs +50 -50
- package/dist/components/ui/file-manager/index.js +1 -1
- package/dist/components/ui/index.cjs +5 -5
- package/dist/components/ui/index.js +4 -4
- package/dist/contexts/chat-runtime-context.d.ts +8 -0
- 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/hooks/index.cjs +3 -3
- package/dist/hooks/index.js +2 -2
- package/dist/hooks/ui/use-header-height.d.ts.map +1 -1
- package/dist/index.cjs +11 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +10 -4
- package/dist/types/navigation.d.ts +10 -0
- package/dist/types/navigation.d.ts.map +1 -1
- package/dist/utils/index.cjs +171 -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 +171 -1
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/sse-subscription.d.ts +60 -0
- package/dist/utils/sse-subscription.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/components/features/time-tracker/time-tracker-header-button.tsx +1 -1
- package/src/components/navigation/app-header.tsx +36 -20
- package/src/components/navigation/header-button.tsx +18 -2
- package/src/components/navigation/header-mingo-button.tsx +2 -2
- package/src/components/navigation/header-skeleton.tsx +4 -4
- package/src/components/navigation/header.tsx +41 -21
- package/src/components/navigation/index.ts +4 -0
- package/src/components/navigation/mingo-ai-button.tsx +23 -22
- package/src/components/navigation/mobile-burger-menu.tsx +3 -3
- package/src/components/navigation/navigation-sidebar-item.tsx +2 -3
- package/src/components/navigation/ticket-alerts-button.tsx +61 -0
- package/src/components/navigation/top-navigation.tsx +17 -1
- package/src/components/navigation/unread-dot.tsx +34 -0
- package/src/components/tickets/.types.md +1 -2
- package/src/components/tickets/help-center-card.tsx +16 -0
- package/src/components/tickets/help-center-list.tsx +6 -6
- package/src/components/tickets/hooks/.use-ticket-engagements.md +3 -12
- package/src/components/tickets/hooks/.use-tickets-list.md +0 -2
- package/src/components/tickets/hooks/use-ticket-actions.ts +9 -0
- package/src/components/tickets/hooks/use-ticket-engagements.ts +4 -13
- package/src/components/tickets/hooks/use-tickets-list.ts +2 -13
- package/src/components/tickets/index.ts +10 -0
- package/src/components/tickets/ticket-detail-drawer.tsx +45 -13
- package/src/components/tickets/ticket-live-provider.tsx +474 -0
- package/src/components/tickets/types.ts +62 -11
- package/src/components/ui/button/button.tsx +165 -133
- package/src/contexts/chat-runtime-context.tsx +8 -0
- package/src/hooks/ui/use-header-height.ts +3 -1
- package/src/stories/TopNavigation.stories.tsx +20 -1
- package/src/types/navigation.ts +10 -0
- package/src/utils/index.ts +12 -0
- package/src/utils/sse-subscription.ts +276 -0
- package/dist/chunk-3T4SMJM5.cjs.map +0 -1
- package/dist/chunk-6GT557KN.js.map +0 -1
- package/dist/chunk-DMXB3VE2.cjs.map +0 -1
- package/dist/chunk-F7AD5CUU.js.map +0 -1
- package/dist/chunk-FFXNRJ5S.cjs.map +0 -1
- package/dist/chunk-HR6F6IP4.js.map +0 -1
- package/dist/chunk-JGXAVNZQ.js.map +0 -1
- package/dist/chunk-L5CI57RM.cjs.map +0 -1
- package/dist/chunk-LILQJ3UZ.cjs.map +0 -1
- package/dist/chunk-WHJGRRK2.js.map +0 -1
- /package/dist/{chunk-SFJRMBJC.js.map → chunk-5SVNA7IU.js.map} +0 -0
- /package/dist/{chunk-OAWFYZZT.js.map → chunk-7DOSAJDK.js.map} +0 -0
- /package/dist/{chunk-BZF63JXP.js.map → chunk-CRJVO4KO.js.map} +0 -0
- /package/dist/{chunk-VO2ZONMR.js.map → chunk-FNTFWP2A.js.map} +0 -0
- /package/dist/{chunk-VCPTU4IW.js.map → chunk-HWER6KGU.js.map} +0 -0
- /package/dist/{chunk-4SR2QHMB.js.map → chunk-ITVWX5HX.js.map} +0 -0
- /package/dist/{chunk-FTOVZ7O2.js.map → chunk-NPO6CHCR.js.map} +0 -0
- /package/dist/{chunk-XV5SXALY.js.map → chunk-NT5FX7GZ.js.map} +0 -0
- /package/dist/{chunk-CLQEBWV7.js.map → chunk-QE55ROZW.js.map} +0 -0
- /package/dist/{chunk-G43CEISB.js.map → chunk-TA3VJ2QO.js.map} +0 -0
- /package/dist/{chunk-VYEDTVIZ.js.map → chunk-XJPMTJAR.js.map} +0 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* TicketLiveProvider — the ONE client home for support-ticket realtime.
|
|
5
|
+
*
|
|
6
|
+
* Owns:
|
|
7
|
+
* - the SSE subscription lifecycle (`createSseSubscription` over
|
|
8
|
+
* `ticketStreamUrl`), with `connected` derived from the SERVER's
|
|
9
|
+
* `status` frames (`subscribed` → true; `retrying`/`reconnect_failed`
|
|
10
|
+
* → false) — never from "the HTTP stream is open";
|
|
11
|
+
* - the unread summary (single source: `ticket-unread-summary`) —
|
|
12
|
+
* header badge total AND per-row dots both read this map;
|
|
13
|
+
* - resync on every (re)connect (summary refetch + engagements
|
|
14
|
+
* invalidation) — the single rule that closes every event-gap class
|
|
15
|
+
* (forced maxDuration reconnects, network blips, tab sleep);
|
|
16
|
+
* - visibility gating: hidden → 45s grace → disconnect; visible →
|
|
17
|
+
* reconnect + resync;
|
|
18
|
+
* - `markRead` with local write-through zeroing (no interval polling
|
|
19
|
+
* exists — a stale post-close map would re-light the dot forever).
|
|
20
|
+
*
|
|
21
|
+
* There is NO polling anywhere: the summary query has no
|
|
22
|
+
* `refetchInterval`; it refetches on mount, (re)connect, window focus,
|
|
23
|
+
* and (debounced) stream events. Hosts whose stream endpoint 404s
|
|
24
|
+
* (terminal) or is absent keep exactly focus/mount freshness.
|
|
25
|
+
*
|
|
26
|
+
* Invalidation semantics on events — deliberately `invalidateQueries`,
|
|
27
|
+
* NOT `setQueryData` patches: the ticket hooks run `gcTime: 0`, so
|
|
28
|
+
* there is usually no cache entry to patch, and `TicketsCacheSlot`
|
|
29
|
+
* shape rules apply when there is.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import React from 'react'
|
|
33
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
34
|
+
import { useChatRuntime } from '../../contexts/chat-runtime-context'
|
|
35
|
+
import { embedAuthedFetch } from '../../utils/embed-authed-fetch'
|
|
36
|
+
import {
|
|
37
|
+
createSseSubscription,
|
|
38
|
+
type SseSubscription,
|
|
39
|
+
type SseTransportStatus,
|
|
40
|
+
} from '../../utils/sse-subscription'
|
|
41
|
+
import { useChatIdentity } from '../chat/hooks/use-chat-identity'
|
|
42
|
+
import type {
|
|
43
|
+
TicketMessageStreamData,
|
|
44
|
+
TicketStreamEvent,
|
|
45
|
+
TicketUnreadSummary,
|
|
46
|
+
} from './types'
|
|
47
|
+
|
|
48
|
+
const TICKET_STREAM_ENDPOINT = '/api/chat/agent/ticket-stream'
|
|
49
|
+
const TICKET_READ_ENDPOINT = '/api/chat/agent/ticket-read'
|
|
50
|
+
const TICKET_UNREAD_SUMMARY_ENDPOINT = '/api/chat/agent/ticket-unread-summary'
|
|
51
|
+
|
|
52
|
+
/** Trailing debounce for event-burst invalidations (an agent pasting 5
|
|
53
|
+
* messages must not fire 5 list refetches). */
|
|
54
|
+
const INVALIDATE_DEBOUNCE_MS = 1_500
|
|
55
|
+
/** Grace before disconnecting a hidden tab (Cmd-Tab thrash protection). */
|
|
56
|
+
const HIDDEN_DISCONNECT_GRACE_MS = 45_000
|
|
57
|
+
/** No `status: subscribed` within this window after transport-open →
|
|
58
|
+
* treat as disconnected and force a client-side reconnect. */
|
|
59
|
+
const SUBSCRIBE_CONFIRM_TIMEOUT_MS = 15_000
|
|
60
|
+
/** After a server-reported `reconnect_failed`, the next client attempt
|
|
61
|
+
* starts at the CAPPED delay — each reconnect costs a full server
|
|
62
|
+
* invocation + the server's own Realtime retries; an outage must not
|
|
63
|
+
* become a stampede. */
|
|
64
|
+
const FAILED_RECONNECT_DELAY_MS = 30_000
|
|
65
|
+
/** A server `retrying` status means the server is recovering the channel
|
|
66
|
+
* itself (exponential backoff, ~5 attempts). Give it this long to reach
|
|
67
|
+
* `subscribed` before the client hard-reconnects. */
|
|
68
|
+
const SERVER_RETRY_GRACE_MS = 90_000
|
|
69
|
+
|
|
70
|
+
export interface TicketLiveContextValue {
|
|
71
|
+
/** Viewer has a resolved, non-anon chat identity. Surfaces (e.g. the
|
|
72
|
+
* header cell) hide themselves when false — computed here so they
|
|
73
|
+
* don't each spin up their own identity resolution. */
|
|
74
|
+
authed: boolean
|
|
75
|
+
/** Realtime-subscribed (server-confirmed), not merely HTTP-open. */
|
|
76
|
+
connected: boolean
|
|
77
|
+
/** Unread total across tickets — drives the header dot. */
|
|
78
|
+
unreadTotal: number
|
|
79
|
+
/** Per-ticket unread counts (missing key = 0) — drives row dots.
|
|
80
|
+
* The currently-open ticket is masked to 0. */
|
|
81
|
+
unreadByTicket: Record<string, number>
|
|
82
|
+
/** Mark a ticket read: POSTs the receipt, then locally zeroes the
|
|
83
|
+
* ticket in the summary map (write-through; reconciled by the next
|
|
84
|
+
* real refetch). */
|
|
85
|
+
markRead: (ticketExternalId: string) => Promise<void>
|
|
86
|
+
/** Drawer open/close registration — suppresses unread bumps for the
|
|
87
|
+
* open ticket and masks it in the derived map. */
|
|
88
|
+
setOpenTicketId: (ticketExternalId: string | null) => void
|
|
89
|
+
/** Hint that the viewer just created a ticket — retries a `no-stream`
|
|
90
|
+
* (204) subscription. */
|
|
91
|
+
notifyTicketCreated: () => void
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const TicketLiveContext = React.createContext<TicketLiveContextValue | null>(null)
|
|
95
|
+
|
|
96
|
+
export function useOptionalTicketLive(): TicketLiveContextValue | null {
|
|
97
|
+
return React.useContext(TicketLiveContext)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function useTicketLive(): TicketLiveContextValue {
|
|
101
|
+
const ctx = React.useContext(TicketLiveContext)
|
|
102
|
+
if (!ctx) {
|
|
103
|
+
throw new Error('useTicketLive must be used within <TicketLiveProvider>')
|
|
104
|
+
}
|
|
105
|
+
return ctx
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isTicketMessageData(value: unknown): value is TicketMessageStreamData {
|
|
109
|
+
return (
|
|
110
|
+
typeof value === 'object' &&
|
|
111
|
+
value !== null &&
|
|
112
|
+
typeof (value as TicketMessageStreamData).ticket_external_id === 'string'
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function TicketLiveProvider({ children }: { children: React.ReactNode }) {
|
|
117
|
+
const runtime = useChatRuntime()
|
|
118
|
+
const identity = useChatIdentity()
|
|
119
|
+
const queryClient = useQueryClient()
|
|
120
|
+
|
|
121
|
+
const streamUrl = runtime?.endpoints.ticketStreamUrl ?? TICKET_STREAM_ENDPOINT
|
|
122
|
+
const readUrl = runtime?.endpoints.ticketReadUrl ?? TICKET_READ_ENDPOINT
|
|
123
|
+
const summaryUrl =
|
|
124
|
+
runtime?.endpoints.ticketUnreadSummaryUrl ?? TICKET_UNREAD_SUMMARY_ENDPOINT
|
|
125
|
+
|
|
126
|
+
const identityKey = identity.user?.email ?? 'anon'
|
|
127
|
+
const authed = identity.authTier !== 'anon' && !!identity.user?.email
|
|
128
|
+
|
|
129
|
+
const [connected, setConnected] = React.useState(false)
|
|
130
|
+
// State + ref for the open drawer: state drives the derived map,
|
|
131
|
+
// the ref lets stream callbacks read it without re-subscribing.
|
|
132
|
+
const [openTicketIdState, setOpenTicketIdState] = React.useState<string | null>(null)
|
|
133
|
+
const openTicketIdRef = React.useRef<string | null>(null)
|
|
134
|
+
|
|
135
|
+
const summaryQueryKey = React.useMemo(
|
|
136
|
+
() => ['ticket-unread-summary', identityKey] as const,
|
|
137
|
+
[identityKey],
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
// ONE unread source. No refetchInterval — freshness is event-driven
|
|
141
|
+
// (mount / focus / (re)connect / debounced stream events).
|
|
142
|
+
const summaryQuery = useQuery({
|
|
143
|
+
queryKey: summaryQueryKey,
|
|
144
|
+
enabled: authed,
|
|
145
|
+
staleTime: 0,
|
|
146
|
+
refetchOnMount: 'always',
|
|
147
|
+
refetchOnWindowFocus: true,
|
|
148
|
+
queryFn: async (): Promise<TicketUnreadSummary> => {
|
|
149
|
+
const response = await embedAuthedFetch(summaryUrl, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
body: JSON.stringify({}),
|
|
152
|
+
})
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
throw new Error(`ticket-unread-summary failed: ${response.status}`)
|
|
155
|
+
}
|
|
156
|
+
const body = (await response.json()) as Partial<TicketUnreadSummary>
|
|
157
|
+
return {
|
|
158
|
+
totalUnread: typeof body.totalUnread === 'number' ? body.totalUnread : 0,
|
|
159
|
+
tickets: body.tickets ?? {},
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const refetchSummary = React.useCallback(() => {
|
|
165
|
+
void queryClient.invalidateQueries({ queryKey: summaryQueryKey })
|
|
166
|
+
}, [queryClient, summaryQueryKey])
|
|
167
|
+
|
|
168
|
+
// ---- Debounced (trailing) invalidation of ticket queries on events ----
|
|
169
|
+
const pendingTicketIdsRef = React.useRef<Set<string>>(new Set())
|
|
170
|
+
const invalidateTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
171
|
+
const flushInvalidations = React.useCallback(() => {
|
|
172
|
+
invalidateTimerRef.current = null
|
|
173
|
+
const ids = Array.from(pendingTicketIdsRef.current)
|
|
174
|
+
pendingTicketIdsRef.current.clear()
|
|
175
|
+
void queryClient.invalidateQueries({ queryKey: ['tickets'] })
|
|
176
|
+
for (const id of ids) {
|
|
177
|
+
void queryClient.invalidateQueries({ queryKey: ['ticket-engagements', id] })
|
|
178
|
+
}
|
|
179
|
+
refetchSummary()
|
|
180
|
+
}, [queryClient, refetchSummary])
|
|
181
|
+
const scheduleInvalidation = React.useCallback(
|
|
182
|
+
(ticketId?: string) => {
|
|
183
|
+
if (ticketId) pendingTicketIdsRef.current.add(ticketId)
|
|
184
|
+
if (invalidateTimerRef.current) return
|
|
185
|
+
invalidateTimerRef.current = setTimeout(flushInvalidations, INVALIDATE_DEBOUNCE_MS)
|
|
186
|
+
},
|
|
187
|
+
[flushInvalidations],
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
// ---- Optimistic unread bump (reconciled by summary refetches) ----
|
|
191
|
+
const bumpUnread = React.useCallback(
|
|
192
|
+
(ticketId: string) => {
|
|
193
|
+
queryClient.setQueryData<TicketUnreadSummary>(summaryQueryKey, (prev) => {
|
|
194
|
+
const base = prev ?? { totalUnread: 0, tickets: {} }
|
|
195
|
+
return {
|
|
196
|
+
totalUnread: base.totalUnread + 1,
|
|
197
|
+
tickets: { ...base.tickets, [ticketId]: (base.tickets[ticketId] ?? 0) + 1 },
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
},
|
|
201
|
+
[queryClient, summaryQueryKey],
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
const zeroUnread = React.useCallback(
|
|
205
|
+
(ticketId: string) => {
|
|
206
|
+
queryClient.setQueryData<TicketUnreadSummary>(summaryQueryKey, (prev) => {
|
|
207
|
+
if (!prev) return prev
|
|
208
|
+
const current = prev.tickets[ticketId] ?? 0
|
|
209
|
+
if (current === 0) return prev
|
|
210
|
+
const tickets = { ...prev.tickets }
|
|
211
|
+
delete tickets[ticketId]
|
|
212
|
+
return { totalUnread: Math.max(0, prev.totalUnread - current), tickets }
|
|
213
|
+
})
|
|
214
|
+
},
|
|
215
|
+
[queryClient, summaryQueryKey],
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
// ---- Stream lifecycle ----
|
|
219
|
+
const subscriptionRef = React.useRef<SseSubscription | null>(null)
|
|
220
|
+
const noStreamRef = React.useRef(false)
|
|
221
|
+
const subscribeConfirmTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
222
|
+
const serverRecoveryTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
223
|
+
const failedReconnectTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
224
|
+
const hiddenGraceTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
225
|
+
|
|
226
|
+
const clearTimer = (ref: React.MutableRefObject<ReturnType<typeof setTimeout> | null>) => {
|
|
227
|
+
if (ref.current) {
|
|
228
|
+
clearTimeout(ref.current)
|
|
229
|
+
ref.current = null
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Stable handler refs so the subscription effect doesn't churn.
|
|
234
|
+
const handlersRef = React.useRef<{
|
|
235
|
+
onEvent: (eventName: string, data: unknown) => void
|
|
236
|
+
onStatus: (status: SseTransportStatus) => void
|
|
237
|
+
}>({ onEvent: () => {}, onStatus: () => {} })
|
|
238
|
+
|
|
239
|
+
const hardReconnect = React.useCallback((delayMs: number) => {
|
|
240
|
+
clearTimer(failedReconnectTimerRef)
|
|
241
|
+
if (delayMs <= 0) {
|
|
242
|
+
subscriptionRef.current?.reconnectNow()
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
failedReconnectTimerRef.current = setTimeout(() => {
|
|
246
|
+
failedReconnectTimerRef.current = null
|
|
247
|
+
subscriptionRef.current?.reconnectNow()
|
|
248
|
+
}, delayMs)
|
|
249
|
+
}, [])
|
|
250
|
+
|
|
251
|
+
handlersRef.current.onEvent = (eventName, data) => {
|
|
252
|
+
if (eventName === 'status') {
|
|
253
|
+
const status = (data as { status?: string } | null)?.status
|
|
254
|
+
if (status === 'subscribed') {
|
|
255
|
+
clearTimer(subscribeConfirmTimerRef)
|
|
256
|
+
clearTimer(serverRecoveryTimerRef)
|
|
257
|
+
setConnected(true)
|
|
258
|
+
// Resync on EVERY (re)connect — closes all event-gap classes.
|
|
259
|
+
refetchSummary()
|
|
260
|
+
const openId = openTicketIdRef.current
|
|
261
|
+
if (openId) {
|
|
262
|
+
void queryClient.invalidateQueries({ queryKey: ['ticket-engagements', openId] })
|
|
263
|
+
}
|
|
264
|
+
} else if (status === 'retrying') {
|
|
265
|
+
// Server is recovering its Realtime channel itself — wait for it
|
|
266
|
+
// (bounded), don't stampede with a client reconnect.
|
|
267
|
+
setConnected(false)
|
|
268
|
+
if (!serverRecoveryTimerRef.current) {
|
|
269
|
+
serverRecoveryTimerRef.current = setTimeout(() => {
|
|
270
|
+
serverRecoveryTimerRef.current = null
|
|
271
|
+
hardReconnect(0)
|
|
272
|
+
}, SERVER_RETRY_GRACE_MS)
|
|
273
|
+
}
|
|
274
|
+
} else if (status === 'reconnect_failed') {
|
|
275
|
+
setConnected(false)
|
|
276
|
+
clearTimer(serverRecoveryTimerRef)
|
|
277
|
+
hardReconnect(FAILED_RECONNECT_DELAY_MS)
|
|
278
|
+
}
|
|
279
|
+
return
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (eventName === 'ticket-resync') {
|
|
283
|
+
refetchSummary()
|
|
284
|
+
scheduleInvalidation()
|
|
285
|
+
return
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (eventName === 'ticket-message') {
|
|
289
|
+
const frame = data as TicketStreamEvent | null
|
|
290
|
+
const payload = frame?.data
|
|
291
|
+
if (!isTicketMessageData(payload)) {
|
|
292
|
+
// Malformed/degraded — treat like a resync hint.
|
|
293
|
+
refetchSummary()
|
|
294
|
+
scheduleInvalidation()
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
// Always invalidate (own replies from another tab render live);
|
|
298
|
+
// bump unread ONLY on the server-stamped predicate, and never for
|
|
299
|
+
// the ticket whose drawer is open.
|
|
300
|
+
scheduleInvalidation(payload.ticket_external_id)
|
|
301
|
+
if (payload.countsAsUnread && payload.ticket_external_id !== openTicketIdRef.current) {
|
|
302
|
+
bumpUnread(payload.ticket_external_id)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
handlersRef.current.onStatus = (status) => {
|
|
308
|
+
if (status === 'open') {
|
|
309
|
+
// Transport open ≠ connected. Require `status: subscribed` within
|
|
310
|
+
// the confirm window or force a reconnect.
|
|
311
|
+
noStreamRef.current = false
|
|
312
|
+
clearTimer(subscribeConfirmTimerRef)
|
|
313
|
+
subscribeConfirmTimerRef.current = setTimeout(() => {
|
|
314
|
+
subscribeConfirmTimerRef.current = null
|
|
315
|
+
setConnected(false)
|
|
316
|
+
hardReconnect(0)
|
|
317
|
+
}, SUBSCRIBE_CONFIRM_TIMEOUT_MS)
|
|
318
|
+
return
|
|
319
|
+
}
|
|
320
|
+
if (status === 'no-stream') {
|
|
321
|
+
// Zero owned tickets (204). Retried on create_ticket / visibility.
|
|
322
|
+
noStreamRef.current = true
|
|
323
|
+
}
|
|
324
|
+
// Every non-'open' transport status means we are not subscribed.
|
|
325
|
+
clearTimer(subscribeConfirmTimerRef)
|
|
326
|
+
setConnected(false)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Mount/unmount of the subscription — gated on auth + a usable URL.
|
|
330
|
+
const streamEnabled = authed && !!streamUrl
|
|
331
|
+
React.useEffect(() => {
|
|
332
|
+
if (!streamEnabled) return
|
|
333
|
+
if (typeof window === 'undefined') return
|
|
334
|
+
|
|
335
|
+
const subscription = createSseSubscription({
|
|
336
|
+
url: streamUrl,
|
|
337
|
+
onEvent: (name, data) => handlersRef.current.onEvent(name, data),
|
|
338
|
+
onStatusChange: (status) => handlersRef.current.onStatus(status),
|
|
339
|
+
})
|
|
340
|
+
subscriptionRef.current = subscription
|
|
341
|
+
|
|
342
|
+
const retryIfIdle = () => {
|
|
343
|
+
// After a 204 (no-stream) or while disconnected-hidden, a
|
|
344
|
+
// visibility/focus/online signal is the retry trigger.
|
|
345
|
+
if (noStreamRef.current) {
|
|
346
|
+
noStreamRef.current = false
|
|
347
|
+
subscription.reconnectNow()
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const onVisibilityChange = () => {
|
|
352
|
+
if (document.visibilityState === 'hidden') {
|
|
353
|
+
if (!hiddenGraceTimerRef.current) {
|
|
354
|
+
hiddenGraceTimerRef.current = setTimeout(() => {
|
|
355
|
+
hiddenGraceTimerRef.current = null
|
|
356
|
+
// Scale relief: a hidden tab holds no server invocation.
|
|
357
|
+
setConnected(false)
|
|
358
|
+
subscription.close()
|
|
359
|
+
subscriptionRef.current = null
|
|
360
|
+
}, HIDDEN_DISCONNECT_GRACE_MS)
|
|
361
|
+
}
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
// Visible again — cancel the grace, or recreate if already torn down.
|
|
365
|
+
clearTimer(hiddenGraceTimerRef)
|
|
366
|
+
if (!subscriptionRef.current) {
|
|
367
|
+
subscriptionRef.current = createSseSubscription({
|
|
368
|
+
url: streamUrl,
|
|
369
|
+
onEvent: (name, data) => handlersRef.current.onEvent(name, data),
|
|
370
|
+
onStatusChange: (status) => handlersRef.current.onStatus(status),
|
|
371
|
+
})
|
|
372
|
+
} else {
|
|
373
|
+
retryIfIdle()
|
|
374
|
+
}
|
|
375
|
+
// Resync regardless — the tab may have slept through events.
|
|
376
|
+
refetchSummary()
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const onOnline = () => {
|
|
380
|
+
subscriptionRef.current?.reconnectNow()
|
|
381
|
+
refetchSummary()
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
document.addEventListener('visibilitychange', onVisibilityChange)
|
|
385
|
+
window.addEventListener('online', onOnline)
|
|
386
|
+
window.addEventListener('focus', retryIfIdle)
|
|
387
|
+
|
|
388
|
+
return () => {
|
|
389
|
+
document.removeEventListener('visibilitychange', onVisibilityChange)
|
|
390
|
+
window.removeEventListener('online', onOnline)
|
|
391
|
+
window.removeEventListener('focus', retryIfIdle)
|
|
392
|
+
clearTimer(hiddenGraceTimerRef)
|
|
393
|
+
clearTimer(subscribeConfirmTimerRef)
|
|
394
|
+
clearTimer(serverRecoveryTimerRef)
|
|
395
|
+
clearTimer(failedReconnectTimerRef)
|
|
396
|
+
if (invalidateTimerRef.current) {
|
|
397
|
+
clearTimeout(invalidateTimerRef.current)
|
|
398
|
+
invalidateTimerRef.current = null
|
|
399
|
+
}
|
|
400
|
+
subscriptionRef.current?.close()
|
|
401
|
+
subscriptionRef.current = null
|
|
402
|
+
setConnected(false)
|
|
403
|
+
}
|
|
404
|
+
// handlersRef is stable; streamUrl/auth changes remount the stream.
|
|
405
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
406
|
+
}, [streamEnabled, streamUrl, refetchSummary])
|
|
407
|
+
|
|
408
|
+
// ---- Public API ----
|
|
409
|
+
const markRead = React.useCallback(
|
|
410
|
+
async (ticketExternalId: string) => {
|
|
411
|
+
try {
|
|
412
|
+
const response = await embedAuthedFetch(readUrl, {
|
|
413
|
+
method: 'POST',
|
|
414
|
+
body: JSON.stringify({ ticket_id: ticketExternalId }),
|
|
415
|
+
})
|
|
416
|
+
if (response.ok) {
|
|
417
|
+
// Write-through zero — without polling, a stale post-close map
|
|
418
|
+
// would re-light the dot until the next focus/event/reconnect.
|
|
419
|
+
zeroUnread(ticketExternalId)
|
|
420
|
+
}
|
|
421
|
+
} catch {
|
|
422
|
+
// Non-fatal — the receipt lands on the next markRead; unread
|
|
423
|
+
// state reconciles via the summary.
|
|
424
|
+
}
|
|
425
|
+
},
|
|
426
|
+
[readUrl, zeroUnread],
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
const setOpenTicketId = React.useCallback((ticketExternalId: string | null) => {
|
|
430
|
+
openTicketIdRef.current = ticketExternalId
|
|
431
|
+
setOpenTicketIdState(ticketExternalId)
|
|
432
|
+
}, [])
|
|
433
|
+
|
|
434
|
+
const notifyTicketCreated = React.useCallback(() => {
|
|
435
|
+
refetchSummary()
|
|
436
|
+
if (noStreamRef.current) {
|
|
437
|
+
noStreamRef.current = false
|
|
438
|
+
subscriptionRef.current?.reconnectNow()
|
|
439
|
+
}
|
|
440
|
+
}, [refetchSummary])
|
|
441
|
+
|
|
442
|
+
// Derived unread map — open ticket masked to 0 so a summary refetch
|
|
443
|
+
// inside the markRead debounce window can't transiently re-badge it.
|
|
444
|
+
const value = React.useMemo<TicketLiveContextValue>(() => {
|
|
445
|
+
const summary = summaryQuery.data ?? { totalUnread: 0, tickets: {} }
|
|
446
|
+
let unreadByTicket = summary.tickets
|
|
447
|
+
let unreadTotal = summary.totalUnread
|
|
448
|
+
if (openTicketIdState && unreadByTicket[openTicketIdState]) {
|
|
449
|
+
const masked = unreadByTicket[openTicketIdState]
|
|
450
|
+
unreadByTicket = { ...unreadByTicket }
|
|
451
|
+
delete unreadByTicket[openTicketIdState]
|
|
452
|
+
unreadTotal = Math.max(0, unreadTotal - masked)
|
|
453
|
+
}
|
|
454
|
+
return {
|
|
455
|
+
authed,
|
|
456
|
+
connected,
|
|
457
|
+
unreadTotal,
|
|
458
|
+
unreadByTicket,
|
|
459
|
+
markRead,
|
|
460
|
+
setOpenTicketId,
|
|
461
|
+
notifyTicketCreated,
|
|
462
|
+
}
|
|
463
|
+
}, [
|
|
464
|
+
summaryQuery.data,
|
|
465
|
+
authed,
|
|
466
|
+
connected,
|
|
467
|
+
openTicketIdState,
|
|
468
|
+
markRead,
|
|
469
|
+
setOpenTicketId,
|
|
470
|
+
notifyTicketCreated,
|
|
471
|
+
])
|
|
472
|
+
|
|
473
|
+
return <TicketLiveContext.Provider value={value}>{children}</TicketLiveContext.Provider>
|
|
474
|
+
}
|
|
@@ -212,18 +212,69 @@ export interface MappedTicketActionError {
|
|
|
212
212
|
export const TICKET_TEXT_MAX_CHARS = 5000
|
|
213
213
|
|
|
214
214
|
/**
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
215
|
+
* Wire contract of the ticket live stream (`GET /api/chat/agent/
|
|
216
|
+
* ticket-stream`, SSE). Producer (the hub route's `mapEvent`) imports
|
|
217
|
+
* THIS type — same producer/consumer SSOT pattern as
|
|
218
|
+
* `ConversationEngagementWire = TicketEngagement`. A schema drift here
|
|
219
|
+
* would be a silent serialization bug; keep one named contract.
|
|
220
|
+
*
|
|
221
|
+
* Frames carry METADATA ONLY — never message bodies. Content is always
|
|
222
|
+
* refetched through the ACL'd `list-engagements` path (one read path).
|
|
223
|
+
*
|
|
224
|
+
* Event names (`eventType`):
|
|
225
|
+
* - `ticket-message` — a message row landed in the mirror for one of
|
|
226
|
+
* the viewer's tickets. `data` carries the metadata.
|
|
227
|
+
* - `ticket-resync` — degraded frame: something happened that the
|
|
228
|
+
* server couldn't fully describe (truncated Realtime payload, or
|
|
229
|
+
* the connection's ownership set just gained tickets). Clients
|
|
230
|
+
* respond by refetching the unread summary + invalidating queries.
|
|
231
|
+
*
|
|
232
|
+
* The server also emits `status` frames (`subscribed` / `retrying` /
|
|
233
|
+
* `reconnect_failed`) from the shared SSE utility — clients derive
|
|
234
|
+
* `connected` from those, NOT from the HTTP stream being open.
|
|
235
|
+
*/
|
|
236
|
+
export type TicketStreamEventType = 'ticket-message' | 'ticket-resync'
|
|
237
|
+
|
|
238
|
+
/** Metadata payload of a `ticket-message` frame. */
|
|
239
|
+
export type TicketMessageStreamData = {
|
|
240
|
+
ticket_external_id: string
|
|
241
|
+
/** Mirror row's HubSpot message id. */
|
|
242
|
+
message_id: string
|
|
243
|
+
/** Server-stamped via the same direction predicate the wire mapper
|
|
244
|
+
* uses. Clients never re-derive author classification. */
|
|
245
|
+
authorRole: 'customer' | 'support'
|
|
246
|
+
/** Server-stamped via the visibility+direction unread predicate
|
|
247
|
+
* (`countsAsUnread` in the hub's conversations DAL). ONLY frames
|
|
248
|
+
* with `true` may bump client-side unread state. */
|
|
249
|
+
countsAsUnread: boolean
|
|
250
|
+
hubspot_created_at: string | null
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export interface TicketStreamEvent {
|
|
254
|
+
eventType: TicketStreamEventType
|
|
255
|
+
/** Human-readable label (SSE envelope compat) — clients ignore it. */
|
|
256
|
+
message: string
|
|
257
|
+
/** Present on `ticket-message`; absent on `ticket-resync`. */
|
|
258
|
+
data?: TicketMessageStreamData
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Wire shape of `POST /api/chat/agent/ticket-unread-summary` — the
|
|
263
|
+
* SINGLE unread source. Header badge total AND per-row dots both read
|
|
264
|
+
* this via the `TicketLiveProvider` map; missing keys mean 0.
|
|
265
|
+
*/
|
|
266
|
+
export interface TicketUnreadSummary {
|
|
267
|
+
totalUnread: number
|
|
268
|
+
tickets: Record<string, number>
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Trailing debounce for `markRead` while the drawer is open and new
|
|
273
|
+
* messages stream in — flushed on drawer close so a fast open/close
|
|
274
|
+
* still persists the receipt. Named beside its sibling cadences so
|
|
275
|
+
* every timing knob lives in one file.
|
|
225
276
|
*/
|
|
226
|
-
export const
|
|
277
|
+
export const TICKET_MARK_READ_DEBOUNCE_MS = 2000
|
|
227
278
|
|
|
228
279
|
/**
|
|
229
280
|
* Centralized toast copy. Keep all wording here so QA / localization
|