@farcaster/snap 1.15.4 → 1.16.1

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 (51) 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/badge.js +2 -3
  6. package/dist/react/index.d.ts +9 -4
  7. package/dist/react/index.js +9 -228
  8. package/dist/react/snap-view-core.d.ts +11 -0
  9. package/dist/react/snap-view-core.js +224 -0
  10. package/dist/react/v1/snap-view.d.ts +14 -0
  11. package/dist/react/v1/snap-view.js +9 -0
  12. package/dist/react/v2/snap-view.d.ts +21 -0
  13. package/dist/react/v2/snap-view.js +76 -0
  14. package/dist/react-native/components/snap-badge.js +3 -3
  15. package/dist/react-native/index.d.ts +15 -45
  16. package/dist/react-native/index.js +10 -166
  17. package/dist/react-native/snap-view-core.d.ts +11 -0
  18. package/dist/react-native/snap-view-core.js +153 -0
  19. package/dist/react-native/types.d.ts +41 -0
  20. package/dist/react-native/types.js +1 -0
  21. package/dist/react-native/v1/snap-view.d.ts +22 -0
  22. package/dist/react-native/v1/snap-view.js +31 -0
  23. package/dist/react-native/v2/snap-view.d.ts +31 -0
  24. package/dist/react-native/v2/snap-view.js +101 -0
  25. package/dist/schemas.d.ts +15 -9
  26. package/dist/schemas.js +7 -8
  27. package/dist/server/parseRequest.d.ts +7 -0
  28. package/dist/server/parseRequest.js +22 -0
  29. package/dist/ui/schema.js +1 -1
  30. package/dist/validator.d.ts +3 -2
  31. package/dist/validator.js +193 -2
  32. package/llms.txt +9 -0
  33. package/package.json +1 -1
  34. package/src/constants.ts +11 -1
  35. package/src/index.ts +8 -0
  36. package/src/react/accent-context.tsx +1 -1
  37. package/src/react/components/badge.tsx +2 -3
  38. package/src/react/index.tsx +37 -330
  39. package/src/react/snap-view-core.tsx +340 -0
  40. package/src/react/v1/snap-view.tsx +50 -0
  41. package/src/react/v2/snap-view.tsx +168 -0
  42. package/src/react-native/components/snap-badge.tsx +3 -3
  43. package/src/react-native/index.tsx +47 -267
  44. package/src/react-native/snap-view-core.tsx +209 -0
  45. package/src/react-native/types.ts +37 -0
  46. package/src/react-native/v1/snap-view.tsx +108 -0
  47. package/src/react-native/v2/snap-view.tsx +239 -0
  48. package/src/schemas.ts +9 -10
  49. package/src/server/parseRequest.ts +34 -0
  50. package/src/ui/schema.ts +1 -1
  51. package/src/validator.ts +240 -2
@@ -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
 
