@lotics/app-sdk 0.25.0 → 0.26.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,71 @@
1
+ /** A file attached to a comment, in the resolved (serving) shape the list returns. */
2
+ export interface AppCommentFile {
3
+ id: string;
4
+ /** Storage key — needed to re-send the file when editing a comment. */
5
+ file_storage_key: string;
6
+ filename: string;
7
+ mime_type: string;
8
+ url?: string;
9
+ thumbnail_url?: string;
10
+ preview_url?: string;
11
+ }
12
+ /** One record comment, as returned by the list op. */
13
+ export interface AppComment {
14
+ id: string;
15
+ record_id: string;
16
+ table_id: string;
17
+ /** The author's member id. */
18
+ member_id: string;
19
+ content: string;
20
+ files: AppCommentFile[] | null;
21
+ workspace_id: string;
22
+ created_at: string;
23
+ updated_at: string;
24
+ }
25
+ export interface CommentsState {
26
+ /** Comments on the record, oldest first. Empty while loading or unavailable. */
27
+ comments: AppComment[];
28
+ /** True on the initial load only — false during background revalidation. */
29
+ loading: boolean;
30
+ error: string | null;
31
+ /**
32
+ * True only when a member is signed in AND the app declared the `comments`
33
+ * capability in its manifest. False in a standalone / public (anonymous) app,
34
+ * and for any app that didn't opt in. Gate the composer on this; mutations
35
+ * reject (and the backend re-checks the capability) when false.
36
+ */
37
+ available: boolean;
38
+ /**
39
+ * Post a new comment as the viewing member. `file_ids` are ids from
40
+ * `useFileUpload().upload()`. No-op for empty content with no files.
41
+ */
42
+ createComment: (input: {
43
+ content: string;
44
+ file_ids?: string[];
45
+ }) => Promise<void>;
46
+ /**
47
+ * Edit a comment (author-only, enforced server-side). Pass `files` to replace
48
+ * the attachment set; omit it to keep the comment's current files — a
49
+ * text-only edit never silently drops attachments.
50
+ */
51
+ updateComment: (id: string, input: {
52
+ content: string;
53
+ files?: AppCommentFile[];
54
+ }) => Promise<void>;
55
+ /** Delete a comment (author-only, enforced server-side). */
56
+ deleteComment: (id: string) => Promise<void>;
57
+ /** Re-pull the thread from the server (e.g. after an external change). */
58
+ refetch: () => void;
59
+ }
60
+ export interface UseCommentsArgs {
61
+ /** The record whose comments to read and write. */
62
+ record_id: string;
63
+ }
64
+ /**
65
+ * ```tsx
66
+ * const { comments, available, createComment } = useComments({
67
+ * record_id: row.__source_record_id,
68
+ * });
69
+ * ```
70
+ */
71
+ export declare function useComments(args: UseCommentsArgs): CommentsState;
@@ -0,0 +1,169 @@
1
+ /**
2
+ * `useComments` — read and write the comments on a record from a custom-code app.
3
+ *
4
+ * Comments are a *members-only* surface and behave differently from `useQuery`
5
+ * / `useWorkflow`. Those run under the app OWNER's authority (the app is the
6
+ * capability), which is what lets a public app expose owner-curated data to an
7
+ * anonymous visitor. Comments are member-to-member discussion tied to a real
8
+ * author identity, so they run under the VIEWING MEMBER's own authority instead:
9
+ *
10
+ * - read / write authorized against the viewer's own table access + row-scope
11
+ * - the comment's author is the viewer (correct attribution)
12
+ * - edit / delete are author-only, checked against the viewer
13
+ *
14
+ * The bridged host (`app_iframe_host`) holds the member session and proxies each
15
+ * op to the member's `/v1/records/.../comments` endpoints. A standalone (public)
16
+ * app has no signed-in member: `available` is false, the list stays empty, and a
17
+ * mutation rejects. Check `available` before rendering a composer.
18
+ *
19
+ * Freshness mirrors `useQuery`: SWR-cached, revalidate on focus / reconnect, and
20
+ * an explicit refetch after every mutation. There is no realtime push to apps,
21
+ * so a thread updated by another viewer appears on the next focus / refetch.
22
+ */
23
+ import { useCallback } from "react";
24
+ import useSWR from "swr";
25
+ import { rpc } from "./rpc.js";
26
+ import { captureAppEvent } from "./analytics.js";
27
+ /** The reduced storage shape the update endpoint accepts for `files`. */
28
+ function toStorageFiles(files) {
29
+ return (files ?? []).map((f) => ({
30
+ id: f.id,
31
+ file_storage_key: f.file_storage_key,
32
+ filename: f.filename,
33
+ mime_type: f.mime_type,
34
+ }));
35
+ }
36
+ /**
37
+ * Read the app's context once, shared across every hook via a stable SWR key.
38
+ * Comments need a signed-in member (members-only) AND the app's declared
39
+ * `comments` capability — both come from here.
40
+ */
41
+ function useAppContext() {
42
+ const { data } = useSWR("app-context", () => rpc("context", {}), {
43
+ revalidateOnFocus: false,
44
+ revalidateIfStale: false,
45
+ revalidateOnReconnect: false,
46
+ shouldRetryOnError: false,
47
+ });
48
+ return {
49
+ memberId: data?.member_id ?? null,
50
+ commentsEnabled: data?.comments_enabled ?? false,
51
+ resolved: data !== undefined,
52
+ };
53
+ }
54
+ let optimisticCounter = 0;
55
+ /**
56
+ * ```tsx
57
+ * const { comments, available, createComment } = useComments({
58
+ * record_id: row.__source_record_id,
59
+ * });
60
+ * ```
61
+ */
62
+ export function useComments(args) {
63
+ const { record_id } = args;
64
+ const { memberId, commentsEnabled, resolved } = useAppContext();
65
+ const available = memberId != null && commentsEnabled;
66
+ const swr = useSWR(available ? ["app-comments", record_id] : null, () => rpc("comments.list", { record_id }), {
67
+ shouldRetryOnError: false,
68
+ });
69
+ const comments = swr.data?.comments ?? [];
70
+ const refetch = useCallback(() => {
71
+ void swr.mutate();
72
+ }, [swr]);
73
+ const createComment = useCallback(async (input) => {
74
+ if (!available || memberId == null) {
75
+ throw new Error("Comments are not available in this app.");
76
+ }
77
+ const content = input.content.trim();
78
+ const fileIds = input.file_ids ?? [];
79
+ if (!content && fileIds.length === 0)
80
+ return;
81
+ const now = new Date().toISOString();
82
+ const optimistic = {
83
+ id: `cmt_optimistic_${Date.now()}_${optimisticCounter++}`,
84
+ record_id,
85
+ // table_id / workspace_id resolve on the refetch (server-owned); the
86
+ // optimistic row only needs to render until then.
87
+ table_id: "",
88
+ member_id: memberId,
89
+ content,
90
+ // The real attachments resolve on the refetch; show none meanwhile.
91
+ files: null,
92
+ workspace_id: "",
93
+ created_at: now,
94
+ updated_at: now,
95
+ };
96
+ await swr.mutate(async () => {
97
+ await rpc("comments.create", {
98
+ record_id,
99
+ content,
100
+ file_ids: fileIds.length > 0 ? fileIds : undefined,
101
+ });
102
+ return rpc("comments.list", { record_id });
103
+ }, {
104
+ optimisticData: (current) => ({
105
+ comments: [...(current?.comments ?? []), optimistic],
106
+ }),
107
+ rollbackOnError: true,
108
+ revalidate: false,
109
+ populateCache: true,
110
+ });
111
+ captureAppEvent("app_comment_created", { has_files: fileIds.length > 0 });
112
+ }, [available, memberId, record_id, swr]);
113
+ const updateComment = useCallback(async (id, input) => {
114
+ if (!available)
115
+ throw new Error("Comments are not available in this app.");
116
+ const content = input.content.trim();
117
+ // Omitted files → keep the comment's current attachments (never drop them
118
+ // on a text-only edit). The update endpoint replaces the whole set.
119
+ const currentFiles = input.files !== undefined
120
+ ? input.files
121
+ : (swr.data?.comments.find((c) => c.id === id)?.files ?? null);
122
+ const now = new Date().toISOString();
123
+ await swr.mutate(async () => {
124
+ await rpc("comments.update", {
125
+ record_id,
126
+ comment_id: id,
127
+ content,
128
+ files: toStorageFiles(currentFiles),
129
+ });
130
+ return rpc("comments.list", { record_id });
131
+ }, {
132
+ optimisticData: (current) => ({
133
+ comments: (current?.comments ?? []).map((c) => c.id === id ? { ...c, content, files: currentFiles, updated_at: now } : c),
134
+ }),
135
+ rollbackOnError: true,
136
+ revalidate: false,
137
+ populateCache: true,
138
+ });
139
+ captureAppEvent("app_comment_updated", {});
140
+ }, [available, record_id, swr]);
141
+ const deleteComment = useCallback(async (id) => {
142
+ if (!available)
143
+ throw new Error("Comments are not available in this app.");
144
+ await swr.mutate(async () => {
145
+ await rpc("comments.delete", { record_id, comment_id: id });
146
+ return rpc("comments.list", { record_id });
147
+ }, {
148
+ optimisticData: (current) => ({
149
+ comments: (current?.comments ?? []).filter((c) => c.id !== id),
150
+ }),
151
+ rollbackOnError: true,
152
+ revalidate: false,
153
+ populateCache: true,
154
+ });
155
+ captureAppEvent("app_comment_deleted", {});
156
+ }, [available, record_id, swr]);
157
+ return {
158
+ comments,
159
+ // Unavailable resolves instantly (no fetch); available shows the initial
160
+ // load until the list lands. Waiting on context counts as loading.
161
+ loading: available ? swr.isLoading : !resolved,
162
+ error: swr.error ? swr.error.message : null,
163
+ available,
164
+ createComment,
165
+ updateComment,
166
+ deleteComment,
167
+ refetch,
168
+ };
169
+ }
@@ -18,6 +18,8 @@ export { mount } from "./mount.js";
18
18
  export type { MountOptions } from "./mount.js";
