@farcaster/snap 2.5.0 → 2.6.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 (81) hide show
  1. package/dist/constants.d.ts +1 -0
  2. package/dist/constants.js +1 -0
  3. package/dist/react/catalog-renderer.js +2 -0
  4. package/dist/react/components/action-button.js +9 -1
  5. package/dist/react/components/cell-grid.js +22 -3
  6. package/dist/react/components/image.js +5 -2
  7. package/dist/react/components/paginator.d.ts +7 -0
  8. package/dist/react/components/paginator.js +103 -0
  9. package/dist/react/components/stack.js +21 -18
  10. package/dist/react/components/text.js +19 -1
  11. package/dist/react/snap-version-context.d.ts +3 -0
  12. package/dist/react/snap-version-context.js +7 -0
  13. package/dist/react/snap-view-core.d.ts +1 -1
  14. package/dist/react/snap-view-core.js +27 -4
  15. package/dist/react-native/catalog-renderer.js +2 -0
  16. package/dist/react-native/components/snap-action-button.js +8 -2
  17. package/dist/react-native/components/snap-cell-grid.js +22 -3
  18. package/dist/react-native/components/snap-image.js +29 -4
  19. package/dist/react-native/components/snap-paginator.d.ts +5 -0
  20. package/dist/react-native/components/snap-paginator.js +194 -0
  21. package/dist/react-native/components/snap-text.js +10 -3
  22. package/dist/react-native/expand-state.d.ts +19 -0
  23. package/dist/react-native/expand-state.js +18 -0
  24. package/dist/react-native/index.d.ts +7 -1
  25. package/dist/react-native/index.js +3 -3
  26. package/dist/react-native/snap-version-context.d.ts +3 -0
  27. package/dist/react-native/snap-version-context.js +6 -0
  28. package/dist/react-native/snap-view-core.d.ts +1 -1
  29. package/dist/react-native/snap-view-core.js +27 -4
  30. package/dist/react-native/v1/snap-view.d.ts +7 -1
  31. package/dist/react-native/v1/snap-view.js +35 -11
  32. package/dist/react-native/v2/snap-view.d.ts +7 -1
  33. package/dist/react-native/v2/snap-view.js +60 -17
  34. package/dist/ui/catalog.d.ts +55 -0
  35. package/dist/ui/catalog.js +20 -3
  36. package/dist/ui/cell-grid.d.ts +15 -0
  37. package/dist/ui/cell-grid.js +6 -4
  38. package/dist/ui/image.d.ts +4 -1
  39. package/dist/ui/image.js +3 -1
  40. package/dist/ui/index.d.ts +2 -0
  41. package/dist/ui/index.js +1 -0
  42. package/dist/ui/paginator-state.d.ts +18 -0
  43. package/dist/ui/paginator-state.js +47 -0
  44. package/dist/ui/paginator.d.ts +17 -0
  45. package/dist/ui/paginator.js +8 -0
  46. package/dist/ui/text.d.ts +1 -0
  47. package/dist/ui/text.js +1 -0
  48. package/dist/validator.js +16 -3
  49. package/llms.txt +19 -4
  50. package/package.json +1 -1
  51. package/src/constants.ts +1 -0
  52. package/src/react/catalog-renderer.tsx +2 -0
  53. package/src/react/components/action-button.tsx +13 -2
  54. package/src/react/components/cell-grid.tsx +29 -4
  55. package/src/react/components/image.tsx +17 -0
  56. package/src/react/components/paginator.tsx +208 -0
  57. package/src/react/components/stack.tsx +20 -18
  58. package/src/react/components/text.tsx +20 -1
  59. package/src/react/snap-version-context.tsx +12 -0
  60. package/src/react/snap-view-core.tsx +44 -12
  61. package/src/react-native/catalog-renderer.tsx +2 -0
  62. package/src/react-native/components/snap-action-button.tsx +10 -2
  63. package/src/react-native/components/snap-cell-grid.tsx +29 -4
  64. package/src/react-native/components/snap-image.tsx +40 -1
  65. package/src/react-native/components/snap-paginator.tsx +283 -0
  66. package/src/react-native/components/snap-text.tsx +11 -2
  67. package/src/react-native/expand-state.ts +48 -0
  68. package/src/react-native/index.tsx +15 -0
  69. package/src/react-native/snap-version-context.tsx +10 -0
  70. package/src/react-native/snap-view-core.tsx +47 -12
  71. package/src/react-native/v1/snap-view.tsx +57 -10
  72. package/src/react-native/v2/snap-view.tsx +88 -17
  73. package/src/ui/README.md +1 -1
  74. package/src/ui/catalog.ts +25 -3
  75. package/src/ui/cell-grid.ts +6 -3
  76. package/src/ui/image.ts +3 -1
  77. package/src/ui/index.ts +3 -0
  78. package/src/ui/paginator-state.ts +67 -0
  79. package/src/ui/paginator.ts +11 -0
  80. package/src/ui/text.ts +1 -0
  81. package/src/validator.ts +19 -3