@@ -34,7 +26,7 @@ export type SnapPage = {
34
26
 
35
27
  export type SnapActionHandlers = {
36
28
  submit: (target: string, inputs: Record<string, JsonValue>) => void;
37
- open_url: (target: string, options?: { isSnap?: boolean }) => void;
29
+ open_url: (target: string) => void;
38
30
  open_mini_app: (target: string) => void;
39
31
  view_cast: (params: { hash: string }) => void;
40
32
  view_profile: (params: { fid: number }) => void;
@@ -53,335 +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 ? "blur(10px) saturate(1.05)" : "none",
178
- opacity: active ? 1 : 0,
179
- pointerEvents: active ? "auto" : "none",
180
- transition: "opacity 0.28s ease, backdrop-filter 0.28s ease",
181
- }}
182
- aria-hidden={!active}
183
- aria-busy={active ? true : undefined}
184
- aria-live={active ? "polite" : undefined}
185
- aria-label={active ? "Loading" : undefined}
186
- >
187
- <div
188
- data-snap-loading-spinner
189
- style={{
190
- width: 30,
191
- height: 30,
192
- borderRadius: "50%",
193
- border: `2.5px solid ${trackColor}`,
194
- borderTopColor: accentHex,
195
- opacity: 0.88,
196
- animation: "snapViewSpin 0.75s linear infinite",
197
- flexShrink: 0,
198
- }}
199
- />
200
- <style>{`
201
- @keyframes snapViewSpin {
202
- to { transform: rotate(360deg); }
203
- }
204
- @media (prefers-reduced-motion: reduce) {
205
- [data-snap-loading-spinner] {
206
- animation: none;
207
- border-top-color: ${accentHex};
208
- opacity: 0.75;
209
- }
210
- }
211
- `}</style>
212
- </div>
213
- );
214
- }
215
-
216
- const PALETTE = [
217
- "gray",
218
- "blue",
219
- "red",
220
- "amber",
221
- "green",
222
- "teal",
223
- "purple",
224
- "pink",
225
- ] as const;
226
-
227
- // ─── SnapView ──────────────────────────────────────────
228
-
229
- export function SnapView({
50
+ export function SnapCard({
230
51
  snap,
231
52
  handlers,
232
53
  loading = false,
233
54
  appearance = "dark",
55
+ maxWidth = 480,
56
+ showOverflowWarning = false,
57
+ onValidationError,
58
+ validationErrorFallback,
234
59
  }: {
235
60
  snap: SnapPage;
236
61
  handlers: SnapActionHandlers;
237
62
  loading?: boolean;
238
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;
239
69
  }) {
240
- const spec = snap.ui;
241
- const initialState = useMemo(() => spec.state ?? { inputs: {} }, [spec]);
242
-
243
- const stateRef = useRef<Record<string, unknown>>(initialState);
244
-
245
- useEffect(() => {
246
- stateRef.current = {
247
- inputs: {
248
- ...((initialState.inputs ?? {}) as Record<string, unknown>),
249
- },
250
- theme: {
251
- ...((initialState.theme ?? {}) as Record<string, unknown>),
252
- },
253
- };
254
- }, [initialState]);
255
-
256
- useEffect(() => {
257
- const result = snapJsonRenderCatalog.validate(spec);
258
- if (!result.success) {
259
- // eslint-disable-next-line no-console
260
- console.warn("[SnapView] catalog validation issues:", result.error);
261
- }
262
- }, [spec]);
263
-
264
- const [pageKey, setPageKey] = useState(0);
265
- useEffect(() => {
266
- setPageKey((k) => k + 1);
267
- }, [spec]);
268
-
269
- const showConfetti = snap.effects?.includes("confetti");
270
-
271
- // Increment key each time a new snap with confetti arrives so the overlay
272
- // unmounts/remounts and restarts its animation on every trigger.
273
- const confettiEpochRef = useRef(0);
274
- const lastConfettiSnapRef = useRef<typeof snap | null>(null);
275
- if (showConfetti && snap !== lastConfettiSnapRef.current) {
276
- confettiEpochRef.current++;
277
- lastConfettiSnapRef.current = snap;
278
- }
279
-
280
- const accentName = snap.theme?.accent ?? "purple";
281
-
282
- const accentHex = useMemo(
283
- () => resolveSnapPaletteHex(accentName, appearance),
284
- [accentName, appearance],
285
- );
286
-
287
- const previewSurfaceStyle = useMemo(() => {
288
- const vars: Record<string, string> = {};
289
- for (const c of PALETTE)
290
- vars[`--snap-color-${c}`] = resolveSnapPaletteHex(c, appearance);
291
- return {
292
- ...snapPreviewPrimaryCssProperties(accentName, appearance),
293
- ...vars,
294
- } as CSSProperties;
295
- }, [accentName, appearance]);
296
-
297
- const handleAction = useCallback(
298
- (name: unknown, params: unknown) => {
299
- const inputs = (stateRef.current.inputs ?? {}) as Record<
300
- string,
301
- JsonValue
302
- >;
303
- const p = (params ?? {}) as Record<string, unknown>;
304
- switch (name) {
305
- case "submit":
306
- handlers.submit(String(p.target ?? ""), inputs);
307
- break;
308
- case "open_url":
309
- handlers.open_url(String(p.target ?? ""), {
310
- isSnap: p.isSnap === true,
311
- });
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 && (
360
- <ConfettiOverlay key={`confetti-${confettiEpochRef.current}`} />
361
- )}
362
- <SnapLoadingOverlay
70
+ if (snap.version === SPEC_VERSION_2) {
71
+ return (
72
+ <SnapCardV2
73
+ snap={snap}
74
+ handlers={handlers}
75
+ loading={loading}
363
76
  appearance={appearance}
364
- accentHex={accentHex}
365
- active={loading}
77
+ maxWidth={maxWidth}
78
+ showOverflowWarning={showOverflowWarning}
79
+ onValidationError={onValidationError}
80
+ validationErrorFallback={validationErrorFallback}
366
81
  />
82
+ );
83
+ }
367
84
 
368
- <div style={previewSurfaceStyle}>
369
- <SnapPreviewAccentProvider
370
- pageAccent={snap.theme?.accent}
371
- appearance={appearance}
372
- >
373
- <SnapCatalogView
374
- key={pageKey}
375
- spec={spec}
376
- state={initialState}
377
- loading={false}
378
- onStateChange={(changes) => {
379
- applyStatePaths(stateRef.current, changes);
380
- }}
381
- onAction={handleAction}
382
- />
383
- </SnapPreviewAccentProvider>
384
- </div>
385
- </div>
85
+ return (
86
+ <SnapCardV1
87
+ snap={snap}
88
+ handlers={handlers}
89
+ loading={loading}
90
+ appearance={appearance}
91
+ maxWidth={maxWidth}
92
+ />
386
93
  );
387
94
  }