@farcaster/snap 2.9.0 → 2.10.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.
Files changed (37) hide show
  1. package/dist/index.d.ts +1 -1
  2. package/dist/index.js +1 -1
  3. package/dist/react/catalog-renderer.d.ts +5 -5
  4. package/dist/react/catalog-renderer.js +16 -4
  5. package/dist/react/components/action-button.js +23 -5
  6. package/dist/react/index.d.ts +2 -1
  7. package/dist/react/snap-view-core.js +90 -25
  8. package/dist/react/v1/snap-view.js +1 -1
  9. package/dist/react/v2/snap-view.js +1 -1
  10. package/dist/react-native/components/snap-action-button.js +6 -1
  11. package/dist/react-native/snap-view-core.js +77 -24
  12. package/dist/react-native/types.d.ts +2 -1
  13. package/dist/render-state.d.ts +9 -0
  14. package/dist/render-state.js +27 -0
  15. package/dist/schemas.d.ts +123 -3
  16. package/dist/schemas.js +53 -2
  17. package/dist/server/parseRequest.js +19 -3
  18. package/dist/ui/button.d.ts +1 -0
  19. package/dist/ui/button.js +1 -0
  20. package/dist/ui/catalog.d.ts +13 -0
  21. package/dist/ui/catalog.js +15 -8
  22. package/package.json +1 -1
  23. package/src/index.ts +7 -0
  24. package/src/react/catalog-renderer.tsx +57 -3
  25. package/src/react/components/action-button.tsx +32 -3
  26. package/src/react/index.tsx +4 -1
  27. package/src/react/snap-view-core.tsx +144 -27
  28. package/src/react/v1/snap-view.tsx +1 -0
  29. package/src/react/v2/snap-view.tsx +1 -0
  30. package/src/react-native/components/snap-action-button.tsx +6 -1
  31. package/src/react-native/snap-view-core.tsx +114 -27
  32. package/src/react-native/types.ts +4 -1
  33. package/src/render-state.ts +46 -0
  34. package/src/schemas.ts +73 -2
  35. package/src/server/parseRequest.ts +37 -6
  36. package/src/ui/button.ts +1 -0
  37. package/src/ui/catalog.ts +16 -8
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type { Spec as SnapSpec, UIElement as SnapUIElement, } from "@json-render/core";
2
2
  export { SPEC_VERSION, SPEC_VERSION_1, SPEC_VERSION_2, SUPPORTED_SPEC_VERSIONS, type SpecVersion, SNAP_PAYLOAD_HEADER, MEDIA_TYPE, EFFECT_VALUES, POST_GRID_TAP_KEY, MAX_ELEMENTS, MAX_ROOT_CHILDREN, MAX_CHILDREN, MAX_DEPTH, } from "./constants.js";
3
3
  export { DEFAULT_THEME_ACCENT, PALETTE_COLOR, PALETTE_COLOR_ACCENT, PALETTE_COLOR_VALUES, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, isSnapHexColorString, readableTextOnHex, resolveSnapColorHex, type PaletteColor, } from "./colors.js";
4
- export { ACTION_TYPE_GET, ACTION_TYPE_POST, snapResponseSchema, payloadSchema, getPayloadSchema, type SnapAction, type SnapGetAction, type SnapContext, type SnapResponse, type SnapHandlerResult, type SnapElementInput, type SnapSpecInput, type SnapFunction, type SnapPayload, type SnapGetPayload, } from "./schemas.js";
4
+ export { ACTION_TYPE_GET, ACTION_TYPE_POST, ACTION_TYPE_TRANSACTION_RESULT, snapResponseSchema, payloadSchema, getPayloadSchema, transactionResultPayloadSchema, snapTransactionResultSchema, type SnapAction, type SnapGetAction, type SnapTransactionResultAction, type SnapContext, type SnapResponse, type SnapHandlerResult, type SnapElementInput, type SnapSpecInput, type SnapFunction, type SnapPayload, type SnapGetPayload, type SnapSendTransactionParams, type SnapTransactionResult, type SnapTransactionResultPayload, } from "./schemas.js";
5
5
  export { validateSnapResponse, type ValidationResult } from "./validator.js";
6
6
  export type { SnapRenderState } from "./render-state.js";
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { SPEC_VERSION, SPEC_VERSION_1, SPEC_VERSION_2, SUPPORTED_SPEC_VERSIONS, SNAP_PAYLOAD_HEADER, MEDIA_TYPE, EFFECT_VALUES, POST_GRID_TAP_KEY, MAX_ELEMENTS, MAX_ROOT_CHILDREN, MAX_CHILDREN, MAX_DEPTH, } from "./constants.js";
2
2
  export { DEFAULT_THEME_ACCENT, PALETTE_COLOR, PALETTE_COLOR_ACCENT, PALETTE_COLOR_VALUES, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, isSnapHexColorString, readableTextOnHex, resolveSnapColorHex, } from "./colors.js";
3
- export { ACTION_TYPE_GET, ACTION_TYPE_POST, snapResponseSchema, payloadSchema, getPayloadSchema, } from "./schemas.js";
3
+ export { ACTION_TYPE_GET, ACTION_TYPE_POST, ACTION_TYPE_TRANSACTION_RESULT, snapResponseSchema, payloadSchema, getPayloadSchema, transactionResultPayloadSchema, snapTransactionResultSchema, } from "./schemas.js";
4
4
  export { validateSnapResponse } from "./validator.js";
