@softr/sdk 0.0.53 → 0.0.54-alpha.0

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/dist/action.d.ts CHANGED
@@ -234,5 +234,35 @@ type IntegrationDefinition<TId extends string> = {
234
234
  * const res = await listEvents({ query: { timeMin: now, singleEvents: "true" } })
235
235
  */
236
236
  declare function defineIntegration<const TId extends string>(definition: IntegrationDefinition<TId>): IntegrationAction<TId>;
237
+ type CommentsResource = {
238
+ type: "COMMENTS";
239
+ datasourceId: SoftrDatasourceId;
240
+ };
241
+ /** Comment threads on the records of one datasource. It has no `permissions`. */
242
+ type CommentsAction<TId extends string = string> = {
243
+ id: TId;
244
+ resource: CommentsResource;
245
+ };
246
+ type CommentsDefinition<TId extends string> = {
247
+ id: TId;
248
+ datasourceId: SoftrDatasourceId;
249
+ };
250
+ /**
251
+ * Declares comment threads on the records of a datasource. Each record has one thread.
252
+ *
253
+ * Who may read, post and delete comes from the app's comment settings in Softr, not from code. Two
254
+ * declarations on the same datasource share the same threads.
255
+ *
256
+ * Pass the returned object to `comments.*` from `@softr/sdk/server`.
257
+ *
258
+ * @example
259
+ * const orderComments = defineComments({
260
+ * id: "orderComments",
261
+ * datasourceId: "8f14e45f-ceea-467a-9d0b-2c1a0e3b5d77",
262
+ * })
263
+ *
264
+ * const thread = await comments.list(orderComments, { recordId })
265
+ */
266
+ declare function defineComments<const TId extends string>(definition: CommentsDefinition<TId>): CommentsAction<TId>;
237
267
  //#endregion
238
- export { ActionData, ActionPermissions, ActionRecord, ActionShape, DatasourceAction, FieldTypeOf, IntegrationAction, ReadOf, ReadShape, SoftrDatasources, WorkflowAction, defineIntegration, defineMutation, defineQuery, defineWorkflow };
268
+ export { ActionData, ActionPermissions, ActionRecord, ActionShape, CommentsAction, DatasourceAction, FieldTypeOf, IntegrationAction, ReadOf, ReadShape, SoftrDatasources, WorkflowAction, defineComments, defineIntegration, defineMutation, defineQuery, defineWorkflow };
package/dist/action.js CHANGED
@@ -50,6 +50,31 @@ function defineIntegration(definition) {
50
50
  permissions
51
51
  };
52
52
  }
