@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.
@@ -0,0 +1,407 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * useTrackChanges — the ONE owner of the injected client for a tracked field
5
+ * (bd startsim-sjuai).
6
+ *
7
+ * It reads blame, suggestions and comments for one field at one version,
8
+ * normalizes them against the stored text (UTF-16 offsets, both spellings),
9
+ * and posts suggesting-mode proposals. The editor is presentational over its
10
+ * result, and the changes rail (startsim-whwxd.3) reads the SAME result, so
11
+ * the rail and the editor can never show two different sets of suggestions or
12
+ * fetch them twice.
13
+ *
14
+ * HONEST STATES. A read that fails is not an empty list: it lands in `notices`
15
+ * as a sentence the host shows. A 404 means the reader may not see this
16
+ * record or field (the routes are nested under the record's gate). A response
17
+ * for a version other than the one loaded is held back rather than painted,
18
+ * because its offsets describe different text.
19
+ */
20
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
21
+
22
+ import { blameNotice, normalizeBlame, normalizeRoster, normalizeSuggestions, normalizeThreads } from './normalize';
23
+ import type {
24
+ AcceptResponseWire,
25
+ BlameResponseWire,
26
+ BlameSpan,
27
+ CommentPageWire,
28
+ CommentThread,
29
+ CommentRequest,
30
+ CommentThreadWire,
31
+ RosterMember,
32
+ RosterPageWire,
33
+ Suggestion,
34
+ SuggestionPageWire,
35
+ SuggestionWire,
36
+ TrackChangesClient,
37
+ } from './types';
38
+
39
+ export type SourceState = 'off' | 'loading' | 'ready' | 'error';
40
+
41
+ export interface SourceStatus {
42
+ state: SourceState;
43
+ /** A sentence for the reader when `state` is `error`. */
44
+ message?: string;
45
+ }
46
+
47
+ export interface UseTrackChangesOptions {
48
+ client: TrackChangesClient;
49
+ /** The record's field, as declared (`blog`, `linkedin`). */
50
+ field: string;
51
+ /** The stored value at `version`: the text every offset refers to. */
52
+ value: string;
53
+ /** The record version `value` was read at; `null` before the record has
54
+ * one (then nothing is read and nothing can be proposed). */
55
+ version: number | null;
56
+ /** Off: read nothing (e.g. while the record is loading). Default on. */
57
+ enabled?: boolean;
58
+ }
59
+
60
+ export interface TrackChanges {
61
+ blame: BlameSpan[];
62
+ suggestions: Suggestion[];
63
+ threads: CommentThread[];
64
+ status: { blame: SourceStatus; suggestions: SourceStatus; comments: SourceStatus };
65
+ /** One sentence per source that could not be read. Never empty-by-failure. */
66
+ notices: string[];
67
+ /** Re-read all three (after an accept, a reply, a resolve). */
68
+ reload: () => void;
69
+ /** Post the whole proposed text against `version`. Rejects on failure, so
70
+ * the editor keeps the proposal and says it was not sent. */
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;
88
+ }
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
+
145
+ interface Loaded<T> {
146
+ status: SourceStatus;
147
+ data: T | null;
148
+ }
149
+
150
+ const OFF: Loaded<never> = { status: { state: 'off' }, data: null };
151
+
152
+ function statusCode(err: unknown): number | undefined {
153
+ const e = err as { status?: unknown; response?: { status?: unknown } } | null;
154
+ const s = e?.status ?? e?.response?.status;
155
+ return typeof s === 'number' ? s : undefined;
156
+ }
157
+
158
+ function detail(err: unknown): string {
159
+ const e = err as { body?: { detail?: unknown }; data?: { detail?: unknown }; message?: unknown } | null;
160
+ const d = e?.body?.detail ?? e?.data?.detail ?? e?.message;
161
+ return typeof d === 'string' && d.trim() ? d.trim() : 'the server did not say why';
162
+ }
163
+
164
+ function failure(what: string, err: unknown): SourceStatus {
165
+ // 404: the record is unreadable. 403: the field is withheld from this
166
+ // reader (the contract refuses narrowing to a withheld field).
167
+ const code = statusCode(err);
168
+ if (code === 404 || code === 403) {
169
+ return { state: 'error', message: `You cannot see ${what} for this field.` };
170
+ }
171
+ return { state: 'error', message: `Could not load ${what}: ${detail(err)}.` };
172
+ }
173
+
174
+ function versionOf(w: unknown): number | null {
175
+ if (!w || Array.isArray(w) || typeof w !== 'object') return null;
176
+ const v = (w as { version?: unknown }).version;
177
+ return typeof v === 'number' ? v : null;
178
+ }
179
+
180
+ /** A response is used only if it describes the loaded version (or says
181
+ * nothing about versions at all). */
182
+ function matches(w: unknown, version: number | null): boolean {
183
+ const v = versionOf(w);
184
+ return v == null || v === version;
185
+ }
186
+
187
+ type Paged<T> = { results?: T[] | null; next_cursor?: string | null; nextCursor?: string | null } | T[];
188
+
189
+ /** Read every page, following `next_cursor`, into one page. Stops if the
190
+ * server hands back a cursor it already gave (a loop, not more data). */
191
+ function allPages<T, P extends Paged<T>>(
192
+ fn: (q: { field: string; cursor?: string }) => Promise<P>,
193
+ ): (q: { field: string }) => Promise<P> {
194
+ return async ({ field }) => {
195
+ const first = await fn({ field });
196
+ if (Array.isArray(first)) return first;
197
+ const all: T[] = [...(first.results ?? [])];
198
+ const seen = new Set<string>();
199
+ let cursor = first.next_cursor ?? first.nextCursor ?? null;
200
+ while (cursor && !seen.has(cursor)) {
201
+ seen.add(cursor);
202
+ const page = await fn({ field, cursor });
203
+ if (Array.isArray(page)) {
204
+ all.push(...(page as T[]));
205
+ break;
206
+ }
207
+ all.push(...(page.results ?? []));
208
+ cursor = page.next_cursor ?? page.nextCursor ?? null;
209
+ }
210
+ return { ...first, results: all, next_cursor: null, nextCursor: null };
211
+ };
212
+ }
213
+
214
+ export function useTrackChanges({
215
+ client,
216
+ field,
217
+ value,
218
+ version,
219
+ enabled = true,
220
+ }: UseTrackChangesOptions): TrackChanges {
221
+ const [blame, setBlame] = useState<Loaded<BlameResponseWire>>(OFF);
222
+ const [sugg, setSugg] = useState<Loaded<SuggestionPageWire | SuggestionWire[]>>(OFF);
223
+ const [comm, setComm] = useState<Loaded<CommentPageWire | CommentThreadWire[]>>(OFF);
224
+ const [tick, setTick] = useState(0);
225
+ const clientRef = useRef(client);
226
+ clientRef.current = client;
227
+
228
+ useEffect(() => {
229
+ if (!enabled || version == null) {
230
+ setBlame(OFF);
231
+ setSugg(OFF);
232
+ setComm(OFF);
233
+ return;
234
+ }
235
+ let live = true;
236
+ const c = clientRef.current;
237
+ function load<T>(
238
+ fn: ((q: { field: string }) => Promise<T>) | undefined,
239
+ set: (l: Loaded<T>) => void,
240
+ what: string,
241
+ ) {
242
+ if (!fn) {
243
+ set(OFF);
244
+ return;
245
+ }
246
+ set({ status: { state: 'loading' }, data: null });
247
+ fn({ field }).then(
248
+ (data) => live && set({ status: { state: 'ready' }, data }),
249
+ (err) => live && set({ status: failure(what, err), data: null }),
250
+ );
251
+ }
252
+ load(c.blame, setBlame, 'who wrote each passage');
253
+ load(c.listSuggestions && allPages(c.listSuggestions), setSugg, 'suggestions');
254
+ const lc = c.listComments;
255
+ load(lc && allPages((q) => lc({ ...q, status: 'all' })), setComm, 'comments');
256
+ return () => {
257
+ live = false;
258
+ };
259
+ }, [enabled, field, version, tick]);
260
+
261
+ const reload = useCallback(() => setTick((t) => t + 1), []);
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
+
302
+ const propose = useCallback(
303
+ async (proposed: string, baseVersion: number) => {
304
+ const fn = clientRef.current.propose;
305
+ if (!fn) throw new Error('This host cannot post suggestions.');
306
+ await fn({ field, base_version: baseVersion, proposed });
307
+ setTick((t) => t + 1);
308
+ },
309
+ [field],
310
+ );
311
+
312
+ const normalized = useMemo(
313
+ () => ({
314
+ blame: blame.data && matches(blame.data, version) ? normalizeBlame(blame.data, value) : [],
315
+ suggestions: sugg.data && matches(sugg.data, version) ? normalizeSuggestions(sugg.data, value) : [],
316
+ threads: comm.data && matches(comm.data, version) ? normalizeThreads(comm.data, value) : [],
317
+ }),
318
+ [blame.data, sugg.data, comm.data, value, version],
319
+ );
320
+
321
+ const status = { blame: blame.status, suggestions: sugg.status, comments: comm.status };
322
+ const notices = [status.blame, status.suggestions, status.comments].flatMap((s) =>
323
+ s.state === 'error' && s.message ? [s.message] : [],
324
+ );
325
+ const unrecorded = blame.data ? blameNotice(blame.data) : null;
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]);
405
+
406
+ return { ...normalized, status, notices, reload, propose, stale, mentionable, rosterNotice, actions };
407
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The smallest word-sized change from one value of a field to another, as
3
+ * editor change specs (bd startsim-sjuai).
4
+ *
5
+ * WHAT THIS IS FOR, AND WHAT IT IS NOT. The editor uses it in exactly one
6
+ * place: when the stored value changes underneath it (an accept, another
7
+ * reviewer's save), it applies the difference as a CHANGE rather than
8
+ * replacing the document, so every decoration and the cursor map through it
9
+ * instead of collapsing to the start. It is NOT the diff that decides span
10
+ * boundaries for blame, anchors or suggestion hunks: that is the shared
11
+ * server module (startsim-whwxd.1), and the editor always paints the server's
12
+ * offsets. A disagreement here can only make a mapped mark land a few
13
+ * characters differently until the next read, never paint wrong authorship.
14
+ *
15
+ * REUSES `diffLines` from history/text-diff (rule #1): that LCS takes any
16
+ * `string[]`, so handing it word tokens instead of lines makes it word-level.
17
+ * The text the live drafts carry (design startsim-xyn25 section 0) is one
18
+ * ~500-character line per paragraph, which is why a line diff is not enough.
19
+ */
20
+ import { diffLines } from '../history/text-diff';
21
+
22
+ export interface TextChange {
23
+ from: number;
24
+ to: number;
25
+ insert: string;
26
+ }
27
+
28
+ /** Han, Hiragana and Katakana have no spaces between words, so each character
29
+ * is its own token; otherwise a whole CJK sentence would be one "word". */
30
+ const TOKEN =
31
+ /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}]|[\p{L}\p{N}\p{M}_]+|\s+|[^\s\p{L}\p{N}\p{M}_]/gu;
32
+
33
+ export function tokenize(text: string): string[] {
34
+ return text.match(TOKEN) ?? [];
35
+ }
36
+
37
+ /** Above this many DP cells (tokens x tokens) the middle is replaced whole.
38
+ * A 4 KB blog is ~1,300 tokens, so a whole-text rewrite is ~1.7M cells. */
39
+ const MAX_CELLS = 4_000_000;
40
+
41
+ const isHigh = (c: number) => c >= 0xd800 && c <= 0xdbff;
42
+ const isLow = (c: number) => c >= 0xdc00 && c <= 0xdfff;
43
+
44
+ /** Changes in `a`'s coordinates, sorted and non-overlapping, that turn `a`
45
+ * into `b`. Empty when they are equal. */
46
+ export function wordChanges(a: string, b: string): TextChange[] {
47
+ if (a === b) return [];
48
+ const max = Math.min(a.length, b.length);
49
+ let p = 0;
50
+ while (p < max && a.charCodeAt(p) === b.charCodeAt(p)) p++;
51
+ // Never split a surrogate pair.
52
+ if (p > 0 && isHigh(a.charCodeAt(p - 1))) p--;
53
+ let s = 0;
54
+ while (s < max - p && a.charCodeAt(a.length - 1 - s) === b.charCodeAt(b.length - 1 - s)) s++;
55
+ if (s > 0 && isLow(a.charCodeAt(a.length - s))) s--;
56
+
57
+ const midA = a.slice(p, a.length - s);
58
+ const midB = b.slice(p, b.length - s);
59
+ const tA = tokenize(midA);
60
+ const tB = tokenize(midB);
61
+ // tokenize covers every character (the last class matches anything that is
62
+ // not whitespace, a letter, a number or a mark), so tokens re-join exactly.
63
+ if (tA.join('') !== midA || tB.join('') !== midB || tA.length * tB.length > MAX_CELLS) {
64
+ return [{ from: p, to: a.length - s, insert: midB }];
65
+ }
66
+
67
+ const out: TextChange[] = [];
68
+ let pos = p;
69
+ let pending: TextChange | null = null;
70
+ for (const op of diffLines(tA, tB)) {
71
+ if (op.type === 'eq') {
72
+ if (pending) out.push(pending);
73
+ pending = null;
74
+ pos += op.line.length;
75
+ } else if (op.type === 'del') {
76
+ pending = pending ?? { from: pos, to: pos, insert: '' };
77
+ pending.to += op.line.length;
78
+ pos += op.line.length;
79
+ } else {
80
+ pending = pending ?? { from: pos, to: pos, insert: '' };
81
+ pending.insert += op.line;
82
+ }
83
+ }
84
+ if (pending) out.push(pending);
85
+ return out;
86
+ }
87
+
88
+ /** Apply sorted, non-overlapping changes expressed in `text`'s coordinates. */
89
+ export function applyTextChanges(text: string, changes: TextChange[]): string {
90
+ let out = '';
91
+ let last = 0;
92
+ for (const c of changes) {
93
+ out += text.slice(last, c.from) + c.insert;
94
+ last = c.to;
95
+ }
96
+ return out + text.slice(last);
97
+ }