@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 +21 -1
- package/dist/contract.cjs +38 -1
- package/dist/contract.js +38 -1
- package/dist/hooks.js +230 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.js +2 -0
- package/dist/index.native.js +2 -0
- package/package.json +1 -1
- package/src/contract.js +38 -1
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
package/dist/index.native.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@colixsystems/widget-sdk",
|
|
3
|
-
"version": "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
|
-
|
|
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,
|