@colixsystems/widget-sdk 0.196.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/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
- if (next && typeof next.release === "function") next.release();
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
- const pick = useCallback(() => request("pick"), [request]);
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
- return { create, update, delete: del };
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
- function _filestoreOwnerId(ctx, spaceType) {
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 from the host context (tenant for
4316
- * project, app user for personal) and calls
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? }`); the hook resolves `owner_id` the
4472
- * same way the read hooks do (tenant for PROJECT, app user for PERSONAL)
4473
- * and posts a multipart FormData to `ctx.filestore.files.upload`.
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 DESTINATION;
4908
- * `spaceType` is required. The hook resolves the destination owner_id the same
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
- const ownerId = _filestoreOwnerId(ctxRef.current, spaceType);
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 = "RECIPIENT_NOT_FOUND";
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
+ }