@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.
- package/dist/__tests__/components/comment-side-panel.test.js +93 -0
- package/dist/__tests__/components/comment-side-panel.test.js.map +1 -1
- package/dist/__tests__/components/mentionable-textarea.test.d.ts +2 -0
- package/dist/__tests__/components/mentionable-textarea.test.d.ts.map +1 -0
- package/dist/__tests__/components/mentionable-textarea.test.js +190 -0
- package/dist/__tests__/components/mentionable-textarea.test.js.map +1 -0
- package/dist/__tests__/components/notification-bell.test.js +146 -0
- package/dist/__tests__/components/notification-bell.test.js.map +1 -1
- package/dist/__tests__/lib/mention-picker.test.d.ts +2 -0
- package/dist/__tests__/lib/mention-picker.test.d.ts.map +1 -0
- package/dist/__tests__/lib/mention-picker.test.js +122 -0
- package/dist/__tests__/lib/mention-picker.test.js.map +1 -0
- package/dist/__tests__/lib/notifications-client.test.js +129 -1
- package/dist/__tests__/lib/notifications-client.test.js.map +1 -1
- package/dist/actuate-admin.css +1 -1
- package/dist/components/CommentSidePanel.d.ts +9 -1
- package/dist/components/CommentSidePanel.d.ts.map +1 -1
- package/dist/components/CommentSidePanel.js +8 -7
- package/dist/components/CommentSidePanel.js.map +1 -1
- package/dist/components/MentionableTextarea.d.ts +68 -0
- package/dist/components/MentionableTextarea.d.ts.map +1 -0
- package/dist/components/MentionableTextarea.js +181 -0
- package/dist/components/MentionableTextarea.js.map +1 -0
- package/dist/components/NotificationBell.d.ts +30 -9
- package/dist/components/NotificationBell.d.ts.map +1 -1
- package/dist/components/NotificationBell.js +57 -5
- package/dist/components/NotificationBell.js.map +1 -1
- package/dist/lib/api.d.ts +8 -0
- package/dist/lib/api.d.ts.map +1 -1
- package/dist/lib/api.js +10 -0
- package/dist/lib/api.js.map +1 -1
- package/dist/lib/mention-picker.d.ts +69 -0
- package/dist/lib/mention-picker.d.ts.map +1 -0
- package/dist/lib/mention-picker.js +138 -0
- package/dist/lib/mention-picker.js.map +1 -0
- package/dist/lib/notifications-client.d.ts +53 -0
- package/dist/lib/notifications-client.d.ts.map +1 -1
- package/dist/lib/notifications-client.js +89 -1
- package/dist/lib/notifications-client.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/components/comment-side-panel.test.tsx +134 -0
- package/src/__tests__/components/mentionable-textarea.test.tsx +226 -0
- package/src/__tests__/components/notification-bell.test.tsx +167 -1
- package/src/__tests__/lib/mention-picker.test.ts +145 -0
- package/src/__tests__/lib/notifications-client.test.ts +145 -1
- package/src/components/CommentSidePanel.tsx +37 -14
- package/src/components/MentionableTextarea.tsx +328 -0
- package/src/components/NotificationBell.tsx +84 -12
- package/src/lib/api.ts +11 -0
- package/src/lib/mention-picker.ts +180 -0
- 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
|
-
* - **
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
/**
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|