@@ -1,5 +1,5 @@
1
- /**
2
- * Maps snap json-render catalog types to React components.
3
- * Keys match the snap wire-format `type` strings exactly.
4
- */
5
- export declare const SnapCatalogView: import("react").ComponentType<import("@json-render/react").CreateRendererProps>;
1
+ import { type CreateRendererProps } from "@json-render/react";
2
+ import { type ReactNode } from "react";
3
+ export declare function SnapCatalogView({ spec, store, state, onAction, onStateChange, functions, loading, fallback, children, }: CreateRendererProps & {
4
+ children?: ReactNode;
5
+ }): import("react/jsx-runtime").JSX.Element;
@@ -1,6 +1,7 @@
1
1
  "use client";
2
- import { createRenderer } from "@json-render/react";
3
- import { snapJsonRenderCatalog } from "@farcaster/snap/ui";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { JSONUIProvider, Renderer, } from "@json-render/react";
4
+ import { useMemo } from "react";
4
5
  import { SnapActionButton } from "./components/action-button.js";
5
6
  import { SnapBadge } from "./components/badge.js";
6
7
  import { SnapIcon } from "./components/icon.js";
@@ -22,7 +23,7 @@ import { SnapCellGrid } from "./components/cell-grid.js";
22
23
  * Maps snap json-render catalog types to React components.
23
24
  * Keys match the snap wire-format `type` strings exactly.
24
25
  */
25
- export const SnapCatalogView = createRenderer(snapJsonRenderCatalog, {
26
+ const snapCatalogRegistry = {
26
27
  badge: SnapBadge,
27
28
  button: SnapActionButton,
28
29
  icon: SnapIcon,
@@ -40,4 +41,15 @@ export const SnapCatalogView = createRenderer(snapJsonRenderCatalog, {
40
41
  toggle_group: SnapToggleGroup,
41
42
  bar_chart: SnapBarChart,
42
43
  cell_grid: SnapCellGrid,
43
- });
44
+ };
45
+ export function SnapCatalogView({ spec, store, state, onAction, onStateChange, functions, loading, fallback, children, }) {
46
+ const actionHandlers = useMemo(() => onAction
47
+ ? new Proxy({}, {
48
+ get: (_target, prop) => {
49
+ return (params) => onAction(String(prop), params);
50
+ },
51
+ has: () => true,
52
+ })
53
+ : undefined, [onAction]);
54
+ return (_jsxs(JSONUIProvider, { registry: snapCatalogRegistry, store: store, initialState: state, handlers: actionHandlers, functions: functions, onStateChange: onStateChange, children: [_jsx(Renderer, { spec: spec, registry: snapCatalogRegistry, loading: loading, fallback: fallback }), children] }));
55
+ }
@@ -1,12 +1,13 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useState } from "react";
4
- import { useStateStore } from "@json-render/react";
3
+ import { useMemo, useState } from "react";
4
+ import { useStateStore, useStateValue } from "@json-render/react";
5
5
  import { ExternalLink } from "lucide-react";
6
6
  import { Button } from "@neynar/ui/button";
7
7
  import { cn } from "@neynar/ui/utils";
8
8
  import { useSnapColors } from "../hooks/use-snap-colors.js";
9
9
  import { getPaginatorAction, runPaginatorAction, } from "../../ui/paginator-state.js";
10
+ import { buildActionActivityStateChanges } from "../../render-state.js";
10
11
  import { useSnapStackDirection } from "../stack-direction-context.js";
11
12
  import { ICON_MAP } from "./icon.js";
12
13
  function isExternalLinkAction(on) {
@@ -17,21 +18,36 @@ function isExternalLinkAction(on) {
17
18
  return false;
18
19
  return press.action === "open_url";
19
20
  }
21
+ function getActionPendingPath(on) {
22
+ const press = on?.press;
23
+ if (!press?.action)
24
+ return "/__snap/action/pending";
25
+ return (buildActionActivityStateChanges({
26
+ actionName: press.action,
27
+ params: press.params ?? {},
28
+ pending: true,
29
+ }).find((change) => change.path.endsWith("/pending"))?.path ??
30
+ "/__snap/action/pending");
31
+ }
20
32
  export function SnapActionButton({ element, emit, }) {
21
33
  const { props } = element;
22
34
  const label = String(props.label ?? "Action");
23
35
  const variant = String(props.variant ?? "secondary");
24
36
  const isPrimary = variant === "primary";
37
+ const disabled = props.disabled === true;
25
38
  const iconName = props.icon ? String(props.icon) : undefined;
26
39
  const colors = useSnapColors();
27
40
  const [hovered, setHovered] = useState(false);
28
41
  const stateStore = useStateStore();
29
42
  const paginatorAction = getPaginatorAction(element.on);
43
+ const actionPendingPath = useMemo(() => getActionPendingPath(element.on), [element.on]);
44
+ const actionPending = useStateValue(actionPendingPath) === true;
30
45
  const Icon = iconName ? ICON_MAP[iconName] : undefined;
31
46
  const showExternalIcon = isExternalLinkAction(element.on);
32
47
  const inHorizontalStack = useSnapStackDirection() === "horizontal";
33
48
  const style = {
34
- cursor: "pointer",
49
+ cursor: disabled ? "not-allowed" : "pointer",
50
+ opacity: disabled ? 0.62 : 1,
35
51
  ...(isPrimary
36
52
  ? {
37
53
  backgroundColor: hovered ? colors.accentHover : colors.accent,
@@ -54,9 +70,11 @@ export function SnapActionButton({ element, emit, }) {
54
70
  */
55
71
  _jsx("div", { className: inHorizontalStack
56
72
  ? "min-w-0 flex-auto"
57
- : "w-full min-w-0", style: inHorizontalStack ? { flex: "1 1 auto" } : undefined, children: _jsxs(Button, { type: "button", variant: isPrimary ? "default" : "secondary", className: cn("h-8 w-full gap-2 px-3 text-sm"), style: style, onClick: () => {
73
+ : "w-full min-w-0", style: inHorizontalStack ? { flex: "1 1 auto" } : undefined, children: _jsxs(Button, { type: "button", variant: isPrimary ? "default" : "secondary", className: cn("h-8 w-full gap-2 px-3 text-sm"), disabled: disabled, style: style, onClick: () => {
74
+ if (disabled)
75
+ return;
58
76
  if (!runPaginatorAction(stateStore, paginatorAction)) {
59
77
  emit("press");
60
78
  }
61
- }, onPointerEnter: () => setHovered(true), onPointerLeave: () => setHovered(false), children: [Icon && _jsx(Icon, { size: 16 }), label, showExternalIcon && (_jsx(ExternalLink, { size: 14, style: { opacity: 0.6 } }))] }) }));
79
+ }, onPointerEnter: () => setHovered(true), onPointerLeave: () => setHovered(false), children: [Icon && _jsx(Icon, { size: 16 }), label, actionPending && (_jsx("span", { "data-snap-action-pending-active": "true", hidden: true })), showExternalIcon && (_jsx(ExternalLink, { size: 14, style: { opacity: 0.6 } }))] }) }));
62
80
  }
@@ -2,6 +2,7 @@ import type { Spec } from "@json-render/core";
2
2
  import type { ReactNode } from "react";
3
3
  import type { ValidationResult } from "../validator.js";
4
4
  import type { SnapRenderState } from "../render-state.js";
5
+ import type { SnapTransactionResult } from "../schemas.js";
5
6
  export type JsonValue = string | number | boolean | null | JsonValue[] | {
6
7
  [key: string]: JsonValue;
7
8
  };
@@ -55,7 +56,7 @@ export type SnapActionHandlers = {
55
56
  sellToken?: string;
56
57
  buyToken?: string;
57
58
  }) => void;
58
- send_transaction?: (params: SnapSendTransactionParams) => void;
59
+ send_transaction?: (params: SnapSendTransactionParams) => void | Promise<void | SnapTransactionResult>;
59
60
  };
