@startsimpli/ui 0.4.142 → 0.4.144
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/package.json +38 -12
- package/src/components/blog/MarkdownRenderer.tsx +23 -5
- package/src/components/track-changes/ChangesRail.tsx +687 -0
- package/src/components/track-changes/TrackedField.tsx +293 -0
- package/src/components/track-changes/TrackedReadView.tsx +273 -0
- package/src/components/track-changes/TrackedTextEditor.tsx +595 -0
- package/src/components/track-changes/__tests__/extension.test.ts +350 -0
- package/src/components/track-changes/__tests__/model.test.ts +461 -0
- package/src/components/track-changes/__tests__/rails.test.tsx +604 -0
- package/src/components/track-changes/__tests__/tracked-text-editor.test.tsx +476 -0
- package/src/components/track-changes/describe.ts +76 -0
- package/src/components/track-changes/extension.ts +724 -0
- package/src/components/track-changes/final-text.ts +54 -0
- package/src/components/track-changes/http-client.ts +109 -0
- package/src/components/track-changes/index.ts +90 -0
- package/src/components/track-changes/normalize.ts +393 -0
- package/src/components/track-changes/read-marks.ts +262 -0
- package/src/components/track-changes/types.ts +451 -0
- package/src/components/track-changes/useTrackChanges.ts +407 -0
- package/src/components/track-changes/word-diff.ts +97 -0
|
@@ -0,0 +1,687 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ChangesRail — every open suggestion and comment thread on a field, in
|
|
5
|
+
* document order, with accept / reject / withdraw and reply / resolve / reopen
|
|
6
|
+
* (bd startsim-whwxd.3; design startsim-xyn25 section d).
|
|
7
|
+
*
|
|
8
|
+
* HUMAN-ONLY (Quinn, 2026-10-07). There is no machine-proposal control: the
|
|
9
|
+
* server refuses suggestions and comments from machine actors.
|
|
10
|
+
*
|
|
11
|
+
* WHO SEES WHAT. Accept and reject render only for someone who may edit the
|
|
12
|
+
* field (`canAccept`): anyone who can edit may accept (Q4). Everyone who can
|
|
13
|
+
* read may suggest, comment, reply and resolve (Q5). An author may withdraw
|
|
14
|
+
* their own suggestion.
|
|
15
|
+
*
|
|
16
|
+
* KEYBOARD. Every item is focusable and named in words (author, and whether a
|
|
17
|
+
* person or a machine wrote it, or that it was not recorded). Up/Down move
|
|
18
|
+
* between items; Enter shows the item in the text; A accepts and R rejects
|
|
19
|
+
* the focused suggestion. "Next change" walks the marks in the text itself.
|
|
20
|
+
*
|
|
21
|
+
* MENTIONS. `@` in a comment offers colleagues; a mention is shown in the app
|
|
22
|
+
* and nothing is emailed or sent to anyone (Quinn, Q7).
|
|
23
|
+
*
|
|
24
|
+
* Presentational: every write is a callback, so the host decides the
|
|
25
|
+
* precondition (the version an accept asserts) and folds the result.
|
|
26
|
+
*/
|
|
27
|
+
import { useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
|
|
28
|
+
|
|
29
|
+
import { ActorIdentity, actorKindTitle } from '../ActorIdentity';
|
|
30
|
+
import { describeChange, describeSuggestion, describeThread, quoted, who } from './describe';
|
|
31
|
+
import type { CommentThread, RosterMember, SelectionAnchor, Suggestion, TrackActor, TrackComment } from './types';
|
|
32
|
+
|
|
33
|
+
export interface ChangesRailProps {
|
|
34
|
+
/** e.g. "Blog". Used in headings and accessible names. */
|
|
35
|
+
fieldLabel: string;
|
|
36
|
+
suggestions: Suggestion[];
|
|
37
|
+
threads: CommentThread[];
|
|
38
|
+
currentActorSub: string | null;
|
|
39
|
+
/** May accept and reject: the field's edit right. */
|
|
40
|
+
canAccept: boolean;
|
|
41
|
+
/** May comment, reply and resolve. Default true (view-only included). */
|
|
42
|
+
canComment?: boolean;
|
|
43
|
+
activeId?: string | null;
|
|
44
|
+
/** Show an item in the text (select its mark). */
|
|
45
|
+
onFocusItem?: (id: string) => void;
|
|
46
|
+
onNextChange?: () => void;
|
|
47
|
+
onPrevChange?: () => void;
|
|
48
|
+
|
|
49
|
+
onAccept?: (ids: string[]) => Promise<unknown>;
|
|
50
|
+
onReject?: (ids: string[]) => Promise<unknown>;
|
|
51
|
+
onWithdraw?: (id: string) => Promise<unknown>;
|
|
52
|
+
/** A new thread: on `selection` when one is given, else on the field. */
|
|
53
|
+
onComment?: (body: string, mentions: string[], selection: SelectionAnchor | null) => Promise<unknown>;
|
|
54
|
+
onReply?: (threadId: string, body: string, mentions: string[]) => Promise<unknown>;
|
|
55
|
+
onResolve?: (threadId: string) => Promise<unknown>;
|
|
56
|
+
onReopen?: (threadId: string) => Promise<unknown>;
|
|
57
|
+
|
|
58
|
+
/** The text selected in the editor or the Read view, for a span comment. */
|
|
59
|
+
selection?: SelectionAnchor | null;
|
|
60
|
+
/** Colleagues for @mentions (see useTrackChanges `mentionable`). */
|
|
61
|
+
mentionable?: RosterMember[];
|
|
62
|
+
/** Why the colleague list could not be read, if it could not. */
|
|
63
|
+
mentionNotice?: string | null;
|
|
64
|
+
className?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function kindWords(a: TrackActor | null | undefined): string {
|
|
68
|
+
if (!a || a.kind === 'unknown') return 'Whether a person or a machine wrote it was not recorded.';
|
|
69
|
+
return a.kind === 'machine' ? 'Written by a machine.' : 'Written by a person.';
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function Author({ actor }: { actor: TrackActor }) {
|
|
73
|
+
return (
|
|
74
|
+
<ActorIdentity
|
|
75
|
+
actor={{ id: actor.sub, name: actor.label || actor.sub || 'Unknown' }}
|
|
76
|
+
kind={actor.kind}
|
|
77
|
+
kindSource={actor.kindSource}
|
|
78
|
+
showInitials={false}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const btn =
|
|
84
|
+
'rounded border border-border px-2 py-0.5 text-xs font-medium transition-colors hover:bg-muted ' +
|
|
85
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50';
|
|
86
|
+
const primaryBtn =
|
|
87
|
+
'rounded bg-primary px-2 py-0.5 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90 ' +
|
|
88
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50';
|
|
89
|
+
|
|
90
|
+
type Item = { kind: 'suggestion'; s: Suggestion; at: number } | { kind: 'thread'; t: CommentThread; at: number };
|
|
91
|
+
|
|
92
|
+
export function ChangesRail(props: ChangesRailProps) {
|
|
93
|
+
const {
|
|
94
|
+
fieldLabel,
|
|
95
|
+
suggestions,
|
|
96
|
+
threads,
|
|
97
|
+
currentActorSub,
|
|
98
|
+
canAccept,
|
|
99
|
+
canComment = true,
|
|
100
|
+
activeId,
|
|
101
|
+
onFocusItem,
|
|
102
|
+
onNextChange,
|
|
103
|
+
onPrevChange,
|
|
104
|
+
onAccept,
|
|
105
|
+
onReject,
|
|
106
|
+
onWithdraw,
|
|
107
|
+
onComment,
|
|
108
|
+
selection = null,
|
|
109
|
+
mentionable = [],
|
|
110
|
+
mentionNotice,
|
|
111
|
+
className,
|
|
112
|
+
} = props;
|
|
113
|
+
|
|
114
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
115
|
+
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
116
|
+
const listRef = useRef<HTMLOListElement>(null);
|
|
117
|
+
|
|
118
|
+
const open = useMemo(() => {
|
|
119
|
+
const items: Item[] = [];
|
|
120
|
+
for (const s of suggestions) {
|
|
121
|
+
if (s.status === 'open' && s.anchorState !== 'orphaned') items.push({ kind: 'suggestion', s, at: s.from });
|
|
122
|
+
}
|
|
123
|
+
for (const t of threads) {
|
|
124
|
+
if (!t.resolved && t.anchorState !== 'orphaned') items.push({ kind: 'thread', t, at: t.from ?? -1 });
|
|
125
|
+
}
|
|
126
|
+
return items.sort((a, b) => a.at - b.at);
|
|
127
|
+
}, [suggestions, threads]);
|
|
128
|
+
const orphanedSuggestions = suggestions.filter((s) => s.status === 'open' && s.anchorState === 'orphaned');
|
|
129
|
+
const orphanedThreads = threads.filter((t) => !t.resolved && t.anchorState === 'orphaned');
|
|
130
|
+
const resolved = threads.filter((t) => t.resolved);
|
|
131
|
+
const openSuggestionIds = open.flatMap((i) => (i.kind === 'suggestion' ? [i.s.id] : []));
|
|
132
|
+
|
|
133
|
+
/** Resolves true when the write landed, false when it failed (and says why). */
|
|
134
|
+
async function run(key: string, fn: () => Promise<unknown>): Promise<boolean> {
|
|
135
|
+
setBusy(key);
|
|
136
|
+
setErrors((e) => {
|
|
137
|
+
const { [key]: _drop, ...rest } = e;
|
|
138
|
+
return rest;
|
|
139
|
+
});
|
|
140
|
+
try {
|
|
141
|
+
await fn();
|
|
142
|
+
return true;
|
|
143
|
+
} catch (err) {
|
|
144
|
+
setErrors((e) => ({ ...e, [key]: err instanceof Error ? err.message : 'That did not work.' }));
|
|
145
|
+
return false;
|
|
146
|
+
} finally {
|
|
147
|
+
setBusy(null);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function moveFocus(from: HTMLElement, delta: 1 | -1) {
|
|
152
|
+
const items = Array.from(listRef.current?.querySelectorAll<HTMLElement>('[data-rail-item]') ?? []);
|
|
153
|
+
const i = items.indexOf(from);
|
|
154
|
+
items[Math.max(0, Math.min(items.length - 1, i + delta))]?.focus();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function onItemKey(e: KeyboardEvent<HTMLElement>, item: Item) {
|
|
158
|
+
if (e.target !== e.currentTarget) return; // a button or a reply box inside
|
|
159
|
+
const id = item.kind === 'suggestion' ? item.s.id : item.t.id;
|
|
160
|
+
if (e.key === 'ArrowDown') moveFocus(e.currentTarget, 1);
|
|
161
|
+
else if (e.key === 'ArrowUp') moveFocus(e.currentTarget, -1);
|
|
162
|
+
else if (e.key === 'Enter') onFocusItem?.(id);
|
|
163
|
+
else if (item.kind === 'suggestion' && canAccept && (e.key === 'a' || e.key === 'A') && onAccept)
|
|
164
|
+
void run(id, () => onAccept([id]));
|
|
165
|
+
else if (item.kind === 'suggestion' && canAccept && (e.key === 'r' || e.key === 'R') && onReject)
|
|
166
|
+
void run(id, () => onReject([id]));
|
|
167
|
+
else return;
|
|
168
|
+
e.preventDefault();
|
|
169
|
+
e.stopPropagation();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const nSugg = openSuggestionIds.length;
|
|
173
|
+
const nThreads = open.length - nSugg;
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<section
|
|
177
|
+
aria-label={`Suggestions and comments on ${fieldLabel}`}
|
|
178
|
+
data-testid="changes-rail"
|
|
179
|
+
className={['@container flex flex-col gap-3 text-sm', className].filter(Boolean).join(' ')}
|
|
180
|
+
>
|
|
181
|
+
<header className="flex flex-col gap-2 @xs:flex-row @xs:flex-wrap @xs:items-center @xs:justify-between">
|
|
182
|
+
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
183
|
+
{nSugg} {nSugg === 1 ? 'suggestion' : 'suggestions'} · {nThreads} {nThreads === 1 ? 'comment' : 'comments'}
|
|
184
|
+
</h3>
|
|
185
|
+
<div className="flex flex-wrap gap-1">
|
|
186
|
+
{onPrevChange && (
|
|
187
|
+
<button type="button" className={btn} onClick={onPrevChange} title="Shift+F8 in the text">
|
|
188
|
+
Previous
|
|
189
|
+
</button>
|
|
190
|
+
)}
|
|
191
|
+
{onNextChange && (
|
|
192
|
+
<button type="button" className={btn} onClick={onNextChange} title="F8 in the text">
|
|
193
|
+
Next change
|
|
194
|
+
</button>
|
|
195
|
+
)}
|
|
196
|
+
{canAccept && nSugg > 1 && onAccept && (
|
|
197
|
+
<button
|
|
198
|
+
type="button"
|
|
199
|
+
className={btn}
|
|
200
|
+
disabled={busy !== null}
|
|
201
|
+
onClick={() => void run('all', () => onAccept(openSuggestionIds))}
|
|
202
|
+
>
|
|
203
|
+
Accept all
|
|
204
|
+
</button>
|
|
205
|
+
)}
|
|
206
|
+
{canAccept && nSugg > 1 && onReject && (
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
className={btn}
|
|
210
|
+
disabled={busy !== null}
|
|
211
|
+
onClick={() => void run('all', () => onReject(openSuggestionIds))}
|
|
212
|
+
>
|
|
213
|
+
Reject all
|
|
214
|
+
</button>
|
|
215
|
+
)}
|
|
216
|
+
</div>
|
|
217
|
+
</header>
|
|
218
|
+
{errors.all && (
|
|
219
|
+
<p role="alert" className="text-xs text-destructive">
|
|
220
|
+
{errors.all}
|
|
221
|
+
</p>
|
|
222
|
+
)}
|
|
223
|
+
|
|
224
|
+
{open.length === 0 ? (
|
|
225
|
+
<p className="text-xs text-muted-foreground">No open suggestions or comments on {fieldLabel}.</p>
|
|
226
|
+
) : (
|
|
227
|
+
<ol ref={listRef} className="flex flex-col gap-2" aria-label="Open suggestions and comments, in document order">
|
|
228
|
+
{open.map((item) => {
|
|
229
|
+
const id = item.kind === 'suggestion' ? item.s.id : item.t.id;
|
|
230
|
+
const active = activeId === id;
|
|
231
|
+
const label =
|
|
232
|
+
item.kind === 'suggestion'
|
|
233
|
+
? `${describeSuggestion(item.s)}. ${kindWords(item.s.author)}`
|
|
234
|
+
: `${describeThread(item.t)}. ${kindWords(item.t.comments[0]?.author)}`;
|
|
235
|
+
return (
|
|
236
|
+
<li
|
|
237
|
+
key={id}
|
|
238
|
+
data-rail-item=""
|
|
239
|
+
data-item-id={id}
|
|
240
|
+
tabIndex={0}
|
|
241
|
+
aria-label={label}
|
|
242
|
+
aria-current={active ? 'true' : undefined}
|
|
243
|
+
onKeyDown={(e) => onItemKey(e, item)}
|
|
244
|
+
onFocus={(e) => {
|
|
245
|
+
if (e.target === e.currentTarget) onFocusItem?.(id);
|
|
246
|
+
}}
|
|
247
|
+
className={[
|
|
248
|
+
'rounded-md border p-2 outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
249
|
+
active ? 'border-primary bg-primary/5' : 'border-border bg-card',
|
|
250
|
+
].join(' ')}
|
|
251
|
+
>
|
|
252
|
+
{item.kind === 'suggestion' ? (
|
|
253
|
+
<SuggestionCard
|
|
254
|
+
s={item.s}
|
|
255
|
+
own={!!currentActorSub && item.s.author.sub === currentActorSub}
|
|
256
|
+
canAccept={canAccept}
|
|
257
|
+
busy={busy !== null}
|
|
258
|
+
error={errors[id]}
|
|
259
|
+
onAccept={onAccept && (() => run(id, () => onAccept([id])))}
|
|
260
|
+
onReject={onReject && (() => run(id, () => onReject([id])))}
|
|
261
|
+
onWithdraw={onWithdraw && (() => run(id, () => onWithdraw(id)))}
|
|
262
|
+
/>
|
|
263
|
+
) : (
|
|
264
|
+
<ThreadCard
|
|
265
|
+
t={item.t}
|
|
266
|
+
canComment={canComment}
|
|
267
|
+
busy={busy !== null}
|
|
268
|
+
error={errors[id]}
|
|
269
|
+
mentionable={mentionable}
|
|
270
|
+
run={(fn) => run(id, fn)}
|
|
271
|
+
onReply={props.onReply}
|
|
272
|
+
onResolve={props.onResolve}
|
|
273
|
+
onReopen={props.onReopen}
|
|
274
|
+
/>
|
|
275
|
+
)}
|
|
276
|
+
</li>
|
|
277
|
+
);
|
|
278
|
+
})}
|
|
279
|
+
</ol>
|
|
280
|
+
)}
|
|
281
|
+
|
|
282
|
+
{canComment && onComment && (
|
|
283
|
+
<NewComment
|
|
284
|
+
fieldLabel={fieldLabel}
|
|
285
|
+
selection={selection}
|
|
286
|
+
mentionable={mentionable}
|
|
287
|
+
mentionNotice={mentionNotice}
|
|
288
|
+
busy={busy !== null}
|
|
289
|
+
error={errors.new}
|
|
290
|
+
onSubmit={(body, mentions) => run('new', () => onComment(body, mentions, selection))}
|
|
291
|
+
/>
|
|
292
|
+
)}
|
|
293
|
+
|
|
294
|
+
{(orphanedSuggestions.length > 0 || orphanedThreads.length > 0) && (
|
|
295
|
+
<details className="rounded-md border border-dashed border-border p-2">
|
|
296
|
+
<summary className="cursor-pointer text-xs font-medium text-muted-foreground">
|
|
297
|
+
Orphaned ({orphanedSuggestions.length + orphanedThreads.length}): the text they were made on is gone
|
|
298
|
+
</summary>
|
|
299
|
+
<ul className="mt-2 flex flex-col gap-2">
|
|
300
|
+
{orphanedSuggestions.map((s) => (
|
|
301
|
+
<li key={s.id} className="text-xs">
|
|
302
|
+
<Author actor={s.author} /> suggested: {describeChange(s.quote, s.replacement)}
|
|
303
|
+
{currentActorSub && s.author.sub === currentActorSub && onWithdraw && (
|
|
304
|
+
<button type="button" className={`${btn} ml-2`} onClick={() => void run(s.id, () => onWithdraw(s.id))}>
|
|
305
|
+
Withdraw
|
|
306
|
+
</button>
|
|
307
|
+
)}
|
|
308
|
+
{errors[s.id] && <span className="block text-destructive">{errors[s.id]}</span>}
|
|
309
|
+
</li>
|
|
310
|
+
))}
|
|
311
|
+
{orphanedThreads.map((t) => (
|
|
312
|
+
<li key={t.id} className="text-xs">
|
|
313
|
+
<ThreadCard
|
|
314
|
+
t={t}
|
|
315
|
+
canComment={canComment}
|
|
316
|
+
busy={busy !== null}
|
|
317
|
+
error={errors[t.id]}
|
|
318
|
+
mentionable={mentionable}
|
|
319
|
+
run={(fn) => run(t.id, fn)}
|
|
320
|
+
onReply={props.onReply}
|
|
321
|
+
onResolve={props.onResolve}
|
|
322
|
+
onReopen={props.onReopen}
|
|
323
|
+
/>
|
|
324
|
+
</li>
|
|
325
|
+
))}
|
|
326
|
+
</ul>
|
|
327
|
+
</details>
|
|
328
|
+
)}
|
|
329
|
+
|
|
330
|
+
{resolved.length > 0 && (
|
|
331
|
+
<details className="rounded-md border border-border p-2">
|
|
332
|
+
<summary className="cursor-pointer text-xs font-medium text-muted-foreground">Resolved ({resolved.length})</summary>
|
|
333
|
+
<ul className="mt-2 flex flex-col gap-2">
|
|
334
|
+
{resolved.map((t) => (
|
|
335
|
+
<li key={t.id} className="text-xs" aria-label={`Resolved: ${describeThread(t)}`}>
|
|
336
|
+
<ThreadCard
|
|
337
|
+
t={t}
|
|
338
|
+
canComment={canComment}
|
|
339
|
+
busy={busy !== null}
|
|
340
|
+
error={errors[t.id]}
|
|
341
|
+
mentionable={mentionable}
|
|
342
|
+
run={(fn) => run(t.id, fn)}
|
|
343
|
+
onReply={props.onReply}
|
|
344
|
+
onResolve={props.onResolve}
|
|
345
|
+
onReopen={props.onReopen}
|
|
346
|
+
/>
|
|
347
|
+
</li>
|
|
348
|
+
))}
|
|
349
|
+
</ul>
|
|
350
|
+
</details>
|
|
351
|
+
)}
|
|
352
|
+
</section>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function SuggestionCard({
|
|
357
|
+
s,
|
|
358
|
+
own,
|
|
359
|
+
canAccept,
|
|
360
|
+
busy,
|
|
361
|
+
error,
|
|
362
|
+
onAccept,
|
|
363
|
+
onReject,
|
|
364
|
+
onWithdraw,
|
|
365
|
+
}: {
|
|
366
|
+
s: Suggestion;
|
|
367
|
+
own: boolean;
|
|
368
|
+
canAccept: boolean;
|
|
369
|
+
busy: boolean;
|
|
370
|
+
error?: string;
|
|
371
|
+
onAccept?: () => void;
|
|
372
|
+
onReject?: () => void;
|
|
373
|
+
onWithdraw?: () => void;
|
|
374
|
+
}) {
|
|
375
|
+
return (
|
|
376
|
+
<div className="flex flex-col gap-1.5" data-testid="rail-suggestion">
|
|
377
|
+
<div className="flex flex-wrap items-center gap-x-1.5 text-xs text-muted-foreground">
|
|
378
|
+
<Author actor={s.author} />
|
|
379
|
+
<span>suggests</span>
|
|
380
|
+
{s.anchorState === 'reanchored' && <span>(the text around it changed)</span>}
|
|
381
|
+
</div>
|
|
382
|
+
<p className="break-words text-xs">
|
|
383
|
+
{s.quote && <del className="bg-red-500/10 text-red-700 dark:text-red-300">{s.quote}</del>}
|
|
384
|
+
{s.quote && s.replacement && ' → '}
|
|
385
|
+
{s.replacement && <ins className="bg-green-600/10 text-green-700 no-underline dark:text-green-300">{s.replacement}</ins>}
|
|
386
|
+
</p>
|
|
387
|
+
<div className="flex flex-wrap gap-1">
|
|
388
|
+
{canAccept && onAccept && (
|
|
389
|
+
<button type="button" className={primaryBtn} disabled={busy} onClick={onAccept} aria-keyshortcuts="A">
|
|
390
|
+
Accept
|
|
391
|
+
</button>
|
|
392
|
+
)}
|
|
393
|
+
{canAccept && onReject && (
|
|
394
|
+
<button type="button" className={btn} disabled={busy} onClick={onReject} aria-keyshortcuts="R">
|
|
395
|
+
Reject
|
|
396
|
+
</button>
|
|
397
|
+
)}
|
|
398
|
+
{own && onWithdraw && (
|
|
399
|
+
<button type="button" className={btn} disabled={busy} onClick={onWithdraw}>
|
|
400
|
+
Withdraw
|
|
401
|
+
</button>
|
|
402
|
+
)}
|
|
403
|
+
</div>
|
|
404
|
+
{error && (
|
|
405
|
+
<p role="alert" className="text-xs text-destructive">
|
|
406
|
+
{error}
|
|
407
|
+
</p>
|
|
408
|
+
)}
|
|
409
|
+
</div>
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function CommentBody({ c, mentionable }: { c: TrackComment; mentionable: RosterMember[] }) {
|
|
414
|
+
if (c.deleted || c.body == null) return <p className="text-xs italic text-muted-foreground">Comment deleted.</p>;
|
|
415
|
+
const names = c.mentions.map((sub) => mentionable.find((m) => m.sub === sub)?.label || sub);
|
|
416
|
+
return (
|
|
417
|
+
<div className="flex flex-col gap-0.5">
|
|
418
|
+
<p className="whitespace-pre-wrap break-words text-xs text-foreground">{c.body}</p>
|
|
419
|
+
{names.length > 0 && (
|
|
420
|
+
<p className="text-[11px] text-muted-foreground">Mentioned: {names.map((n) => `@${n}`).join(', ')}</p>
|
|
421
|
+
)}
|
|
422
|
+
</div>
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function ThreadCard({
|
|
427
|
+
t,
|
|
428
|
+
canComment,
|
|
429
|
+
busy,
|
|
430
|
+
error,
|
|
431
|
+
mentionable,
|
|
432
|
+
run,
|
|
433
|
+
onReply,
|
|
434
|
+
onResolve,
|
|
435
|
+
onReopen,
|
|
436
|
+
}: {
|
|
437
|
+
t: CommentThread;
|
|
438
|
+
canComment: boolean;
|
|
439
|
+
busy: boolean;
|
|
440
|
+
error?: string;
|
|
441
|
+
mentionable: RosterMember[];
|
|
442
|
+
run: (fn: () => Promise<unknown>) => Promise<boolean>;
|
|
443
|
+
} & Pick<ChangesRailProps, 'onReply' | 'onResolve' | 'onReopen'>) {
|
|
444
|
+
const [replying, setReplying] = useState(false);
|
|
445
|
+
return (
|
|
446
|
+
<div className="flex flex-col gap-1.5" data-testid="rail-thread">
|
|
447
|
+
{t.quote ? (
|
|
448
|
+
<p className="text-xs text-muted-foreground">
|
|
449
|
+
On <mark className="bg-yellow-400/30 text-inherit">{quoted(t.quote)}</mark>
|
|
450
|
+
</p>
|
|
451
|
+
) : (
|
|
452
|
+
<p className="text-xs text-muted-foreground">{t.field ? 'On the whole field' : 'On the whole record'}</p>
|
|
453
|
+
)}
|
|
454
|
+
<ol className="flex flex-col gap-1.5">
|
|
455
|
+
{t.comments.map((c) => (
|
|
456
|
+
<li key={c.id} className="flex flex-col gap-0.5" title={actorKindTitle(c.author.kind, c.author.kindSource)}>
|
|
457
|
+
<Author actor={c.author} />
|
|
458
|
+
<CommentBody c={c} mentionable={mentionable} />
|
|
459
|
+
</li>
|
|
460
|
+
))}
|
|
461
|
+
</ol>
|
|
462
|
+
{t.resolved && t.resolution && (
|
|
463
|
+
<p className="text-[11px] text-muted-foreground">Resolved by {who(t.resolution.by)}</p>
|
|
464
|
+
)}
|
|
465
|
+
{canComment && (
|
|
466
|
+
<div className="flex flex-wrap gap-1">
|
|
467
|
+
{!t.resolved && onReply && !replying && (
|
|
468
|
+
<button type="button" className={btn} disabled={busy} onClick={() => setReplying(true)}>
|
|
469
|
+
Reply
|
|
470
|
+
</button>
|
|
471
|
+
)}
|
|
472
|
+
{!t.resolved && onResolve && (
|
|
473
|
+
<button type="button" className={btn} disabled={busy} onClick={() => void run(() => onResolve(t.id))}>
|
|
474
|
+
Resolve
|
|
475
|
+
</button>
|
|
476
|
+
)}
|
|
477
|
+
{t.resolved && onReopen && (
|
|
478
|
+
<button type="button" className={btn} disabled={busy} onClick={() => void run(() => onReopen(t.id))}>
|
|
479
|
+
Reopen
|
|
480
|
+
</button>
|
|
481
|
+
)}
|
|
482
|
+
</div>
|
|
483
|
+
)}
|
|
484
|
+
{replying && onReply && (
|
|
485
|
+
<Composer
|
|
486
|
+
label="Reply"
|
|
487
|
+
submitLabel="Reply"
|
|
488
|
+
mentionable={mentionable}
|
|
489
|
+
busy={busy}
|
|
490
|
+
autoFocus
|
|
491
|
+
onCancel={() => setReplying(false)}
|
|
492
|
+
onSubmit={async (body, mentions) => {
|
|
493
|
+
const ok = await run(() => onReply(t.id, body, mentions));
|
|
494
|
+
if (ok) setReplying(false);
|
|
495
|
+
return ok;
|
|
496
|
+
}}
|
|
497
|
+
/>
|
|
498
|
+
)}
|
|
499
|
+
{error && (
|
|
500
|
+
<p role="alert" className="text-xs text-destructive">
|
|
501
|
+
{error}
|
|
502
|
+
</p>
|
|
503
|
+
)}
|
|
504
|
+
</div>
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function NewComment({
|
|
509
|
+
fieldLabel,
|
|
510
|
+
selection,
|
|
511
|
+
mentionable,
|
|
512
|
+
mentionNotice,
|
|
513
|
+
busy,
|
|
514
|
+
error,
|
|
515
|
+
onSubmit,
|
|
516
|
+
}: {
|
|
517
|
+
fieldLabel: string;
|
|
518
|
+
selection: SelectionAnchor | null;
|
|
519
|
+
mentionable: RosterMember[];
|
|
520
|
+
mentionNotice?: string | null;
|
|
521
|
+
busy: boolean;
|
|
522
|
+
error?: string;
|
|
523
|
+
onSubmit: (body: string, mentions: string[]) => Promise<boolean>;
|
|
524
|
+
}) {
|
|
525
|
+
const on = selection && selection.to > selection.from ? selection : null;
|
|
526
|
+
return (
|
|
527
|
+
<div className="flex flex-col gap-1 rounded-md border border-border p-2" data-testid="new-comment">
|
|
528
|
+
<p className="text-xs text-muted-foreground">
|
|
529
|
+
{on ? (
|
|
530
|
+
<>
|
|
531
|
+
Comment on <mark className="bg-yellow-400/30 text-inherit">{quoted(on.quote)}</mark>
|
|
532
|
+
</>
|
|
533
|
+
) : (
|
|
534
|
+
`Comment on ${fieldLabel} (select text first to comment on a passage)`
|
|
535
|
+
)}
|
|
536
|
+
</p>
|
|
537
|
+
<Composer label={`New comment on ${fieldLabel}`} submitLabel="Comment" mentionable={mentionable} busy={busy} onSubmit={onSubmit} />
|
|
538
|
+
{mentionNotice && <p className="text-[11px] text-muted-foreground">{mentionNotice}</p>}
|
|
539
|
+
<p className="text-[11px] text-muted-foreground">Type @ to mention a colleague. Mentions show here only; nobody is emailed.</p>
|
|
540
|
+
{error && (
|
|
541
|
+
<p role="alert" className="text-xs text-destructive">
|
|
542
|
+
{error}
|
|
543
|
+
</p>
|
|
544
|
+
)}
|
|
545
|
+
</div>
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** The @query being typed just before the caret, or null. */
|
|
550
|
+
export function mentionQuery(text: string, caret: number): { start: number; query: string } | null {
|
|
551
|
+
const m = /(^|\s)@([^\s@]*)$/.exec(text.slice(0, caret));
|
|
552
|
+
if (!m) return null;
|
|
553
|
+
return { start: caret - m[2].length - 1, query: m[2] };
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** The subs a body still mentions: picked, and their `@label` still present. */
|
|
557
|
+
export function mentionsIn(body: string, picked: RosterMember[]): string[] {
|
|
558
|
+
const out: string[] = [];
|
|
559
|
+
for (const m of picked) {
|
|
560
|
+
const tag = `@${m.label || m.sub}`;
|
|
561
|
+
if (body.includes(tag) && !out.includes(m.sub)) out.push(m.sub);
|
|
562
|
+
}
|
|
563
|
+
return out;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function Composer({
|
|
567
|
+
label,
|
|
568
|
+
submitLabel,
|
|
569
|
+
mentionable,
|
|
570
|
+
busy,
|
|
571
|
+
autoFocus,
|
|
572
|
+
onSubmit,
|
|
573
|
+
onCancel,
|
|
574
|
+
}: {
|
|
575
|
+
label: string;
|
|
576
|
+
submitLabel: string;
|
|
577
|
+
mentionable: RosterMember[];
|
|
578
|
+
busy: boolean;
|
|
579
|
+
autoFocus?: boolean;
|
|
580
|
+
/** Resolves false when the write failed: the text is kept. */
|
|
581
|
+
onSubmit: (body: string, mentions: string[]) => Promise<boolean>;
|
|
582
|
+
onCancel?: () => void;
|
|
583
|
+
}): ReactNode {
|
|
584
|
+
const [body, setBody] = useState('');
|
|
585
|
+
const [picked, setPicked] = useState<RosterMember[]>([]);
|
|
586
|
+
const [q, setQ] = useState<{ start: number; query: string } | null>(null);
|
|
587
|
+
const [hi, setHi] = useState(0);
|
|
588
|
+
const ref = useRef<HTMLTextAreaElement>(null);
|
|
589
|
+
const listId = `mention-${useId()}`;
|
|
590
|
+
|
|
591
|
+
const matches = useMemo(() => {
|
|
592
|
+
if (!q) return [];
|
|
593
|
+
const needle = q.query.toLowerCase();
|
|
594
|
+
return mentionable.filter((m) => (m.label || m.sub).toLowerCase().includes(needle)).slice(0, 8);
|
|
595
|
+
}, [q, mentionable]);
|
|
596
|
+
|
|
597
|
+
const choose = (m: RosterMember) => {
|
|
598
|
+
if (!q) return;
|
|
599
|
+
const el = ref.current;
|
|
600
|
+
const caret = el?.selectionStart ?? body.length;
|
|
601
|
+
const tag = `@${m.label || m.sub} `;
|
|
602
|
+
const next = body.slice(0, q.start) + tag + body.slice(caret);
|
|
603
|
+
setBody(next);
|
|
604
|
+
setPicked((p) => (p.some((x) => x.sub === m.sub) ? p : [...p, m]));
|
|
605
|
+
setQ(null);
|
|
606
|
+
requestAnimationFrame(() => {
|
|
607
|
+
const pos = q.start + tag.length;
|
|
608
|
+
el?.focus();
|
|
609
|
+
el?.setSelectionRange(pos, pos);
|
|
610
|
+
});
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
const submit = async () => {
|
|
614
|
+
const text = body.trim();
|
|
615
|
+
if (!text) return;
|
|
616
|
+
if (!(await onSubmit(text, mentionsIn(text, picked)))) return;
|
|
617
|
+
setBody('');
|
|
618
|
+
setPicked([]);
|
|
619
|
+
};
|
|
620
|
+
|
|
621
|
+
return (
|
|
622
|
+
<div className="relative flex flex-col gap-1">
|
|
623
|
+
<textarea
|
|
624
|
+
ref={ref}
|
|
625
|
+
aria-label={label}
|
|
626
|
+
value={body}
|
|
627
|
+
rows={2}
|
|
628
|
+
autoFocus={autoFocus}
|
|
629
|
+
role="combobox"
|
|
630
|
+
aria-autocomplete="list"
|
|
631
|
+
aria-expanded={matches.length > 0}
|
|
632
|
+
aria-controls={listId}
|
|
633
|
+
className="w-full resize-y rounded-md border border-input bg-background p-2 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
634
|
+
onChange={(e) => {
|
|
635
|
+
setBody(e.target.value);
|
|
636
|
+
setQ(mentionQuery(e.target.value, e.target.selectionStart ?? e.target.value.length));
|
|
637
|
+
setHi(0);
|
|
638
|
+
}}
|
|
639
|
+
onKeyDown={(e) => {
|
|
640
|
+
if (matches.length > 0) {
|
|
641
|
+
if (e.key === 'ArrowDown') setHi((h) => (h + 1) % matches.length);
|
|
642
|
+
else if (e.key === 'ArrowUp') setHi((h) => (h - 1 + matches.length) % matches.length);
|
|
643
|
+
else if (e.key === 'Enter' || e.key === 'Tab') choose(matches[hi]);
|
|
644
|
+
else if (e.key === 'Escape') setQ(null);
|
|
645
|
+
else return;
|
|
646
|
+
e.preventDefault();
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
650
|
+
e.preventDefault();
|
|
651
|
+
void submit();
|
|
652
|
+
}
|
|
653
|
+
if (e.key === 'Escape' && onCancel) onCancel();
|
|
654
|
+
e.stopPropagation();
|
|
655
|
+
}}
|
|
656
|
+
/>
|
|
657
|
+
{matches.length > 0 && (
|
|
658
|
+
<ul id={listId} role="listbox" aria-label="Colleagues" className="absolute top-full z-10 mt-1 w-full rounded-md border border-border bg-popover p-1 text-xs shadow-md">
|
|
659
|
+
{matches.map((m, i) => (
|
|
660
|
+
<li
|
|
661
|
+
key={m.sub}
|
|
662
|
+
role="option"
|
|
663
|
+
aria-selected={i === hi}
|
|
664
|
+
className={`cursor-pointer rounded px-2 py-1 ${i === hi ? 'bg-muted' : ''}`}
|
|
665
|
+
onMouseDown={(e) => {
|
|
666
|
+
e.preventDefault();
|
|
667
|
+
choose(m);
|
|
668
|
+
}}
|
|
669
|
+
>
|
|
670
|
+
{m.label || m.sub}
|
|
671
|
+
</li>
|
|
672
|
+
))}
|
|
673
|
+
</ul>
|
|
674
|
+
)}
|
|
675
|
+
<div className="flex gap-1">
|
|
676
|
+
<button type="button" className={primaryBtn} disabled={busy || !body.trim()} onClick={() => void submit()}>
|
|
677
|
+
{submitLabel}
|
|
678
|
+
</button>
|
|
679
|
+
{onCancel && (
|
|
680
|
+
<button type="button" className={btn} onClick={onCancel}>
|
|
681
|
+
Cancel
|
|
682
|
+
</button>
|
|
683
|
+
)}
|
|
684
|
+
</div>
|
|
685
|
+
</div>
|
|
686
|
+
);
|
|
687
|
+
}
|