@farcaster/snap 1.15.3 → 1.16.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 (60) hide show
  1. package/dist/constants.d.ts +8 -0
  2. package/dist/constants.js +9 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.js +1 -1
  5. package/dist/react/components/action-button.d.ts +2 -1
  6. package/dist/react/components/action-button.js +16 -3
  7. package/dist/react/components/badge.js +2 -3
  8. package/dist/react/index.d.ts +8 -1
  9. package/dist/react/index.js +9 -228
  10. package/dist/react/snap-view-core.d.ts +11 -0
  11. package/dist/react/snap-view-core.js +224 -0
  12. package/dist/react/v1/snap-view.d.ts +14 -0
  13. package/dist/react/v1/snap-view.js +9 -0
  14. package/dist/react/v2/snap-view.d.ts +21 -0
  15. package/dist/react/v2/snap-view.js +76 -0
  16. package/dist/react-native/components/snap-action-button.d.ts +1 -1
  17. package/dist/react-native/components/snap-action-button.js +19 -2
  18. package/dist/react-native/components/snap-badge.js +3 -3
  19. package/dist/react-native/index.d.ts +15 -43
  20. package/dist/react-native/index.js +10 -164
  21. package/dist/react-native/snap-view-core.d.ts +11 -0
  22. package/dist/react-native/snap-view-core.js +153 -0
  23. package/dist/react-native/types.d.ts +41 -0
  24. package/dist/react-native/types.js +1 -0
  25. package/dist/react-native/v1/snap-view.d.ts +22 -0
  26. package/dist/react-native/v1/snap-view.js +31 -0
  27. package/dist/react-native/v2/snap-view.d.ts +31 -0
  28. package/dist/react-native/v2/snap-view.js +101 -0
  29. package/dist/schemas.d.ts +15 -9
  30. package/dist/schemas.js +7 -8
  31. package/dist/server/parseRequest.d.ts +7 -0
  32. package/dist/server/parseRequest.js +27 -0
  33. package/dist/ui/catalog.d.ts +1 -0
  34. package/dist/ui/catalog.js +5 -2
  35. package/dist/ui/schema.js +1 -1
  36. package/dist/validator.d.ts +3 -2
  37. package/dist/validator.js +193 -2
  38. package/llms.txt +9 -0
  39. package/package.json +1 -1
  40. package/src/constants.ts +11 -1
  41. package/src/index.ts +8 -0
  42. package/src/react/accent-context.tsx +1 -1
  43. package/src/react/components/action-button.tsx +25 -3
  44. package/src/react/components/badge.tsx +2 -3
  45. package/src/react/index.tsx +36 -327
  46. package/src/react/snap-view-core.tsx +340 -0
  47. package/src/react/v1/snap-view.tsx +50 -0
  48. package/src/react/v2/snap-view.tsx +168 -0
  49. package/src/react-native/components/snap-action-button.tsx +26 -4
  50. package/src/react-native/components/snap-badge.tsx +3 -3
  51. package/src/react-native/index.tsx +47 -263
  52. package/src/react-native/snap-view-core.tsx +209 -0
  53. package/src/react-native/types.ts +37 -0
  54. package/src/react-native/v1/snap-view.tsx +108 -0
  55. package/src/react-native/v2/snap-view.tsx +239 -0
  56. package/src/schemas.ts +9 -10
  57. package/src/server/parseRequest.ts +39 -0
  58. package/src/ui/catalog.ts +5 -2
  59. package/src/ui/schema.ts +1 -1
  60. package/src/validator.ts +240 -2
@@ -1,18 +1,34 @@
1
1
  "use client";
2
2
 
3
3
  import { useState } from "react";
4
+ import { ExternalLink } from "lucide-react";
4
5
  import { Button } from "@neynar/ui/button";
5
6
  import { cn } from "@neynar/ui/utils";
6
7
  import { useSnapColors } from "../hooks/use-snap-colors";
7
8
  import { ICON_MAP } from "./icon";
8
9
 
