@softr/sdk 0.0.52 → 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 +31 -1
- package/dist/action.js +26 -1
- package/dist/client.d.ts +2 -1
- package/dist/client.js +2 -1
- package/dist/comments/comments.d.ts +137 -0
- package/dist/comments/comments.js +329 -0
- package/dist/comments/use-comment-composer.d.ts +37 -0
- package/dist/comments/use-comment-composer.js +127 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +2 -2
- package/dist/package.js +1 -1
- package/dist/server.d.ts +2 -1
- package/dist/server.js +2 -1
- package/package.json +3 -1
- package/skills/softr-sdk/SKILL.md +7 -3
- package/skills/softr-sdk/comments/SKILL.md +149 -0
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
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
319
|
+
}
|
|
320
|
+
function escapeAttribute(value) {
|
|
321
|
+
return value.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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 };
|
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
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.
|
|
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": {
|
|
@@ -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 |
|
|
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
|
```
|
|
@@ -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
|