@colixsystems/widget-sdk 0.190.0 → 0.192.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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)",
@@ -2187,6 +2212,64 @@ const HOOKS = [
2187
2212
  requiredContextSlice: [],
2188
2213
  scopes: null,
2189
2214
  },
2215
+ // sc-10590 — host-brokered audio recording + playback. Optional slices; the
2216
+ // hooks report supported:false rather than throwing at render.
2217
+ {
2218
+ name: "useAudioRecorder",
2219
+ signature: "useAudioRecorder()",
2220
+ description:
2221
+ "Record a clip from the microphone. Returns { supported, permission, recording, durationMs, level, error, start, stop, " +
2222
+ "cancel }. Capture is IMPERATIVE — call start() from a user gesture (a tap on a record button); the browser and the " +
2223
+ "mobile OS gate the microphone prompt on a gesture, so it NEVER records on mount. stop() resolves { file, uri, " +
2224
+ "durationMs, mime }, or NULL when nothing was recording; cancel() discards the clip. `file` is already the upload part " +
2225
+ "for the host (a File on web, expo-file-system's File on native) — pass it straight to upload(file) from " +
2226
+ "useFilestoreUpload(). `uri` plays the clip locally through useAudioPlayer(uri) before it is uploaded; the hook " +
2227
+ "releases it on the next start(), on cancel() and on unmount. `permission` is granted | denied | undetermined; " +
2228
+ "`level` is the input level from 0 (silence) to 1, for a live meter. start() and stop() reject with an " +
2229
+ "AudioRecorderError whose .code is one of PERMISSION_DENIED | UNSUPPORTED | INTERNAL. The web records AAC (audio/mp4) " +
2230
+ "where the browser can, otherwise Opus in WebM/Ogg — an Opus or Vorbis clip uploaded to the File library or the asset " +
2231
+ "library by a signed-in user is converted to AAC (audio/mp4, .m4a) on upload so it plays on every host, unless the " +
2232
+ "upload opts out of compression (compress: false) or the clip is over 25 MB or 15 minutes. The Expo export always records AAC. Check " +
2233
+ "`supported` before enabling the record control. Identical on web (MediaRecorder) and the Expo export (expo-audio).",
2234
+ returnShape: {
2235
+ supported: "boolean // false when the host brokers no recorder",
2236
+ permission: "'granted' | 'denied' | 'undetermined'",
2237
+ recording: "boolean",
2238
+ durationMs: "number // length of the current (or last) recording",
2239
+ level: "number // input level 0..1 while recording",
2240
+ error: "AudioRecorderError | null",
2241
+ start: "() => Promise<void> // rejects with AudioRecorderError",
2242
+ stop: "() => Promise<{ file, uri, durationMs, mime } | null> // null if not recording",
2243
+ cancel: "() => void // stop and discard the clip",
2244
+ },
2245
+ requiredContextSlice: [],
2246
+ scopes: null,
2247
+ },
2248
+ {
2249
+ name: "useAudioPlayer",
2250
+ signature: "useAudioPlayer(uri)",
2251
+ description:
2252
+ "Play one audio clip. Returns { supported, loading, playing, positionMs, durationMs, error, play, pause, seek }. `uri` " +
2253
+ "is any playable URL — a stored file's URL (useFilestoreFile(id).url) or the local `uri` a useAudioRecorder() clip " +
2254
+ "resolves; changing it loads the new clip and a falsy uri loads nothing. `durationMs` is null until the host knows the " +
2255
+ "length. When the clip ends, `playing` turns false and `positionMs` returns to 0, so play() replays it. seek(ms) moves " +
2256
+ "the playhead. Call play() from a user gesture — browsers refuse to start audio otherwise. play() and seek() reject " +
2257
+ "with an AudioPlayerError whose .code is one of UNSUPPORTED | LOAD_FAILED | BLOCKED | INTERNAL. Plays MP3, AAC/M4A and " +
2258
+ "WAV on both hosts; WebM/Ogg do not play on iOS. Identical on web (<audio>) and the Expo export (expo-audio).",
2259
+ returnShape: {
2260
+ supported: "boolean // false when the host brokers no player",
2261
+ loading: "boolean // true until the host has loaded the clip",
2262
+ playing: "boolean",
2263
+ positionMs: "number",
2264
+ durationMs: "number | null // null until known",
2265
+ error: "AudioPlayerError | null",
2266
+ play: "() => Promise<void> // rejects with AudioPlayerError",
2267
+ pause: "() => void",
2268
+ seek: "(positionMs) => Promise<void>",
2269
+ },
2270
+ requiredContextSlice: [],
2271
+ scopes: null,
2272
+ },
2190
2273
  // sc-6448 — host-brokered camera capture / photo picking. Optional slice;
