@beyondwork/docx-react-component 1.0.41 → 1.0.42

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.
Files changed (88) hide show
  1. package/package.json +13 -1
  2. package/src/api/awareness-identity-types.ts +35 -0
  3. package/src/api/comment-negotiation-types.ts +130 -0
  4. package/src/api/comment-presentation-types.ts +106 -0
  5. package/src/api/external-custody-types.ts +74 -0
  6. package/src/api/participants-types.ts +18 -0
  7. package/src/api/public-types.ts +347 -4
  8. package/src/api/scope-metadata-resolver-types.ts +88 -0
  9. package/src/core/commands/formatting-commands.ts +1 -1
  10. package/src/core/commands/index.ts +568 -1
  11. package/src/index.ts +118 -1
  12. package/src/io/export/escape-xml-attribute.ts +26 -0
  13. package/src/io/export/external-send.ts +188 -0
  14. package/src/io/export/serialize-comments.ts +13 -16
  15. package/src/io/export/serialize-footnotes.ts +17 -24
  16. package/src/io/export/serialize-headers-footers.ts +17 -24
  17. package/src/io/export/serialize-main-document.ts +59 -62
  18. package/src/io/export/serialize-numbering.ts +20 -27
  19. package/src/io/export/serialize-runtime-revisions.ts +2 -9
  20. package/src/io/export/serialize-tables.ts +8 -15
  21. package/src/io/export/table-properties-xml.ts +25 -32
  22. package/src/io/import/external-reimport.ts +40 -0
  23. package/src/io/ooxml/bw-xml.ts +244 -0
  24. package/src/io/ooxml/canonicalize-payload.ts +301 -0
  25. package/src/io/ooxml/comment-negotiation-payload.ts +288 -0
  26. package/src/io/ooxml/comment-presentation-payload.ts +311 -0
  27. package/src/io/ooxml/external-custody-payload.ts +102 -0
  28. package/src/io/ooxml/participants-payload.ts +97 -0
  29. package/src/io/ooxml/payload-signature.ts +112 -0
  30. package/src/io/ooxml/workflow-payload-validator.ts +271 -0
  31. package/src/io/ooxml/workflow-payload.ts +146 -7
  32. package/src/runtime/awareness-identity.ts +173 -0
  33. package/src/runtime/collab/event-types.ts +27 -0
  34. package/src/runtime/collab-session-bridge.ts +157 -0
  35. package/src/runtime/collab-session-facet.ts +193 -0
  36. package/src/runtime/collab-session.ts +273 -0
  37. package/src/runtime/comment-negotiation-sync.ts +91 -0
  38. package/src/runtime/comment-negotiation.ts +158 -0
  39. package/src/runtime/comment-presentation.ts +223 -0
  40. package/src/runtime/document-runtime.ts +280 -93
  41. package/src/runtime/external-send-runtime.ts +117 -0
  42. package/src/runtime/layout/docx-font-loader.ts +11 -30
  43. package/src/runtime/layout/inert-layout-facet.ts +2 -0
  44. package/src/runtime/layout/layout-engine-instance.ts +122 -12
  45. package/src/runtime/layout/page-graph.ts +79 -7
  46. package/src/runtime/layout/paginated-layout-engine.ts +230 -34
  47. package/src/runtime/layout/public-facet.ts +185 -13
  48. package/src/runtime/layout/table-row-split.ts +316 -0
  49. package/src/runtime/markdown-sanitizer.ts +132 -0
  50. package/src/runtime/participants.ts +134 -0
  51. package/src/runtime/resign-payload.ts +120 -0
  52. package/src/runtime/tamper-gate.ts +157 -0
  53. package/src/runtime/workflow-markup.ts +9 -0
  54. package/src/runtime/workflow-rail-segments.ts +244 -5
  55. package/src/ui/WordReviewEditor.tsx +587 -0
  56. package/src/ui/editor-runtime-boundary.ts +1 -0
  57. package/src/ui/editor-shell-view.tsx +11 -0
  58. package/src/ui-tailwind/chrome/chrome-preset-model.ts +28 -0
  59. package/src/ui-tailwind/chrome/collab-audience-chip.tsx +73 -0
  60. package/src/ui-tailwind/chrome/collab-negotiation-action-bar.tsx +244 -0
  61. package/src/ui-tailwind/chrome/collab-presence-strip.tsx +150 -0
  62. package/src/ui-tailwind/chrome/collab-role-chip.tsx +62 -0
  63. package/src/ui-tailwind/chrome/collab-send-to-supplier-button.tsx +68 -0
  64. package/src/ui-tailwind/chrome/collab-send-to-supplier-modal.tsx +149 -0
  65. package/src/ui-tailwind/chrome/collab-tamper-banner.tsx +68 -0
  66. package/src/ui-tailwind/chrome/forward-non-drag-click.ts +104 -0
  67. package/src/ui-tailwind/chrome/tw-mode-dock.tsx +1 -0
  68. package/src/ui-tailwind/chrome/tw-selection-tool-host.tsx +7 -1
  69. package/src/ui-tailwind/chrome/tw-table-grip-layer.tsx +1 -38
  70. package/src/ui-tailwind/chrome-overlay/index.ts +6 -0
  71. package/src/ui-tailwind/chrome-overlay/scope-card-role-model.ts +78 -0
  72. package/src/ui-tailwind/chrome-overlay/scope-keyboard-cycle.ts +49 -0
  73. package/src/ui-tailwind/chrome-overlay/tw-chrome-overlay.tsx +58 -0
  74. package/src/ui-tailwind/chrome-overlay/tw-page-stack-overlay-layer.tsx +527 -0
  75. package/src/ui-tailwind/chrome-overlay/tw-scope-card-layer.tsx +120 -22
  76. package/src/ui-tailwind/chrome-overlay/tw-scope-card.tsx +310 -32
  77. package/src/ui-tailwind/chrome-overlay/tw-scope-rail-layer.tsx +93 -14
  78. package/src/ui-tailwind/editor-surface/pm-decorations.ts +35 -1
  79. package/src/ui-tailwind/editor-surface/pm-page-break-decorations.ts +86 -3
  80. package/src/ui-tailwind/editor-surface/pm-schema.ts +15 -13
  81. package/src/ui-tailwind/editor-surface/remote-cursor-plugin.ts +20 -3
  82. package/src/ui-tailwind/editor-surface/tw-page-block-view.tsx +15 -14
  83. package/src/ui-tailwind/editor-surface/tw-table-node-view.tsx +66 -0
  84. package/src/ui-tailwind/index.ts +32 -0
  85. package/src/ui-tailwind/review/tw-review-rail-footer.tsx +29 -3
  86. package/src/ui-tailwind/status/tw-status-bar.tsx +52 -1
  87. package/src/ui-tailwind/theme/editor-theme.css +25 -0
  88. package/src/ui-tailwind/tw-review-workspace.tsx +293 -34
