@colixsystems/widget-sdk 0.190.0 → 0.191.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 CHANGED
@@ -43,6 +43,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
43
43
  | **CORE** | `useCamera(options)` (optional: options) | `{ asset, loading, error, supported, capture, pick, reset }` | `ctx.device.camera` — no scope. Take a photo (`capture()`) or choose one (`pick()`). Capture is IMPERATIVE: call from a user gesture (a tap), never on mount. Both resolve a normalised `{ uri, name, mimeType, width, height, size, file }`, or **`null` when the user dismisses the picker** — dismissal is not an error, so no `try/catch` is needed on the happy path. Rejects with `CameraError` (`.code` in `PERMISSION_DENIED \| UNSUPPORTED \| INTERNAL`). `asset.file` is already the right upload part for the host, so `fd.append("file", asset.file)` → `ctx.assets.upload(fd)` is ONE code path on both. **Gate your camera button on `supported`.** Identical on web (a live `getUserMedia` preview, phones included; a file input only where getUserMedia is absent) and the Expo export (`expo-image-picker`). |
44
44
  | **CORE** | `useI18n()` | `{ t, locale }` | `ctx.i18n` — no scope. `t(key)` resolves the widget-namespaced key (`widget.<id>.<key>`, declared in `manifest.translations`) first, then a **predefined shared key** (`shared.<key>`) when `key` is one of the standard strings (`submit`, `cancel`, `save`, `loading`, …), then the raw key. Use a shared key for an identical default string so it translates once and any per-instance `widget.<id>.<key>` override still wins. |
45
45
  | **CORE** | `useTranslate()` | `{ translate, translating, error, language, available }` | `ctx.i18n.translate` — no scope. Machine-translates **user-generated content** (record text, file names, API payloads) into the app user's language; `useI18n().t()` is still the answer for your own copy. `translate(str)` → `Promise<string>`, `translate(str[])` → `Promise<string[]>` in ONE request. Target defaults to the app user's language. Cached per session, per pod, and durably per workspace, so repeat text is free. Limits: 50 segments / 5 000 chars each / 20 000 total. Rejects with `TranslateError`; `available` is false where the host cannot translate. |
46
+ | **CORE** | `useLinkPreview(url)` | `{ preview, loading, error, available }` — `preview` is `{ url, title, description, site_name, image_url } \| null` | `ctx.linkPreviews.get` — no scope. A link's title, description, site name and image, fetched by the platform (the device never contacts the linked site). A page with nothing to show resolves with null fields, not an error — render the plain link. A non-http(s) `url` makes no request. Memoized for up to an hour across widgets; concurrent calls for one URL share a request. `error` is a `LinkPreviewError` (`VALIDATION` \| `AUTH_REQUIRED` \| `RATE_LIMITED` \| `NETWORK` \| `INTERNAL`). `available` is false on a host with no link-preview client |
46
47
  | **CORE** | `useStableQuery(buildQuery)` | `T \| undefined` (whatever `buildQuery()` returns) | No context slice, no scope. Keeps `buildQuery()`'s result at a STABLE reference across renders when its (JSON-serialised) content hasn't changed, so `useDatastoreQuery(tableId, useStableQuery(() => ({...})))` replaces a hand-rolled `useMemo` with an easy-to-get-wrong deps array. Never throws: a `buildQuery` that itself throws degrades to a stable `undefined`; a result that can't be diffed (e.g. circular) degrades to "always a new reference". |
47
48
  | **DATASTORE** (`ctx.datastore`) | `useDatastoreQuery(table, options)` (optional: options) | `{ data, total, loading, fetching, error, refetch }` — `loading` only until the first result for the table; a changed query / refetch keeps `data` while `fetching`; `total` is the envelope's `meta.total` | `records(table).list` (unwraps `{ data, meta }` to `data: []`) — `datastore.read:*` |
48
49
  | **DATASTORE** | `useDatastoreFigure(table, { aggregate, column, filter })` (optional: options) | `{ value, text, truncated, loading, fetching, error, refetch }` | `records(table).list` (a count reads the envelope total; a sum or average up to 1000 rows) — `datastore.read:*`. One count / sum / average for a `<Stat>`, computed exactly as the built-in Stat widget does. |
@@ -76,7 +77,26 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
76
77
 
77
78
  ## Status
78
79
 
