@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.
@@ -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
- {content}
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
- collapsable={false}
201
- onLayout={(e) => setContentHeight(Math.round(e.nativeEvent.layout.height))}
202
- style={{ paddingHorizontal: 16, paddingVertical: 16 }}
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
- {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>
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
- <View style={{ flex: 1, backgroundColor: "rgba(255,50,50,0.15)" }} />
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. 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