@colixsystems/widget-sdk 0.189.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 +36 -1
- package/dist/contract.cjs +76 -1
- package/dist/contract.js +76 -1
- package/dist/hooks.js +295 -0
- package/dist/index.d.ts +81 -0
- package/dist/index.js +3 -0
- package/dist/index.native.js +3 -0
- package/package.json +1 -1
- package/src/contract.js +76 -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. |
|
|
@@ -53,6 +54,7 @@ The data layer lives in **four separate domain-client packages**, each instantia
|
|
|
53
54
|
| **AGENTS** | `useAgent(agentRef)` | `{ send, cancel, reset, messages, sending, resuming, error, available }` | `ctx.agents` — `ai.invoke:*`. Hold a conversation with one of the WORKSPACE's own AI agents (an author-written name + system prompt). Sending is IMPERATIVE: call `send(text)` from an event handler, never on mount — the first send opens the conversation, so a widget nobody types into costs nothing. On mount the hook DOES reopen the caller's most recent thread with this agent, so a refresh or a navigation keeps the conversation instead of starting a blank one; that resume only READS and spends no credit. `resuming` is true while it runs — render a loading state, not an empty thread. `messages` is the running transcript, oldest first. You **cannot** set or read the system prompt, replay an edited transcript, or pick a model: the server assembles every turn from the stored prompt plus the stored history, and `content` is the only thing that reaches the wire. `reset()` drops the thread (a "new chat" affordance), and the new thread is the one a later mount resumes. **Every send spends the workspace's AI credits** and is rate-limited per caller AND per workspace. Rejects with `AgentError`; branch on `.code` — `AI_QUOTA_EXCEEDED` (429) is the workspace's credit ceiling and will NOT clear on a retry, unlike `RATE_LIMITED`, so never offer "try again" for it, and `AGENT_DISABLED` (409) means the author turned it off, so hide the affordance. Never surface a raw error or the workspace's billing state to an app user. `available` is false when no agent is bound or the host brokers none — render a visible unbound state, never a blank box. An assistant message that used a tool also carries **`activity`** — what the agent DID with the user's data: the table it read, or the table and columns it CHANGED (never the values, and never a row the permissions refused). **Render a write.** A reply saying "done!" is not evidence a row was written, and if the agent got it wrong the user has no other way to notice. A talk-only turn omits the key entirely. |
|
|
54
55
|
| **DATASTORE** | `useDatastoreMutation(table)` | `{ create, update, delete }` | `records(table).{ create, update (PATCH), delete }` — `datastore.write:*`. **To CLEAR a cell, pass it empty** — `update(id, { when: null })` (also `""` or `[]`) empties that cell and it reads back as `null` (`[]` on an array column). There is no separate clear call, so never write a sentinel such as `0` or a placeholder date to mean "no value". `false` and `0` are values, not clears, and a REQUIRED column cannot be cleared. |
|
|
55
56
|
| **DATASTORE** | `useDatastoreSubscription(table, handlers, options)` (optional: options) | `{ status }` — `"connecting" \| "live" \| "reconnecting" \| "fallback"` | `records(table).subscribe` — `datastore.read:<table>`. Live `onCreated` / `onUpdated` / `onDeleted` off the REQ-RT-07 socket; never throws, resolving to `{ status: "fallback" }` so the widget polls instead. A whole-table subscribe is gated on read-EVERY-row, because one envelope reaches every subscriber of the table — so for a table governed by per-record grants pass `options.scope`: `{ kind: "record", record_id }` for one row, or `{ kind: "parent", relation_column, record_id }` for the rows whose RELATION column points at that parent (the column must carry `inheritAcl`, else the subscribe reports `"fallback"`). Re-subscribes on the scope's VALUES, so a fresh object literal each render is fine. |
|
|
57
|
+
| **DATASTORE** | `useTyping(tableId, recordId)` | `{ typingUserIds, setTyping, status }` — status `"connecting" \| "live" \| "unavailable"` | `records(table).typing` — `datastore.read:<table>`. Who else is typing in one record, over the same socket as `useDatastoreSubscription`. Call `setTyping(true)` on every keystroke (throttled to one signal per 3 s; `false` is sent after 5 s idle) and `setTyping(false)` on send, clear or blur. `typingUserIds` lists the OTHER users' ids; each drops out 6 s after their last signal. Needs a signed-in user who can read the record. Never throws: an anonymous visitor, a refused subscription or a host whose client lacks `typing` reads `{ typingUserIds: [], status: "unavailable" }`. Re-subscribes when either id changes. |
|
|
56
58
|
| **DATASTORE** | `useRecordPermissions(tableId, recordId)` | `{ permissions, loading, error, grant, revoke, update, refetch }` | `records(table).permissions(record).{ list, grant, update, revoke }` — `acl.write:records` (+ `can_grant` on the record) |
|
|
57
59
|
| **DATASTORE** | `useCanWrite(tableId, options)` (optional: options) | `{ canWrite, loading, error, refetch }` | `myPermissions(tableId, { recordId? })` — scope `datastore.read:<table>`. A FLOOR, not a full replacement for domain-specific write rules: answers "may this caller write", reading the same table-ACL answer the write endpoint enforces — so a table granting Create to Everyone answers `true` for a logged-out visitor, and this hook alone is the right gate for a widget meant to work without signing in. Pass `{ recordId }` for a per-row check. A widget whose own rule is MORE SPECIFIC than the table ACL (e.g. "only the assigned user may edit this row") must still hand-check that in addition. Pair with `useUser()` to also tell "not signed in" apart from "signed in but forbidden" — both resolve `canWrite: false` here. Falsy `tableId`, or a host that hasn't injected `myPermissions` (an older host), collapses to `{ canWrite: false, loading: false, error: null, refetch: async () => undefined }` rather than throwing. |
|
|
58
60
|
| **FILES** (`ctx.assets`) | `useAsset(id)` | `{ url, file, loading, error, refetch }` | `ctx.assets.get` — no scope |
|
|
@@ -75,7 +77,40 @@ See the design reference for the full architecture: [`docs/architecture/widget-m
|
|
|
75
77
|
|
|
76
78
|
## Status
|
|
77
79
|
|
|
78
|
-
`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.
|
|
100
|
+
|
|
101
|
+
### What's new in 0.190.0 (contract 1.151.0)
|
|
102
|
+
|
|
103
|
+
**New `useTyping(tableId, recordId)` hook: a typing indicator for one record.** It returns `{ typingUserIds, setTyping, status }`. Call `setTyping(true)` on every keystroke in your composer and `setTyping(false)` when the message is sent, the field is cleared or it loses focus. The hook throttles for you: it sends at most one signal every 3 s, and sends `false` on its own after 5 s without a keystroke. `typingUserIds` lists the other users typing in that record (never yourself); each one drops out 6 s after their last signal, so a closed tab never leaves a stale indicator. Signals carry no message content and are never stored.
|
|
104
|
+
|
|
105
|
+
Key it on the record the conversation lives in (a thread or channel row), which the user must be able to read. It rides the same realtime connection as `useDatastoreSubscription`, so a widget holds one socket for both. `status` is `"connecting"`, `"live"` or `"unavailable"`; it is `"unavailable"` for an anonymous visitor, a refused subscription, a preview with no backend, and a host whose datastore client predates typing. The hook never throws, so render the indicator only when `typingUserIds` is non-empty:
|
|
106
|
+
|
|
107
|
+
```jsx
|
|
108
|
+
const { typingUserIds, setTyping } = useTyping(threadsTableId, threadId);
|
|
109
|
+
// <TextInput onChangeText={(t) => { setDraft(t); setTyping(t.length > 0); }} onBlur={() => setTyping(false)} />
|
|
110
|
+
// {typingUserIds.length > 0 && <Text>{typingUserIds.length === 1 ? "Someone is typing…" : "Several people are typing…"}</Text>}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Declare `datastore.read` for the table. Additive; works identically in the web Player and the native export.
|
|
79
114
|
|
|
80
115
|
### What's new in 0.189.0 (contract 1.150.0)
|
|
81
116
|
|
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)",
|
|
@@ -2043,6 +2068,40 @@ const HOOKS = [
|
|
|
2043
2068
|
requiredContextSlice: ["datastore.records"],
|
|
2044
2069
|
scopes: ["datastore.read:<table>"],
|
|
2045
2070
|
},
|
|
2071
|
+
// REQ-RT-08 — typing signals for one record.
|
|
2072
|
+
{
|
|
2073
|
+
name: "useTyping",
|
|
2074
|
+
signature: "useTyping(tableId, recordId)",
|
|
2075
|
+
description:
|
|
2076
|
+
"Who else is typing in one record, and announcing your own typing, via " +
|
|
2077
|
+
"ctx.datastore.records(tableId).typing(recordId, ...). It shares the " +
|
|
2078
|
+
"datastore client's one realtime socket with useDatastoreSubscription. " +
|
|
2079
|
+
"Call setTyping(true) on every keystroke (throttled inside: one signal " +
|
|
2080
|
+
"per 3 s at most, and false is sent after 5 s without a keystroke) and " +
|
|
2081
|
+
"setTyping(false) on send, clear or blur. typingUserIds lists the OTHER " +
|
|
2082
|
+
"users typing (user ids, never yourself); each drops out 6 s after their " +
|
|
2083
|
+
"last signal. Signals carry no message content and are never stored. " +
|
|
2084
|
+
"Typing needs a signed-in user who can read the record; key it on the " +
|
|
2085
|
+
"record the conversation lives in (a thread or channel row). Gate the " +
|
|
2086
|
+
"composer that calls setTyping on useCanWrite as usual. status is " +
|
|
2087
|
+
"'connecting' | 'live' | 'unavailable'. " +
|
|
2088
|
+
"Never throws: with a falsy id, an anonymous visitor, a refused " +
|
|
2089
|
+
"subscription, a host whose datastore client predates typing, or a " +
|
|
2090
|
+
"preview with no backend, it returns { typingUserIds: [], setTyping: no-op, " +
|
|
2091
|
+
"status: 'unavailable' }, so render the indicator only when " +
|
|
2092
|
+
"typingUserIds is non-empty. Re-subscribes when tableId or recordId " +
|
|
2093
|
+
"changes. Declare datastore.read for the table.",
|
|
2094
|
+
returnShape: {
|
|
2095
|
+
typingUserIds:
|
|
2096
|
+
"string[] // other users typing in this record; empty when unavailable",
|
|
2097
|
+
setTyping:
|
|
2098
|
+
"(active: boolean) => void // true per keystroke, false on send/clear/blur",
|
|
2099
|
+
status:
|
|
2100
|
+
"'connecting' | 'live' | 'unavailable' // 'unavailable' → hide the indicator",
|
|
2101
|
+
},
|
|
2102
|
+
requiredContextSlice: ["datastore.records"],
|
|
2103
|
+
scopes: ["datastore.read:<table>"],
|
|
2104
|
+
},
|
|
2046
2105
|
// REQ-WSDK-PLATFORM §6 — Tier A SDK hooks. Mirror of contract.js.
|
|
2047
2106
|
{
|
|
2048
2107
|
name: "useClipboard",
|
|
@@ -2892,6 +2951,15 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
2892
2951
|
required: false,
|
|
2893
2952
|
fields: { conversations: "function" },
|
|
2894
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
|
+
},
|
|
2895
2963
|
datastore: {
|
|
2896
2964
|
description:
|
|
2897
2965
|
"Injected @colixsystems/datastore-client instance. " +
|
|
@@ -4923,7 +4991,14 @@ const CONTRACT = deepFreeze({
|
|
|
4923
4991
|
// native-only file keeps every vetted subpath (Metro resolves them).
|
|
4924
4992
|
// New `hostExternalSpecifiers` / `subpathImportRoots` lists and the
|
|
4925
4993
|
// linter export `subpathImportRootsFor(manifest)`.
|
|
4926
|
-
|
|
4994
|
+
// 1.151.0: additive (sc-10538) — new `useTyping(tableId, recordId)` returns
|
|
4995
|
+
// `{ typingUserIds, setTyping, status }`: the other users typing in one
|
|
4996
|
+
// record, over the datastore client's single realtime socket. A host
|
|
4997
|
+
// whose client lacks `records(t).typing` reads `status: "unavailable"`.
|
|
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",
|
|
4927
5002
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4928
5003
|
hooks: HOOKS,
|
|
4929
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)",
|
|
@@ -2043,6 +2068,40 @@ const HOOKS = [
|
|
|
2043
2068
|
requiredContextSlice: ["datastore.records"],
|
|
2044
2069
|
scopes: ["datastore.read:<table>"],
|
|
2045
2070
|
},
|
|
2071
|
+
// REQ-RT-08 — typing signals for one record.
|
|
2072
|
+
{
|
|
2073
|
+
name: "useTyping",
|
|
2074
|
+
signature: "useTyping(tableId, recordId)",
|
|
2075
|
+
description:
|
|
2076
|
+
"Who else is typing in one record, and announcing your own typing, via " +
|
|
2077
|
+
"ctx.datastore.records(tableId).typing(recordId, ...). It shares the " +
|
|
2078
|
+
"datastore client's one realtime socket with useDatastoreSubscription. " +
|
|
2079
|
+
"Call setTyping(true) on every keystroke (throttled inside: one signal " +
|
|
2080
|
+
"per 3 s at most, and false is sent after 5 s without a keystroke) and " +
|
|
2081
|
+
"setTyping(false) on send, clear or blur. typingUserIds lists the OTHER " +
|
|
2082
|
+
"users typing (user ids, never yourself); each drops out 6 s after their " +
|
|
2083
|
+
"last signal. Signals carry no message content and are never stored. " +
|
|
2084
|
+
"Typing needs a signed-in user who can read the record; key it on the " +
|
|
2085
|
+
"record the conversation lives in (a thread or channel row). Gate the " +
|
|
2086
|
+
"composer that calls setTyping on useCanWrite as usual. status is " +
|
|
2087
|
+
"'connecting' | 'live' | 'unavailable'. " +
|
|
2088
|
+
"Never throws: with a falsy id, an anonymous visitor, a refused " +
|
|
2089
|
+
"subscription, a host whose datastore client predates typing, or a " +
|
|
2090
|
+
"preview with no backend, it returns { typingUserIds: [], setTyping: no-op, " +
|
|
2091
|
+
"status: 'unavailable' }, so render the indicator only when " +
|
|
2092
|
+
"typingUserIds is non-empty. Re-subscribes when tableId or recordId " +
|
|
2093
|
+
"changes. Declare datastore.read for the table.",
|
|
2094
|
+
returnShape: {
|
|
2095
|
+
typingUserIds:
|
|
2096
|
+
"string[] // other users typing in this record; empty when unavailable",
|
|
2097
|
+
setTyping:
|
|
2098
|
+
"(active: boolean) => void // true per keystroke, false on send/clear/blur",
|
|
2099
|
+
status:
|
|
2100
|
+
"'connecting' | 'live' | 'unavailable' // 'unavailable' → hide the indicator",
|
|
2101
|
+
},
|
|
2102
|
+
requiredContextSlice: ["datastore.records"],
|
|
2103
|
+
scopes: ["datastore.read:<table>"],
|
|
2104
|
+
},
|
|
2046
2105
|
// REQ-WSDK-PLATFORM §6 — Tier A SDK hooks. Mirror of contract.js.
|
|
2047
2106
|
{
|
|
2048
2107
|
name: "useClipboard",
|
|
@@ -2888,6 +2947,15 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
2888
2947
|
required: false,
|
|
2889
2948
|
fields: { conversations: "function" },
|
|
2890
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
|
+
},
|
|
2891
2959
|
datastore: {
|
|
2892
2960
|
description:
|
|
2893
2961
|
"Injected @colixsystems/datastore-client instance. " +
|
|
@@ -4919,7 +4987,14 @@ const CONTRACT = deepFreeze({
|
|
|
4919
4987
|
// native-only file keeps every vetted subpath (Metro resolves them).
|
|
4920
4988
|
// New `hostExternalSpecifiers` / `subpathImportRoots` lists and the
|
|
4921
4989
|
// linter export `subpathImportRootsFor(manifest)`.
|
|
4922
|
-
|
|
4990
|
+
// 1.151.0: additive (sc-10538) — new `useTyping(tableId, recordId)` returns
|
|
4991
|
+
// `{ typingUserIds, setTyping, status }`: the other users typing in one
|
|
4992
|
+
// record, over the datastore client's single realtime socket. A host
|
|
4993
|
+
// whose client lacks `records(t).typing` reads `status: "unavailable"`.
|
|
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",
|
|
4923
4998
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4924
4999
|
hooks: HOOKS,
|
|
4925
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
|
|
@@ -3834,6 +4064,71 @@ export function useDatastoreSubscription(table, handlers, options) {
|
|
|
3834
4064
|
return { status };
|
|
3835
4065
|
}
|
|
3836
4066
|
|
|
4067
|
+
const _NO_TYPERS = Object.freeze([]);
|
|
4068
|
+
|
|
4069
|
+
/**
|
|
4070
|
+
* Who else is typing in one record; call `setTyping` per keystroke.
|
|
4071
|
+
* Never throws: a client without typing reports status "unavailable".
|
|
4072
|
+
*
|
|
4073
|
+
* @param {string} tableId
|
|
4074
|
+
* @param {string} recordId
|
|
4075
|
+
* @returns {{ typingUserIds: string[], setTyping: (active: boolean) => void, status: "connecting" | "live" | "unavailable" }}
|
|
4076
|
+
*/
|
|
4077
|
+
export function useTyping(tableId, recordId) {
|
|
4078
|
+
const ctx = useWidgetContextOrThrow("useTyping");
|
|
4079
|
+
const [typingUserIds, setTypingUserIds] = useState(_NO_TYPERS);
|
|
4080
|
+
const [status, setStatus] = useState("connecting");
|
|
4081
|
+
const handleRef = useRef(null);
|
|
4082
|
+
// `ctx` is a fresh identity per host render; the effect keys on the ids.
|
|
4083
|
+
const recordsRef = useRef(null);
|
|
4084
|
+
recordsRef.current =
|
|
4085
|
+
ctx.datastore && typeof ctx.datastore.records === "function"
|
|
4086
|
+
? ctx.datastore.records
|
|
4087
|
+
: null;
|
|
4088
|
+
|
|
4089
|
+
useEffect(() => {
|
|
4090
|
+
setTypingUserIds(_NO_TYPERS);
|
|
4091
|
+
setStatus("connecting");
|
|
4092
|
+
const unavailable = () => {
|
|
4093
|
+
setStatus("unavailable");
|
|
4094
|
+
return undefined;
|
|
4095
|
+
};
|
|
4096
|
+
if (!tableId || !recordId || typeof recordsRef.current !== "function") {
|
|
4097
|
+
return unavailable();
|
|
4098
|
+
}
|
|
4099
|
+
try {
|
|
4100
|
+
const ns = recordsRef.current(tableId);
|
|
4101
|
+
if (!ns || typeof ns.typing !== "function") return unavailable();
|
|
4102
|
+
let current = true;
|
|
4103
|
+
const handle = ns.typing(recordId, {
|
|
4104
|
+
onChange: (ids) => {
|
|
4105
|
+
if (current) setTypingUserIds(ids);
|
|
4106
|
+
},
|
|
4107
|
+
onStatus: (s) => {
|
|
4108
|
+
if (current) setStatus(s);
|
|
4109
|
+
},
|
|
4110
|
+
});
|
|
4111
|
+
handleRef.current = handle;
|
|
4112
|
+
return () => {
|
|
4113
|
+
current = false;
|
|
4114
|
+
handleRef.current = null;
|
|
4115
|
+
if (handle && typeof handle.stop === "function") handle.stop();
|
|
4116
|
+
};
|
|
4117
|
+
} catch (_) {
|
|
4118
|
+
return unavailable();
|
|
4119
|
+
}
|
|
4120
|
+
}, [tableId, recordId]);
|
|
4121
|
+
|
|
4122
|
+
const setTyping = useCallback((active) => {
|
|
4123
|
+
const handle = handleRef.current;
|
|
4124
|
+
if (handle && typeof handle.setTyping === "function") {
|
|
4125
|
+
handle.setTyping(active === true);
|
|
4126
|
+
}
|
|
4127
|
+
}, []);
|
|
4128
|
+
|
|
4129
|
+
return { typingUserIds, setTyping, status };
|
|
4130
|
+
}
|
|
4131
|
+
|
|
3837
4132
|
/* ============================================================================
|
|
3838
4133
|
* ASSETS CLIENT — ctx.assets (@colixsystems/assets-client)
|
|
3839
4134
|
*
|
package/dist/index.d.ts
CHANGED
|
@@ -496,6 +496,14 @@ export interface DatastoreClient {
|
|
|
496
496
|
): Promise<unknown>;
|
|
497
497
|
revoke(permissionId: string): Promise<void>;
|
|
498
498
|
};
|
|
499
|
+
/** REQ-RT-08 — backs `useTyping`; optional, the hook degrades without it. */
|
|
500
|
+
typing?(
|
|
501
|
+
recordId: string,
|
|
502
|
+
handlers: {
|
|
503
|
+
onChange?(typingUserIds: string[]): void;
|
|
504
|
+
onStatus?(status: TypingStatus): void;
|
|
505
|
+
},
|
|
506
|
+
): { setTyping(active: boolean): void; stop(): void };
|
|
499
507
|
};
|
|
500
508
|
}
|
|
501
509
|
|
|
@@ -689,6 +697,8 @@ export interface WidgetContext<TProps = unknown> {
|
|
|
689
697
|
identification: IdentificationClient;
|
|
690
698
|
/** sc-7991 — injected @colixsystems/agents-client backing useAgent(). */
|
|
691
699
|
agents?: { conversations(agentId: string): unknown };
|
|
700
|
+
/** Injected @colixsystems/link-preview-client backing useLinkPreview(). */
|
|
701
|
+
linkPreviews?: { get(url: string): Promise<LinkPreview> };
|
|
692
702
|
/** Host child-node renderer; backs WidgetTree / useChildRenderer. */
|
|
693
703
|
renderer: { renderNode(node: unknown): unknown };
|
|
694
704
|
events: { emit(eventName: string, payload?: unknown): void };
|
|
@@ -1410,6 +1420,27 @@ export function useDatastoreSubscription(
|
|
|
1410
1420
|
options?: DatastoreSubscriptionOptions,
|
|
1411
1421
|
): { status: DatastoreSubscriptionStatus };
|
|
1412
1422
|
|
|
1423
|
+
export type TypingStatus = "connecting" | "live" | "unavailable";
|
|
1424
|
+
|
|
1425
|
+
/**
|
|
1426
|
+
* REQ-RT-08: who else is typing in one record, and announcing your own typing,
|
|
1427
|
+
* via `ctx.datastore.records(tableId).typing(recordId, ...)`. Call
|
|
1428
|
+
* `setTyping(true)` on every keystroke (throttled inside; `false` is sent
|
|
1429
|
+
* after 5 s idle) and `setTyping(false)` on send, clear or blur.
|
|
1430
|
+
* `typingUserIds` lists the other users typing; each drops out 6 s after
|
|
1431
|
+
* their last signal. Needs a signed-in user who can read the record.
|
|
1432
|
+
* Re-subscribes when `tableId` or `recordId` changes. Never throws — a host
|
|
1433
|
+
* whose datastore client lacks typing reports `status: "unavailable"`.
|
|
1434
|
+
*/
|
|
1435
|
+
export function useTyping(
|
|
1436
|
+
tableId: string | null | undefined,
|
|
1437
|
+
recordId: string | null | undefined,
|
|
1438
|
+
): {
|
|
1439
|
+
typingUserIds: string[];
|
|
1440
|
+
setTyping(active: boolean): void;
|
|
1441
|
+
status: TypingStatus;
|
|
1442
|
+
};
|
|
1443
|
+
|
|
1413
1444
|
/**
|
|
1414
1445
|
* Stateful file-asset resolver hook. Returns `{ url, file, loading, error,
|
|
1415
1446
|
* refetch }`. The `url` is an absolute URL composed against the host's API
|
|
@@ -1506,6 +1537,36 @@ export function useTranslate(): {
|
|
|
1506
1537
|
available: boolean;
|
|
1507
1538
|
};
|
|
1508
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
|
+
|
|
1509
1570
|
export interface TranslateOptions {
|
|
1510
1571
|
/** Target language code. Defaults to the app user's selected language. */
|
|
1511
1572
|
target?: string;
|
|
@@ -2169,6 +2230,26 @@ export class AgentError extends Error {
|
|
|
2169
2230
|
constructor(code: string, message: string, opts?: { cause?: unknown });
|
|
2170
2231
|
}
|
|
2171
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
|
+
|
|
2172
2253
|
export class TranslateError extends Error {
|
|
2173
2254
|
code:
|
|
2174
2255
|
| "UNSUPPORTED"
|
package/dist/index.js
CHANGED
|
@@ -60,6 +60,7 @@ export {
|
|
|
60
60
|
IdentificationError,
|
|
61
61
|
useRecordPermissions,
|
|
62
62
|
useDatastoreSubscription,
|
|
63
|
+
useTyping,
|
|
63
64
|
useWidgetEvent,
|
|
64
65
|
useWidgetInput,
|
|
65
66
|
usePayments,
|
|
@@ -70,6 +71,8 @@ export {
|
|
|
70
71
|
useI18n,
|
|
71
72
|
useTranslate,
|
|
72
73
|
TranslateError,
|
|
74
|
+
useLinkPreview,
|
|
75
|
+
LinkPreviewError,
|
|
73
76
|
useUser,
|
|
74
77
|
useUserPhoto,
|
|
75
78
|
useFill,
|
package/dist/index.native.js
CHANGED
|
@@ -60,6 +60,7 @@ export {
|
|
|
60
60
|
IdentificationError,
|
|
61
61
|
useRecordPermissions,
|
|
62
62
|
useDatastoreSubscription,
|
|
63
|
+
useTyping,
|
|
63
64
|
useWidgetEvent,
|
|
64
65
|
useWidgetInput,
|
|
65
66
|
usePayments,
|
|
@@ -70,6 +71,8 @@ export {
|
|
|
70
71
|
useI18n,
|
|
71
72
|
useTranslate,
|
|
72
73
|
TranslateError,
|
|
74
|
+
useLinkPreview,
|
|
75
|
+
LinkPreviewError,
|
|
73
76
|
useUser,
|
|
74
77
|
useUserPhoto,
|
|
75
78
|
useFill,
|
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)",
|
|
@@ -2043,6 +2068,40 @@ const HOOKS = [
|
|
|
2043
2068
|
requiredContextSlice: ["datastore.records"],
|
|
2044
2069
|
scopes: ["datastore.read:<table>"],
|
|
2045
2070
|
},
|
|
2071
|
+
// REQ-RT-08 — typing signals for one record.
|
|
2072
|
+
{
|
|
2073
|
+
name: "useTyping",
|
|
2074
|
+
signature: "useTyping(tableId, recordId)",
|
|
2075
|
+
description:
|
|
2076
|
+
"Who else is typing in one record, and announcing your own typing, via " +
|
|
2077
|
+
"ctx.datastore.records(tableId).typing(recordId, ...). It shares the " +
|
|
2078
|
+
"datastore client's one realtime socket with useDatastoreSubscription. " +
|
|
2079
|
+
"Call setTyping(true) on every keystroke (throttled inside: one signal " +
|
|
2080
|
+
"per 3 s at most, and false is sent after 5 s without a keystroke) and " +
|
|
2081
|
+
"setTyping(false) on send, clear or blur. typingUserIds lists the OTHER " +
|
|
2082
|
+
"users typing (user ids, never yourself); each drops out 6 s after their " +
|
|
2083
|
+
"last signal. Signals carry no message content and are never stored. " +
|
|
2084
|
+
"Typing needs a signed-in user who can read the record; key it on the " +
|
|
2085
|
+
"record the conversation lives in (a thread or channel row). Gate the " +
|
|
2086
|
+
"composer that calls setTyping on useCanWrite as usual. status is " +
|
|
2087
|
+
"'connecting' | 'live' | 'unavailable'. " +
|
|
2088
|
+
"Never throws: with a falsy id, an anonymous visitor, a refused " +
|
|
2089
|
+
"subscription, a host whose datastore client predates typing, or a " +
|
|
2090
|
+
"preview with no backend, it returns { typingUserIds: [], setTyping: no-op, " +
|
|
2091
|
+
"status: 'unavailable' }, so render the indicator only when " +
|
|
2092
|
+
"typingUserIds is non-empty. Re-subscribes when tableId or recordId " +
|
|
2093
|
+
"changes. Declare datastore.read for the table.",
|
|
2094
|
+
returnShape: {
|
|
2095
|
+
typingUserIds:
|
|
2096
|
+
"string[] // other users typing in this record; empty when unavailable",
|
|
2097
|
+
setTyping:
|
|
2098
|
+
"(active: boolean) => void // true per keystroke, false on send/clear/blur",
|
|
2099
|
+
status:
|
|
2100
|
+
"'connecting' | 'live' | 'unavailable' // 'unavailable' → hide the indicator",
|
|
2101
|
+
},
|
|
2102
|
+
requiredContextSlice: ["datastore.records"],
|
|
2103
|
+
scopes: ["datastore.read:<table>"],
|
|
2104
|
+
},
|
|
2046
2105
|
// REQ-WSDK-PLATFORM §6 — Tier A SDK hooks. Mirror of contract.js.
|
|
2047
2106
|
{
|
|
2048
2107
|
name: "useClipboard",
|
|
@@ -2888,6 +2947,15 @@ const WIDGET_CONTEXT_SHAPE = {
|
|
|
2888
2947
|
required: false,
|
|
2889
2948
|
fields: { conversations: "function" },
|
|
2890
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
|
+
},
|
|
2891
2959
|
datastore: {
|
|
2892
2960
|
description:
|
|
2893
2961
|
"Injected @colixsystems/datastore-client instance. " +
|
|
@@ -4919,7 +4987,14 @@ const CONTRACT = deepFreeze({
|
|
|
4919
4987
|
// native-only file keeps every vetted subpath (Metro resolves them).
|
|
4920
4988
|
// New `hostExternalSpecifiers` / `subpathImportRoots` lists and the
|
|
4921
4989
|
// linter export `subpathImportRootsFor(manifest)`.
|
|
4922
|
-
|
|
4990
|
+
// 1.151.0: additive (sc-10538) — new `useTyping(tableId, recordId)` returns
|
|
4991
|
+
// `{ typingUserIds, setTyping, status }`: the other users typing in one
|
|
4992
|
+
// record, over the datastore client's single realtime socket. A host
|
|
4993
|
+
// whose client lacks `records(t).typing` reads `status: "unavailable"`.
|
|
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",
|
|
4923
4998
|
sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
|
|
4924
4999
|
hooks: HOOKS,
|
|
4925
5000
|
primitives: PRIMITIVES,
|