@softr/sdk 0.0.54-alpha.0 → 0.0.55
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 +1 -31
- package/dist/action.js +1 -26
- package/dist/auth/isomorphic.d.ts +1 -1
- package/dist/auth/isomorphic.js +2 -1
- package/dist/boot-error.js +1 -0
- package/dist/client.d.ts +1 -2
- package/dist/client.js +1 -2
- package/dist/default-error-overlay.d.ts +2 -2
- package/dist/default-error-overlay.js +86 -2
- package/dist/fetch.js +2 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -2
- package/dist/mcp.d.ts +2 -2
- package/dist/package.js +1 -1
- package/dist/pwa.d.ts +54 -0
- package/dist/pwa.js +152 -0
- package/dist/server.d.ts +1 -2
- package/dist/server.js +1 -2
- package/package.json +1 -3
- package/skills/softr-sdk/SKILL.md +7 -7
- package/skills/softr-sdk/auth/SKILL.md +1 -1
- package/skills/softr-sdk/charts-and-metrics/SKILL.md +1 -1
- package/skills/softr-sdk/current-user/SKILL.md +1 -1
- package/skills/softr-sdk/file-uploads/SKILL.md +1 -1
- package/skills/softr-sdk/integrations/SKILL.md +1 -1
- package/skills/softr-sdk/mcp/SKILL.md +1 -1
- package/skills/softr-sdk/pwa/SKILL.md +77 -0
- package/skills/softr-sdk/record-mutations/SKILL.md +1 -1
- package/skills/softr-sdk/record-queries/SKILL.md +2 -2
- package/skills/softr-sdk/region-visibility/SKILL.md +1 -1
- package/skills/softr-sdk/routes-and-visibility/SKILL.md +1 -1
- package/skills/softr-sdk/workflows/SKILL.md +1 -1
- package/dist/comments/comments.d.ts +0 -137
- package/dist/comments/comments.js +0 -329
- package/dist/comments/use-comment-composer.d.ts +0 -37
- package/dist/comments/use-comment-composer.js +0 -127
- package/skills/softr-sdk/comments/SKILL.md +0 -149
|
@@ -10,7 +10,7 @@ description: >-
|
|
|
10
10
|
metadata:
|
|
11
11
|
type: sub-skill
|
|
12
12
|
library: softr-sdk
|
|
13
|
-
library_version: 0.0.
|
|
13
|
+
library_version: 0.0.55
|
|
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.
|
|
13
|
+
library_version: 0.0.55
|
|
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.
|
|
12
|
+
library_version: 0.0.55
|
|
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.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pwa
|
|
3
|
+
description: >-
|
|
4
|
+
Let visitors install the app on their home screen: SoftrPwaHead for the
|
|
5
|
+
manifest and icon links, usePwaInstall to decide whether to show an install
|
|
6
|
+
button and which platform's steps to show, the PwaInstall and PwaPlatform
|
|
7
|
+
types, and how to check that the app is installable. The manifest and icon
|
|
8
|
+
are already served; never write them.
|
|
9
|
+
metadata:
|
|
10
|
+
type: sub-skill
|
|
11
|
+
library: softr-sdk
|
|
12
|
+
library_version: 0.0.55
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# Installing the app
|
|
16
|
+
|
|
17
|
+
The builder turns installing on in Studio, under Settings → Mobile app, and sets the name, colour and icon there. The app already answers with its manifest and icon once they do. What the app adds is two things: the links that point the browser at them, and a button that tells visitors how to install.
|
|
18
|
+
|
|
19
|
+
> **CRITICAL**: Never write a web app manifest, icon files, a service worker, or `manifest` / `apple-touch-icon` links of your own, in `public/` or in `head()`. The app already serves them from Studio's settings, and a file of your own shadows those settings.
|
|
20
|
+
> **CRITICAL**: Render an install button only while `usePwaInstall()` returns `available: true`. Never show it unconditionally, never cache the answer, never replace the check with your own. Softr can switch installing off for an app, and this check is the only way that reaches the button.
|
|
21
|
+
|
|
22
|
+
## Setup
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { SoftrPwaHead, usePwaInstall } from '@softr/sdk'
|
|
26
|
+
import type { PwaInstall, PwaPlatform } from '@softr/sdk'
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
type PwaPlatform = 'ios' | 'android' | 'desktop'
|
|
31
|
+
|
|
32
|
+
type PwaInstall =
|
|
33
|
+
| { available: false }
|
|
34
|
+
| {
|
|
35
|
+
available: true
|
|
36
|
+
platform: PwaPlatform // which steps install the app on this device
|
|
37
|
+
name: string // what the home screen will show
|
|
38
|
+
iconUrl: string // the icon the home screen will show
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Is the app installable?
|
|
43
|
+
|
|
44
|
+
The builder switches installing on in Studio, and `usePwaInstall()` reads that switch at runtime. To check it before writing any code, ask the running app for `/_softr/pwa.json`: `200` means installable, `404` means installing is off or there is no icon yet. Do not build an install button against a `404`: installing has to be turned on first. A change in Studio can take up to two minutes to reach the app.
|
|
45
|
+
|
|
46
|
+
## Core Patterns
|
|
47
|
+
|
|
48
|
+
### The links: `<SoftrPwaHead />`
|
|
49
|
+
|
|
50
|
+
Render it once, in `<head>` next to `<HeadContent />`, in `RootDocument` in `src/routes/__root.tsx`.
|
|
51
|
+
|
|
52
|
+
```tsx
|
|
53
|
+
function RootDocument({ children }: { children: React.ReactNode }) {
|
|
54
|
+
return (
|
|
55
|
+
<html lang="en">
|
|
56
|
+
<head>
|
|
57
|
+
<HeadContent />
|
|
58
|
+
<SoftrPwaHead />
|
|
59
|
+
</head>
|
|
60
|
+
<body>
|
|
61
|
+
{children}
|
|
62
|
+
<Scripts />
|
|
63
|
+
</body>
|
|
64
|
+
</html>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Whether to show a button: `usePwaInstall()`
|
|
70
|
+
|
|
71
|
+
It fetches after mount, so the first render, the server render included, always says `available: false`. It stays unavailable when the app is not installable, when the request fails, and when the app already runs installed from the home screen. Inside the Studio preview it says available, so the builder can see the button; installing itself only works on the published app.
|
|
72
|
+
|
|
73
|
+
There is no `install()` to call. No browser opens its install dialog for this app on request, so the button opens steps for `platform` instead: Share, then Add to Home Screen on iOS; the browser menu's Install app on Android and desktop. `name` and `iconUrl` tell the visitor what they are adding.
|
|
74
|
+
|
|
75
|
+
## References
|
|
76
|
+
|
|
77
|
+
- [SKILL.md](../SKILL.md): the entrypoints and which one each export lives on
|
|
@@ -11,7 +11,7 @@ description: >-
|
|
|
11
11
|
metadata:
|
|
12
12
|
type: sub-skill
|
|
13
13
|
library: softr-sdk
|
|
14
|
-
library_version: 0.0.
|
|
14
|
+
library_version: 0.0.55
|
|
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,10 +13,10 @@ description: >-
|
|
|
13
13
|
metadata:
|
|
14
14
|
type: sub-skill
|
|
15
15
|
library: softr-sdk
|
|
16
|
-
library_version: 0.0.
|
|
16
|
+
library_version: 0.0.55
|
|
17
17
|
---
|
|
18
18
|
|
|
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.
|
|
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. The same holds for any helper that calls `ds`: declare it inside the handler. A function at module level that calls `ds` keeps the server-only import in the browser bundle, and every page stops loading until it moves. `defineQuery` and every type come from `@softr/sdk` and can be imported anywhere.
|
|
20
20
|
|
|
21
21
|
# Reading records
|
|
22
22
|
|
|
@@ -9,7 +9,7 @@ description: >-
|
|
|
9
9
|
metadata:
|
|
10
10
|
type: sub-skill
|
|
11
11
|
library: softr-sdk
|
|
12
|
-
library_version: 0.0.
|
|
12
|
+
library_version: 0.0.55
|
|
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.
|
|
@@ -1,137 +0,0 @@
|
|
|
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 };
|
|
@@ -1,329 +0,0 @@
|
|
|
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 };
|
|
@@ -1,37 +0,0 @@
|
|
|
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 };
|