@farcaster/snap 2.0.3 → 2.1.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/react/components/cell-grid.d.ts +3 -1
- package/dist/react/components/cell-grid.js +8 -4
- package/dist/react/snap-view-core.js +7 -2
- package/dist/react/v1/snap-view.js +40 -34
- package/dist/react/v2/snap-view.js +92 -29
- package/dist/react-native/components/snap-cell-grid.d.ts +1 -1
- package/dist/react-native/components/snap-cell-grid.js +10 -4
- package/dist/react-native/confetti-overlay.js +33 -36
- package/dist/react-native/snap-view-core.js +8 -1
- package/dist/react-native/v1/snap-view.js +41 -47
- package/dist/react-native/v2/snap-view.js +78 -16
- package/dist/ui/catalog.js +1 -1
- package/dist/validator.js +8 -33
- package/llms.txt +22 -1
- package/package.json +1 -1
- package/src/react/components/cell-grid.tsx +11 -5
- package/src/react/snap-view-core.tsx +6 -2
- package/src/react/v1/snap-view.tsx +69 -63
- package/src/react/v2/snap-view.tsx +152 -61
- package/src/react-native/components/snap-cell-grid.tsx +11 -4
- package/src/react-native/confetti-overlay.tsx +40 -37
- package/src/react-native/snap-view-core.tsx +8 -0
- package/src/react-native/v1/snap-view.tsx +34 -42
- package/src/react-native/v2/snap-view.tsx +131 -30
- package/src/ui/catalog.ts +1 -1
- package/src/validator.ts +22 -46
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ReactNode } from "react";
|
|
2
2
|
import { useEffect, useMemo, useState } from "react";
|
|
3
|
-
import { Platform, StyleSheet, Text, View } from "react-native";
|
|
3
|
+
import { Platform, Pressable, StyleSheet, Text, View } from "react-native";
|
|
4
4
|
import { SnapThemeProvider, useSnapTheme, type SnapNativeColors } from "../theme";
|
|
5
5
|
import {
|
|
6
6
|
SnapLoadingOverlay,
|
|
@@ -156,6 +156,15 @@ function SnapCardV2Inner({
|
|
|
156
156
|
const { colors, mode } = useSnapTheme();
|
|
157
157
|
const accentHex = resolveAccentHex(snap.theme?.accent, mode);
|
|
158
158
|
const [contentHeight, setContentHeight] = useState(0);
|
|
159
|
+
const [isExpanded, setIsExpanded] = useState(false);
|
|
160
|
+
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
setIsExpanded(false);
|
|
163
|
+
setContentHeight(0);
|
|
164
|
+
}, [snap]);
|
|
165
|
+
|
|
166
|
+
const isExpandable = !showOverflowWarning && contentHeight > SNAP_MAX_HEIGHT + 1;
|
|
167
|
+
const isClipped = isExpandable && !isExpanded;
|
|
159
168
|
|
|
160
169
|
const content = (
|
|
161
170
|
<SnapViewV2Inner
|
|
@@ -171,52 +180,117 @@ function SnapCardV2Inner({
|
|
|
171
180
|
if (plain) {
|
|
172
181
|
return (
|
|
173
182
|
<>
|
|
174
|
-
{
|
|
183
|
+
<View style={isClipped ? { maxHeight: SNAP_MAX_HEIGHT, overflow: "hidden" } : undefined}>
|
|
184
|
+
<View
|
|
185
|
+
collapsable={false}
|
|
186
|
+
onLayout={(e) => {
|
|
187
|
+
const nextHeight = Math.round(e.nativeEvent.layout.height);
|
|
188
|
+
setContentHeight((current) =>
|
|
189
|
+
isClipped
|
|
190
|
+
? Math.max(current, nextHeight)
|
|
191
|
+
: current === nextHeight
|
|
192
|
+
? current
|
|
193
|
+
: nextHeight,
|
|
194
|
+
);
|
|
195
|
+
}}
|
|
196
|
+
>
|
|
197
|
+
{content}
|
|
198
|
+
</View>
|
|
199
|
+
</View>
|
|
175
200
|
{loading
|
|
176
201
|
? loadingOverlay === undefined
|
|
177
202
|
? <SnapLoadingOverlay appearance={mode} accentHex={accentHex} />
|
|
178
203
|
: loadingOverlay
|
|
179
204
|
: null}
|
|
205
|
+
{isExpandable ? (
|
|
206
|
+
<View style={[cardStyles.expandRow, cardStyles.expandRowPlain]}>
|
|
207
|
+
<Pressable
|
|
208
|
+
style={({ pressed }) => [
|
|
209
|
+
cardStyles.expandButton,
|
|
210
|
+
{
|
|
211
|
+
backgroundColor: pressed ? colors.mutedHover : colors.muted,
|
|
212
|
+
},
|
|
213
|
+
]}
|
|
214
|
+
onPress={() => setIsExpanded((value) => !value)}
|
|
215
|
+
>
|
|
216
|
+
<Text style={[cardStyles.expandButtonText, { color: colors.text }]}>
|
|
217
|
+
{isExpanded ? "Show less" : "Show more"}
|
|
218
|
+
</Text>
|
|
219
|
+
</Pressable>
|
|
220
|
+
</View>
|
|
221
|
+
) : null}
|
|
180
222
|
</>
|
|
181
223
|
);
|
|
182
224
|
}
|
|
183
225
|
|
|
184
226
|
const overflowAmount = showOverflowWarning ? contentHeight - SNAP_MAX_HEIGHT : 0;
|
|
227
|
+
const isDark = mode === "dark";
|
|
228
|
+
const pillBg = isDark ? "rgba(40,40,40,0.92)" : "rgba(255,255,255,0.92)";
|
|
229
|
+
const pillBgPressed = isDark ? "rgba(60,60,60,0.95)" : "rgba(240,240,240,0.95)";
|
|
185
230
|
|
|
186
231
|
return (
|
|
187
232
|
<>
|
|
188
|
-
<View
|
|
189
|
-
style={{
|
|
190
|
-
borderRadius,
|
|
191
|
-
borderWidth: 1,
|
|
192
|
-
borderColor: colors.border,
|
|
193
|
-
backgroundColor: colors.surface,
|
|
194
|
-
maxHeight: showOverflowWarning ? undefined : SNAP_MAX_HEIGHT,
|
|
195
|
-
overflow: "hidden",
|
|
196
|
-
minHeight: 120,
|
|
197
|
-
}}
|
|
198
|
-
>
|
|
233
|
+
<View style={{ position: "relative" }}>
|
|
199
234
|
<View
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
235
|
+
style={{
|
|
236
|
+
borderRadius,
|
|
237
|
+
borderWidth: 1,
|
|
238
|
+
borderColor: colors.border,
|
|
239
|
+
backgroundColor: colors.surface,
|
|
240
|
+
maxHeight: showOverflowWarning ? undefined : isClipped ? SNAP_MAX_HEIGHT : undefined,
|
|
241
|
+
overflow: "hidden",
|
|
242
|
+
minHeight: 120,
|
|
243
|
+
}}
|
|
203
244
|
>
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
245
|
+
<View
|
|
246
|
+
collapsable={false}
|
|
247
|
+
onLayout={(e) => {
|
|
248
|
+
const nextHeight = Math.round(e.nativeEvent.layout.height);
|
|
249
|
+
setContentHeight((current) =>
|
|
250
|
+
isClipped
|
|
251
|
+
? Math.max(current, nextHeight)
|
|
252
|
+
: current === nextHeight
|
|
253
|
+
? current
|
|
254
|
+
: nextHeight,
|
|
255
|
+
);
|
|
256
|
+
}}
|
|
257
|
+
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
|
|
258
|
+
>
|
|
259
|
+
{content}
|
|
260
|
+
</View>
|
|
261
|
+
{showOverflowWarning && contentHeight > SNAP_MAX_HEIGHT && (
|
|
262
|
+
<View style={{ position: "absolute", top: SNAP_MAX_HEIGHT, left: 0, right: 0, height: overflowAmount, zIndex: 10, pointerEvents: "none" }}>
|
|
263
|
+
<View style={{ height: 1, borderTopWidth: 1, borderStyle: "dashed", borderColor: "rgba(255,100,100,0.6)" }} />
|
|
264
|
+
<View style={{ position: "absolute", top: -10, right: 4, backgroundColor: "rgba(0,0,0,0.7)", paddingHorizontal: 4, paddingVertical: 1, borderRadius: 3 }}>
|
|
265
|
+
<Text style={{ fontSize: 10, color: "rgba(255,100,100,0.7)", fontFamily: Platform.select({ ios: "Menlo", default: "monospace" }) }}>{SNAP_MAX_HEIGHT}px</Text>
|
|
266
|
+
</View>
|
|
267
|
+
<View style={{ flex: 1, backgroundColor: "rgba(255,50,50,0.15)" }} />
|
|
211
268
|
</View>
|
|
212
|
-
|
|
269
|
+
)}
|
|
270
|
+
{loading
|
|
271
|
+
? loadingOverlay === undefined
|
|
272
|
+
? <SnapLoadingOverlay appearance={mode} accentHex={accentHex} />
|
|
273
|
+
: loadingOverlay
|
|
274
|
+
: null}
|
|
275
|
+
</View>
|
|
276
|
+
{isExpandable ? (
|
|
277
|
+
<View pointerEvents="box-none" style={cardStyles.expandFloat}>
|
|
278
|
+
<Pressable
|
|
279
|
+
style={({ pressed }) => [
|
|
280
|
+
cardStyles.expandButton,
|
|
281
|
+
{
|
|
282
|
+
backgroundColor: pressed ? pillBgPressed : pillBg,
|
|
283
|
+
borderColor: colors.border,
|
|
284
|
+
},
|
|
285
|
+
]}
|
|
286
|
+
onPress={() => setIsExpanded((value) => !value)}
|
|
287
|
+
>
|
|
288
|
+
<Text style={[cardStyles.expandButtonText, { color: colors.text }]}>
|
|
289
|
+
{isExpanded ? "Show less" : "Show more"}
|
|
290
|
+
</Text>
|
|
291
|
+
</Pressable>
|
|
213
292
|
</View>
|
|
214
|
-
)}
|
|
215
|
-
{loading
|
|
216
|
-
? loadingOverlay === undefined
|
|
217
|
-
? <SnapLoadingOverlay appearance={mode} accentHex={accentHex} />
|
|
218
|
-
: loadingOverlay
|
|
219
|
-
: null}
|
|
293
|
+
) : null}
|
|
220
294
|
</View>
|
|
221
295
|
{actionError && (
|
|
222
296
|
<Text
|
|
@@ -289,6 +363,33 @@ const cardStyles = StyleSheet.create({
|
|
|
289
363
|
card: { borderWidth: 1, minHeight: 120, overflow: "hidden" },
|
|
290
364
|
body: { paddingHorizontal: 16, paddingVertical: 16 },
|
|
291
365
|
actionError: { paddingHorizontal: 12, paddingVertical: 8, fontSize: 13 },
|
|
366
|
+
expandFloat: {
|
|
367
|
+
position: "absolute",
|
|
368
|
+
left: 0,
|
|
369
|
+
right: 0,
|
|
370
|
+
bottom: -14,
|
|
371
|
+
height: 28,
|
|
372
|
+
alignItems: "center",
|
|
373
|
+
justifyContent: "center",
|
|
374
|
+
},
|
|
375
|
+
expandRowPlain: {
|
|
376
|
+
paddingTop: 8,
|
|
377
|
+
alignItems: "center",
|
|
378
|
+
},
|
|
379
|
+
expandButton: {
|
|
380
|
+
minWidth: 92,
|
|
381
|
+
alignItems: "center",
|
|
382
|
+
justifyContent: "center",
|
|
383
|
+
borderRadius: 9999,
|
|
384
|
+
borderWidth: 1,
|
|
385
|
+
paddingHorizontal: 10,
|
|
386
|
+
paddingVertical: 4,
|
|
387
|
+
},
|
|
388
|
+
expandButtonText: {
|
|
389
|
+
fontSize: 12,
|
|
390
|
+
lineHeight: 16,
|
|
391
|
+
fontWeight: "600",
|
|
392
|
+
},
|
|
292
393
|
warningOverlay: {
|
|
293
394
|
position: "absolute",
|
|
294
395
|
top: SNAP_MAX_HEIGHT,
|
package/src/ui/catalog.ts
CHANGED
|
@@ -107,7 +107,7 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
|
|
|
107
107
|
cell_grid: {
|
|
108
108
|
props: cellGridProps,
|
|
109
109
|
description:
|
|
110
|
-
"Cell grid — sparse colored cells on a rows×cols grid.
|
|
110
|
+
"Cell grid — sparse colored cells on a rows×cols grid. Two interaction modes: leave select 'off' and bind on.press to fire an action per cell press (inputs[name] is the pressed 'row,col' before the action runs); or set select 'single'/'multiple' for press-to-select with a visual ring (no auto-fire — pair with a separate submit button). on.press is ignored when select is on.",
|
|
111
111
|
},
|
|
112
112
|
},
|
|
113
113
|
actions: {
|
package/src/validator.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { snapResponseSchema } from "./schemas";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
MAX_CHILDREN,
|
|
5
|
+
MAX_DEPTH,
|
|
6
|
+
MAX_ELEMENTS,
|
|
7
|
+
MAX_ROOT_CHILDREN,
|
|
8
|
+
SPEC_VERSION_1,
|
|
9
|
+
} from "./constants";
|
|
4
10
|
import { snapJsonRenderCatalog } from "./ui/catalog.js";
|
|
5
11
|
|
|
6
12
|
export type ValidationResult = {
|
|
@@ -18,9 +24,6 @@ const URL_TARGET_ACTIONS = new Set([
|
|
|
18
24
|
"open_mini_app",
|
|
19
25
|
]);
|
|
20
26
|
|
|
21
|
-
/** Image file extensions allowed in image URLs. */
|
|
22
|
-
const ALLOWED_IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "webp"]);
|
|
23
|
-
|
|
24
27
|
/**
|
|
25
28
|
* Returns true if the URL is a loopback address (localhost dev exception).
|
|
26
29
|
*/
|
|
@@ -48,34 +51,6 @@ function validateUrl(raw: string): string | null {
|
|
|
48
51
|
return `URL must use HTTPS (got ${url.protocol.replace(":", "")}): "${raw}"`;
|
|
49
52
|
}
|
|
50
53
|
|
|
51
|
-
/**
|
|
52
|
-
* Validate an image URL: must pass URL validation + have an allowed extension.
|
|
53
|
-
*/
|
|
54
|
-
function validateImageUrl(raw: string): string | null {
|
|
55
|
-
const urlError = validateUrl(raw);
|
|
56
|
-
if (urlError) return urlError;
|
|
57
|
-
|
|
58
|
-
let url: URL;
|
|
59
|
-
try {
|
|
60
|
-
url = new URL(raw);
|
|
61
|
-
} catch {
|
|
62
|
-
return null; // already caught above
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const pathname = url.pathname;
|
|
66
|
-
const lastDot = pathname.lastIndexOf(".");
|
|
67
|
-
if (lastDot === -1) {
|
|
68
|
-
return `Image URL must end with a supported extension (${[...ALLOWED_IMAGE_EXTENSIONS].join(", ")}): "${raw}"`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const ext = pathname.slice(lastDot + 1).toLowerCase();
|
|
72
|
-
if (!ALLOWED_IMAGE_EXTENSIONS.has(ext)) {
|
|
73
|
-
return `Image URL has unsupported extension ".${ext}" (allowed: ${[...ALLOWED_IMAGE_EXTENSIONS].join(", ")}): "${raw}"`;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return null;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
54
|
// ─── Depth measurement ────────────────────────────────
|
|
80
55
|
|
|
81
56
|
/**
|
|
@@ -118,9 +93,10 @@ type ElementShape = {
|
|
|
118
93
|
* - Children per element ≤ MAX_CHILDREN
|
|
119
94
|
* - Nesting depth ≤ MAX_DEPTH
|
|
120
95
|
*/
|
|
121
|
-
function validateStructure(
|
|
122
|
-
|
|
123
|
-
|
|
96
|
+
function validateStructure(ui: {
|
|
97
|
+
root: string;
|
|
98
|
+
elements: Record<string, unknown>;
|
|
99
|
+
}): z.core.$ZodIssue[] {
|
|
124
100
|
const issues: z.core.$ZodIssue[] = [];
|
|
125
101
|
const elements = ui.elements as Record<string, ElementShape>;
|
|
126
102
|
|
|
@@ -173,19 +149,17 @@ function validateStructure(
|
|
|
173
149
|
|
|
174
150
|
/**
|
|
175
151
|
* Validate all URLs in the snap:
|
|
176
|
-
* - image.url: must
|
|
177
|
-
* - action target URLs (submit, open_url, open_snap, open_mini_app): must
|
|
152
|
+
* - image.url: must use HTTPS (or HTTP on loopback for dev)
|
|
153
|
+
* - action target URLs (submit, open_url, open_snap, open_mini_app): must use HTTPS (or HTTP on loopback for dev)
|
|
178
154
|
*/
|
|
179
|
-
function validateUrls(
|
|
180
|
-
elements: Record<string, unknown>,
|
|
181
|
-
): z.core.$ZodIssue[] {
|
|
155
|
+
function validateUrls(elements: Record<string, unknown>): z.core.$ZodIssue[] {
|
|
182
156
|
const issues: z.core.$ZodIssue[] = [];
|
|
183
157
|
const els = elements as Record<string, ElementShape>;
|
|
184
158
|
|
|
185
159
|
for (const [id, el] of Object.entries(els)) {
|
|
186
160
|
// Validate image URLs
|
|
187
161
|
if (el.type === "image" && typeof el.props?.url === "string") {
|
|
188
|
-
const error =
|
|
162
|
+
const error = validateUrl(el.props.url);
|
|
189
163
|
if (error) {
|
|
190
164
|
issues.push({
|
|
191
165
|
code: "custom",
|
|
@@ -242,11 +216,13 @@ export function validateSnapResponse(json: unknown): ValidationResult {
|
|
|
242
216
|
if (!(ui.root in ui.elements)) {
|
|
243
217
|
return {
|
|
244
218
|
valid: false,
|
|
245
|
-
issues: [
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
219
|
+
issues: [
|
|
220
|
+
{
|
|
221
|
+
code: "custom",
|
|
222
|
+
message: `ui.root "${ui.root}" does not exist in ui.elements`,
|
|
223
|
+
path: ["ui", "root"],
|
|
224
|
+
},
|
|
225
|
+
],
|
|
250
226
|
};
|
|
251
227
|
}
|
|
252
228
|
|