@farcaster/snap 2.0.2 → 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.
Files changed (38) hide show
  1. package/dist/react/components/cell-grid.d.ts +3 -1
  2. package/dist/react/components/cell-grid.js +8 -4
  3. package/dist/react/index.d.ts +3 -1
  4. package/dist/react/index.js +3 -3
  5. package/dist/react/snap-view-core.d.ts +12 -1
  6. package/dist/react/snap-view-core.js +10 -5
  7. package/dist/react/v1/snap-view.d.ts +7 -2
  8. package/dist/react/v1/snap-view.js +48 -40
  9. package/dist/react/v2/snap-view.d.ts +6 -2
  10. package/dist/react/v2/snap-view.js +98 -33
  11. package/dist/react-native/components/snap-cell-grid.d.ts +1 -1
  12. package/dist/react-native/components/snap-cell-grid.js +10 -4
  13. package/dist/react-native/confetti-overlay.js +33 -36
  14. package/dist/react-native/index.d.ts +3 -1
  15. package/dist/react-native/index.js +3 -3
  16. package/dist/react-native/snap-view-core.d.ts +11 -1
  17. package/dist/react-native/snap-view-core.js +25 -9
  18. package/dist/react-native/v1/snap-view.d.ts +9 -3
  19. package/dist/react-native/v1/snap-view.js +51 -52
  20. package/dist/react-native/v2/snap-view.d.ts +8 -3
  21. package/dist/react-native/v2/snap-view.js +92 -21
  22. package/dist/ui/catalog.js +2 -2
  23. package/dist/validator.js +8 -33
  24. package/llms.txt +26 -3
  25. package/package.json +1 -1
  26. package/src/react/components/cell-grid.tsx +11 -5
  27. package/src/react/index.tsx +5 -0
  28. package/src/react/snap-view-core.tsx +23 -8
  29. package/src/react/v1/snap-view.tsx +84 -55
  30. package/src/react/v2/snap-view.tsx +165 -52
  31. package/src/react-native/components/snap-cell-grid.tsx +11 -4
  32. package/src/react-native/confetti-overlay.tsx +40 -37
  33. package/src/react-native/index.tsx +5 -0
  34. package/src/react-native/snap-view-core.tsx +56 -14
  35. package/src/react-native/v1/snap-view.tsx +71 -47
  36. package/src/react-native/v2/snap-view.tsx +166 -28
  37. package/src/ui/catalog.ts +2 -2
  38. package/src/validator.ts +22 -46
@@ -1,9 +1,10 @@
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
- import { SnapViewCore } from "../snap-view-core";
6
+ import { SnapViewCore, SnapLoadingOverlay } from "../snap-view-core";
7
+ import { resolveSnapPaletteHex } from "../lib/resolve-palette-hex";
7
8
  import type { SnapPage, SnapActionHandlers } from "../index";
8
9
 
9
10
  const SNAP_MAX_HEIGHT = 500;
