@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
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
|
/**
|
|
@@ -4303,8 +4421,10 @@ export function useAssetsByTag(tag, options) {
|
|
|
4303
4421
|
// owned by the signed-in app user (ctx.user.id). The widget only chooses the
|
|
4304
4422
|
// space. PUBLIC is world-readable, so its files resolve even when there is no
|
|
4305
4423
|
// signed-in user (ctx.workspace.id is set for a logged-out Player visitor too).
|
|
4306
|
-
|
|
4424
|
+
// sc-10269: a RECORD space is owned by the datastore record the widget names.
|
|
4425
|
+
function _filestoreOwnerId(ctx, spaceType, recordId) {
|
|
4307
4426
|
const space = String(spaceType || "project").toUpperCase();
|
|
4427
|
+
if (space === "RECORD") return recordId || null;
|
|
4308
4428
|
if (space === "PERSONAL") return (ctx.user && ctx.user.id) || null;
|
|
4309
4429
|
return (ctx.workspace && ctx.workspace.id) || null;
|
|
4310
4430
|
}
|
|
@@ -4312,8 +4432,9 @@ function _filestoreOwnerId(ctx, spaceType) {
|
|
|
4312
4432
|
/**
|
|
4313
4433
|
* Browse the end-user's Filestore files. Returns { files, loading, error,
|
|
4314
4434
|
* refetch }. The widget passes only the SPACE (`{ spaceType, folderId, q,
|
|
4315
|
-
* type }`); the hook resolves owner_id
|
|
4316
|
-
* project, app user for personal
|
|
4435
|
+
* type }`, plus `recordId` for a `record` space); the hook resolves owner_id
|
|
4436
|
+
* from the host context (tenant for project, app user for personal, the named
|
|
4437
|
+
* record for record) and calls
|
|
4317
4438
|
* `ctx.filestore.files.list({ space_type, owner_id, folder_id, q, type })`,
|
|
4318
4439
|
* unwrapping the `{ data, meta }` envelope to the files array. When the owner
|
|
4319
4440
|
* can't be resolved (no signed-in user for a personal space) it collapses to an
|
|
@@ -4330,8 +4451,8 @@ export function useFilestoreFiles(options) {
|
|
|
4330
4451
|
"useFilestoreFiles: host did not inject a filestore client (ctx.filestore.files.list)",
|
|
4331
4452
|
);
|
|
4332
4453
|
}
|
|
4333
|
-
const { spaceType = "project", folderId = null, q, type } = options || {};
|
|
4334
|
-
const ownerId = _filestoreOwnerId(ctx, spaceType);
|
|
4454
|
+
const { spaceType = "project", recordId = null, folderId = null, q, type } = options || {};
|
|
4455
|
+
const ownerId = _filestoreOwnerId(ctx, spaceType, recordId);
|
|
4335
4456
|
|
|
4336
4457
|
const [files, setFiles] = useState([]);
|
|
4337
4458
|
const [loading, setLoading] = useState(true);
|
|
@@ -4468,9 +4589,10 @@ export function useFilestoreFile(fileId) {
|
|
|
4468
4589
|
/**
|
|
4469
4590
|
* sc-1378 — upload a file into the end-user's Filestore space. Returns
|
|
4470
4591
|
* `{ upload, uploading, error, lastUploaded }`. The widget passes the
|
|
4471
|
-
* SPACE (`{ spaceType, folderId? }`
|
|
4472
|
-
*
|
|
4473
|
-
*
|
|
4592
|
+
* SPACE (`{ spaceType, folderId? }`, or `{ spaceType: "record", recordId }`
|
|
4593
|
+
* to attach the file to a datastore record); the hook resolves `owner_id` the
|
|
4594
|
+
* same way the read hooks do (tenant for PROJECT, app user for PERSONAL, the
|
|
4595
|
+
* record for RECORD) and posts a multipart FormData to `ctx.filestore.files.upload`.
|
|
4474
4596
|
*
|
|
4475
4597
|
* `upload(file, { folderId?, fileName? })` takes a browser `File` on the web
|
|
4476
4598
|
* Player and expo-file-system's `File` on the Expo export, as `<FilePicker>`,
|
|
@@ -4502,10 +4624,11 @@ export function useFilestoreUpload(options) {
|
|
|
4502
4624
|
}
|
|
4503
4625
|
const {
|
|
4504
4626
|
spaceType = "project",
|
|
4627
|
+
recordId = null,
|
|
4505
4628
|
folderId: defaultFolderId = null,
|
|
4506
4629
|
compress: defaultCompress = true,
|
|
4507
4630
|
} = options || {};
|
|
4508
|
-
const ownerId = _filestoreOwnerId(ctx, spaceType);
|
|
4631
|
+
const ownerId = _filestoreOwnerId(ctx, spaceType, recordId);
|
|
4509
4632
|
|
|
4510
4633
|
const [uploading, setUploading] = useState(false);
|
|
4511
4634
|
const [error, setError] = useState(null);
|
|
@@ -4901,11 +5024,13 @@ export function useFilestoreCreateFolder(options) {
|
|
|
4901
5024
|
|
|
4902
5025
|
/**
|
|
4903
5026
|
* 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 }`.
|
|
5027
|
+
* to — including ACROSS spaces (project, public, the user's personal space,
|
|
5028
|
+
* a datastore record's attachments). Returns `{ copy, copying, error, lastCopied }`.
|
|
4906
5029
|
*
|
|
4907
|
-
* `copy(fileId, { spaceType, folderId?, name? })` names the
|
|
4908
|
-
* `spaceType` is required
|
|
5030
|
+
* `copy(fileId, { spaceType, recordId?, folderId?, name? })` names the
|
|
5031
|
+
* DESTINATION; `spaceType` is required, and `{ spaceType: "record", recordId }`
|
|
5032
|
+
* attaches the copy to that record (write access to it is needed; a record has
|
|
5033
|
+
* no folders). The hook resolves the destination owner_id the same
|
|
4909
5034
|
* way the upload hook does and posts through `ctx.filestore.files.copy`. It
|
|
4910
5035
|
* resolves to the NEW file row (its own id and url); the source is untouched.
|
|
4911
5036
|
* A name already taken at the destination lands as "name (2).ext" rather than
|
|
@@ -4938,11 +5063,14 @@ export function useFilestoreCopy() {
|
|
|
4938
5063
|
|
|
4939
5064
|
const copy = useCallback(async (fileId, destination) => {
|
|
4940
5065
|
if (!fileId) throw new Error("useFilestoreCopy: fileId is required");
|
|
4941
|
-
const { spaceType, folderId, name } = destination || {};
|
|
5066
|
+
const { spaceType, recordId, folderId, name } = destination || {};
|
|
4942
5067
|
if (!spaceType) {
|
|
4943
5068
|
throw new Error("useFilestoreCopy: destination spaceType is required");
|
|
4944
5069
|
}
|
|
4945
|
-
|
|
5070
|
+
if (String(spaceType).toUpperCase() === "RECORD" && !recordId) {
|
|
5071
|
+
throw new Error("useFilestoreCopy: a record destination needs its recordId");
|
|
5072
|
+
}
|
|
5073
|
+
const ownerId = _filestoreOwnerId(ctxRef.current, spaceType, recordId);
|
|
4946
5074
|
if (!ownerId) {
|
|
4947
5075
|
const err = new Error("Sign in to copy files");
|
|
4948
5076
|
setError(err);
|
|
@@ -4971,6 +5099,48 @@ export function useFilestoreCopy() {
|
|
|
4971
5099
|
return { copy, copying, error, lastCopied };
|
|
4972
5100
|
}
|
|
4973
5101
|
|
|
5102
|
+
/**
|
|
5103
|
+
* sc-10269 — delete a Filestore file the caller may delete. Returns
|
|
5104
|
+
* `{ remove, removing, error }`. `remove(fileId)` calls
|
|
5105
|
+
* `ctx.filestore.files.remove`: a record attachment is deleted permanently and
|
|
5106
|
+
* only its uploader may do it; a file in any other space moves to the trash.
|
|
5107
|
+
* Refusals arrive as `err.status` (403 not permitted, 404 not visible).
|
|
5108
|
+
*/
|
|
5109
|
+
export function useFilestoreDelete() {
|
|
5110
|
+
const ctx = useWidgetContextOrThrow("useFilestoreDelete");
|
|
5111
|
+
if (
|
|
5112
|
+
!ctx.filestore ||
|
|
5113
|
+
!ctx.filestore.files ||
|
|
5114
|
+
typeof ctx.filestore.files.remove !== "function"
|
|
5115
|
+
) {
|
|
5116
|
+
throw new Error(
|
|
5117
|
+
"useFilestoreDelete: host did not inject a filestore client (ctx.filestore.files.remove)",
|
|
5118
|
+
);
|
|
5119
|
+
}
|
|
5120
|
+
|
|
5121
|
+
const [removing, setRemoving] = useState(false);
|
|
5122
|
+
const [error, setError] = useState(null);
|
|
5123
|
+
|
|
5124
|
+
const filesRef = useRef(ctx.filestore.files);
|
|
5125
|
+
filesRef.current = ctx.filestore.files;
|
|
5126
|
+
|
|
5127
|
+
const remove = useCallback(async (fileId) => {
|
|
5128
|
+
if (!fileId) throw new Error("useFilestoreDelete: fileId is required");
|
|
5129
|
+
setRemoving(true);
|
|
5130
|
+
setError(null);
|
|
5131
|
+
try {
|
|
5132
|
+
await filesRef.current.remove(fileId);
|
|
5133
|
+
setRemoving(false);
|
|
5134
|
+
} catch (err) {
|
|
5135
|
+
setError(err);
|
|
5136
|
+
setRemoving(false);
|
|
5137
|
+
throw err;
|
|
5138
|
+
}
|
|
5139
|
+
}, []);
|
|
5140
|
+
|
|
5141
|
+
return { remove, removing, error };
|
|
5142
|
+
}
|
|
5143
|
+
|
|
4974
5144
|
/**
|
|
4975
5145
|
* Drive a BankID signing flow for one Filestore file. Returns
|
|
4976
5146
|
* `{ status, qr, autoStartToken, message, signerName, signedAt, verdict,
|
|
@@ -6521,7 +6691,7 @@ export function usePayments() {
|
|
|
6521
6691
|
/* ============================================================================
|
|
6522
6692
|
* NOTIFICATIONS CLIENT — ctx.notifications (@colixsystems/notifications-client)
|
|
6523
6693
|
*
|
|
6524
|
-
* send. Covers: useSendNotification.
|
|
6694
|
+
* send, mutes. Covers: useSendNotification, useRecordMute.
|
|
6525
6695
|
* ==========================================================================*/
|
|
6526
6696
|
|
|
6527
6697
|
/**
|
|
@@ -6539,7 +6709,9 @@ export function usePayments() {
|
|
|
6539
6709
|
* - "INTERNAL" — anything else (network, 5xx)
|
|
6540
6710
|
*
|
|
6541
6711
|
* The server's stable `code` (when volunteered in the error body) is preserved
|
|
6542
|
-
* over the status-derived one.
|
|
6712
|
+
* over the status-derived one. `useRecordMute` adds "RECORD_NOT_FOUND" (404:
|
|
6713
|
+
* the record is missing or the user cannot read it), "INVALID_MUTED_UNTIL",
|
|
6714
|
+
* "INVALID_RECORD_REF" and "NOTIFICATIONS_UNAVAILABLE" (no mute client here).
|
|
6543
6715
|
*/
|
|
6544
6716
|
export class NotificationError extends Error {
|
|
6545
6717
|
constructor(code, message, opts) {
|
|
@@ -6550,7 +6722,7 @@ export class NotificationError extends Error {
|
|
|
6550
6722
|
}
|
|
6551
6723
|
}
|
|
6552
6724
|
|
|
6553
|
-
function toNotificationError(err) {
|
|
6725
|
+
function toNotificationError(err, notFoundCode = "RECIPIENT_NOT_FOUND") {
|
|
6554
6726
|
if (err instanceof NotificationError) return err;
|
|
6555
6727
|
// The injected notifications-client rejects with its own NotificationError
|
|
6556
6728
|
// subclass carrying { code, status }; the host facade may instead throw an
|
|
@@ -6562,18 +6734,21 @@ function toNotificationError(err) {
|
|
|
6562
6734
|
: err && typeof err.status === "number"
|
|
6563
6735
|
? err.status
|
|
6564
6736
|
: null;
|
|
6737
|
+
// The client keeps the server body at err.details; its own err.code is a
|
|
6738
|
+
// per-status class default, so it only answers when nothing else does.
|
|
6565
6739
|
const bodyCode =
|
|
6566
6740
|
(err && err.response && err.response.data && err.response.data.code) ||
|
|
6567
|
-
(err && typeof err.code === "string" ? err.code : null);
|
|
6741
|
+
(err && err.details && typeof err.details.code === "string" ? err.details.code : null);
|
|
6568
6742
|
const bodyMessage =
|
|
6569
6743
|
err && err.response && err.response.data && err.response.data.error;
|
|
6570
6744
|
let code = "INTERNAL";
|
|
6571
6745
|
if (typeof bodyCode === "string" && bodyCode) code = bodyCode;
|
|
6572
6746
|
else if (status === 401) code = "AUTH_REQUIRED";
|
|
6573
6747
|
else if (status === 403) code = "FORBIDDEN";
|
|
6574
|
-
else if (status === 404) code =
|
|
6748
|
+
else if (status === 404) code = notFoundCode;
|
|
6575
6749
|
else if (status === 429) code = "RATE_LIMITED";
|
|
6576
6750
|
else if (status === 400) code = "VALIDATION";
|
|
6751
|
+
else if (err && typeof err.code === "string" && err.code) code = err.code;
|
|
6577
6752
|
const message =
|
|
6578
6753
|
(typeof bodyMessage === "string" && bodyMessage) ||
|
|
6579
6754
|
(err && typeof err.message === "string"
|
|
@@ -6638,3 +6813,335 @@ export function useSendNotification() {
|
|
|
6638
6813
|
|
|
6639
6814
|
return { send, sending, error };
|
|
6640
6815
|
}
|
|
6816
|
+
|
|
6817
|
+
const _NOT_MUTED = Object.freeze({ muted: false, mutedUntil: null });
|
|
6818
|
+
// setTimeout overflows past ~24.8 days; a later expiry waits for a refetch.
|
|
6819
|
+
const _MAX_TIMER_MS = 2147483647;
|
|
6820
|
+
|
|
6821
|
+
const _RECORD_MUTE_IDLE = Object.freeze({
|
|
6822
|
+
muted: false,
|
|
6823
|
+
mutedUntil: null,
|
|
6824
|
+
loading: false,
|
|
6825
|
+
error: null,
|
|
6826
|
+
mute: async () => undefined,
|
|
6827
|
+
unmute: async () => undefined,
|
|
6828
|
+
refetch: async () => undefined,
|
|
6829
|
+
});
|
|
6830
|
+
|
|
6831
|
+
// `mute(until)` input → the wire's ISO string, or null for until unmuted.
|
|
6832
|
+
function _muteUntilIso(until) {
|
|
6833
|
+
if (until === undefined || until === null) return null;
|
|
6834
|
+
const at =
|
|
6835
|
+
until instanceof Date ? until : typeof until === "string" ? new Date(until) : null;
|
|
6836
|
+
if (!at || Number.isNaN(at.getTime())) {
|
|
6837
|
+
throw new NotificationError(
|
|
6838
|
+
"INVALID_MUTED_UNTIL",
|
|
6839
|
+
"useRecordMute: mute(until) takes an ISO time, a Date, or null for until unmuted",
|
|
6840
|
+
);
|
|
6841
|
+
}
|
|
6842
|
+
return at.toISOString();
|
|
6843
|
+
}
|
|
6844
|
+
|
|
6845
|
+
function _muteStateFrom(wire) {
|
|
6846
|
+
if (!wire || !wire.muted) return _NOT_MUTED;
|
|
6847
|
+
return {
|
|
6848
|
+
muted: true,
|
|
6849
|
+
mutedUntil: typeof wire.muted_until === "string" ? wire.muted_until : null,
|
|
6850
|
+
};
|
|
6851
|
+
}
|
|
6852
|
+
|
|
6853
|
+
/**
|
|
6854
|
+
* sc-10268 — the signed-in user's mute of the notifications ONE datastore
|
|
6855
|
+
* record produces for them (a chat conversation, a thread). Returns
|
|
6856
|
+
* `{ muted, mutedUntil, loading, error, mute, unmute, refetch }`.
|
|
6857
|
+
*
|
|
6858
|
+
* `mute(until)` takes an ISO time or a `Date`, or `null` (or nothing) to mute
|
|
6859
|
+
* until `unmute()`. While muted, a server action's
|
|
6860
|
+
* `notifications.notifyRecordSubjects` on the record sends this user nothing:
|
|
6861
|
+
* no inbox row, no push, no email. `mutedUntil` is an ISO string, or null when
|
|
6862
|
+
* muted until unmuted; `muted` turns false on its own once `mutedUntil` passes.
|
|
6863
|
+
*
|
|
6864
|
+
* The user must be able to read the record; otherwise every call fails with
|
|
6865
|
+
* `NotificationError { code: "RECORD_NOT_FOUND" }`, exactly as for a missing
|
|
6866
|
+
* record. A falsy `tableId` / `recordId` collapses to a stable not-muted result
|
|
6867
|
+
* with no network call. A host without `ctx.notifications.mutes` (the Builder
|
|
6868
|
+
* canvas preview) reads not muted, and `mute` / `unmute` reject with
|
|
6869
|
+
* `NOTIFICATIONS_UNAVAILABLE` rather than the hook throwing at render.
|
|
6870
|
+
*/
|
|
6871
|
+
export function useRecordMute(tableId, recordId) {
|
|
6872
|
+
const ctx = useWidgetContextOrThrow("useRecordMute");
|
|
6873
|
+
const mutes = ctx.notifications && ctx.notifications.mutes;
|
|
6874
|
+
const available =
|
|
6875
|
+
Boolean(mutes) &&
|
|
6876
|
+
typeof mutes.get === "function" &&
|
|
6877
|
+
typeof mutes.set === "function" &&
|
|
6878
|
+
typeof mutes.clear === "function";
|
|
6879
|
+
const ready = Boolean(tableId && recordId);
|
|
6880
|
+
|
|
6881
|
+
const mutesRef = useRef(null);
|
|
6882
|
+
mutesRef.current = available ? mutes : null;
|
|
6883
|
+
const recordRef = useRef(null);
|
|
6884
|
+
recordRef.current = ready ? { table_id: tableId, record_id: recordId } : null;
|
|
6885
|
+
|
|
6886
|
+
const [state, setState] = useState(_NOT_MUTED);
|
|
6887
|
+
const [loading, setLoading] = useState(ready && available);
|
|
6888
|
+
const [error, setError] = useState(null);
|
|
6889
|
+
// Each read or write takes a ticket; only the latest may land its result.
|
|
6890
|
+
const runRef = useRef(0);
|
|
6891
|
+
|
|
6892
|
+
const doFetch = useCallback(async () => {
|
|
6893
|
+
const myRun = ++runRef.current;
|
|
6894
|
+
const client = mutesRef.current;
|
|
6895
|
+
const record = recordRef.current;
|
|
6896
|
+
if (!client || !record) {
|
|
6897
|
+
setState(_NOT_MUTED);
|
|
6898
|
+
setLoading(false);
|
|
6899
|
+
setError(null);
|
|
6900
|
+
return;
|
|
6901
|
+
}
|
|
6902
|
+
setLoading(true);
|
|
6903
|
+
setError(null);
|
|
6904
|
+
try {
|
|
6905
|
+
const wire = await client.get(record);
|
|
6906
|
+
if (runRef.current !== myRun) return;
|
|
6907
|
+
setState(_muteStateFrom(wire));
|
|
6908
|
+
setLoading(false);
|
|
6909
|
+
} catch (err) {
|
|
6910
|
+
if (runRef.current !== myRun) return;
|
|
6911
|
+
setError(toNotificationError(err, "RECORD_NOT_FOUND"));
|
|
6912
|
+
setLoading(false);
|
|
6913
|
+
}
|
|
6914
|
+
}, []);
|
|
6915
|
+
|
|
6916
|
+
useEffect(() => {
|
|
6917
|
+
setState(_NOT_MUTED);
|
|
6918
|
+
doFetch();
|
|
6919
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
6920
|
+
}, [tableId, recordId, available]);
|
|
6921
|
+
|
|
6922
|
+
// Another instance on the same record (a list's sheet, a header's bell)
|
|
6923
|
+
// that mutes or unmutes it updates this one too.
|
|
6924
|
+
useEffect(() => {
|
|
6925
|
+
if (!ready) return undefined;
|
|
6926
|
+
const listener = (changedTable, changedRecord, next) => {
|
|
6927
|
+
if (changedTable !== tableId || changedRecord !== recordId) return;
|
|
6928
|
+
runRef.current += 1;
|
|
6929
|
+
setState(next);
|
|
6930
|
+
setLoading(false);
|
|
6931
|
+
};
|
|
6932
|
+
_muteListeners.add(listener);
|
|
6933
|
+
return () => {
|
|
6934
|
+
_muteListeners.delete(listener);
|
|
6935
|
+
};
|
|
6936
|
+
}, [ready, tableId, recordId]);
|
|
6937
|
+
|
|
6938
|
+
useEffect(() => {
|
|
6939
|
+
if (!state.muted || !state.mutedUntil) return undefined;
|
|
6940
|
+
const remaining = Date.parse(state.mutedUntil) - Date.now();
|
|
6941
|
+
if (!(remaining <= _MAX_TIMER_MS)) return undefined;
|
|
6942
|
+
const timer = setTimeout(() => setState(_NOT_MUTED), Math.max(remaining, 0));
|
|
6943
|
+
return () => clearTimeout(timer);
|
|
6944
|
+
}, [state]);
|
|
6945
|
+
|
|
6946
|
+
const write = useCallback(async (perform) => {
|
|
6947
|
+
const client = mutesRef.current;
|
|
6948
|
+
if (!client) {
|
|
6949
|
+
const err = new NotificationError(
|
|
6950
|
+
"NOTIFICATIONS_UNAVAILABLE",
|
|
6951
|
+
"Muting notifications is not available here",
|
|
6952
|
+
);
|
|
6953
|
+
setError(err);
|
|
6954
|
+
throw err;
|
|
6955
|
+
}
|
|
6956
|
+
const record = recordRef.current;
|
|
6957
|
+
if (!record) return;
|
|
6958
|
+
const myRun = ++runRef.current;
|
|
6959
|
+
setError(null);
|
|
6960
|
+
try {
|
|
6961
|
+
const next = await perform(client, record);
|
|
6962
|
+
_announceMute(record.table_id, record.record_id, next);
|
|
6963
|
+
if (runRef.current === myRun) {
|
|
6964
|
+
setState(next);
|
|
6965
|
+
setLoading(false);
|
|
6966
|
+
}
|
|
6967
|
+
} catch (err) {
|
|
6968
|
+
const e = toNotificationError(err, "RECORD_NOT_FOUND");
|
|
6969
|
+
setError(e);
|
|
6970
|
+
throw e;
|
|
6971
|
+
}
|
|
6972
|
+
}, []);
|
|
6973
|
+
|
|
6974
|
+
const mute = useCallback(
|
|
6975
|
+
(until) => {
|
|
6976
|
+
let mutedUntil;
|
|
6977
|
+
try {
|
|
6978
|
+
mutedUntil = _muteUntilIso(until);
|
|
6979
|
+
} catch (err) {
|
|
6980
|
+
setError(err);
|
|
6981
|
+
return Promise.reject(err);
|
|
6982
|
+
}
|
|
6983
|
+
return write(async (client, record) =>
|
|
6984
|
+
_muteStateFrom(await client.set({ ...record, muted_until: mutedUntil })),
|
|
6985
|
+
);
|
|
6986
|
+
},
|
|
6987
|
+
[write],
|
|
6988
|
+
);
|
|
6989
|
+
|
|
6990
|
+
const unmute = useCallback(
|
|
6991
|
+
() =>
|
|
6992
|
+
write(async (client, record) => {
|
|
6993
|
+
await client.clear(record);
|
|
6994
|
+
return _NOT_MUTED;
|
|
6995
|
+
}),
|
|
6996
|
+
[write],
|
|
6997
|
+
);
|
|
6998
|
+
|
|
6999
|
+
const refetch = useCallback(async () => {
|
|
7000
|
+
await doFetch();
|
|
7001
|
+
}, [doFetch]);
|
|
7002
|
+
|
|
7003
|
+
if (!ready) return _RECORD_MUTE_IDLE;
|
|
7004
|
+
return { muted: state.muted, mutedUntil: state.mutedUntil, loading, error, mute, unmute, refetch };
|
|
7005
|
+
}
|
|
7006
|
+
|
|
7007
|
+
// A useRecordMute write is announced, so every useRecordMutes list on the
|
|
7008
|
+
// same table shows it at once, without a second read.
|
|
7009
|
+
const _muteListeners = new Set();
|
|
7010
|
+
|
|
7011
|
+
function _announceMute(tableId, recordId, state) {
|
|
7012
|
+
for (const listener of _muteListeners) listener(tableId, recordId, state);
|
|
7013
|
+
}
|
|
7014
|
+
|
|
7015
|
+
const _MUTED_RECORDS_PAGE = 200;
|
|
7016
|
+
// Bounds one load to 1,000 mutes, however many a user has on a table.
|
|
7017
|
+
const _MUTED_RECORDS_MAX_PAGES = 5;
|
|
7018
|
+
const _NO_MUTED_RECORDS = Object.freeze({});
|
|
7019
|
+
|
|
7020
|
+
// { [recordId]: mutedUntil } with one record's new state applied.
|
|
7021
|
+
function _withMuteState(byId, recordId, state) {
|
|
7022
|
+
const next = { ...byId };
|
|
7023
|
+
if (state && state.muted) next[recordId] = state.mutedUntil;
|
|
7024
|
+
else delete next[recordId];
|
|
7025
|
+
return Object.freeze(next);
|
|
7026
|
+
}
|
|
7027
|
+
|
|
7028
|
+
function _isMutedAt(byId, recordId, now) {
|
|
7029
|
+
if (!recordId || !Object.prototype.hasOwnProperty.call(byId, recordId)) return false;
|
|
7030
|
+
const until = byId[recordId];
|
|
7031
|
+
return until === null || Date.parse(until) > now;
|
|
7032
|
+
}
|
|
7033
|
+
|
|
7034
|
+
// The soonest future time a timed mute in the map lapses, or Infinity.
|
|
7035
|
+
function _nextMuteLapse(byId, now) {
|
|
7036
|
+
let soonest = Infinity;
|
|
7037
|
+
for (const recordId of Object.keys(byId)) {
|
|
7038
|
+
const at = byId[recordId] === null ? NaN : Date.parse(byId[recordId]);
|
|
7039
|
+
if (at > now && at < soonest) soonest = at;
|
|
7040
|
+
}
|
|
7041
|
+
return soonest;
|
|
7042
|
+
}
|
|
7043
|
+
|
|
7044
|
+
const _RECORD_MUTES_IDLE = Object.freeze({
|
|
7045
|
+
isMuted: () => false,
|
|
7046
|
+
loading: false,
|
|
7047
|
+
error: null,
|
|
7048
|
+
refetch: async () => {},
|
|
7049
|
+
});
|
|
7050
|
+
|
|
7051
|
+
/**
|
|
7052
|
+
* sc-10268 — which records of one table the signed-in user has muted, read in
|
|
7053
|
+
* one request for a whole list (a chat's conversations), never one per row.
|
|
7054
|
+
* Returns `{ isMuted(recordId), loading, error, refetch }`.
|
|
7055
|
+
*
|
|
7056
|
+
* Only active mutes count, and `isMuted` turns false by itself once a timed
|
|
7057
|
+
* mute lapses. A mute or unmute made through `useRecordMute` on the same table
|
|
7058
|
+
* shows here at once. A falsy `tableId`, or a host without
|
|
7059
|
+
* `ctx.notifications.mutes.list` (the Builder canvas preview), reads nothing
|
|
7060
|
+
* muted with no network call.
|
|
7061
|
+
*/
|
|
7062
|
+
export function useRecordMutes(tableId) {
|
|
7063
|
+
const ctx = useWidgetContextOrThrow("useRecordMutes");
|
|
7064
|
+
const mutes = ctx.notifications && ctx.notifications.mutes;
|
|
7065
|
+
const available = Boolean(mutes) && typeof mutes.list === "function";
|
|
7066
|
+
|
|
7067
|
+
const mutesRef = useRef(null);
|
|
7068
|
+
mutesRef.current = available ? mutes : null;
|
|
7069
|
+
const tableRef = useRef(null);
|
|
7070
|
+
tableRef.current = tableId || null;
|
|
7071
|
+
|
|
7072
|
+
const [byId, setById] = useState(_NO_MUTED_RECORDS);
|
|
7073
|
+
const [now, setNow] = useState(() => Date.now());
|
|
7074
|
+
const [loading, setLoading] = useState(Boolean(tableId) && available);
|
|
7075
|
+
const [error, setError] = useState(null);
|
|
7076
|
+
const runRef = useRef(0);
|
|
7077
|
+
|
|
7078
|
+
const doFetch = useCallback(async () => {
|
|
7079
|
+
const myRun = ++runRef.current;
|
|
7080
|
+
const client = mutesRef.current;
|
|
7081
|
+
const table = tableRef.current;
|
|
7082
|
+
if (!client || !table) {
|
|
7083
|
+
setById(_NO_MUTED_RECORDS);
|
|
7084
|
+
setLoading(false);
|
|
7085
|
+
setError(null);
|
|
7086
|
+
return;
|
|
7087
|
+
}
|
|
7088
|
+
setLoading(true);
|
|
7089
|
+
setError(null);
|
|
7090
|
+
try {
|
|
7091
|
+
const next = {};
|
|
7092
|
+
for (let page = 0; page < _MUTED_RECORDS_MAX_PAGES; page += 1) {
|
|
7093
|
+
const offset = page * _MUTED_RECORDS_PAGE;
|
|
7094
|
+
const res = await client.list({ table_id: table, limit: _MUTED_RECORDS_PAGE, offset });
|
|
7095
|
+
if (runRef.current !== myRun) return;
|
|
7096
|
+
const rows = res && Array.isArray(res.data) ? res.data : [];
|
|
7097
|
+
for (const row of rows) {
|
|
7098
|
+
if (row && typeof row.record_id === "string") {
|
|
7099
|
+
next[row.record_id] = typeof row.muted_until === "string" ? row.muted_until : null;
|
|
7100
|
+
}
|
|
7101
|
+
}
|
|
7102
|
+
const total = res && res.meta && Number.isFinite(res.meta.total) ? res.meta.total : 0;
|
|
7103
|
+
if (rows.length < _MUTED_RECORDS_PAGE || offset + rows.length >= total) break;
|
|
7104
|
+
}
|
|
7105
|
+
setById(Object.freeze(next));
|
|
7106
|
+
setNow(Date.now());
|
|
7107
|
+
setLoading(false);
|
|
7108
|
+
} catch (err) {
|
|
7109
|
+
if (runRef.current !== myRun) return;
|
|
7110
|
+
setError(toNotificationError(err, "INVALID_TABLE_ID"));
|
|
7111
|
+
setLoading(false);
|
|
7112
|
+
}
|
|
7113
|
+
}, []);
|
|
7114
|
+
|
|
7115
|
+
useEffect(() => {
|
|
7116
|
+
setById(_NO_MUTED_RECORDS);
|
|
7117
|
+
doFetch();
|
|
7118
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
7119
|
+
}, [tableId, available]);
|
|
7120
|
+
|
|
7121
|
+
useEffect(() => {
|
|
7122
|
+
const listener = (table, recordId, state) => {
|
|
7123
|
+
if (table !== tableRef.current) return;
|
|
7124
|
+
setById((prev) => _withMuteState(prev, recordId, state));
|
|
7125
|
+
setNow(Date.now());
|
|
7126
|
+
};
|
|
7127
|
+
_muteListeners.add(listener);
|
|
7128
|
+
return () => {
|
|
7129
|
+
_muteListeners.delete(listener);
|
|
7130
|
+
};
|
|
7131
|
+
}, []);
|
|
7132
|
+
|
|
7133
|
+
useEffect(() => {
|
|
7134
|
+
const remaining = _nextMuteLapse(byId, now) - Date.now();
|
|
7135
|
+
if (!(remaining <= _MAX_TIMER_MS)) return undefined;
|
|
7136
|
+
const timer = setTimeout(() => setNow(Date.now()), Math.max(remaining, 0));
|
|
7137
|
+
return () => clearTimeout(timer);
|
|
7138
|
+
}, [byId, now]);
|
|
7139
|
+
|
|
7140
|
+
const isMuted = useCallback((recordId) => _isMutedAt(byId, recordId, now), [byId, now]);
|
|
7141
|
+
const refetch = useCallback(async () => {
|
|
7142
|
+
await doFetch();
|
|
7143
|
+
}, [doFetch]);
|
|
7144
|
+
|
|
7145
|
+
if (!tableId) return _RECORD_MUTES_IDLE;
|
|
7146
|
+
return { isMuted, loading, error, refetch };
|
|
7147
|
+
}
|