@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
  "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";
@@ -116,16 +116,55 @@ export function SnapCardV2({
116
116
  /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
117
117
  loadingOverlay?: ReactNode;
118
118
  }) {
119
- const maxHeight = showOverflowWarning ? SNAP_WARNING_HEIGHT : SNAP_MAX_HEIGHT;
120
119
  const isDark = appearance === "dark";
121
120
  const bg = isDark ? "rgba(0,0,0,0.85)" : "rgba(255,255,255,0.9)";
122
121
  const borderColor = isDark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
123
122
  const surfaceBg = isDark ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.02)";
123
+ const toggleBg = isDark ? "rgba(255,255,255,0.06)" : "rgba(0,0,0,0.05)";
124
+ const toggleBgHover = isDark
125
+ ? "rgba(255,255,255,0.1)"
126
+ : "rgba(0,0,0,0.08)";
127
+ const toggleText = isDark ? "rgba(255,255,255,0.82)" : "rgba(0,0,0,0.72)";
124
128
  const accentHex = useMemo(
125
129
  () => resolveSnapPaletteHex(snap.theme?.accent ?? "purple", appearance),
126
130
  [snap.theme?.accent, appearance],
127
131
  );
128
132
 
133
+ const contentRef = useRef<HTMLDivElement>(null);
134
+ const [isExpandable, setIsExpandable] = useState(false);
135
+ const [isExpanded, setIsExpanded] = useState(false);
136
+
137
+ useEffect(() => {
138
+ setIsExpanded(false);
139
+ }, [snap]);
140
+
141
+ useEffect(() => {
142
+ const node = contentRef.current;
143
+ if (!node) return;
144
+
145
+ const measure = () => {
146
+ setIsExpandable(node.scrollHeight > SNAP_MAX_HEIGHT + 1);
147
+ };
148
+
149
+ measure();
150
+
151
+ if (typeof ResizeObserver === "undefined") return;
152
+ const observer = new ResizeObserver(() => {
153
+ measure();
154
+ });
155
+ observer.observe(node);
156
+ return () => observer.disconnect();
157
+ }, [snap, plain, showOverflowWarning]);
158
+
159
+ useEffect(() => {
160
+ if (!isExpandable) {
161
+ setIsExpanded(false);
162
+ }
163
+ }, [isExpandable]);
164
+
165
+ const isClipped = !showOverflowWarning && isExpandable && !isExpanded;
166
+ const containerMaxHeight = showOverflowWarning ? SNAP_WARNING_HEIGHT : undefined;
167
+
129
168
  return (
130
169
  <>
131
170
  <div
@@ -133,73 +172,125 @@ export function SnapCardV2({
133
172
  position: "relative",
134
173
  width: "100%",
135
174
  maxWidth,
136
- maxHeight,
137
- overflow: "hidden",
138
- ...(plain ? {} : {
139
- borderRadius: 16,
140
- border: `1px solid ${borderColor}`,
141
- backgroundColor: surfaceBg,
142
- }),
143
175
  }}
144
176
  >
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 && (
177
+ <div
178
+ style={{
179
+ position: "relative",
180
+ maxHeight: containerMaxHeight,
181
+ overflow: "hidden",
182
+ ...(plain ? {} : {
183
+ borderRadius: 16,
184
+ border: `1px solid ${borderColor}`,
185
+ backgroundColor: surfaceBg,
186
+ }),
187
+ }}
188
+ >
166
189
  <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
- }}
190
+ style={
191
+ isClipped
192
+ ? { maxHeight: SNAP_MAX_HEIGHT, overflow: "hidden" }
193
+ : undefined
194
+ }
176
195
  >
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>
196
+ <div ref={contentRef} style={plain ? undefined : { padding: 16 }}>
197
+ <SnapViewV2
198
+ snap={snap}
199
+ handlers={handlers}
200
+ loading={loading}
201
+ appearance={appearance}
202
+ onValidationError={onValidationError}
203
+ validationErrorFallback={validationErrorFallback}
204
+ loadingOverlay={null}
205
+ />
193
206
  </div>
207
+ </div>
208
+ {loadingOverlay === undefined ? (
209
+ <SnapLoadingOverlay
210
+ appearance={appearance}
211
+ accentHex={accentHex}
212
+ active={loading}
213
+ />
214
+ ) : loading ? (
215
+ <>{loadingOverlay}</>
216
+ ) : null}
217
+ {showOverflowWarning && (
194
218
  <div
195
219
  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)",
220
+ position: "absolute",
221
+ top: SNAP_MAX_HEIGHT,
222
+ left: 0,
223
+ right: 0,
224
+ bottom: 0,
225
+ pointerEvents: "none",
226
+ zIndex: 10,
199
227
  }}
200
- />
201
- </div>
202
- )}
228
+ >
229
+ <div style={{ borderTop: "1px dashed rgba(255,100,100,0.6)", position: "relative" }}>
230
+ <span
231
+ style={{
232
+ position: "absolute",
233
+ top: -10,
234
+ right: 0,
235
+ fontSize: 10,
236
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
237
+ color: "rgba(255,100,100,0.7)",
238
+ background: bg,
239
+ padding: "1px 4px",
240
+ borderRadius: 3,
241
+ }}
242
+ >
243
+ {SNAP_MAX_HEIGHT}px
244
+ </span>
245
+ </div>
246
+ <div
247
+ style={{
248
+ height: "100%",
249
+ background:
250
+ "repeating-linear-gradient(-45deg, transparent, transparent 8px, rgba(255,100,100,0.06) 8px, rgba(255,100,100,0.06) 16px)",
251
+ }}
252
+ />
253
+ </div>
254
+ )}
255
+ </div>
256
+ {!showOverflowWarning && isExpandable ? (
257
+ <button
258
+ type="button"
259
+ aria-expanded={isExpanded}
260
+ onClick={() => setIsExpanded((value) => !value)}
261
+ style={{
262
+ position: "absolute",
263
+ bottom: 0,
264
+ left: "50%",
265
+ transform: "translate(-50%, 50%)",
266
+ appearance: "none",
267
+ border: `1px solid ${borderColor}`,
268
+ borderRadius: 9999,
269
+ backgroundColor: isDark ? "rgba(30,30,30,0.6)" : "rgba(255,255,255,0.6)",
270
+ backdropFilter: "blur(12px) saturate(180%)",
271
+ WebkitBackdropFilter: "blur(12px) saturate(180%)",
272
+ color: toggleText,
273
+ padding: "2px 10px",
274
+ fontSize: 12,
275
+ lineHeight: "16px",
276
+ fontWeight: 600,
277
+ cursor: "pointer",
278
+ zIndex: 11,
279
+ }}
280
+ onMouseEnter={(event) => {
281
+ event.currentTarget.style.backgroundColor = isDark
282
+ ? "rgba(50,50,50,0.7)"
283
+ : "rgba(245,245,245,0.75)";
284
+ }}
285
+ onMouseLeave={(event) => {
286
+ event.currentTarget.style.backgroundColor = isDark
287
+ ? "rgba(30,30,30,0.6)"
288
+ : "rgba(255,255,255,0.6)";
289
+ }}
290
+ >
291
+ {isExpanded ? "Show less" : "Show more"}
292
+ </button>
293
+ ) : null}
203
294
  </div>
204
295
  {actionError && (
205
296
  <div
@@ -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 },