@startsimpli/ui 0.4.143 → 0.4.145
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 +1 -1
- package/src/components/blog/MarkdownRenderer.tsx +23 -5
- package/src/components/track-changes/ChangesRail.tsx +697 -0
- package/src/components/track-changes/TrackedField.tsx +293 -0
- package/src/components/track-changes/TrackedReadView.tsx +273 -0
- package/src/components/track-changes/__tests__/rails.test.tsx +616 -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 +18 -2
- package/src/components/track-changes/normalize.ts +29 -0
- package/src/components/track-changes/read-marks.ts +262 -0
- package/src/components/track-changes/types.ts +79 -1
- package/src/components/track-changes/useTrackChanges.ts +196 -3
|
@@ -19,13 +19,17 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
21
21
|
|
|
22
|
-
import { blameNotice, normalizeBlame, normalizeSuggestions, normalizeThreads } from './normalize';
|
|
22
|
+
import { blameNotice, normalizeBlame, normalizeRoster, normalizeSuggestions, normalizeThreads } from './normalize';
|
|
23
23
|
import type {
|
|
24
|
+
AcceptResponseWire,
|
|
24
25
|
BlameResponseWire,
|
|
25
26
|
BlameSpan,
|
|
26
27
|
CommentPageWire,
|
|
27
28
|
CommentThread,
|
|
29
|
+
CommentRequest,
|
|
28
30
|
CommentThreadWire,
|
|
31
|
+
RosterMember,
|
|
32
|
+
RosterPageWire,
|
|
29
33
|
Suggestion,
|
|
30
34
|
SuggestionPageWire,
|
|
31
35
|
SuggestionWire,
|
|
@@ -65,8 +69,79 @@ export interface TrackChanges {
|
|
|
65
69
|
/** Post the whole proposed text against `version`. Rejects on failure, so
|
|
66
70
|
* the editor keeps the proposal and says it was not sent. */
|
|
67
71
|
propose: (proposed: string, baseVersion: number) => Promise<void>;
|
|
72
|
+
/** True when a read came back for a DIFFERENT version than the one loaded:
|
|
73
|
+
* someone changed the record. Its offsets describe other text, so it is
|
|
74
|
+
* not painted, and `notices` says so (never a silent empty list). */
|
|
75
|
+
stale: boolean;
|
|
76
|
+
/** Colleagues who may be @mentioned: the org roster, plus everyone already
|
|
77
|
+
* on this field's trail (blame, suggestions, comments), deduped by `sub`.
|
|
78
|
+
* The trail half is what keeps mentions working when the roster read is
|
|
79
|
+
* unavailable. */
|
|
80
|
+
mentionable: RosterMember[];
|
|
81
|
+
/** Why the org roster could not be read (a viewer-role token gets central's
|
|
82
|
+
* 403 relayed), or null. Mentions still offer the people on the trail. */
|
|
83
|
+
rosterNotice: string | null;
|
|
84
|
+
/** The rail's writes. Each rejects with the server's sentence on failure and
|
|
85
|
+
* re-reads all three sources on success. Absent when the client has not
|
|
86
|
+
* bound the call. */
|
|
87
|
+
actions: TrackChangesActions;
|
|
68
88
|
}
|
|
69
89
|
|
|
90
|
+
export interface TrackChangesActions {
|
|
91
|
+
/** Resolves the accept answer: the RECORD at its new version. The host
|
|
92
|
+
* must adopt it (text and version) before any other write. */
|
|
93
|
+
accept?: (ids: string[], expectedVersion: number) => Promise<AcceptResponseWire>;
|
|
94
|
+
reject?: (ids: string[], reason?: string) => Promise<void>;
|
|
95
|
+
withdraw?: (id: string) => Promise<void>;
|
|
96
|
+
comment?: (req: CommentRequest) => Promise<void>;
|
|
97
|
+
reply?: (threadId: string, body: string, mentions: string[]) => Promise<void>;
|
|
98
|
+
resolve?: (threadId: string) => Promise<void>;
|
|
99
|
+
reopen?: (threadId: string) => Promise<void>;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A failed write, with the server's own sentence when it gave one. */
|
|
103
|
+
export class TrackChangesActionError extends Error {
|
|
104
|
+
readonly status?: number;
|
|
105
|
+
readonly reason?: string;
|
|
106
|
+
constructor(message: string, status?: number, reason?: string) {
|
|
107
|
+
super(message);
|
|
108
|
+
this.name = 'TrackChangesActionError';
|
|
109
|
+
this.status = status;
|
|
110
|
+
this.reason = reason;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const REASON_TEXT: Record<string, string> = {
|
|
115
|
+
conflicted: 'the text under this suggestion changed, so it no longer applies',
|
|
116
|
+
orphaned: 'the text this suggestion was made on is gone',
|
|
117
|
+
not_open: 'it was already accepted, rejected or withdrawn',
|
|
118
|
+
machine_actor: 'machine accounts cannot suggest or comment',
|
|
119
|
+
history_disabled: 'this record keeps no history, so it cannot carry suggestions or comments',
|
|
120
|
+
already_resolved: 'it is already resolved',
|
|
121
|
+
not_resolved: 'it is not resolved',
|
|
122
|
+
base_unknown: 'the text it was made on can no longer be rebuilt',
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function actionError(what: string, err: unknown): TrackChangesActionError {
|
|
126
|
+
const code = statusCode(err);
|
|
127
|
+
const e = err as { body?: { reason?: unknown; detail?: unknown } } | null;
|
|
128
|
+
const reason = typeof e?.body?.reason === 'string' ? e.body.reason : undefined;
|
|
129
|
+
if (code === 412) {
|
|
130
|
+
return new TrackChangesActionError(
|
|
131
|
+
`Could not ${what}: someone changed this record since you loaded it. Reload, then try again.`,
|
|
132
|
+
code,
|
|
133
|
+
reason,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
if (code === 403 && !reason) return new TrackChangesActionError(`You may not ${what} here.`, code);
|
|
137
|
+
const why = (reason && REASON_TEXT[reason]) || detail(err);
|
|
138
|
+
return new TrackChangesActionError(`Could not ${what}: ${why}.`, code, reason);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Roster pages read before giving up on more (a guard, not a limit anyone
|
|
142
|
+
* should reach: central serves 100 per page). */
|
|
143
|
+
const ROSTER_MAX_PAGES = 50;
|
|
144
|
+
|
|
70
145
|
interface Loaded<T> {
|
|
71
146
|
status: SourceStatus;
|
|
72
147
|
data: T | null;
|
|
@@ -176,7 +251,8 @@ export function useTrackChanges({
|
|
|
176
251
|
}
|
|
177
252
|
load(c.blame, setBlame, 'who wrote each passage');
|
|
178
253
|
load(c.listSuggestions && allPages(c.listSuggestions), setSugg, 'suggestions');
|
|
179
|
-
|
|
254
|
+
const lc = c.listComments;
|
|
255
|
+
load(lc && allPages((q) => lc({ ...q, status: 'all' })), setComm, 'comments');
|
|
180
256
|
return () => {
|
|
181
257
|
live = false;
|
|
182
258
|
};
|
|
@@ -184,6 +260,45 @@ export function useTrackChanges({
|
|
|
184
260
|
|
|
185
261
|
const reload = useCallback(() => setTick((t) => t + 1), []);
|
|
186
262
|
|
|
263
|
+
// The roster is the org's, not the field's: read once per client.
|
|
264
|
+
const [roster, setRoster] = useState<RosterMember[]>([]);
|
|
265
|
+
const [rosterNotice, setRosterNotice] = useState<string | null>(null);
|
|
266
|
+
const rosterFn = client.roster;
|
|
267
|
+
useEffect(() => {
|
|
268
|
+
if (!enabled || !rosterFn) return;
|
|
269
|
+
let live = true;
|
|
270
|
+
(async () => {
|
|
271
|
+
const pages: RosterPageWire[] = [];
|
|
272
|
+
let cursor: string | undefined;
|
|
273
|
+
const seen = new Set<string>();
|
|
274
|
+
for (let i = 0; i < ROSTER_MAX_PAGES; i++) {
|
|
275
|
+
const page = await rosterFn(cursor ? { cursor } : {});
|
|
276
|
+
pages.push(page);
|
|
277
|
+
const next = page?.next_cursor ?? page?.nextCursor ?? null;
|
|
278
|
+
if (!next || seen.has(next)) break;
|
|
279
|
+
seen.add(next);
|
|
280
|
+
cursor = next;
|
|
281
|
+
}
|
|
282
|
+
if (live) {
|
|
283
|
+
setRoster(normalizeRoster(pages));
|
|
284
|
+
setRosterNotice(null);
|
|
285
|
+
}
|
|
286
|
+
})().catch((err) => {
|
|
287
|
+
// Mentions fall back to the people on the trail; a roster that cannot be
|
|
288
|
+
// read is not a reason to stop anyone commenting, but it is said.
|
|
289
|
+
if (!live) return;
|
|
290
|
+
const code = statusCode(err);
|
|
291
|
+
setRosterNotice(
|
|
292
|
+
code === 403
|
|
293
|
+
? 'Your account cannot read the colleague list, so you can only @mention people already on this draft.'
|
|
294
|
+
: 'The colleague list could not be loaded, so you can only @mention people already on this draft.',
|
|
295
|
+
);
|
|
296
|
+
});
|
|
297
|
+
return () => {
|
|
298
|
+
live = false;
|
|
299
|
+
};
|
|
300
|
+
}, [enabled, rosterFn]);
|
|
301
|
+
|
|
187
302
|
const propose = useCallback(
|
|
188
303
|
async (proposed: string, baseVersion: number) => {
|
|
189
304
|
const fn = clientRef.current.propose;
|
|
@@ -209,6 +324,84 @@ export function useTrackChanges({
|
|
|
209
324
|
);
|
|
210
325
|
const unrecorded = blame.data ? blameNotice(blame.data) : null;
|
|
211
326
|
if (unrecorded) notices.unshift(unrecorded);
|
|
327
|
+
const stale = [blame.data, sugg.data, comm.data].some((d) => d != null && !matches(d, version));
|
|
328
|
+
if (stale) {
|
|
329
|
+
notices.unshift(
|
|
330
|
+
'Someone changed this record since you opened it, so its suggestions and comments are not shown. Reload to see the latest.',
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const mentionable = useMemo(() => {
|
|
335
|
+
const bySub = new Map<string, RosterMember>();
|
|
336
|
+
for (const m of roster) bySub.set(m.sub, m);
|
|
337
|
+
const add = (a: { sub: string; label: string; kind: string } | null | undefined) => {
|
|
338
|
+
if (!a || !a.sub || a.kind === 'machine' || bySub.has(a.sub)) return;
|
|
339
|
+
bySub.set(a.sub, { sub: a.sub, label: a.label });
|
|
340
|
+
};
|
|
341
|
+
for (const b of normalized.blame) add(b.author);
|
|
342
|
+
for (const s of normalized.suggestions) add(s.author);
|
|
343
|
+
for (const t of normalized.threads) for (const c of t.comments) add(c.author);
|
|
344
|
+
return [...bySub.values()].sort((a, b) => (a.label || a.sub).localeCompare(b.label || b.sub));
|
|
345
|
+
}, [roster, normalized]);
|
|
346
|
+
|
|
347
|
+
const actions = useMemo<TrackChangesActions>(() => {
|
|
348
|
+
const c = client;
|
|
349
|
+
const run = async <T,>(what: string, fn: () => Promise<T>): Promise<T> => {
|
|
350
|
+
let out: T;
|
|
351
|
+
try {
|
|
352
|
+
out = await fn();
|
|
353
|
+
} catch (err) {
|
|
354
|
+
throw actionError(what, err);
|
|
355
|
+
}
|
|
356
|
+
setTick((t) => t + 1);
|
|
357
|
+
return out;
|
|
358
|
+
};
|
|
359
|
+
const a: TrackChangesActions = {};
|
|
360
|
+
if (c.accept) {
|
|
361
|
+
const f = c.accept;
|
|
362
|
+
a.accept = (ids, expectedVersion) =>
|
|
363
|
+
run(ids.length > 1 ? 'accept these suggestions' : 'accept this suggestion', () => f({ ids, expectedVersion }));
|
|
364
|
+
}
|
|
365
|
+
if (c.reject) {
|
|
366
|
+
const f = c.reject;
|
|
367
|
+
a.reject = async (ids, reason) => {
|
|
368
|
+
await run(ids.length > 1 ? 'reject these suggestions' : 'reject this suggestion', () =>
|
|
369
|
+
f(reason ? { ids, reason } : { ids }),
|
|
370
|
+
);
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
if (c.withdraw) {
|
|
374
|
+
const f = c.withdraw;
|
|
375
|
+
a.withdraw = async (id) => {
|
|
376
|
+
await run('withdraw your suggestion', () => f(id));
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
if (c.comment) {
|
|
380
|
+
const f = c.comment;
|
|
381
|
+
a.comment = async (req) => {
|
|
382
|
+
await run('add the comment', () => f(req));
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
if (c.reply) {
|
|
386
|
+
const f = c.reply;
|
|
387
|
+
a.reply = async (threadId, body, mentions) => {
|
|
388
|
+
await run('reply', () => f(threadId, mentions.length ? { body, mentions } : { body }));
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
if (c.resolveThread) {
|
|
392
|
+
const f = c.resolveThread;
|
|
393
|
+
a.resolve = async (id) => {
|
|
394
|
+
await run('resolve the thread', () => f(id));
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
if (c.reopenThread) {
|
|
398
|
+
const f = c.reopenThread;
|
|
399
|
+
a.reopen = async (id) => {
|
|
400
|
+
await run('reopen the thread', () => f(id));
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
return a;
|
|
404
|
+
}, [client]);
|
|
212
405
|
|
|
213
|
-
return { ...normalized, status, notices, reload, propose };
|
|
406
|
+
return { ...normalized, status, notices, reload, propose, stale, mentionable, rosterNotice, actions };
|
|
214
407
|
}
|