Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

297 linesCodeBlameRaw
1/**
2 * Comment threads, kept in the page's Yjs document (the `threads` map) in
3 * the shape BlockNote's Yjs thread stores read, so every open editor shows
4 * a new comment the moment it is made. Writes come here, never from the
5 * editor itself: the room applies them after checking who is asking.
6 * Pure (yjs only).
7 *
8 * A thread's metadata carries `page_level: true` for a comment on the
9 * whole page (no anchor in the text) and `quote`, the text it was on.
10 * Comment authors are member keys (`user:<id>`, `agent:<id>`).
11 */
12import type { DocRole, DocThread, DocThreadAction } from "@g1t/contracts";
13import * as Y from "yjs";
14
15import { atLeast } from "./access.ts";
16
17export type ThreadResult =
18 | { ok: true; value: unknown; mentions?: string[]; text?: string; thread_id?: string }
19 | { ok: false; code: "not_found" | "forbidden" | "invalid"; message: string };
20
21const MAX_BODY_BYTES = 64 * 1024;
22
23function newId(): string {
24 return crypto.randomUUID();
25}
26
27function commentMap(id: string, author: string, body: unknown, metadata: unknown, now: number): Y.Map<unknown> {
28 const m = new Y.Map<unknown>();
29 m.set("id", id);
30 m.set("userId", author);
31 m.set("createdAt", now);
32 m.set("updatedAt", now);
33 m.set("body", body);
34 m.set("reactionsByUser", new Y.Map());
35 m.set("metadata", metadata ?? null);
36 return m;
37}
38
39function indexOf(comments: Y.Array<Y.Map<unknown>>, id: string): number {
40 for (let i = 0; i < comments.length; i++) if (comments.get(i).get("id") === id) return i;
41 return -1;
42}
43
44/** A comment body's text: BlockNote blocks' inline text, joined. */
45export function bodyText(body: unknown): string {
46 const out: string[] = [];
47 const walk = (value: unknown) => {
48 if (Array.isArray(value)) value.forEach(walk);
49 else if (value && typeof value === "object") {
50 const v = value as Record<string, unknown>;
51 if (v.type === "text" && typeof v.text === "string") out.push(v.text);
52 else if (v.type === "mention" && v.props && typeof v.props === "object") out.push(`@${(v.props as Record<string, string>).name ?? ""}`);
53 else {
54 if (v.content) walk(v.content);
55 if (v.children) walk(v.children);
56 }
57 } else if (typeof value === "string") out.push(value);
58 };
59 walk(body);
60 return out.join(" ").replace(/\s+/g, " ").trim();
61}
62
63/** Member keys a comment body mentions (inline `mention` content). */
64export function bodyMentions(body: unknown): string[] {
65 const out = new Set<string>();
66 const walk = (value: unknown) => {
67 if (Array.isArray(value)) value.forEach(walk);
68 else if (value && typeof value === "object") {
69 const v = value as Record<string, unknown>;
70 if (v.type === "mention" && v.props && typeof v.props === "object") {
71 const p = v.props as Record<string, string>;
72 if ((p.kind === "user" || p.kind === "agent") && p.id) out.add(`${p.kind}:${p.id}`);
73 }
74 if (v.content) walk(v.content);
75 if (v.children) walk(v.children);
76 }
77 };
78 walk(body);
79 return [...out];
80}
81
82function tooBig(body: unknown): boolean {
83 return new TextEncoder().encode(JSON.stringify(body ?? null)).length > MAX_BODY_BYTES;
84}
85
86/**
87 * Applies one comment operation for `actor` (a member key) whose role in
88 * the space is `role`. Anyone who can comment may start threads, reply,
89 * resolve and react; only a comment's author edits it; its author or an
90 * editor deletes it; only editors delete whole threads. `anchor` is done
91 * by the room (edits.ts `anchorThread`), not here.
92 */
93export function applyThreadAction(doc: Y.Doc, actor: string, role: DocRole, action: DocThreadAction, now = Date.now()): ThreadResult {
94 if (!atLeast(role, "comment")) return { ok: false, code: "forbidden", message: "You can read this page but not comment on it." };
95 const threads = doc.getMap<Y.Map<unknown>>("threads");
96 let result: ThreadResult = { ok: false, code: "invalid", message: "Unknown comment operation." };
97 doc.transact(() => {
98 switch (action.op) {
99 case "create": {
100 if (tooBig(action.body)) {
101 result = { ok: false, code: "invalid", message: "That comment is too long." };
102 return;
103 }
104 const id = newId();
105 const thread = new Y.Map<unknown>();
106 thread.set("id", id);
107 thread.set("createdAt", now);
108 thread.set("updatedAt", now);
109 const comments = new Y.Array<Y.Map<unknown>>();
110 const comment = commentMap(newId(), actor, action.body, action.metadata, now);
111 comments.push([comment]);
112 thread.set("comments", comments);
113 thread.set("resolved", false);
114 const metadata = { ...((action.metadata as object | null) ?? {}), ...(action.page_level ? { page_level: true } : {}) };
115 thread.set("metadata", metadata);
116 threads.set(id, thread);
117 result = { ok: true, value: snapshot(thread), mentions: bodyMentions(action.body), text: bodyText(action.body), thread_id: id };
118 return;
119 }
120 case "comment": {
121 const thread = threads.get(action.thread_id);
122 if (!thread) {
123 result = { ok: false, code: "not_found", message: "That thread is gone." };
124 return;
125 }
126 if (tooBig(action.body)) {
127 result = { ok: false, code: "invalid", message: "That comment is too long." };
128 return;
129 }
130 const comment = commentMap(newId(), actor, action.body, action.metadata, now);
131 (thread.get("comments") as Y.Array<Y.Map<unknown>>).push([comment]);
132 thread.set("updatedAt", now);
133 result = { ok: true, value: commentSnapshot(comment), mentions: bodyMentions(action.body), text: bodyText(action.body), thread_id: action.thread_id };
134 return;
135 }
136 case "edit_comment": {
137 const found = findComment(threads, action.thread_id, action.comment_id);
138 if (!found) {
139 result = { ok: false, code: "not_found", message: "That comment is gone." };
140 return;
141 }
142 if (found.comment.get("userId") !== actor) {
143 result = { ok: false, code: "forbidden", message: "Only its author can edit a comment." };
144 return;
145 }
146 found.comment.set("body", action.body);
147 found.comment.set("updatedAt", now);
148 found.comment.set("metadata", action.metadata ?? found.comment.get("metadata") ?? null);
149 result = { ok: true, value: null };
150 return;
151 }
152 case "delete_comment": {
153 const found = findComment(threads, action.thread_id, action.comment_id);
154 if (!found) {
155 result = { ok: false, code: "not_found", message: "That comment is gone." };
156 return;
157 }
158 if (found.comment.get("userId") !== actor && !atLeast(role, "edit")) {
159 result = { ok: false, code: "forbidden", message: "Only its author or an editor can delete a comment." };
160 return;
161 }
162 const comments = found.thread.get("comments") as Y.Array<Y.Map<unknown>>;
163 if (action.soft) {
164 found.comment.set("deletedAt", now);
165 found.comment.set("body", undefined);
166 } else comments.delete(found.index, 1);
167 const left = comments.toArray().filter((c) => !c.get("deletedAt"));
168 if (!left.length) {
169 if (action.soft) found.thread.set("deletedAt", now);
170 else threads.delete(action.thread_id);
171 }
172 found.thread.set("updatedAt", now);
173 result = { ok: true, value: null };
174 return;
175 }
176 case "delete_thread": {
177 if (!threads.get(action.thread_id)) {
178 result = { ok: false, code: "not_found", message: "That thread is gone." };
179 return;
180 }
181 if (!atLeast(role, "edit")) {
182 result = { ok: false, code: "forbidden", message: "Only editors can delete a thread." };
183 return;
184 }
185 threads.delete(action.thread_id);
186 result = { ok: true, value: null };
187 return;
188 }
189 case "resolve":
190 case "unresolve": {
191 const thread = threads.get(action.thread_id);
192 if (!thread) {
193 result = { ok: false, code: "not_found", message: "That thread is gone." };
194 return;
195 }
196 thread.set("resolved", action.op === "resolve");
197 thread.set("resolvedUpdatedAt", now);
198 if (action.op === "resolve") thread.set("resolvedBy", actor);
199 result = { ok: true, value: null };
200 return;
201 }
202 case "react":
203 case "unreact": {
204 const found = findComment(threads, action.thread_id, action.comment_id);
205 if (!found) {
206 result = { ok: false, code: "not_found", message: "That comment is gone." };
207 return;
208 }
209 const emoji = String(action.emoji ?? "").slice(0, 64);
210 if (!emoji) {
211 result = { ok: false, code: "invalid", message: "Choose an emoji." };
212 return;
213 }
214 const reactions = found.comment.get("reactionsByUser") as Y.Map<Y.Map<unknown>>;
215 const key = `${actor}-${emoji}`;
216 if (action.op === "react" && !reactions.has(key)) {
217 const r = new Y.Map<unknown>();
218 r.set("emoji", emoji);
219 r.set("createdAt", now);
220 r.set("userId", actor);
221 reactions.set(key, r);
222 }
223 if (action.op === "unreact") reactions.delete(key);
224 result = { ok: true, value: null };
225 return;
226 }
227 default:
228 return;
229 }
230 });
231 return result;
232}
233
234function findComment(threads: Y.Map<Y.Map<unknown>>, threadId: string, commentId: string) {
235 const thread = threads.get(threadId);
236 if (!thread) return null;
237 const comments = thread.get("comments") as Y.Array<Y.Map<unknown>> | undefined;
238 if (!comments) return null;
239 const index = indexOf(comments, commentId);
240 if (index < 0) return null;
241 return { thread, comment: comments.get(index), index };
242}
243
244function commentSnapshot(c: Y.Map<unknown>) {
245 return {
246 type: "comment",
247 id: c.get("id"),
248 userId: c.get("userId"),
249 createdAt: c.get("createdAt"),
250 updatedAt: c.get("updatedAt"),
251 body: c.get("body"),
252 reactions: [],
253 metadata: c.get("metadata"),
254 };
255}
256
257function snapshot(t: Y.Map<unknown>) {
258 return {
259 type: "thread",
260 id: t.get("id"),
261 createdAt: t.get("createdAt"),
262 updatedAt: t.get("updatedAt"),
263 comments: ((t.get("comments") as Y.Array<Y.Map<unknown>>)?.toArray() ?? []).map(commentSnapshot),
264 resolved: t.get("resolved"),
265 metadata: t.get("metadata"),
266 };
267}
268
269/** Sets a thread's quote (the text it is on), once anchored. */
270export function setQuote(doc: Y.Doc, threadId: string, quote: string): void {
271 const thread = doc.getMap<Y.Map<unknown>>("threads").get(threadId);
272 if (!thread) return;
273 doc.transact(() => thread.set("metadata", { ...((thread.get("metadata") as object | null) ?? {}), quote: quote.slice(0, 500) }));
274}
275
276/** Threads as agents and lists read them; authors as member keys, resolved later. */
277export function listThreads(doc: Y.Doc): (Omit<DocThread, "comments"> & { comments: { id: string; author: string; text: string; created_at: string }[] })[] {
278 const out: (Omit<DocThread, "comments"> & { comments: { id: string; author: string; text: string; created_at: string }[] })[] = [];
279 doc.getMap<Y.Map<unknown>>("threads").forEach((t, id) => {
280 if (!(t instanceof Y.Map) || t.get("deletedAt")) return;
281 const meta = (t.get("metadata") as { quote?: string; page_level?: boolean } | null) ?? {};
282 out.push({
283 id,
284 quote: meta.page_level ? null : (meta.quote ?? null),
285 resolved: !!t.get("resolved"),
286 comments: ((t.get("comments") as Y.Array<Y.Map<unknown>>)?.toArray() ?? [])
287 .filter((c) => !c.get("deletedAt"))
288 .map((c) => ({
289 id: String(c.get("id")),
290 author: String(c.get("userId")),
291 text: bodyText(c.get("body")),
292 created_at: new Date(Number(c.get("createdAt")) || 0).toISOString(),
293 })),
294 });
295 });
296 return out.sort((a, b) => (a.comments[0]?.created_at ?? "").localeCompare(b.comments[0]?.created_at ?? ""));
297}