@softr/sdk 0.0.62 → 0.0.63-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.
@@ -0,0 +1,46 @@
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
+ * To edit a comment, pass its source as initial, in a composer mounted only while editing.
14
+ * `initial` is read once, on mount.
15
+ *
16
+ * @example
17
+ * const composer = useCommentComposer()
18
+ * <textarea {...composer.textareaProps} />
19
+ * {composer.query !== null && <MentionMenu query={composer.query} onPick={composer.pick} />}
20
+ */
21
+ declare function useCommentComposer(options?: {
22
+ /** Fills the box to edit a comment: pass its `source`. */
23
+ initial?: {
24
+ text: string;
25
+ mentions: ReadonlyArray<CommentMention>;
26
+ };
27
+ }): {
28
+ text: string;
29
+ mentions: Array<CommentMention>;
30
+ /** What follows the `@` before the caret, or `null` when the caret is not in a mention. */
31
+ query: string | null;
32
+ textareaProps: {
33
+ ref: RefObject<HTMLTextAreaElement | null>;
34
+ value: string;
35
+ onChange: (event: ChangeEvent<HTMLTextAreaElement>) => void;
36
+ onSelect: (event: SyntheticEvent<HTMLTextAreaElement>) => void;
37
+ };
38
+ /** Replaces the `@` fragment with the person's `@name`, and puts the caret after it. */
39
+ pick: (person: {
40
+ name: string;
41
+ email: string;
42
+ }) => void;
43
+ reset: () => void;
44
+ };
45
+ //#endregion
46
+ export { useCommentComposer };
@@ -0,0 +1,134 @@
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
+ * To edit a comment, pass its source as initial, in a composer mounted only while editing.
18
+ * `initial` is read once, on mount.
19
+ *
20
+ * @example
21
+ * const composer = useCommentComposer()
22
+ * <textarea {...composer.textareaProps} />
23
+ * {composer.query !== null && <MentionMenu query={composer.query} onPick={composer.pick} />}
24
+ */
25
+ function useCommentComposer(options) {
26
+ const [state, setState] = useState(() => options?.initial === void 0 ? EMPTY : {
27
+ text: options.initial.text,
28
+ mentions: [...options.initial.mentions],
29
+ caret: options.initial.text.length
30
+ });
31
+ const ref = useRef(null);
32
+ const pendingCaret = useRef(null);
33
+ useLayoutEffect(() => {
34
+ const caret = pendingCaret.current;
35
+ if (caret === null || ref.current === null) return;
36
+ pendingCaret.current = null;
37
+ ref.current.focus();
38
+ ref.current.setSelectionRange(caret, caret);
39
+ });
40
+ const fragment = findMentionQuery(state.text, state.caret, state.mentions);
41
+ return {
42
+ text: state.text,
43
+ mentions: state.mentions,
44
+ query: fragment?.query ?? null,
45
+ textareaProps: {
46
+ ref,
47
+ value: state.text,
48
+ onChange(event) {
49
+ const next = event.currentTarget.value;
50
+ const caret = event.currentTarget.selectionStart;
51
+ setState((previous) => ({
52
+ text: next,
53
+ mentions: remapMentions(previous.text, next, previous.mentions),
54
+ caret
55
+ }));
56
+ },
57
+ onSelect(event) {
58
+ const caret = event.currentTarget.selectionStart;
59
+ setState((previous) => previous.caret === caret ? previous : {
60
+ ...previous,
61
+ caret
62
+ });
63
+ }
64
+ },
65
+ pick(person) {
66
+ assert(fragment, "pick needs an @ fragment under the caret");
67
+ const label = `@${person.name}`;
68
+ const text = `${state.text.slice(0, fragment.start)}${label} ${state.text.slice(state.caret)}`;
69
+ const added = {
70
+ ...person,
71
+ start: fragment.start,
72
+ end: fragment.start + label.length
73
+ };
74
+ const caret = added.end + 1;
75
+ pendingCaret.current = caret;
76
+ setState({
77
+ text,
78
+ mentions: [...remapMentions(state.text, text, state.mentions), added],
79
+ caret
80
+ });
81
+ },
82
+ reset() {
83
+ setState(EMPTY);
84
+ }
85
+ };
86
+ }
87
+ /** A fragment longer than this is prose, not a name being typed. */
88
+ const MENTION_QUERY_MAX_LENGTH = 32;
89
+ /**
90
+ * Moves mention ranges through an edit. A textarea reports only its new value, so the edit is the
91
+ * span between the longest common prefix and suffix. Ranges after it shift. Ranges it reached into
92
+ * are dropped, which is how deleting part of a name un-mentions that person.
93
+ */
94
+ function remapMentions(previous, next, mentions) {
95
+ if (previous === next) return mentions;
96
+ let prefix = 0;
97
+ while (prefix < previous.length && prefix < next.length && previous[prefix] === next[prefix]) prefix += 1;
98
+ let suffix = 0;
99
+ while (suffix < previous.length - prefix && suffix < next.length - prefix && previous[previous.length - 1 - suffix] === next[next.length - 1 - suffix]) suffix += 1;
100
+ const editStart = prefix;
101
+ const editEnd = previous.length - suffix;
102
+ const delta = next.length - previous.length;
103
+ return mentions.flatMap((mention) => {
104
+ if (mention.end <= editStart) return [mention];
105
+ if (mention.start >= editEnd) return [{
106
+ ...mention,
107
+ start: mention.start + delta,
108
+ end: mention.end + delta
109
+ }];
110
+ return [];
111
+ });
112
+ }
113
+ /**
114
+ * The `@` fragment the caret sits in, or `null`. An `@` does not count when:
115
+ * - it does not start a word, so an email address opens no menu;
116
+ * - it belongs to a mention already made;
117
+ * - the fragment ran past a newline or is long enough to be prose. Spaces are fine, names have them.
118
+ */
119
+ function findMentionQuery(text, caret, mentions) {
120
+ const before = text.slice(0, caret);
121
+ const at = before.lastIndexOf("@");
122
+ if (at === -1) return null;
123
+ if (at > 0 && !/\s/.test(before.charAt(at - 1))) return null;
124
+ if (mentions.some((mention) => at >= mention.start && at < mention.end)) return null;
125
+ const query = before.slice(at + 1);
126
+ if (query.includes("\n") || query.length > MENTION_QUERY_MAX_LENGTH) return null;
127
+ return {
128
+ query,
129
+ start: at
130
+ };
131
+ }
132
+
133
+ //#endregion
134
+ export { useCommentComposer };
@@ -1,4 +1,4 @@
1
- import { DatasourceAction } from "../action.js";
1
+ import { CommentsAction, DatasourceAction } from "../action.js";
2
2
 
