@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
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
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";
@@ -10,6 +10,7 @@ import {
10
10
  getPaginatorAction,
11
11
  runPaginatorAction,
12
12
  } from "../../ui/paginator-state";
13
+ import { buildActionActivityStateChanges } from "../../render-state";
13
14
  import { useSnapStackDirection } from "../stack-direction-context";
14
15
  import { ICON_MAP } from "./icon";
15
16
 
@@ -24,6 +25,22 @@ function isExternalLinkAction(
24
25
  return press.action === "open_url";
25
26
  }
26
27
 
28
+ function getActionPendingPath(on: Record<string, unknown> | undefined) {
29
+ const press = on?.press as
30
+ | { action?: unknown; params?: Record<string, unknown> }
31
+ | undefined;
32
+ if (!press?.action) return "/__snap/action/pending";
33
+
34
+ return (
35
+ buildActionActivityStateChanges({
36
+ actionName: press.action,
37
+ params: press.params ?? {},
38
+ pending: true,
39
+ }).find((change) => change.path.endsWith("/pending"))?.path ??
40
+ "/__snap/action/pending"
41
+ );
42
+ }
43
+
27
44
  export function SnapActionButton({
28
45
  element,
29
46
  emit,
@@ -38,18 +55,25 @@ export function SnapActionButton({
38
55
  const label = String(props.label ?? "Action");
39
56
  const variant = String(props.variant ?? "secondary");
40
57
  const isPrimary = variant === "primary";
58
+ const disabled = props.disabled === true;
41
59
  const iconName = props.icon ? String(props.icon) : undefined;
42
60
  const colors = useSnapColors();
43
61
  const [hovered, setHovered] = useState(false);
44
62
  const stateStore = useStateStore();
45
63
  const paginatorAction = getPaginatorAction(element.on);
64
+ const actionPendingPath = useMemo(
65
+ () => getActionPendingPath(element.on),
66
+ [element.on],
67
+ );
68
+ const actionPending = useStateValue(actionPendingPath) === true;
46
69
 
47
70
  const Icon = iconName ? ICON_MAP[iconName] : undefined;
48
71
  const showExternalIcon = isExternalLinkAction(element.on);
49
72
  const inHorizontalStack = useSnapStackDirection() === "horizontal";
50
73
 
51
74
  const style = {
52
- cursor: "pointer" as const,
75
+ cursor: disabled ? ("not-allowed" as const) : ("pointer" as const),
76
+ opacity: disabled ? 0.62 : 1,
53
77
  ...(isPrimary
54
78
  ? {
55
79
  backgroundColor: hovered ? colors.accentHover : colors.accent,
@@ -83,8 +107,10 @@ export function SnapActionButton({
83
107
  type="button"
84
108
  variant={isPrimary ? "default" : "secondary"}
85
109
  className={cn("h-8 w-full gap-2 px-3 text-sm")}
110
+ disabled={disabled}
86
111
  style={style}
87
112
  onClick={() => {
113
+ if (disabled) return;
88
114
  if (!runPaginatorAction(stateStore, paginatorAction)) {
89
115
  emit("press");
90
116
  }
@@ -94,6 +120,9 @@ export function SnapActionButton({
94
120
  >
95
121
  {Icon && <Icon size={16} />}
96
122
  {label}
123
+ {actionPending && (
124
+ <span data-snap-action-pending-active="true" hidden />
125
+ )}
97
126
  {showExternalIcon && (
98
127
  <ExternalLink size={14} style={{ opacity: 0.6 }} />
99
128
  )}
@@ -5,6 +5,7 @@ import type { ReactNode } from "react";
5
5
  import type { ValidationResult } from "../validator.js";
6
6
  import { SPEC_VERSION_2 } from "../constants";
7
7
  import type { SnapRenderState } from "../render-state";
8
+ import type { SnapTransactionResult } from "../schemas";
8
9
  import { SnapCardV1 } from "./v1/snap-view";
9
10
  import { SnapCardV2 } from "./v2/snap-view";
10
11
 
@@ -57,7 +58,9 @@ export type SnapActionHandlers = {
57
58
  recipientAddress?: string;
58
59
  }) => void;
59
60
  swap_token: (params: { sellToken?: string; buyToken?: string }) => void;
60
- send_transaction?: (params: SnapSendTransactionParams) => void;
61
+ send_transaction?: (
62
+ params: SnapSendTransactionParams,
63
+ ) => void | Promise<void | SnapTransactionResult>;
61
64
  };
62
65
 
63
66
  export type { SnapRenderState };
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import type { Spec } from "@json-render/core";
4
+ import { createStateStore } from "@json-render/react";
4
5
  import { snapJsonRenderCatalog } from "../ui/index.js";
5
6
  import { SnapCatalogView } from "./catalog-renderer";
6
7
  import { SnapPreviewAccentProvider } from "./accent-context";
@@ -8,10 +9,11 @@ import { SnapVersionProvider } from "./snap-version-context";
8
9
  import { resolveSnapPaletteHex } from "./lib/resolve-palette-hex";
9
10
  import { snapPreviewPrimaryCssProperties } from "./lib/preview-primary-css";
10
11
  import {
11
- applyStatePaths,
12
+ buildActionActivityStateChanges,
12
13
  buildInitialRenderState,
13
14
  cloneSnapRenderState,
14
15
  getUnpresentedSnapEffects,
16
+ hasPendingSnapAction,
15
17
  markSnapEffectsPresented,
16
18
  type SnapRenderState,
17
19
  } from "../render-state";
@@ -36,6 +38,12 @@ function optionalString(value: unknown): string | undefined {
36
38
  return value ? String(value) : undefined;
37
39
  }
38
40
 
41
+ function recordValue(value: unknown): Record<string, unknown> | undefined {
42
+ return value && typeof value === "object" && !Array.isArray(value)
43
+ ? (value as Record<string, unknown>)
44
+ : undefined;
45
+ }
46
+
39
47
  function withDefaultElementProps(spec: Spec): Spec {
40
48
  if (!spec || typeof spec !== "object" || !("elements" in spec)) return spec;
41
49
  const elements = spec.elements as unknown as Record<
@@ -266,6 +274,8 @@ export function SnapLoadingOverlay({
266
274
 
267
275
  return (
268
276
  <div
277
+ data-snap-loading-overlay
278
+ data-snap-loading-active={active ? "true" : "false"}
269
279
  style={{
270
280
  position: "absolute",
271
281
  inset: 0,
@@ -299,6 +309,20 @@ export function SnapLoadingOverlay({
299
309
  }}
300
310
  />
301
311
  <style>{`
312
+ [data-snap-view-root]:has([data-snap-action-pending-active="true"])
313
+ [data-snap-loading-overlay] {
314
+ opacity: 1 !important;
315
+ pointer-events: auto !important;
316
+ backdrop-filter: blur(10px) saturate(1.05) !important;
317
+ -webkit-backdrop-filter: blur(10px) saturate(1.05) !important;
318
+ }
319
+ [data-snap-card-surface]:has([data-snap-action-pending-active="true"])
320
+ > [data-snap-loading-overlay] {
321
+ opacity: 1 !important;
322
+ pointer-events: auto !important;
323
+ backdrop-filter: blur(10px) saturate(1.05) !important;
324
+ -webkit-backdrop-filter: blur(10px) saturate(1.05) !important;
325
+ }
302
326
  @keyframes snapViewSpin {
303
327
  to { transform: rotate(360deg); }
304
328
  }
@@ -314,6 +338,22 @@ export function SnapLoadingOverlay({
314
338
  );
315
339
  }
316
340
 
341
+ function SnapPendingActionOverlay({
342
+ appearance,
343
+ accentHex,
344
+ }: {
345
+ appearance: "light" | "dark";
346
+ accentHex: string;
347
+ }) {
348
+ return (
349
+ <SnapLoadingOverlay
350
+ appearance={appearance}
351
+ accentHex={accentHex}
352
+ active={false}
353
+ />
354
+ );
355
+ }
356
+
317
357
  const PALETTE = [
318
358
  "gray",
319
359
  "blue",
@@ -360,12 +400,31 @@ export function SnapViewCore({
360
400
  [initialRenderState, spec.state, snap.theme?.accent],
361
401
  );
362
402
 
403
+ const stateStore = useMemo(() => createStateStore(initialState), [
404
+ initialState,
405
+ ]);
363
406
  const stateRef = useRef<Record<string, unknown>>(initialState);
407
+ const onRenderStateChangeRef = useRef(onRenderStateChange);
408
+ const pendingActionCountRef = useRef(0);
364
409
 
365
410
  useEffect(() => {
366
411
  stateRef.current = cloneSnapRenderState(initialState);
367
412
  }, [initialState]);
368
413
 
414
+ useEffect(() => {
415
+ onRenderStateChangeRef.current = onRenderStateChange;
416
+ }, [onRenderStateChange]);
417
+
418
+ useEffect(
419
+ () =>
420
+ stateStore.subscribe(() => {
421
+ const snapshot = cloneSnapRenderState(stateStore.getSnapshot());
422
+ stateRef.current = snapshot;
423
+ onRenderStateChangeRef.current?.(snapshot);
424
+ }),
425
+ [stateStore],
426
+ );
427
+
369
428
  useEffect(() => {
370
429
  const catalogResult = snapJsonRenderCatalog.validate(spec);
371
430
  if (!catalogResult.success) {
@@ -390,10 +449,6 @@ export function SnapViewCore({
390
449
  confetti: 0,
391
450
  fireworks: 0,
392
451
  });
393
- const onRenderStateChangeRef = useRef(onRenderStateChange);
394
- useEffect(() => {
395
- onRenderStateChangeRef.current = onRenderStateChange;
396
- }, [onRenderStateChange]);
397
452
  useEffect(() => {
398
453
  const effectsToPresent = getUnpresentedSnapEffects(
399
454
  stateRef.current,
@@ -415,7 +470,10 @@ export function SnapViewCore({
415
470
  }
416
471
 
417
472
  if (markSnapEffectsPresented(stateRef.current, effectsToPresent)) {
418
- onRenderStateChangeRef.current?.(cloneSnapRenderState(stateRef.current));
473
+ const meta = recordValue(stateRef.current.__snapRender);
474
+ stateStore.update({
475
+ "/__snapRender/presentedEffects": meta?.presentedEffects ?? [],
476
+ });
419
477
  }
420
478
 
421
479
  setEffectRunKeys((current) => ({
@@ -430,7 +488,7 @@ export function SnapViewCore({
430
488
  ? current.fireworks
431
489
  : 0,
432
490
  }));
433
- }, [initialState, showConfetti, showFireworks, snapEffects]);
491
+ }, [initialState, showConfetti, showFireworks, snapEffects, stateStore]);
434
492
 
435
493
  const accentName = snap.theme?.accent ?? "purple";
436
494
 
@@ -449,6 +507,40 @@ export function SnapViewCore({
449
507
  } as CSSProperties;
450
508
  }, [accentName, appearance]);
451
509
 
510
+ const applyActionActivityState = useCallback(
511
+ (name: unknown, params: Record<string, unknown>, pending: boolean) => {
512
+ stateStore.update(
513
+ Object.fromEntries(
514
+ buildActionActivityStateChanges({
515
+ actionName: name,
516
+ params,
517
+ pending,
518
+ }).map(({ path, value }) => [path, value]),
519
+ ),
520
+ );
521
+ },
522
+ [stateStore],
523
+ );
524
+
525
+ const setActionPending = useCallback(
526
+ (name: unknown, params: Record<string, unknown>) => {
527
+ pendingActionCountRef.current += 1;
528
+ applyActionActivityState(name, params, true);
529
+ },
530
+ [applyActionActivityState],
531
+ );
532
+
533
+ const setActionSettled = useCallback(
534
+ (name: unknown, params: Record<string, unknown>) => {
535
+ pendingActionCountRef.current = Math.max(
536
+ 0,
537
+ pendingActionCountRef.current - 1,
538
+ );
539
+ applyActionActivityState(name, params, false);
540
+ },
541
+ [applyActionActivityState],
542
+ );
543
+
452
544
  const handleAction = useCallback(
453
545
  (name: unknown, params: unknown) => {
454
546
  const inputs = (stateRef.current.inputs ?? {}) as Record<
@@ -456,30 +548,35 @@ export function SnapViewCore({
456
548
  JsonValue
457
549
  >;
458
550
  const p = (params ?? {}) as Record<string, unknown>;
551
+ let result: unknown;
552
+ setActionPending(name, p);
553
+
459
554
  switch (name) {
460
555
  case "submit":
461
- handlers.submit(String(p.target ?? ""), inputs);
556
+ result = handlers.submit(String(p.target ?? ""), inputs);
462
557
  break;
463
558
  case "open_url":
464
- handlers.open_url(String(p.target ?? ""));
559
+ result = handlers.open_url(String(p.target ?? ""));
465
560
  break;
466
561
  case "open_snap":
467
- handlers.open_snap(String(p.target ?? ""));
562
+ result = handlers.open_snap(String(p.target ?? ""));
468
563
  break;
469
564
  case "open_mini_app":
470
- handlers.open_mini_app(String(p.target ?? ""));
565
+ result = handlers.open_mini_app(String(p.target ?? ""));
471
566
  break;
472
567
  case "view_cast":
473
- handlers.view_cast({ hash: String(p.hash ?? "") });
568
+ result = handlers.view_cast({ hash: String(p.hash ?? "") });
474
569
  break;
475
570
  case "view_profile":
476
- handlers.view_profile({ fid: Number(p.fid ?? 0) });
571
+ result = handlers.view_profile({ fid: Number(p.fid ?? 0) });
477
572
  break;
478
573
  case "view_channel":
479
- handlers.view_channel({ channelKey: String(p.channelKey ?? "") });
574
+ result = handlers.view_channel({
575
+ channelKey: String(p.channelKey ?? ""),
576
+ });
480
577
  break;
481
578
  case "compose_cast":
482
- handlers.compose_cast({
579
+ result = handlers.compose_cast({
483
580
  text: p.text ? String(p.text) : undefined,
484
581
  channelKey: p.channelKey ? String(p.channelKey) : undefined,
485
582
  embeds: Array.isArray(p.embeds)
@@ -488,10 +585,10 @@ export function SnapViewCore({
488
585
  });
489
586
  break;
490
587
  case "view_token":
491
- handlers.view_token({ token: String(p.token ?? "") });
588
+ result = handlers.view_token({ token: String(p.token ?? "") });
492
589
  break;
493
590
  case "send_token":
494
- handlers.send_token({
591
+ result = handlers.send_token({
495
592
  token: String(p.token ?? ""),
496
593
  amount: p.amount ? String(p.amount) : undefined,
497
594
  recipientFid: p.recipientFid ? Number(p.recipientFid) : undefined,
@@ -501,13 +598,13 @@ export function SnapViewCore({
501
598
  });
502
599
  break;
503
600
  case "swap_token":
504
- handlers.swap_token({
601
+ result = handlers.swap_token({
505
602
  sellToken: p.sellToken ? String(p.sellToken) : undefined,
506
603
  buyToken: p.buyToken ? String(p.buyToken) : undefined,
507
604
  });
508
605
  break;
509
606
  case "send_transaction":
510
- handlers.send_transaction?.({
607
+ result = handlers.send_transaction?.({
511
608
  chainId: String(p.chainId ?? ""),
512
609
  to: String(p.to ?? ""),
513
610
  data: optionalString(p.data),
@@ -521,12 +618,29 @@ export function SnapViewCore({
521
618
  default:
522
619
  break;
523
620
  }
621
+
622
+ if (result instanceof Promise) {
623
+ void result.finally(() => {
624
+ setActionSettled(name, p);
625
+ }).catch(() => {});
626
+ } else {
627
+ setActionSettled(name, p);
628
+ }
629
+ return result;
524
630
  },
525
- [handlers],
631
+ [handlers, setActionPending, setActionSettled],
526
632
  );
527
633
 
528
634
  return (
529
- <div style={{ position: "relative", width: "100%" }}>
635
+ <div
636
+ data-snap-view-root
637
+ style={{ position: "relative", width: "100%" }}
638
+ onClickCapture={(event) => {
639
+ if (!hasPendingSnapAction(stateRef.current)) return;
640
+ event.preventDefault();
641
+ event.stopPropagation();
642
+ }}
643
+ >
530
644
  {showConfetti && effectRunKeys.confetti > 0 && (
531
645
  <ConfettiOverlay key={effectRunKeys.confetti} />
532
646
  )}
@@ -552,14 +666,17 @@ export function SnapViewCore({
552
666
  <SnapCatalogView
553
667
  key={pageKey}
554
668
  spec={spec}
555
- state={initialState}
669
+ store={stateStore}
556
670
  loading={false}
557
- onStateChange={(changes) => {
558
- applyStatePaths(stateRef.current, changes);
559
- onRenderStateChange?.(cloneSnapRenderState(stateRef.current));
560
- }}
561
671
  onAction={handleAction}
562
- />
672
+ >
673
+ {loadingOverlay === undefined ? (
674
+ <SnapPendingActionOverlay
675
+ appearance={appearance}
676
+ accentHex={accentHex}
677
+ />
678
+ ) : null}
679
+ </SnapCatalogView>
563
680
  </SnapVersionProvider>
564
681
  </SnapPreviewAccentProvider>
565
682
  </div>
@@ -120,6 +120,7 @@ export function SnapCardV1({
120
120
  }}
121
121
  >
122
122
  <div
123
+ data-snap-card-surface
123
124
  style={{
124
125
  position: "relative",
125
126
  overflow: "hidden",
@@ -193,6 +193,7 @@ export function SnapCardV2({
193
193
  }}
194
194
  >
195
195
  <div
196
+ data-snap-card-surface
196
197
  style={{
197
198
  position: "relative",
198
199
  maxHeight: containerMaxHeight,
@@ -34,6 +34,7 @@ export function SnapActionButton({
34
34
  const label = String(props.label ?? "Action");
35
35
  const variant = String(props.variant ?? "secondary");
36
36
  const isPrimary = variant === "primary";
37
+ const disabled = props.disabled === true;
37
38
  const iconName = props.icon ? String(props.icon) : undefined;
38
39
 
39
40
  const textColor = isPrimary ? "#fff" : colors.text;
@@ -53,10 +54,13 @@ export function SnapActionButton({
53
54
  isPrimary ? styles.btnDefault : styles.btnOther,
54
55
  isPrimary
55
56
  ? { backgroundColor: pressed ? accentHex + "DD" : accentHex }
56
- : { backgroundColor: pressed ? colors.mutedHover : colors.muted },
57
+ : { backgroundColor: pressed ? colors.mutedHover : colors.muted },
57
58
  pressed && styles.pressed,
59
+ disabled && styles.disabled,
58
60
  ]}
61
+ disabled={disabled}
59
62
  onPress={() => {
63
+ if (disabled) return;
60
64
  if (runPaginatorAction(stateStore, paginatorAction)) return;
61
65
  void (async () => {
62
66
  try {
@@ -114,4 +118,5 @@ const styles = StyleSheet.create({
114
118
  paddingVertical: 6,
115
119
  },
116
120
  pressed: { opacity: 0.88 },
121
+ disabled: { opacity: 0.62 },
117
122
  });