@@ -45,6 +46,7 @@ export function SnapViewV2({
45
46
  appearance = "dark",
46
47
  onValidationError,
47
48
  validationErrorFallback,
49
+ loadingOverlay,
48
50
  }: {
49
51
  snap: SnapPage;
50
52
  handlers: SnapActionHandlers;
@@ -52,6 +54,8 @@ export function SnapViewV2({
52
54
  appearance?: "light" | "dark";
53
55
  onValidationError?: (result: ValidationResult) => void;
54
56
  validationErrorFallback?: ReactNode;
57
+ /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
58
+ loadingOverlay?: ReactNode;
55
59
  }) {
56
60
  const validation = useMemo(() => validateSnapResponse(snap), [snap]);
57
61
  const valid = validation.valid;
@@ -79,6 +83,7 @@ export function SnapViewV2({
79
83
  handlers={handlers}
80
84
  loading={loading}
81
85
  appearance={appearance}
86
+ loadingOverlay={loadingOverlay}
82
87
  />
83
88
  );
84
89
  }
@@ -96,6 +101,7 @@ export function SnapCardV2({
96
101
  validationErrorFallback,
97
102
  actionError,
98
103
  plain = false,
104
+ loadingOverlay,
99
105
  }: {
100
106
  snap: SnapPage;
101
107
  handlers: SnapActionHandlers;
@@ -107,12 +113,57 @@ export function SnapCardV2({
107
113
  validationErrorFallback?: ReactNode;
108
114
  actionError?: string | null;
109
115
  plain?: boolean;
116
+ /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
117
+ loadingOverlay?: ReactNode;
110
118
  }) {
111
- const maxHeight = showOverflowWarning ? SNAP_WARNING_HEIGHT : SNAP_MAX_HEIGHT;
112
119
  const isDark = appearance === "dark";
113
120
  const bg = isDark ? "rgba(0,0,0,0.85)" : "rgba(255,255,255,0.9)";
114
121
  const borderColor = isDark ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.1)";
115
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)";
128
+ const accentHex = useMemo(
129
+ () => resolveSnapPaletteHex(snap.theme?.accent ?? "purple", appearance),
130
+ [snap.theme?.accent, appearance],
131
+ );
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;
116
167
 
117
168
  return (
118
169
  <>
@@ -121,63 +172,125 @@ export function SnapCardV2({
121
172
  position: "relative",
122
173
  width: "100%",
123
174
  maxWidth,
124
- maxHeight,
125
- overflow: "hidden",
126
- ...(plain ? {} : {
127
- borderRadius: 16,
128
- border: `1px solid ${borderColor}`,
129
- backgroundColor: surfaceBg,
130
- }),
131
175
  }}
132
176
  >
133
- <div style={plain ? undefined : { padding: 16 }}>
134
- <SnapViewV2
135
- snap={snap}
136
- handlers={handlers}
137
- loading={loading}
138
- appearance={appearance}
139
- onValidationError={onValidationError}
140
- validationErrorFallback={validationErrorFallback}
141
- />
142
- </div>
143
- {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
+ >
144
189
  <div
145
- style={{
146
- position: "absolute",
147
- top: SNAP_MAX_HEIGHT,
148
- left: 0,
149
- right: 0,
150
- bottom: 0,
151
- pointerEvents: "none",
152
- zIndex: 10,
153
- }}
190
+ style={
191
+ isClipped
192
+ ? { maxHeight: SNAP_MAX_HEIGHT, overflow: "hidden" }
193
+ : undefined
194
+ }
154
195
  >
155
- <div style={{ borderTop: "1px dashed rgba(255,100,100,0.6)", position: "relative" }}>
156
- <span
157
- style={{
158
- position: "absolute",
159
- top: -10,
160
- right: 0,
161
- fontSize: 10,
162
- fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
163
- color: "rgba(255,100,100,0.7)",
164
- background: bg,
165
- padding: "1px 4px",
166
- borderRadius: 3,
167
- }}
168
- >
169
- {SNAP_MAX_HEIGHT}px
170
- </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
+ />
171
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 && (
172
218
  <div
173
219
  style={{
174
- height: "100%",
175
- background:
176
- "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,
177
227
  }}
178
- />
179
- </div>
180
- )}
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}
181
294
  </div>
182
295
  {actionError && (
183
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
  },
@@ -31,6 +31,7 @@ export function SnapCard({
31
31
  validationErrorFallback,
32
32
  actionError,
33
33
  plain = false,
34
+ loadingOverlay,
34
35
  }: {
35
36
  snap: SnapPage;
36
37
  handlers: SnapActionHandlers;
@@ -49,6 +50,8 @@ export function SnapCard({
49
50
  actionError?: string | null;
50
51
  /** When true, renders without card frame (no border, background, or padding). */
51
52
  plain?: boolean;
53
+ /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
54
+ loadingOverlay?: ReactNode;
52
55
  }) {
53
56
  if (snap.version === SPEC_VERSION_2) {
54
57
  return (
@@ -64,6 +67,7 @@ export function SnapCard({
64
67
  validationErrorFallback={validationErrorFallback}
65
68
  actionError={actionError}
66
69
  plain={plain}
70
+ loadingOverlay={loadingOverlay}
67
71
  />
68
72
  );
69
73
  }
@@ -78,6 +82,7 @@ export function SnapCard({
78
82
  borderRadius={borderRadius}
79
83
  actionError={actionError}
80
84
  plain={plain}
85
+ loadingOverlay={loadingOverlay}
81
86
  />
82
87
  );
83
88
  }
@@ -1,8 +1,16 @@
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
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
+ import {
7
+ type ReactNode,
8
+ useCallback,
9
+ useEffect,
10
+ useMemo,
11
+ useRef,
12
+ useState,
13
+ } from "react";
6
14
  import { ActivityIndicator, StyleSheet, View } from "react-native";
7
15
  import {
8
16
  DEFAULT_THEME_ACCENT,
@@ -66,10 +74,16 @@ export function SnapViewCoreInner({
66
74
  snap,
67
75
  handlers,
68
76
  loading = false,
77
+ loadingOverlay,
69
78
  }: {
70
79
  snap: SnapPage;
71
80
  handlers: SnapActionHandlers;
72
81
  loading?: boolean;
82
+ /**
83
+ * Custom content rendered while `loading` is true. When `undefined` (default)
84
+ * the built-in ActivityIndicator overlay is used. Pass `null` to render nothing.
85
+ */
86
+ loadingOverlay?: ReactNode;
73
87
  }) {
74
88
  const { mode } = useSnapTheme();
75
89
  const spec = snap.ui;
@@ -113,6 +127,12 @@ export function SnapViewCoreInner({
113
127
  setPageKey((k) => k + 1);
114
128
  }, [spec]);
115
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
+
116
136
  const handlersRef = useRef(handlers);
117
137
  handlersRef.current = handlers;
118
138
 
@@ -172,19 +192,16 @@ export function SnapViewCoreInner({
172
192
 
173
193
  return (
174
194
  <View style={styles.container}>
175
- {loading ? (
176
- <View
177
- style={[
178
- styles.overlay,
179
- {
180
- backgroundColor:
181
- mode === "dark" ? "rgba(0,0,0,0.1)" : "rgba(255,255,255,0.2)",
182
- },
183
- ]}
184
- >
185
- <ActivityIndicator size="large" color={accentHex} />
186
- </View>
187
- ) : null}
195
+ {loading
196
+ ? loadingOverlay === undefined
197
+ ? (
198
+ <SnapLoadingOverlay
199
+ appearance={mode}
200
+ accentHex={accentHex}
201
+ />
202
+ )
203
+ : loadingOverlay
204
+ : null}
188
205
  <SnapCatalogView
189
206
  key={pageKey}
190
207
  spec={spec}
@@ -195,6 +212,31 @@ export function SnapViewCoreInner({
195
212
  }}
196
213
  onAction={handleAction}
197
214
  />
215
+ {showConfetti && <ConfettiOverlay key={confettiKey} />}
216
+ </View>
217
+ );
218
+ }
219
+
220
+ export function SnapLoadingOverlay({
221
+ appearance,
222
+ accentHex,
223
+ }: {
224
+ appearance: "light" | "dark";
225
+ accentHex: string;
226
+ }) {
227
+ return (
228
+ <View
229
+ style={[
230
+ styles.overlay,
231
+ {
232
+ backgroundColor:
233
+ appearance === "dark"
234
+ ? "rgba(0,0,0,0.1)"
235
+ : "rgba(255,255,255,0.2)",
236
+ },
237
+ ]}
238
+ >
239
+ <ActivityIndicator size="large" color={accentHex} />
198
240
  </View>
199
241
  );
200
242
  }