@colixsystems/widget-sdk 0.197.0 → 0.199.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 +148 -3
- package/dist/avatar-color.js +35 -0
- package/dist/bundled-assets-fs.js +60 -0
- package/dist/bundled-assets.cjs +239 -0
- package/dist/bundled-assets.js +239 -0
- package/dist/cli.js +52 -11
- package/dist/contract.cjs +225 -26
- package/dist/contract.js +225 -26
- package/dist/detail-parts-view.js +9 -4
- package/dist/devserver.js +68 -10
- package/dist/hooks.js +549 -28
- package/dist/index.d.ts +247 -5
- package/dist/index.js +14 -0
- package/dist/index.native.js +14 -0
- package/dist/initials.js +3 -2
- package/dist/linter.cjs +28 -0
- package/dist/linter.js +28 -0
- package/dist/manifest.cjs +3 -1
- package/dist/manifest.js +3 -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 +11 -1
- package/src/contract.js +225 -26
package/dist/hooks.js
CHANGED
|
@@ -590,6 +590,91 @@ export function useNavigation() {
|
|
|
590
590
|
return ctx.navigation;
|
|
591
591
|
}
|
|
592
592
|
|
|
593
|
+
/**
|
|
594
|
+
* sc-10268 — `{ uri }` an `<Image>` can show for a file the person picked but
|
|
595
|
+
* has not uploaded yet, such as a photo waiting in a composer. Native picks
|
|
596
|
+
* carry their own file uri; on the web the SDK makes a temporary object URL
|
|
597
|
+
* and releases it when the file changes or the component unmounts. `uri` is
|
|
598
|
+
* null for no file, or a host that cannot preview one.
|
|
599
|
+
*/
|
|
600
|
+
export function useFilePreview(file) {
|
|
601
|
+
const [uri, setUri] = useState(null);
|
|
602
|
+
useEffect(() => {
|
|
603
|
+
if (!file) {
|
|
604
|
+
setUri(null);
|
|
605
|
+
return undefined;
|
|
606
|
+
}
|
|
607
|
+
if (typeof file.uri === "string" && file.uri) {
|
|
608
|
+
setUri(file.uri);
|
|
609
|
+
return undefined;
|
|
610
|
+
}
|
|
611
|
+
const urls = typeof URL !== "undefined" ? URL : null;
|
|
612
|
+
const isBlob = typeof Blob !== "undefined" && file instanceof Blob;
|
|
613
|
+
if (!urls || typeof urls.createObjectURL !== "function" || !isBlob) {
|
|
614
|
+
setUri(null);
|
|
615
|
+
return undefined;
|
|
616
|
+
}
|
|
617
|
+
const objectUrl = urls.createObjectURL(file);
|
|
618
|
+
setUri(objectUrl);
|
|
619
|
+
return () => urls.revokeObjectURL(objectUrl);
|
|
620
|
+
}, [file]);
|
|
621
|
+
return { uri };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// A count the host can draw: a whole number, 0 for nothing to show.
|
|
625
|
+
function _badgeCount(count) {
|
|
626
|
+
return typeof count === "number" && Number.isFinite(count) && count > 0 ? Math.floor(count) : 0;
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* sc-10268 — puts `count` on the menu entry or tab of the page this widget is
|
|
631
|
+
* on, such as a chat's unread messages. Returns `{ supported }`. The host sums
|
|
632
|
+
* every widget on the page and keeps the last count after the widget leaves
|
|
633
|
+
* the screen, until it reports again; 0 (or anything not a positive number)
|
|
634
|
+
* clears this widget's share. A host without page badges (the Builder canvas)
|
|
635
|
+
* ignores it.
|
|
636
|
+
*/
|
|
637
|
+
export function usePageBadge(count) {
|
|
638
|
+
const ctx = useWidgetContextOrThrow("usePageBadge");
|
|
639
|
+
const nav = ctx.navigation || {};
|
|
640
|
+
const setBadge = typeof nav.setBadge === "function" ? nav.setBadge : null;
|
|
641
|
+
const value = _badgeCount(count);
|
|
642
|
+
useEffect(() => {
|
|
643
|
+
if (setBadge) setBadge(value);
|
|
644
|
+
}, [setBadge, value]);
|
|
645
|
+
return { supported: Boolean(setBadge) };
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* sc-10268 — Android's back button, for a widget with pages of its own (a
|
|
650
|
+
* thread over a list, a settings page over a thread). `handler` runs on a
|
|
651
|
+
* back press while the widget's screen is in view; return true when it went
|
|
652
|
+
* back a step, false to let the app go back. Pass null while there is nothing
|
|
653
|
+
* to go back from. Returns `{ supported }`: false on the web, where browsers
|
|
654
|
+
* have no hardware back, so keep a visible back control there.
|
|
655
|
+
*/
|
|
656
|
+
export function useHardwareBack(handler) {
|
|
657
|
+
const ctx = useWidgetContextOrThrow("useHardwareBack");
|
|
658
|
+
const nav = ctx.navigation || {};
|
|
659
|
+
const subscribe =
|
|
660
|
+
typeof nav.onHardwareBack === "function" ? nav.onHardwareBack : null;
|
|
661
|
+
// Refs, so a new handler or context each render never re-subscribes.
|
|
662
|
+
const subscribeRef = useRef(subscribe);
|
|
663
|
+
subscribeRef.current = subscribe;
|
|
664
|
+
const handlerRef = useRef(handler);
|
|
665
|
+
handlerRef.current = handler;
|
|
666
|
+
const active = Boolean(subscribe) && typeof handler === "function";
|
|
667
|
+
useEffect(() => {
|
|
668
|
+
if (!active) return undefined;
|
|
669
|
+
return subscribeRef.current(() =>
|
|
670
|
+
typeof handlerRef.current === "function"
|
|
671
|
+
? Boolean(handlerRef.current())
|
|
672
|
+
: false,
|
|
673
|
+
);
|
|
674
|
+
}, [active]);
|
|
675
|
+
return { supported: Boolean(subscribe) };
|
|
676
|
+
}
|
|
677
|
+
|
|
593
678
|
const EMPTY_PARAMS = Object.freeze({});
|
|
594
679
|
// Stable references so `useEffect([params])` / `[records]` don't re-fire every
|
|
595
680
|
// render on a page that declares no parameters.
|
|
@@ -1863,6 +1948,15 @@ function toCameraError(err) {
|
|
|
1863
1948
|
return new CameraError(code, message, { cause: err });
|
|
1864
1949
|
}
|
|
1865
1950
|
|
|
1951
|
+
const CAMERA_PICK_LIMIT = 10;
|
|
1952
|
+
|
|
1953
|
+
// One asset or a multi-pick's array: every one holds a host resource to free.
|
|
1954
|
+
function releaseCameraAssets(result) {
|
|
1955
|
+
for (const item of Array.isArray(result) ? result : [result]) {
|
|
1956
|
+
if (item && typeof item.release === "function") item.release();
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1866
1960
|
/**
|
|
1867
1961
|
* Take a photo or choose one from the device library. Returns
|
|
1868
1962
|
* `{ asset, loading, error, supported, capture, pick, reset }`.
|
|
@@ -1886,6 +1980,10 @@ function toCameraError(err) {
|
|
|
1886
1980
|
*
|
|
1887
1981
|
* Check `supported` before rendering a camera button; a host with no broker
|
|
1888
1982
|
* reports false rather than throwing at render.
|
|
1983
|
+
*
|
|
1984
|
+
* `pick({ multiple: true })` lets the user choose up to 10 photos at once and
|
|
1985
|
+
* resolves an array of assets (or `null` when dismissed); `asset` then holds
|
|
1986
|
+
* that array. `capture()` always takes one photo.
|
|
1889
1987
|
*/
|
|
1890
1988
|
export function useCamera(options) {
|
|
1891
1989
|
const ctx = useWidgetContextOrThrow("useCamera");
|
|
@@ -1950,7 +2048,7 @@ export function useCamera(options) {
|
|
|
1950
2048
|
// run through one request path — otherwise the loading/abort bookkeeping
|
|
1951
2049
|
// would exist twice and drift.
|
|
1952
2050
|
const request = useCallback(
|
|
1953
|
-
async (method) => {
|
|
2051
|
+
async (method, callOptions) => {
|
|
1954
2052
|
const client = clientRef.current;
|
|
1955
2053
|
if (
|
|
1956
2054
|
!client ||
|
|
@@ -1969,17 +2067,16 @@ export function useCamera(options) {
|
|
|
1969
2067
|
setError(null);
|
|
1970
2068
|
try {
|
|
1971
2069
|
inFlightRef.current += 1;
|
|
1972
|
-
const next = await client[method](optionsRef.current || {});
|
|
2070
|
+
const next = await client[method]({ ...(optionsRef.current || {}), ...(callOptions || {}) });
|
|
1973
2071
|
// A reset() or a newer request landed while this one was open — drop
|
|
1974
2072
|
// the result rather than clobbering what the widget now shows.
|
|
1975
2073
|
if (run !== runRef.current) {
|
|
1976
|
-
|
|
2074
|
+
releaseCameraAssets(next);
|
|
1977
2075
|
return null;
|
|
1978
2076
|
}
|
|
1979
|
-
if (!next) return null;
|
|
2077
|
+
if (!next || (Array.isArray(next) && next.length === 0)) return null;
|
|
1980
2078
|
release();
|
|
1981
|
-
releaseRef.current =
|
|
1982
|
-
typeof next.release === "function" ? next.release : null;
|
|
2079
|
+
releaseRef.current = () => releaseCameraAssets(next);
|
|
1983
2080
|
setAsset(next);
|
|
1984
2081
|
return next;
|
|
1985
2082
|
} catch (err) {
|
|
@@ -2001,7 +2098,26 @@ export function useCamera(options) {
|
|
|
2001
2098
|
);
|
|
2002
2099
|
|
|
2003
2100
|
const capture = useCallback(() => request("capture"), [request]);
|
|
2004
|
-
|
|
2101
|
+
// sc-10268: pick({ multiple: true }) resolves an array of up to
|
|
2102
|
+
// CAMERA_PICK_LIMIT photos, and { videos: true } offers videos beside them.
|
|
2103
|
+
// Anything else (an onPress event) is a single photo.
|
|
2104
|
+
const pick = useCallback(
|
|
2105
|
+
(callOptions) => {
|
|
2106
|
+
const opts = callOptions && typeof callOptions === "object" ? callOptions : {};
|
|
2107
|
+
const many = opts.multiple === true;
|
|
2108
|
+
const videos = opts.videos === true;
|
|
2109
|
+
return request(
|
|
2110
|
+
"pick",
|
|
2111
|
+
many || videos
|
|
2112
|
+
? {
|
|
2113
|
+
...(many ? { multiple: true, limit: CAMERA_PICK_LIMIT } : null),
|
|
2114
|
+
...(videos ? { videos: true } : null),
|
|
2115
|
+
}
|
|
2116
|
+
: undefined,
|
|
2117
|
+
);
|
|
2118
|
+
},
|
|
2119
|
+
[request],
|
|
2120
|
+
);
|
|
2005
2121
|
|
|
2006
2122
|
return { asset, loading, error, supported, capture, pick, reset };
|
|
2007
2123
|
}
|
|
@@ -3596,7 +3712,9 @@ export function useDatastoreMutation(table) {
|
|
|
3596
3712
|
[resolveNs],
|
|
3597
3713
|
);
|
|
3598
3714
|
|
|
3599
|
-
|
|
3715
|
+
// sc-10268: one object while the methods are unchanged, so a widget can list
|
|
3716
|
+
// the mutation in a dependency array without re-running every render.
|
|
3717
|
+
return useMemo(() => ({ create, update, delete: del }), [create, update, del]);
|
|
3600
3718
|
}
|
|
3601
3719
|
|
|
3602
3720
|
/**
|
|
@@ -4216,6 +4334,20 @@ export function useAsset(assetId) {
|
|
|
4216
4334
|
return { url, asset, loading, error, refetch };
|
|
4217
4335
|
}
|
|
4218
4336
|
|
|
4337
|
+
/**
|
|
4338
|
+
* URL of a file the widget package ships in its `assets/` directory, or
|
|
4339
|
+
* `null` when the package bundles no file with that name.
|
|
4340
|
+
*/
|
|
4341
|
+
export function useBundledAsset(fileName) {
|
|
4342
|
+
const ctx = useWidgetContextOrThrow("useBundledAsset");
|
|
4343
|
+
// An older host publishes no `widget.assets`.
|
|
4344
|
+
const assets = ctx.widget && ctx.widget.assets;
|
|
4345
|
+
if (!fileName || !assets) return null;
|
|
4346
|
+
return Object.prototype.hasOwnProperty.call(assets, fileName)
|
|
4347
|
+
? assets[fileName]
|
|
4348
|
+
: null;
|
|
4349
|
+
}
|
|
4350
|
+
|
|
4219
4351
|
/**
|
|
4220
4352
|
* List every tenant asset carrying a given tag. Returns
|
|
4221
4353
|
* `{ assets, loading, error, refetch }`. The widget passes a single `tag`
|
|
@@ -4303,8 +4435,10 @@ export function useAssetsByTag(tag, options) {
|
|
|
4303
4435
|
// owned by the signed-in app user (ctx.user.id). The widget only chooses the
|
|
4304
4436
|
// space. PUBLIC is world-readable, so its files resolve even when there is no
|
|
4305
4437
|
// signed-in user (ctx.workspace.id is set for a logged-out Player visitor too).
|
|
4306
|
-
|
|
4438
|
+
// sc-10269: a RECORD space is owned by the datastore record the widget names.
|
|
4439
|
+
function _filestoreOwnerId(ctx, spaceType, recordId) {
|
|
4307
4440
|
const space = String(spaceType || "project").toUpperCase();
|
|
4441
|
+
if (space === "RECORD") return recordId || null;
|
|
4308
4442
|
if (space === "PERSONAL") return (ctx.user && ctx.user.id) || null;
|
|
4309
4443
|
return (ctx.workspace && ctx.workspace.id) || null;
|
|
4310
4444
|
}
|
|
@@ -4312,8 +4446,9 @@ function _filestoreOwnerId(ctx, spaceType) {
|
|
|
4312
4446
|
/**
|
|
4313
4447
|
* Browse the end-user's Filestore files. Returns { files, loading, error,
|
|
4314
4448
|
* refetch }. The widget passes only the SPACE (`{ spaceType, folderId, q,
|
|
4315
|
-
* type }`); the hook resolves owner_id
|
|
4316
|
-
* project, app user for personal
|
|
4449
|
+
* type }`, plus `recordId` for a `record` space); the hook resolves owner_id
|
|
4450
|
+
* from the host context (tenant for project, app user for personal, the named
|
|
4451
|
+
* record for record) and calls
|
|
4317
4452
|
* `ctx.filestore.files.list({ space_type, owner_id, folder_id, q, type })`,
|
|
4318
4453
|
* unwrapping the `{ data, meta }` envelope to the files array. When the owner
|
|
4319
4454
|
* can't be resolved (no signed-in user for a personal space) it collapses to an
|
|
@@ -4330,8 +4465,8 @@ export function useFilestoreFiles(options) {
|
|
|
4330
4465
|
"useFilestoreFiles: host did not inject a filestore client (ctx.filestore.files.list)",
|
|
4331
4466
|
);
|
|
4332
4467
|
}
|
|
4333
|
-
const { spaceType = "project", folderId = null, q, type } = options || {};
|
|
4334
|
-
const ownerId = _filestoreOwnerId(ctx, spaceType);
|
|
4468
|
+
const { spaceType = "project", recordId = null, folderId = null, q, type } = options || {};
|
|
4469
|
+
const ownerId = _filestoreOwnerId(ctx, spaceType, recordId);
|
|
4335
4470
|
|
|
4336
4471
|
const [files, setFiles] = useState([]);
|
|
4337
4472
|
const [loading, setLoading] = useState(true);
|
|
@@ -4468,9 +4603,10 @@ export function useFilestoreFile(fileId) {
|
|
|
4468
4603
|
/**
|
|
4469
4604
|
* sc-1378 — upload a file into the end-user's Filestore space. Returns
|
|
4470
4605
|
* `{ upload, uploading, error, lastUploaded }`. The widget passes the
|
|
4471
|
-
* SPACE (`{ spaceType, folderId? }`
|
|
4472
|
-
*
|
|
4473
|
-
*
|
|
4606
|
+
* SPACE (`{ spaceType, folderId? }`, or `{ spaceType: "record", recordId }`
|
|
4607
|
+
* to attach the file to a datastore record); the hook resolves `owner_id` the
|
|
4608
|
+
* same way the read hooks do (tenant for PROJECT, app user for PERSONAL, the
|
|
4609
|
+
* record for RECORD) and posts a multipart FormData to `ctx.filestore.files.upload`.
|
|
4474
4610
|
*
|
|
4475
4611
|
* `upload(file, { folderId?, fileName? })` takes a browser `File` on the web
|
|
4476
4612
|
* Player and expo-file-system's `File` on the Expo export, as `<FilePicker>`,
|
|
@@ -4502,10 +4638,11 @@ export function useFilestoreUpload(options) {
|
|
|
4502
4638
|
}
|
|
4503
4639
|
const {
|
|
4504
4640
|
spaceType = "project",
|
|
4641
|
+
recordId = null,
|
|
4505
4642
|
folderId: defaultFolderId = null,
|
|
4506
4643
|
compress: defaultCompress = true,
|
|
4507
4644
|
} = options || {};
|
|
4508
|
-
const ownerId = _filestoreOwnerId(ctx, spaceType);
|
|
4645
|
+
const ownerId = _filestoreOwnerId(ctx, spaceType, recordId);
|
|
4509
4646
|
|
|
4510
4647
|
const [uploading, setUploading] = useState(false);
|
|
4511
4648
|
const [error, setError] = useState(null);
|
|
@@ -4901,11 +5038,13 @@ export function useFilestoreCreateFolder(options) {
|
|
|
4901
5038
|
|
|
4902
5039
|
/**
|
|
4903
5040
|
* Copy a Filestore file into any space and folder the caller may add files
|
|
4904
|
-
* to — including ACROSS spaces (project, public, the user's personal space
|
|
4905
|
-
* Returns `{ copy, copying, error, lastCopied }`.
|
|
5041
|
+
* to — including ACROSS spaces (project, public, the user's personal space,
|
|
5042
|
+
* a datastore record's attachments). Returns `{ copy, copying, error, lastCopied }`.
|
|
4906
5043
|
*
|
|
4907
|
-
* `copy(fileId, { spaceType, folderId?, name? })` names the
|
|
4908
|
-
* `spaceType` is required
|
|
5044
|
+
* `copy(fileId, { spaceType, recordId?, folderId?, name? })` names the
|
|
5045
|
+
* DESTINATION; `spaceType` is required, and `{ spaceType: "record", recordId }`
|
|
5046
|
+
* attaches the copy to that record (write access to it is needed; a record has
|
|
5047
|
+
* no folders). The hook resolves the destination owner_id the same
|
|
4909
5048
|
* way the upload hook does and posts through `ctx.filestore.files.copy`. It
|
|
4910
5049
|
* resolves to the NEW file row (its own id and url); the source is untouched.
|
|
4911
5050
|
* A name already taken at the destination lands as "name (2).ext" rather than
|
|
@@ -4938,11 +5077,14 @@ export function useFilestoreCopy() {
|
|
|
4938
5077
|
|
|
4939
5078
|
const copy = useCallback(async (fileId, destination) => {
|
|
4940
5079
|
if (!fileId) throw new Error("useFilestoreCopy: fileId is required");
|
|
4941
|
-
const { spaceType, folderId, name } = destination || {};
|
|
5080
|
+
const { spaceType, recordId, folderId, name } = destination || {};
|
|
4942
5081
|
if (!spaceType) {
|
|
4943
5082
|
throw new Error("useFilestoreCopy: destination spaceType is required");
|
|
4944
5083
|
}
|
|
4945
|
-
|
|
5084
|
+
if (String(spaceType).toUpperCase() === "RECORD" && !recordId) {
|
|
5085
|
+
throw new Error("useFilestoreCopy: a record destination needs its recordId");
|
|
5086
|
+
}
|
|
5087
|
+
const ownerId = _filestoreOwnerId(ctxRef.current, spaceType, recordId);
|
|
4946
5088
|
if (!ownerId) {
|
|
4947
5089
|
const err = new Error("Sign in to copy files");
|
|
4948
5090
|
setError(err);
|
|
@@ -4971,6 +5113,48 @@ export function useFilestoreCopy() {
|
|
|
4971
5113
|
return { copy, copying, error, lastCopied };
|
|
4972
5114
|
}
|
|
4973
5115
|
|
|
5116
|
+
/**
|
|
5117
|
+
* sc-10269 — delete a Filestore file the caller may delete. Returns
|
|
5118
|
+
* `{ remove, removing, error }`. `remove(fileId)` calls
|
|
5119
|
+
* `ctx.filestore.files.remove`: a record attachment is deleted permanently and
|
|
5120
|
+
* only its uploader may do it; a file in any other space moves to the trash.
|
|
5121
|
+
* Refusals arrive as `err.status` (403 not permitted, 404 not visible).
|
|
5122
|
+
*/
|
|
5123
|
+
export function useFilestoreDelete() {
|
|
5124
|
+
const ctx = useWidgetContextOrThrow("useFilestoreDelete");
|
|
5125
|
+
if (
|
|
5126
|
+
!ctx.filestore ||
|
|
5127
|
+
!ctx.filestore.files ||
|
|
5128
|
+
typeof ctx.filestore.files.remove !== "function"
|
|
5129
|
+
) {
|
|
5130
|
+
throw new Error(
|
|
5131
|
+
"useFilestoreDelete: host did not inject a filestore client (ctx.filestore.files.remove)",
|
|
5132
|
+
);
|
|
5133
|
+
}
|
|
5134
|
+
|
|
5135
|
+
const [removing, setRemoving] = useState(false);
|
|
5136
|
+
const [error, setError] = useState(null);
|
|
5137
|
+
|
|
5138
|
+
const filesRef = useRef(ctx.filestore.files);
|
|
5139
|
+
filesRef.current = ctx.filestore.files;
|
|
5140
|
+
|
|
5141
|
+
const remove = useCallback(async (fileId) => {
|
|
5142
|
+
if (!fileId) throw new Error("useFilestoreDelete: fileId is required");
|
|
5143
|
+
setRemoving(true);
|
|
5144
|
+
setError(null);
|
|
5145
|
+
try {
|
|
5146
|
+
await filesRef.current.remove(fileId);
|
|
5147
|
+
setRemoving(false);
|
|
5148
|
+
} catch (err) {
|
|
5149
|
+
setError(err);
|
|
5150
|
+
setRemoving(false);
|
|
5151
|
+
throw err;
|
|
5152
|
+
}
|
|
5153
|
+
}, []);
|
|
5154
|
+
|
|
5155
|
+
return { remove, removing, error };
|
|
5156
|
+
}
|
|
5157
|
+
|
|
4974
5158
|
/**
|
|
4975
5159
|
* Drive a BankID signing flow for one Filestore file. Returns
|
|
4976
5160
|
* `{ status, qr, autoStartToken, message, signerName, signedAt, verdict,
|
|
@@ -6521,7 +6705,7 @@ export function usePayments() {
|
|
|
6521
6705
|
/* ============================================================================
|
|
6522
6706
|
* NOTIFICATIONS CLIENT — ctx.notifications (@colixsystems/notifications-client)
|
|
6523
6707
|
*
|
|
6524
|
-
* send. Covers: useSendNotification.
|
|
6708
|
+
* send, mutes. Covers: useSendNotification, useRecordMute.
|
|
6525
6709
|
* ==========================================================================*/
|
|
6526
6710
|
|
|
6527
6711
|
/**
|
|
@@ -6539,7 +6723,9 @@ export function usePayments() {
|
|
|
6539
6723
|
* - "INTERNAL" — anything else (network, 5xx)
|
|
6540
6724
|
*
|
|
6541
6725
|
* The server's stable `code` (when volunteered in the error body) is preserved
|
|
6542
|
-
* over the status-derived one.
|
|
6726
|
+
* over the status-derived one. `useRecordMute` adds "RECORD_NOT_FOUND" (404:
|
|
6727
|
+
* the record is missing or the user cannot read it), "INVALID_MUTED_UNTIL",
|
|
6728
|
+
* "INVALID_RECORD_REF" and "NOTIFICATIONS_UNAVAILABLE" (no mute client here).
|
|
6543
6729
|
*/
|
|
6544
6730
|
export class NotificationError extends Error {
|
|
6545
6731
|
constructor(code, message, opts) {
|
|
@@ -6550,7 +6736,7 @@ export class NotificationError extends Error {
|
|
|
6550
6736
|
}
|
|
6551
6737
|
}
|
|
6552
6738
|
|
|
6553
|
-
function toNotificationError(err) {
|
|
6739
|
+
function toNotificationError(err, notFoundCode = "RECIPIENT_NOT_FOUND") {
|
|
6554
6740
|
if (err instanceof NotificationError) return err;
|
|
6555
6741
|
// The injected notifications-client rejects with its own NotificationError
|
|
6556
6742
|
// subclass carrying { code, status }; the host facade may instead throw an
|
|
@@ -6562,18 +6748,21 @@ function toNotificationError(err) {
|
|
|
6562
6748
|
: err && typeof err.status === "number"
|
|
6563
6749
|
? err.status
|
|
6564
6750
|
: null;
|
|
6751
|
+
// The client keeps the server body at err.details; its own err.code is a
|
|
6752
|
+
// per-status class default, so it only answers when nothing else does.
|
|
6565
6753
|
const bodyCode =
|
|
6566
6754
|
(err && err.response && err.response.data && err.response.data.code) ||
|
|
6567
|
-
(err && typeof err.code === "string" ? err.code : null);
|
|
6755
|
+
(err && err.details && typeof err.details.code === "string" ? err.details.code : null);
|
|
6568
6756
|
const bodyMessage =
|
|
6569
6757
|
err && err.response && err.response.data && err.response.data.error;
|
|
6570
6758
|
let code = "INTERNAL";
|
|
6571
6759
|
if (typeof bodyCode === "string" && bodyCode) code = bodyCode;
|
|
6572
6760
|
else if (status === 401) code = "AUTH_REQUIRED";
|
|
6573
6761
|
else if (status === 403) code = "FORBIDDEN";
|
|
6574
|
-
else if (status === 404) code =
|
|
6762
|
+
else if (status === 404) code = notFoundCode;
|
|
6575
6763
|
else if (status === 429) code = "RATE_LIMITED";
|
|
6576
6764
|
else if (status === 400) code = "VALIDATION";
|
|
6765
|
+
else if (err && typeof err.code === "string" && err.code) code = err.code;
|
|
6577
6766
|
const message =
|
|
6578
6767
|
(typeof bodyMessage === "string" && bodyMessage) ||
|
|
6579
6768
|
(err && typeof err.message === "string"
|
|
@@ -6638,3 +6827,335 @@ export function useSendNotification() {
|
|
|
6638
6827
|
|
|
6639
6828
|
return { send, sending, error };
|
|
6640
6829
|
}
|
|
6830
|
+
|
|
6831
|
+
const _NOT_MUTED = Object.freeze({ muted: false, mutedUntil: null });
|
|
6832
|
+
// setTimeout overflows past ~24.8 days; a later expiry waits for a refetch.
|
|
6833
|
+
const _MAX_TIMER_MS = 2147483647;
|
|
6834
|
+
|
|
6835
|
+
const _RECORD_MUTE_IDLE = Object.freeze({
|
|
6836
|
+
muted: false,
|
|
6837
|
+
mutedUntil: null,
|
|
6838
|
+
loading: false,
|
|
6839
|
+
error: null,
|
|
6840
|
+
mute: async () => undefined,
|
|
6841
|
+
unmute: async () => undefined,
|
|
6842
|
+
refetch: async () => undefined,
|
|
6843
|
+
});
|
|
6844
|
+
|
|
6845
|
+
// `mute(until)` input → the wire's ISO string, or null for until unmuted.
|
|
6846
|
+
function _muteUntilIso(until) {
|
|
6847
|
+
if (until === undefined || until === null) return null;
|
|
6848
|
+
const at =
|
|
6849
|
+
until instanceof Date ? until : typeof until === "string" ? new Date(until) : null;
|
|
6850
|
+
if (!at || Number.isNaN(at.getTime())) {
|
|
6851
|
+
throw new NotificationError(
|
|
6852
|
+
"INVALID_MUTED_UNTIL",
|
|
6853
|
+
"useRecordMute: mute(until) takes an ISO time, a Date, or null for until unmuted",
|
|
6854
|
+
);
|
|
6855
|
+
}
|
|
6856
|
+
return at.toISOString();
|
|
6857
|
+
}
|
|
6858
|
+
|
|
6859
|
+
function _muteStateFrom(wire) {
|
|
6860
|
+
if (!wire || !wire.muted) return _NOT_MUTED;
|
|
6861
|
+
return {
|
|
6862
|
+
muted: true,
|
|
6863
|
+
mutedUntil: typeof wire.muted_until === "string" ? wire.muted_until : null,
|
|
6864
|
+
};
|
|
6865
|
+
}
|
|
6866
|
+
|
|
6867
|
+
/**
|
|
6868
|
+
* sc-10268 — the signed-in user's mute of the notifications ONE datastore
|
|
6869
|
+
* record produces for them (a chat conversation, a thread). Returns
|
|
6870
|
+
* `{ muted, mutedUntil, loading, error, mute, unmute, refetch }`.
|
|
6871
|
+
*
|
|
6872
|
+
* `mute(until)` takes an ISO time or a `Date`, or `null` (or nothing) to mute
|
|
6873
|
+
* until `unmute()`. While muted, a server action's
|
|
6874
|
+
* `notifications.notifyRecordSubjects` on the record sends this user nothing:
|
|
6875
|
+
* no inbox row, no push, no email. `mutedUntil` is an ISO string, or null when
|
|
6876
|
+
* muted until unmuted; `muted` turns false on its own once `mutedUntil` passes.
|
|
6877
|
+
*
|
|
6878
|
+
* The user must be able to read the record; otherwise every call fails with
|
|
6879
|
+
* `NotificationError { code: "RECORD_NOT_FOUND" }`, exactly as for a missing
|
|
6880
|
+
* record. A falsy `tableId` / `recordId` collapses to a stable not-muted result
|
|
6881
|
+
* with no network call. A host without `ctx.notifications.mutes` (the Builder
|
|
6882
|
+
* canvas preview) reads not muted, and `mute` / `unmute` reject with
|
|
6883
|
+
* `NOTIFICATIONS_UNAVAILABLE` rather than the hook throwing at render.
|
|
6884
|
+
*/
|
|
6885
|
+
export function useRecordMute(tableId, recordId) {
|
|
6886
|
+
const ctx = useWidgetContextOrThrow("useRecordMute");
|
|
6887
|
+
const mutes = ctx.notifications && ctx.notifications.mutes;
|
|
6888
|
+
const available =
|
|
6889
|
+
Boolean(mutes) &&
|
|
6890
|
+
typeof mutes.get === "function" &&
|
|
6891
|
+
typeof mutes.set === "function" &&
|
|
6892
|
+
typeof mutes.clear === "function";
|
|
6893
|
+
const ready = Boolean(tableId && recordId);
|
|
6894
|
+
|
|
6895
|
+
const mutesRef = useRef(null);
|
|
6896
|
+
mutesRef.current = available ? mutes : null;
|
|
6897
|
+
const recordRef = useRef(null);
|
|
6898
|
+
recordRef.current = ready ? { table_id: tableId, record_id: recordId } : null;
|
|
6899
|
+
|
|
6900
|
+
const [state, setState] = useState(_NOT_MUTED);
|
|
6901
|
+
const [loading, setLoading] = useState(ready && available);
|
|
6902
|
+
const [error, setError] = useState(null);
|
|
6903
|
+
// Each read or write takes a ticket; only the latest may land its result.
|
|
6904
|
+
const runRef = useRef(0);
|
|
6905
|
+
|
|
6906
|
+
const doFetch = useCallback(async () => {
|
|
6907
|
+
const myRun = ++runRef.current;
|
|
6908
|
+
const client = mutesRef.current;
|
|
6909
|
+
const record = recordRef.current;
|
|
6910
|
+
if (!client || !record) {
|
|
6911
|
+
setState(_NOT_MUTED);
|
|
6912
|
+
setLoading(false);
|
|
6913
|
+
setError(null);
|
|
6914
|
+
return;
|
|
6915
|
+
}
|
|
6916
|
+
setLoading(true);
|
|
6917
|
+
setError(null);
|
|
6918
|
+
try {
|
|
6919
|
+
const wire = await client.get(record);
|
|
6920
|
+
if (runRef.current !== myRun) return;
|
|
6921
|
+
setState(_muteStateFrom(wire));
|
|
6922
|
+
setLoading(false);
|
|
6923
|
+
} catch (err) {
|
|
6924
|
+
if (runRef.current !== myRun) return;
|
|
6925
|
+
setError(toNotificationError(err, "RECORD_NOT_FOUND"));
|
|
6926
|
+
setLoading(false);
|
|
6927
|
+
}
|
|
6928
|
+
}, []);
|
|
6929
|
+
|
|
6930
|
+
useEffect(() => {
|
|
6931
|
+
setState(_NOT_MUTED);
|
|
6932
|
+
doFetch();
|
|
6933
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
6934
|
+
}, [tableId, recordId, available]);
|
|
6935
|
+
|
|
6936
|
+
// Another instance on the same record (a list's sheet, a header's bell)
|
|
6937
|
+
// that mutes or unmutes it updates this one too.
|
|
6938
|
+
useEffect(() => {
|
|
6939
|
+
if (!ready) return undefined;
|
|
6940
|
+
const listener = (changedTable, changedRecord, next) => {
|
|
6941
|
+
if (changedTable !== tableId || changedRecord !== recordId) return;
|
|
6942
|
+
runRef.current += 1;
|
|
6943
|
+
setState(next);
|
|
6944
|
+
setLoading(false);
|
|
6945
|
+
};
|
|
6946
|
+
_muteListeners.add(listener);
|
|
6947
|
+
return () => {
|
|
6948
|
+
_muteListeners.delete(listener);
|
|
6949
|
+
};
|
|
6950
|
+
}, [ready, tableId, recordId]);
|
|
6951
|
+
|
|
6952
|
+
useEffect(() => {
|
|
6953
|
+
if (!state.muted || !state.mutedUntil) return undefined;
|
|
6954
|
+
const remaining = Date.parse(state.mutedUntil) - Date.now();
|
|
6955
|
+
if (!(remaining <= _MAX_TIMER_MS)) return undefined;
|
|
6956
|
+
const timer = setTimeout(() => setState(_NOT_MUTED), Math.max(remaining, 0));
|
|
6957
|
+
return () => clearTimeout(timer);
|
|
6958
|
+
}, [state]);
|
|
6959
|
+
|
|
6960
|
+
const write = useCallback(async (perform) => {
|
|
6961
|
+
const client = mutesRef.current;
|
|
6962
|
+
if (!client) {
|
|
6963
|
+
const err = new NotificationError(
|
|
6964
|
+
"NOTIFICATIONS_UNAVAILABLE",
|
|
6965
|
+
"Muting notifications is not available here",
|
|
6966
|
+
);
|
|
6967
|
+
setError(err);
|
|
6968
|
+
throw err;
|
|
6969
|
+
}
|
|
6970
|
+
const record = recordRef.current;
|
|
6971
|
+
if (!record) return;
|
|
6972
|
+
const myRun = ++runRef.current;
|
|
6973
|
+
setError(null);
|
|
6974
|
+
try {
|
|
6975
|
+
const next = await perform(client, record);
|
|
6976
|
+
_announceMute(record.table_id, record.record_id, next);
|
|
6977
|
+
if (runRef.current === myRun) {
|
|
6978
|
+
setState(next);
|
|
6979
|
+
setLoading(false);
|
|
6980
|
+
}
|
|
6981
|
+
} catch (err) {
|
|
6982
|
+
const e = toNotificationError(err, "RECORD_NOT_FOUND");
|
|
6983
|
+
setError(e);
|
|
6984
|
+
throw e;
|
|
6985
|
+
}
|
|
6986
|
+
}, []);
|
|
6987
|
+
|
|
6988
|
+
const mute = useCallback(
|
|
6989
|
+
(until) => {
|
|
6990
|
+
let mutedUntil;
|
|
6991
|
+
try {
|
|
6992
|
+
mutedUntil = _muteUntilIso(until);
|
|
6993
|
+
} catch (err) {
|
|
6994
|
+
setError(err);
|
|
6995
|
+
return Promise.reject(err);
|
|
6996
|
+
}
|
|
6997
|
+
return write(async (client, record) =>
|
|
6998
|
+
_muteStateFrom(await client.set({ ...record, muted_until: mutedUntil })),
|
|
6999
|
+
);
|
|
7000
|
+
},
|
|
7001
|
+
[write],
|
|
7002
|
+
);
|
|
7003
|
+
|
|
7004
|
+
const unmute = useCallback(
|
|
7005
|
+
() =>
|
|
7006
|
+
write(async (client, record) => {
|
|
7007
|
+
await client.clear(record);
|
|
7008
|
+
return _NOT_MUTED;
|
|
7009
|
+
}),
|
|
7010
|
+
[write],
|
|
7011
|
+
);
|
|
7012
|
+
|
|
7013
|
+
const refetch = useCallback(async () => {
|
|
7014
|
+
await doFetch();
|
|
7015
|
+
}, [doFetch]);
|
|
7016
|
+
|
|
7017
|
+
if (!ready) return _RECORD_MUTE_IDLE;
|
|
7018
|
+
return { muted: state.muted, mutedUntil: state.mutedUntil, loading, error, mute, unmute, refetch };
|
|
7019
|
+
}
|
|
7020
|
+
|
|
7021
|
+
// A useRecordMute write is announced, so every useRecordMutes list on the
|
|
7022
|
+
// same table shows it at once, without a second read.
|
|
7023
|
+
const _muteListeners = new Set();
|
|
7024
|
+
|
|
7025
|
+
function _announceMute(tableId, recordId, state) {
|
|
7026
|
+
for (const listener of _muteListeners) listener(tableId, recordId, state);
|
|
7027
|
+
}
|
|
7028
|
+
|
|
7029
|
+
const _MUTED_RECORDS_PAGE = 200;
|
|
7030
|
+
// Bounds one load to 1,000 mutes, however many a user has on a table.
|
|
7031
|
+
const _MUTED_RECORDS_MAX_PAGES = 5;
|
|
7032
|
+
const _NO_MUTED_RECORDS = Object.freeze({});
|
|
7033
|
+
|
|
7034
|
+
// { [recordId]: mutedUntil } with one record's new state applied.
|
|
7035
|
+
function _withMuteState(byId, recordId, state) {
|
|
7036
|
+
const next = { ...byId };
|
|
7037
|
+
if (state && state.muted) next[recordId] = state.mutedUntil;
|
|
7038
|
+
else delete next[recordId];
|
|
7039
|
+
return Object.freeze(next);
|
|
7040
|
+
}
|
|
7041
|
+
|
|
7042
|
+
function _isMutedAt(byId, recordId, now) {
|
|
7043
|
+
if (!recordId || !Object.prototype.hasOwnProperty.call(byId, recordId)) return false;
|
|
7044
|
+
const until = byId[recordId];
|
|
7045
|
+
return until === null || Date.parse(until) > now;
|
|
7046
|
+
}
|
|
7047
|
+
|
|
7048
|
+
// The soonest future time a timed mute in the map lapses, or Infinity.
|
|
7049
|
+
function _nextMuteLapse(byId, now) {
|
|
7050
|
+
let soonest = Infinity;
|
|
7051
|
+
for (const recordId of Object.keys(byId)) {
|
|
7052
|
+
const at = byId[recordId] === null ? NaN : Date.parse(byId[recordId]);
|
|
7053
|
+
if (at > now && at < soonest) soonest = at;
|
|
7054
|
+
}
|
|
7055
|
+
return soonest;
|
|
7056
|
+
}
|
|
7057
|
+
|
|
7058
|
+
const _RECORD_MUTES_IDLE = Object.freeze({
|
|
7059
|
+
isMuted: () => false,
|
|
7060
|
+
loading: false,
|
|
7061
|
+
error: null,
|
|
7062
|
+
refetch: async () => {},
|
|
7063
|
+
});
|
|
7064
|
+
|
|
7065
|
+
/**
|
|
7066
|
+
* sc-10268 — which records of one table the signed-in user has muted, read in
|
|
7067
|
+
* one request for a whole list (a chat's conversations), never one per row.
|
|
7068
|
+
* Returns `{ isMuted(recordId), loading, error, refetch }`.
|
|
7069
|
+
*
|
|
7070
|
+
* Only active mutes count, and `isMuted` turns false by itself once a timed
|
|
7071
|
+
* mute lapses. A mute or unmute made through `useRecordMute` on the same table
|
|
7072
|
+
* shows here at once. A falsy `tableId`, or a host without
|
|
7073
|
+
* `ctx.notifications.mutes.list` (the Builder canvas preview), reads nothing
|
|
7074
|
+
* muted with no network call.
|
|
7075
|
+
*/
|
|
7076
|
+
export function useRecordMutes(tableId) {
|
|
7077
|
+
const ctx = useWidgetContextOrThrow("useRecordMutes");
|
|
7078
|
+
const mutes = ctx.notifications && ctx.notifications.mutes;
|
|
7079
|
+
const available = Boolean(mutes) && typeof mutes.list === "function";
|
|
7080
|
+
|
|
7081
|
+
const mutesRef = useRef(null);
|
|
7082
|
+
mutesRef.current = available ? mutes : null;
|
|
7083
|
+
const tableRef = useRef(null);
|
|
7084
|
+
tableRef.current = tableId || null;
|
|
7085
|
+
|
|
7086
|
+
const [byId, setById] = useState(_NO_MUTED_RECORDS);
|
|
7087
|
+
const [now, setNow] = useState(() => Date.now());
|
|
7088
|
+
const [loading, setLoading] = useState(Boolean(tableId) && available);
|
|
7089
|
+
const [error, setError] = useState(null);
|
|
7090
|
+
const runRef = useRef(0);
|
|
7091
|
+
|
|
7092
|
+
const doFetch = useCallback(async () => {
|
|
7093
|
+
const myRun = ++runRef.current;
|
|
7094
|
+
const client = mutesRef.current;
|
|
7095
|
+
const table = tableRef.current;
|
|
7096
|
+
if (!client || !table) {
|
|
7097
|
+
setById(_NO_MUTED_RECORDS);
|
|
7098
|
+
setLoading(false);
|
|
7099
|
+
setError(null);
|
|
7100
|
+
return;
|
|
7101
|
+
}
|
|
7102
|
+
setLoading(true);
|
|
7103
|
+
setError(null);
|
|
7104
|
+
try {
|
|
7105
|
+
const next = {};
|
|
7106
|
+
for (let page = 0; page < _MUTED_RECORDS_MAX_PAGES; page += 1) {
|
|
7107
|
+
const offset = page * _MUTED_RECORDS_PAGE;
|
|
7108
|
+
const res = await client.list({ table_id: table, limit: _MUTED_RECORDS_PAGE, offset });
|
|
7109
|
+
if (runRef.current !== myRun) return;
|
|
7110
|
+
const rows = res && Array.isArray(res.data) ? res.data : [];
|
|
7111
|
+
for (const row of rows) {
|
|
7112
|
+
if (row && typeof row.record_id === "string") {
|
|
7113
|
+
next[row.record_id] = typeof row.muted_until === "string" ? row.muted_until : null;
|
|
7114
|
+
}
|
|
7115
|
+
}
|
|
7116
|
+
const total = res && res.meta && Number.isFinite(res.meta.total) ? res.meta.total : 0;
|
|
7117
|
+
if (rows.length < _MUTED_RECORDS_PAGE || offset + rows.length >= total) break;
|
|
7118
|
+
}
|
|
7119
|
+
setById(Object.freeze(next));
|
|
7120
|
+
setNow(Date.now());
|
|
7121
|
+
setLoading(false);
|
|
7122
|
+
} catch (err) {
|
|
7123
|
+
if (runRef.current !== myRun) return;
|
|
7124
|
+
setError(toNotificationError(err, "INVALID_TABLE_ID"));
|
|
7125
|
+
setLoading(false);
|
|
7126
|
+
}
|
|
7127
|
+
}, []);
|
|
7128
|
+
|
|
7129
|
+
useEffect(() => {
|
|
7130
|
+
setById(_NO_MUTED_RECORDS);
|
|
7131
|
+
doFetch();
|
|
7132
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
7133
|
+
}, [tableId, available]);
|
|
7134
|
+
|
|
7135
|
+
useEffect(() => {
|
|
7136
|
+
const listener = (table, recordId, state) => {
|
|
7137
|
+
if (table !== tableRef.current) return;
|
|
7138
|
+
setById((prev) => _withMuteState(prev, recordId, state));
|
|
7139
|
+
setNow(Date.now());
|
|
7140
|
+
};
|
|
7141
|
+
_muteListeners.add(listener);
|
|
7142
|
+
return () => {
|
|
7143
|
+
_muteListeners.delete(listener);
|
|
7144
|
+
};
|
|
7145
|
+
}, []);
|
|
7146
|
+
|
|
7147
|
+
useEffect(() => {
|
|
7148
|
+
const remaining = _nextMuteLapse(byId, now) - Date.now();
|
|
7149
|
+
if (!(remaining <= _MAX_TIMER_MS)) return undefined;
|
|
7150
|
+
const timer = setTimeout(() => setNow(Date.now()), Math.max(remaining, 0));
|
|
7151
|
+
return () => clearTimeout(timer);
|
|
7152
|
+
}, [byId, now]);
|
|
7153
|
+
|
|
7154
|
+
const isMuted = useCallback((recordId) => _isMutedAt(byId, recordId, now), [byId, now]);
|
|
7155
|
+
const refetch = useCallback(async () => {
|
|
7156
|
+
await doFetch();
|
|
7157
|
+
}, [doFetch]);
|
|
7158
|
+
|
|
7159
|
+
if (!tableId) return _RECORD_MUTES_IDLE;
|
|
7160
|
+
return { isMuted, loading, error, refetch };
|
|
7161
|
+
}
|