@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.
@@ -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
- {content}
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
- collapsable={false}
201
- onLayout={(e) => setContentHeight(Math.round(e.nativeEvent.layout.height))}
202
- style={{ paddingHorizontal: 16, paddingVertical: 16 }}
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
- {content}
205
- </View>
206
- {showOverflowWarning && contentHeight > SNAP_MAX_HEIGHT && (
207
- <View style={{ position: "absolute", top: SNAP_MAX_HEIGHT, left: 0, right: 0, height: overflowAmount, zIndex: 10, pointerEvents: "none" }}>
208
- <View style={{ height: 1, borderTopWidth: 1, borderStyle: "dashed", borderColor: "rgba(255,100,100,0.6)" }} />
209
- <View style={{ position: "absolute", top: -10, right: 4, backgroundColor: "rgba(0,0,0,0.7)", paddingHorizontal: 4, paddingVertical: 1, borderRadius: 3 }}>
210
- <Text style={{ fontSize: 10, color: "rgba(255,100,100,0.7)", fontFamily: Platform.select({ ios: "Menlo", default: "monospace" }) }}>{SNAP_MAX_HEIGHT}px</Text>
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
- <View style={{ flex: 1, backgroundColor: "rgba(255,50,50,0.15)" }} />
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. Optional gap and selection mode (taps write to inputs[name]).",
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 { MAX_CHILDREN, MAX_DEPTH, MAX_ELEMENTS, MAX_ROOT_CHILDREN, SPEC_VERSION_1 } from "./constants";
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
- ui: { root: string; elements: Record<string, unknown> },
123
- ): z.core.$ZodIssue[] {
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 be HTTPS with allowed extension
177
- * - action target URLs (submit, open_url, open_snap, open_mini_app): must be HTTPS
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 = validateImageUrl(el.props.url);
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
- code: "custom",
247
- message: `ui.root "${ui.root}" does not exist in ui.elements`,
248
- path: ["ui", "root"],
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