2191
2274
  // the hook reports supported:false rather than throwing at render.
2192
2275
  {
@@ -2926,6 +3009,15 @@ const WIDGET_CONTEXT_SHAPE = {
2926
3009
  required: false,
2927
3010
  fields: { conversations: "function" },
2928
3011
  },
3012
+ linkPreviews: {
3013
+ description:
3014
+ "Injected @colixsystems/link-preview-client instance. { get(url) -> Promise<{ url, title, description, site_name, image_url }> }. " +
3015
+ "Backs useLinkPreview(). The server fetches the page; a page with nothing to show resolves with null fields rather than rejecting. " +
3016
+ "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 " +
3017
+ "user previewing on the canvas). Absent on a host that brokers none, where useLinkPreview() degrades to available: false.",
3018
+ required: false,
3019
+ fields: { get: "function" },
3020
+ },
2929
3021
  datastore: {
2930
3022
  description:
2931
3023
  "Injected @colixsystems/datastore-client instance. " +
@@ -3120,10 +3212,17 @@ const WIDGET_CONTEXT_SHAPE = {
3120
3212
  "camera: { isSupported() -> boolean, capture(options?) -> Promise<asset | null>, pick(options?) -> Promise<asset | null> }, " +
3121
3213
  "imageEditor: { isSupported() -> boolean, edit(uri, actions, options?) -> Promise<asset> }, " +
3122
3214
  "barcode: { isSupported() -> boolean, scan(options?) -> Promise<{ value, format } | null> }, " +
3123
- "pdfViewer: { isSupported() -> boolean, open({ fileName, load: () => Promise<ArrayBuffer> }) -> Promise<void> } }. " +
3124
- "Backs useGeolocation(), useSpeechToText(), useCamera(), useImageEditor(), useBarcodeScanner() and usePdfExport().openPdf. The web Player brokers them via " +
3125
- "navigator.geolocation, window.SpeechRecognition, a getUserMedia camera preview, a host-side canvas, BarcodeDetector and a new browser tab; the Expo export via " +
3126
- "expo-location, expo-speech-recognition, expo-image-picker, expo-image-manipulator, expo-camera and expo-print. " +
3215
+ "pdfViewer: { isSupported() -> boolean, open({ fileName, load: () => Promise<ArrayBuffer> }) -> Promise<void> }, " +
3216
+ "audioRecorder: { isSupported() -> boolean, getPermission() -> Promise<'granted' | 'denied' | 'undetermined'>, " +
3217
+ "start() -> Promise<{ getStatus() -> { durationMs, levelDb, ended }, stop() -> Promise<{ file, uri, durationMs, mime, release? }>, cancel() }> }, " +
3218
+ "audioPlayer: { isSupported() -> boolean, load(uri, onStatus({ loaded, playing, positionMs, durationMs, error? })) -> " +
3219
+ "{ play() -> Promise<void>, pause(), seek(ms) -> Promise<void>, release() } } }. " +
3220
+ "Backs useGeolocation(), useSpeechToText(), useCamera(), useImageEditor(), useBarcodeScanner(), usePdfExport().openPdf, useAudioRecorder() and useAudioPlayer(). The web Player brokers them via " +
3221
+ "navigator.geolocation, window.SpeechRecognition, a getUserMedia camera preview, a host-side canvas, BarcodeDetector, a new browser tab, MediaRecorder and an <audio> element; the Expo export via " +
3222
+ "expo-location, expo-speech-recognition, expo-image-picker, expo-image-manipulator, expo-camera, expo-print and expo-audio. " +
3223
+ "audioRecorder.start rejects with .code PERMISSION_DENIED | UNSUPPORTED | INTERNAL; getStatus().levelDb is the input level in dBFS " +
3224
+ "(null when unknown); `ended` is true once the host stopped the recording itself (a lost headset, a call), and stop() then " +
3225
+ "still resolves that clip. audioPlayer reports the end of a clip as playing:false at positionMs 0. " +
3127
3226
  "pdfViewer.open must be CALLED synchronously from the user's press: the web broker opens its tab before awaiting `load` " +
3128
3227
  "(the server render), which is what keeps a popup blocker from refusing it; the Expo export writes the bytes to the " +
3129
3228
  "cache and hands them to the OS print sheet. " +
@@ -3155,6 +3254,8 @@ const WIDGET_CONTEXT_SHAPE = {
3155
3254
  imageEditor: "object",
3156
3255
  barcode: "object",
3157
3256
  pdfViewer: "object",
3257
+ audioRecorder: "object",
3258
+ audioPlayer: "object",
3158
3259
  },
3159
3260
  },
3160
3261
  };
@@ -3346,7 +3447,8 @@ const VETTED_IMPORTS = [
3346
3447
  platforms: ["native"],
3347
3448
  category: "media",
3348
3449
  description:
3349
- "Native audio playback (Expo SDK 56; replaces the removed expo-av). Native-only; pair with the browser <audio> element in widget.web.jsx.",
3450
+ "Native audio playback (Expo SDK 56; replaces the removed expo-av). Native-only; pair with the browser <audio> element in widget.web.jsx. " +
3451
+ "To record or play a clip on BOTH hosts, prefer the SDK's useAudioRecorder() / useAudioPlayer(uri) — no platform split needed.",
3350
3452
  },
3351
3453
  {
3352
3454
  specifier: "expo-video",
@@ -4961,7 +5063,14 @@ const CONTRACT = deepFreeze({
4961
5063
  // `{ typingUserIds, setTyping, status }`: the other users typing in one
4962
5064
  // record, over the datastore client's single realtime socket. A host
4963
5065
  // whose client lacks `records(t).typing` reads `status: "unavailable"`.
4964
- version: "1.151.0",
5066
+ // 1.152.0: additive (sc-10580) — new `useLinkPreview(url)` returns
5067
+ // `{ preview, loading, error, available }` from the new optional
5068
+ // `linkPreviews` slice (@colixsystems/link-preview-client).
5069
+ // 1.153.0: additive (sc-10590) — new `useAudioRecorder()` and
5070
+ // `useAudioPlayer(uri)`, backed by the optional `device.audioRecorder` /
5071
+ // `device.audioPlayer` slices (MediaRecorder + <audio> on web, expo-audio
5072
+ // on native), with `AudioRecorderError` / `AudioPlayerError`.
5073
+ version: "1.153.0",
4965
5074
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4966
5075
  hooks: HOOKS,
4967
5076
  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)",
@@ -2187,6 +2212,64 @@ const HOOKS = [
2187
2212
  requiredContextSlice: [],
2188
2213
  scopes: null,
2189
2214
  },
2215
+ // sc-10590 — host-brokered audio recording + playback. Optional slices; the
2216
+ // hooks report supported:false rather than throwing at render.
2217
+ {
2218
+ name: "useAudioRecorder",
2219
+ signature: "useAudioRecorder()",
2220
+ description:
2221
+ "Record a clip from the microphone. Returns { supported, permission, recording, durationMs, level, error, start, stop, " +
2222
+ "cancel }. Capture is IMPERATIVE — call start() from a user gesture (a tap on a record button); the browser and the " +
2223
+ "mobile OS gate the microphone prompt on a gesture, so it NEVER records on mount. stop() resolves { file, uri, " +
2224
+ "durationMs, mime }, or NULL when nothing was recording; cancel() discards the clip. `file` is already the upload part " +
2225
+ "for the host (a File on web, expo-file-system's File on native) — pass it straight to upload(file) from " +
2226
+ "useFilestoreUpload(). `uri` plays the clip locally through useAudioPlayer(uri) before it is uploaded; the hook " +
2227
+ "releases it on the next start(), on cancel() and on unmount. `permission` is granted | denied | undetermined; " +
2228
+ "`level` is the input level from 0 (silence) to 1, for a live meter. start() and stop() reject with an " +
2229
+ "AudioRecorderError whose .code is one of PERMISSION_DENIED | UNSUPPORTED | INTERNAL. The web records AAC (audio/mp4) " +
2230
+ "where the browser can, otherwise Opus in WebM/Ogg — an Opus or Vorbis clip uploaded to the File library or the asset " +
2231
+ "library by a signed-in user is converted to AAC (audio/mp4, .m4a) on upload so it plays on every host, unless the " +
2232
+ "upload opts out of compression (compress: false) or the clip is over 25 MB or 15 minutes. The Expo export always records AAC. Check " +
2233
+ "`supported` before enabling the record control. Identical on web (MediaRecorder) and the Expo export (expo-audio).",
2234
+ returnShape: {
2235
+ supported: "boolean // false when the host brokers no recorder",
2236
+ permission: "'granted' | 'denied' | 'undetermined'",
2237
+ recording: "boolean",
2238
+ durationMs: "number // length of the current (or last) recording",
2239
+ level: "number // input level 0..1 while recording",
2240
+ error: "AudioRecorderError | null",
2241
+ start: "() => Promise<void> // rejects with AudioRecorderError",
2242
+ stop: "() => Promise<{ file, uri, durationMs, mime } | null> // null if not recording",
2243
+ cancel: "() => void // stop and discard the clip",
2244
+ },
2245
+ requiredContextSlice: [],
2246
+ scopes: null,
2247
+ },
2248
+ {
2249
+ name: "useAudioPlayer",
2250
+ signature: "useAudioPlayer(uri)",
2251
+ description:
2252
+ "Play one audio clip. Returns { supported, loading, playing, positionMs, durationMs, error, play, pause, seek }. `uri` " +
2253
+ "is any playable URL — a stored file's URL (useFilestoreFile(id).url) or the local `uri` a useAudioRecorder() clip " +
2254
+ "resolves; changing it loads the new clip and a falsy uri loads nothing. `durationMs` is null until the host knows the " +
2255
+ "length. When the clip ends, `playing` turns false and `positionMs` returns to 0, so play() replays it. seek(ms) moves " +
2256
+ "the playhead. Call play() from a user gesture — browsers refuse to start audio otherwise. play() and seek() reject " +
2257
+ "with an AudioPlayerError whose .code is one of UNSUPPORTED | LOAD_FAILED | BLOCKED | INTERNAL. Plays MP3, AAC/M4A and " +
2258
+ "WAV on both hosts; WebM/Ogg do not play on iOS. Identical on web (<audio>) and the Expo export (expo-audio).",
2259
+ returnShape: {
2260
+ supported: "boolean // false when the host brokers no player",
2261
+ loading: "boolean // true until the host has loaded the clip",
2262
+ playing: "boolean",
2263
+ positionMs: "number",
2264
+ durationMs: "number | null // null until known",
2265
+ error: "AudioPlayerError | null",
2266
+ play: "() => Promise<void> // rejects with AudioPlayerError",
2267
+ pause: "() => void",
2268
+ seek: "(positionMs) => Promise<void>",
2269
+ },
2270
+ requiredContextSlice: [],
2271
+ scopes: null,
2272
+ },
2190
2273
  // sc-6448 — host-brokered camera capture / photo picking. Optional slice;
2191
2274
  // the hook reports supported:false rather than throwing at render.
2192
2275
  {
@@ -2922,6 +3005,15 @@ const WIDGET_CONTEXT_SHAPE = {
2922
3005
  required: false,
2923
3006
  fields: { conversations: "function" },
2924
3007
  },
3008
+ linkPreviews: {
3009
+ description:
3010
+ "Injected @colixsystems/link-preview-client instance. { get(url) -> Promise<{ url, title, description, site_name, image_url }> }. " +
3011
+ "Backs useLinkPreview(). The server fetches the page; a page with nothing to show resolves with null fields rather than rejecting. " +
3012
+ "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 " +
3013
+ "user previewing on the canvas). Absent on a host that brokers none, where useLinkPreview() degrades to available: false.",
3014
+ required: false,
3015
+ fields: { get: "function" },
3016
+ },
2925
3017
  datastore: {
2926
3018
  description:
2927
3019
  "Injected @colixsystems/datastore-client instance. " +
@@ -3116,10 +3208,17 @@ const WIDGET_CONTEXT_SHAPE = {
3116
3208
  "camera: { isSupported() -> boolean, capture(options?) -> Promise<asset | null>, pick(options?) -> Promise<asset | null> }, " +
3117
3209
  "imageEditor: { isSupported() -> boolean, edit(uri, actions, options?) -> Promise<asset> }, " +
3118
3210
  "barcode: { isSupported() -> boolean, scan(options?) -> Promise<{ value, format } | null> }, " +
3119
- "pdfViewer: { isSupported() -> boolean, open({ fileName, load: () => Promise<ArrayBuffer> }) -> Promise<void> } }. " +
3120
- "Backs useGeolocation(), useSpeechToText(), useCamera(), useImageEditor(), useBarcodeScanner() and usePdfExport().openPdf. The web Player brokers them via " +
3121
- "navigator.geolocation, window.SpeechRecognition, a getUserMedia camera preview, a host-side canvas, BarcodeDetector and a new browser tab; the Expo export via " +
3122
- "expo-location, expo-speech-recognition, expo-image-picker, expo-image-manipulator, expo-camera and expo-print. " +
3211
+ "pdfViewer: { isSupported() -> boolean, open({ fileName, load: () => Promise<ArrayBuffer> }) -> Promise<void> }, " +
3212
+ "audioRecorder: { isSupported() -> boolean, getPermission() -> Promise<'granted' | 'denied' | 'undetermined'>, " +
3213
+ "start() -> Promise<{ getStatus() -> { durationMs, levelDb, ended }, stop() -> Promise<{ file, uri, durationMs, mime, release? }>, cancel() }> }, " +
3214
+ "audioPlayer: { isSupported() -> boolean, load(uri, onStatus({ loaded, playing, positionMs, durationMs, error? })) -> " +
3215
+ "{ play() -> Promise<void>, pause(), seek(ms) -> Promise<void>, release() } } }. " +
3216
+ "Backs useGeolocation(), useSpeechToText(), useCamera(), useImageEditor(), useBarcodeScanner(), usePdfExport().openPdf, useAudioRecorder() and useAudioPlayer(). The web Player brokers them via " +
3217
+ "navigator.geolocation, window.SpeechRecognition, a getUserMedia camera preview, a host-side canvas, BarcodeDetector, a new browser tab, MediaRecorder and an <audio> element; the Expo export via " +
3218
+ "expo-location, expo-speech-recognition, expo-image-picker, expo-image-manipulator, expo-camera, expo-print and expo-audio. " +
3219
+ "audioRecorder.start rejects with .code PERMISSION_DENIED | UNSUPPORTED | INTERNAL; getStatus().levelDb is the input level in dBFS " +
3220
+ "(null when unknown); `ended` is true once the host stopped the recording itself (a lost headset, a call), and stop() then " +
3221
+ "still resolves that clip. audioPlayer reports the end of a clip as playing:false at positionMs 0. " +
3123
3222
  "pdfViewer.open must be CALLED synchronously from the user's press: the web broker opens its tab before awaiting `load` " +
3124
3223
  "(the server render), which is what keeps a popup blocker from refusing it; the Expo export writes the bytes to the " +
3125
3224
  "cache and hands them to the OS print sheet. " +
@@ -3151,6 +3250,8 @@ const WIDGET_CONTEXT_SHAPE = {
3151
3250
  imageEditor: "object",
3152
3251
  barcode: "object",
3153
3252
  pdfViewer: "object",
3253
+ audioRecorder: "object",
3254
+ audioPlayer: "object",
3154
3255
  },
3155
3256
  },
3156
3257
  };
@@ -3342,7 +3443,8 @@ const VETTED_IMPORTS = [
3342
3443
  platforms: ["native"],
3343
3444
  category: "media",
3344
3445
  description:
3345
- "Native audio playback (Expo SDK 56; replaces the removed expo-av). Native-only; pair with the browser <audio> element in widget.web.jsx.",
3446
+ "Native audio playback (Expo SDK 56; replaces the removed expo-av). Native-only; pair with the browser <audio> element in widget.web.jsx. " +
3447
+ "To record or play a clip on BOTH hosts, prefer the SDK's useAudioRecorder() / useAudioPlayer(uri) — no platform split needed.",
3346
3448
  },
3347
3449
  {
3348
3450
  specifier: "expo-video",
@@ -4957,7 +5059,14 @@ const CONTRACT = deepFreeze({
4957
5059
  // `{ typingUserIds, setTyping, status }`: the other users typing in one
4958
5060
  // record, over the datastore client's single realtime socket. A host
4959
5061
  // whose client lacks `records(t).typing` reads `status: "unavailable"`.
4960
- version: "1.151.0",
5062
+ // 1.152.0: additive (sc-10580) — new `useLinkPreview(url)` returns
5063
+ // `{ preview, loading, error, available }` from the new optional
5064
+ // `linkPreviews` slice (@colixsystems/link-preview-client).
5065
+ // 1.153.0: additive (sc-10590) — new `useAudioRecorder()` and
5066
+ // `useAudioPlayer(uri)`, backed by the optional `device.audioRecorder` /
5067
+ // `device.audioPlayer` slices (MediaRecorder + <audio> on web, expo-audio
5068
+ // on native), with `AudioRecorderError` / `AudioPlayerError`.
5069
+ version: "1.153.0",
4961
5070
  sharedTranslationKeys: SHARED_TRANSLATION_KEYS,
4962
5071
  hooks: HOOKS,
4963
5072
  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