10
+ function isExternalLinkAction(
11
+ on: Record<string, unknown> | undefined,
12
+ ): boolean {
13
+ if (!on) return false;
14
+ const press = on.press as
15
+ | { action?: string; params?: Record<string, unknown> }
16
+ | undefined;
17
+ if (!press || press.action !== "open_url") return false;
18
+ return press.params?.isSnap !== true;
19
+ }
20
+
9
21
  export function SnapActionButton({
10
- element: { props },
22
+ element,
11
23
  emit,
12
24
  }: {
13
- element: { props: Record<string, unknown> };
25
+ element: {
26
+ props: Record<string, unknown>;
27
+ on?: Record<string, unknown>;
28
+ };
14
29
  emit: (name: string) => void;
15
30
  }) {
31
+ const { props } = element;
16
32
  const label = String(props.label ?? "Action");
17
33
  const variant = String(props.variant ?? "secondary");
18
34
  const isPrimary = variant === "primary";
@@ -21,6 +37,7 @@ export function SnapActionButton({
21
37
  const [hovered, setHovered] = useState(false);
22
38
 
23
39
  const Icon = iconName ? ICON_MAP[iconName] : undefined;
40
+ const showExternalIcon = isExternalLinkAction(element.on);
24
41
 
25
42
  const style = isPrimary
26
43
  ? {
@@ -29,7 +46,9 @@ export function SnapActionButton({
29
46
  borderColor: "transparent",
30
47
  }
31
48
  : {
32
- backgroundColor: hovered ? `color-mix(in srgb, ${colors.accent} 15%, transparent)` : colors.muted,
49
+ backgroundColor: hovered
50
+ ? `color-mix(in srgb, ${colors.accent} 15%, transparent)`
51
+ : colors.muted,
33
52
  color: colors.text,
34
53
  borderColor: "transparent",
35
54
  };
@@ -47,6 +66,9 @@ export function SnapActionButton({
47
66
  >
48
67
  {Icon && <Icon size={16} />}
49
68
  {label}
69
+ {showExternalIcon && (
70
+ <ExternalLink size={14} style={{ opacity: 0.6 }} />
71
+ )}
50
72
  </Button>
51
73
  </div>
52
74
  );
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { Badge } from "@neynar/ui/badge";
4
- import { useSnapColors, pickForegroundForBg } from "../hooks/use-snap-colors";
4
+ import { useSnapColors } from "../hooks/use-snap-colors";
5
5
  import { ICON_MAP } from "./icon";
6
6
 
7
7
  export function SnapBadge({
@@ -16,14 +16,13 @@ export function SnapBadge({
16
16
  const colors = useSnapColors();
17
17
 
18
18
  const badgeColor = colors.colorHex(color);
19
- const badgeFg = variant === "default" ? pickForegroundForBg(badgeColor) : badgeColor;
20
19
 
21
20
  const Icon = iconName ? ICON_MAP[iconName] : undefined;
22
21
 
23
22
  const style =
24
23
  variant === "outline"
25
24
  ? { borderColor: badgeColor, color: badgeColor, backgroundColor: "transparent" }
26
- : { backgroundColor: badgeColor, color: badgeFg, borderColor: "transparent" };
25
+ : { backgroundColor: `${badgeColor}20`, color: badgeColor, borderColor: "transparent" };
27
26
 
28
27
  return (
29
28
  <Badge variant={variant} className="gap-1" style={style}>
@@ -1,19 +1,11 @@
1
1
  "use client";
2
2
 
3
3
  import type { Spec } from "@json-render/core";
4
- import { snapJsonRenderCatalog } from "../ui/index.js";
5
- import { SnapCatalogView } from "./catalog-renderer";
6
- import { SnapPreviewAccentProvider } from "./accent-context";
7
- import { resolveSnapPaletteHex } from "./lib/resolve-palette-hex";
8
- import { snapPreviewPrimaryCssProperties } from "./lib/preview-primary-css";
9
- import {
10
- type CSSProperties,
11
- useCallback,
12
- useEffect,
13
- useMemo,
14
- useRef,
15
- useState,
16
- } from "react";
4
+ import type { ReactNode } from "react";
5
+ import type { ValidationResult } from "../validator.js";
6
+ import { SPEC_VERSION_2 } from "../constants";
7
+ import { SnapCardV1 } from "./v1/snap-view";
8
+ import { SnapCardV2 } from "./v2/snap-view";
17
9
 
18
10
  // ─── Public types ──────────────────────────────────────
19
11
 
@@ -53,333 +45,50 @@ export type SnapActionHandlers = {
53
45
  swap_token: (params: { sellToken?: string; buyToken?: string }) => void;
54
46
  };
55
47
 
56
- // ─── Internal helpers ──────────────────────────────────
48
+ // ─── SnapCard ────────────────────────────────────────
57
49
 
58
- function applyStatePaths(
59
- model: Record<string, unknown>,
60
- changes: { path: string; value: unknown }[] | Record<string, unknown>,
61
- ): void {
62
- const entries = Array.isArray(changes)
63
- ? changes.map((c) => [c.path, c.value] as const)
64
- : Object.entries(changes);
65
- for (const [path, value] of entries) {
66
- const trimmed = path.startsWith("/") ? path : `/${path}`;
67
- const parts = trimmed.split("/").filter(Boolean);
68
- if (parts.length < 2) continue;
69
- const [top, ...rest] = parts;
70
- if (top === "inputs") {
71
- if (typeof model.inputs !== "object" || model.inputs === null) {
72
- model.inputs = {};
73
- }
74
- const inputs = model.inputs as Record<string, unknown>;
75
- if (rest.length === 1) {
76
- inputs[rest[0]!] = value;
77
- }
78
- continue;
79
- }
80
- if (top === "theme") {
81
- if (typeof model.theme !== "object" || model.theme === null) {
82
- model.theme = {};
83
- }
84
- const theme = model.theme as Record<string, unknown>;
85
- if (rest.length === 1) {
86
- theme[rest[0]!] = value;
87
- }
88
- }
89
- }
90
- }
91
-
92
- const CONFETTI_COLORS = [
93
- "#8B5CF6",
94
- "#EC4899",
95
- "#3B82F6",
96
- "#10B981",
97
- "#F59E0B",
98
- "#EF4444",
99
- "#06B6D4",
100
- ];
101
-
102
- function ConfettiOverlay() {
103
- const pieces = useMemo(
104
- () =>
105
- Array.from({ length: 80 }, (_, i) => ({
106
- id: i,
107
- left: Math.random() * 100,
108
- delay: Math.random() * 1.2,
109
- duration: 2.5 + Math.random() * 2,
110
- color:
111
- CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
112
- size: 6 + Math.random() * 8,
113
- rotation: Math.random() * 360,
114
- })),
115
- [],
116
- );
117
-
118
- return (
119
- <div
120
- style={{
121
- position: "absolute",
122
- inset: 0,
123
- overflow: "hidden",
124
- pointerEvents: "none",
125
- zIndex: 20,
126
- }}
127
- >
128
- {pieces.map(({ id, left, delay, duration, color, size, rotation }) => (
129
- <div
130
- key={id}
131
- style={{
132
- position: "absolute",
133
- left: `${left}%`,
134
- top: -20,
135
- width: size,
136
- height: size * 0.6,
137
- backgroundColor: color,
138
- borderRadius: 2,
139
- transform: `rotate(${rotation}deg)`,
140
- animation: `confettiFall ${duration}s ease-in ${delay}s forwards`,
141
- }}
142
- />
143
- ))}
144
- <style>{`@keyframes confettiFall{0%{top:-20px;opacity:1;transform:rotate(0deg) translateX(0)}50%{opacity:1}100%{top:110%;opacity:0;transform:rotate(720deg) translateX(${
145
- Math.random() > 0.5 ? "" : "-"
146
- }40px)}}`}</style>
147
- </div>
148
- );
149
- }
150
-
151
- function SnapLoadingOverlay({
152
- appearance,
153
- accentHex,
154
- active,
155
- }: {
156
- appearance: "light" | "dark";
157
- accentHex: string;
158
- active: boolean;
159
- }) {
160
- const isDark = appearance === "dark";
161
- const tint = isDark ? "rgba(0, 0, 0, 0.1)" : "rgba(255, 255, 255, 0.2)";
162
- const trackColor = isDark
163
- ? "rgba(255, 255, 255, 0.12)"
164
- : "rgba(15, 23, 42, 0.1)";
165
-
166
- return (
167
- <div
168
- style={{
169
- position: "absolute",
170
- inset: 0,
171
- display: "flex",
172
- alignItems: "center",
173
- justifyContent: "center",
174
- zIndex: 10,
175
- background: tint,
176
- backdropFilter: active ? "blur(10px) saturate(1.05)" : "none",
177
- WebkitBackdropFilter: active
178
- ? "blur(10px) saturate(1.05)"
179
- : "none",
180
- opacity: active ? 1 : 0,
181
- pointerEvents: active ? "auto" : "none",
182
- transition: "opacity 0.28s ease, backdrop-filter 0.28s ease",
183
- }}
184
- aria-hidden={!active}
185
- aria-busy={active ? true : undefined}
186
- aria-live={active ? "polite" : undefined}
187
- aria-label={active ? "Loading" : undefined}
188
- >
189
- <div
190
- data-snap-loading-spinner
191
- style={{
192
- width: 30,
193
- height: 30,
194
- borderRadius: "50%",
195
- border: `2.5px solid ${trackColor}`,
196
- borderTopColor: accentHex,
197
- opacity: 0.88,
198
- animation: "snapViewSpin 0.75s linear infinite",
199
- flexShrink: 0,
200
- }}
201
- />
202
- <style>{`
203
- @keyframes snapViewSpin {
204
- to { transform: rotate(360deg); }
205
- }
206
- @media (prefers-reduced-motion: reduce) {
207
- [data-snap-loading-spinner] {
208
- animation: none;
209
- border-top-color: ${accentHex};
210
- opacity: 0.75;
211
- }
212
- }
213
- `}</style>
214
- </div>
215
- );
216
- }
217
-
218
- const PALETTE = [
219
- "gray",
220
- "blue",
221
- "red",
222
- "amber",
223
- "green",
224
- "teal",
225
- "purple",
226
- "pink",
227
- ] as const;
228
-
229
- // ─── SnapView ──────────────────────────────────────────
230
-
231
- export function SnapView({
50
+ export function SnapCard({
232
51
  snap,
233
52
  handlers,
234
53
  loading = false,
235
54
  appearance = "dark",
55
+ maxWidth = 480,
56
+ showOverflowWarning = false,
57
+ onValidationError,
58
+ validationErrorFallback,
236
59
  }: {
237
60
  snap: SnapPage;
238
61
  handlers: SnapActionHandlers;
239
62
  loading?: boolean;
240
63
  appearance?: "light" | "dark";
64
+ maxWidth?: number;
65
+ /** When true, extends to 700px and shows a warning overlay below 500px. When false, clips at 500px. Only applies to v2 snaps. */
66
+ showOverflowWarning?: boolean;
67
+ onValidationError?: (result: ValidationResult) => void;
68
+ validationErrorFallback?: ReactNode;
241
69
  }) {
242
- const spec = snap.ui;
243
- const initialState = useMemo(() => spec.state ?? { inputs: {} }, [spec]);
244
-
245
- const stateRef = useRef<Record<string, unknown>>(initialState);
246
-
247
- useEffect(() => {
248
- stateRef.current = {
249
- inputs: {
250
- ...((initialState.inputs ?? {}) as Record<string, unknown>),
251
- },
252
- theme: {
253
- ...((initialState.theme ?? {}) as Record<string, unknown>),
254
- },
255
- };
256
- }, [initialState]);
257
-
258
- useEffect(() => {
259
- const result = snapJsonRenderCatalog.validate(spec);
260
- if (!result.success) {
261
- // eslint-disable-next-line no-console
262
- console.warn("[SnapView] catalog validation issues:", result.error);
263
- }
264
- }, [spec]);
265
-
266
- const [pageKey, setPageKey] = useState(0);
267
- useEffect(() => {
268
- setPageKey((k) => k + 1);
269
- }, [spec]);
270
-
271
- const showConfetti = snap.effects?.includes("confetti");
272
-
273
- // Increment key each time a new snap with confetti arrives so the overlay
274
- // unmounts/remounts and restarts its animation on every trigger.
275
- const confettiEpochRef = useRef(0);
276
- const lastConfettiSnapRef = useRef<typeof snap | null>(null);
277
- if (showConfetti && snap !== lastConfettiSnapRef.current) {
278
- confettiEpochRef.current++;
279
- lastConfettiSnapRef.current = snap;
280
- }
281
-
282
- const accentName = snap.theme?.accent ?? "purple";
283
-
284
- const accentHex = useMemo(
285
- () => resolveSnapPaletteHex(accentName, appearance),
286
- [accentName, appearance],
287
- );
288
-
289
- const previewSurfaceStyle = useMemo(() => {
290
- const vars: Record<string, string> = {};
291
- for (const c of PALETTE)
292
- vars[`--snap-color-${c}`] = resolveSnapPaletteHex(c, appearance);
293
- return {
294
- ...snapPreviewPrimaryCssProperties(accentName, appearance),
295
- ...vars,
296
- } as CSSProperties;
297
- }, [accentName, appearance]);
298
-
299
- const handleAction = useCallback(
300
- (name: unknown, params: unknown) => {
301
- const inputs = (stateRef.current.inputs ?? {}) as Record<
302
- string,
303
- JsonValue
304
- >;
305
- const p = (params ?? {}) as Record<string, unknown>;
306
- switch (name) {
307
- case "submit":
308
- handlers.submit(String(p.target ?? ""), inputs);
309
- break;
310
- case "open_url":
311
- handlers.open_url(String(p.target ?? ""));
312
- break;
313
- case "open_mini_app":
314
- handlers.open_mini_app(String(p.target ?? ""));
315
- break;
316
- case "view_cast":
317
- handlers.view_cast({ hash: String(p.hash ?? "") });
318
- break;
319
- case "view_profile":
320
- handlers.view_profile({ fid: Number(p.fid ?? 0) });
321
- break;
322
- case "compose_cast":
323
- handlers.compose_cast({
324
- text: p.text ? String(p.text) : undefined,
325
- channelKey: p.channelKey ? String(p.channelKey) : undefined,
326
- embeds: Array.isArray(p.embeds)
327
- ? (p.embeds as string[])
328
- : undefined,
329
- });
330
- break;
331
- case "view_token":
332
- handlers.view_token({ token: String(p.token ?? "") });
333
- break;
334
- case "send_token":
335
- handlers.send_token({
336
- token: String(p.token ?? ""),
337
- amount: p.amount ? String(p.amount) : undefined,
338
- recipientFid: p.recipientFid ? Number(p.recipientFid) : undefined,
339
- recipientAddress: p.recipientAddress
340
- ? String(p.recipientAddress)
341
- : undefined,
342
- });
343
- break;
344
- case "swap_token":
345
- handlers.swap_token({
346
- sellToken: p.sellToken ? String(p.sellToken) : undefined,
347
- buyToken: p.buyToken ? String(p.buyToken) : undefined,
348
- });
349
- break;
350
- default:
351
- break;
352
- }
353
- },
354
- [handlers],
355
- );
356
-
357
- return (
358
- <div style={{ position: "relative", width: "100%" }}>
359
- {showConfetti && <ConfettiOverlay key={`confetti-${confettiEpochRef.current}`} />}
360
- <SnapLoadingOverlay
70
+ if (snap.version === SPEC_VERSION_2) {
71
+ return (
72
+ <SnapCardV2
73
+ snap={snap}
74
+ handlers={handlers}
75
+ loading={loading}
361
76
  appearance={appearance}
362
- accentHex={accentHex}
363
- active={loading}
77
+ maxWidth={maxWidth}
78
+ showOverflowWarning={showOverflowWarning}
79
+ onValidationError={onValidationError}
80
+ validationErrorFallback={validationErrorFallback}
364
81
  />
82
+ );
83
+ }
365
84
 
366
- <div style={previewSurfaceStyle}>
367
- <SnapPreviewAccentProvider
368
- pageAccent={snap.theme?.accent}
369
- appearance={appearance}
370
- >
371
- <SnapCatalogView
372
- key={pageKey}
373
- spec={spec}
374
- state={initialState}
375
- loading={false}
376
- onStateChange={(changes) => {
377
- applyStatePaths(stateRef.current, changes);
378
- }}
379
- onAction={handleAction}
380
- />
381
- </SnapPreviewAccentProvider>
382
- </div>
383
- </div>
85
+ return (
86
+ <SnapCardV1
87
+ snap={snap}
88
+ handlers={handlers}
89
+ loading={loading}
90
+ appearance={appearance}
91
+ maxWidth={maxWidth}
92
+ />
384
93
  );
385
94
  }