3
3
  //#region src/core/file-upload.d.ts
4
4
 
@@ -10,7 +10,12 @@ type FileAttachment = {
10
10
  url: string;
11
11
  filename: string;
12
12
  id: string;
13
+ type?: string | null;
13
14
  };
14
- declare function upload(action: DatasourceAction, file: File): Promise<FileAttachment>;
15
+ /**
16
+ * A COMMENTS action uploads a comment attachment.
17
+ * The platform checks the comment settings.
18
+ */
19
+ declare function upload(action: DatasourceAction | CommentsAction, file: File): Promise<FileAttachment>;
15
20
  //#endregion
16
21
  export { FileAttachment, upload };
@@ -5,6 +5,10 @@ import { platformApiUrl } from "../auth/platform.js";
5
5
  import { randomUUID } from "node:crypto";
6
6
 
7
7
  //#region src/core/file-upload.ts
8
+ /**
9
+ * A COMMENTS action uploads a comment attachment.
10
+ * The platform checks the comment settings.
11
+ */
8
12
  async function upload(action, file) {
9
13
  const { applicationId } = sessionConfig();
10
14
  const baseUrl = await platformApiUrl();
@@ -23,7 +27,8 @@ async function upload(action, file) {
23
27
  return {
24
28
  url: resultJSON.url,
25
29
  filename: resultJSON.filename || "Untitled file",
26
- id: resultJSON.id || randomUUID()
30
+ id: resultJSON.id || randomUUID(),
31
+ type: resultJSON.type ?? null
27
32
  };
28
33
  }
29
34
 
@@ -1,4 +1,4 @@
1
- import { DatasourceAction } from "../action.js";
1
+ import { CommentsAction, DatasourceAction } from "../action.js";
2
2
  import { FileAttachment } from "./file-upload.js";
3
3
 
4
4
  //#region src/core/use-file-upload.d.ts
@@ -16,7 +16,7 @@ type FileItem = {
16
16
  filename: string;
17
17
  error: Error;
18
18
  };
19
- declare function useFileUpload(action: DatasourceAction, options?: {
19
+ declare function useFileUpload(action: DatasourceAction | CommentsAction, options?: {
20
20
  initialAttachments?: FileAttachment[];
21
21
  }): {
22
22
  files: FileItem[];
@@ -1,4 +1,4 @@
1
- import * as react0 from "react";
1
+ import * as react1 from "react";
2
2
  import { ReactNode } from "react";
3
3
  import { z } from "zod";
4
4
 
@@ -94,7 +94,7 @@ declare function FormattingScope({
94
94
  /** A BCP 47 locale, such as `de-DE`. It drops the app's fixed date format inside the scope. */
95
95
  locale?: string;
96
96
  children?: ReactNode;
97
- }): react0.FunctionComponentElement<react0.ProviderProps<Scope>>;
97
+ }): react1.FunctionComponentElement<react1.ProviderProps<Scope>>;
98
98
  /**
99
99
  * A date in the app's format, safe to render on the server. A timestamp whose
100
100
  * zone is not known yet renders `fallback` until the browser supplies it, so
@@ -115,6 +115,6 @@ declare function FormattedDate({
115
115
  time?: boolean;
116
116
  /** What renders while the visitor's zone is unknown. */
117
117
  fallback?: ReactNode;
118
- }): react0.FunctionComponentElement<react0.FragmentProps>;
118
+ }): react1.FunctionComponentElement<react1.FragmentProps>;
119
119
  //#endregion
