@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
  "use client";
2
2
 
3
- import { type ReactNode, useEffect, useMemo } from "react";
3
+ import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { validateSnapResponse } from "../../validator.js";
5
5
  import type { ValidationResult } from "../../validator.js";
6
6
  import { SnapViewCore, SnapLoadingOverlay } from "../snap-view-core";
@@ -9,6 +9,7 @@ import type { SnapPage, SnapActionHandlers } from "../index";
9
9
 
10
10
  const SNAP_MAX_HEIGHT = 500;
11
11
  const SNAP_WARNING_HEIGHT = 700;
12
+ const SHOW_MORE_OVERHANG = 14;
12
13
 
13
14
  // ─── Default validation error fallback ────────────────
14
15
 
@@ -116,90 +117,186 @@ export function SnapCardV2({
116
117
  /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
117
118
  loadingOverlay?: ReactNode;
118
119
  }) {
119
- const maxHeight = showOverflowWarning ? SNAP_WARNING_HEIGHT : SNAP_MAX_HEIGHT;
120
120
  const isDark = appearance === "dark";
121
121
  const bg = isDark ? "rgba(0,0,0,0.85)" : "rgba(255,255,255,0.9)";
122
122
  const borderColor = isDark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
123
123
  const surfaceBg = isDark ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.02)";
124
+ const toggleBg = isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.05)";
125
+ const toggleBgHover = isDark
126
+ ? "rgba(255,255,255,0.1)"
127
+ : "rgba(0,0,0,0.08)";
128
+ const toggleText = isDark ? "rgba(255,255,255,0.82)" : "rgba(0,0,0,0.72)";
124
129
  const accentHex = useMemo(
125
130
  () => resolveSnapPaletteHex(snap.theme?.accent ?? "purple", appearance),
126
131
  [snap.theme?.accent, appearance],
127
132
  );
128
133
 