79
- `v0.190.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
80
+ `v0.191.0` — pre-publish. The package surface (types, function names, export paths) is the v1 contract; runtime behaviour for some hooks is stubbed (each hook documents what's wired and what isn't). It is **not yet published to npm**.
81
+
82
+ ### What's new in 0.191.0 (contract 1.152.0)
83
+
84
+ **New `useLinkPreview(url)` hook: show a preview card for a link.** It returns `{ preview, loading, error, available }`, where `preview` is `{ url, title, description, site_name, image_url }` (the wire's snake_case names) once it loads. The platform fetches the page, so the device showing the link never contacts the linked site. `image_url` is a signed URL that loads directly in an `<Image>` with no auth headers.
85
+
86
+ A page with nothing to show (unreachable, not HTML, too slow) is not an error: it resolves with every field but `url` set to `null`, and you render the plain link. A `url` that is not an absolute `http:`/`https:` URL returns `preview: null` without a request, so you can pass the first link you found in a message straight in. Results are kept for up to an hour and shared by every widget, and concurrent calls for one URL make one request, so a message list may call the hook per row. `error` is a `LinkPreviewError` whose `.code` is `VALIDATION`, `AUTH_REQUIRED`, `RATE_LIMITED`, `NETWORK` or `INTERNAL`; render the plain link on any error. A `RATE_LIMITED` failure is retried once, automatically, after the `retryAfter` the server sends (clamped to 1–60 s), so a long list of links still fills in. `available` is `false` on a host that brokers no link-preview client, and the hook then never fetches.
87
+
88
+ ```jsx
89
+ const { preview } = useLinkPreview(firstLink);
90
+ // {preview && preview.title && (
91
+ // <View>
92
+ // {preview.image_url && <Image source={{ uri: preview.image_url }} style={{ width: 64, height: 64 }} />}
93
+ // <Text>{preview.title}</Text>
94
+ // {preview.site_name && <Text>{preview.site_name}</Text>}
95
+ // </View>
96
+ // )}
97
+ ```
98
+
99
+ It needs no scope. The host injects `@colixsystems/link-preview-client` as the new optional `ctx.linkPreviews` slice. Additive; works identically in the web Player and the native export.
80
100
 
81
101
  ### What's new in 0.190.0 (contract 1.151.0)
82
102
 
package/dist/contract.cjs CHANGED
@@ -784,6 +784,31 @@ const HOOKS = [
784
784
  requiredContextSlice: ["i18n.locale"],
785
785
  scopes: null,
786
786
  },
787
+ {
788
+ name: "useLinkPreview",
789
+ signature: "useLinkPreview(url)",
790
+ description:
791
+ "Preview a link the way a messenger does: the page's title, description, site name and image. The PLATFORM fetches the page, so the " +
792
+ "device never contacts the linked site, and private or internal addresses are refused. Returns { preview, loading, error, available }. " +
793
+ "`preview` is { url, title, description, site_name, image_url } with the wire's snake_case names, or null while loading. A page with " +
794
+ "nothing to show (unreachable, not HTML, too slow) resolves with every field but url null — that is NOT an error: render the plain link. " +
795
+ "image_url is a signed URL that loads directly in <Image> with no auth headers. A url that is not an absolute http(s) URL returns " +
796
+ "preview: null with no request, so pass the link you found in the text straight in. Results are memoized for up to an hour across every " +
797
+ "widget and concurrent calls for one URL make one request, so a list of messages may call it per row. error is a LinkPreviewError whose " +
798
+ ".code is VALIDATION | AUTH_REQUIRED | RATE_LIMITED | NETWORK | INTERNAL; render the plain link on any error. A RATE_LIMITED failure is " +
799
+ "retried ONCE automatically after the server's retryAfter (clamped to 1–60 s), so the card can still appear. `available` is false on a " +
800
+ "host that brokers no link-preview client; the hook then never fetches. Never block a message on it: render the text first and add the " +
801
+ "preview card when it arrives. Reads ctx.linkPreviews. Identical on web (Player) and the Expo export.",
802
+ returnShape: {
803
+ preview:
804
+ "{ url: string, title: string | null, description: string | null, site_name: string | null, image_url: string | null } | null",
805
+ loading: "boolean",
806
+ error: "LinkPreviewError | null",
807
+ available: "boolean // false when the host brokers no link-preview client",
808
+ },
809
+ requiredContextSlice: ["linkPreviews.get"],
810
+ scopes: null,
811
+ },
787
812
  {
788
813
  name: "useStableQuery",
789
814
  signature: "useStableQuery(buildQuery)",
@@ -2926,6 +2951,15 @@ const WIDGET_CONTEXT_SHAPE = {
2926
2951
  required: false,
2927
2952
  fields: { conversations: "function" },
2928
2953
  },
2954
+ linkPreviews: {
2955
+ description:
2956
+ "Injected @colixsystems/link-preview-client instance. { get(url) -> Promise<{ url, title, description, site_name, image_url }> }. " +
2957
+ "Backs useLinkPreview(). The server fetches the page; a page with nothing to show resolves with null fields rather than rejecting. " +
2958
+ "image_url is absolute on every host and needs no auth headers. Any signed-in principal may call it (the app user, or the studio " +
2959
+ "user previewing on the canvas). Absent on a host that brokers none, where useLinkPreview() degrades to available: false.",
2960
+ required: false,
2961
+ fields: { get: "function" },
2962
+ },
2929
2963
  datastore: {
2930
2964
  description:
2931
2965
  "Injected @colixsystems/datastore-client instance. " +
@@ -4961,7 +4995,10 @@ const CONTRACT = deepFreeze({
4961
4995
  // `{ typingUserIds, setTyping, status }`: the other users typing in one
4962
4996
  // record, over the datastore client's single realtime socket. A host
4963
4997
  // whose client lacks `records(t).typing` reads `status: "unavailable"`.
4964
- version: "1.151.0",
4998
+ // 1.152.0: additive (sc-10580) — new `useLinkPreview(url)` returns
4999
+ // `{ preview, loading, error, available }` from the new optional
5000
+ // `linkPreviews` slice (@colixsystems/link-preview-client).
5001
+ version: "1.152.0",
4965
5002
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4966
5003
  hooks: HOOKS,
4967
5004
  primitives: PRIMITIVES,
package/dist/contract.js CHANGED
@@ -784,6 +784,31 @@ const HOOKS = [
784
784
  requiredContextSlice: ["i18n.locale"],
785
785
  scopes: null,
786
786
  },
787
+ {
788
+ name: "useLinkPreview",
789
+ signature: "useLinkPreview(url)",
790
+ description:
791
+ "Preview a link the way a messenger does: the page's title, description, site name and image. The PLATFORM fetches the page, so the " +
792
+ "device never contacts the linked site, and private or internal addresses are refused. Returns { preview, loading, error, available }. " +
793
+ "`preview` is { url, title, description, site_name, image_url } with the wire's snake_case names, or null while loading. A page with " +
794
+ "nothing to show (unreachable, not HTML, too slow) resolves with every field but url null — that is NOT an error: render the plain link. " +
795
+ "image_url is a signed URL that loads directly in <Image> with no auth headers. A url that is not an absolute http(s) URL returns " +
796
+ "preview: null with no request, so pass the link you found in the text straight in. Results are memoized for up to an hour across every " +
797
+ "widget and concurrent calls for one URL make one request, so a list of messages may call it per row. error is a LinkPreviewError whose " +
798
+ ".code is VALIDATION | AUTH_REQUIRED | RATE_LIMITED | NETWORK | INTERNAL; render the plain link on any error. A RATE_LIMITED failure is " +
799
+ "retried ONCE automatically after the server's retryAfter (clamped to 1–60 s), so the card can still appear. `available` is false on a " +
800
+ "host that brokers no link-preview client; the hook then never fetches. Never block a message on it: render the text first and add the " +
801
+ "preview card when it arrives. Reads ctx.linkPreviews. Identical on web (Player) and the Expo export.",
802
+ returnShape: {
803
+ preview:
804
+ "{ url: string, title: string | null, description: string | null, site_name: string | null, image_url: string | null } | null",
805
+ loading: "boolean",
806
+ error: "LinkPreviewError | null",
807
+ available: "boolean // false when the host brokers no link-preview client",
808
+ },
809
+ requiredContextSlice: ["linkPreviews.get"],
810
+ scopes: null,
811
+ },
787
812
  {
788
813
  name: "useStableQuery",
789
814
  signature: "useStableQuery(buildQuery)",
@@ -2922,6 +2947,15 @@ const WIDGET_CONTEXT_SHAPE = {
2922
2947
  required: false,
2923
2948
  fields: { conversations: "function" },
2924
2949
  },
2950
+ linkPreviews: {
2951
+ description:
2952
+ "Injected @colixsystems/link-preview-client instance. { get(url) -> Promise<{ url, title, description, site_name, image_url }> }. " +
2953
+ "Backs useLinkPreview(). The server fetches the page; a page with nothing to show resolves with null fields rather than rejecting. " +
2954
+ "image_url is absolute on every host and needs no auth headers. Any signed-in principal may call it (the app user, or the studio " +
2955
+ "user previewing on the canvas). Absent on a host that brokers none, where useLinkPreview() degrades to available: false.",
2956
+ required: false,
2957
+ fields: { get: "function" },
2958
+ },
2925
2959
  datastore: {
2926
2960
  description:
2927
2961
  "Injected @colixsystems/datastore-client instance. " +
@@ -4957,7 +4991,10 @@ const CONTRACT = deepFreeze({
4957
4991
  // `{ typingUserIds, setTyping, status }`: the other users typing in one
4958
4992
  // record, over the datastore client's single realtime socket. A host
4959
4993
  // whose client lacks `records(t).typing` reads `status: "unavailable"`.
4960
- version: "1.151.0",
4994
+ // 1.152.0: additive (sc-10580) — new `useLinkPreview(url)` returns
4995
+ // `{ preview, loading, error, available }` from the new optional
4996
+ // `linkPreviews` slice (@colixsystems/link-preview-client).
4997
+ version: "1.152.0",
4961
4998
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4962
4999
  hooks: HOOKS,
4963
5000
  primitives: PRIMITIVES,
package/dist/hooks.js CHANGED
@@ -1047,6 +1047,236 @@ export function useTranslate() {
1047
1047
  return { translate, translating, error, language: locale, available: client !== null };
1048
1048
  }
1049
1049
 
1050
+ /**
1051
+ * Structured error surfaced by `useLinkPreview().error`. `code` is one of:
1052
+ * - "VALIDATION" — 400 (the server refused the URL)
1053
+ * - "AUTH_REQUIRED" — 401 (no signed-in principal)
1054
+ * - "RATE_LIMITED" — 429 (too many previews; `retryAfter` is in seconds)
1055
+ * - "NETWORK" — no answer from the server (offline, timeout)
1056
+ * - "INTERNAL" — anything else
1057
+ * A page that simply has no preview is NOT an error.
1058
+ */
1059
+ export class LinkPreviewError extends Error {
1060
+ constructor(code, message, opts) {
1061
+ super(message);
1062
+ this.name = "LinkPreviewError";
1063
+ this.code = code;
1064
+ this.retryAfter =
1065
+ opts && typeof opts.retryAfter === "number" ? opts.retryAfter : null;
1066
+ if (opts && opts.cause) this.cause = opts.cause;
1067
+ }
1068
+ }
1069
+
1070
+ const LINK_PREVIEW_CODES = new Set([
1071
+ "VALIDATION",
1072
+ "AUTH_REQUIRED",
1073
+ "RATE_LIMITED",
1074
+ "NETWORK",
1075
+ "INTERNAL",
1076
+ ]);
1077
+
1078
+ function toLinkPreviewError(err) {
1079
+ if (err instanceof LinkPreviewError) return err;
1080
+ const status = err && typeof err.status === "number" ? err.status : null;
1081
+ let code = "INTERNAL";
1082
+ if (err && LINK_PREVIEW_CODES.has(err.code)) code = err.code;
1083
+ else if (status === 400) code = "VALIDATION";
1084
+ else if (status === 401) code = "AUTH_REQUIRED";
1085
+ else if (status === 429) code = "RATE_LIMITED";
1086
+ const message =
1087
+ err && typeof err.message === "string" && err.message
1088
+ ? err.message
1089
+ : "Link preview failed";
1090
+ return new LinkPreviewError(code, message, {
1091
+ cause: err,
1092
+ retryAfter: err && typeof err.retryAfter === "number" ? err.retryAfter : null,
1093
+ });
1094
+ }
1095
+
1096
+ // `URL` is avoided on purpose: React Native's polyfill lacks `protocol`.
1097
+ const PREVIEWABLE_URL = /^https?:\/\/[^\s/?#]+(?:[/?#]\S*)?$/i;
1098
+
1099
+ function previewableUrl(url) {
1100
+ if (typeof url !== "string") return null;
1101
+ const trimmed = url.trim();
1102
+ return PREVIEWABLE_URL.test(trimmed) ? trimmed : null;
1103
+ }
1104
+
1105
+ // Shared by every widget instance so a chat with fifty copies of one link
1106
+ // asks once. Errors are never memoized: a later mount may succeed.
1107
+ const LINK_PREVIEW_MEMO_MAX = 500;
1108
+ // Well inside the signed image URL's lifetime, so a cached image still loads.
1109
+ const LINK_PREVIEW_MEMO_TTL_MS = 60 * 60 * 1000;
1110
+ const linkPreviewMemo = new Map();
1111
+ const linkPreviewInFlight = new Map();
1112
+
1113
+ const LINK_PREVIEW_RETRY_MIN_S = 1;
1114
+ const LINK_PREVIEW_RETRY_MAX_S = 60;
1115
+
1116
+ function isFreshLinkPreview(entry) {
1117
+ return Date.now() - entry.storedAt <= LINK_PREVIEW_MEMO_TTL_MS;
1118
+ }
1119
+
1120
+ // Render-safe: never reorders or evicts, so rendering stays pure.
1121
+ function linkPreviewMemoPeek(url) {
1122
+ const entry = linkPreviewMemo.get(url);
1123
+ return entry !== undefined && isFreshLinkPreview(entry) ? entry.preview : undefined;
1124
+ }
1125
+
1126
+ // Effect-only: marks the entry recently used and drops it once stale.
1127
+ function linkPreviewMemoTouch(url) {
1128
+ const entry = linkPreviewMemo.get(url);
1129
+ if (entry === undefined) return undefined;
1130
+ linkPreviewMemo.delete(url);
1131
+ if (!isFreshLinkPreview(entry)) return undefined;
1132
+ linkPreviewMemo.set(url, entry);
1133
+ return entry.preview;
1134
+ }
1135
+
1136
+ function linkPreviewRetryDelayMs(error) {
1137
+ if (!error || error.code !== "RATE_LIMITED") return null;
1138
+ const seconds = error.retryAfter;
1139
+ if (typeof seconds !== "number" || !Number.isFinite(seconds)) return null;
1140
+ const clamped = Math.min(
1141
+ LINK_PREVIEW_RETRY_MAX_S,
1142
+ Math.max(LINK_PREVIEW_RETRY_MIN_S, seconds),
1143
+ );
1144
+ return clamped * 1000;
1145
+ }
1146
+
1147
+ function linkPreviewMemoWrite(url, preview) {
1148
+ linkPreviewMemo.delete(url);
1149
+ if (linkPreviewMemo.size >= LINK_PREVIEW_MEMO_MAX) {
1150
+ const oldest = linkPreviewMemo.keys().next();
1151
+ if (!oldest.done) linkPreviewMemo.delete(oldest.value);
1152
+ }
1153
+ linkPreviewMemo.set(url, { preview, storedAt: Date.now() });
1154
+ }
1155
+
1156
+ function requestLinkPreview(client, url) {
1157
+ const pending = linkPreviewInFlight.get(url);
1158
+ if (pending) return pending;
1159
+ let started;
1160
+ try {
1161
+ started = Promise.resolve(client.get(url));
1162
+ } catch (err) {
1163
+ started = Promise.reject(err);
1164
+ }
1165
+ const request = started
1166
+ .then((preview) => {
1167
+ linkPreviewMemoWrite(url, preview);
1168
+ return preview;
1169
+ })
1170
+ .finally(() => {
1171
+ if (linkPreviewInFlight.get(url) === request) linkPreviewInFlight.delete(url);
1172
+ });
1173
+ linkPreviewInFlight.set(url, request);
1174
+ return request;
1175
+ }
1176
+
1177
+ /** Exposed so tests (and a host teardown) can drop the session memo. */
1178
+ export function _resetLinkPreviewMemo() {
1179
+ linkPreviewMemo.clear();
1180
+ linkPreviewInFlight.clear();
1181
+ }
1182
+
1183
+ /**
1184
+ * Preview a link: the page's title, description, site name and image, fetched
1185
+ * by the platform (the device never contacts the linked site). Returns
1186
+ * `{ preview, loading, error, available }`.
1187
+ *
1188
+ * const { preview } = useLinkPreview(firstUrlInMessage);
1189
+ * // preview: { url, title, description, site_name, image_url } | null
1190
+ *
1191
+ * `preview` keeps the wire's snake_case names. A page with nothing to show
1192
+ * resolves with every field but `url` null — render the plain link then.
1193
+ * `image_url` loads directly in an <Image>; it needs no auth headers.
1194
+ *
1195
+ * A `url` that is not an absolute http(s) URL returns `preview: null` without
1196
+ * a request. `available` is false on a host that brokers no link-preview
1197
+ * client; the hook then never throws and never fetches. Results are shared
1198
+ * across every widget for up to an hour, and concurrent requests for one URL
1199
+ * are made once. A RATE_LIMITED failure is retried once, after the server's
1200
+ * `retryAfter` (clamped to 1–60 s).
1201
+ */
1202
+ export function useLinkPreview(url) {
1203
+ const ctx = useWidgetContextOrThrow("useLinkPreview");
1204
+ const client =
1205
+ ctx.linkPreviews && typeof ctx.linkPreviews.get === "function"
1206
+ ? ctx.linkPreviews
1207
+ : null;
1208
+ const available = client !== null;
1209
+ const target = previewableUrl(url);
1210
+
1211
+ const clientRef = useRef(client);
1212
+ clientRef.current = client;
1213
+ const [settled, setSettled] = useState(() => {
1214
+ const hit = target ? linkPreviewMemoPeek(target) : undefined;
1215
+ return hit === undefined ? null : { url: target, preview: hit, error: null };
1216
+ });
1217
+ // URLs this mount already retried, so a rate limit is retried only once.
1218
+ const retriedRef = useRef(null);
1219
+ if (retriedRef.current === null) retriedRef.current = new Set();
1220
+
1221
+ useEffect(() => {
1222
+ if (!target || !clientRef.current) return undefined;
1223
+ const memoized = linkPreviewMemoTouch(target);
1224
+ if (memoized !== undefined) {
1225
+ // Held in state too, so a later LRU eviction cannot strand `loading`.
1226
+ setSettled((prev) =>
1227
+ prev && prev.url === target && prev.preview === memoized
1228
+ ? prev
1229
+ : { url: target, preview: memoized, error: null },
1230
+ );
1231
+ return undefined;
1232
+ }
1233
+ let live = true;
1234
+ let retryTimer = null;
1235
+ const load = () => {
1236
+ const hit = linkPreviewMemoTouch(target);
1237
+ const result =
1238
+ hit !== undefined ? Promise.resolve(hit) : requestLinkPreview(clientRef.current, target);
1239
+ result.then(
1240
+ (preview) => {
1241
+ if (live) setSettled({ url: target, preview, error: null });
1242
+ },
1243
+ (err) => {
1244
+ if (!live) return;
1245
+ const error = toLinkPreviewError(err);
1246
+ setSettled({ url: target, preview: null, error });
1247
+ const delayMs = linkPreviewRetryDelayMs(error);
1248
+ if (delayMs === null || retriedRef.current.has(target) || !clientRef.current) return;
1249
+ retriedRef.current.add(target);
1250
+ retryTimer = setTimeout(() => {
1251
+ retryTimer = null;
1252
+ if (live && clientRef.current) load();
1253
+ }, delayMs);
1254
+ },
1255
+ );
1256
+ };
1257
+ load();
1258
+ return () => {
1259
+ live = false;
1260
+ if (retryTimer !== null) clearTimeout(retryTimer);
1261
+ };
1262
+ }, [target, available]);
1263
+
1264
+ if (!target || !available) {
1265
+ return { preview: null, loading: false, error: null, available };
1266
+ }
1267
+ const memoized = linkPreviewMemoPeek(target);
1268
+ if (memoized !== undefined) {
1269
+ return { preview: memoized, loading: false, error: null, available };
1270
+ }
1271
+ const own = settled && settled.url === target ? settled : null;
1272
+ return {
1273
+ preview: own ? own.preview : null,
1274
+ loading: own === null,
1275
+ error: own ? own.error : null,
1276
+ available,
1277
+ };
1278
+ }
1279
+
1050
1280
  /**
1051
1281
  * sc-5206 — keep a query-shaped argument at a STABLE reference across
1052
1282
  * renders so it can be handed straight to `useDatastoreQuery`'s second
package/dist/index.d.ts CHANGED
@@ -697,6 +697,8 @@ export interface WidgetContext<TProps = unknown> {
697
697
  identification: IdentificationClient;
698
698
  /** sc-7991 — injected @colixsystems/agents-client backing useAgent(). */
699
699
  agents?: { conversations(agentId: string): unknown };
700
+ /** Injected @colixsystems/link-preview-client backing useLinkPreview(). */
701
+ linkPreviews?: { get(url: string): Promise<LinkPreview> };
700
702
  /** Host child-node renderer; backs WidgetTree / useChildRenderer. */
701
703
  renderer: { renderNode(node: unknown): unknown };
702
704
  events: { emit(eventName: string, payload?: unknown): void };
@@ -1535,6 +1537,36 @@ export function useTranslate(): {
1535
1537
  available: boolean;
1536
1538
  };
1537
1539
 
1540
+ /**
1541
+ * The preview of one page, with the wire's snake_case names. A page with
1542
+ * nothing to show has every field except `url` null. `image_url` loads
1543
+ * directly in an <Image> and needs no auth headers.
1544
+ */
1545
+ export interface LinkPreview {
1546
+ url: string;
1547
+ title: string | null;
1548
+ description: string | null;
1549
+ site_name: string | null;
1550
+ image_url: string | null;
1551
+ }
1552
+
1553
+ /**
1554
+ * Preview a link: the page's title, description, site name and image, fetched
1555
+ * by the platform so the device never contacts the linked site.
1556
+ *
1557
+ * A `url` that is not an absolute http(s) URL returns `preview: null` without
1558
+ * a request. `available` is false on a host that brokers no link-preview
1559
+ * client; the hook then never fetches. Results are shared across every widget
1560
+ * for up to an hour, and concurrent requests for one URL are made once. A
1561
+ * RATE_LIMITED failure is retried once after `retryAfter` (clamped to 1–60 s).
1562
+ */
1563
+ export function useLinkPreview(url: string | null | undefined): {
1564
+ preview: LinkPreview | null;
1565
+ loading: boolean;
1566
+ error: LinkPreviewError | null;
1567
+ available: boolean;
1568
+ };
1569
+
1538
1570
  export interface TranslateOptions {
1539
1571
  /** Target language code. Defaults to the app user's selected language. */
1540
1572
  target?: string;
@@ -2198,6 +2230,26 @@ export class AgentError extends Error {
2198
2230
  constructor(code: string, message: string, opts?: { cause?: unknown });
2199
2231
  }
2200
2232
 
2233
+ /**
2234
+ * Error surfaced by `useLinkPreview().error`. A page that simply has no
2235
+ * preview is not an error. `retryAfter` (seconds) is set on RATE_LIMITED.
2236
+ */
2237
+ export class LinkPreviewError extends Error {
2238
+ code:
2239
+ | "VALIDATION"
2240
+ | "AUTH_REQUIRED"
2241
+ | "RATE_LIMITED"
2242
+ | "NETWORK"
2243
+ | "INTERNAL"
2244
+ | (string & {});
2245
+ retryAfter: number | null;
2246
+ constructor(
2247
+ code: string,
2248
+ message: string,
2249
+ opts?: { cause?: unknown; retryAfter?: number | null },
2250
+ );
2251
+ }
2252
+
2201
2253
  export class TranslateError extends Error {
2202
2254
  code:
2203
2255
  | "UNSUPPORTED"
package/dist/index.js CHANGED
@@ -71,6 +71,8 @@ export {
71
71
  useI18n,
72
72
  useTranslate,
73
73
  TranslateError,
74
+ useLinkPreview,
75
+ LinkPreviewError,
74
76
  useUser,
75
77
  useUserPhoto,
76
78
  useFill,
@@ -71,6 +71,8 @@ export {
71
71
  useI18n,
72
72
  useTranslate,
73
73
  TranslateError,
74
+ useLinkPreview,
75
+ LinkPreviewError,
74
76
  useUser,
75
77
  useUserPhoto,
76
78
  useFill,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@colixsystems/widget-sdk",
3
- "version": "0.190.0",
3
+ "version": "0.191.0",
4
4
  "description": "Common widget interface for AppStudio. Implements WidgetManifest, WidgetContext, property schema, and helper hooks.",
5
5
  "homepage": "https://github.com/Colix-AB/AppStudio",
6
6
  "type": "module",
package/src/contract.js CHANGED
@@ -784,6 +784,31 @@ const HOOKS = [
784
784
  requiredContextSlice: ["i18n.locale"],
785
785
  scopes: null,
786
786
  },
787
+ {
788
+ name: "useLinkPreview",
789
+ signature: "useLinkPreview(url)",
790
+ description:
791
+ "Preview a link the way a messenger does: the page's title, description, site name and image. The PLATFORM fetches the page, so the " +
792
+ "device never contacts the linked site, and private or internal addresses are refused. Returns { preview, loading, error, available }. " +
793
+ "`preview` is { url, title, description, site_name, image_url } with the wire's snake_case names, or null while loading. A page with " +
794
+ "nothing to show (unreachable, not HTML, too slow) resolves with every field but url null — that is NOT an error: render the plain link. " +
795
+ "image_url is a signed URL that loads directly in <Image> with no auth headers. A url that is not an absolute http(s) URL returns " +
796
+ "preview: null with no request, so pass the link you found in the text straight in. Results are memoized for up to an hour across every " +
797
+ "widget and concurrent calls for one URL make one request, so a list of messages may call it per row. error is a LinkPreviewError whose " +
798
+ ".code is VALIDATION | AUTH_REQUIRED | RATE_LIMITED | NETWORK | INTERNAL; render the plain link on any error. A RATE_LIMITED failure is " +
799
+ "retried ONCE automatically after the server's retryAfter (clamped to 1–60 s), so the card can still appear. `available` is false on a " +
800
+ "host that brokers no link-preview client; the hook then never fetches. Never block a message on it: render the text first and add the " +
801
+ "preview card when it arrives. Reads ctx.linkPreviews. Identical on web (Player) and the Expo export.",
802
+ returnShape: {
803
+ preview:
804
+ "{ url: string, title: string | null, description: string | null, site_name: string | null, image_url: string | null } | null",
805
+ loading: "boolean",
806
+ error: "LinkPreviewError | null",
807
+ available: "boolean // false when the host brokers no link-preview client",
808
+ },
809
+ requiredContextSlice: ["linkPreviews.get"],
810
+ scopes: null,
811
+ },
787
812
  {
788
813
  name: "useStableQuery",
789
814
  signature: "useStableQuery(buildQuery)",
@@ -2922,6 +2947,15 @@ const WIDGET_CONTEXT_SHAPE = {
2922
2947
  required: false,
2923
2948
  fields: { conversations: "function" },
2924
2949
  },
2950
+ linkPreviews: {
2951
+ description:
2952
+ "Injected @colixsystems/link-preview-client instance. { get(url) -> Promise<{ url, title, description, site_name, image_url }> }. " +
2953
+ "Backs useLinkPreview(). The server fetches the page; a page with nothing to show resolves with null fields rather than rejecting. " +
2954
+ "image_url is absolute on every host and needs no auth headers. Any signed-in principal may call it (the app user, or the studio " +
2955
+ "user previewing on the canvas). Absent on a host that brokers none, where useLinkPreview() degrades to available: false.",
2956
+ required: false,
2957
+ fields: { get: "function" },
2958
+ },
2925
2959
  datastore: {
2926
2960
  description:
2927
2961
  "Injected @colixsystems/datastore-client instance. " +
@@ -4957,7 +4991,10 @@ const CONTRACT = deepFreeze({
4957
4991
  // `{ typingUserIds, setTyping, status }`: the other users typing in one
4958
4992
  // record, over the datastore client's single realtime socket. A host
4959
4993
  // whose client lacks `records(t).typing` reads `status: "unavailable"`.
4960
- version: "1.151.0",
4994
+ // 1.152.0: additive (sc-10580) — new `useLinkPreview(url)` returns
4995
+ // `{ preview, loading, error, available }` from the new optional
4996
+ // `linkPreviews` slice (@colixsystems/link-preview-client).
4997
+ version: "1.152.0",
4961
4998
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4962
4999
  hooks: HOOKS,
4963
5000
  primitives: PRIMITIVES,