@actuate-media/cms-admin 0.12.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/__tests__/components/comment-side-panel.test.js +93 -0
  2. package/dist/__tests__/components/comment-side-panel.test.js.map +1 -1
  3. package/dist/__tests__/components/mentionable-textarea.test.d.ts +2 -0
  4. package/dist/__tests__/components/mentionable-textarea.test.d.ts.map +1 -0
  5. package/dist/__tests__/components/mentionable-textarea.test.js +190 -0
  6. package/dist/__tests__/components/mentionable-textarea.test.js.map +1 -0
  7. package/dist/__tests__/components/notification-bell.test.js +146 -0
  8. package/dist/__tests__/components/notification-bell.test.js.map +1 -1
  9. package/dist/__tests__/lib/mention-picker.test.d.ts +2 -0
  10. package/dist/__tests__/lib/mention-picker.test.d.ts.map +1 -0
  11. package/dist/__tests__/lib/mention-picker.test.js +122 -0
  12. package/dist/__tests__/lib/mention-picker.test.js.map +1 -0
  13. package/dist/__tests__/lib/notifications-client.test.js +129 -1
  14. package/dist/__tests__/lib/notifications-client.test.js.map +1 -1
  15. package/dist/actuate-admin.css +1 -1
  16. package/dist/components/CommentSidePanel.d.ts +9 -1
  17. package/dist/components/CommentSidePanel.d.ts.map +1 -1
  18. package/dist/components/CommentSidePanel.js +8 -7
  19. package/dist/components/CommentSidePanel.js.map +1 -1
  20. package/dist/components/MentionableTextarea.d.ts +68 -0
  21. package/dist/components/MentionableTextarea.d.ts.map +1 -0
  22. package/dist/components/MentionableTextarea.js +181 -0
  23. package/dist/components/MentionableTextarea.js.map +1 -0
  24. package/dist/components/NotificationBell.d.ts +30 -9
  25. package/dist/components/NotificationBell.d.ts.map +1 -1
  26. package/dist/components/NotificationBell.js +57 -5
  27. package/dist/components/NotificationBell.js.map +1 -1
  28. package/dist/lib/api.d.ts +8 -0
  29. package/dist/lib/api.d.ts.map +1 -1
  30. package/dist/lib/api.js +10 -0
  31. package/dist/lib/api.js.map +1 -1
  32. package/dist/lib/mention-picker.d.ts +69 -0
  33. package/dist/lib/mention-picker.d.ts.map +1 -0
  34. package/dist/lib/mention-picker.js +138 -0
  35. package/dist/lib/mention-picker.js.map +1 -0
  36. package/dist/lib/notifications-client.d.ts +53 -0
  37. package/dist/lib/notifications-client.d.ts.map +1 -1
  38. package/dist/lib/notifications-client.js +89 -1
  39. package/dist/lib/notifications-client.js.map +1 -1
  40. package/package.json +2 -2
  41. package/src/__tests__/components/comment-side-panel.test.tsx +134 -0
  42. package/src/__tests__/components/mentionable-textarea.test.tsx +226 -0
  43. package/src/__tests__/components/notification-bell.test.tsx +167 -1
  44. package/src/__tests__/lib/mention-picker.test.ts +145 -0
  45. package/src/__tests__/lib/notifications-client.test.ts +145 -1
  46. package/src/components/CommentSidePanel.tsx +37 -14
  47. package/src/components/MentionableTextarea.tsx +328 -0
  48. package/src/components/NotificationBell.tsx +84 -12
  49. package/src/lib/api.ts +11 -0
  50. package/src/lib/mention-picker.ts +180 -0
  51. package/src/lib/notifications-client.ts +147 -1
@@ -5,10 +5,13 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'
5
5
  import {
6
6
  type Notification,
7
7
  type NotificationOutcome,
8
+ type NotificationStreamHandle,
9
+ type NotificationStreamOptions,
8
10
  listNotifications as defaultListNotifications,
9
11
  markAllNotificationsRead as defaultMarkAllRead,
10
12
  markNotificationRead as defaultMarkRead,
11
13
  notificationSummary,
14
+ subscribeNotificationStream as defaultSubscribeStream,
12
15
  unreadCount as defaultUnreadCount,
13
16
  } from '../lib/notifications-client.js'