134
+ const contentRef = useRef<HTMLDivElement>(null);
135
+ const [isExpandable, setIsExpandable] = useState(false);
136
+ const [isExpanded, setIsExpanded] = useState(false);
137
+
138
+ useEffect(() => {
139
+ setIsExpanded(false);
140
+ }, [snap]);
141
+
142
+ useEffect(() => {
143
+ const node = contentRef.current;
144
+ if (!node) return;
145
+
146
+ const measure = () => {
147
+ setIsExpandable(node.scrollHeight > SNAP_MAX_HEIGHT + 1);
148
+ };
149
+
150
+ measure();
151
+
152
+ if (typeof ResizeObserver === "undefined") return;
153
+ const observer = new ResizeObserver(() => {
154
+ measure();
155
+ });
156
+ observer.observe(node);
157
+ return () => observer.disconnect();
158
+ }, [snap, plain, showOverflowWarning]);
159
+
160
+ useEffect(() => {
161
+ if (!isExpandable) {
162
+ setIsExpanded(false);
163
+ }
164
+ }, [isExpandable]);
165
+
166
+ const isClipped = !showOverflowWarning && isExpandable && !isExpanded;
167
+ const containerMaxHeight = showOverflowWarning ? SNAP_WARNING_HEIGHT : undefined;
168
+
129
169
  return (
130
- <>
170
+ <div
171
+ style={{
172
+ paddingBottom:
173
+ !showOverflowWarning && isExpandable ? SHOW_MORE_OVERHANG : 0,
174
+ }}
175
+ >
131
176
  <div
132
177
  style={{
133
178
  position: "relative",
134
179
  width: "100%",
135
180
  maxWidth,
136
- maxHeight,
137
- overflow: "hidden",
138
- ...(plain ? {} : {
139
- borderRadius: 16,
140
- border: `1px solid ${borderColor}`,
141
- backgroundColor: surfaceBg,
142
- }),
143
181
  }}
144
182
  >
145
- <div style={plain ? undefined : { padding: 16 }}>
146
- <SnapViewV2
147
- snap={snap}
148
- handlers={handlers}
149
- loading={loading}
150
- appearance={appearance}
151
- onValidationError={onValidationError}
152
- validationErrorFallback={validationErrorFallback}
153
- loadingOverlay={null}
154
- />
155
- </div>
156
- {loadingOverlay === undefined ? (
157
- <SnapLoadingOverlay
158
- appearance={appearance}
159
- accentHex={accentHex}
160
- active={loading}
161
- />
162
- ) : loading ? (
163
- <>{loadingOverlay}</>
164
- ) : null}
165
- {showOverflowWarning && (
183
+ <div
184
+ style={{
185
+ position: "relative",
186
+ maxHeight: containerMaxHeight,
187
+ overflow: "hidden",
188
+ ...(plain ? {} : {
189
+ borderRadius: 16,
190
+ border: `1px solid ${borderColor}`,
191
+ backgroundColor: surfaceBg,
192
+ }),
193
+ }}
194
+ >
166
195
  <div
167
- style={{
168
- position: "absolute",
169
- top: SNAP_MAX_HEIGHT,
170
- left: 0,
171
- right: 0,
172
- bottom: 0,
173
- pointerEvents: "none",
174
- zIndex: 10,
175
- }}
196
+ style={
197
+ isClipped
198
+ ? { maxHeight: SNAP_MAX_HEIGHT, overflow: "hidden" }
199
+ : undefined
200
+ }
176
201
  >
177
- <div style={{ borderTop: "1px dashed rgba(255,100,100,0.6)", position: "relative" }}>
178
- <span
179
- style={{
180
- position: "absolute",
181
- top: -10,
182
- right: 0,
183
- fontSize: 10,
184
- fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
185
- color: "rgba(255,100,100,0.7)",
186
- background: bg,
187
- padding: "1px 4px",
188
- borderRadius: 3,
189
- }}
190
- >
191
- {SNAP_MAX_HEIGHT}px
192
- </span>
202
+ <div ref={contentRef} style={plain ? undefined : { padding: 16 }}>
203
+ <SnapViewV2
204
+ snap={snap}
205
+ handlers={handlers}
206
+ loading={loading}
207
+ appearance={appearance}
208
+ onValidationError={onValidationError}
209
+ validationErrorFallback={validationErrorFallback}
210
+ loadingOverlay={null}
211
+ />
193
212
  </div>
213
+ </div>
214
+ {loadingOverlay === undefined ? (
215
+ <SnapLoadingOverlay
216
+ appearance={appearance}
217
+ accentHex={accentHex}
218
+ active={loading}
219
+ />
220
+ ) : loading ? (
221
+ <>{loadingOverlay}</>
222
+ ) : null}
223
+ {showOverflowWarning && (
194
224
  <div
195
225
  style={{
196
- height: "100%",
197
- background:
198
- "repeating-linear-gradient(-45deg, transparent, transparent 8px, rgba(255,100,100,0.06) 8px, rgba(255,100,100,0.06) 16px)",
226
+ position: "absolute",
227
+ top: SNAP_MAX_HEIGHT,
228
+ left: 0,
229
+ right: 0,
230
+ bottom: 0,
231
+ pointerEvents: "none",
232
+ zIndex: 10,
199
233
  }}
200
- />
201
- </div>
202
- )}
234
+ >
235
+ <div style={{ borderTop: "1px dashed rgba(255,100,100,0.6)", position: "relative" }}>
236
+ <span
237
+ style={{
238
+ position: "absolute",
239
+ top: -10,
240
+ right: 0,
241
+ fontSize: 10,
242
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
243
+ color: "rgba(255,100,100,0.7)",
244
+ background: bg,
245
+ padding: "1px 4px",
246
+ borderRadius: 3,
247
+ }}
248
+ >
249
+ {SNAP_MAX_HEIGHT}px
250
+ </span>
251
+ </div>
252
+ <div
253
+ style={{
254
+ height: "100%",
255
+ background:
256
+ "repeating-linear-gradient(-45deg, transparent, transparent 8px, rgba(255,100,100,0.06) 8px, rgba(255,100,100,0.06) 16px)",
257
+ }}
258
+ />
259
+ </div>
260
+ )}
261
+ </div>
262
+ {!showOverflowWarning && isExpandable ? (
263
+ <button
264
+ type="button"
265
+ aria-expanded={isExpanded}
266
+ onClick={() => setIsExpanded((value) => !value)}
267
+ style={{
268
+ position: "absolute",
269
+ bottom: 0,
270
+ left: "50%",
271
+ transform: "translate(-50%, 50%)",
272
+ appearance: "none",
273
+ border: `1px solid ${borderColor}`,
274
+ borderRadius: 9999,
275
+ backgroundColor: isDark ? "rgba(30,30,30,0.6)" : "rgba(255,255,255,0.6)",
276
+ backdropFilter: "blur(12px) saturate(180%)",
277
+ WebkitBackdropFilter: "blur(12px) saturate(180%)",
278
+ color: toggleText,
279
+ padding: "2px 10px",
280
+ fontSize: 12,
281
+ lineHeight: "16px",
282
+ fontWeight: 600,
283
+ cursor: "pointer",
284
+ zIndex: 11,
285
+ }}
286
+ onMouseEnter={(event) => {
287
+ event.currentTarget.style.backgroundColor = isDark
288
+ ? "rgba(50,50,50,0.7)"
289
+ : "rgba(245,245,245,0.75)";
290
+ }}
291
+ onMouseLeave={(event) => {
292
+ event.currentTarget.style.backgroundColor = isDark
293
+ ? "rgba(30,30,30,0.6)"
294
+ : "rgba(255,255,255,0.6)";
295
+ }}
296
+ >
297
+ {isExpanded ? "Show less" : "Show more"}
298
+ </button>
299
+ ) : null}
203
300
  </div>
204
301
  {actionError && (
205
302
  <div
@@ -216,6 +313,6 @@ export function SnapCardV2({
216
313
  {actionError}
217
314
  </div>
218
315
  )}
219
- </>
316
+ </div>
220
317
  );
221
318
  }
@@ -6,8 +6,11 @@ import { useSnapTheme } from "../theme";
6
6
  import { POST_GRID_TAP_KEY } from "@farcaster/snap";
7
7
 
8
8
  export function SnapCellGrid({
9
- element: { props },
9
+ element,
10
+ emit,
10
11
  }: ComponentRenderProps<Record<string, unknown>>) {
12
+ const { props } = element;
13
+ const on = (element as unknown as { on?: Record<string, unknown> }).on;
11
14
  const { hex, appearance } = useSnapPalette();
12
15
  const { colors } = useSnapTheme();
13
16
  const { get, set } = useStateStore();
@@ -20,8 +23,10 @@ export function SnapCellGrid({
20
23
  const gapPx = gapMap[gap] ?? 1;
21
24
 
22
25
  const select = String(props.select ?? "off");
23
- const interactive = select !== "off";
24
26
  const isMultiple = select === "multiple";
27
+ const isSelectable = select !== "off";
28
+ const hasPressAction = Boolean(on?.press);
29
+ const interactive = isSelectable || hasPressAction;
25
30
 
26
31
  const name = props.name ? String(props.name) : POST_GRID_TAP_KEY;
27
32
  const tapPath = `/inputs/${name}`;
@@ -34,7 +39,8 @@ export function SnapCellGrid({
34
39
  }
35
40
  }
36
41
 
37
- const isSelected = (r: number, c: number) => selectedSet.has(`${r},${c}`);
42
+ const isSelected = (r: number, c: number) =>
43
+ isSelectable && selectedSet.has(`${r},${c}`);
38
44
 
39
45
  const handleTap = (r: number, c: number) => {
40
46
  const key = `${r},${c}`;
@@ -46,6 +52,7 @@ export function SnapCellGrid({
46
52
  } else {
47
53
  set(tapPath, key);
48
54
  }
55
+ if (hasPressAction) emit("press");
49
56
  };
50
57
 
51
58
  const cellMap = new Map<string, { color?: string; content?: string }>();
@@ -114,7 +121,7 @@ export function SnapCellGrid({
114
121
  );
115
122
  }
116
123
 
117
- const selectionLabel = interactive && selectedSet.size > 0
124
+ const selectionLabel = isSelectable && selectedSet.size > 0
118
125
  ? `inputs.${name}: ${[...selectedSet].join(isMultiple ? " | " : "")}`
119
126
  : null;
120
127
 
@@ -30,7 +30,10 @@ export function ConfettiOverlay() {
30
30
  CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)]!,
31
31
  size: 6 + Math.random() * 8,
32
32
  startRotation: Math.random() * 360,
33
- driftX: (Math.random() > 0.5 ? 1 : -1) * Math.random() * 40,
33
+ // Per-piece swirl: amplitude, frequency (full oscillations), phase.
34
+ swirlAmp: 20 + Math.random() * 40,
35
+ swirlFreq: 1 + Math.random() * 1.5,
36
+ swirlPhase: Math.random() * Math.PI * 2,
34
37
  })),
35
38
  // width captured once on mount; intentional stable dep
36
39
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -39,45 +42,21 @@ export function ConfettiOverlay() {
39
42
 
40
43
  const anims = useRef(
41
44
  pieces.map(() => ({
42
- translateY: new Animated.Value(-20),
43
- opacity: new Animated.Value(1),
44
- rotate: new Animated.Value(0),
45
- translateX: new Animated.Value(0),
45
+ progress: new Animated.Value(0),
46
46
  })),
47
47
  ).current;
48
48
 
49
49
  useEffect(() => {
50
50
  const animations = pieces.map((piece, i) => {
51
51
  const anim = anims[i]!;
52
- anim.translateY.setValue(-20);
53
- anim.opacity.setValue(1);
54
- anim.rotate.setValue(0);
55
- anim.translateX.setValue(0);
56
-
52
+ anim.progress.setValue(0);
57
53
  return Animated.sequence([
58
54
  Animated.delay(piece.delay),
59
- Animated.parallel([
60
- Animated.timing(anim.translateY, {
61
- toValue: height + 20,
62
- duration: piece.duration,
63
- useNativeDriver: true,
64
- }),
65
- Animated.timing(anim.opacity, {
66
- toValue: 0,
67
- duration: piece.duration,
68
- useNativeDriver: true,
69
- }),
70
- Animated.timing(anim.rotate, {
71
- toValue: 720,
72
- duration: piece.duration,
73
- useNativeDriver: true,
74
- }),
75
- Animated.timing(anim.translateX, {
76
- toValue: piece.driftX,
77
- duration: piece.duration,
78
- useNativeDriver: true,
79
- }),
80
- ]),
55
+ Animated.timing(anim.progress, {
56
+ toValue: 1,
57
+ duration: piece.duration,
58
+ useNativeDriver: true,
59
+ }),
81
60
  ]);
82
61
  });
83
62
 
@@ -86,17 +65,41 @@ export function ConfettiOverlay() {
86
65
  return () => composite.stop();
87
66
  }, [pieces, anims, height]);
88
67
 
68
+ // Sample the sine curve at fixed progress points to build an interpolation
69
+ // that drives horizontal swirl on the native driver.
70
+ const SAMPLE_COUNT = 21;
71
+ const samplePoints = Array.from(
72
+ { length: SAMPLE_COUNT },
73
+ (_, k) => k / (SAMPLE_COUNT - 1),
74
+ );
75
+
89
76
  return (
90
77
  <View style={[StyleSheet.absoluteFill, styles.container]} pointerEvents="none">
91
78
  {pieces.map((piece, i) => {
92
79
  const anim = anims[i]!;
93
- const rotate = anim.rotate.interpolate({
94
- inputRange: [0, 720],
80
+ const translateY = anim.progress.interpolate({
81
+ inputRange: [0, 1],
82
+ outputRange: [-20, height + 20],
83
+ });
84
+ const rotate = anim.progress.interpolate({
85
+ inputRange: [0, 1],
95
86
  outputRange: [
96
87
  `${piece.startRotation}deg`,
97
88
  `${piece.startRotation + 720}deg`,
98
89
  ],
99
90
  });
91
+ const opacity = anim.progress.interpolate({
92
+ inputRange: [0, 0.5, 1],
93
+ outputRange: [1, 1, 0],
94
+ });
95
+ const translateX = anim.progress.interpolate({
96
+ inputRange: samplePoints,
97
+ outputRange: samplePoints.map(
98
+ (t) =>
99
+ Math.sin(t * piece.swirlFreq * Math.PI * 2 + piece.swirlPhase) *
100
+ piece.swirlAmp,
101
+ ),
102
+ });
100
103
  return (
101
104
  <Animated.View
102
105
  key={piece.id}
@@ -107,10 +110,10 @@ export function ConfettiOverlay() {
107
110
  width: piece.size,
108
111
  height: piece.size * 0.6,
109
112
  backgroundColor: piece.color,
110
- opacity: anim.opacity,
113
+ opacity,
111
114
  transform: [
112
- { translateY: anim.translateY },
113
- { translateX: anim.translateX },
115
+ { translateY },
116
+ { translateX },
114
117
  { rotate },
115
118
  ],
116
119
  },
@@ -1,6 +1,7 @@
1
1
  import type { Spec } from "@json-render/core";
2
2
  import { snapJsonRenderCatalog } from "@farcaster/snap/ui";
3
3
  import { SnapCatalogView } from "./catalog-renderer";
4
+ import { ConfettiOverlay } from "./confetti-overlay";
4
5
  import { useSnapTheme } from "./theme";
5
6
  import {
6
7
  type ReactNode,
@@ -126,6 +127,12 @@ export function SnapViewCoreInner({
126
127
  setPageKey((k) => k + 1);
127
128
  }, [spec]);
128
129
 
130
+ const showConfetti = snap.effects?.includes("confetti") ?? false;
131
+ const [confettiKey, setConfettiKey] = useState(0);
132
+ useEffect(() => {
133
+ if (showConfetti) setConfettiKey((k) => k + 1);
134
+ }, [showConfetti, snap]);
135
+
129
136
  const handlersRef = useRef(handlers);
130
137
  handlersRef.current = handlers;
131
138
 
@@ -205,6 +212,7 @@ export function SnapViewCoreInner({
205
212
  }}
206
213
  onAction={handleAction}
207
214
  />
215
+ {showConfetti && <ConfettiOverlay key={confettiKey} />}
208
216
  </View>
209
217
  );
210
218
  }
@@ -95,6 +95,9 @@ function SnapCardV1Inner({
95
95
  const isExpandable = contentHeight > SNAP_MAX_HEIGHT + 1;
96
96
  const isClipped = isExpandable && !isExpanded;
97
97
 
98
+ const isDark = mode === "dark";
99
+ const pillBg = isDark ? "rgba(40,40,40,0.92)" : "rgba(255,255,255,0.92)";
100
+ const pillBgPressed = isDark ? "rgba(60,60,60,0.95)" : "rgba(240,240,240,0.95)";
98
101
  return (
99
102
  <>
100
103
  <View style={cardStyles.frameRing}>
@@ -138,37 +141,29 @@ function SnapCardV1Inner({
138
141
  ? <SnapLoadingOverlay appearance={mode} accentHex={accentHex} />
139
142
  : loadingOverlay
140
143
  : null}
141
- {isExpandable ? (
142
- <View
143
- style={[
144
- cardStyles.expandRow,
145
- plain
146
- ? cardStyles.expandRowPlain
147
- : { borderTopColor: colors.border },
144
+ </View>
145
+ {isExpandable ? (
146
+ <View pointerEvents="box-none" style={cardStyles.expandFloat}>
147
+ <Pressable
148
+ style={({ pressed }) => [
149
+ cardStyles.expandButton,
150
+ {
151
+ backgroundColor: pressed ? pillBgPressed : pillBg,
152
+ borderColor: colors.border,
153
+ },
148
154
  ]}
155
+ onPress={() => {
156
+ setIsExpanded((value) => !value);
157
+ }}
149
158
  >
150
- <Pressable
151
- style={({ pressed }) => [
152
- cardStyles.expandButton,
153
- {
154
- backgroundColor: pressed
155
- ? colors.mutedHover
156
- : colors.muted,
157
- },
158
- ]}
159
- onPress={() => {
160
- setIsExpanded((value) => !value);
161
- }}
159
+ <Text
160
+ style={[cardStyles.expandButtonText, { color: colors.text }]}
162
161
  >
163
- <Text
164
- style={[cardStyles.expandButtonText, { color: colors.text }]}
165
- >
166
- {isExpanded ? "Show less" : "Show more"}
167
- </Text>
168
- </Pressable>
169
- </View>
170
- ) : null}
171
- </View>
162
+ {isExpanded ? "Show less" : "Show more"}
163
+ </Text>
164
+ </Pressable>
165
+ </View>
166
+ ) : null}
172
167
  </View>
173
168
  {actionError && (
174
169
  <Text
@@ -231,30 +226,27 @@ const cardStyles = StyleSheet.create({
231
226
  frameRing: { alignSelf: "stretch" },
232
227
  card: { overflow: "hidden", borderWidth: 1, minHeight: 120 },
233
228
  body: { paddingHorizontal: 16, paddingVertical: 16 },
234
- expandRow: {
229
+ expandFloat: {
230
+ position: "absolute",
231
+ left: 0,
232
+ right: 0,
233
+ bottom: -14,
234
+ height: 28,
235
235
  alignItems: "center",
236
- paddingHorizontal: 16,
237
- paddingTop: 10,
238
- paddingBottom: 12,
239
- borderTopWidth: StyleSheet.hairlineWidth,
240
- },
241
- expandRowPlain: {
242
- paddingHorizontal: 0,
243
- paddingTop: 8,
244
- paddingBottom: 0,
245
- borderTopWidth: 0,
236
+ justifyContent: "center",
246
237
  },
247
238
  expandButton: {
248
239
  minWidth: 92,
249
240
  alignItems: "center",
250
241
  justifyContent: "center",
251
242
  borderRadius: 9999,
243
+ borderWidth: 1,
252
244
  paddingHorizontal: 10,
253
- paddingVertical: 6,
245
+ paddingVertical: 4,
254
246
  },
255
247
  expandButtonText: {
256
- fontSize: 13,
257
- lineHeight: 18,
248
+ fontSize: 12,
249
+ lineHeight: 16,
258
250
  fontWeight: "600",
259
251
  },
260
252
  actionError: { paddingHorizontal: 12, paddingVertical: 8, fontSize: 13 },