@startsimpli/ui 0.4.143 → 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.
@@ -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
- load(c.listComments && allPages(c.listComments), setComm, 'comments');
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
  }