53
+ /**
54
+ * Declares comment threads on the records of a datasource. Each record has one thread.
55
+ *
56
+ * Who may read, post and delete comes from the app's comment settings in Softr, not from code. Two
57
+ * declarations on the same datasource share the same threads.
58
+ *
59
+ * Pass the returned object to `comments.*` from `@softr/sdk/server`.
60
+ *
61
+ * @example
62
+ * const orderComments = defineComments({
63
+ * id: "orderComments",
64
+ * datasourceId: "8f14e45f-ceea-467a-9d0b-2c1a0e3b5d77",
65
+ * })
66
+ *
67
+ * const thread = await comments.list(orderComments, { recordId })
68
+ */
69
+ function defineComments(definition) {
70
+ return {
71
+ id: definition.id,
72
+ resource: {
73
+ type: "COMMENTS",
74
+ datasourceId: definition.datasourceId
75
+ }
76
+ };
77
+ }
53
78
  function isDatasourceAction(value) {
54
79
  if (typeof value !== "object" || value === null) return false;
55
80
  const candidate = value;
@@ -58,4 +83,4 @@ function isDatasourceAction(value) {
58
83
  }
59
84
 
60
85
  //#endregion
61
- export { defineIntegration, defineMutation, defineQuery, defineWorkflow, isDatasourceAction };
86
+ export { defineComments, defineIntegration, defineMutation, defineQuery, defineWorkflow, isDatasourceAction };
package/dist/client.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  import { FileItem, useFileUpload } from "./core/use-file-upload.js";
2
+ import { useCommentComposer } from "./comments/use-comment-composer.js";
2
3
  import "@tanstack/react-start/client-only";
3
- export { type FileItem, useFileUpload };
4
+ export { type FileItem, useCommentComposer, useFileUpload };
package/dist/client.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { useFileUpload } from "./core/use-file-upload.js";
2
+ import { useCommentComposer } from "./comments/use-comment-composer.js";
2
3
  import "@tanstack/react-start/client-only";
3
4
 
4
- export { useFileUpload };
5
+ export { useCommentComposer, useFileUpload };
@@ -0,0 +1,137 @@
1
+ import { CommentsAction } from "../action.js";
2
+
3
+ //#region src/comments/comments.d.ts
4
+ /** A person picked in the composer, and where their `@name` sits in the text. */
5
+ type CommentMention = {
6
+ name: string;
7
+ email: string;
8
+ start: number;
9
+ end: number;
10
+ };
11
+ type CommentView = {
12
+ id: string;
13
+ /** The body as safe HTML. Render it with `dangerouslySetInnerHTML`, never as text. */
14
+ bodyHtml: string;
15
+ attachments: Array<{
16
+ url: string;
17
+ filename: string;
18
+ }>;
19
+ author: {
20
+ name: string;
21
+ avatar: string | null;
22
+ isDeactivated: boolean;
23
+ };
24
+ /** ISO 8601. */
25
+ createdAt: string;
26
+ canDelete: boolean;
27
+ reactions: Array<{
28
+ emoji: string;
29
+ label: string;
30
+ count: number;
31
+ isSelected: boolean;
32
+ }>;
33
+ };
34
+ type CommentThread = {
35
+ canPost: boolean;
36
+ allowMentions: boolean;
37
+ quickReactions: ReadonlyArray<{
38
+ emoji: string;
39
+ label: string;
40
+ }>;
41
+ total: number;
42
+ /** Pass it back as `offset` for the next page. `null` on the last page. */
43
+ nextOffset: number | null;
44
+ items: Array<CommentView>;
45
+ };
46
+ /**
47
+ * The thread on one record, newest first, or `null` when this visitor cannot see comments on it.
48
+ * Render nothing for `null`.
49
+ *
50
+ * @example
51
+ * const thread = await comments.list(orderComments, { recordId: order.id })
52
+ */
53
+ declare function listComments(action: CommentsAction, options: {
54
+ recordId: string;
55
+ offset?: number | null;
56
+ limit?: number;
57
+ }): Promise<CommentThread | null>;
58
+ /**
59
+ * Posts a comment. `body` is the text the visitor typed. `mentions` are the people they picked,
60
+ * each with the range of its `@name` in `body`. A range that no longer reads as `@name` is posted as
61
+ * plain text.
62
+ *
63
+ * @example
64
+ * await comments.create(orderComments, { recordId, body: "Hey @Ann", mentions })
65
+ */
66
+ declare function createComment(action: CommentsAction, options: {
67
+ recordId: string;
68
+ body: string;
69
+ mentions: ReadonlyArray<CommentMention>;
70
+ }): Promise<void>;
71
+ /**
72
+ * Deletes a comment. Offer it only where the comment's `canDelete` is true.
73
+ *
74
+ * @example
75
+ * await comments.delete(orderComments, { recordId, commentId })
76
+ */
77
+ declare function deleteComment(action: CommentsAction, options: {
78
+ recordId: string;
79
+ commentId: string;
80
+ }): Promise<void>;
81
+ /**
82
+ * Adds the visitor's reaction when `selected` is true, and removes it when false. Pass the
83
+ * reaction's current `isSelected` negated.
84
+ *
85
+ * @example
86
+ * await comments.react(orderComments, { recordId, commentId, emoji: "👍", selected: true })
87
+ */
88
+ declare function reactToComment(action: CommentsAction, options: {
89
+ recordId: string;
90
+ commentId: string;
91
+ emoji: string;
92
+ selected: boolean;
93
+ }): Promise<void>;
94
+ /**
95
+ * Whether the visitor gets an email when someone comments on this record.
96
+ *
97
+ * @example
98
+ * const subscribed = await comments.subscription(orderComments, { recordId })
99
+ */
100
+ declare function readSubscription(action: CommentsAction, options: {
101
+ recordId: string;
102
+ }): Promise<boolean>;
103
+ /**
104
+ * Turns the visitor's emails about this record's comments on or off.
105
+ *
106
+ * @example
107
+ * await comments.setSubscription(orderComments, { recordId, subscribed: false })
108
+ */
109
+ declare function setSubscription(action: CommentsAction, options: {
110
+ recordId: string;
111
+ subscribed: boolean;
112
+ }): Promise<void>;
113
+ /**
114
+ * The people the visitor may mention whose name or email matches `query`. Only call it when the
115
+ * thread's `allowMentions` is true.
116
+ *
117
+ * @example
118
+ * const people = await comments.searchMentions(orderComments, { query: "an" })
119
+ */
120
+ declare function searchMentions(action: CommentsAction, options: {
121
+ query: string;
122
+ }): Promise<Array<{
123
+ name: string;
124
+ email: string;
125
+ avatar: string | null;
126
+ }>>;
127
+ declare const comments: {
128
+ list: typeof listComments;
129
+ create: typeof createComment;
130
+ delete: typeof deleteComment;
131
+ react: typeof reactToComment;
132
+ subscription: typeof readSubscription;
133
+ setSubscription: typeof setSubscription;
134
+ searchMentions: typeof searchMentions;
135
+ };
136
+ //#endregion
137
+ export { CommentMention, CommentThread, CommentView, comments };
@@ -0,0 +1,329 @@
1
+ import { assert } from "../assertions.js";
2
+ import { fetch } from "../fetch.js";
3
+ import { sessionConfig } from "../auth/config.js";
4
+ import { readSessionToken } from "../auth/session.js";
5
+ import { platformApiUrl } from "../auth/platform.js";
6
+ import { describeFailure } from "../datasource/record.js";
7
+ import { z } from "zod";
8
+ import { Marked } from "marked";
9
+ import { FilterXSS } from "xss";
10
+
11
+ //#region src/comments/comments.ts
12
+ /**
13
+ * The emoji a comment can be reacted with. The app has no other set: an emoji outside it has no
14
+ * label to store, and native blocks would show it without a name.
15
+ */
16
+ const QUICK_REACTIONS = [
17
+ {
18
+ emoji: "👍",
19
+ label: "Thumbs Up"
20
+ },
21
+ {
22
+ emoji: "🙌",
23
+ label: "Raised Hands"
24
+ },
25
+ {
26
+ emoji: "✅",
27
+ label: "Check Mark"
28
+ },
29
+ {
30
+ emoji: "🔥",
31
+ label: "Fire"
32
+ },
33
+ {
34
+ emoji: "👀",
35
+ label: "Eyes"
36
+ }
37
+ ];
38
+ const threadSchema = z.object({
39
+ items: z.array(z.object({
40
+ id: z.string(),
41
+ comment: z.string(),
42
+ attachments: z.array(z.object({
43
+ url: z.string(),
44
+ filename: z.string()
45
+ })),
46
+ author: z.object({
47
+ name: z.string(),
48
+ avatar: z.string().nullable(),
49
+ deactivated: z.boolean()
50
+ }),
51
+ createdAt: z.number(),
52
+ canDelete: z.boolean(),
53
+ reactions: z.array(z.object({
54
+ emoji: z.string(),
55
+ label: z.string(),
56
+ count: z.number(),
57
+ selected: z.boolean()
58
+ }))
59
+ })),
60
+ total: z.number(),
61
+ offset: z.number(),
62
+ canPost: z.boolean(),
63
+ allowMentions: z.boolean()
64
+ });
65
+ const subscriptionSchema = z.object({ subscribed: z.boolean() });
66
+ const mentionCandidatesSchema = z.array(z.object({
67
+ name: z.string(),
68
+ email: z.string(),
69
+ avatar: z.string().nullable()
70
+ }));
71
+ async function request(action, path, init) {
72
+ const token = readSessionToken();
73
+ const headers = {};
74
+ if (token !== null) headers.Authorization = `Bearer ${token}`;
75
+ if (init.body !== void 0) headers["Content-Type"] = "application/json";
76
+ return fetch(`${await platformApiUrl()}/v1/vibe/applications/${sessionConfig().applicationId}/actions/${encodeURIComponent(action.id)}/comments${path}`, {
77
+ method: init.method,
78
+ headers,
79
+ body: init.body === void 0 ? void 0 : JSON.stringify(init.body)
80
+ });
81
+ }
82
+ async function failure(action, what, res) {
83
+ return /* @__PURE__ */ new Error(`comments ${what} failed for "${action.id}": ${await describeFailure(res)}`);
84
+ }
85
+ function recordPath(recordId) {
86
+ assert(recordId, "a record id must not be empty");
87
+ return `/records/${encodeURIComponent(recordId)}`;
88
+ }
89
+ /**
90
+ * The thread on one record, newest first, or `null` when this visitor cannot see comments on it.
91
+ * Render nothing for `null`.
92
+ *
93
+ * @example
94
+ * const thread = await comments.list(orderComments, { recordId: order.id })
95
+ */
96
+ async function listComments(action, options) {
97
+ const query = new URLSearchParams();
98
+ if (options.offset !== void 0 && options.offset !== null) query.set("offset", String(options.offset));
99
+ if (options.limit !== void 0) query.set("limit", String(options.limit));
100
+ const search = query.size > 0 ? `?${query}` : "";
101
+ const res = await request(action, `${recordPath(options.recordId)}${search}`, { method: "GET" });
102
+ if (res.status === 403) return null;
103
+ if (!res.ok) throw await failure(action, "list", res);
104
+ const payload = threadSchema.parse(await res.json());
105
+ const next = payload.offset + payload.items.length;
106
+ return {
107
+ canPost: payload.canPost,
108
+ allowMentions: payload.allowMentions,
109
+ quickReactions: QUICK_REACTIONS,
110
+ total: payload.total,
111
+ nextOffset: next < payload.total ? next : null,
112
+ items: payload.items.map((item) => ({
113
+ id: item.id,
114
+ bodyHtml: renderCommentBody(item.comment),
115
+ attachments: item.attachments,
116
+ author: {
117
+ name: item.author.name,
118
+ avatar: item.author.avatar,
119
+ isDeactivated: item.author.deactivated
120
+ },
121
+ createdAt: new Date(item.createdAt).toISOString(),
122
+ canDelete: item.canDelete,
123
+ reactions: item.reactions.map(({ selected,...reaction }) => ({
124
+ ...reaction,
125
+ isSelected: selected
126
+ }))
127
+ }))
128
+ };
129
+ }
130
+ /**
131
+ * Posts a comment. `body` is the text the visitor typed. `mentions` are the people they picked,
132
+ * each with the range of its `@name` in `body`. A range that no longer reads as `@name` is posted as
133
+ * plain text.
134
+ *
135
+ * @example
136
+ * await comments.create(orderComments, { recordId, body: "Hey @Ann", mentions })
137
+ */
138
+ async function createComment(action, options) {
139
+ assert(options.body.trim(), "a comment must not be empty");
140
+ const res = await request(action, recordPath(options.recordId), {
141
+ method: "POST",
142
+ body: { comment: encodeCommentBody(options.body, options.mentions) }
143
+ });
144
+ if (!res.ok) throw await failure(action, "create", res);
145
+ }
146
+ /**
147
+ * Deletes a comment. Offer it only where the comment's `canDelete` is true.
148
+ *
149
+ * @example
150
+ * await comments.delete(orderComments, { recordId, commentId })
151
+ */
152
+ async function deleteComment(action, options) {
153
+ const res = await request(action, `${recordPath(options.recordId)}/${encodeURIComponent(options.commentId)}`, { method: "DELETE" });
154
+ if (!res.ok) throw await failure(action, "delete", res);
155
+ }
156
+ /**
157
+ * Adds the visitor's reaction when `selected` is true, and removes it when false. Pass the
158
+ * reaction's current `isSelected` negated.
159
+ *
160
+ * @example
161
+ * await comments.react(orderComments, { recordId, commentId, emoji: "👍", selected: true })
162
+ */
163
+ async function reactToComment(action, options) {
164
+ const reaction = QUICK_REACTIONS.find(({ emoji }) => emoji === options.emoji);
165
+ assert(reaction, `"${options.emoji}" is not one of the quick reactions`);
166
+ const path = `${recordPath(options.recordId)}/${encodeURIComponent(options.commentId)}/react`;
167
+ const res = options.selected ? await request(action, path, {
168
+ method: "PUT",
169
+ body: reaction
170
+ }) : await request(action, `${path}?${new URLSearchParams({ emoji: reaction.emoji })}`, { method: "DELETE" });
171
+ if (!res.ok) throw await failure(action, "react", res);
172
+ }
173
+ /**
174
+ * Whether the visitor gets an email when someone comments on this record.
175
+ *
176
+ * @example
177
+ * const subscribed = await comments.subscription(orderComments, { recordId })
178
+ */
179
+ async function readSubscription(action, options) {
180
+ const res = await request(action, `${recordPath(options.recordId)}/subscription`, { method: "GET" });
181
+ if (!res.ok) throw await failure(action, "subscription", res);
182
+ return subscriptionSchema.parse(await res.json()).subscribed;
183
+ }
184
+ /**
185
+ * Turns the visitor's emails about this record's comments on or off.
186
+ *
187
+ * @example
188
+ * await comments.setSubscription(orderComments, { recordId, subscribed: false })
189
+ */
190
+ async function setSubscription(action, options) {
191
+ const res = await request(action, `${recordPath(options.recordId)}/subscription`, {
192
+ method: "PUT",
193
+ body: { subscribed: options.subscribed }
194
+ });
195
+ if (!res.ok) throw await failure(action, "set subscription", res);
196
+ }
197
+ /**
198
+ * The people the visitor may mention whose name or email matches `query`. Only call it when the
199
+ * thread's `allowMentions` is true.
200
+ *
201
+ * @example
202
+ * const people = await comments.searchMentions(orderComments, { query: "an" })
203
+ */
204
+ async function searchMentions(action, options) {
205
+ const res = await request(action, `/mentions?${new URLSearchParams({ query: options.query })}`, { method: "GET" });
206
+ if (!res.ok) throw await failure(action, "mention search", res);
207
+ return mentionCandidatesSchema.parse(await res.json());
208
+ }
209
+ const comments = {
210
+ list: listComments,
211
+ create: createComment,
212
+ delete: deleteComment,
213
+ react: reactToComment,
214
+ subscription: readSubscription,
215
+ setSubscription,
216
+ searchMentions
217
+ };
218
+ /**
219
+ * Splices each mention into the body as JSON. A range that does not read as its own `@name` is
220
+ * left as text, so a composer that fell out of step never puts an email on the wrong words.
221
+ */
222
+ function encodeCommentBody(body, mentions) {
223
+ const ordered = mentions.filter(({ name, start, end }) => body.slice(start, end) === `@${name}`).toSorted((a, b) => a.start - b.start);
224
+ let encoded = "";
225
+ let cursor = 0;
226
+ for (const { name, email, start, end } of ordered) {
227
+ if (start < cursor) continue;
228
+ encoded += body.slice(cursor, start) + JSON.stringify({
229
+ name,
230
+ email
231
+ });
232
+ cursor = end;
233
+ }
234
+ return encoded + body.slice(cursor);
235
+ }
236
+ const MENTION = /^\{\s*(?:"name"\s*:\s*"(?:[^"\\]|\\.)*"\s*,\s*"email"\s*:\s*"(?:[^"\\]|\\.)*"|"email"\s*:\s*"(?:[^"\\]|\\.)*"\s*,\s*"name"\s*:\s*"(?:[^"\\]|\\.)*")\s*\}/;
237
+ const mentionSchema = z.object({
238
+ name: z.string(),
239
+ email: z.string()
240
+ });
241
+ /** The mention's fields, or `undefined` when the match is not valid JSON, e.g. a stray `\q`. */
242
+ function parseMention(raw) {
243
+ try {
244
+ return mentionSchema.parse(JSON.parse(raw));
245
+ } catch {
246
+ return;
247
+ }
248
+ }
249
+ const markdown = new Marked({
250
+ gfm: true,
251
+ breaks: true,
252
+ async: false,
253
+ extensions: [{
254
+ name: "mention",
255
+ level: "inline",
256
+ start: (src) => src.indexOf("{"),
257
+ tokenizer(src) {
258
+ const match = MENTION.exec(src);
259
+ if (match === null) return void 0;
260
+ const mention = parseMention(match[0]);
261
+ if (mention === void 0) return void 0;
262
+ return {
263
+ type: "mention",
264
+ raw: match[0],
265
+ ...mention
266
+ };
267
+ },
268
+ renderer(token) {
269
+ const label = String(token.name || token.email);
270
+ return `<span data-type="mention" data-name="${escapeHtml(label)}">@${escapeHtml(label)}</span>`;
271
+ }
272
+ }]
273
+ });
274
+ const sanitizer = new FilterXSS({
275
+ whiteList: Object.fromEntries([
276
+ "p",
277
+ "br",
278
+ "strong",
279
+ "em",
280
+ "u",
281
+ "s",
282
+ "a",
283
+ "ul",
284
+ "ol",
285
+ "li",
286
+ "code",
287
+ "pre",
288
+ "blockquote",
289
+ "h1",
290
+ "h2",
291
+ "h3",
292
+ "span",
293
+ "del"
294
+ ].map((tag) => [tag, tag === "a" ? ["href", "title"] : []])),
295
+ stripIgnoreTag: true,
296
+ stripIgnoreTagBody: ["script", "style"],
297
+ onTagAttr(tag, name, value) {
298
+ if (tag !== "a") return void 0;
299
+ if (name === "target" || name === "rel") return "";
300
+ if (name !== "href") return void 0;
301
+ const href = linkify(value);
302
+ if (!SAFE_HREF.test(href)) return "";
303
+ return `href="${escapeAttribute(href)}" target="_blank" rel="noopener noreferrer"`;
304
+ },
305
+ onIgnoreTagAttr(_tag, name, value) {
306
+ if (!name.startsWith("data-")) return void 0;
307
+ return `${name}="${escapeAttribute(value)}"`;
308
+ }
309
+ });
310
+ const SAFE_HREF = /^(?:https?:\/\/|mailto:|tel:|#|\/)/i;
311
+ /** A bare domain like `example.com` becomes an https link, as native blocks do. */
312
+ function linkify(href) {
313
+ const trimmed = href.trim();
314
+ if (/^(?:[a-z][a-z0-9+.-]*:|#|\/)/i.test(trimmed)) return trimmed;
315
+ return trimmed.includes(".") ? `https://${trimmed}` : "";
316
+ }
317
+ function escapeHtml(text) {
318
+ return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
319
+ }
320
+ function escapeAttribute(value) {
321
+ return value.replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
322
+ }
323
+ function renderCommentBody(comment) {
324
+ const trimmed = comment.replace(/^(?:\s*<br\s*\/?>\s*)+/i, "").replace(/(?:\s*<br\s*\/?>\s*)+$/i, "");
325
+ return sanitizer.process(markdown.parse(trimmed, { async: false }));
326
+ }
327
+
328
+ //#endregion
329
+ export { comments };
@@ -0,0 +1,37 @@
1
+ import { CommentMention } from "./comments.js";
2
+ import { ChangeEvent, RefObject, SyntheticEvent } from "react";
3
+
4
+ //#region src/comments/use-comment-composer.d.ts
5
+
6
+ /**
7
+ * The state of a comment box: the text, the people mentioned in it, and the `@` fragment being
8
+ * typed. Spread `textareaProps` onto a `<textarea>`. Show a mention menu while `query` is not
9
+ * `null`, and call `pick` with the person chosen from it.
10
+ *
11
+ * Post `text` and `mentions` as they are, then call `reset` once the post succeeds.
12
+ *
13
+ * @example
14
+ * const composer = useCommentComposer()
15
+ * <textarea {...composer.textareaProps} />
16
+ * {composer.query !== null && <MentionMenu query={composer.query} onPick={composer.pick} />}
17
+ */
18
+ declare function useCommentComposer(): {
19
+ text: string;
20
+ mentions: Array<CommentMention>;
21
+ /** What follows the `@` before the caret, or `null` when the caret is not in a mention. */
22
+ query: string | null;
23
+ textareaProps: {
24
+ ref: RefObject<HTMLTextAreaElement | null>;
25
+ value: string;
26
+ onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void;
27
+ onSelect: (event: SyntheticEvent<HTMLTextAreaElement>) => void;
28
+ };
29
+ /** Replaces the `@` fragment with the person's `@name`, and puts the caret after it. */
30
+ pick: (person: {
31
+ name: string;
32
+ email: string;
33
+ }) => void;
34
+ reset: () => void;
35
+ };
36
+ //#endregion
37
+ export { useCommentComposer };
@@ -0,0 +1,127 @@
1
+ import { assert } from "../assertions.js";
2
+ import { useLayoutEffect, useRef, useState } from "react";
3
+
4
+ //#region src/comments/use-comment-composer.ts
5
+ const EMPTY = {
6
+ text: "",
7
+ mentions: [],
8
+ caret: 0
9
+ };
10
+ /**
11
+ * The state of a comment box: the text, the people mentioned in it, and the `@` fragment being
12
+ * typed. Spread `textareaProps` onto a `<textarea>`. Show a mention menu while `query` is not
13
+ * `null`, and call `pick` with the person chosen from it.
14
+ *
15
+ * Post `text` and `mentions` as they are, then call `reset` once the post succeeds.
16
+ *
17
+ * @example
18
+ * const composer = useCommentComposer()
19
+ * <textarea {...composer.textareaProps} />
20
+ * {composer.query !== null && <MentionMenu query={composer.query} onPick={composer.pick} />}
21
+ */
22
+ function useCommentComposer() {
23
+ const [state, setState] = useState(EMPTY);
24
+ const ref = useRef(null);
25
+ const pendingCaret = useRef(null);
26
+ useLayoutEffect(() => {
27
+ const caret = pendingCaret.current;
28
+ if (caret === null || ref.current === null) return;
29
+ pendingCaret.current = null;
30
+ ref.current.focus();
31
+ ref.current.setSelectionRange(caret, caret);
32
+ });
33
+ const fragment = findMentionQuery(state.text, state.caret, state.mentions);
34
+ return {
35
+ text: state.text,
36
+ mentions: state.mentions,
37
+ query: fragment?.query ?? null,
38
+ textareaProps: {
39
+ ref,
40
+ value: state.text,
41
+ onChange(event) {
42
+ const next = event.currentTarget.value;
43
+ const caret = event.currentTarget.selectionStart;
44
+ setState((previous) => ({
45
+ text: next,
46
+ mentions: remapMentions(previous.text, next, previous.mentions),
47
+ caret
48
+ }));
49
+ },
50
+ onSelect(event) {
51
+ const caret = event.currentTarget.selectionStart;
52
+ setState((previous) => previous.caret === caret ? previous : {
53
+ ...previous,
54
+ caret
55
+ });
56
+ }
57
+ },
58
+ pick(person) {
59
+ assert(fragment, "pick needs an @ fragment under the caret");
60
+ const label = `@${person.name}`;
61
+ const text = `${state.text.slice(0, fragment.start)}${label} ${state.text.slice(state.caret)}`;
62
+ const added = {
63
+ ...person,
64
+ start: fragment.start,
65
+ end: fragment.start + label.length
66
+ };
67
+ const caret = added.end + 1;
68
+ pendingCaret.current = caret;
69
+ setState({
70
+ text,
71
+ mentions: [...remapMentions(state.text, text, state.mentions), added],
72
+ caret
73
+ });
74
+ },
75
+ reset() {
76
+ setState(EMPTY);
77
+ }
78
+ };
79
+ }
80
+ /** A fragment longer than this is prose, not a name being typed. */
81
+ const MENTION_QUERY_MAX_LENGTH = 32;
82
+ /**
83
+ * Moves mention ranges through an edit. A textarea reports only its new value, so the edit is the
84
+ * span between the longest common prefix and suffix. Ranges after it shift. Ranges it reached into
85
+ * are dropped, which is how deleting part of a name un-mentions that person.
86
+ */
87
+ function remapMentions(previous, next, mentions) {
88
+ if (previous === next) return mentions;
89
+ let prefix = 0;
90
+ while (prefix < previous.length && prefix < next.length && previous[prefix] === next[prefix]) prefix += 1;
91
+ let suffix = 0;
92
+ while (suffix < previous.length - prefix && suffix < next.length - prefix && previous[previous.length - 1 - suffix] === next[next.length - 1 - suffix]) suffix += 1;
93
+ const editStart = prefix;
94
+ const editEnd = previous.length - suffix;
95
+ const delta = next.length - previous.length;
96
+ return mentions.flatMap((mention) => {
97
+ if (mention.end <= editStart) return [mention];
98
+ if (mention.start >= editEnd) return [{
99
+ ...mention,
100
+ start: mention.start + delta,
101
+ end: mention.end + delta
102
+ }];
103
+ return [];
104
+ });
105
+ }
106
+ /**
107
+ * The `@` fragment the caret sits in, or `null`. An `@` does not count when:
108
+ * - it does not start a word, so an email address opens no menu;
109
+ * - it belongs to a mention already made;
110
+ * - the fragment ran past a newline or is long enough to be prose. Spaces are fine, names have them.
111
+ */
112
+ function findMentionQuery(text, caret, mentions) {
113
+ const before = text.slice(0, caret);
114
+ const at = before.lastIndexOf("@");
115
+ if (at === -1) return null;
116
+ if (at > 0 && !/\s/.test(before.charAt(at - 1))) return null;
117
+ if (mentions.some((mention) => at >= mention.start && at < mention.end)) return null;
118
+ const query = before.slice(at + 1);
119
+ if (query.includes("\n") || query.length > MENTION_QUERY_MAX_LENGTH) return null;
120
+ return {
121
+ query,
122
+ start: at
123
+ };
124
+ }
125
+
126
+ //#endregion
127
+ export { useCommentComposer };
@@ -1,25 +1,10 @@
1
- import { useEffect, useRef, useState, useSyncExternalStore } from "react";
2
- import { useRouter } from "@tanstack/react-router";
1
+ import { useRef, useState } from "react";
3
2
  import { jsx, jsxs } from "react/jsx-runtime";
4
3
 
5
4
  //#region src/default-error-overlay.tsx
6
- /** Set by Studio's canvas engine, which ships apart from the SDK each app pins, so renaming it breaks older apps. */
7
- const AGENT_TURN_ATTRIBUTE = "data-agent-turn";
8
- function subscribeToAgentTurn(listener) {
9
- const observer = new MutationObserver(listener);
10
- observer.observe(document.documentElement, {
11
- attributes: true,
12
- attributeFilter: [AGENT_TURN_ATTRIBUTE]
13
- });
14
- return () => observer.disconnect();
15
- }
16
- function isAgentTurnRunning() {
17
- return document.documentElement.getAttribute(AGENT_TURN_ATTRIBUTE) === "running";
18
- }
19
5
  function DefaultErrorOverlay({ error, reset }) {
20
6
  if (process.env.NODE_ENV === "production") return /* @__PURE__ */ jsx("div", {
21
7
  style: s.overlay,
22
- "data-softr-error-overlay": "error",
23
8
  children: /* @__PURE__ */ jsxs("div", {
24
9
  style: s.card,
25
10
  children: [
@@ -41,33 +26,8 @@ function DefaultErrorOverlay({ error, reset }) {
41
26
  });
42
27
  }
43
28
  function DevView({ error, reset }) {
44
- const running = useSyncExternalStore(subscribeToAgentTurn, isAgentTurnRunning, () => false);
45
- const [retryPending, setRetryPending] = useState(running);
46
- const router = useRouter();
47
- useEffect(() => {
48
- if (running) {
49
- setRetryPending(true);
50
- return;
51
- }
52
- if (!retryPending) return;
53
- const showError = () => setRetryPending(false);
54
- const timer = setTimeout(() => {
55
- router.invalidate().then(() => {
56
- reset?.();
57
- showError();
58
- }, showError);
59
- }, 1e3);
60
- return () => clearTimeout(timer);
61
- }, [
62
- running,
63
- retryPending,
64
- reset,
65
- router
66
- ]);
67
- if (running || retryPending) return /* @__PURE__ */ jsx(UpdatingView, {});
68
29
  return /* @__PURE__ */ jsx("div", {
69
30
  style: s.overlay,
70
- "data-softr-error-overlay": "error",
71
31
  children: /* @__PURE__ */ jsxs("div", {
72
32
  style: s.card,
73
33
  children: [
@@ -96,39 +56,6 @@ function DevView({ error, reset }) {
96
56
  })
97
57
  });
98
58
  }
99
- function UpdatingView() {
100
- return /* @__PURE__ */ jsx("div", {
101
- style: s.overlay,
102
- "data-softr-error-overlay": "updating",
103
- children: /* @__PURE__ */ jsxs("div", {
104
- style: s.card,
105
- children: [/* @__PURE__ */ jsx("div", {
106
- style: s.pending,
107
- children: /* @__PURE__ */ jsxs("svg", {
108
- style: icon.md,
109
- viewBox: "0 0 24 24",
110
- fill: "none",
111
- stroke: "currentColor",
112
- strokeWidth: "2",
113
- strokeLinecap: "round",
114
- strokeLinejoin: "round",
115
- children: [
116
- /* @__PURE__ */ jsx("path", { d: "M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" }),
117
- /* @__PURE__ */ jsx("path", { d: "M21 3v5h-5" }),
118
- /* @__PURE__ */ jsx("path", { d: "M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" }),
119
- /* @__PURE__ */ jsx("path", { d: "M8 16H3v5" })
120
- ]
121
- })
122
- }), /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("h1", {
123
- style: s.heading,
124
- children: "Updating the app…"
125
- }), /* @__PURE__ */ jsx("p", {
126
- style: s.body,
127
- children: "The agent is still working on this change. The preview will recover once it is complete."
128
- })] })]
129
- })
130
- });
131
- }
132
59
  function cleanStack(stack) {
133
60
  return stack.split("\n").slice(1).filter((line) => line.trim().startsWith("at ") && !line.includes("/node_modules/")).map((line) => line.replace(/https?:\/\/[^/]+\//, "").replace(/\?[^):]+/g, "")).join("\n");
134
61
  }
@@ -301,17 +228,6 @@ const s = {
301
228
  color: "var(--destructive, oklch(0.577 0.245 27.325))",
302
229
  flexShrink: 0
303
230
  },
304
- pending: {
305
- display: "flex",
306
- alignItems: "center",
307
- justifyContent: "center",
308
- width: "2.5rem",
309
- height: "2.5rem",
310
- borderRadius: "var(--radius, 0.625rem)",
311
- background: "var(--muted, oklch(0.97 0 0))",
312
- color: "var(--muted-foreground, oklch(0.556 0 0))",
313
- flexShrink: 0
314
- },
315
231
  heading: {
316
232
  margin: 0,
317
233
  fontSize: "1rem",
@@ -351,7 +267,7 @@ const s = {
351
267
  border: "1px solid var(--input, oklch(0.922 0 0))",
352
268
  borderRadius: "var(--radius, 0.625rem)",
353
269
  background: "var(--input-fill, oklch(1 0 0))",
354
- color: "var(--foreground, oklch(0.145 0 0))",
270
+ color: "var(--primary-foreground, oklch(0.985 0 0))",
355
271
  fontFamily: "inherit",
356
272
  fontSize: "0.875rem",
357
273
  fontWeight: 500,
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { SoftrUserGroupId, SoftrUserGroups, User } from "./auth/user.js";
2
- import { ActionPermissions, SoftrDatasources, defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
2
+ import { ActionPermissions, SoftrDatasources, defineComments, defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
3
3
  import { FileAttachment } from "./core/file-upload.js";
4
4
  import { FileItem, useFileUpload } from "./core/use-file-upload.js";
5
+ import { CommentMention, CommentThread, CommentView } from "./comments/comments.js";
5
6
  import { SoftrContext } from "./auth/context.js";
6
7
  import { checkAccess, defineVisibility, isAuthorized } from "./visibility/visibility.js";
7
8
  import { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError } from "./visibility/route.js";
@@ -10,4 +11,4 @@ import { softrMcpMiddleware } from "./mcp.js";
10
11
  import { useUserTimezone } from "./timezone.js";
11
12
  import { DefaultErrorOverlay } from "./default-error-overlay.js";
12
13
  import { Audience } from "./visibility/audience.js";
13
- export { type ActionPermissions, Audience, DefaultErrorOverlay, type FileAttachment, type FileItem, type SoftrContext, type SoftrDatasources, type SoftrUserGroupId, type SoftrUserGroups, type User, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, useCurrentUser, useFileUpload, useUserTimezone };
14
+ export { type ActionPermissions, Audience, type CommentMention, type CommentThread, type CommentView, DefaultErrorOverlay, type FileAttachment, type FileItem, type SoftrContext, type SoftrDatasources, type SoftrUserGroupId, type SoftrUserGroups, type User, assertRouteAccess, checkAccess, defineComments, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, useCurrentUser, useFileUpload, useUserTimezone };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { useFileUpload } from "./core/use-file-upload.js";
2
- import { defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
2
+ import { defineComments, defineIntegration, defineMutation, defineQuery, defineWorkflow } from "./action.js";
3
3
  import { checkAccess, defineVisibility, isAuthorized } from "./visibility/visibility.js";
4
4
  import { assertRouteAccess, guardRoute, isAccessError, isAlreadyAuthenticatedError, isForbiddenError, isUnauthenticatedError } from "./visibility/route.js";
5
5
  import { getSoftrContext, initialSoftrContext, softrAuthMiddleware, useCurrentUser } from "./auth/isomorphic.js";
@@ -8,4 +8,4 @@ import { useUserTimezone } from "./timezone.js";
8
8
  import { DefaultErrorOverlay } from "./default-error-overlay.js";
9
9
  import { Audience } from "./visibility/audience.js";
10
10
 
11
- export { Audience, DefaultErrorOverlay, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, useCurrentUser, useFileUpload, useUserTimezone };
11
+ export { Audience, DefaultErrorOverlay, assertRouteAccess, checkAccess, defineComments, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, useCurrentUser, useFileUpload, useUserTimezone };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.0.53";
2
+ var version = "0.0.54-alpha.0";
3
3
 
4
4
  //#endregion
5
5
  export { version };
package/dist/server.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { upload } from "./core/file-upload.js";
2
+ import { comments } from "./comments/comments.js";
2
3
  import { auth } from "./auth/server.js";
3
4
  import { ds } from "./datasource/datasource.js";
4
5
  import { workflow } from "./core/workflow.js";
5
6
  import { integration } from "./core/integration.js";
6
7
  import "@tanstack/react-start/server-only";
7
- export { auth, ds, integration, upload, workflow };
8
+ export { auth, comments, ds, integration, upload, workflow };
package/dist/server.js CHANGED
@@ -3,6 +3,7 @@ import { ds } from "./datasource/datasource.js";
3
3
  import { auth } from "./auth/server.js";
4
4
  import { workflow } from "./core/workflow.js";
5
5
  import { integration } from "./core/integration.js";
6
+ import { comments } from "./comments/comments.js";
6
7
  import "@tanstack/react-start/server-only";
7
8
 
8
- export { auth, ds, integration, upload, workflow };
9
+ export { auth, comments, ds, integration, upload, workflow };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softr/sdk",
3
- "version": "0.0.53",
3
+ "version": "0.0.54-alpha.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
@@ -17,6 +17,8 @@
17
17
  "typecheck": "tsc --noEmit"
18
18
  },
19
19
  "dependencies": {
20
+ "marked": "^18.0.14",
21
+ "xss": "^1.0.15",
20
22
  "zod": "^4.4.3"
21
23
  },
22
24
  "devDependencies": {
@@ -11,7 +11,7 @@ description: >-
11
11
  metadata:
12
12
  type: core
13
13
  library: softr-sdk
14
- library_version: 0.0.53
14
+ library_version: 0.0.52
15
15
  ---
16
16
 
17
17
  # Softr SDK core
@@ -29,9 +29,9 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
29
29
 
30
30
  | Import | Runs | Holds |
31
31
  | --- | --- | --- |
32
- | `@softr/sdk` | Both | `defineQuery`, `defineMutation`, `defineVisibility`, `guardRoute`, `assertRouteAccess`, `isAuthorized`, `checkAccess`, `Audience`, `useCurrentUser`, `useUserTimezone`, `getSoftrContext`, `initialSoftrContext`, `softrAuthMiddleware`, the error predicates, and every type |
33
- | `@softr/sdk/server` | Server only | `ds`, `auth`, `workflow`, `integration` |
34
- | `@softr/sdk/client` | Browser only | Nothing yet, reserved for browser-only utilities |
32
+ | `@softr/sdk` | Both | `defineQuery`, `defineMutation`, `defineComments`, `defineVisibility`, `guardRoute`, `assertRouteAccess`, `isAuthorized`, `checkAccess`, `Audience`, `useCurrentUser`, `useUserTimezone`, `getSoftrContext`, `initialSoftrContext`, `softrAuthMiddleware`, the error predicates, and every type |
33
+ | `@softr/sdk/server` | Server only | `ds`, `auth`, `workflow`, `integration`, `comments` |
34
+ | `@softr/sdk/client` | Browser only | `useCommentComposer` |
35
35
  | `@softr/sdk/vite` | Build | `softr()`, already in `vite.config.ts` |
36
36
 
37
37
  ## Sub-Skills
@@ -48,6 +48,7 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
48
48
  | Sign a visitor in, out, or up | [auth/SKILL.md](./auth/SKILL.md) |
49
49
  | Call a connected third-party API (Slack, Stripe, Gmail, …) | [integrations/SKILL.md](./integrations/SKILL.md) |
50
50
  | Open the app to AI assistants over MCP | [mcp/SKILL.md](./mcp/SKILL.md) |
51
+ | Add comments to a record | [comments/SKILL.md](./comments/SKILL.md) |
51
52
 
52
53
  ## Quick Decision Tree
53
54
 
@@ -82,6 +83,9 @@ Calling a connected third-party API (Slack, Stripe, Gmail, Google Calendar, …)
82
83
  Opening the app to an AI assistant over MCP?
83
84
  load softr-sdk/mcp
84
85
 
86
+ Letting visitors comment on a record?
87
+ load softr-sdk/comments
88
+
85
89
  Writing a server function, loader, or middleware in general?
86
90
  not a Softr concern. Use the TanStack Start skills.
87
91
  ```
@@ -16,7 +16,7 @@ description: >-
16
16
  metadata:
17
17
  type: sub-skill
18
18
  library: softr-sdk
19
- library_version: 0.0.53
19
+ library_version: 0.0.52
20
20
  ---
21
21
 
22
22
  **Boundary.** `auth` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build.
@@ -12,7 +12,7 @@ description: >-
12
12
  metadata:
13
13
  type: sub-skill
14
14
  library: softr-sdk
15
- library_version: 0.0.53
15
+ library_version: 0.0.52
16
16
  ---
17
17
 
18
18
  **Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -0,0 +1,149 @@
1
+ ---
2
+ name: comments
3
+ description: >-
4
+ Comment threads on records: defineComments from @softr/sdk declares which
5
+ datasource has comments; comments.list, create, delete, react, subscription,
6
+ setSubscription and searchMentions from @softr/sdk/server read and write one
7
+ record's thread inside a server function; useCommentComposer from
8
+ @softr/sdk/client keeps a textarea's text and @mentions in step. Bodies come
9
+ back as sanitised bodyHtml. A null thread means render nothing.
10
+ metadata:
11
+ type: sub-skill
12
+ library: softr-sdk
13
+ library_version: 0.0.52
14
+ ---
15
+
16
+ **Boundary.** `comments` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler. Importing it into a component fails the build. `useCommentComposer` lives on `@softr/sdk/client` and runs in components. `defineComments` comes from `@softr/sdk` and can be imported anywhere.
17
+
18
+ # Comments on records
19
+
20
+ Every record of a commented datasource has one thread. The SDK reads and writes it. The app owns everything you see: layout, copy, sort order, dates.
21
+
22
+ ## Setup
23
+
24
+ ```ts
25
+ import { createServerFn } from '@tanstack/react-start'
26
+ import { defineComments, type CommentMention } from '@softr/sdk'
27
+ import { comments } from '@softr/sdk/server'
28
+ import { useCommentComposer } from '@softr/sdk/client'
29
+ ```
30
+
31
+ ## Declare the comments action
32
+
33
+ ```ts
34
+ export const orderComments = defineComments({
35
+ id: 'orderComments',
36
+ datasourceId: '8f14e45f-ceea-467a-9d0b-2c1a0e3b5d77',
37
+ })
38
+ ```
39
+
40
+ - `id` is a name you choose, unique across the app. `datasourceId` is the table whose records get threads.
41
+ - There is no `permissions`. Who may read, post and delete is set in Softr under Settings → Comments, the same settings native comment blocks use.
42
+ - Declare it once per datasource. Two declarations on the same datasource are the **same** threads, so "internal notes" next to "customer comments" on one table is not possible.
43
+ - Write the object inline. Passing it through a variable hides it from the build.
44
+
45
+ ## Read a thread
46
+
47
+ ```ts
48
+ const getThread = createServerFn({ method: 'GET' })
49
+ .validator((data: { recordId: string; offset?: number | null }) => data)
50
+ .handler(({ data }) => comments.list(orderComments, { recordId: data.recordId, offset: data.offset }))
51
+ ```
52
+
53
+ `comments.list` answers `null` when this visitor cannot see comments on the record: comments are off for the app, the table is not set up yet, or the visitor's group may not read them. Render nothing for `null`, not an error.
54
+
55
+ Otherwise it answers:
56
+
57
+ | Field | What it is |
58
+ | --- | --- |
59
+ | `items` | The comments, newest first. Each is a `CommentView` |
60
+ | `canPost` | Whether this visitor may post. Hide the composer when false |
61
+ | `allowMentions` | Whether `@` opens a mention menu. Do not call `searchMentions` when false |
62
+ | `quickReactions` | The five `{ emoji, label }` a comment can be reacted with. There are no others |
63
+ | `total`, `nextOffset` | Paging. Pass `nextOffset` back as `offset` for older comments. `null` on the last page |
64
+
65
+ A `CommentView` is `{ id, bodyHtml, attachments, author: { name, avatar, isDeactivated }, createdAt, canDelete, reactions }`. `createdAt` is ISO 8601. Format it with `useUserTimezone`. Each reaction is `{ emoji, label, count, isSelected }`.
66
+
67
+ `bodyHtml` is already sanitised. Render it with `dangerouslySetInnerHTML` and nothing else:
68
+
69
+ ```tsx
70
+ <div className="comment-body" dangerouslySetInnerHTML={{ __html: comment.bodyHtml }} />
71
+ ```
72
+
73
+ It holds `p`, `br`, `strong`, `em`, `u`, `s`, `del`, `a`, `ul`, `ol`, `li`, `code`, `pre`, `blockquote`, `h1`–`h3` and mention `span[data-type="mention"]`. Links open in a new tab. Style those tags under the body's class, because the reset strips list bullets, link colour and spacing.
74
+
75
+ ## Post a comment
76
+
77
+ ```ts
78
+ const postComment = createServerFn({ method: 'POST' })
79
+ .validator((data: { recordId: string; body: string; mentions: Array<CommentMention> }) => data)
80
+ .handler(({ data }) => comments.create(orderComments, data))
81
+ ```
82
+
83
+ Pass the composer's `text` as `body` and its `mentions` as they are. The SDK stores the mentions in the format native blocks read, and the people mentioned get an email. A mention whose `@name` was edited is posted as plain text. There is no editing a posted comment and no attachment upload.
84
+
85
+ ## The composer
86
+
87
+ ```tsx
88
+ const composer = useCommentComposer()
89
+
90
+ <textarea {...composer.textareaProps} placeholder="Write a comment" />
91
+ {/* While composer.query is not null, list findPeople's answer for it. Picking a person calls composer.pick(person). */}
92
+ <button
93
+ disabled={composer.text.trim() === ''}
94
+ onClick={async () => {
95
+ await postComment({ data: { recordId, body: composer.text, mentions: composer.mentions } })
96
+ composer.reset()
97
+ }}
98
+ >
99
+ Post
100
+ </button>
101
+ ```
102
+
103
+ - Spread `textareaProps` onto the `<textarea>`. It carries the `ref`, `value`, `onChange` and `onSelect` the composer needs, so do not replace them.
104
+ - `query` is what follows `@` before the caret, or `null`. It is `""` the moment `@` is typed.
105
+ - `pick({ name, email })` writes `@name`, records the mention and puts the caret after it.
106
+ - `reset()` empties the box. Call it after the post succeeds, never before.
107
+
108
+ ## Mentions
109
+
110
+ ```ts
111
+ const findPeople = createServerFn({ method: 'GET' })
112
+ .validator((data: { query: string }) => data)
113
+ .handler(({ data }) => comments.searchMentions(orderComments, data))
114
+ ```
115
+
116
+ It answers `[{ name, email, avatar }]`, the people this visitor may mention whose name or email matches. Who is mentionable is set in Settings → Comments, per user group.
117
+
118
+ ## Delete, react, subscribe
119
+
120
+ ```ts
121
+ await comments.delete(orderComments, { recordId, commentId })
122
+ await comments.react(orderComments, { recordId, commentId, emoji: '👍', selected: !reaction.isSelected })
123
+ const subscribed = await comments.subscription(orderComments, { recordId })
124
+ await comments.setSubscription(orderComments, { recordId, subscribed: !subscribed })
125
+ ```
126
+
127
+ Each runs inside its own server function, like `list`.
128
+
129
+ - Offer delete only where `canDelete` is true.
130
+ - `react` adds the visitor's reaction when `selected` is true and removes it when false. `emoji` must be one of `quickReactions`.
131
+ - A subscription is whether the visitor gets an email when someone comments on this record.
132
+
133
+ Re-read the thread after each write. The writes answer nothing.
134
+
135
+ ## Failure modes
136
+
137
+ **Keying the thread on the URL instead of the record.** Pass the fetched `record.id` as `recordId`, never a route param or a search param. A param can be a slug, stale, or a different record's id, and the thread then shows another record's comments.
138
+
139
+ **Treating `null` as an error.** A visitor who may not read comments gets `null`. Render nothing. An error box for it shows every such visitor a broken page.
140
+
141
+ **Rendering a body as text, or sanitising it again.** Use `bodyHtml` with `dangerouslySetInnerHTML`. As text it shows raw tags, and a second sanitiser strips the mention markup.
142
+
143
+ **Inventing reactions.** Only the five in `quickReactions` can be stored. `react` throws for any other emoji.
144
+
145
+ **Calling `comments.*` from a component.** It is server only. Wrap each call in a `createServerFn`.
146
+
147
+ ## References
148
+
149
+ - [record-queries/SKILL.md](../record-queries/SKILL.md): fetching the record whose `id` keys the thread
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.53
12
+ library_version: 0.0.52
13
13
  ---
14
14
 
15
15
  # The current user
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.53
13
+ library_version: 0.0.52
14
14
  ---
15
15
 
16
16
  **Boundary.** `upload` lives on `@softr/sdk/server` and runs inside server functions. `useFileUpload` and `FileItem` live on `@softr/sdk` and run in components. `FileAttachment` comes from `@softr/sdk` and can be imported anywhere.
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.53
13
+ library_version: 0.0.52
14
14
  ---
15
15
 
16
16
  **Boundary.** `integration` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineIntegration` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.53
12
+ library_version: 0.0.52
13
13
  ---
14
14
 
15
15
  **Boundary.** `softrMcpMiddleware` lives on `@softr/sdk` and is registered once, in `src/start.ts`. The actions you hand it are the same `defineQuery` / `defineMutation` values the app's own pages use — declared wherever they already live, not duplicated for MCP.
@@ -11,7 +11,7 @@ description: >-
11
11
  metadata:
12
12
  type: sub-skill
13
13
  library: softr-sdk
14
- library_version: 0.0.53
14
+ library_version: 0.0.52
15
15
  ---
16
16
 
17
17
  **Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineMutation` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -13,7 +13,7 @@ description: >-
13
13
  metadata:
14
14
  type: sub-skill
15
15
  library: softr-sdk
16
- library_version: 0.0.53
16
+ library_version: 0.0.52
17
17
  ---
18
18
 
19
19
  **Boundary.** `ds` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler or a server-side route hook — importing it into a component fails the build. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.53
12
+ library_version: 0.0.52
13
13
  ---
14
14
 
15
15
  **Boundary.** `Audience` and `defineVisibility` come from `@softr/sdk` and work anywhere.
@@ -13,7 +13,7 @@ description: >-
13
13
  metadata:
14
14
  type: sub-skill
15
15
  library: softr-sdk
16
- library_version: 0.0.53
16
+ library_version: 0.0.52
17
17
  ---
18
18
 
19
19
  **Boundary.** `defineVisibility`, the access functions and the error predicates come from `@softr/sdk` and work anywhere.
@@ -9,7 +9,7 @@ description: >-
9
9
  metadata:
10
10
  type: sub-skill
11
11
  library: softr-sdk
12
- library_version: 0.0.53
12
+ library_version: 0.0.52
13
13
  ---
14
14
 
15
15
  **Boundary.** `workflow` lives on `@softr/sdk/server` and runs only inside a `createServerFn` handler — importing it into a component fails the build. `defineWorkflow` and every type come from `@softr/sdk` and can be imported anywhere.