@@ -0,0 +1,273 @@
1
+ import type * as Y from "yjs";
2
+ import type { Awareness } from "y-protocols/awareness";
3
+
4
+ import type {
5
+ AwarenessIdentity,
6
+ CollabPosture,
7
+ PresenceSnapshot,
8
+ TransportStatus,
9
+ } from "../api/awareness-identity-types.ts";
10
+ import type {
11
+ ExternalCustodyResolver,
12
+ } from "../api/external-custody-types.ts";
13
+ import type {
14
+ PayloadSignature,
15
+ PayloadSigner,
16
+ PayloadVerifier,
17
+ } from "../io/ooxml/payload-signature.ts";
18
+ import {
19
+ clearLocalIdentity,
20
+ getCollabPosture,
21
+ getPresenceSnapshot,
22
+ setLocalIdentity,
23
+ } from "./awareness-identity.ts";
24
+ import {
25
+ createCollabSessionBridge,
26
+ type CollabSessionBridge,
27
+ type CollabSessionEvent,
28
+ } from "./collab-session-bridge.ts";
29
+ import type { CommentNegotiationAction } from "../api/comment-negotiation-types.ts";
30
+ import type { Participant } from "../api/participants-types.ts";
31
+ import type {
32
+ RuntimeSendToExternalArgs,
33
+ RuntimeSendToExternalResult,
34
+ } from "./external-send-runtime.ts";
35
+ import { runtimeSendToExternal } from "./external-send-runtime.ts";
36
+ import {
37
+ createTamperGate,
38
+ type MetadataIntegrity,
39
+ type TamperGate,
40
+ type TamperGateEvent,
41
+ } from "./tamper-gate.ts";
42
+
43
+ /**
44
+ * Event union emitted by the session. Merges the bridge's event
45
+ * stream with the tamper-gate stream into a single typed channel so
46
+ * hosts subscribe once.
47
+ */
48
+ export type CollabSessionEventOrIntegrity =
49
+ | CollabSessionEvent
50
+ | TamperGateEvent;
51
+
52
+ export interface CollabSessionOptions {
53
+ ydoc?: Y.Doc;
54
+ awareness?: Awareness;
55
+ identity?: AwarenessIdentity;
56
+ signer: PayloadSigner;
57
+ verifier?: PayloadVerifier;
58
+ }
59
+
60
+ export interface AttachPayloadArgs {
61
+ payloadXml: string;
62
+ signature: PayloadSignature | undefined;
63
+ }
64
+
65
+ export type SendToExternalCallArgs = Omit<
66
+ RuntimeSendToExternalArgs,
67
+ "bridge" | "tamperGate" | "signer" | "role"
68
+ > & {
69
+ role?: "author" | "reviewer" | "observer";
70
+ };
71
+
72
+ /**
73
+ * Unified collab session — composes the P8a–P8f slices so hosts call
74
+ * `createCollabSession(...)` instead of wiring the bridge, tamper
75
+ * gate, resolver and identity channel by hand.
76
+ */
77
+ export interface CollabSession {
78
+ readonly bridge: CollabSessionBridge;
79
+ readonly tamperGate: TamperGate;
80
+
81
+ isAttached(): boolean;
82
+ getMetadataIntegrity(): MetadataIntegrity;
83
+
84
+ attach(args: {
85
+ ydoc?: Y.Doc;
86
+ awareness?: Awareness;
87
+ identity?: AwarenessIdentity;
88
+ payload?: AttachPayloadArgs;
89
+ }): Promise<void>;
90
+ detach(): void;
91
+ destroy(): void;
92
+
93
+ // Negotiation + presentation + roster
94
+ dispatchCommentNegotiation: CollabSessionBridge["dispatchCommentNegotiation"];
95
+ dispatchCommentPresentation: CollabSessionBridge["dispatchCommentPresentation"];
96
+ upsertParticipant(entry: Participant): Participant | undefined;
97
+
98
+ getCommentNegotiationSnapshot: CollabSessionBridge["getCommentNegotiationSnapshot"];
99
+ getCommentPresentationSnapshot: CollabSessionBridge["getCommentPresentationSnapshot"];
100
+ getParticipantRoster: CollabSessionBridge["getParticipantRoster"];
101
+
102
+ // Presence + posture
103
+ setLocalIdentity(identity: AwarenessIdentity): void;
104
+ clearLocalIdentity(): void;
105
+ getPresenceSnapshot(opts?: {
106
+ transportStatus?: TransportStatus;
107
+ queuedLocalEvents?: number;
108
+ activeStoryFilter?: string;
109
+ }): PresenceSnapshot;
110
+ getCollabPosture(opts?: { transportStatus?: TransportStatus }): CollabPosture;
111
+
112
+ // Tamper gate
113
+ acknowledgeMetadataTampering(): void;
114
+
115
+ // External-send
116
+ registerExternalCustodyResolver(resolver: ExternalCustodyResolver | undefined): void;
117
+ sendToExternal(args: SendToExternalCallArgs): Promise<RuntimeSendToExternalResult>;
118
+
119
+ subscribe(listener: (event: CollabSessionEventOrIntegrity) => void): () => void;
120
+ }
121
+
122
+ type NegotiationAction = CommentNegotiationAction;
123
+
124
+ export function createCollabSession(
125
+ options: CollabSessionOptions,
126
+ ): CollabSession {
127
+ const bridge = createCollabSessionBridge(
128
+ options.ydoc ? { ydoc: options.ydoc } : {},
129
+ );
130
+ const tamperGate = createTamperGate(
131
+ options.verifier ? { verifier: options.verifier } : {},
132
+ );
133
+
134
+ let awareness: Awareness | undefined = options.awareness;
135
+ let resolver: ExternalCustodyResolver | undefined;
136
+ let destroyed = false;
137
+
138
+ const listeners = new Set<(event: CollabSessionEventOrIntegrity) => void>();
139
+ const emit = (event: CollabSessionEventOrIntegrity): void => {
140
+ for (const fn of [...listeners]) fn(event);
141
+ };
142
+ bridge.subscribe(emit);
143
+ tamperGate.subscribe(emit);
144
+
145
+ if (options.identity && awareness) {
146
+ setLocalIdentity(awareness, options.identity);
147
+ }
148
+
149
+ return {
150
+ bridge,
151
+ tamperGate,
152
+
153
+ isAttached: () => bridge.isAttached(),
154
+ getMetadataIntegrity: () => tamperGate.state,
155
+
156
+ async attach(args) {
157
+ if (destroyed) return;
158
+ if (args.ydoc) bridge.attach(args.ydoc);
159
+ if (args.awareness) awareness = args.awareness;
160
+ if (args.identity && awareness) {
161
+ setLocalIdentity(awareness, args.identity);
162
+ }
163
+ if (args.payload) {
164
+ await tamperGate.attach(args.payload);
165
+ }
166
+ },
167
+
168
+ detach() {
169
+ bridge.detach();
170
+ if (awareness) clearLocalIdentity(awareness);
171
+ tamperGate.detach();
172
+ },
173
+
174
+ destroy() {
175
+ destroyed = true;
176
+ bridge.destroy();
177
+ tamperGate.destroy();
178
+ listeners.clear();
179
+ },
180
+
181
+ dispatchCommentNegotiation(action: NegotiationAction, ctx) {
182
+ return bridge.dispatchCommentNegotiation(action, ctx);
183
+ },
184
+
185
+ async dispatchCommentPresentation(action) {
186
+ return bridge.dispatchCommentPresentation(action);
187
+ },
188
+
189
+ upsertParticipant(entry) {
190
+ return bridge.upsertParticipant(entry);
191
+ },
192
+
193
+ getCommentNegotiationSnapshot: () => bridge.getCommentNegotiationSnapshot(),
194
+ getCommentPresentationSnapshot: () => bridge.getCommentPresentationSnapshot(),
195
+ getParticipantRoster: () => bridge.getParticipantRoster(),
196
+
197
+ setLocalIdentity(identity) {
198
+ if (!awareness) {
199
+ throw new Error("collab session: no awareness attached");
200
+ }
201
+ setLocalIdentity(awareness, identity);
202
+ },
203
+
204
+ clearLocalIdentity() {
205
+ if (!awareness) return;
206
+ clearLocalIdentity(awareness);
207
+ },
208
+
209
+ getPresenceSnapshot(opts = {}) {
210
+ const args: Parameters<typeof getPresenceSnapshot>[0] = {};
211
+ if (awareness) args.awareness = awareness;
212
+ if (opts.transportStatus) args.transportStatus = opts.transportStatus;
213
+ if (opts.queuedLocalEvents !== undefined) {
214
+ args.queuedLocalEvents = opts.queuedLocalEvents;
215
+ }
216
+ if (opts.activeStoryFilter !== undefined) {
217
+ args.activeStoryFilter = opts.activeStoryFilter;
218
+ }
219
+ return getPresenceSnapshot(args);
220
+ },
221
+
222
+ getCollabPosture(opts = {}) {
223
+ const args: Parameters<typeof getCollabPosture>[0] = {};
224
+ if (awareness) args.awareness = awareness;
225
+ if (opts.transportStatus) args.transportStatus = opts.transportStatus;
226
+ return getCollabPosture(args);
227
+ },
228
+
229
+ acknowledgeMetadataTampering() {
230
+ tamperGate.acknowledge();
231
+ },
232
+
233
+ registerExternalCustodyResolver(next) {
234
+ resolver = next;
235
+ },
236
+
237
+ async sendToExternal(args) {
238
+ if (!resolver) {
239
+ throw new Error(
240
+ "collab session: registerExternalCustodyResolver(...) not called",
241
+ );
242
+ }
243
+ const resolvedRole =
244
+ args.role ??
245
+ (awareness
246
+ ? getCollabPosture({ awareness }).role
247
+ : "author");
248
+ return runtimeSendToExternal({
249
+ bridge,
250
+ tamperGate,
251
+ signer: options.signer,
252
+ payloadXml: args.payloadXml,
253
+ role: resolvedRole,
254
+ originDocumentId: args.originDocumentId,
255
+ originPayloadId: args.originPayloadId,
256
+ originContentHash: args.originContentHash,
257
+ resolver,
258
+ recipient: args.recipient,
259
+ sentBy: args.sentBy,
260
+ archiveRef: args.archiveRef,
261
+ ...(args.custodyId !== undefined ? { custodyId: args.custodyId } : {}),
262
+ ...(args.now !== undefined ? { now: args.now } : {}),
263
+ });
264
+ },
265
+
266
+ subscribe(listener) {
267
+ listeners.add(listener);
268
+ return () => {
269
+ listeners.delete(listener);
270
+ };
271
+ },
272
+ };
273
+ }
@@ -0,0 +1,91 @@
1
+ import * as Y from "yjs";
2
+
3
+ import type {
4
+ CommentNegotiationEntry,
5
+ CommentNegotiationSnapshot,
6
+ } from "../api/comment-negotiation-types.ts";
7
+
8
+ export interface NegotiationSyncHandle {
9
+ readEntry(commentId: string): CommentNegotiationEntry | undefined;
10
+ writeEntry(entry: CommentNegotiationEntry): void;
11
+ snapshot(): CommentNegotiationSnapshot;
12
+ subscribe(fn: (changedIds: string[]) => void): () => void;
13
+ destroy(): void;
14
+ }
15
+
16
+ const MAP_KEY = "commentNegotiation";
17
+
18
+ export function createNegotiationSync(ydoc: Y.Doc): NegotiationSyncHandle {
19
+ const yMap = ydoc.getMap<CommentNegotiationEntry>(MAP_KEY);
20
+ const listeners = new Set<(ids: string[]) => void>();
21
+
22
+ const onChange = (event: Y.YMapEvent<CommentNegotiationEntry>): void => {
23
+ if (listeners.size === 0) return;
24
+ const ids = Array.from(event.keysChanged);
25
+ for (const fn of listeners) fn(ids);
26
+ };
27
+ yMap.observe(onChange);
28
+
29
+ return {
30
+ readEntry(commentId) {
31
+ const entry = yMap.get(commentId);
32
+ return entry ? cloneEntry(entry) : undefined;
33
+ },
34
+ writeEntry(entry) {
35
+ yMap.set(entry.commentId, cloneEntry(entry));
36
+ },
37
+ snapshot() {
38
+ return {
39
+ schemaVersion: 1,
40
+ entries: Array.from(yMap.values()).map(cloneEntry),
41
+ };
42
+ },
43
+ subscribe(fn) {
44
+ listeners.add(fn);
45
+ return () => {
46
+ listeners.delete(fn);
47
+ };
48
+ },
49
+ destroy() {
50
+ yMap.unobserve(onChange);
51
+ listeners.clear();
52
+ },
53
+ };
54
+ }
55
+
56
+ function cloneEntry(entry: CommentNegotiationEntry): CommentNegotiationEntry {
57
+ const clone: CommentNegotiationEntry = {
58
+ commentId: entry.commentId,
59
+ state: entry.state,
60
+ requiredApprovers: [...entry.requiredApprovers],
61
+ votes: entry.votes.map((v) => ({ ...v })),
62
+ counterProposals: entry.counterProposals.map((p) => {
63
+ const copy: CommentNegotiationEntry["counterProposals"][number] = {
64
+ id: p.id,
65
+ authorId: p.authorId,
66
+ createdAt: p.createdAt,
67
+ body: p.body,
68
+ };
69
+ if (p.proposedRangeEdit) copy.proposedRangeEdit = { ...p.proposedRangeEdit };
70
+ if (p.supersededBy !== undefined) copy.supersededBy = p.supersededBy;
71
+ return copy;
72
+ }),
73
+ history: entry.history.map((h) => {
74
+ const row: CommentNegotiationEntry["history"][number] = {
75
+ from: h.from,
76
+ to: h.to,
77
+ actorId: h.actorId,
78
+ at: h.at,
79
+ action: h.action,
80
+ };
81
+ if (h.reasonCode !== undefined) row.reasonCode = h.reasonCode;
82
+ return row;
83
+ }),
84
+ };
85
+ if (entry.acceptedProposalId !== undefined) {
86
+ clone.acceptedProposalId = entry.acceptedProposalId;
87
+ }
88
+ if (entry.lockedAt !== undefined) clone.lockedAt = entry.lockedAt;
89
+ if (entry.lockedBy !== undefined) clone.lockedBy = entry.lockedBy;
90
+ return clone;
91
+ }
@@ -0,0 +1,158 @@
1
+ import type {
2
+ CommentNegotiationAction,
3
+ CommentNegotiationActionType,
4
+ CommentNegotiationEntry,
5
+ CommentNegotiationState,
6
+ NegotiationBlockReason,
7
+ NegotiationHistoryRow,
8
+ NegotiationRole,
9
+ } from "../api/comment-negotiation-types.ts";
10
+
11
+ export interface ReduceContext {
12
+ role: NegotiationRole;
13
+ now: string;
14
+ }
15
+
16
+ export type ReduceResult =
17
+ | { ok: true; entry: CommentNegotiationEntry }
18
+ | { ok: false; reason: NegotiationBlockReason };
19
+
20
+ export function createInitialEntry(commentId: string): CommentNegotiationEntry {
21
+ return {
22
+ commentId,
23
+ state: "proposed",
24
+ requiredApprovers: [],
25
+ votes: [],
26
+ counterProposals: [],
27
+ history: [],
28
+ };
29
+ }
30
+
31
+ const ALLOWED_BY_STATE: Record<
32
+ CommentNegotiationState,
33
+ ReadonlyArray<CommentNegotiationActionType>
34
+ > = {
35
+ proposed: ["propose-change", "counter-propose", "reject"],
36
+ negotiating: ["counter-propose", "vote", "accept", "reject"],
37
+ accepted: ["lock"],
38
+ rejected: ["lock"],
39
+ resolved: ["reopen"],
40
+ };
41
+
42
+ export function reduceNegotiation(
43
+ entry: CommentNegotiationEntry,
44
+ action: CommentNegotiationAction,
45
+ ctx: ReduceContext,
46
+ ): ReduceResult {
47
+ if (ctx.role === "observer") {
48
+ return { ok: false, reason: "collab_observer_readonly" };
49
+ }
50
+ if (
51
+ ctx.role === "reviewer" &&
52
+ (action.type === "accept" || action.type === "lock")
53
+ ) {
54
+ return { ok: false, reason: "collab_role_restricted" };
55
+ }
56
+ if (!ALLOWED_BY_STATE[entry.state].includes(action.type)) {
57
+ return { ok: false, reason: "negotiation_invalid_transition" };
58
+ }
59
+ if (action.type === "accept" && !quorumMet(entry)) {
60
+ return { ok: false, reason: "negotiation_quorum_not_met" };
61
+ }
62
+ return { ok: true, entry: applyAction(entry, action, ctx) };
63
+ }
64
+
65
+ export function quorumMet(entry: CommentNegotiationEntry): boolean {
66
+ if (entry.requiredApprovers.length > 0) {
67
+ return entry.requiredApprovers.every(
68
+ (userId) =>
69
+ entry.votes.find((v) => v.authorId === userId)?.verdict === "approve",
70
+ );
71
+ }
72
+ const approves = entry.votes.filter((v) => v.verdict === "approve").length;
73
+ const rejects = entry.votes.filter((v) => v.verdict === "reject").length;
74
+ return approves >= 1 && rejects === 0;
75
+ }
76
+
77
+ function applyAction(
78
+ entry: CommentNegotiationEntry,
79
+ action: CommentNegotiationAction,
80
+ ctx: ReduceContext,
81
+ ): CommentNegotiationEntry {
82
+ const from = entry.state;
83
+ let next: CommentNegotiationEntry = { ...entry };
84
+
85
+ switch (action.type) {
86
+ case "propose-change":
87
+ next.state = "negotiating";
88
+ break;
89
+
90
+ case "counter-propose": {
91
+ next.state = from === "proposed" ? "negotiating" : from;
92
+ next.counterProposals = [
93
+ ...entry.counterProposals,
94
+ {
95
+ id: action.proposalId,
96
+ authorId: action.authorId,
97
+ createdAt: action.createdAt,
98
+ body: action.body,
99
+ proposedRangeEdit: action.proposedRangeEdit,
100
+ },
101
+ ];
102
+ break;
103
+ }
104
+
105
+ case "vote":
106
+ next.votes = [
107
+ ...entry.votes.filter((v) => v.authorId !== action.authorId),
108
+ {
109
+ authorId: action.authorId,
110
+ verdict: action.verdict,
111
+ castAt: ctx.now,
112
+ },
113
+ ];
114
+ break;
115
+
116
+ case "accept":
117
+ next.state = "accepted";
118
+ next.acceptedProposalId = action.acceptedProposalId;
119
+ break;
120
+
121
+ case "reject":
122
+ next.state = "rejected";
123
+ break;
124
+
125
+ case "lock":
126
+ next.state = "resolved";
127
+ next.lockedAt = ctx.now;
128
+ next.lockedBy = action.actorId;
129
+ break;
130
+
131
+ case "reopen":
132
+ next.state = "negotiating";
133
+ next.lockedAt = undefined;
134
+ next.lockedBy = undefined;
135
+ break;
136
+ }
137
+
138
+ if (next.state !== from) {
139
+ const row: NegotiationHistoryRow = {
140
+ from,
141
+ to: next.state,
142
+ actorId: actorOf(action),
143
+ at: ctx.now,
144
+ action: action.type,
145
+ };
146
+ if (action.type === "reject" && action.reasonCode !== undefined) {
147
+ row.reasonCode = action.reasonCode;
148
+ }
149
+ next.history = [...entry.history, row];
150
+ }
151
+
152
+ return next;
153
+ }
154
+
155
+ function actorOf(action: CommentNegotiationAction): string {
156
+ if ("actorId" in action) return action.actorId;
157
+ return action.authorId;
158
+ }