@farcaster/snap 2.0.3 → 2.1.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.
- 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 +96 -30
- 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 +79 -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 +160 -63
- 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 +134 -32
- 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,
|
|
@@ -17,6 +17,7 @@ import type { SnapPage, SnapActionHandlers } from "../types";
|
|
|
17
17
|
|
|
18
18
|
const SNAP_MAX_HEIGHT = 500;
|
|
19
19
|
const SNAP_WARNING_HEIGHT = 700;
|
|
20
|
+
const SHOW_MORE_OVERHANG = 14;
|
|
20
21
|
|
|
21
22
|
// ─── Validation fallback ─────────────────────────────
|
|
22
23
|
|
|
@@ -156,6 +157,15 @@ function SnapCardV2Inner({
|
|
|
156
157
|
const { colors, mode } = useSnapTheme();
|
|
157
158
|
const accentHex = resolveAccentHex(snap.theme?.accent, mode);
|
|
158
159
|
const [contentHeight, setContentHeight] = useState(0);
|
|
160
|
+
const [isExpanded, setIsExpanded] = useState(false);
|
|
161
|
+
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
setIsExpanded(false);
|
|
164
|
+
setContentHeight(0);
|
|
165
|
+
}, [snap]);
|
|
166
|
+
|
|
167
|
+
const isExpandable = !showOverflowWarning && contentHeight > SNAP_MAX_HEIGHT + 1;
|
|
168
|
+
const isClipped = isExpandable && !isExpanded;
|
|
159
169
|
|
|
160
170
|
const content = (
|
|
161
171
|
<SnapViewV2Inner
|
|
@@ -171,52 +181,117 @@ function SnapCardV2Inner({
|
|
|
171
181
|
if (plain) {
|
|
172
182
|
return (
|
|
173
183
|
<>
|
|
174
|
-
{
|
|
184
|
+
<View style={isClipped ? { maxHeight: SNAP_MAX_HEIGHT, overflow: "hidden" } : undefined}>
|
|
185
|
+
<View
|
|
186
|
+
collapsable={false}
|
|
187
|
+
onLayout={(e) => {
|
|
188
|
+
const nextHeight = Math.round(e.nativeEvent.layout.height);
|
|
189
|
+
setContentHeight((current) =>
|
|
190
|
+
isClipped
|
|
191
|
+
? Math.max(current, nextHeight)
|
|
192
|
+
: current === nextHeight
|
|
193
|
+
? current
|
|
194
|
+
: nextHeight,
|
|
195
|
+
);
|
|
196
|
+
}}
|
|
197
|
+
>
|
|
198
|
+
{content}
|
|
199
|
+
</View>
|
|
200
|
+
</View>
|
|
175
201
|
{loading
|
|
176
202
|
? loadingOverlay === undefined
|
|
177
203
|
? <SnapLoadingOverlay appearance={mode} accentHex={accentHex} />
|
|
178
204
|
: loadingOverlay
|
|
179
205
|
: null}
|
|
206
|
+
{isExpandable ? (
|
|
207
|
+
<View style={[cardStyles.expandRow, cardStyles.expandRowPlain]}>
|
|
208
|
+
<Pressable
|
|
209
|
+
style={({ pressed }) => [
|
|
210
|
+
cardStyles.expandButton,
|
|
211
|
+
{
|
|
212
|
+
backgroundColor: pressed ? colors.mutedHover : colors.muted,
|
|
213
|
+
},
|
|
214
|
+
]}
|
|
215
|
+
onPress={() => setIsExpanded((value) => !value)}
|
|
216
|
+
>
|
|
217
|
+
<Text style={[cardStyles.expandButtonText, { color: colors.text }]}>
|
|
218
|
+
{isExpanded ? "Show less" : "Show more"}
|
|
219
|
+
</Text>
|
|
220
|
+
</Pressable>
|
|
221
|
+
</View>
|
|
222
|
+
) : null}
|
|
180
223
|
</>
|
|
181
224
|
);
|
|
182
225
|
}
|
|
183
226
|
|
|
184
227
|
const overflowAmount = showOverflowWarning ? contentHeight - SNAP_MAX_HEIGHT : 0;
|
|
228
|
+
const isDark = mode === "dark";
|
|
229
|
+
const pillBg = isDark ? "rgba(40,40,40,0.92)" : "rgba(255,255,255,0.92)";
|
|
230
|
+
const pillBgPressed = isDark ? "rgba(60,60,60,0.95)" : "rgba(240,240,240,0.95)";
|
|
185
231
|
|
|
186
232
|
return (
|
|
187
|
-
|
|
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={{ paddingBottom: isExpandable ? SHOW_MORE_OVERHANG : 0 }}>
|
|
234
|
+
<View style={{ position: "relative" }}>
|
|
199
235
|
<View
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
236
|
+
style={{
|
|
237
|
+
borderRadius,
|
|
238
|
+
borderWidth: 1,
|
|
239
|
+
borderColor: colors.border,
|
|
240
|
+
backgroundColor: colors.surface,
|
|
241
|
+
maxHeight: showOverflowWarning ? undefined : isClipped ? SNAP_MAX_HEIGHT : undefined,
|
|
242
|
+
overflow: "hidden",
|
|
243
|
+
minHeight: 120,
|
|
244
|
+
}}
|
|
203
245
|
>
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
246
|
+
<View
|
|
247
|
+
collapsable={false}
|
|
248
|
+
onLayout={(e) => {
|
|
249
|
+
const nextHeight = Math.round(e.nativeEvent.layout.height);
|
|
250
|
+
setContentHeight((current) =>
|
|
251
|
+
isClipped
|
|
252
|
+
? Math.max(current, nextHeight)
|
|
253
|
+
: current === nextHeight
|
|
254
|
+
? current
|
|
255
|
+
: nextHeight,
|
|
256
|
+
);
|
|
257
|
+
}}
|
|
258
|
+
style={{ paddingHorizontal: 16, paddingVertical: 16 }}
|
|
259
|
+
>
|
|
260
|
+
{content}
|
|
261
|
+
</View>
|
|
262
|
+
{showOverflowWarning && contentHeight > SNAP_MAX_HEIGHT && (
|
|
263
|
+
<View style={{ position: "absolute", top: SNAP_MAX_HEIGHT, left: 0, right: 0, height: overflowAmount, zIndex: 10, pointerEvents: "none" }}>
|
|
264
|
+
<View style={{ height: 1, borderTopWidth: 1, borderStyle: "dashed", borderColor: "rgba(255,100,100,0.6)" }} />
|
|
265
|
+
<View style={{ position: "absolute", top: -10, right: 4, backgroundColor: "rgba(0,0,0,0.7)", paddingHorizontal: 4, paddingVertical: 1, borderRadius: 3 }}>
|
|
266
|
+
<Text style={{ fontSize: 10, color: "rgba(255,100,100,0.7)", fontFamily: Platform.select({ ios: "Menlo", default: "monospace" }) }}>{SNAP_MAX_HEIGHT}px</Text>
|
|
267
|
+
</View>
|
|
268
|
+
<View style={{ flex: 1, backgroundColor: "rgba(255,50,50,0.15)" }} />
|
|
211
269
|
</View>
|
|
212
|
-
|
|
270
|
+
)}
|
|
271
|
+
{loading
|
|
272
|
+
? loadingOverlay === undefined
|
|
273
|
+
? <SnapLoadingOverlay appearance={mode} accentHex={accentHex} />
|
|
274
|
+
: loadingOverlay
|
|
275
|
+
: null}
|
|
276
|
+
</View>
|
|
277
|
+
{isExpandable ? (
|
|
278
|
+
<View pointerEvents="box-none" style={cardStyles.expandFloat}>
|
|
279
|
+
<Pressable
|
|
280
|
+
style={({ pressed }) => [
|
|
281
|
+
cardStyles.expandButton,
|
|
282
|
+
{
|
|
283
|
+
backgroundColor: pressed ? pillBgPressed : pillBg,
|
|
284
|
+
borderColor: colors.border,
|
|
285
|
+
},
|
|
286
|
+
]}
|
|
287
|
+
onPress={() => setIsExpanded((value) => !value)}
|
|
288
|
+
>
|
|
289
|
+
<Text style={[cardStyles.expandButtonText, { color: colors.text }]}>
|
|
290
|
+
{isExpanded ? "Show less" : "Show more"}
|
|
291
|
+
</Text>
|
|
292
|
+
</Pressable>
|
|
213
293
|
</View>
|
|
214
|
-
)}
|
|
215
|
-
{loading
|
|
216
|
-
? loadingOverlay === undefined
|
|
217
|
-
? <SnapLoadingOverlay appearance={mode} accentHex={accentHex} />
|
|
218
|
-
: loadingOverlay
|
|
219
|
-
: null}
|
|
294
|
+
) : null}
|
|
220
295
|
</View>
|
|
221
296
|
{actionError && (
|
|
222
297
|
<Text
|
|
@@ -233,7 +308,7 @@ function SnapCardV2Inner({
|
|
|
233
308
|
{actionError}
|
|
234
309
|
</Text>
|
|
235
310
|
)}
|
|
236
|
-
|
|
311
|
+
</View>
|
|
237
312
|
);
|
|
238
313
|
}
|
|
239
314
|
|
|
@@ -289,6 +364,33 @@ const cardStyles = StyleSheet.create({
|
|
|
289
364
|
card: { borderWidth: 1, minHeight: 120, overflow: "hidden" },
|
|
290
365
|
body: { paddingHorizontal: 16, paddingVertical: 16 },
|
|
291
366
|
actionError: { paddingHorizontal: 12, paddingVertical: 8, fontSize: 13 },
|
|
367
|
+
expandFloat: {
|
|
368
|
+
position: "absolute",
|
|
369
|
+
left: 0,
|
|
370
|
+
right: 0,
|
|
371
|
+
bottom: -14,
|
|
372
|
+
height: 28,
|
|
373
|
+
alignItems: "center",
|
|
374
|
+
justifyContent: "center",
|
|
375
|
+
},
|
|
376
|
+
expandRowPlain: {
|
|
377
|
+
paddingTop: 8,
|
|
378
|
+
alignItems: "center",
|
|
379
|
+
},
|
|
380
|
+
expandButton: {
|
|
381
|
+
minWidth: 92,
|
|
382
|
+
alignItems: "center",
|
|
383
|
+
justifyContent: "center",
|
|
384
|
+
borderRadius: 9999,
|
|
385
|
+
borderWidth: 1,
|
|
386
|
+
paddingHorizontal: 10,
|
|
387
|
+
paddingVertical: 4,
|
|
388
|
+
},
|
|
389
|
+
expandButtonText: {
|
|
390
|
+
fontSize: 12,
|
|
391
|
+
lineHeight: 16,
|
|
392
|
+
fontWeight: "600",
|
|
393
|
+
},
|
|
292
394
|
warningOverlay: {
|
|
293
395
|
position: "absolute",
|
|
294
396
|
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
|
|