120
120
  export { DateOptions, DateParts, DateSeparator, DateValue, FixedDateFormat, FormattedDate, Formatting, FormattingScope, WeekDay, formatDate, formatDateTime, formatNumber, useFormatting };
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 { CommentAttachmentInput, CommentMention, CommentThread, CommentView } from "./comments/comments.js";
5
6
  import { DateOptions, DateParts, DateSeparator, DateValue, FixedDateFormat, FormattedDate, Formatting, FormattingScope, WeekDay, formatDate, formatDateTime, formatNumber, useFormatting } from "./formatting.js";
6
7
  import { SoftrContext } from "./auth/context.js";
7
8
  import { checkAccess, defineVisibility, isAuthorized } from "./visibility/visibility.js";
@@ -12,4 +13,4 @@ import { useUserTimezone } from "./timezone.js";
12
13
  import { PwaInstall, PwaPlatform, SoftrPwaHead, usePwaInstall } from "./pwa.js";
13
14
  import { DefaultErrorOverlay } from "./default-error-overlay.js";
14
15
  import { Audience } from "./visibility/audience.js";
15
- export { type ActionPermissions, Audience, type DateOptions, type DateParts, type DateSeparator, type DateValue, DefaultErrorOverlay, type FileAttachment, type FileItem, type FixedDateFormat, FormattedDate, type Formatting, FormattingScope, type PwaInstall, type PwaPlatform, type SoftrContext, type SoftrDatasources, SoftrPwaHead, type SoftrUserGroupId, type SoftrUserGroups, type User, type WeekDay, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, formatDate, formatDateTime, formatNumber, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, useCurrentUser, useFileUpload, useFormatting, usePwaInstall, useUserTimezone };
16
+ export { type ActionPermissions, Audience, type CommentAttachmentInput, type CommentMention, type CommentThread, type CommentView, type DateOptions, type DateParts, type DateSeparator, type DateValue, DefaultErrorOverlay, type FileAttachment, type FileItem, type FixedDateFormat, FormattedDate, type Formatting, FormattingScope, type PwaInstall, type PwaPlatform, type SoftrContext, type SoftrDatasources, SoftrPwaHead, type SoftrUserGroupId, type SoftrUserGroups, type User, type WeekDay, assertRouteAccess, checkAccess, defineComments, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, formatDate, formatDateTime, formatNumber, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, useCurrentUser, useFileUpload, useFormatting, usePwaInstall, 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 { FormattedDate, FormattingScope, formatDate, formatDateTime, formatNumber, useFormatting } from "./formatting.js";
@@ -10,4 +10,4 @@ import { useUserTimezone } from "./timezone.js";
10
10
  import { DefaultErrorOverlay } from "./default-error-overlay.js";
11
11
  import { Audience } from "./visibility/audience.js";
12
12
 
13
- export { Audience, DefaultErrorOverlay, FormattedDate, FormattingScope, SoftrPwaHead, assertRouteAccess, checkAccess, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, formatDate, formatDateTime, formatNumber, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, useCurrentUser, useFileUpload, useFormatting, usePwaInstall, useUserTimezone };
13
+ export { Audience, DefaultErrorOverlay, FormattedDate, FormattingScope, SoftrPwaHead, assertRouteAccess, checkAccess, defineComments, defineIntegration, defineMutation, defineQuery, defineVisibility, defineWorkflow, formatDate, formatDateTime, formatNumber, getSoftrContext, guardRoute, initialSoftrContext, isAccessError, isAlreadyAuthenticatedError, isAuthorized, isForbiddenError, isUnauthenticatedError, softrAuthMiddleware, softrMcpMiddleware, useCurrentUser, useFileUpload, useFormatting, usePwaInstall, useUserTimezone };
package/dist/package.js CHANGED
@@ -1,5 +1,5 @@
1
1
  //#region package.json
2
- var version = "0.0.62";
2
+ var version = "0.0.63-alpha.0";
3
3
 
4
4
  //#endregion
5
5
  export { version };
package/dist/pwa.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import * as react3 from "react";
1
+ import * as react0 from "react";
2
2
  import "@tanstack/react-start";
3
3
 
4
4
  //#region src/pwa.d.ts
@@ -23,7 +23,7 @@ import "@tanstack/react-start";
23
23
  * )