19
19
  export { useWorkflow, useQuery, useFileUpload, useMembers } from "./hooks.js";
20
20
  export type { UploadedFile, QueryOptions, QuerySortKey, QueryFilter, QueryFilterCondition, QueryFilterGroup, WorkflowResult, MembersOptions, } from "./hooks.js";
21
+ export { useComments } from "./comments.js";
22
+ export type { AppComment, AppCommentFile, CommentsState, UseCommentsArgs } from "./comments.js";
21
23
  export { rpc } from "./rpc.js";
22
24
  export type { RpcOp } from "./rpc.js";
23
25
  export { openExternal } from "./open_external.js";
package/dist/src/index.js CHANGED
@@ -16,6 +16,7 @@
16
16
  */
17
17
  export { mount } from "./mount.js";
18
18
  export { useWorkflow, useQuery, useFileUpload, useMembers } from "./hooks.js";
19
+ export { useComments } from "./comments.js";
19
20
  export { rpc } from "./rpc.js";
20
21
  export { openExternal } from "./open_external.js";
21
22
  export { readMembers } from "./members.js";
package/dist/src/rpc.d.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  * app → host: { id, op, payload }
19
19
  * host → app: { id, type: "result", data } | { id, type: "error", message }
20
20
  */
21
- export type RpcOp = "query" | "workflow" | "upload" | "members" | "context" | "openExternal";
21
+ export type RpcOp = "query" | "workflow" | "upload" | "members" | "context" | "openExternal" | "comments.list" | "comments.create" | "comments.update" | "comments.delete";
22
22
  /**
23
23
  * The app's identity, resolved once at startup to tag PostHog events.
24
24
  * Assembled by whichever transport is active:
@@ -38,5 +38,11 @@ export interface AppContext {
38
38
  workspace_id: string;
39
39
  organization_id: string;
40
40
  member_id: string | null;
41
+ /**
42
+ * Whether the app declared the `comments` capability. `useComments` is
43
+ * available only when this is true AND a member is signed in. False for any
44
+ * app that didn't opt in, and (vacuously) for standalone visitors.
45
+ */
46
+ comments_enabled: boolean;
41
47
  }