@@ -4,6 +4,10 @@ import { useStateStore } from "@json-render/react-native";
4
4
  import { useSnapPalette } from "../use-snap-palette";
5
5
  import { useSnapTheme } from "../theme";
6
6
  import { POST_GRID_TAP_KEY, readableTextOnHex } from "@farcaster/snap";
7
+ import {
8
+ getPaginatorAction,
9
+ runPaginatorAction,
10
+ } from "../../ui/paginator-state";
7
11
 
8
12
  export function SnapCellGrid({
9
13
  element,
@@ -13,12 +17,21 @@ export function SnapCellGrid({
13
17
  const on = (element as unknown as { on?: Record<string, unknown> }).on;
14
18
  const { hex, appearance } = useSnapPalette();
15
19
  const { colors } = useSnapTheme();
16
- const { get, set } = useStateStore();
20
+ const stateStore = useStateStore();
21
+ const { get, set } = stateStore;
22
+ const paginatorAction = getPaginatorAction(on);
17
23
  const cols = Number(props.cols ?? 2);
18
24
  const rows = Number(props.rows ?? 2);
19
25
  const cells = Array.isArray(props.cells) ? props.cells : [];
20
26
  const rowHeight = typeof props.rowHeight === "number" ? props.rowHeight : 28;
21
27
  const squareCells = props.cellAspectRatio === "square";
28
+ const maxWidthKey = typeof props.maxWidth === "string" ? props.maxWidth : undefined;
29
+ const maxWidthMap: Record<string, number | undefined> = {
30
+ sm: 160,
31
+ md: 220,
32
+ lg: undefined,
33
+ };
34
+ const maxWidth = maxWidthKey ? maxWidthMap[maxWidthKey] : undefined;
22
35
  const gap = String(props.gap ?? "sm");
23
36
  const gapMap: Record<string, number> = { none: 0, sm: 1, md: 2, lg: 4 };
24
37
  const gapPx = gapMap[gap] ?? 1;
@@ -35,11 +48,12 @@ export function SnapCellGrid({
35
48
 
36
49
  const cellMap = new Map<
37
50
  string,
38
- { color?: string; content?: string; value?: string }
51
+ { color?: string; textColor?: string; content?: string; value?: string }
39
52
  >();
40
53
  for (const c of cells) {
41
54
  cellMap.set(`${Number(c.row)},${Number(c.col)}`, {
42
55
  color: c.color as string | undefined,
56
+ textColor: c.textColor as string | undefined,
43
57
  content: c.content != null ? String(c.content) : undefined,
44
58
  value: typeof c.value === "string" ? c.value : undefined,
45
59
  });
@@ -74,7 +88,12 @@ export function SnapCellGrid({
74
88
  } else {
75
89
  set(tapPath, wire);
76
90
  }
77
- if (hasPressAction) emit("press");
91
+ if (
92
+ hasPressAction &&
93
+ !runPaginatorAction(stateStore, paginatorAction)
94
+ ) {
95
+ emit("press");
96
+ }
78
97
  };
79
98
 
80
99
  const ringOuter = appearance === "dark" ? "#fff" : "#000";
@@ -92,7 +111,11 @@ export function SnapCellGrid({
92
111
  const selected = interactive && isSelected(r, c);
93
112
  const bgHex = cell?.color ? hex(cell.color) : null;
94
113
  const bg = bgHex ?? emptyCellBg;
95
- const textColor = bgHex ? readableTextOnHex(bgHex) : colors.text;
114
+ const textColor = cell?.textColor
115
+ ? hex(cell.textColor)
116
+ : bgHex
117
+ ? readableTextOnHex(bgHex)
118
+ : colors.text;
96
119
 
97
120
  const cellContent = cell?.content ? (
98
121
  <Text style={[styles.cellText, { color: textColor }]}>
@@ -163,6 +186,8 @@ export function SnapCellGrid({
163
186
  style={[
164
187
  styles.wrap,
165
188
  {
189
+ maxWidth,
190
+ alignSelf: maxWidth ? "center" : undefined,
166
191
  gap: gapPx,
167
192
  backgroundColor: colors.muted,
168
193
  padding: 4,
@@ -1,6 +1,6 @@
1
1
  import type { ComponentRenderProps } from "@json-render/react-native";
2
2
  import { Image } from "expo-image";
3
- import { StyleSheet, View } from "react-native";
3
+ import { StyleSheet, Text, View } from "react-native";
4
4
  import { useSnapStackDirection } from "../stack-direction-context";
5
5
 
6
6
  function aspectToRatio(aspect: string): number {
@@ -14,6 +14,9 @@ export function SnapImage({
14
14
  }: ComponentRenderProps<Record<string, unknown>>) {
15
15
  const url = String(props.url ?? "");
16
16
  const alt = String(props.alt ?? "");
17
+ const title = props.title ? String(props.title) : "";
18
+ const subtitle = props.subtitle ? String(props.subtitle) : "";
19
+ const hasOverlay = title.length > 0 || subtitle.length > 0;
17
20
  const ratio = aspectToRatio(String(props.aspect ?? "1:1"));
18
21
  const stackDir = useSnapStackDirection();
19
22
  const inHorizontalStack = stackDir === "horizontal";
@@ -32,6 +35,20 @@ export function SnapImage({
32
35
  contentFit="cover"
33
36
  accessibilityLabel={alt || undefined}
34
37
  />
38
+ {hasOverlay ? (
39
+ <View style={styles.overlay}>
40
+ {title ? (
41
+ <Text numberOfLines={1} style={styles.title}>
42
+ {title}
43
+ </Text>
44
+ ) : null}
45
+ {subtitle ? (
46
+ <Text numberOfLines={1} style={styles.subtitle}>
47
+ {subtitle}
48
+ </Text>
49
+ ) : null}
50
+ </View>
51
+ ) : null}
35
52
  </View>
36
53
  );
37
54
  }
@@ -49,4 +66,26 @@ const styles = StyleSheet.create({
49
66
  flex: 1,
50
67
  minWidth: 0,
51
68
  },
69
+ overlay: {
70
+ position: "absolute",
71
+ left: 0,
72
+ right: 0,
73
+ bottom: 0,
74
+ paddingHorizontal: 12,
75
+ paddingTop: 24,
76
+ paddingBottom: 10,
77
+ backgroundColor: "rgba(0, 0, 0, 0.48)",
78
+ },
79
+ title: {
80
+ color: "#fff",
81
+ fontSize: 14,
82
+ lineHeight: 18,
83
+ fontWeight: "700",
84
+ },
85
+ subtitle: {
86
+ color: "rgba(255, 255, 255, 0.85)",
87
+ fontSize: 12,
88
+ lineHeight: 16,
89
+ fontWeight: "500",
90
+ },
52
91
  });
@@ -0,0 +1,283 @@
1
+ import type { ComponentRenderProps } from "@json-render/react-native";
2
+ import { useStateStore } from "@json-render/react-native";
3
+ import {
4
+ Children,
5
+ type ReactNode,
6
+ useEffect,
7
+ useMemo,
8
+ useRef,
9
+ useState,
10
+ } from "react";
11
+ import { Animated, Pressable, StyleSheet, View } from "react-native";
12
+ import { ChevronLeft, ChevronRight } from "lucide-react-native";
13
+ import { useSnapPalette } from "../use-snap-palette";
14
+ import { useSnapTheme } from "../theme";
15
+ import {
16
+ clampPaginatorPage,
17
+ pageFromValue,
18
+ SNAP_PAGINATOR_PAGE_COUNT_PATH,
19
+ SNAP_PAGINATOR_PAGE_PATH,
20
+ } from "../../ui/paginator-state";
21
+
22
+ function clampInitialPage(value: unknown, pageCount: number): number {
23
+ if (typeof value !== "number" || !Number.isInteger(value)) return 0;
24
+ return Math.min(Math.max(value, 0), Math.max(pageCount - 1, 0));
25
+ }
26
+
27
+ export function SnapPaginator({
28
+ element: { props },
29
+ children,
30
+ }: ComponentRenderProps<Record<string, unknown>> & { children?: ReactNode }) {
31
+ const pages = useMemo(
32
+ () => Children.toArray(children),
33
+ [children],
34
+ );
35
+ const { colors, mode } = useSnapTheme();
36
+ const { accentHex } = useSnapPalette();
37
+ const { get, set } = useStateStore();
38
+ const initialPage = clampInitialPage(props.initialPage, pages.length);
39
+ const page = clampPaginatorPage(
40
+ pageFromValue(get(SNAP_PAGINATOR_PAGE_PATH), initialPage),
41
+ pages.length,
42
+ );
43
+ const activePage = Math.min(page, Math.max(pages.length - 1, 0));
44
+ const showControls = props.showControls !== false && pages.length > 1;
45
+ const showIndicators = props.showIndicators !== false && pages.length > 1;
46
+ const controlsPosition = props.controlsPosition === "top" ? "top" : "bottom";
47
+ const transition =
48
+ props.transition === "fade" ||
49
+ props.transition === "scale" ||
50
+ props.transition === "none"
51
+ ? props.transition
52
+ : "slide";
53
+ const showControlBar = showControls || showIndicators;
54
+ const [transitionDirection, setTransitionDirection] =
55
+ useState<"next" | "previous">("next");
56
+ const pageAnim = useRef(new Animated.Value(1)).current;
57
+
58
+ const canGoPrev = activePage > 0;
59
+ const canGoNext = activePage < pages.length - 1;
60
+ const goToPage = (targetPage: number) => {
61
+ const nextPage = clampPaginatorPage(targetPage, pages.length);
62
+ if (nextPage !== activePage) {
63
+ setTransitionDirection(nextPage > activePage ? "next" : "previous");
64
+ }
65
+ set(SNAP_PAGINATOR_PAGE_PATH, nextPage);
66
+ };
67
+ const goPrev = () => goToPage(activePage - 1);
68
+ const goNext = () => goToPage(activePage + 1);
69
+
70
+ useEffect(() => {
71
+ if (pages.length === 0) return;
72
+ const nextPage = clampPaginatorPage(
73
+ pageFromValue(get(SNAP_PAGINATOR_PAGE_PATH), initialPage),
74
+ pages.length,
75
+ );
76
+ if (get(SNAP_PAGINATOR_PAGE_PATH) !== nextPage) {
77
+ set(SNAP_PAGINATOR_PAGE_PATH, nextPage);
78
+ }
79
+ if (get(SNAP_PAGINATOR_PAGE_COUNT_PATH) !== pages.length) {
80
+ set(SNAP_PAGINATOR_PAGE_COUNT_PATH, pages.length);
81
+ }
82
+ }, [get, initialPage, pages.length, set]);
83
+
84
+ useEffect(() => {
85
+ if (transition === "none") {
86
+ pageAnim.setValue(1);
87
+ return;
88
+ }
89
+ pageAnim.setValue(0);
90
+ Animated.timing(pageAnim, {
91
+ toValue: 1,
92
+ duration: transition === "scale" ? 240 : transition === "slide" ? 260 : 180,
93
+ useNativeDriver: true,
94
+ }).start();
95
+ }, [activePage, pageAnim, transition]);
96
+
97
+ if (pages.length === 0) return null;
98
+
99
+ const controlBar = showControlBar ? (
100
+ <View style={styles.footer}>
101
+ {showControls ? (
102
+ <Pressable
103
+ accessibilityRole="button"
104
+ accessibilityLabel="Previous page"
105
+ disabled={!canGoPrev}
106
+ onPress={goPrev}
107
+ style={[
108
+ styles.control,
109
+ {
110
+ borderColor: colors.border,
111
+ backgroundColor: colors.muted,
112
+ opacity: canGoPrev ? 1 : 0.35,
113
+ },
114
+ ]}
115
+ >
116
+ <ChevronLeft size={15} color={colors.text} />
117
+ </Pressable>
118
+ ) : (
119
+ <View style={styles.controlPlaceholder} />
120
+ )}
121
+
122
+ {showIndicators ? (
123
+ <View style={styles.indicators}>
124
+ {pages.map((_, index) => {
125
+ const current = index === activePage;
126
+ return (
127
+ <View
128
+ key={index}
129
+ accessibilityLabel={`Page ${index + 1}${current ? ", current" : ""}`}
130
+ style={[
131
+ styles.dot,
132
+ current ? styles.dotCurrent : styles.dotInactive,
133
+ {
134
+ backgroundColor: current
135
+ ? accentHex
136
+ : mode === "dark"
137
+ ? "rgba(255,255,255,0.5)"
138
+ : "rgba(0,0,0,0.28)",
139
+ borderColor: current
140
+ ? mode === "dark"
141
+ ? "rgba(255,255,255,0.18)"
142
+ : "rgba(0,0,0,0.12)"
143
+ : "transparent",
144
+ },
145
+ ]}
146
+ />
147
+ );
148
+ })}
149
+ </View>
150
+ ) : (
151
+ <View style={styles.indicators} />
152
+ )}
153
+
154
+ {showControls ? (
155
+ <Pressable
156
+ accessibilityRole="button"
157
+ accessibilityLabel="Next page"
158
+ disabled={!canGoNext}
159
+ onPress={goNext}
160
+ style={[
161
+ styles.control,
162
+ {
163
+ borderColor: colors.border,
164
+ backgroundColor: colors.muted,
165
+ opacity: canGoNext ? 1 : 0.35,
166
+ },
167
+ ]}
168
+ >
169
+ <ChevronRight size={15} color={colors.text} />
170
+ </Pressable>
171
+ ) : (
172
+ <View style={styles.controlPlaceholder} />
173
+ )}
174
+ </View>
175
+ ) : null;
176
+
177
+ const animatedPageStyle =
178
+ transition === "none"
179
+ ? undefined
180
+ : {
181
+ opacity: pageAnim.interpolate({
182
+ inputRange: [0, 1],
183
+ outputRange: [transition === "fade" ? 0.2 : 0.35, 1],
184
+ }),
185
+ transform:
186
+ transition === "scale"
187
+ ? [
188
+ {
189
+ scale: pageAnim.interpolate({
190
+ inputRange: [0, 1],
191
+ outputRange: [0.94, 1],
192
+ }),
193
+ },
194
+ ]
195
+ : transition === "slide"
196
+ ? [
197
+ {
198
+ translateX: pageAnim.interpolate({
199
+ inputRange: [0, 1],
200
+ outputRange: [
201
+ transitionDirection === "previous" ? -22 : 22,
202
+ 0,
203
+ ],
204
+ }),
205
+ },
206
+ {
207
+ scale: pageAnim.interpolate({
208
+ inputRange: [0, 1],
209
+ outputRange: [0.985, 1],
210
+ }),
211
+ },
212
+ ]
213
+ : [],
214
+ };
215
+
216
+ return (
217
+ <View style={styles.wrap}>
218
+ {controlsPosition === "top" ? controlBar : null}
219
+ <Animated.View
220
+ style={[
221
+ styles.page,
222
+ animatedPageStyle,
223
+ ]}
224
+ >
225
+ {pages[activePage]}
226
+ </Animated.View>
227
+ {controlsPosition === "bottom" ? controlBar : null}
228
+ </View>
229
+ );
230
+ }
231
+
232
+ const styles = StyleSheet.create({
233
+ wrap: {
234
+ width: "100%",
235
+ minWidth: 0,
236
+ gap: 8,
237
+ },
238
+ page: {
239
+ width: "100%",
240
+ minWidth: 0,
241
+ },
242
+ footer: {
243
+ minHeight: 28,
244
+ flexDirection: "row",
245
+ alignItems: "center",
246
+ justifyContent: "space-between",
247
+ gap: 8,
248
+ },
249
+ control: {
250
+ width: 28,
251
+ height: 28,
252
+ borderWidth: 1,
253
+ borderRadius: 6,
254
+ alignItems: "center",
255
+ justifyContent: "center",
256
+ },
257
+ controlPlaceholder: {
258
+ width: 28,
259
+ height: 28,
260
+ },
261
+ indicators: {
262
+ flex: 1,
263
+ flexDirection: "row",
264
+ alignItems: "center",
265
+ justifyContent: "center",
266
+ gap: 6,
267
+ },
268
+ dot: {
269
+ borderWidth: 0,
270
+ overflow: "hidden",
271
+ },
272
+ dotInactive: {
273
+ width: 8,
274
+ height: 8,
275
+ borderRadius: 4,
276
+ },
277
+ dotCurrent: {
278
+ width: 10,
279
+ height: 10,
280
+ borderRadius: 5,
281
+ borderWidth: 2,
282
+ },
283
+ });
@@ -1,11 +1,12 @@
1
1
  import type { ComponentRenderProps } from "@json-render/react-native";
2
2
  import { StyleSheet, Text, View } from "react-native";
3
3
  import { useSnapStackDirection } from "../stack-direction-context";
4
+ import { useSnapVersion } from "../snap-version-context";
4
5
  import { useSnapTheme } from "../theme";
5
6
 
6
7
  const SIZE_STYLES: Record<string, { fontSize: number; lineHeight?: number; fontWeight?: "400" | "500" | "600" | "700" }> = {
7
- md: { fontSize: 16, lineHeight: 24 },
8
- sm: { fontSize: 13, lineHeight: 18 },
8
+ md: { fontSize: 16, lineHeight: 22 },
9
+ sm: { fontSize: 13, lineHeight: 16 },
9
10
  };
10
11
 
11
12
  const WEIGHT_MAP: Record<string, "400" | "500" | "600" | "700"> = {
@@ -21,11 +22,18 @@ export function SnapText({
21
22
  const size = String(props.size ?? "md");
22
23
  const weight = props.weight ? String(props.weight) : undefined;
23
24
  const align = (props.align as "left" | "center" | "right" | undefined) ?? undefined;
25
+ const snapVersion = useSnapVersion();
24
26
 
25
27
  const sizeStyle = SIZE_STYLES[size] ?? SIZE_STYLES.md;
26
28
  const resolvedWeight = weight ? WEIGHT_MAP[weight] : sizeStyle?.fontWeight;
27
29
  const textAlign = align === "center" ? "center" : align === "right" ? "right" : "left";
28
30
  const inHorizontalStack = useSnapStackDirection() === "horizontal";
31
+ const maxLines =
32
+ typeof props.maxLines === "number"
33
+ ? props.maxLines
34
+ : snapVersion === "2.0"
35
+ ? 1
36
+ : undefined;
29
37
 
30
38
  return (
31
39
  <View style={inHorizontalStack ? styles.wrapRow : styles.wrapCol}>
@@ -40,6 +48,7 @@ export function SnapText({
40
48
  textAlign,
41
49
  },
42
50
  ]}
51
+ numberOfLines={maxLines}
43
52
  >
44
53
  {content}
45
54
  </Text>
@@ -0,0 +1,48 @@
1
+ export const SNAP_MAX_HEIGHT = 500;
2
+
3
+ export type SnapExpansionOptions = {
4
+ contentHeight: number;
5
+ internalExpanded: boolean;
6
+ forceExpanded?: boolean;
7
+ onExpandPress?: (() => void) | undefined;
8
+ expandButtonLabel?: string | undefined;
9
+ showOverflowWarning?: boolean | undefined;
10
+ };
11
+
12
+ export type SnapExpansionState = {
13
+ expandable: boolean;
14
+ clipped: boolean;
15
+ showButton: boolean;
16
+ buttonLabel: string;
17
+ useInternalToggle: boolean;
18
+ showOverflowWarning: boolean;
19
+ maxHeight: number | undefined;
20
+ };
21
+
22
+ export function getSnapExpansionState({
23
+ contentHeight,
24
+ internalExpanded,
25
+ forceExpanded = false,
26
+ onExpandPress,
27
+ expandButtonLabel,
28
+ showOverflowWarning = false,
29
+ }: SnapExpansionOptions): SnapExpansionState {
30
+ const hostControlled = typeof onExpandPress === "function";
31
+ const overflowWarning = showOverflowWarning && !forceExpanded;
32
+ const expandable =
33
+ !forceExpanded && !overflowWarning && contentHeight > SNAP_MAX_HEIGHT + 1;
34
+ const clipped = expandable && !internalExpanded;
35
+ const showButton = expandable;
36
+ const useInternalToggle = !hostControlled;
37
+
38
+ return {
39
+ expandable,
40
+ clipped,
41
+ showButton,
42
+ buttonLabel:
43
+ clipped && expandButtonLabel ? expandButtonLabel : internalExpanded ? "Show less" : "Show more",
44
+ useInternalToggle,
45
+ showOverflowWarning: overflowWarning,
46
+ maxHeight: clipped ? SNAP_MAX_HEIGHT : undefined,
47
+ };
48
+ }
@@ -32,6 +32,9 @@ export function SnapCard({
32
32
  actionError,
33
33
  plain = false,
34
34
  loadingOverlay,
35
+ forceExpanded,
36
+ expandButtonLabel,
37
+ onExpandPress,
35
38
  }: {
36
39
  snap: SnapPage;
37
40
  handlers: SnapActionHandlers;
@@ -52,6 +55,12 @@ export function SnapCard({
52
55
  plain?: boolean;
53
56
  /** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
54
57
  loadingOverlay?: ReactNode;
58
+ /** When true, render full content height without 500px clipping or expand controls. */
59
+ forceExpanded?: boolean;
60
+ /** Custom label for the collapsed expand button. */
61
+ expandButtonLabel?: string;
62
+ /** Called from the collapsed expand button instead of toggling internal state. */
63
+ onExpandPress?: () => void;
55
64
  }) {
56
65
  if (snap.version === SPEC_VERSION_2) {
57
66
  return (
@@ -68,6 +77,9 @@ export function SnapCard({
68
77
  actionError={actionError}
69
78
  plain={plain}
70
79
  loadingOverlay={loadingOverlay}
80
+ forceExpanded={forceExpanded}
81
+ expandButtonLabel={expandButtonLabel}
82
+ onExpandPress={onExpandPress}
71
83
  />
72
84
  );
73
85
  }
@@ -83,6 +95,9 @@ export function SnapCard({
83
95
  actionError={actionError}
84
96
  plain={plain}
85
97
  loadingOverlay={loadingOverlay}
98
+ forceExpanded={forceExpanded}
99
+ expandButtonLabel={expandButtonLabel}
100
+ onExpandPress={onExpandPress}
86
101
  />
87
102
  );
88
103
  }
@@ -0,0 +1,10 @@
1
+ import { createContext, useContext } from "react";
2
+ import type { SpecVersion } from "@farcaster/snap";
3
+
4
+ const SnapVersionContext = createContext<SpecVersion>("1.0");
5
+
6
+ export const SnapVersionProvider = SnapVersionContext.Provider;
7
+
8
+ export function useSnapVersion(): SpecVersion {
9
+ return useContext(SnapVersionContext);
10
+ }
@@ -4,6 +4,7 @@ import { SnapCatalogView } from "./catalog-renderer";
4
4
  import { ConfettiOverlay } from "./confetti-overlay";
5
5
  import { FireworksOverlay } from "./fireworks-overlay";
6
6
  import { useSnapTheme } from "./theme";
7
+ import { SnapVersionProvider } from "./snap-version-context";
7
8
  import {
8
9
  type ReactNode,
9
10
  useCallback,
@@ -25,8 +26,13 @@ import type { SnapPage, SnapActionHandlers, JsonValue } from "./types";
25
26
 
26
27
  export function applyStatePaths(
27
28
  model: Record<string, unknown>,
28
- changes: { path: string; value: unknown }[] | Record<string, unknown>,
29
+ changes:
30
+ | { path: string; value: unknown }[]
31
+ | Record<string, unknown>
32
+ | null
33
+ | undefined,
29
34
  ): void {
35
+ if (!changes) return;
30
36
  const entries = Array.isArray(changes)
31
37
  ? changes.map((c) => [c.path, c.value] as const)
32
38
  : Object.entries(changes);
@@ -57,6 +63,30 @@ export function applyStatePaths(
57
63
  }
58
64
  }
59
65
 
66
+ function withDefaultElementProps(spec: Spec): Spec {
67
+ if (!spec || typeof spec !== "object" || !("elements" in spec)) return spec;
68
+ const elements = spec.elements as unknown as Record<
69
+ string,
70
+ Record<string, unknown>
71
+ >;
72
+ if (!elements || typeof elements !== "object") return spec;
73
+
74
+ let changed = false;
75
+ const nextElements: Record<string, Record<string, unknown>> = {};
76
+ for (const [id, element] of Object.entries(elements)) {
77
+ if (element.props !== undefined) {
78
+ nextElements[id] = element;
79
+ continue;
80
+ }
81
+ changed = true;
82
+ nextElements[id] = { ...element, props: {} };
83
+ }
84
+
85
+ return changed
86
+ ? ({ ...spec, elements: nextElements } as unknown as Spec)
87
+ : spec;
88
+ }
89
+
60
90
  export function resolveAccentHex(
61
91
  accent: string | undefined,
62
92
  appearance: "light" | "dark",
@@ -87,7 +117,10 @@ export function SnapViewCoreInner({
87
117
  loadingOverlay?: ReactNode;
88
118
  }) {
89
119
  const { mode } = useSnapTheme();
90
- const spec = snap.ui;
120
+ const spec = useMemo(
121
+ () => withDefaultElementProps(snap.ui as unknown as Spec) as SnapPage["ui"],
122
+ [snap.ui],
123
+ );
91
124
  const accentHex = resolveAccentHex(snap.theme?.accent, mode);
92
125
 
93
126
  const initialState = useMemo(
@@ -206,16 +239,18 @@ export function SnapViewCoreInner({
206
239
  )
207
240
  : loadingOverlay
208
241
  : null}
209
- <SnapCatalogView
210
- key={pageKey}
211
- spec={spec}
212
- state={initialState}
213
- loading={false}
214
- onStateChange={(changes) => {
215
- applyStatePaths(stateRef.current, changes);
216
- }}
217
- onAction={handleAction}
218
- />
242
+ <SnapVersionProvider value={snap.version === "2.0" ? "2.0" : "1.0"}>
243
+ <SnapCatalogView
244
+ key={pageKey}
245
+ spec={spec}
246
+ state={initialState}
247
+ loading={false}
248
+ onStateChange={(changes) => {
249
+ applyStatePaths(stateRef.current, changes);
250
+ }}
251
+ onAction={handleAction}
252
+ />
253
+ </SnapVersionProvider>
219
254
  {showConfetti && <ConfettiOverlay key={confettiKey} />}
220
255
  {showFireworks && <FireworksOverlay key={fireworksKey} />}
221
256
  </View>