24
24
  * }
25
25
  */
26
- declare function SoftrPwaHead(): react3.FunctionComponentElement<react3.FragmentProps>;
26
+ declare function SoftrPwaHead(): react0.FunctionComponentElement<react0.FragmentProps>;
27
27
  type PwaPlatform = "ios" | "android" | "desktop";
28
28
  type PwaInstall = {
29
29
  available: false;
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.62",
3
+ "version": "0.0.63-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.61
14
+ library_version: 0.0.63-alpha.0
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`, `FormattedDate`, `FormattingScope`, `useFormatting`, `formatDate`, `formatDateTime`, `formatNumber`, `SoftrPwaHead`, `usePwaInstall`, `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`, `FormattedDate`, `FormattingScope`, `useFormatting`, `formatDate`, `formatDateTime`, `formatNumber`, `SoftrPwaHead`, `usePwaInstall`, `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
@@ -50,6 +50,7 @@ Each sub-skill stands alone for its task and repeats the boundary above, so most
50
50
  | Open the app to AI assistants over MCP | [mcp/SKILL.md](./mcp/SKILL.md) |
51
51
  | Let visitors install the app on their home screen | [pwa/SKILL.md](./pwa/SKILL.md) |
52
52
  | Show a date, a time or a number | [formatting/SKILL.md](./formatting/SKILL.md) |
53
+ | Add comments to a record | [comments/SKILL.md](./comments/SKILL.md) |
53
54
 
54
55
  ## Quick Decision Tree
55
56
 
@@ -90,6 +91,9 @@ Adding an install button, or making the app installable?
90
91
  Showing a date, a time or a number on a page?
91
92
  load softr-sdk/formatting
92
93
 
94
+ Letting visitors comment on a record?
95
+ load softr-sdk/comments
96
+
93
97
  Writing a server function, loader, or middleware in general?
94
98
  not a Softr concern. Use the TanStack Start skills.
95
99
  ```
@@ -16,7 +16,7 @@ description: >-
16
16
  metadata:
17
17
  type: sub-skill
18
18
  library: softr-sdk
19
- library_version: 0.0.61
19
+ library_version: 0.0.63-alpha.0
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.61
15
+ library_version: 0.0.63-alpha.0
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,181 @@
1
+ ---
2
+ name: comments
3
+ description: >-
4
+ Comment threads on records: defineComments from @softr/sdk declares which
5
+ datasource has comments; comments.list, create, update, delete, react,
6
+ subscription, setSubscription and searchMentions from @softr/sdk/server read
7
+ and write one record's thread inside a server function; useCommentComposer
8
+ and useFileUpload from @softr/sdk/client keep a textarea's text, its
9
+ @mentions and its attachments in step. Bodies come back as sanitised
10
+ bodyHtml. A null thread means render nothing.
11
+ metadata:
12
+ type: sub-skill
13
+ library: softr-sdk
14
+ library_version: 0.0.63-alpha.0
15
+ ---
16
+
17
+ **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.
18
+
19
+ # Comments on records
20
+
21
+ Each record of a commented datasource has one thread. The SDK reads and writes it. The app owns what you see: layout, copy, order, dates.
22
+
23
+ ## Setup
24
+
25
+ ```ts
26
+ import { createServerFn } from '@tanstack/react-start'
27
+ import { defineComments, type CommentMention, type CommentAttachmentInput } from '@softr/sdk'
28
+ import { comments } from '@softr/sdk/server'
29
+ import { useCommentComposer, useFileUpload } from '@softr/sdk/client'
30
+ ```
31
+
32
+ ## Declare the comments action
33
+
34
+ ```ts
35
+ export const orderComments = defineComments({
36
+ id: 'orderComments',
37
+ datasourceId: '8f14e45f-ceea-467a-9d0b-2c1a0e3b5d77',
38
+ })
39
+ ```
40
+
41
+ - `id` is your name for it, unique in the app. `datasourceId` is the table that gets threads.
42
+ - No `permissions`. Who can read, post and delete is set in Settings → Comments, shared with native blocks.
43
+ - A viewer sees a thread only if a `read` action (`defineQuery`) on the same table lets them read it. Without one, `list` returns `null` for them.
44
+ - One declaration per datasource. Two on the same table are the **same** threads, so "internal notes" plus "customer comments" on one table isn't possible.
45
+ - Write the object inline. A variable hides it from the build.
46
+
47
+ ## Read a thread
48
+
49
+ ```ts
50
+ const getThread = createServerFn({ method: 'GET' })
51
+ .validator((data: { recordId: string; offset?: number | null }) => data)
52
+ .handler(({ data }) => comments.list(orderComments, { recordId: data.recordId, offset: data.offset }))
53
+ ```
54
+
55
+ `comments.list` returns `null` when this visitor can't see comments on the record (comments are off, the table isn't set up, or their group can't read). Render nothing for `null`, not an error.
56
+
57
+ Otherwise it returns:
58
+
59
+ | Field | What it is |
60
+ | --- | --- |
61
+ | `items` | This page's comments, newest first. Each is a `CommentView` |
62
+ | `canPost` | Whether the visitor can post. Hide the composer when false |
63
+ | `allowMentions` | Whether `@` opens a mention menu. Don't call `searchMentions` when false |
64
+ | `allowAttachments` | Whether the composer can attach files |
65
+ | `notificationsEnabled` | Whether to show the notifications switch at all |
66
+ | `quickReactions` | The five `{ emoji, label }` reactions. There are no others |
67
+ | `total`, `nextOffset` | Paging. Pass `nextOffset` back as `offset` to get the next, older page. `null` on the last page |
68
+
69
+ A `CommentView` is `{ id, bodyHtml, attachments, author: { name, avatar, isDeactivated }, createdAt, canDelete, canEdit, edited, source, reactions }`.
70
+
71
+ - `createdAt` is ISO 8601. Format it with `useUserTimezone`.
72
+ - `attachments` are `{ url, filename, type }`. `type` is `null` when unknown.
73
+ - `edited` is true once the comment changed after posting.
74
+ - `source` is `{ text, mentions }` for the edit box, or `null` when `canEdit` is false.
75
+ - Each reaction is `{ emoji, label, count, isSelected }`.
76
+
77
+ `bodyHtml` is already sanitised. Render it with `dangerouslySetInnerHTML` and nothing else:
78
+
79
+ ```tsx
80
+ <div className="comment-body" dangerouslySetInnerHTML={{ __html: comment.bodyHtml }} />
81
+ ```
82
+
83
+ It holds `p`, `br`, `strong`, `em`, `u`, `s`, `del`, `a`, `ul`, `ol`, `li`, `code`, `pre`, `blockquote`, `h1`–`h3` and mentions (`span[data-type="mention"]`). Links open in a new tab. Style these tags under the body's class: the reset strips bullets, link colour and spacing.
84
+
85
+ ## Post a comment
86
+
87
+ ```ts
88
+ const postComment = createServerFn({ method: 'POST' })
89
+ .validator(
90
+ (data: {
91
+ recordId: string
92
+ body: string
93
+ mentions: Array<CommentMention>
94
+ attachments?: Array<CommentAttachmentInput>
95
+ }) => data,
96
+ )
97
+ .handler(({ data }) => comments.create(orderComments, data))
98
+ ```
99
+
100
+ Pass the composer's `text` and `mentions` as they are. Mentioned people get an email. A mention whose `@name` was edited posts as plain text.
101
+
102
+ `attachments` are the finished uploads: `useFileUpload(orderComments).files.filter((f) => f.status === 'done')`. `useFileUpload` takes the comments declaration like a mutation. Send files only when `allowAttachments` is true. A comment needs text or at least one file.
103
+
104
+ ## The composer
105
+
106
+ ```tsx
107
+ const composer = useCommentComposer()
108
+ const { files, isUploading, upload, remove } = useFileUpload(orderComments)
109
+ const done = files.filter((f) => f.status === 'done')
110
+
111
+ <textarea {...composer.textareaProps} placeholder="Write a comment" />
112
+ {/* While composer.query is not null, list findPeople's answer for it. Picking a person calls composer.pick(person). */}
113
+ <button
114
+ disabled={(composer.text.trim() === '' && done.length === 0) || isUploading}
115
+ onClick={async () => {
116
+ await postComment({ data: { recordId, body: composer.text, mentions: composer.mentions, attachments: done } })
117
+ composer.reset()
118
+ done.forEach((f) => remove(f.id))
119
+ }}
120
+ >
121
+ Post
122
+ </button>
123
+ ```
124
+
125
+ - Spread `textareaProps` onto the `<textarea>` and don't override its handlers.
126
+ - `query` is the text after `@` before the caret, or `null`. It's `""` right after `@` is typed.
127
+ - `pick({ name, email })` writes `@name`, records the mention and moves the caret after it.
128
+ - `reset()` empties the box. Call it only after the post succeeds.
129
+
130
+ ## Editing a comment
131
+
132
+ Offer Edit only where `canEdit` is true. Mount the edit box only while editing.
133
+
134
+ - Text: `useCommentComposer({ initial: comment.source })`.
135
+ - Files: `useFileUpload(orderComments, { initialAttachments: comment.attachments.map((a) => ({ ...a, id: a.url })) })`. Attachments have no `id`, so the url stands in.
136
+ - Save with `comments.update`, passing the full file list: the kept files plus new uploads. With `allowAttachments` false the server keeps the old files whatever you send.
137
+
138
+ ## Mentions
139
+
140
+ ```ts
141
+ const findPeople = createServerFn({ method: 'GET' })
142
+ .validator((data: { query: string }) => data)
143
+ .handler(({ data }) => comments.searchMentions(orderComments, data))
144
+ ```
145
+
146
+ It returns `[{ name, email, avatar }]`: people the visitor can mention whose name or email matches. Who's mentionable is set per user group in Settings → Comments.
147
+
148
+ ## Delete, update, react, subscribe
149
+
150
+ ```ts
151
+ await comments.delete(orderComments, { recordId, commentId })
152
+ await comments.update(orderComments, { recordId, commentId, body, mentions, attachments })
153
+ await comments.react(orderComments, { recordId, commentId, emoji: '👍', selected: !reaction.isSelected })
154
+ const subscribed = await comments.subscription(orderComments, { recordId })
155
+ await comments.setSubscription(orderComments, { recordId, subscribed: !subscribed })
156
+ ```
157
+
158
+ Each runs in its own server function, like `list`. Writes return nothing, so re-read the thread after each one.
159
+
160
+ - Offer delete only where `canDelete` is true.
161
+ - `react` adds the reaction when `selected` is true and removes it when false. `emoji` must be one of `quickReactions`.
162
+ - A subscription means the visitor gets an email about new comments on the record. It needs a signed-in visitor. Posting subscribes the author; a mention subscribes the person mentioned.
163
+ - Mails go out a few minutes after a comment, never to its author, and only while notifications are on in Settings → Comments.
164
+
165
+ ## Failure modes
166
+
167
+ **Keying the thread on the URL.** Pass the fetched `record.id`, never a route or search param. A param can be a slug or stale, and the thread shows another record's comments.
168
+
169
+ **Treating `null` as an error.** Render nothing. An error box shows every such visitor a broken page.
170
+
171
+ **Rendering a body as text, or sanitising it again.** Use `bodyHtml` with `dangerouslySetInnerHTML`. As text it shows raw tags; a second sanitiser strips the mentions.
172
+
173
+ **Inventing reactions.** Only `quickReactions` can be stored. `react` throws for anything else.
174
+
175
+ **Sending only new files on edit.** `update` replaces the whole list, so kept files you leave out get deleted.
176
+
177
+ **Calling `comments.*` from a component.** It's server only. Wrap each call in a `createServerFn`.
178
+
179
+ ## References
180
+
181
+ - [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.61
12
+ library_version: 0.0.63-alpha.0
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.61
13
+ library_version: 0.0.63-alpha.0
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.
@@ -75,9 +75,10 @@ function ApplicationForm() {
75
75
 
76
76
  ```ts
77
77
  type FileAttachment = {
78
- url: string // clean URL, no internal query params
79
- filename: string // original filename
80
- id: string // file id extracted from the upload response
78
+ url: string // clean URL, no internal query params
79
+ filename: string // original filename
80
+ id: string // file id extracted from the upload response
81
+ type?: string | null // content type from the upload response, or null when the platform doesn't know it
81
82
  }
82
83
  ```
83
84
 
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.61
13
+ library_version: 0.0.63-alpha.0
14
14
  ---
15
15
 
16
16
  # Dates, times and numbers
@@ -10,7 +10,7 @@ description: >-
10
10
  metadata:
11
11
  type: sub-skill
12
12
  library: softr-sdk
13
- library_version: 0.0.61
13
+ library_version: 0.0.63-alpha.0
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.