@actuate-media/cms-admin 0.13.0 → 0.14.1
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__/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/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.map +1 -1
- package/dist/components/NotificationBell.js +32 -11
- package/dist/components/NotificationBell.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/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__/lib/mention-picker.test.ts +145 -0
- package/src/components/CommentSidePanel.tsx +37 -14
- package/src/components/MentionableTextarea.tsx +328 -0
- package/src/components/NotificationBell.tsx +31 -11
- package/src/lib/mention-picker.ts +180 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React, { useCallback, useEffect, useId, useRef, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type MentionUser,
|
|
7
|
+
detectMentionContext,
|
|
8
|
+
insertMentionToken,
|
|
9
|
+
searchMentionUsers,
|
|
10
|
+
} from '../lib/mention-picker.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* `<MentionableTextarea />` — drop-in replacement for `<textarea>` /
|
|
14
|
+
* `<input type="text">` that opens a Radix-styled mention picker
|
|
15
|
+
* whenever the user types `@`.
|
|
16
|
+
*
|
|
17
|
+
* Behaviour
|
|
18
|
+
* ─────────
|
|
19
|
+
* - The picker appears anchored to the textarea, below the input.
|
|
20
|
+
* We intentionally do NOT track the caret pixel position (that
|
|
21
|
+
* requires a textarea-mirror trick which doesn't add enough UX to
|
|
22
|
+
* justify the complexity); GitHub, Linear, and Asana all anchor
|
|
23
|
+
* their pickers this way on simple textareas.
|
|
24
|
+
* - Arrow Up / Arrow Down move the highlight. Enter / Tab commit the
|
|
25
|
+
* selection. Escape closes the picker without inserting anything.
|
|
26
|
+
* Mouse click on a row also commits.
|
|
27
|
+
* - The picker only fires server queries while a real mention is
|
|
28
|
+
* being typed; it short-circuits an empty `query` so the dropdown
|
|
29
|
+
* shows up immediately after `@` is typed (with an empty state)
|
|
30
|
+
* and only flips to "no matches" once the user has typed at least
|
|
31
|
+
* one character.
|
|
32
|
+
* - The textarea value is fully controlled by the parent — we never
|
|
33
|
+
* keep a separate copy. `onChange` is called with the next value
|
|
34
|
+
* on every keystroke AND every mention insertion.
|
|
35
|
+
*
|
|
36
|
+
* Dependency-injection seam
|
|
37
|
+
* ─────────────────────────
|
|
38
|
+
* The `search` prop accepts an override so tests can substitute a
|
|
39
|
+
* synchronous fake. Production callers leave it unset and the
|
|
40
|
+
* component uses the real `searchMentionUsers()` REST helper.
|
|
41
|
+
*/
|
|
42
|
+
export interface MentionableTextareaProps {
|
|
43
|
+
value: string
|
|
44
|
+
onChange: (next: string) => void
|
|
45
|
+
/** Forwarded to the underlying textarea. */
|
|
46
|
+
placeholder?: string
|
|
47
|
+
/** Forwarded to the underlying textarea. */
|
|
48
|
+
rows?: number
|
|
49
|
+
className?: string
|
|
50
|
+
/** Forwarded — useful for keyboard shortcuts the parent owns. */
|
|
51
|
+
onKeyDown?: (event: React.KeyboardEvent<HTMLTextAreaElement>) => void
|
|
52
|
+
/** Forwarded — useful for click-to-stop-propagation in nested cards. */
|
|
53
|
+
onClick?: (event: React.MouseEvent<HTMLTextAreaElement>) => void
|
|
54
|
+
/** Test hook — required when rendering with React state assertions. */
|
|
55
|
+
'data-testid'?: string
|
|
56
|
+
/**
|
|
57
|
+
* Override the user search. The default uses the REST helper.
|
|
58
|
+
* Tests inject an in-memory fake so dropdown contents are
|
|
59
|
+
* deterministic without HTTP mocking.
|
|
60
|
+
*/
|
|
61
|
+
search?: (query: string) => Promise<MentionUser[]>
|
|
62
|
+
/**
|
|
63
|
+
* Optional fixed id for the picker dropdown — pass when the parent
|
|
64
|
+
* wires up `aria-controls`. We generate one with `useId()` when
|
|
65
|
+
* unset.
|
|
66
|
+
*/
|
|
67
|
+
pickerId?: string
|
|
68
|
+
/**
|
|
69
|
+
* Format the displayed token. By default we show `@<name>` while
|
|
70
|
+
* the underlying value stores `@<userId>`. A future slice can swap
|
|
71
|
+
* to a rich-text editor where these diverge; the prop lets that
|
|
72
|
+
* land without changing the picker.
|
|
73
|
+
*/
|
|
74
|
+
formatLabel?: (user: MentionUser) => string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface PickerState {
|
|
78
|
+
open: boolean
|
|
79
|
+
query: string
|
|
80
|
+
highlight: number
|
|
81
|
+
users: MentionUser[]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const initialPicker: PickerState = {
|
|
85
|
+
open: false,
|
|
86
|
+
query: '',
|
|
87
|
+
highlight: 0,
|
|
88
|
+
users: [],
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function MentionableTextarea({
|
|
92
|
+
value,
|
|
93
|
+
onChange,
|
|
94
|
+
placeholder,
|
|
95
|
+
rows = 3,
|
|
96
|
+
className,
|
|
97
|
+
onKeyDown,
|
|
98
|
+
onClick,
|
|
99
|
+
'data-testid': testId,
|
|
100
|
+
search = (q) => searchMentionUsers(q).then((r) => (r.ok ? r.result : [])),
|
|
101
|
+
pickerId: pickerIdProp,
|
|
102
|
+
formatLabel = (u) => `@${u.name}`,
|
|
103
|
+
}: MentionableTextareaProps): React.JSX.Element {
|
|
104
|
+
const generatedId = useId()
|
|
105
|
+
const pickerId = pickerIdProp ?? `actuate-mention-picker-${generatedId}`
|
|
106
|
+
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
|
107
|
+
const [picker, setPicker] = useState<PickerState>(initialPicker)
|
|
108
|
+
// `pickerRef` mirrors `picker` for the keydown handler so the
|
|
109
|
+
// arrow-key / enter listeners don't go stale between renders.
|
|
110
|
+
const pickerRef = useRef(picker)
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
pickerRef.current = picker
|
|
113
|
+
}, [picker])
|
|
114
|
+
const requestSeq = useRef(0)
|
|
115
|
+
const mountedRef = useRef(true)
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
return () => {
|
|
118
|
+
mountedRef.current = false
|
|
119
|
+
}
|
|
120
|
+
}, [])
|
|
121
|
+
|
|
122
|
+
const closePicker = useCallback(() => {
|
|
123
|
+
setPicker((p) => (p.open ? initialPicker : p))
|
|
124
|
+
}, [])
|
|
125
|
+
|
|
126
|
+
// Recompute the active mention context whenever the value or
|
|
127
|
+
// selection changes. Centralising this in one effect keeps the
|
|
128
|
+
// open/close logic in a single place — every textarea event
|
|
129
|
+
// (input, click, keyup) just calls `recompute()`.
|
|
130
|
+
const recompute = useCallback(
|
|
131
|
+
async (nextValue: string, caret: number) => {
|
|
132
|
+
const ctx = detectMentionContext(nextValue, caret)
|
|
133
|
+
if (!ctx) {
|
|
134
|
+
closePicker()
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
const id = ++requestSeq.current
|
|
138
|
+
// Open immediately so the user sees a dropdown the moment they
|
|
139
|
+
// type `@`. Clear the `users` array on every new query so the
|
|
140
|
+
// user can never press Enter / Tab and commit a stale row
|
|
141
|
+
// from the previous query while the new request is in flight
|
|
142
|
+
// (PR #159 Copilot finding — type `@a` then `@bo` quickly and
|
|
143
|
+
// hit Enter before the `@bo` results arrive: without this
|
|
144
|
+
// reset, the picker would commit a row from `@a`).
|
|
145
|
+
setPicker((p) => ({
|
|
146
|
+
open: true,
|
|
147
|
+
query: ctx.query,
|
|
148
|
+
highlight: 0,
|
|
149
|
+
users: p.query === ctx.query ? p.users : [],
|
|
150
|
+
}))
|
|
151
|
+
try {
|
|
152
|
+
const users = await search(ctx.query)
|
|
153
|
+
if (!mountedRef.current) return
|
|
154
|
+
// Drop stale responses — only commit if our request is still
|
|
155
|
+
// the latest one. Otherwise the user has typed more and a
|
|
156
|
+
// newer query is in flight.
|
|
157
|
+
if (id !== requestSeq.current) return
|
|
158
|
+
setPicker((p) =>
|
|
159
|
+
// If the picker was closed (Escape) between request and
|
|
160
|
+
// response, don't reopen it.
|
|
161
|
+
p.open ? { ...p, users, highlight: 0 } : p,
|
|
162
|
+
)
|
|
163
|
+
} catch {
|
|
164
|
+
if (!mountedRef.current) return
|
|
165
|
+
if (id !== requestSeq.current) return
|
|
166
|
+
// Surface no rows on failure — the textarea must keep
|
|
167
|
+
// working when the user is offline. The poll loop / native
|
|
168
|
+
// submit path is unaffected.
|
|
169
|
+
setPicker((p) => (p.open ? { ...p, users: [] } : p))
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
[closePicker, search],
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
const commitMention = useCallback(
|
|
176
|
+
(user: MentionUser) => {
|
|
177
|
+
const el = textareaRef.current
|
|
178
|
+
if (!el) return
|
|
179
|
+
const caret = el.selectionStart ?? value.length
|
|
180
|
+
const ctx = detectMentionContext(value, caret)
|
|
181
|
+
if (!ctx) {
|
|
182
|
+
closePicker()
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
const next = insertMentionToken(value, ctx, user.id)
|
|
186
|
+
onChange(next.value)
|
|
187
|
+
closePicker()
|
|
188
|
+
// Move the caret past the inserted token AFTER React paints
|
|
189
|
+
// the new value, so the selection isn't clobbered by the
|
|
190
|
+
// controlled update.
|
|
191
|
+
requestAnimationFrame(() => {
|
|
192
|
+
const live = textareaRef.current
|
|
193
|
+
if (!live) return
|
|
194
|
+
live.focus()
|
|
195
|
+
live.setSelectionRange(next.caret, next.caret)
|
|
196
|
+
})
|
|
197
|
+
},
|
|
198
|
+
[closePicker, onChange, value],
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
const handleChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
202
|
+
const next = event.target.value
|
|
203
|
+
const caret = event.target.selectionStart ?? next.length
|
|
204
|
+
onChange(next)
|
|
205
|
+
void recompute(next, caret)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
209
|
+
const p = pickerRef.current
|
|
210
|
+
if (p.open && p.users.length > 0) {
|
|
211
|
+
if (event.key === 'ArrowDown') {
|
|
212
|
+
event.preventDefault()
|
|
213
|
+
setPicker((s) => ({ ...s, highlight: (s.highlight + 1) % s.users.length }))
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
if (event.key === 'ArrowUp') {
|
|
217
|
+
event.preventDefault()
|
|
218
|
+
setPicker((s) => ({
|
|
219
|
+
...s,
|
|
220
|
+
highlight: (s.highlight - 1 + s.users.length) % s.users.length,
|
|
221
|
+
}))
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
if (event.key === 'Enter' || event.key === 'Tab') {
|
|
225
|
+
event.preventDefault()
|
|
226
|
+
// Defensive: highlight could theoretically point past the
|
|
227
|
+
// end of `users` if state went stale between renders. Guard
|
|
228
|
+
// it so we never `undefined`-bang.
|
|
229
|
+
const target = p.users[p.highlight]
|
|
230
|
+
if (target) commitMention(target)
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (event.key === 'Escape' && p.open) {
|
|
235
|
+
event.preventDefault()
|
|
236
|
+
closePicker()
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
onKeyDown?.(event)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const handleClick = (event: React.MouseEvent<HTMLTextAreaElement>) => {
|
|
243
|
+
onClick?.(event)
|
|
244
|
+
const el = textareaRef.current
|
|
245
|
+
if (!el) return
|
|
246
|
+
void recompute(el.value, el.selectionStart ?? el.value.length)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Closing on outside click — when the user clicks anywhere outside
|
|
250
|
+
// the textarea or the picker, the dropdown should disappear so it
|
|
251
|
+
// doesn't sit on top of unrelated UI.
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
if (!picker.open) return
|
|
254
|
+
const onDocMouseDown = (ev: MouseEvent) => {
|
|
255
|
+
const t = ev.target as Node | null
|
|
256
|
+
if (!t) return
|
|
257
|
+
const parent = textareaRef.current?.parentElement
|
|
258
|
+
if (parent && parent.contains(t)) return
|
|
259
|
+
closePicker()
|
|
260
|
+
}
|
|
261
|
+
document.addEventListener('mousedown', onDocMouseDown)
|
|
262
|
+
return () => document.removeEventListener('mousedown', onDocMouseDown)
|
|
263
|
+
}, [picker.open, closePicker])
|
|
264
|
+
|
|
265
|
+
return (
|
|
266
|
+
<div className="relative">
|
|
267
|
+
<textarea
|
|
268
|
+
ref={textareaRef}
|
|
269
|
+
value={value}
|
|
270
|
+
onChange={handleChange}
|
|
271
|
+
onKeyDown={handleKeyDown}
|
|
272
|
+
onClick={handleClick}
|
|
273
|
+
placeholder={placeholder}
|
|
274
|
+
rows={rows}
|
|
275
|
+
className={className}
|
|
276
|
+
aria-autocomplete="list"
|
|
277
|
+
aria-controls={picker.open ? pickerId : undefined}
|
|
278
|
+
aria-expanded={picker.open}
|
|
279
|
+
data-testid={testId}
|
|
280
|
+
/>
|
|
281
|
+
{picker.open && (
|
|
282
|
+
<div
|
|
283
|
+
id={pickerId}
|
|
284
|
+
role="listbox"
|
|
285
|
+
aria-label="Mention a teammate"
|
|
286
|
+
className="absolute top-full left-0 z-30 mt-1 max-h-56 w-64 overflow-y-auto rounded border border-gray-200 bg-white shadow-lg"
|
|
287
|
+
data-testid={testId ? `${testId}-picker` : 'mention-picker'}
|
|
288
|
+
>
|
|
289
|
+
{picker.users.length === 0 ? (
|
|
290
|
+
<p className="px-3 py-2 text-xs text-gray-500">
|
|
291
|
+
{picker.query ? 'No matches' : 'Type a name…'}
|
|
292
|
+
</p>
|
|
293
|
+
) : (
|
|
294
|
+
<ul>
|
|
295
|
+
{picker.users.map((u, idx) => (
|
|
296
|
+
<li key={u.id}>
|
|
297
|
+
<button
|
|
298
|
+
type="button"
|
|
299
|
+
role="option"
|
|
300
|
+
aria-selected={idx === picker.highlight}
|
|
301
|
+
onMouseDown={(e) => {
|
|
302
|
+
// mousedown (not click) — prevents the
|
|
303
|
+
// textarea from losing focus before we
|
|
304
|
+
// commit, which would otherwise hide the
|
|
305
|
+
// selection and feel janky.
|
|
306
|
+
e.preventDefault()
|
|
307
|
+
commitMention(u)
|
|
308
|
+
}}
|
|
309
|
+
onMouseEnter={() => setPicker((s) => ({ ...s, highlight: idx }))}
|
|
310
|
+
className={`block w-full px-3 py-1.5 text-left text-xs ${
|
|
311
|
+
idx === picker.highlight
|
|
312
|
+
? 'bg-blue-50 text-blue-900'
|
|
313
|
+
: 'text-gray-800 hover:bg-gray-50'
|
|
314
|
+
}`}
|
|
315
|
+
data-testid={testId ? `${testId}-picker-row-${u.id}` : `mention-row-${u.id}`}
|
|
316
|
+
>
|
|
317
|
+
<div className="font-medium">{formatLabel(u)}</div>
|
|
318
|
+
<div className="text-[10px] text-gray-500">{u.email}</div>
|
|
319
|
+
</button>
|
|
320
|
+
</li>
|
|
321
|
+
))}
|
|
322
|
+
</ul>
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
)}
|
|
326
|
+
</div>
|
|
327
|
+
)
|
|
328
|
+
}
|
|
@@ -100,12 +100,18 @@ export function NotificationBell({
|
|
|
100
100
|
// Set of notification IDs we've already rendered. The SSE handler
|
|
101
101
|
// consults this synchronously so duplicate deliveries (REST refresh
|
|
102
102
|
// + SSE push, or two SSE retries) don't double-bump the badge.
|
|
103
|
-
//
|
|
104
|
-
//
|
|
103
|
+
//
|
|
104
|
+
// The ref is updated **synchronously** at every call site that
|
|
105
|
+
// changes the set of items (REST refresh, SSE push, dropdown clear).
|
|
106
|
+
// A prior implementation reconciled it via `useEffect([items])`,
|
|
107
|
+
// but that effect runs *after* React commits — so an SSE push that
|
|
108
|
+
// arrives between the REST list resolving (`items` mutated) and the
|
|
109
|
+
// effect running would read a stale ref, fall through the
|
|
110
|
+
// de-dup gate, and append a duplicate row. The reproducible
|
|
111
|
+
// regression was caught by the test
|
|
112
|
+
// `does not double-count a notification that arrives via both
|
|
113
|
+
// SSE and REST`.
|
|
105
114
|
const seenIdsRef = useRef<Set<string>>(new Set())
|
|
106
|
-
useEffect(() => {
|
|
107
|
-
seenIdsRef.current = new Set(items.map((n) => n.id))
|
|
108
|
-
}, [items])
|
|
109
115
|
useEffect(() => {
|
|
110
116
|
return () => {
|
|
111
117
|
mountedRef.current = false
|
|
@@ -130,8 +136,10 @@ export function NotificationBell({
|
|
|
130
136
|
if (!out.ok) {
|
|
131
137
|
onError?.(out.error)
|
|
132
138
|
setItems([])
|
|
139
|
+
seenIdsRef.current = new Set()
|
|
133
140
|
} else {
|
|
134
141
|
setItems(out.result)
|
|
142
|
+
seenIdsRef.current = new Set(out.result.map((n) => n.id))
|
|
135
143
|
}
|
|
136
144
|
setLoading(false)
|
|
137
145
|
},
|
|
@@ -154,14 +162,26 @@ export function NotificationBell({
|
|
|
154
162
|
const handle = api.subscribe({
|
|
155
163
|
onNotification: (n) => {
|
|
156
164
|
if (!mountedRef.current) return
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
165
|
+
// SSE events can race with REST refreshes (or with their own
|
|
166
|
+
// retries). Two gates close the door on duplicates:
|
|
167
|
+
// 1. **Synchronous ref check** — fast-path that also
|
|
168
|
+
// gates the unread badge bump, so a second delivery of
|
|
169
|
+
// the same row never drifts the counter. The ref is
|
|
170
|
+
// maintained synchronously by `refreshList` so the
|
|
171
|
+
// check sees a consistent snapshot.
|
|
172
|
+
// 2. **Inner setItems check** — final safety net. The
|
|
173
|
+
// updater sees the latest `arr` snapshot React has
|
|
174
|
+
// queued, which may differ from the ref if a
|
|
175
|
+
// `refreshList` resolved between this handler's ref
|
|
176
|
+
// read and the updater running. Without it, an
|
|
177
|
+
// interleaved REST refresh could leave `items` with a
|
|
178
|
+
// duplicate row.
|
|
162
179
|
if (seenIdsRef.current.has(n.id)) return
|
|
163
180
|
seenIdsRef.current.add(n.id)
|
|
164
|
-
setItems((arr) =>
|
|
181
|
+
setItems((arr) => {
|
|
182
|
+
if (arr.some((existing) => existing.id === n.id)) return arr
|
|
183
|
+
return [n, ...arr]
|
|
184
|
+
})
|
|
165
185
|
if (!n.readAt) setUnread((v) => v + 1)
|
|
166
186
|
},
|
|
167
187
|
onError: () => {
|
|
@@ -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
|
+
}
|