60
61
  export type { SnapRenderState };
61
62
  export declare function SnapCard({ snap, handlers, loading, appearance, maxWidth, showOverflowWarning, onValidationError, validationErrorFallback, actionError, plain, loadingOverlay, initialRenderState, onRenderStateChange, }: {
@@ -1,12 +1,13 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { createStateStore } from "@json-render/react";
3
4
  import { snapJsonRenderCatalog } from "../ui/index.js";
4
5
  import { SnapCatalogView } from "./catalog-renderer.js";
5
6
  import { SnapPreviewAccentProvider } from "./accent-context.js";
6
7
  import { SnapVersionProvider } from "./snap-version-context.js";
7
8
  import { resolveSnapPaletteHex } from "./lib/resolve-palette-hex.js";
8
9
  import { snapPreviewPrimaryCssProperties } from "./lib/preview-primary-css.js";
9
- import { applyStatePaths, buildInitialRenderState, cloneSnapRenderState, getUnpresentedSnapEffects, markSnapEffectsPresented, } from "../render-state.js";
10
+ import { buildActionActivityStateChanges, buildInitialRenderState, cloneSnapRenderState, getUnpresentedSnapEffects, hasPendingSnapAction, markSnapEffectsPresented, } from "../render-state.js";
10
11
  import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
11
12
  function asRecord(value) {
12
13
  return value && typeof value === "object"
@@ -16,6 +17,11 @@ function asRecord(value) {
16
17
  function optionalString(value) {
17
18
  return value ? String(value) : undefined;
18
19
  }
20
+ function recordValue(value) {
21
+ return value && typeof value === "object" && !Array.isArray(value)
22
+ ? value
23
+ : undefined;
24
+ }
19
25
  function withDefaultElementProps(spec) {
20
26
  if (!spec || typeof spec !== "object" || !("elements" in spec))
21
27
  return spec;
@@ -165,7 +171,7 @@ export function SnapLoadingOverlay({ appearance, accentHex, active, }) {
165
171
  const trackColor = isDark
166
172
  ? "rgba(255, 255, 255, 0.12)"
167
173
  : "rgba(15, 23, 42, 0.1)";
168
- return (_jsxs("div", { style: {
174
+ return (_jsxs("div", { "data-snap-loading-overlay": true, "data-snap-loading-active": active ? "true" : "false", style: {
169
175
  position: "absolute",
170
176
  inset: 0,
171
177
  display: "flex",
@@ -188,6 +194,20 @@ export function SnapLoadingOverlay({ appearance, accentHex, active, }) {
188
194
  animation: "snapViewSpin 0.75s linear infinite",
189
195
  flexShrink: 0,
190
196
  } }), _jsx("style", { children: `
197
+ [data-snap-view-root]:has([data-snap-action-pending-active="true"])
198
+ [data-snap-loading-overlay] {
199
+ opacity: 1 !important;
200
+ pointer-events: auto !important;
201
+ backdrop-filter: blur(10px) saturate(1.05) !important;
202
+ -webkit-backdrop-filter: blur(10px) saturate(1.05) !important;
203
+ }
204
+ [data-snap-card-surface]:has([data-snap-action-pending-active="true"])
205
+ > [data-snap-loading-overlay] {
206
+ opacity: 1 !important;
207
+ pointer-events: auto !important;
208
+ backdrop-filter: blur(10px) saturate(1.05) !important;
209
+ -webkit-backdrop-filter: blur(10px) saturate(1.05) !important;
210
+ }
191
211
  @keyframes snapViewSpin {
192
212
  to { transform: rotate(360deg); }
193
213
  }
@@ -200,6 +220,9 @@ export function SnapLoadingOverlay({ appearance, accentHex, active, }) {
200
220
  }
201
221
  ` })] }));
202
222
  }
223
+ function SnapPendingActionOverlay({ appearance, accentHex, }) {
224
+ return (_jsx(SnapLoadingOverlay, { appearance: appearance, accentHex: accentHex, active: false }));
225
+ }
203
226
  const PALETTE = [
204
227
  "gray",
205
228
  "blue",
@@ -219,10 +242,23 @@ export function SnapViewCore({ snap, handlers, loading = false, appearance = "da
219
242
  initialRenderState,
220
243
  themeAccent: snap.theme?.accent,
221
244
  }), [initialRenderState, spec.state, snap.theme?.accent]);
245
+ const stateStore = useMemo(() => createStateStore(initialState), [
246
+ initialState,
247
+ ]);
222
248
  const stateRef = useRef(initialState);
249
+ const onRenderStateChangeRef = useRef(onRenderStateChange);
250
+ const pendingActionCountRef = useRef(0);
223
251
  useEffect(() => {
224
252
  stateRef.current = cloneSnapRenderState(initialState);
225
253
  }, [initialState]);
254
+ useEffect(() => {
255
+ onRenderStateChangeRef.current = onRenderStateChange;
256
+ }, [onRenderStateChange]);
257
+ useEffect(() => stateStore.subscribe(() => {
258
+ const snapshot = cloneSnapRenderState(stateStore.getSnapshot());
259
+ stateRef.current = snapshot;
260
+ onRenderStateChangeRef.current?.(snapshot);
261
+ }), [stateStore]);
226
262
  useEffect(() => {
227
263
  const catalogResult = snapJsonRenderCatalog.validate(spec);
228
264
  if (!catalogResult.success) {
@@ -242,10 +278,6 @@ export function SnapViewCore({ snap, handlers, loading = false, appearance = "da
242
278
  confetti: 0,
243
279
  fireworks: 0,
244
280
  });
245
- const onRenderStateChangeRef = useRef(onRenderStateChange);
246
- useEffect(() => {
247
- onRenderStateChangeRef.current = onRenderStateChange;
248
- }, [onRenderStateChange]);
249
281
  useEffect(() => {
250
282
  const effectsToPresent = getUnpresentedSnapEffects(stateRef.current, snapEffects);
251
283
  if (effectsToPresent.length === 0) {
@@ -262,7 +294,10 @@ export function SnapViewCore({ snap, handlers, loading = false, appearance = "da
262
294
  return;
263
295
  }
264
296
  if (markSnapEffectsPresented(stateRef.current, effectsToPresent)) {
265
- onRenderStateChangeRef.current?.(cloneSnapRenderState(stateRef.current));
297
+ const meta = recordValue(stateRef.current.__snapRender);
298
+ stateStore.update({
299
+ "/__snapRender/presentedEffects": meta?.presentedEffects ?? [],
300
+ });
266
301
  }
267
302
  setEffectRunKeys((current) => ({
268
303
  confetti: effectsToPresent.includes("confetti")
@@ -276,7 +311,7 @@ export function SnapViewCore({ snap, handlers, loading = false, appearance = "da
276
311
  ? current.fireworks
277
312
  : 0,
278
313
  }));
279
- }, [initialState, showConfetti, showFireworks, snapEffects]);
314
+ }, [initialState, showConfetti, showFireworks, snapEffects, stateStore]);
280
315
  const accentName = snap.theme?.accent ?? "purple";
281
316
  const accentHex = useMemo(() => resolveSnapPaletteHex(accentName, appearance), [accentName, appearance]);
282
317
  const previewSurfaceStyle = useMemo(() => {
@@ -288,33 +323,52 @@ export function SnapViewCore({ snap, handlers, loading = false, appearance = "da
288
323
  ...vars,
289
324
  };
290
325
  }, [accentName, appearance]);
326
+ const applyActionActivityState = useCallback((name, params, pending) => {
327
+ stateStore.update(Object.fromEntries(buildActionActivityStateChanges({
328
+ actionName: name,
329
+ params,
330
+ pending,
331
+ }).map(({ path, value }) => [path, value])));
332
+ }, [stateStore]);
333
+ const setActionPending = useCallback((name, params) => {
334
+ pendingActionCountRef.current += 1;
335
+ applyActionActivityState(name, params, true);
336
+ }, [applyActionActivityState]);
337
+ const setActionSettled = useCallback((name, params) => {
338
+ pendingActionCountRef.current = Math.max(0, pendingActionCountRef.current - 1);
339
+ applyActionActivityState(name, params, false);
340
+ }, [applyActionActivityState]);
291
341
  const handleAction = useCallback((name, params) => {
292
342
  const inputs = (stateRef.current.inputs ?? {});
293
343
  const p = (params ?? {});
344
+ let result;
345
+ setActionPending(name, p);
294
346
  switch (name) {
295
347
  case "submit":
296
- handlers.submit(String(p.target ?? ""), inputs);
348
+ result = handlers.submit(String(p.target ?? ""), inputs);
297
349
  break;
298
350
  case "open_url":
299
- handlers.open_url(String(p.target ?? ""));
351
+ result = handlers.open_url(String(p.target ?? ""));
300
352
  break;
301
353
  case "open_snap":
302
- handlers.open_snap(String(p.target ?? ""));
354
+ result = handlers.open_snap(String(p.target ?? ""));
303
355
  break;
304
356
  case "open_mini_app":
305
- handlers.open_mini_app(String(p.target ?? ""));
357
+ result = handlers.open_mini_app(String(p.target ?? ""));
306
358
  break;
307
359
  case "view_cast":
308
- handlers.view_cast({ hash: String(p.hash ?? "") });
360
+ result = handlers.view_cast({ hash: String(p.hash ?? "") });
309
361
  break;
310
362
  case "view_profile":
311
- handlers.view_profile({ fid: Number(p.fid ?? 0) });
363
+ result = handlers.view_profile({ fid: Number(p.fid ?? 0) });
312
364
  break;
313
365
  case "view_channel":
314
- handlers.view_channel({ channelKey: String(p.channelKey ?? "") });
366
+ result = handlers.view_channel({
367
+ channelKey: String(p.channelKey ?? ""),
368
+ });
315
369
  break;
316
370
  case "compose_cast":
317
- handlers.compose_cast({
371
+ result = handlers.compose_cast({
318
372
  text: p.text ? String(p.text) : undefined,
319
373
  channelKey: p.channelKey ? String(p.channelKey) : undefined,
320
374
  embeds: Array.isArray(p.embeds)
@@ -323,10 +377,10 @@ export function SnapViewCore({ snap, handlers, loading = false, appearance = "da
323
377
  });
324
378
  break;
325
379
  case "view_token":
326
- handlers.view_token({ token: String(p.token ?? "") });
380
+ result = handlers.view_token({ token: String(p.token ?? "") });
327
381
  break;
328
382
  case "send_token":
329
- handlers.send_token({
383
+ result = handlers.send_token({
330
384
  token: String(p.token ?? ""),
331
385
  amount: p.amount ? String(p.amount) : undefined,
332
386
  recipientFid: p.recipientFid ? Number(p.recipientFid) : undefined,
@@ -336,13 +390,13 @@ export function SnapViewCore({ snap, handlers, loading = false, appearance = "da
336
390
  });
337
391
  break;
338
392
  case "swap_token":
339
- handlers.swap_token({
393
+ result = handlers.swap_token({
340
394
  sellToken: p.sellToken ? String(p.sellToken) : undefined,
341
395
  buyToken: p.buyToken ? String(p.buyToken) : undefined,
342
396
  });
343
397
  break;
344
398
  case "send_transaction":
345
- handlers.send_transaction?.({
399
+ result = handlers.send_transaction?.({
346
400
  chainId: String(p.chainId ?? ""),
347
401
  to: String(p.to ?? ""),
348
402
  data: optionalString(p.data),
@@ -356,9 +410,20 @@ export function SnapViewCore({ snap, handlers, loading = false, appearance = "da
356
410
  default:
357
411
  break;
358
412
  }
359
- }, [handlers]);
360
- return (_jsxs("div", { style: { position: "relative", width: "100%" }, children: [showConfetti && effectRunKeys.confetti > 0 && (_jsx(ConfettiOverlay, {}, effectRunKeys.confetti)), showFireworks && effectRunKeys.fireworks > 0 && (_jsx(FireworksOverlay, {}, effectRunKeys.fireworks)), loadingOverlay === undefined ? (_jsx(SnapLoadingOverlay, { appearance: appearance, accentHex: accentHex, active: loading })) : loading ? (_jsx(_Fragment, { children: loadingOverlay })) : null, _jsx("div", { style: previewSurfaceStyle, children: _jsx(SnapPreviewAccentProvider, { pageAccent: snap.theme?.accent, appearance: appearance, children: _jsx(SnapVersionProvider, { value: snap.version === "2.0" ? "2.0" : "1.0", children: _jsx(SnapCatalogView, { spec: spec, state: initialState, loading: false, onStateChange: (changes) => {
361
- applyStatePaths(stateRef.current, changes);
362
- onRenderStateChange?.(cloneSnapRenderState(stateRef.current));
363
- }, onAction: handleAction }, pageKey) }) }) })] }));
413
+ if (result instanceof Promise) {
414
+ void result.finally(() => {
415
+ setActionSettled(name, p);
416
+ }).catch(() => { });
417
+ }
418
+ else {
419
+ setActionSettled(name, p);
420
+ }
421
+ return result;
422
+ }, [handlers, setActionPending, setActionSettled]);
423
+ return (_jsxs("div", { "data-snap-view-root": true, style: { position: "relative", width: "100%" }, onClickCapture: (event) => {
424
+ if (!hasPendingSnapAction(stateRef.current))
425
+ return;
426
+ event.preventDefault();
427
+ event.stopPropagation();
428
+ }, children: [showConfetti && effectRunKeys.confetti > 0 && (_jsx(ConfettiOverlay, {}, effectRunKeys.confetti)), showFireworks && effectRunKeys.fireworks > 0 && (_jsx(FireworksOverlay, {}, effectRunKeys.fireworks)), loadingOverlay === undefined ? (_jsx(SnapLoadingOverlay, { appearance: appearance, accentHex: accentHex, active: loading })) : loading ? (_jsx(_Fragment, { children: loadingOverlay })) : null, _jsx("div", { style: previewSurfaceStyle, children: _jsx(SnapPreviewAccentProvider, { pageAccent: snap.theme?.accent, appearance: appearance, children: _jsx(SnapVersionProvider, { value: snap.version === "2.0" ? "2.0" : "1.0", children: _jsx(SnapCatalogView, { spec: spec, store: stateStore, loading: false, onAction: handleAction, children: loadingOverlay === undefined ? (_jsx(SnapPendingActionOverlay, { appearance: appearance, accentHex: accentHex })) : null }, pageKey) }) }) })] }));
364
429
  }
@@ -49,7 +49,7 @@ export function SnapCardV1({ snap, handlers, loading = false, appearance = "dark
49
49
  position: "relative",
50
50
  width: "100%",
51
51
  maxWidth,
52
- }, children: [_jsxs("div", { style: {
52
+ }, children: [_jsxs("div", { "data-snap-card-surface": true, style: {
53
53
  position: "relative",
54
54
  overflow: "hidden",
55
55
  ...(plain ? {} : {
@@ -90,7 +90,7 @@ export function SnapCardV2({ snap, handlers, loading = false, appearance = "dark
90
90
  position: "relative",
91
91
  width: "100%",
92
92
  maxWidth,
93
- }, children: [_jsxs("div", { style: {
93
+ }, children: [_jsxs("div", { "data-snap-card-surface": true, style: {
94
94
  position: "relative",
95
95
  maxHeight: containerMaxHeight,
96
96
  overflow: "hidden",
@@ -22,6 +22,7 @@ export function SnapActionButton({ element, emit, }) {
22
22
  const label = String(props.label ?? "Action");
23
23
  const variant = String(props.variant ?? "secondary");
24
24
  const isPrimary = variant === "primary";
25
+ const disabled = props.disabled === true;
25
26
  const iconName = props.icon ? String(props.icon) : undefined;
26
27
  const textColor = isPrimary ? "#fff" : colors.text;
27
28
  const iconColor = isPrimary ? "#fff" : colors.text;
@@ -37,7 +38,10 @@ export function SnapActionButton({ element, emit, }) {
37
38
  ? { backgroundColor: pressed ? accentHex + "DD" : accentHex }
38
39
  : { backgroundColor: pressed ? colors.mutedHover : colors.muted },
39
40
  pressed && styles.pressed,
40
- ], onPress: () => {
41
+ disabled && styles.disabled,
42
+ ], disabled: disabled, onPress: () => {
43
+ if (disabled)
44
+ return;
41
45
  if (runPaginatorAction(stateStore, paginatorAction))
42
46
  return;
43
47
  void (async () => {
@@ -85,4 +89,5 @@ const styles = StyleSheet.create({
85
89
  paddingVertical: 6,
86
90
  },
87
91
  pressed: { opacity: 0.88 },
92
+ disabled: { opacity: 0.62 },
88
93
  });
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createStateStore } from "@json-render/react-native";
2
3
  import { snapJsonRenderCatalog } from "@farcaster/snap/ui";
3
4
  import { SnapCatalogView } from "./catalog-renderer.js";
4
5
  import { ConfettiOverlay } from "./confetti-overlay.js";
@@ -8,7 +9,7 @@ import { SnapVersionProvider } from "./snap-version-context.js";
8
9
  import { useCallback, useEffect, useMemo, useRef, useState, } from "react";
9
10
  import { ActivityIndicator, StyleSheet, View } from "react-native";
10
11
  import { DEFAULT_THEME_ACCENT, PALETTE_LIGHT_HEX, PALETTE_DARK_HEX, } from "@farcaster/snap";
11
- import { applyStatePaths, buildInitialRenderState, cloneSnapRenderState, getUnpresentedSnapEffects, markSnapEffectsPresented, } from "../render-state.js";
12
+ import { buildActionActivityStateChanges, buildInitialRenderState, cloneSnapRenderState, getUnpresentedSnapEffects, hasPendingSnapAction, markSnapEffectsPresented, } from "../render-state.js";
12
13
  function asRecord(value) {
13
14
  return value && typeof value === "object"
14
15
  ? value
@@ -17,6 +18,11 @@ function asRecord(value) {
17
18
  function optionalString(value) {
18
19
  return value ? String(value) : undefined;
19
20
  }
21
+ function recordValue(value) {
22
+ return value && typeof value === "object" && !Array.isArray(value)
23
+ ? value
24
+ : undefined;
25
+ }
20
26
  function withDefaultElementProps(spec) {
21
27
  if (!spec || typeof spec !== "object" || !("elements" in spec))
22
28
  return spec;
@@ -54,10 +60,26 @@ export function SnapViewCoreInner({ snap, handlers, loading = false, loadingOver
54
60
  initialRenderState,
55
61
  themeAccent: snap.theme?.accent,
56
62
  }), [initialRenderState, spec.state, snap.theme?.accent]);
63
+ const stateStore = useMemo(() => createStateStore(initialState), [
64
+ initialState,
65
+ ]);
57
66
  const stateRef = useRef(initialState);
67
+ const onRenderStateChangeRef = useRef(onRenderStateChange);
68
+ const pendingActionCountRef = useRef(0);
69
+ const [hasPendingAction, setHasPendingAction] = useState(false);
70
+ const [actionActivityVersion, setActionActivityVersion] = useState(0);
58
71
  useEffect(() => {
59
72
  stateRef.current = cloneSnapRenderState(initialState);
60
73
  }, [initialState]);
74
+ useEffect(() => {
75
+ onRenderStateChangeRef.current = onRenderStateChange;
76
+ }, [onRenderStateChange]);
77
+ useEffect(() => stateStore.subscribe(() => {
78
+ const snapshot = cloneSnapRenderState(stateStore.getSnapshot());
79
+ stateRef.current = snapshot;
80
+ setHasPendingAction(hasPendingSnapAction(snapshot));
81
+ onRenderStateChangeRef.current?.(snapshot);
82
+ }), [stateStore]);
61
83
  useEffect(() => {
62
84
  const catalogResult = snapJsonRenderCatalog.validate(spec);
63
85
  if (!catalogResult.success) {
@@ -77,10 +99,6 @@ export function SnapViewCoreInner({ snap, handlers, loading = false, loadingOver
77
99
  confetti: 0,
78
100
  fireworks: 0,
79
101
  });
80
- const onRenderStateChangeRef = useRef(onRenderStateChange);
81
- useEffect(() => {
82
- onRenderStateChangeRef.current = onRenderStateChange;
83
- }, [onRenderStateChange]);
84
102
  useEffect(() => {
85
103
  const effectsToPresent = getUnpresentedSnapEffects(stateRef.current, snapEffects);
86
104
  if (effectsToPresent.length === 0) {
@@ -97,7 +115,10 @@ export function SnapViewCoreInner({ snap, handlers, loading = false, loadingOver
97
115
  return;
98
116
  }
99
117
  if (markSnapEffectsPresented(stateRef.current, effectsToPresent)) {
100
- onRenderStateChangeRef.current?.(cloneSnapRenderState(stateRef.current));
118
+ const meta = recordValue(stateRef.current.__snapRender);
119
+ stateStore.update({
120
+ "/__snapRender/presentedEffects": meta?.presentedEffects ?? [],
121
+ });
101
122
  }
102
123
  setEffectRunKeys((current) => ({
103
124
  confetti: effectsToPresent.includes("confetti")
@@ -111,47 +132,70 @@ export function SnapViewCoreInner({ snap, handlers, loading = false, loadingOver
111
132
  ? current.fireworks
112
133
  : 0,
113
134
  }));
114
- }, [initialState, showConfetti, showFireworks, snapEffects]);
135
+ }, [initialState, showConfetti, showFireworks, snapEffects, stateStore]);
115
136
  const handlersRef = useRef(handlers);
116
137
  handlersRef.current = handlers;
138
+ const applyActionActivityState = useCallback((name, params, pending) => {
139
+ stateStore.update(Object.fromEntries(buildActionActivityStateChanges({
140
+ actionName: name,
141
+ params,
142
+ pending,
143
+ }).map(({ path, value }) => [path, value])));
144
+ }, [stateStore]);
145
+ const setActionPending = useCallback((name, params) => {
146
+ pendingActionCountRef.current += 1;
147
+ setHasPendingAction(true);
148
+ setActionActivityVersion((version) => version + 1);
149
+ applyActionActivityState(name, params, true);
150
+ }, [applyActionActivityState]);
151
+ const setActionSettled = useCallback((name, params) => {
152
+ pendingActionCountRef.current = Math.max(0, pendingActionCountRef.current - 1);
153
+ applyActionActivityState(name, params, false);
154
+ if (pendingActionCountRef.current === 0) {
155
+ setHasPendingAction(false);
156
+ }
157
+ setActionActivityVersion((version) => version + 1);
158
+ }, [applyActionActivityState]);
117
159
  const handleAction = useCallback((name, params) => {
118
160
  const inputs = (stateRef.current.inputs ?? {});
119
161
  const p = (params ?? {});
120
162
  const h = handlersRef.current;
163
+ let result;
164
+ setActionPending(name, p);
121
165
  switch (name) {
122
166
  case "submit":
123
- h.submit(String(p.target ?? ""), inputs);
167
+ result = h.submit(String(p.target ?? ""), inputs);
124
168
  break;
125
169
  case "open_url":
126
- h.open_url(String(p.target ?? ""));
170
+ result = h.open_url(String(p.target ?? ""));
127
171
  break;
128
172
  case "open_snap":
129
- h.open_snap(String(p.target ?? ""));
173
+ result = h.open_snap(String(p.target ?? ""));
130
174
  break;
131
175
  case "open_mini_app":
132
- h.open_mini_app(String(p.target ?? ""));
176
+ result = h.open_mini_app(String(p.target ?? ""));
133
177
  break;
134
178
  case "view_cast":
135
- h.view_cast({ hash: String(p.hash ?? "") });
179
+ result = h.view_cast({ hash: String(p.hash ?? "") });
136
180
  break;
137
181
  case "view_profile":
138
- h.view_profile({ fid: Number(p.fid ?? 0) });
182
+ result = h.view_profile({ fid: Number(p.fid ?? 0) });
139
183
  break;
140
184
  case "view_channel":
141
- h.view_channel({ channelKey: String(p.channelKey ?? "") });
185
+ result = h.view_channel({ channelKey: String(p.channelKey ?? "") });
142
186
  break;
143
187
  case "compose_cast":
144
- h.compose_cast({
188
+ result = h.compose_cast({
145
189
  text: p.text ? String(p.text) : undefined,
146
190
  channelKey: p.channelKey ? String(p.channelKey) : undefined,
147
191
  embeds: Array.isArray(p.embeds) ? p.embeds : undefined,
148
192
  });
149
193
  break;
150
194
  case "view_token":
151
- h.view_token({ token: String(p.token ?? "") });
195
+ result = h.view_token({ token: String(p.token ?? "") });
152
196
  break;
153
197
  case "send_token":
154
- h.send_token({
198
+ result = h.send_token({
155
199
  token: String(p.token ?? ""),
156
200
  amount: p.amount ? String(p.amount) : undefined,
157
201
  recipientFid: p.recipientFid ? Number(p.recipientFid) : undefined,
@@ -161,13 +205,13 @@ export function SnapViewCoreInner({ snap, handlers, loading = false, loadingOver
161
205
  });
162
206
  break;
163
207
  case "swap_token":
164
- h.swap_token({
208
+ result = h.swap_token({
165
209
  sellToken: p.sellToken ? String(p.sellToken) : undefined,
166
210
  buyToken: p.buyToken ? String(p.buyToken) : undefined,
167
211
  });
168
212
  break;
169
213
  case "send_transaction":
170
- h.send_transaction?.({
214
+ result = h.send_transaction?.({
171
215
  chainId: String(p.chainId ?? ""),
172
216
  to: String(p.to ?? ""),
173
217
  data: optionalString(p.data),
@@ -181,11 +225,20 @@ export function SnapViewCoreInner({ snap, handlers, loading = false, loadingOver
181
225
  default:
182
226
  break;
183
227
  }
184
- }, []);
185
- return (_jsxs(View, { style: styles.container, children: [loading ? (loadingOverlay === undefined ? (_jsx(SnapLoadingOverlay, { appearance: mode, accentHex: accentHex })) : (loadingOverlay)) : null, _jsx(SnapVersionProvider, { value: snap.version === "2.0" ? "2.0" : "1.0", children: _jsx(SnapCatalogView, { spec: spec, state: initialState, loading: false, onStateChange: (changes) => {
186
- applyStatePaths(stateRef.current, changes);
187
- onRenderStateChange?.(cloneSnapRenderState(stateRef.current));
188
- }, onAction: handleAction }, pageKey) }), showConfetti && effectRunKeys.confetti > 0 && (_jsx(ConfettiOverlay, {}, effectRunKeys.confetti)), showFireworks && effectRunKeys.fireworks > 0 && (_jsx(FireworksOverlay, {}, effectRunKeys.fireworks))] }));
228
+ if (result instanceof Promise) {
229
+ void result.finally(() => {
230
+ setActionSettled(name, p);
231
+ }).catch(() => { });
232
+ }
233
+ else {
234
+ setActionSettled(name, p);
235
+ }
236
+ return result;
237
+ }, [setActionPending, setActionSettled]);
238
+ const showLoadingOverlay = loading ||
239
+ hasPendingAction ||
240
+ (actionActivityVersion >= 0 && pendingActionCountRef.current > 0);
241
+ return (_jsxs(View, { style: styles.container, onStartShouldSetResponderCapture: () => hasPendingSnapAction(stateRef.current), children: [showLoadingOverlay ? (loadingOverlay === undefined ? (_jsx(SnapLoadingOverlay, { appearance: mode, accentHex: accentHex })) : (loadingOverlay)) : null, _jsx(SnapVersionProvider, { value: snap.version === "2.0" ? "2.0" : "1.0", children: _jsx(SnapCatalogView, { spec: spec, store: stateStore, loading: false, onAction: handleAction }, pageKey) }), showConfetti && effectRunKeys.confetti > 0 && (_jsx(ConfettiOverlay, {}, effectRunKeys.confetti)), showFireworks && effectRunKeys.fireworks > 0 && (_jsx(FireworksOverlay, {}, effectRunKeys.fireworks))] }));
189
242
  }
190
243
  export function SnapLoadingOverlay({ appearance, accentHex, }) {
191
244
  return (_jsx(View, { style: [
@@ -1,5 +1,6 @@
1
1
  import type { Spec } from "@json-render/core";
2
2
  import type { SnapRenderState } from "../render-state.js";
3
+ import type { SnapTransactionResult } from "../schemas.js";
3
4
  export type { SnapRenderState };
4
5
  export type JsonValue = string | number | boolean | null | JsonValue[] | {
5
6
  [key: string]: JsonValue;
@@ -54,5 +55,5 @@ export type SnapActionHandlers = {
54
55
  sellToken?: string;
55
56
  buyToken?: string;
56
57
  }) => void;
57
- send_transaction?: (params: SnapSendTransactionParams) => void;
58
+ send_transaction?: (params: SnapSendTransactionParams) => void | Promise<void | SnapTransactionResult>;
58
59
  };