14
17
 
@@ -20,25 +23,41 @@ import {
20
23
  *
21
24
  * Design choices:
22
25
  *
23
- * - **Polling, not SSE.** Phase 3 already ships an SSE stream for live
24
- * previews (Phase 2 / `/preview/stream`), but extending it to
25
- * per-user notifications adds a non-trivial fan-out path and a fresh
26
- * security surface (privacy-leak risk on misconfigured streams). The
27
- * bell polls every `pollIntervalMs` (default 30s) and on dropdown
28
- * open. A push-based extension is queued as a Phase 4 follow-up.
26
+ * - **SSE push, with poll fallback (Phase 7).** A persistent
27
+ * `EventSource` subscribes to `/api/cms/notifications/stream` for
28
+ * instant delivery; new notifications bump the badge immediately
29
+ * instead of after the next 30 s poll tick. The poll loop is kept
30
+ * alive at a back-pressure-friendly cadence (default 60 s twice
31
+ * the legacy 30 s value because SSE handles the common case) and
32
+ * acts as a backstop for:
33
+ * 1. multi-node deployments where the in-process bus only
34
+ * publishes to subscribers on the same node, and
35
+ * 2. proxies / browsers that silently drop the EventSource
36
+ * without firing `onerror`.
29
37
  * - **Optimistic read state.** When the user clicks a notification we
30
38
  * mark it read locally before the server confirms — so the bell
31
39
  * counter feels instant. Server failures roll the state back via
32
40
  * the `onError` toast.
33
41
  * - **API injection.** The dependency-injection seam matches the rest
34
42
  * of the admin (`CommentSidePanel.api`) so unit tests can substitute
35
- * in-memory fakes.
43
+ * in-memory fakes — including the EventSource subscriber, which
44
+ * isn't available in JSDOM by default.
36
45
  */
37
46
  export interface NotificationsApi {
38
47
  list: (opts: { includeRead?: boolean }) => Promise<NotificationOutcome<Notification[]>>
39
48
  unread: () => Promise<NotificationOutcome<number>>
40
49
  markRead: (id: string) => Promise<NotificationOutcome<Notification>>
41
50
  markAllRead: () => Promise<NotificationOutcome<number>>
51
+ /**
52
+ * Open a per-user SSE subscription. Implementations MUST always
53
+ * return a handle — even in environments without `EventSource`
54
+ * (SSR, JSDOM without a polyfill), where a no-op `{ close() {} }`
55
+ * is the right answer. The bell's effect calls `handle.close()`
56
+ * unconditionally on unmount; a `null` return would crash.
57
+ * Omitting the member entirely is the supported way to opt out of
58
+ * SSE (the bell then runs poll-only).
59
+ */
60
+ subscribe?: (opts: NotificationStreamOptions) => NotificationStreamHandle
42
61
  }
43
62
 
44
63
  const defaultApi: NotificationsApi = {
@@ -46,10 +65,16 @@ const defaultApi: NotificationsApi = {
46
65
  unread: () => defaultUnreadCount(),
47
66
  markRead: (id) => defaultMarkRead(id),
48
67
  markAllRead: () => defaultMarkAllRead(),
68
+ subscribe: (opts) => defaultSubscribeStream(opts),
49
69
  }
50
70
 
51
71
  export interface NotificationBellProps {
52
- /** Polling cadence in milliseconds. Default 30,000. Pass 0 to disable. */
72
+ /**
73
+ * Polling cadence in milliseconds for the fallback loop. Default
74
+ * 60,000 (twice the pre-SSE value — push handles the common case).
75
+ * Pass 0 to disable polling entirely (relies on SSE only — only
76
+ * safe on single-node deployments).
77
+ */
53
78
  pollIntervalMs?: number
54
79
  /** Optional click hook — called with the notification when a row is clicked. */
55
80
  onSelect?: (notification: Notification) => void
@@ -61,7 +86,7 @@ export interface NotificationBellProps {
61
86
  }
62
87
 
63
88
  export function NotificationBell({
64
- pollIntervalMs = 30_000,
89
+ pollIntervalMs = 60_000,
65
90
  onSelect,
66
91
  onError,
67
92
  api = defaultApi,
@@ -72,6 +97,15 @@ export function NotificationBell({
72
97
  const [items, setItems] = useState<Notification[]>([])
73
98
  const [loading, setLoading] = useState(false)
74
99
  const mountedRef = useRef(true)
100
+ // Set of notification IDs we've already rendered. The SSE handler
101
+ // consults this synchronously so duplicate deliveries (REST refresh
102
+ // + SSE push, or two SSE retries) don't double-bump the badge.
103
+ // Kept in sync with `items` via an effect — the items state is the
104
+ // source of truth; this ref is a derived O(1) lookup index.
105
+ const seenIdsRef = useRef<Set<string>>(new Set())
106
+ useEffect(() => {
107
+ seenIdsRef.current = new Set(items.map((n) => n.id))
108
+ }, [items])
75
109
  useEffect(() => {
76
110
  return () => {
77
111
  mountedRef.current = false
@@ -111,6 +145,34 @@ export function NotificationBell({
111
145
  return () => clearInterval(id)
112
146
  }, [refreshUnread, pollIntervalMs])
113
147
 
148
+ // SSE push subscription. Bumps the badge + prepends the new row to
149
+ // the open dropdown the instant the server publishes. Falls back
150
+ // silently when the api/runtime doesn't support EventSource — the
151
+ // poll loop above keeps the bell correct in that case.
152
+ useEffect(() => {
153
+ if (!api.subscribe) return
154
+ const handle = api.subscribe({
155
+ onNotification: (n) => {
156
+ if (!mountedRef.current) return
157
+ // Defensive: SSE events can race with REST refreshes (or with
158
+ // their own retries). The seen-id ref is updated
159
+ // synchronously so a second delivery of the same row is a
160
+ // no-op for BOTH `items` and the unread badge — guarding only
161
+ // setItems would still let the counter drift.
162
+ if (seenIdsRef.current.has(n.id)) return
163
+ seenIdsRef.current.add(n.id)
164
+ setItems((arr) => [n, ...arr])
165
+ if (!n.readAt) setUnread((v) => v + 1)
166
+ },
167
+ onError: () => {
168
+ // Don't surface every transient SSE disconnect as a UI error —
169
+ // the browser auto-reconnects, and the poll loop keeps state
170
+ // converging. We deliberately stay quiet here.
171
+ },
172
+ })
173
+ return () => handle.close()
174
+ }, [api])
175
+
114
176
  useEffect(() => {
115
177
  if (!open) return
116
178
  void refreshList(false)
@@ -138,14 +200,24 @@ export function NotificationBell({
138
200
  )
139
201
 
140
202
  const handleMarkAll = useCallback(async () => {
141
- const previous = items.slice()
203
+ // Snapshot ONLY the previously-unread IDs (and their original
204
+ // readAt values, which are null by definition). On rollback we
205
+ // restore those specific rows and leave anything that arrived
206
+ // via SSE while the request was in flight alone — otherwise a
207
+ // failed mark-all-read would drop fresh notifications.
208
+ const previouslyUnreadIds = items.filter((n) => n.readAt === null).map((n) => n.id)
209
+ if (previouslyUnreadIds.length === 0) return
210
+ const ids = new Set(previouslyUnreadIds)
142
211
  const now = new Date().toISOString()
143
- setItems(items.map((n) => ({ ...n, readAt: n.readAt ?? now })))
212
+ setItems((arr) => arr.map((n) => (ids.has(n.id) ? { ...n, readAt: n.readAt ?? now } : n)))
144
213
  setUnread(0)
145
214
  const out = await api.markAllRead()
146
215
  if (!out.ok) {
147
216
  onError?.(out.error)
148
- setItems(previous)
217
+ // Functional rollback: only revert the rows we optimistically
218
+ // marked. Anything mid-flight (SSE arrivals, locally-added) is
219
+ // untouched.
220
+ setItems((arr) => arr.map((n) => (ids.has(n.id) ? { ...n, readAt: null } : n)))
149
221
  await refreshUnread()
150
222
  }
151
223
  }, [api, items, onError, refreshUnread])
package/src/lib/api.ts CHANGED
@@ -6,6 +6,17 @@ export function setApiBase(path: string) {
6
6
  basePath = path
7
7
  }
8
8
 
9
+ /**
10
+ * Current API base path. Exposed so non-`fetch` consumers (in
11
+ * particular `EventSource` for SSE channels) can resolve URLs against
12
+ * the same prefix configured via `setApiBase()`. Keep this read-only
13
+ * — mutation must always flow through `setApiBase()` so we never have
14
+ * two callers with diverging views of the base.
15
+ */
16
+ export function getApiBase(): string {
17
+ return basePath
18
+ }
19
+
9
20
  function getCsrfToken(): string {
10
21
  if (typeof document === 'undefined') return ''
11
22
  const match = document.cookie.match(/(?:^|;\s*)actuate_csrf=([^;]*)/)
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Mention picker client logic + REST helper.
3
+ *
4
+ * Two responsibilities, deliberately separated so each can be unit
5
+ * tested without rendering React or hitting fetch:
6
+ *
7
+ * 1. `searchMentionUsers(query)` — typed wrapper over the new
8
+ * `GET /api/cms/users/search` endpoint introduced in Phase 7.
9
+ * Returns up to 10 `{ id, name, email }` rows matching the query.
10
+ * 2. `detectMentionContext(value, caret)` — pure parser that scans a
11
+ * text input value backwards from the caret to decide whether the
12
+ * user is currently typing a `@`-mention, and if so what the
13
+ * query fragment is. Returns `null` when no active mention.
14
+ *
15
+ * Wire format note: the picker INSERTS `@<userId>` tokens at the
16
+ * cursor (matching the server-side `extractMentionedUserIds` regex
17
+ * in `cms-core/realtime/notifications.ts`). The user sees the chosen
18
+ * user's *name* in the picker but the textarea always holds the id.
19
+ * Rendering names back in the comment view is a separate slice that
20
+ * can ship later without breaking the wire format.
21
+ */
22
+ import { cmsApi } from './api.js'
23
+
24
+ export interface MentionUser {
25
+ id: string
26
+ name: string
27
+ email: string
28
+ }
29
+
30
+ export type MentionSearchOutcome =
31
+ | { ok: true; result: MentionUser[] }
32
+ | { ok: false; error: string; status: number }
33
+
34
+ export async function searchMentionUsers(query: string): Promise<MentionSearchOutcome> {
35
+ const trimmed = query.trim()
36
+ if (trimmed.length === 0) {
37
+ // Mirror the server's contract: an empty query returns an empty
38
+ // array. We short-circuit here so the picker doesn't spend a
39
+ // round-trip on a guaranteed-empty response.
40
+ return { ok: true, result: [] }
41
+ }
42
+ const res = await cmsApi<MentionUser[]>(`/users/search?q=${encodeURIComponent(trimmed)}`, {
43
+ method: 'GET',
44
+ })
45
+ if (res.error || !res.data) {
46
+ return { ok: false, error: res.error ?? `Request failed (${res.status})`, status: res.status }
47
+ }
48
+ return { ok: true, result: res.data }
49
+ }
50
+
51
+ /**
52
+ * Description of an active `@`-mention being typed in a textarea / input.
53
+ *
54
+ * - `start` and `end` are character offsets into the field value: the
55
+ * `@` itself sits at `start`, and `end` is the caret position
56
+ * (exclusive end of the query slice).
57
+ * - `query` is the substring between the `@` and the caret — exactly
58
+ * what we should pass to `searchMentionUsers()`.
59
+ *
60
+ * Callers replace `value.slice(start, end)` with the chosen token
61
+ * (`@<userId> `, including the trailing space) to commit a selection.
62
+ */
63
+ export interface MentionContext {
64
+ start: number
65
+ end: number
66
+ query: string
67
+ }
68
+
69
+ /**
70
+ * Find an active mention at the caret. Mirrors the server-side
71
+ * `extractMentionedUserIds` regex
72
+ * (`/(^|\s)@[a-zA-Z0-9_-]{3,64}\b/g`) so the picker's intuition
73
+ * about what counts as a mention can never disagree with the
74
+ * server's notification routing.
75
+ *
76
+ * Returns `null` whenever:
77
+ * - the caret is at offset 0,
78
+ * - there is no `@` between the caret and the previous whitespace
79
+ * boundary,
80
+ * - the `@` is preceded by a non-whitespace, non-newline character
81
+ * (so an email address mid-word like `foo@bar` does NOT open
82
+ * the picker), or
83
+ * - the candidate query contains a character outside the mention
84
+ * charset (`[a-zA-Z0-9_-]`).
85
+ *
86
+ * The picker DOES open with a 0–2 character query — that's the
87
+ * "you've started typing a mention" UX. But it can NEVER commit
88
+ * such a token: `insertMentionToken` only fires for a `MentionUser`
89
+ * which always has an id of at least 3 chars, matching the server
90
+ * regex's min-length requirement.
91
+ */
92
+ export function detectMentionContext(value: string, caret: number): MentionContext | null {
93
+ if (typeof value !== 'string') return null
94
+ if (caret <= 0 || caret > value.length) return null
95
+
96
+ // Walk back from the caret looking for a `@`. Stop early on any
97
+ // character that is neither a valid mention char nor `@`. This
98
+ // means a typed space, newline, punctuation, etc. terminates the
99
+ // mention candidate the user was building.
100
+ let i = caret - 1
101
+ while (i >= 0) {
102
+ const ch = value.charCodeAt(i)
103
+ if (ch === 64 /* @ */) break
104
+ if (!isMentionChar(ch)) return null
105
+ i -= 1
106
+ }
107
+ if (i < 0) return null
108
+
109
+ // The `@` must sit at the start of the field or be preceded by
110
+ // whitespace / newline. Otherwise we're mid-word (think `foo@bar`),
111
+ // which is not a mention.
112
+ if (i > 0) {
113
+ const prev = value.charCodeAt(i - 1)
114
+ if (!isBoundary(prev)) return null
115
+ }
116
+
117
+ const query = value.slice(i + 1, caret)
118
+ // Server caps queries at 64 chars; the picker simply stops
119
+ // suggesting once the typed query exceeds that — extra typing
120
+ // closes the picker so the user can complete an email or other
121
+ // long token without staring at a dead dropdown.
122
+ if (query.length > 64) return null
123
+
124
+ return { start: i, end: caret, query }
125
+ }
126
+
127
+ function isMentionChar(code: number): boolean {
128
+ // [a-zA-Z0-9_-] — keep this in lock-step with the regex in
129
+ // `cms-core/realtime/notifications.ts:extractMentionedUserIds`.
130
+ if (code === 95 /* _ */ || code === 45 /* - */) return true
131
+ if (code >= 48 && code <= 57) return true // 0-9
132
+ if (code >= 65 && code <= 90) return true // A-Z
133
+ if (code >= 97 && code <= 122) return true // a-z
134
+ return false
135
+ }
136
+
137
+ function isBoundary(code: number): boolean {
138
+ // ASCII space, tab, line-feed, or carriage-return. We deliberately
139
+ // do NOT widen this to the full Unicode whitespace class — the
140
+ // server-side `extractMentionedUserIds` regex in
141
+ // `cms-core/realtime/notifications.ts` uses `\s` which DOES match
142
+ // Unicode whitespace, but its `^` anchor means the only realistic
143
+ // mid-string boundaries in practice are these four ASCII chars
144
+ // (a CMS comment body containing U+00A0 NBSP before an `@` would
145
+ // be both unusual and not worth opening the picker for).
146
+ return code === 32 || code === 9 || code === 10 || code === 13
147
+ }
148
+
149
+ /**
150
+ * Apply a mention selection to a text value. Returns the new value
151
+ * plus the caret position where the next keystroke should land.
152
+ *
153
+ * Inserts `@<userId> ` (trailing space) so the user can keep typing
154
+ * without manually moving the cursor past the token. If the caret
155
+ * already sits before a space, we don't insert a duplicate space.
156
+ */
157
+ export interface MentionInsertion {
158
+ value: string
159
+ caret: number
160
+ }
161
+
162
+ export function insertMentionToken(
163
+ value: string,
164
+ context: MentionContext,
165
+ userId: string,
166
+ ): MentionInsertion {
167
+ const hasTrailingSpace = value.charAt(context.end) === ' '
168
+ // If the user already has a space immediately after the mention
169
+ // region, reuse it (don't double-space) but still advance the
170
+ // caret past that space so the next keystroke continues naturally
171
+ // rather than landing back in front of the space.
172
+ const trailingSpace = hasTrailingSpace ? '' : ' '
173
+ const token = `@${userId}${trailingSpace}`
174
+ const before = value.slice(0, context.start)
175
+ const after = value.slice(context.end)
176
+ return {
177
+ value: `${before}${token}${after}`,
178
+ caret: context.start + token.length + (hasTrailingSpace ? 1 : 0),
179
+ }
180
+ }
@@ -11,7 +11,7 @@
11
11
  * (which carries Prisma types that don't belong in the admin bundle).
12
12
  */
13
13
 
14
- import { cmsApi } from './api.js'
14
+ import { cmsApi, getApiBase } from './api.js'
15
15
 
16
16
  export type NotificationKind = 'comment_reply' | 'comment_resolved' | 'comment_mention'
17
17
 
@@ -125,6 +125,152 @@ export async function markAllNotificationsRead(): Promise<NotificationOutcome<nu
125
125
  return { ok: true, result: res.data.count }
126
126
  }
127
127
 
128
+ /**
129
+ * Subscribe to the per-user SSE push channel that ships notifications
130
+ * the instant they're persisted server-side. Replaces the 30-second
131
+ * poll loop in the notification bell.
132
+ *
133
+ * The browser's native `EventSource` handles reconnection for us — on
134
+ * a server-initiated close (the stream caps at ~4 minutes by design),
135
+ * the connection re-opens automatically. We DO NOT abort the poll
136
+ * fallback in callers: SSE is a latency-win, the poll is what keeps
137
+ * multi-node deployments correct (each node only pushes to its own
138
+ * subscribers).
139
+ *
140
+ * Returns a `close()` function that callers MUST invoke on unmount —
141
+ * otherwise the browser keeps the connection open and we leak event
142
+ * listeners.
143
+ *
144
+ * `EventSource` itself isn't available in the JSDOM test environment;
145
+ * callers can inject a `factory` to substitute a test double. In
146
+ * production we fall back to the global `EventSource` when no factory
147
+ * is supplied.
148
+ */
149
+ export interface NotificationStreamOptions {
150
+ /** Called for every `event: notification` frame. */
151
+ onNotification: (n: Notification) => void
152
+ /** Called once on the `event: ready` frame. */
153
+ onReady?: (payload: { userId: string }) => void
154
+ /** Called on connection errors. Useful for falling back to the poll loop. */
155
+ onError?: (event: Event) => void
156
+ /**
157
+ * Optional factory so unit tests can inject a fake `EventSource`,
158
+ * or a null sentinel when the environment lacks one (SSR, JSDOM
159
+ * without a polyfill). Production code can rely on the default
160
+ * (global `EventSource`).
161
+ */
162
+ factory?: (url: string) => EventSourceLike | null
163
+ }
164
+
165
+ /**
166
+ * Minimal subset of the `EventSource` interface our subscriber relies
167
+ * on. Browsers ship the full DOM type; the admin only needs add/remove
168
+ * event listeners plus `close()`.
169
+ */
170
+ export interface EventSourceLike {
171
+ addEventListener: (type: string, listener: (event: MessageEvent | Event) => void) => void
172
+ removeEventListener?: (type: string, listener: (event: MessageEvent | Event) => void) => void
173
+ close: () => void
174
+ }
175
+
176
+ export interface NotificationStreamHandle {
177
+ /** Tear down the EventSource and stop receiving events. Idempotent. */
178
+ close: () => void
179
+ }
180
+
181
+ export function subscribeNotificationStream(
182
+ opts: NotificationStreamOptions,
183
+ ): NotificationStreamHandle {
184
+ // The `cmsApi()` helper handles base-path / origin resolution for
185
+ // fetch calls, but EventSource takes a raw URL. We mirror its
186
+ // logic locally — `/api/cms/notifications/stream` is correct for
187
+ // both the local dev server and any production deploy that mounts
188
+ // the CMS API at the default prefix. Custom prefixes are handled by
189
+ // the consumer override (factory).
190
+ // Resolve against `setApiBase()` so a consumer mounting the CMS at
191
+ // a non-default prefix (e.g. `/cms-api`) still hits the right SSE
192
+ // endpoint — `EventSource` doesn't go through `cmsApi()`, so we
193
+ // can't rely on its url-construction logic.
194
+ const url = `${getApiBase()}/notifications/stream`
195
+ const make = opts.factory ?? defaultEventSourceFactory
196
+ const source = make(url)
197
+ if (!source) {
198
+ // No EventSource available (SSR? JSDOM without polyfill? Or the
199
+ // injected factory deliberately returns null in tests). Return a
200
+ // no-op handle so callers can compose without conditional logic.
201
+ return { close: () => {} }
202
+ }
203
+ let closed = false
204
+
205
+ const onReady = (event: MessageEvent | Event) => {
206
+ if (!opts.onReady) return
207
+ const data = parseEventData(event)
208
+ if (data && typeof (data as { userId?: unknown }).userId === 'string') {
209
+ opts.onReady({ userId: (data as { userId: string }).userId })
210
+ }
211
+ }
212
+ const onNotification = (event: MessageEvent | Event) => {
213
+ const data = parseEventData(event)
214
+ if (data && isNotification(data)) {
215
+ opts.onNotification(data)
216
+ }
217
+ }
218
+ const onError = (event: Event) => {
219
+ opts.onError?.(event)
220
+ }
221
+
222
+ source.addEventListener('ready', onReady)
223
+ source.addEventListener('notification', onNotification)
224
+ source.addEventListener('error', onError)
225
+
226
+ return {
227
+ close: () => {
228
+ if (closed) return
229
+ closed = true
230
+ source.removeEventListener?.('ready', onReady)
231
+ source.removeEventListener?.('notification', onNotification)
232
+ source.removeEventListener?.('error', onError)
233
+ source.close()
234
+ },
235
+ }
236
+ }
237
+
238
+ function defaultEventSourceFactory(url: string): EventSourceLike | null {
239
+ if (typeof EventSource === 'undefined') return null
240
+ // `withCredentials: true` so the admin session cookie travels with
241
+ // the connection upgrade — every notification stream requires auth
242
+ // and the same-origin cookie is the only credential we trust here.
243
+ return new EventSource(url, { withCredentials: true }) as unknown as EventSourceLike
244
+ }
245
+
246
+ function parseEventData(event: MessageEvent | Event): unknown {
247
+ // EventSource emits MessageEvents with `data: string`. Plain
248
+ // `Event` (e.g. the `error` event) has no payload; ignore.
249
+ const data = (event as MessageEvent).data
250
+ if (typeof data !== 'string') return null
251
+ try {
252
+ return JSON.parse(data)
253
+ } catch {
254
+ return null
255
+ }
256
+ }
257
+
258
+ function isNotification(value: unknown): value is Notification {
259
+ if (!value || typeof value !== 'object') return false
260
+ const v = value as Record<string, unknown>
261
+ // Defensive: don't trust the wire blindly — verify the discriminant
262
+ // properties our consumers will read. A drift in the server-side
263
+ // wire format must surface as "missing fields" in the test suite,
264
+ // not as runtime errors deep in the bell's render path.
265
+ return (
266
+ typeof v.id === 'string' &&
267
+ typeof v.userId === 'string' &&
268
+ typeof v.createdAt === 'string' &&
269
+ !!v.payload &&
270
+ typeof (v.payload as { kind?: unknown }).kind === 'string'
271
+ )
272
+ }
273
+
128
274
  /**
129
275
  * Cheap single-line summary for the bell list — the server gives us a
130
276
  * structured payload, but the dropdown wants one human-readable string