42
48
  export declare function rpc<T = unknown>(op: RpcOp, payload: unknown): Promise<T>;
package/dist/src/rpc.js CHANGED
@@ -223,8 +223,24 @@ function rpcStandalone(op, payload) {
223
223
  return standaloneContext();
224
224
  case "openExternal":
225
225
  return standaloneOpenExternal(payload);
226
+ case "comments.list":
227
+ case "comments.create":
228
+ case "comments.update":
229
+ case "comments.delete":
230
+ return rejectCommentsStandalone();
226
231
  }
227
232
  }
233
+ /**
234
+ * Comments are a members-only collaboration surface. A comment must have an
235
+ * authenticated member as its author, and a thread can carry member-to-member
236
+ * discussion — so a standalone (public, anonymous) app has no member to author
237
+ * as and no business reading other members' threads. `useComments` detects this
238
+ * up front via the null context `member_id` and never sends a comment op; this
239
+ * rejection is the belt-and-braces backstop for any direct `rpc()` caller.
240
+ */
241
+ function rejectCommentsStandalone() {
242
+ return Promise.reject(new Error("Comments are available only in embedded apps — a signed-in member is required."));
243
+ }
228
244
  async function standaloneMembers(p) {
229
245
  const { app_id } = await boot();
230
246
  const qs = p.group ? `?group_id=${encodeURIComponent(p.group)}` : "";
@@ -261,8 +277,10 @@ async function standaloneContext() {
261
277
  app_name: info.app_name,
262
278
  workspace_id: info.workspace_id,
263
279
  organization_id: info.organization_id,
264
- // No host session in standalone mode — the visitor is anonymous.
280
+ // No host session in standalone mode — the visitor is anonymous, so
281
+ // comments are unavailable regardless of the capability flag.
265
282
  member_id: null,
283
+ comments_enabled: info.comments_enabled,
266
284
  };
267
285
  }
268
286
  async function standaloneQuery(p) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/app-sdk",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "Runtime SDK for Lotics custom-code apps — typed hooks, postMessage bridge, mount entry point",
5
5
  "type": "module",
6
6
  "exports": {