@colixsystems/widget-sdk 0.197.0 → 0.198.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/README.md +57 -1
- package/dist/avatar-color.js +35 -0
- package/dist/contract.cjs +184 -24
- package/dist/contract.js +184 -24
- package/dist/detail-parts-view.js +9 -4
- package/dist/hooks.js +535 -28
- package/dist/index.d.ts +177 -4
- package/dist/index.js +8 -0
- package/dist/index.native.js +8 -0
- package/dist/initials.js +3 -2
- package/dist/manifest.cjs +2 -1
- package/dist/manifest.js +2 -1
- package/dist/overlay-view.js +2 -5
- package/dist/overlay.js +12 -5
- package/dist/primitives.js +2 -0
- package/dist/primitives.native.js +2 -0
- package/dist/video.js +51 -0
- package/dist/video.native.js +61 -0
- package/dist/web-modal.js +139 -0
- package/package.json +5 -1
- package/src/contract.js +184 -24
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// The web `<Overlay>`'s modal layer: a body-level portal that owns its
|
|
2
|
+
// container in a layout effect.
|
|
3
|
+
//
|
|
4
|
+
// react-native-web's `Modal` creates its container during render and removes it
|
|
5
|
+
// in an effect cleanup. React StrictMode's mount → cleanup → mount detaches that
|
|
6
|
+
// container, so the overlay rendered into an off-page node until some unrelated
|
|
7
|
+
// re-render rebuilt it, and its Escape and focus handling never re-armed.
|
|
8
|
+
//
|
|
9
|
+
// Implements only the `Modal` props `<Overlay>` passes: `visible`,
|
|
10
|
+
// `onRequestClose` and `children`; it is never animated.
|
|
11
|
+
|
|
12
|
+
import React from "react";
|
|
13
|
+
import { createPortal } from "react-dom";
|
|
14
|
+
|
|
15
|
+
const FOCUSABLE =
|
|
16
|
+
'button:not([disabled]), a[href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
17
|
+
|
|
18
|
+
const layerStyle = {
|
|
19
|
+
position: "fixed",
|
|
20
|
+
top: 0,
|
|
21
|
+
right: 0,
|
|
22
|
+
bottom: 0,
|
|
23
|
+
left: 0,
|
|
24
|
+
zIndex: 9999,
|
|
25
|
+
display: "flex",
|
|
26
|
+
flexDirection: "column",
|
|
27
|
+
outline: "none",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Open layers, innermost last: only the top one handles Escape and Tab.
|
|
31
|
+
const openLayers = [];
|
|
32
|
+
|
|
33
|
+
function tabStops(dialog) {
|
|
34
|
+
return Array.from(dialog.querySelectorAll(FOCUSABLE)).filter(
|
|
35
|
+
(node) => node.getAttribute("aria-hidden") !== "true",
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {{visible?: boolean, onRequestClose?: () => void, children?: any}} props
|
|
41
|
+
*/
|
|
42
|
+
export function WebModal({ visible = true, onRequestClose, children }) {
|
|
43
|
+
const [container, setContainer] = React.useState(null);
|
|
44
|
+
const dialogRef = React.useRef(null);
|
|
45
|
+
const onCloseRef = React.useRef(onRequestClose);
|
|
46
|
+
onCloseRef.current = onRequestClose;
|
|
47
|
+
// Taken before the dialog mounts, so a child that focuses itself on mount
|
|
48
|
+
// cannot become the place focus returns to.
|
|
49
|
+
const returnFocusRef = React.useRef(null);
|
|
50
|
+
|
|
51
|
+
React.useLayoutEffect(() => {
|
|
52
|
+
if (!visible) return undefined;
|
|
53
|
+
returnFocusRef.current = document.activeElement;
|
|
54
|
+
const node = document.createElement("div");
|
|
55
|
+
document.body.appendChild(node);
|
|
56
|
+
setContainer(node);
|
|
57
|
+
return () => {
|
|
58
|
+
node.remove();
|
|
59
|
+
setContainer(null);
|
|
60
|
+
};
|
|
61
|
+
}, [visible]);
|
|
62
|
+
|
|
63
|
+
React.useEffect(() => {
|
|
64
|
+
const dialog = dialogRef.current;
|
|
65
|
+
if (!container || !dialog) return undefined;
|
|
66
|
+
const layer = {};
|
|
67
|
+
openLayers.push(layer);
|
|
68
|
+
const isTop = () => openLayers[openLayers.length - 1] === layer;
|
|
69
|
+
const returnFocusTo = returnFocusRef.current;
|
|
70
|
+
if (!dialog.contains(document.activeElement)) {
|
|
71
|
+
dialog.focus({ preventScroll: true });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// keyup, as react-native-web's Modal did, so the keydown that opened a
|
|
75
|
+
// nested overlay cannot also close it.
|
|
76
|
+
const onKeyUp = (event) => {
|
|
77
|
+
if (event.key !== "Escape" || !isTop()) return;
|
|
78
|
+
event.stopPropagation();
|
|
79
|
+
if (typeof onCloseRef.current === "function") onCloseRef.current();
|
|
80
|
+
};
|
|
81
|
+
const onKeyDown = (event) => {
|
|
82
|
+
if (event.key !== "Tab" || !isTop()) return;
|
|
83
|
+
const stops = tabStops(dialog);
|
|
84
|
+
if (!stops.length) {
|
|
85
|
+
event.preventDefault();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const first = stops[0];
|
|
89
|
+
const last = stops[stops.length - 1];
|
|
90
|
+
const current = document.activeElement;
|
|
91
|
+
const outside = !dialog.contains(current) || current === dialog;
|
|
92
|
+
if (event.shiftKey && (outside || current === first)) {
|
|
93
|
+
event.preventDefault();
|
|
94
|
+
last.focus();
|
|
95
|
+
} else if (!event.shiftKey && (outside || current === last)) {
|
|
96
|
+
event.preventDefault();
|
|
97
|
+
first.focus();
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
const onFocusIn = (event) => {
|
|
101
|
+
if (isTop() && !dialog.contains(event.target)) {
|
|
102
|
+
dialog.focus({ preventScroll: true });
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
document.addEventListener("keyup", onKeyUp);
|
|
106
|
+
document.addEventListener("keydown", onKeyDown);
|
|
107
|
+
document.addEventListener("focusin", onFocusIn);
|
|
108
|
+
return () => {
|
|
109
|
+
document.removeEventListener("keyup", onKeyUp);
|
|
110
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
111
|
+
document.removeEventListener("focusin", onFocusIn);
|
|
112
|
+
const index = openLayers.indexOf(layer);
|
|
113
|
+
if (index !== -1) openLayers.splice(index, 1);
|
|
114
|
+
if (
|
|
115
|
+
returnFocusTo &&
|
|
116
|
+
typeof returnFocusTo.focus === "function" &&
|
|
117
|
+
returnFocusTo.isConnected
|
|
118
|
+
) {
|
|
119
|
+
returnFocusTo.focus({ preventScroll: true });
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
}, [container]);
|
|
123
|
+
|
|
124
|
+
if (!visible || !container) return null;
|
|
125
|
+
return createPortal(
|
|
126
|
+
React.createElement(
|
|
127
|
+
"div",
|
|
128
|
+
{
|
|
129
|
+
ref: dialogRef,
|
|
130
|
+
role: "dialog",
|
|
131
|
+
"aria-modal": "true",
|
|
132
|
+
tabIndex: -1,
|
|
133
|
+
style: layerStyle,
|
|
134
|
+
},
|
|
135
|
+
children,
|
|
136
|
+
),
|
|
137
|
+
container,
|
|
138
|
+
);
|
|
139
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.198.0",
|
|
4
4
|
"description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
|
|
5
5
|
"homepage": "https://github.com/Colix-AB/AppStudio",
|
|
6
6
|
"type": "module",
|
|
@@ -62,6 +62,7 @@
|
|
|
62
62
|
"peerDependencies": {
|
|
63
63
|
"react": ">=18.0.0",
|
|
64
64
|
"react-native": "*",
|
|
65
|
+
"react-dom": ">=18.0.0",
|
|
65
66
|
"react-native-web": ">=0.19.0"
|
|
66
67
|
},
|
|
67
68
|
"peerDependenciesMeta": {
|
|
@@ -73,6 +74,9 @@
|
|
|
73
74
|
},
|
|
74
75
|
"react-native-web": {
|
|
75
76
|
"optional": true
|
|
77
|
+
},
|
|
78
|
+
"react-dom": {
|
|
79
|
+
"optional": true
|
|
76
80
|
}
|
|
77
81
|
},
|
|
78
82
|
"optionalDependencies": {
|
package/src/contract.js
CHANGED
|
@@ -1044,6 +1044,51 @@ const HOOKS = [
|
|
|
1044
1044
|
requiredContextSlice: ["navigation"],
|
|
1045
1045
|
scopes: null,
|
|
1046
1046
|
},
|
|
1047
|
+
{
|
|
1048
|
+
name: "useFilePreview",
|
|
1049
|
+
signature: "useFilePreview(file)",
|
|
1050
|
+
description:
|
|
1051
|
+
"{ uri } an <Image> can show for a file the person picked but has not uploaded yet (a photo waiting in a " +
|
|
1052
|
+
"composer, before it is sent). Native picks carry their own file uri; on the web the SDK makes a temporary " +
|
|
1053
|
+
"address and releases it when the file changes or the component unmounts. uri is null for no file, or a host " +
|
|
1054
|
+
"that cannot preview one. Widgets may not call createObjectURL themselves; this is the sanctioned way. No scope. " +
|
|
1055
|
+
"Identical on web (Player) and the Expo export.",
|
|
1056
|
+
returnShape: {
|
|
1057
|
+
uri: "string | null // pass as <Image source={{ uri }} />; null when there is nothing to show",
|
|
1058
|
+
},
|
|
1059
|
+
requiredContextSlice: [],
|
|
1060
|
+
scopes: null,
|
|
1061
|
+
},
|
|
1062
|
+
{
|
|
1063
|
+
name: "usePageBadge",
|
|
1064
|
+
signature: "usePageBadge(count)",
|
|
1065
|
+
description:
|
|
1066
|
+
"Puts count on the menu entry or tab of the page this widget is on (a chat's unread messages, open tasks). " +
|
|
1067
|
+
"The host sums every widget on the page and keeps the last count after the widget leaves the screen, until it " +
|
|
1068
|
+
"reports again; 0, or anything not a positive number, clears this widget's share. Returns { supported }: false " +
|
|
1069
|
+
"on a host without page badges (the Builder canvas), where it does nothing. Call it at render with the current " +
|
|
1070
|
+
"count, never in a loop. Backed by ctx.navigation.setBadge. Identical on web (Player) and the Expo export.",
|
|
1071
|
+
returnShape: {
|
|
1072
|
+
supported: "boolean // false where the host draws no page badges",
|
|
1073
|
+
},
|
|
1074
|
+
requiredContextSlice: ["navigation"],
|
|
1075
|
+
scopes: null,
|
|
1076
|
+
},
|
|
1077
|
+
{
|
|
1078
|
+
name: "useHardwareBack",
|
|
1079
|
+
signature: "useHardwareBack(handler)",
|
|
1080
|
+
description:
|
|
1081
|
+
"Android's back button, for a widget with pages of its own (a thread over a list, a settings page over a thread). " +
|
|
1082
|
+
"handler runs on a back press while the widget's screen is in view: return true when it went back a step, false to " +
|
|
1083
|
+
"let the app go back. Pass null while there is nothing to go back from. Returns { supported }: false on the web " +
|
|
1084
|
+
"Player, where browsers have no hardware back, so always keep a visible back control too. Backed by the optional " +
|
|
1085
|
+
"ctx.navigation.onHardwareBack(listener) → unsubscribe, which the Expo export answers only for the screen in view.",
|
|
1086
|
+
returnShape: {
|
|
1087
|
+
supported: "boolean // false on the web, which has no hardware back",
|
|
1088
|
+
},
|
|
1089
|
+
requiredContextSlice: ["navigation"],
|
|
1090
|
+
scopes: null,
|
|
1091
|
+
},
|
|
1047
1092
|
{
|
|
1048
1093
|
name: "usePageContext",
|
|
1049
1094
|
signature: "usePageContext()",
|
|
@@ -1128,14 +1173,18 @@ const HOOKS = [
|
|
|
1128
1173
|
},
|
|
1129
1174
|
{
|
|
1130
1175
|
name: "useFilestoreFiles",
|
|
1131
|
-
signature: "useFilestoreFiles({ spaceType, folderId, q, type })",
|
|
1132
|
-
optionalArgs: ["folderId", "q", "type"],
|
|
1176
|
+
signature: "useFilestoreFiles({ spaceType, recordId, folderId, q, type })",
|
|
1177
|
+
optionalArgs: ["recordId", "folderId", "q", "type"],
|
|
1133
1178
|
description:
|
|
1134
|
-
"Browse the end-user's Filestore files in a project, personal, or
|
|
1135
|
-
"space. The hook resolves owner_id from the host context (tenant
|
|
1136
|
-
"project/public, app user for personal) — the widget only chooses the " +
|
|
1179
|
+
"Browse the end-user's Filestore files in a project, personal, public or " +
|
|
1180
|
+
"record space. The hook resolves owner_id from the host context (tenant " +
|
|
1181
|
+
"for project/public, app user for personal) — the widget only chooses the " +
|
|
1137
1182
|
"space. A public space is world-readable: its files render even for " +
|
|
1138
|
-
"logged-out Player visitors.
|
|
1183
|
+
"logged-out Player visitors. A record space (`{ spaceType: \"record\", " +
|
|
1184
|
+
"recordId }`) holds one datastore record's attachments, readable by " +
|
|
1185
|
+
"exactly whoever may read that record; it has no folders, lists newest " +
|
|
1186
|
+
"first, and `type: \"media\"` narrows it to images and videos " +
|
|
1187
|
+
"(`\"document\"` to everything else). Reads ctx.filestore.files.list and unwraps " +
|
|
1139
1188
|
"{ data, meta } to the files array. Every row carries a ready-to-render " +
|
|
1140
1189
|
"`url` (its absolutized presigned_url, aliased by the filestore client) " +
|
|
1141
1190
|
"so a gallery renders `files.map(f => f.url)` directly. Every row ALSO " +
|
|
@@ -1190,11 +1239,13 @@ const HOOKS = [
|
|
|
1190
1239
|
},
|
|
1191
1240
|
{
|
|
1192
1241
|
name: "useFilestoreUpload",
|
|
1193
|
-
signature: "useFilestoreUpload({ spaceType, folderId, compress })",
|
|
1194
|
-
optionalArgs: ["folderId", "compress"],
|
|
1242
|
+
signature: "useFilestoreUpload({ spaceType, recordId, folderId, compress })",
|
|
1243
|
+
optionalArgs: ["recordId", "folderId", "compress"],
|
|
1195
1244
|
description:
|
|
1196
1245
|
"Upload a file into the end-user's Filestore space. The widget passes " +
|
|
1197
|
-
"the SPACE (`{ spaceType, folderId? }
|
|
1246
|
+
"the SPACE (`{ spaceType, folderId? }`, or `{ spaceType: \"record\", " +
|
|
1247
|
+
"recordId }` to attach the file to a datastore record: that needs write " +
|
|
1248
|
+
"on the record, and only the uploader may later delete it); the hook resolves owner_id " +
|
|
1198
1249
|
"from the host context, builds a multipart FormData with the " +
|
|
1199
1250
|
"snake_case fields the backend reads verbatim (`space_type`, " +
|
|
1200
1251
|
"`owner_id`, `folder_id`, plus the binary `file`), and POSTs through " +
|
|
@@ -1318,9 +1369,12 @@ const HOOKS = [
|
|
|
1318
1369
|
signature: "useFilestoreCopy()",
|
|
1319
1370
|
description:
|
|
1320
1371
|
"Copy a Filestore file into any space and folder the caller may add files " +
|
|
1321
|
-
"to, including ACROSS spaces (project, public,
|
|
1322
|
-
"personal space
|
|
1323
|
-
"
|
|
1372
|
+
"to, including ACROSS spaces (project, public, the signed-in user's " +
|
|
1373
|
+
"personal space, and a datastore record's attachments). " +
|
|
1374
|
+
"`copy(fileId, { spaceType, recordId?, folderId?, name? })` names the " +
|
|
1375
|
+
"DESTINATION (spaceType is required; `{ spaceType: \"record\", recordId }` " +
|
|
1376
|
+
"attaches the copy to that record, which needs write access to it and has " +
|
|
1377
|
+
"no folders); the hook resolves its owner_id from " +
|
|
1324
1378
|
"the host context and posts through ctx.filestore.files.copy. Resolves to " +
|
|
1325
1379
|
"the NEW file row (its own id and url) — the source is untouched and the " +
|
|
1326
1380
|
"bytes are duplicated, so later changes to one never affect the other. A " +
|
|
@@ -1332,7 +1386,7 @@ const HOOKS = [
|
|
|
1332
1386
|
"STORAGE_QUOTA_EXCEEDED when the destination space is full; 409 " +
|
|
1333
1387
|
"FILE_PROCESSING while a video is still being processed.",
|
|
1334
1388
|
returnShape: {
|
|
1335
|
-
copy: "(fileId, { spaceType, folderId?, name? }) => Promise<FilestoreFile>",
|
|
1389
|
+
copy: "(fileId, { spaceType, recordId?, folderId?, name? }) => Promise<FilestoreFile> // recordId with spaceType \"record\"",
|
|
1336
1390
|
copying: "boolean",
|
|
1337
1391
|
error: "Error | null",
|
|
1338
1392
|
lastCopied: "FilestoreFile | null",
|
|
@@ -1340,6 +1394,26 @@ const HOOKS = [
|
|
|
1340
1394
|
requiredContextSlice: ["filestore.files"],
|
|
1341
1395
|
scopes: ["files.write:*"],
|
|
1342
1396
|
},
|
|
1397
|
+
{
|
|
1398
|
+
name: "useFilestoreDelete",
|
|
1399
|
+
signature: "useFilestoreDelete()",
|
|
1400
|
+
description:
|
|
1401
|
+
"Delete a Filestore file the caller may delete. `remove(fileId)` calls " +
|
|
1402
|
+
"ctx.filestore.files.remove: a record attachment (a file in a record " +
|
|
1403
|
+
"space) is deleted permanently and only its uploader may do it; a file in " +
|
|
1404
|
+
"any other space moves to the trash. Use it when the thing that showed " +
|
|
1405
|
+
"the file is withdrawn, such as an unsent chat message, so the file stops " +
|
|
1406
|
+
"being readable rather than merely hidden. Refusals arrive as " +
|
|
1407
|
+
"`err.status`: 403 when the caller may not delete it, 404 when it is not " +
|
|
1408
|
+
"visible.",
|
|
1409
|
+
returnShape: {
|
|
1410
|
+
remove: "(fileId) => Promise<void>",
|
|
1411
|
+
removing: "boolean",
|
|
1412
|
+
error: "Error | null",
|
|
1413
|
+
},
|
|
1414
|
+
requiredContextSlice: ["filestore.files"],
|
|
1415
|
+
scopes: ["files.write:*"],
|
|
1416
|
+
},
|
|
1343
1417
|
{
|
|
1344
1418
|
name: "useFileSignature",
|
|
1345
1419
|
signature: "useFileSignature(fileId, existingSignatureId)",
|
|
@@ -1658,6 +1732,8 @@ const HOOKS = [
|
|
|
1658
1732
|
name: "useDirectory",
|
|
1659
1733
|
signature: "useDirectory(query)",
|
|
1660
1734
|
optionalArgs: ["query"],
|
|
1735
|
+
description:
|
|
1736
|
+
"Read-only roster of app users. query is { q?, role?, is_active?, group_id?, ids?, limit?, offset? }; ids (1 to 100) resolves the names behind ids a widget already holds.",
|
|
1661
1737
|
returnShape: {
|
|
1662
1738
|
users: "Array<{ id, name, role }> // snake_case rows; unwrapped from { data, meta }",
|
|
1663
1739
|
loading: "boolean",
|
|
@@ -1745,6 +1821,53 @@ const HOOKS = [
|
|
|
1745
1821
|
requiredContextSlice: ["notifications.send"],
|
|
1746
1822
|
scopes: ["notifications.send:appUser"],
|
|
1747
1823
|
},
|
|
1824
|
+
// sc-10268 — the signed-in user's mute of one record's notifications.
|
|
1825
|
+
// Reads ctx.notifications.mutes. No scope: it only touches the caller's
|
|
1826
|
+
// own setting, and the server requires their read access to the record.
|
|
1827
|
+
{
|
|
1828
|
+
name: "useRecordMute",
|
|
1829
|
+
signature: "useRecordMute(tableId, recordId)",
|
|
1830
|
+
description:
|
|
1831
|
+
"The signed-in user's mute of the notifications ONE datastore record produces for them (a chat conversation, a " +
|
|
1832
|
+
"thread). mute(until) takes an ISO time or a Date, or null (or nothing) to mute until unmute(). While muted, a " +
|
|
1833
|
+
"server action's notifications.notifyRecordSubjects on that record sends this user nothing: no inbox row, no push, " +
|
|
1834
|
+
"no email. mutedUntil is an ISO string, or null when muted until unmuted; muted turns false by itself once " +
|
|
1835
|
+
"mutedUntil passes. The user must be able to read the record, otherwise every call fails with NotificationError " +
|
|
1836
|
+
"{ code: \"RECORD_NOT_FOUND\" }, exactly as for a missing record. A falsy tableId or recordId returns a stable " +
|
|
1837
|
+
"not-muted result with no network call. On a host that cannot mute (the Builder canvas preview) it reads not " +
|
|
1838
|
+
"muted and mute() / unmute() reject with NOTIFICATIONS_UNAVAILABLE instead of the hook throwing at render. " +
|
|
1839
|
+
"Needs no scope: it only ever changes the caller's own setting. Identical on web (Player) and the Expo export.",
|
|
1840
|
+
returnShape: {
|
|
1841
|
+
muted: "boolean",
|
|
1842
|
+
mutedUntil: "string | null // ISO time the mute ends; null when muted until unmuted, or not muted",
|
|
1843
|
+
loading: "boolean",
|
|
1844
|
+
error: "NotificationError | null",
|
|
1845
|
+
mute: "(until?: string | Date | null) => Promise<void> // null or omitted = until unmuted",
|
|
1846
|
+
unmute: "() => Promise<void>",
|
|
1847
|
+
refetch: "() => Promise<void>",
|
|
1848
|
+
},
|
|
1849
|
+
requiredContextSlice: ["notifications.mutes"],
|
|
1850
|
+
scopes: null,
|
|
1851
|
+
},
|
|
1852
|
+
{
|
|
1853
|
+
name: "useRecordMutes",
|
|
1854
|
+
signature: "useRecordMutes(tableId)",
|
|
1855
|
+
description:
|
|
1856
|
+
"Which records of ONE table the signed-in user has muted, read in one request for a whole list (a chat's " +
|
|
1857
|
+
"conversations), never one useRecordMute per row. isMuted(recordId) is true while that record's mute is active " +
|
|
1858
|
+
"and turns false by itself once a timed mute lapses. A mute or unmute made through useRecordMute on the same table " +
|
|
1859
|
+
"shows here at once. A falsy tableId, or a host that cannot mute (the Builder canvas preview), reads nothing " +
|
|
1860
|
+
"muted with no network call. Needs no scope: it only reads the caller's own settings. Identical on web (Player) " +
|
|
1861
|
+
"and the Expo export.",
|
|
1862
|
+
returnShape: {
|
|
1863
|
+
isMuted: "(recordId: string) => boolean",
|
|
1864
|
+
loading: "boolean",
|
|
1865
|
+
error: "NotificationError | null",
|
|
1866
|
+
refetch: "() => Promise<void>",
|
|
1867
|
+
},
|
|
1868
|
+
requiredContextSlice: ["notifications.mutes"],
|
|
1869
|
+
scopes: null,
|
|
1870
|
+
},
|
|
1748
1871
|
// REQ-USERMGMT / REQ-ACL-SYS M3 — AppUser administration. Returns
|
|
1749
1872
|
// `{ users, loading, error, refetch, invite, deactivate, reactivate, remove }`.
|
|
1750
1873
|
// Reads need `users.read:*` scope; edit-style mutations (invite /
|
|
@@ -2298,9 +2421,12 @@ const HOOKS = [
|
|
|
2298
2421
|
optionalArgs: ["options"],
|
|
2299
2422
|
description:
|
|
2300
2423
|
"Take a photo or choose one from the device library. Returns { asset, loading, error, supported, capture, pick, reset }. " +
|
|
2424
|
+
"pick({ multiple: true }) lets the user choose up to 10 photos at once and resolves an ARRAY of assets (asset then holds that array); " +
|
|
2425
|
+
"pick({ videos: true }) offers videos beside photos — a video asset has a video/* mimeType and durationMs (null for a photo); play it with <Video>. " +
|
|
2426
|
+
"capture() always takes one. " +
|
|
2301
2427
|
"Capture is IMPERATIVE — call capture() or pick() from a user gesture (a tap); the browser and the mobile OS gate the " +
|
|
2302
2428
|
"permission prompt on a gesture, so it NEVER opens on mount. Both resolve to a normalised asset " +
|
|
2303
|
-
"{ uri, name, mimeType, width, height, size, file }, or NULL when the user dismisses the picker — dismissal is the " +
|
|
2429
|
+
"{ uri, name, mimeType, width, height, size, durationMs, file }, or NULL when the user dismisses the picker — dismissal is the " +
|
|
2304
2430
|
"common case and is deliberately NOT an error, so no try/catch is needed on the happy path. They reject with a " +
|
|
2305
2431
|
"CameraError whose .code is one of PERMISSION_DENIED | UNSUPPORTED | INTERNAL. `asset.file` is already the right " +
|
|
2306
2432
|
"upload part for the host — a BLOB on both (a File on web, expo-file-system's File on native), because Expo's fetch " +
|
|
@@ -2313,13 +2439,13 @@ const HOOKS = [
|
|
|
2313
2439
|
"rejects UNSUPPORTED — there is no silent fall back to a file dialog.",
|
|
2314
2440
|
returnShape: {
|
|
2315
2441
|
asset:
|
|
2316
|
-
"{ uri, name, mimeType, width, height, size, file } | null",
|
|
2442
|
+
"{ uri, name, mimeType, width, height, size, durationMs, file } | null",
|
|
2317
2443
|
loading: "boolean",
|
|
2318
2444
|
error: "CameraError | null",
|
|
2319
2445
|
supported: "boolean // false when the host brokers no camera",
|
|
2320
2446
|
capture:
|
|
2321
2447
|
"() => Promise<asset | null> // null if dismissed; rejects with CameraError",
|
|
2322
|
-
pick: "() => Promise<asset | null> // null if dismissed; rejects with CameraError",
|
|
2448
|
+
pick: "(options?: { multiple?: boolean, videos?: boolean }) => Promise<asset | asset[] | null> // { multiple: true } resolves an array of up to 10; { videos: true } offers videos too; null if dismissed; rejects with CameraError",
|
|
2323
2449
|
reset: "() => void // clear the asset + error and release it",
|
|
2324
2450
|
},
|
|
2325
2451
|
requiredContextSlice: [],
|
|
@@ -2529,6 +2655,14 @@ const PRIMITIVES = [
|
|
|
2529
2655
|
rnComponent: "expo-linear-gradient",
|
|
2530
2656
|
docsUrl: "https://docs.expo.dev/versions/latest/sdk/linear-gradient/",
|
|
2531
2657
|
},
|
|
2658
|
+
// sc-10268 — inline video playback with no platform split.
|
|
2659
|
+
{
|
|
2660
|
+
name: "Video",
|
|
2661
|
+
description:
|
|
2662
|
+
"Plays a video file inline with the platform's own controls. `<Video source={url} contentFit=\"cover\" style={{ width: 240, height: 180, borderRadius: theme.radii.md, overflow: \"hidden\" }} />`. `source` is a playable URL (a filestore file's url, or a picked asset's `uri` for a preview); `controls` (default true) shows play/seek/fullscreen; `autoPlay` starts it muted (browsers only autoplay muted video); `contentFit` is \"contain\" (default, letterboxed) or \"cover\"; `onError()` fires when the file cannot be played, so offer a download instead. Before playback it shows the first frame. Size it with `style` (it fills its box). Web uses the browser's <video>; native uses expo-video's VideoView, so the same widget plays on both hosts. A browser plays only the codecs it supports, so H.264 MP4 is the format that plays everywhere.",
|
|
2663
|
+
rnComponent: "expo-video",
|
|
2664
|
+
docsUrl: "https://docs.expo.dev/versions/latest/sdk/video/",
|
|
2665
|
+
},
|
|
2532
2666
|
// sc-6531 (REQ-AI-AGENT-DESIGN-LIFT) — the hover/press lift affordance. A
|
|
2533
2667
|
// FUNCTION primitive, not a component: it resolves the interaction tokens per
|
|
2534
2668
|
// input, so one declaration answers the pointer on web and the press on
|
|
@@ -2628,7 +2762,7 @@ const PRIMITIVES = [
|
|
|
2628
2762
|
{
|
|
2629
2763
|
name: "Avatar",
|
|
2630
2764
|
description:
|
|
2631
|
-
'Themed picture of a person. `<Avatar name={row.owner.label} url={photoUrl} size="sm" />`. THE way to show a PERSON: in a `<ListRow>` leading slot, a profile header, a list of members. It falls back from the picture to the name\'s initials to a person glyph, so the slot is never blank. Never use it for a place, product or topic. Props: `name` (the initials and the accessibility label come from it), `url` (an absolute image URL; resolve a file field to a URL first), `size` ("sm" 32 | "md" 40 | "lg" 56 | "xl" 80, or a number of px), `shape` ("circle" | "rounded"), `accessibilityLabel`, `style`, `testID`.',
|
|
2765
|
+
'Themed picture of a person. `<Avatar name={row.owner.label} url={photoUrl} size="sm" />`. THE way to show a PERSON: in a `<ListRow>` leading slot, a profile header, a list of members. It falls back from the picture to the name\'s initials to a person glyph, so the slot is never blank. Never use it for a place, product or topic. Props: `name` (the initials and the accessibility label come from it), `seed` (a stable id such as the user\'s id: the initials sit on that person\'s own colour, the same one the Studio\'s member lists use, so people in a list tell apart at a glance; `avatarColorFor(seed)` returns it), `url` (an absolute image URL; resolve a file field to a URL first), `size` ("sm" 32 | "md" 40 | "lg" 56 | "xl" 80, or a number of px), `shape` ("circle" | "rounded"), `accessibilityLabel`, `style`, `testID`.',
|
|
2632
2766
|
rnComponent: null,
|
|
2633
2767
|
docsUrl: null,
|
|
2634
2768
|
},
|
|
@@ -2913,13 +3047,13 @@ const MANIFEST_SCHEMA = {
|
|
|
2913
3047
|
type: "object",
|
|
2914
3048
|
required: false,
|
|
2915
3049
|
description:
|
|
2916
|
-
"Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 8 tables, 24 columns per table. RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 8 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected.",
|
|
3050
|
+
"Optional. Tables the widget needs, seeded into the workspace at install time. Authors wire them into the widget's `tableRef` properties via the Properties Panel — the SDK does not auto-bind. Limits: 8 tables, 24 columns per table. RELATION columns address siblings by `targetSuffix` (any table in the same array, in any order; tables may relate to each other both ways). Tables persist across uninstalls. A `tableRef` property may also carry `sharedTable: true`, declaring that the table is the APP'S OWN — the author picks an existing table and the widget seeds nothing for it. That is the correct shape for a widget that reads data it does not own (a chart, a metric, a manager view over another widget's table). `sharedTable` exempts the property from needing a seeded table at publish (`manifest.requiredTableRefsHaveTemplate`) WITHOUT making it optional: the author must still bind one, and the host still refuses to place the widget unbound. Use it instead of dropping `required` — a widget that silently renders an empty tile is the defect it looks like a fix for. sc-7530: a table may also declare `roleGrants` — group-scoped access named SYMBOLICALLY, because a group id belongs to the workspace that installs the widget and could never travel in the manifest. Declare the audiences once at template level as `roles: [{ key, label, description? }]` (key is lowercase kebab, max 8 roles), then per table `roleGrants: [{ role, canRead?, canWrite?, canDelete? }]`. The installing workspace binds each role to one of its own user groups (or has one created) during the install consent step, so a permission model where an admin group does everything and signed-in users only read survives the trip between workspaces. Use `publicGrant` for the two id-less audiences (everyone / any signed-in user) and `roleGrants` for anything that names a group. A role nobody binds simply writes no grant — it never fails the install. sc-7557: a column may declare `encrypted: true` — its values are stored AES-256-GCM encrypted at rest under a per-workspace subkey. The widget still reads and writes PLAINTEXT (the platform encrypts on write, decrypts for end users); studio users see `🔒` instead, and the column cannot be searched, filtered or sorted on. Sample `rows` you seed for it are encrypted the same way. Not valid on `RELATION` — its value is a foreign key the backend must resolve — and publishing one is rejected. An `inheritAcl` RELATION column may add `inheritAclReadOnly: true` — rows then inherit READ access only, so members of the parent read every child and may add children, but change or delete only the rows they created (pair with `grantCreatorPermissions`). A column may declare `defaultValue`, written when a create omits it: `@currentUser` on USER, `@now` on DATE / DATETIME, or a literal of the column's own type (none on FILE, RELATION or USER_GROUP — ids belong to the installing workspace). `defaultLocked: true` on an `@currentUser` / `@now` default makes the server always stamp it and ignore any client value on create and update, so an author column cannot be forged. `immutable: true` makes a column write-once: a create sets it as usual and any value sent for it on update is ignored (not valid on a MANY_TO_MANY relation).",
|
|
2917
3051
|
},
|
|
2918
3052
|
translations: {
|
|
2919
3053
|
type: "object",
|
|
2920
3054
|
required: false,
|
|
2921
3055
|
description:
|
|
2922
|
-
"Optional (REQ-L10N-WIDGET). Translation strings the widget ships. Shape { <key>: { en: string, <locale>?: string, ... } } — `en` is REQUIRED per key. At install the host merges these into the tenant's localization dictionary under a per-widget namespace (`widget.<manifest.id>.<key>`); `useI18n().t(\"<key>\")` resolves the namespaced key automatically, so authors never type the prefix. Non-destructive: an existing admin-edited value is never overwritten, and a language the tenant has not added is never created (values for absent locales are dropped; the widget's `en` seeds the tenant's base language so every key renders). Keys persist across uninstalls; admins prune them from the Translations admin (bulk delete by `widget.<id>.` prefix). Limits: ≤
|
|
3056
|
+
"Optional (REQ-L10N-WIDGET). Translation strings the widget ships. Shape { <key>: { en: string, <locale>?: string, ... } } — `en` is REQUIRED per key. At install the host merges these into the tenant's localization dictionary under a per-widget namespace (`widget.<manifest.id>.<key>`); `useI18n().t(\"<key>\")` resolves the namespaced key automatically, so authors never type the prefix. Non-destructive: an existing admin-edited value is never overwritten, and a language the tenant has not added is never created (values for absent locales are dropped; the widget's `en` seeds the tenant's base language so every key renders). Keys persist across uninstalls; admins prune them from the Translations admin (bulk delete by `widget.<id>.` prefix). Limits: ≤200 keys; each key matches /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/; each value ≤1 KB; the namespaced key must fit the dictionary key cap (128 chars).",
|
|
2923
3057
|
default: {},
|
|
2924
3058
|
},
|
|
2925
3059
|
};
|
|
@@ -2987,7 +3121,8 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
2987
3121
|
},
|
|
2988
3122
|
},
|
|
2989
3123
|
navigation: {
|
|
2990
|
-
description:
|
|
3124
|
+
description:
|
|
3125
|
+
"{ push(path), replace(path), back() }, and optionally setBadge(count) — the page badge usePageBadge() writes, bound by the host to this widget on its page — and onHardwareBack(listener) → unsubscribe, Android's back button for useHardwareBack(), which the native host answers only for the screen in view.",
|
|
2991
3126
|
required: true,
|
|
2992
3127
|
fields: { push: "function", replace: "function", back: "function" },
|
|
2993
3128
|
},
|
|
@@ -3158,9 +3293,9 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
3158
3293
|
// sc-890 — backs useSendNotification(). Mirror of contract.cjs.
|
|
3159
3294
|
notifications: {
|
|
3160
3295
|
description:
|
|
3161
|
-
"Injected @colixsystems/notifications-client instance (sc-890). { send(body) -> Promise<notification
|
|
3296
|
+
"Injected @colixsystems/notifications-client instance (sc-890). { send(body) -> Promise<notification>, mutes: { get, set, clear } }. send backs useSendNotification(); requires the notifications.send:appUser scope. The host POSTs the body snake_case verbatim ({ recipient_user_id, title, body, link?, payload? }) to /notifications/send and returns the created notification row; the call is imperative (a send never fires on mount). mutes backs useRecordMute() and useRecordMutes() (sc-10268): get({ table_id, record_id }) / set({ table_id, record_id, muted_until }) -> { muted, muted_until }, clear({ table_id, record_id }) -> undefined, list({ table_id, limit?, offset? }) -> { data: [{ record_id, muted_until }], meta }. mutes is optional — a host without it (the Builder canvas preview) leaves useRecordMute() reading not muted.",
|
|
3162
3297
|
required: true,
|
|
3163
|
-
fields: { send: "function" },
|
|
3298
|
+
fields: { send: "function", mutes: "object" },
|
|
3164
3299
|
},
|
|
3165
3300
|
// REQ-IDENT (sc-4313) — backs useIdentification(). Mirror of contract.cjs.
|
|
3166
3301
|
identification: {
|
|
@@ -3471,7 +3606,8 @@ const VETTED_IMPORTS = [
|
|
|
3471
3606
|
platforms: ["native"],
|
|
3472
3607
|
category: "media",
|
|
3473
3608
|
description:
|
|
3474
|
-
"Native video playback (Expo SDK 56; replaces the removed expo-av). Native-only; pair with the browser <video> element in widget.web.jsx."
|
|
3609
|
+
"Native video playback (Expo SDK 56; replaces the removed expo-av). Native-only; pair with the browser <video> element in widget.web.jsx. " +
|
|
3610
|
+
"To play a video file on BOTH hosts, prefer the SDK's <Video> primitive — no platform split needed.",
|
|
3475
3611
|
},
|
|
3476
3612
|
{
|
|
3477
3613
|
specifier: "@react-native-community/datetimepicker",
|
|
@@ -5104,7 +5240,31 @@ const CONTRACT = deepFreeze({
|
|
|
5104
5240
|
// as before; a malformed `via` is dropped, or voids the gradient under the
|
|
5105
5241
|
// new `{ strict: true }` option. New `componentGradientColors` / `componentGradientMidpoint`
|
|
5106
5242
|
// read the paint list and the ink basis off a normalised gradient.
|
|
5107
|
-
|
|
5243
|
+
// 1.158.0: additive (sc-10268) — a datastoreTemplate column may declare
|
|
5244
|
+
// `inheritAclReadOnly` (with `inheritAcl`), `defaultValue`, and
|
|
5245
|
+
// `defaultLocked` (with a "@currentUser" / "@now" default), and
|
|
5246
|
+
// `immutable` (write-once; not on MANY_TO_MANY).
|
|
5247
|
+
// (sc-10269) `useFilestoreFiles` / `useFilestoreUpload` accept
|
|
5248
|
+
// `spaceType: "record"` with `recordId`: a record's private attachments.
|
|
5249
|
+
// New `useFilestoreDelete()` → `{ remove, removing, error }`.
|
|
5250
|
+
// `useCamera().pick({ multiple: true })` resolves an array of up to 10.
|
|
5251
|
+
// `<Avatar seed>` + `avatarColorFor(seed)`: a person's own monogram
|
|
5252
|
+
// colour, shared with the Studio's member lists.
|
|
5253
|
+
// New `useRecordMute(tableId, recordId)` mutes one record's
|
|
5254
|
+
// notifications for the signed-in user, through the new
|
|
5255
|
+
// `ctx.notifications.mutes` ({ get, set, clear }).
|
|
5256
|
+
// `useFilestoreCopy().copy` takes `{ spaceType: "record", recordId }`.
|
|
5257
|
+
// `useDirectory({ ids })` resolves the names behind up to 100 user ids.
|
|
5258
|
+
// `manifest.translations` may declare up to 200 keys (was 100).
|
|
5259
|
+
// New `useRecordMutes(tableId)` lists the muted records at once;
|
|
5260
|
+
// `usePageBadge(count)` counts on the page's menu entry through the new
|
|
5261
|
+
// optional `ctx.navigation.setBadge`; `useFilePreview(file)` shows a
|
|
5262
|
+
// picked file before upload. New `<Video>` plays a file inline on both
|
|
5263
|
+
// hosts, and `useCamera().pick({ videos: true })` offers videos, an
|
|
5264
|
+
// asset carrying `durationMs`. New `useHardwareBack(handler)` walks a
|
|
5265
|
+
// widget's own pages on Android's back button (optional
|
|
5266
|
+
// `ctx.navigation.onHardwareBack`; unsupported on the web).
|
|
5267
|
+
version: "1.158.0",
|
|
5108
5268
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
5109
5269
|
hooks: HOOKS,
|
|
5110
5270
|
primitives: PRIMITIVES,
|