@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.
- package/dist/react/components/cell-grid.d.ts +3 -1
- package/dist/react/components/cell-grid.js +8 -4
- package/dist/react/index.d.ts +3 -1
- package/dist/react/index.js +3 -3
- package/dist/react/snap-view-core.d.ts +12 -1
- package/dist/react/snap-view-core.js +10 -5
- package/dist/react/v1/snap-view.d.ts +7 -2
- package/dist/react/v1/snap-view.js +48 -40
- package/dist/react/v2/snap-view.d.ts +6 -2
- package/dist/react/v2/snap-view.js +98 -33
- package/dist/react-native/components/snap-cell-grid.d.ts +1 -1
- package/dist/react-native/components/snap-cell-grid.js +10 -4
- package/dist/react-native/confetti-overlay.js +33 -36
- package/dist/react-native/index.d.ts +3 -1
- package/dist/react-native/index.js +3 -3
- package/dist/react-native/snap-view-core.d.ts +11 -1
- package/dist/react-native/snap-view-core.js +25 -9
- package/dist/react-native/v1/snap-view.d.ts +9 -3
- package/dist/react-native/v1/snap-view.js +51 -52
- package/dist/react-native/v2/snap-view.d.ts +8 -3
- package/dist/react-native/v2/snap-view.js +92 -21
- package/dist/ui/catalog.js +2 -2
- package/dist/validator.js +8 -33
- package/llms.txt +26 -3
- package/package.json +1 -1
- package/src/react/components/cell-grid.tsx +11 -5
- package/src/react/index.tsx +5 -0
- package/src/react/snap-view-core.tsx +23 -8
- package/src/react/v1/snap-view.tsx +84 -55
- package/src/react/v2/snap-view.tsx +165 -52
- package/src/react-native/components/snap-cell-grid.tsx +11 -4
- package/src/react-native/confetti-overlay.tsx +40 -37
- package/src/react-native/index.tsx +5 -0
- package/src/react-native/snap-view-core.tsx +56 -14
- package/src/react-native/v1/snap-view.tsx +71 -47
- package/src/react-native/v2/snap-view.tsx +166 -28
- package/src/ui/catalog.ts +2 -2
- package/src/validator.ts +22 -46
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { useEffect, useState } from "react";
|
|
1
|
+
import { type ReactNode, useEffect, useState } from "react";
|
|
2
2
|
import { View, Text, StyleSheet, Pressable } from "react-native";
|
|
3
3
|
import { SnapThemeProvider, useSnapTheme, type SnapNativeColors } from "../theme";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
SnapLoadingOverlay,
|
|
6
|
+
SnapViewCoreInner,
|
|
7
|
+
resolveAccentHex,
|
|
8
|
+
} from "../snap-view-core";
|
|
5
9
|
import type { SnapPage, SnapActionHandlers } from "../types";
|
|
6
10
|
|
|
7
11
|
const SNAP_MAX_HEIGHT = 500;
|
|
@@ -12,13 +16,20 @@ export function SnapViewV1Inner({
|
|
|
12
16
|
snap,
|
|
13
17
|
handlers,
|
|
14
18
|
loading = false,
|
|
19
|
+
loadingOverlay,
|
|
15
20
|
}: {
|
|
16
21
|
snap: SnapPage;
|
|
17
22
|
handlers: SnapActionHandlers;
|
|
18
23
|
loading?: boolean;
|
|
24
|
+
loadingOverlay?: ReactNode;
|
|
19
25
|
}) {
|
|
20
26
|
return (
|
|
21
|
-
<SnapViewCoreInner
|
|
27
|
+
<SnapViewCoreInner
|
|
28
|
+
snap={snap}
|
|
29
|
+
handlers={handlers}
|
|
30
|
+
loading={loading}
|
|
31
|
+
loadingOverlay={loadingOverlay}
|
|
32
|
+
/>
|
|
22
33
|
);
|
|
23
34
|
}
|
|
24
35
|
|
|
@@ -28,16 +39,24 @@ export function SnapViewV1({
|
|
|
28
39
|
loading = false,
|
|
29
40
|
appearance = "dark",
|
|
30
41
|
colors,
|
|
42
|
+
loadingOverlay,
|
|
31
43
|
}: {
|
|
32
44
|
snap: SnapPage;
|
|
33
45
|
handlers: SnapActionHandlers;
|
|
34
46
|
loading?: boolean;
|
|
35
47
|
appearance?: "light" | "dark";
|
|
36
48
|
colors?: Partial<SnapNativeColors>;
|
|
49
|
+
/** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
|
|
50
|
+
loadingOverlay?: ReactNode;
|
|
37
51
|
}) {
|
|
38
52
|
return (
|
|
39
53
|
<SnapThemeProvider appearance={appearance} colors={colors}>
|
|
40
|
-
<SnapViewV1Inner
|
|
54
|
+
<SnapViewV1Inner
|
|
55
|
+
snap={snap}
|
|
56
|
+
handlers={handlers}
|
|
57
|
+
loading={loading}
|
|
58
|
+
loadingOverlay={loadingOverlay}
|
|
59
|
+
/>
|
|
41
60
|
</SnapThemeProvider>
|
|
42
61
|
);
|
|
43
62
|
}
|
|
@@ -52,6 +71,7 @@ function SnapCardV1Inner({
|
|
|
52
71
|
actionError,
|
|
53
72
|
appearance,
|
|
54
73
|
plain,
|
|
74
|
+
loadingOverlay,
|
|
55
75
|
}: {
|
|
56
76
|
snap: SnapPage;
|
|
57
77
|
handlers: SnapActionHandlers;
|
|
@@ -60,8 +80,10 @@ function SnapCardV1Inner({
|
|
|
60
80
|
actionError?: string | null;
|
|
61
81
|
appearance: "light" | "dark";
|
|
62
82
|
plain: boolean;
|
|
83
|
+
loadingOverlay?: ReactNode;
|
|
63
84
|
}) {
|
|
64
|
-
const { colors } = useSnapTheme();
|
|
85
|
+
const { colors, mode } = useSnapTheme();
|
|
86
|
+
const accentHex = resolveAccentHex(snap.theme?.accent, mode);
|
|
65
87
|
const [contentHeight, setContentHeight] = useState(0);
|
|
66
88
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
67
89
|
|
|
@@ -73,6 +95,9 @@ function SnapCardV1Inner({
|
|
|
73
95
|
const isExpandable = contentHeight > SNAP_MAX_HEIGHT + 1;
|
|
74
96
|
const isClipped = isExpandable && !isExpanded;
|
|
75
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)";
|
|
76
101
|
return (
|
|
77
102
|
<>
|
|
78
103
|
<View style={cardStyles.frameRing}>
|
|
@@ -107,40 +132,38 @@ function SnapCardV1Inner({
|
|
|
107
132
|
snap={snap}
|
|
108
133
|
handlers={handlers}
|
|
109
134
|
loading={loading}
|
|
135
|
+
loadingOverlay={null}
|
|
110
136
|
/>
|
|
111
137
|
</View>
|
|
112
138
|
</View>
|
|
113
|
-
{
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
139
|
+
{loading
|
|
140
|
+
? loadingOverlay === undefined
|
|
141
|
+
? <SnapLoadingOverlay appearance={mode} accentHex={accentHex} />
|
|
142
|
+
: loadingOverlay
|
|
143
|
+
: null}
|
|
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
|
+
},
|
|
120
154
|
]}
|
|
155
|
+
onPress={() => {
|
|
156
|
+
setIsExpanded((value) => !value);
|
|
157
|
+
}}
|
|
121
158
|
>
|
|
122
|
-
<
|
|
123
|
-
style={
|
|
124
|
-
cardStyles.expandButton,
|
|
125
|
-
{
|
|
126
|
-
backgroundColor: pressed
|
|
127
|
-
? colors.mutedHover
|
|
128
|
-
: colors.muted,
|
|
129
|
-
},
|
|
130
|
-
]}
|
|
131
|
-
onPress={() => {
|
|
132
|
-
setIsExpanded((value) => !value);
|
|
133
|
-
}}
|
|
159
|
+
<Text
|
|
160
|
+
style={[cardStyles.expandButtonText, { color: colors.text }]}
|
|
134
161
|
>
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
</Pressable>
|
|
141
|
-
</View>
|
|
142
|
-
) : null}
|
|
143
|
-
</View>
|
|
162
|
+
{isExpanded ? "Show less" : "Show more"}
|
|
163
|
+
</Text>
|
|
164
|
+
</Pressable>
|
|
165
|
+
</View>
|
|
166
|
+
) : null}
|
|
144
167
|
</View>
|
|
145
168
|
{actionError && (
|
|
146
169
|
<Text
|
|
@@ -170,6 +193,7 @@ export function SnapCardV1({
|
|
|
170
193
|
borderRadius = 16,
|
|
171
194
|
actionError,
|
|
172
195
|
plain = false,
|
|
196
|
+
loadingOverlay,
|
|
173
197
|
}: {
|
|
174
198
|
snap: SnapPage;
|
|
175
199
|
handlers: SnapActionHandlers;
|
|
@@ -179,6 +203,8 @@ export function SnapCardV1({
|
|
|
179
203
|
borderRadius?: number;
|
|
180
204
|
actionError?: string | null;
|
|
181
205
|
plain?: boolean;
|
|
206
|
+
/** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
|
|
207
|
+
loadingOverlay?: ReactNode;
|
|
182
208
|
}) {
|
|
183
209
|
return (
|
|
184
210
|
<SnapThemeProvider appearance={appearance} colors={colors}>
|
|
@@ -190,6 +216,7 @@ export function SnapCardV1({
|
|
|
190
216
|
actionError={actionError}
|
|
191
217
|
appearance={appearance}
|
|
192
218
|
plain={plain}
|
|
219
|
+
loadingOverlay={loadingOverlay}
|
|
193
220
|
/>
|
|
194
221
|
</SnapThemeProvider>
|
|
195
222
|
);
|
|
@@ -199,30 +226,27 @@ const cardStyles = StyleSheet.create({
|
|
|
199
226
|
frameRing: { alignSelf: "stretch" },
|
|
200
227
|
card: { overflow: "hidden", borderWidth: 1, minHeight: 120 },
|
|
201
228
|
body: { paddingHorizontal: 16, paddingVertical: 16 },
|
|
202
|
-
|
|
229
|
+
expandFloat: {
|
|
230
|
+
position: "absolute",
|
|
231
|
+
left: 0,
|
|
232
|
+
right: 0,
|
|
233
|
+
bottom: -14,
|
|
234
|
+
height: 28,
|
|
203
235
|
alignItems: "center",
|
|
204
|
-
|
|
205
|
-
paddingTop: 10,
|
|
206
|
-
paddingBottom: 12,
|
|
207
|
-
borderTopWidth: StyleSheet.hairlineWidth,
|
|
208
|
-
},
|
|
209
|
-
expandRowPlain: {
|
|
210
|
-
paddingHorizontal: 0,
|
|
211
|
-
paddingTop: 8,
|
|
212
|
-
paddingBottom: 0,
|
|
213
|
-
borderTopWidth: 0,
|
|
236
|
+
justifyContent: "center",
|
|
214
237
|
},
|
|
215
238
|
expandButton: {
|
|
216
239
|
minWidth: 92,
|
|
217
240
|
alignItems: "center",
|
|
218
241
|
justifyContent: "center",
|
|
219
242
|
borderRadius: 9999,
|
|
243
|
+
borderWidth: 1,
|
|
220
244
|
paddingHorizontal: 10,
|
|
221
|
-
paddingVertical:
|
|
245
|
+
paddingVertical: 4,
|
|
222
246
|
},
|
|
223
247
|
expandButtonText: {
|
|
224
|
-
fontSize:
|
|
225
|
-
lineHeight:
|
|
248
|
+
fontSize: 12,
|
|
249
|
+
lineHeight: 16,
|
|
226
250
|
fontWeight: "600",
|
|
227
251
|
},
|
|
228
252
|
actionError: { paddingHorizontal: 12, paddingVertical: 8, fontSize: 13 },
|
|
@@ -1,8 +1,12 @@
|
|
|
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
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
SnapLoadingOverlay,
|
|
7
|
+
SnapViewCoreInner,
|
|
8
|
+
resolveAccentHex,
|
|
9
|
+
} from "../snap-view-core";
|
|
6
10
|
import {
|
|
7
11
|
validateSnapResponse,
|
|
8
12
|
type ValidationResult,
|
|
@@ -47,12 +51,14 @@ export function SnapViewV2Inner({
|
|
|
47
51
|
loading = false,
|
|
48
52
|
onValidationError,
|
|
49
53
|
validationErrorFallback,
|
|
54
|
+
loadingOverlay,
|
|
50
55
|
}: {
|
|
51
56
|
snap: SnapPage;
|
|
52
57
|
handlers: SnapActionHandlers;
|
|
53
58
|
loading?: boolean;
|
|
54
59
|
onValidationError?: (result: ValidationResult) => void;
|
|
55
60
|
validationErrorFallback?: ReactNode;
|
|
61
|
+
loadingOverlay?: ReactNode;
|
|
56
62
|
}) {
|
|
57
63
|
const validation = useMemo(() => validateSnapResponse(snap), [snap]);
|
|
58
64
|
const valid = validation.valid;
|
|
@@ -77,7 +83,12 @@ export function SnapViewV2Inner({
|
|
|
77
83
|
}
|
|
78
84
|
|
|
79
85
|
return (
|
|
80
|
-
<SnapViewCoreInner
|
|
86
|
+
<SnapViewCoreInner
|
|
87
|
+
snap={snap}
|
|
88
|
+
handlers={handlers}
|
|
89
|
+
loading={loading}
|
|
90
|
+
loadingOverlay={loadingOverlay}
|
|
91
|
+
/>
|
|
81
92
|
);
|
|
82
93
|
}
|
|
83
94
|
|
|
@@ -89,6 +100,7 @@ export function SnapViewV2({
|
|
|
89
100
|
colors,
|
|
90
101
|
onValidationError,
|
|
91
102
|
validationErrorFallback,
|
|
103
|
+
loadingOverlay,
|
|
92
104
|
}: {
|
|
93
105
|
snap: SnapPage;
|
|
94
106
|
handlers: SnapActionHandlers;
|
|
@@ -97,6 +109,8 @@ export function SnapViewV2({
|
|
|
97
109
|
colors?: Partial<SnapNativeColors>;
|
|
98
110
|
onValidationError?: (result: ValidationResult) => void;
|
|
99
111
|
validationErrorFallback?: ReactNode;
|
|
112
|
+
/** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
|
|
113
|
+
loadingOverlay?: ReactNode;
|
|
100
114
|
}) {
|
|
101
115
|
return (
|
|
102
116
|
<SnapThemeProvider appearance={appearance} colors={colors}>
|
|
@@ -106,6 +120,7 @@ export function SnapViewV2({
|
|
|
106
120
|
loading={loading}
|
|
107
121
|
onValidationError={onValidationError}
|
|
108
122
|
validationErrorFallback={validationErrorFallback}
|
|
123
|
+
loadingOverlay={loadingOverlay}
|
|
109
124
|
/>
|
|
110
125
|
</SnapThemeProvider>
|
|
111
126
|
);
|
|
@@ -124,6 +139,7 @@ function SnapCardV2Inner({
|
|
|
124
139
|
actionError,
|
|
125
140
|
appearance,
|
|
126
141
|
plain,
|
|
142
|
+
loadingOverlay,
|
|
127
143
|
}: {
|
|
128
144
|
snap: SnapPage;
|
|
129
145
|
handlers: SnapActionHandlers;
|
|
@@ -135,9 +151,20 @@ function SnapCardV2Inner({
|
|
|
135
151
|
actionError?: string | null;
|
|
136
152
|
appearance: "light" | "dark";
|
|
137
153
|
plain: boolean;
|
|
154
|
+
loadingOverlay?: ReactNode;
|
|
138
155
|
}) {
|
|
139
|
-
const { colors } = useSnapTheme();
|
|
156
|
+
const { colors, mode } = useSnapTheme();
|
|
157
|
+
const accentHex = resolveAccentHex(snap.theme?.accent, mode);
|
|
140
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;
|
|
141
168
|
|
|
142
169
|
const content = (
|
|
143
170
|
<SnapViewV2Inner
|
|
@@ -146,44 +173,124 @@ function SnapCardV2Inner({
|
|
|
146
173
|
loading={loading}
|
|
147
174
|
onValidationError={onValidationError}
|
|
148
175
|
validationErrorFallback={validationErrorFallback}
|
|
176
|
+
loadingOverlay={null}
|
|
149
177
|
/>
|
|
150
178
|
);
|
|
151
179
|
|
|
152
180
|
if (plain) {
|
|
153
|
-
return
|
|
181
|
+
return (
|
|
182
|
+
<>
|
|
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>
|
|
200
|
+
{loading
|
|
201
|
+
? loadingOverlay === undefined
|
|
202
|
+
? <SnapLoadingOverlay appearance={mode} accentHex={accentHex} />
|
|
203
|
+
: loadingOverlay
|
|
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}
|
|
222
|
+
</>
|
|
223
|
+
);
|
|
154
224
|
}
|
|
155
225
|
|
|
156
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)";
|
|
157
230
|
|
|
158
231
|
return (
|
|
159
232
|
<>
|
|
160
|
-
<View
|
|
161
|
-
style={{
|
|
162
|
-
borderRadius,
|
|
163
|
-
borderWidth: 1,
|
|
164
|
-
borderColor: colors.border,
|
|
165
|
-
backgroundColor: colors.surface,
|
|
166
|
-
maxHeight: showOverflowWarning ? undefined : SNAP_MAX_HEIGHT,
|
|
167
|
-
overflow: "hidden",
|
|
168
|
-
minHeight: 120,
|
|
169
|
-
}}
|
|
170
|
-
>
|
|
233
|
+
<View style={{ position: "relative" }}>
|
|
171
234
|
<View
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
+
}}
|
|
175
244
|
>
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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)" }} />
|
|
183
268
|
</View>
|
|
184
|
-
|
|
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>
|
|
185
292
|
</View>
|
|
186
|
-
)}
|
|
293
|
+
) : null}
|
|
187
294
|
</View>
|
|
188
295
|
{actionError && (
|
|
189
296
|
<Text
|
|
@@ -216,6 +323,7 @@ export function SnapCardV2({
|
|
|
216
323
|
validationErrorFallback,
|
|
217
324
|
actionError,
|
|
218
325
|
plain = false,
|
|
326
|
+
loadingOverlay,
|
|
219
327
|
}: {
|
|
220
328
|
snap: SnapPage;
|
|
221
329
|
handlers: SnapActionHandlers;
|
|
@@ -228,6 +336,8 @@ export function SnapCardV2({
|
|
|
228
336
|
validationErrorFallback?: ReactNode;
|
|
229
337
|
actionError?: string | null;
|
|
230
338
|
plain?: boolean;
|
|
339
|
+
/** Custom content rendered while `loading` is true. Pass `null` to render nothing. */
|
|
340
|
+
loadingOverlay?: ReactNode;
|
|
231
341
|
}) {
|
|
232
342
|
return (
|
|
233
343
|
<SnapThemeProvider appearance={appearance} colors={colors}>
|
|
@@ -242,6 +352,7 @@ export function SnapCardV2({
|
|
|
242
352
|
actionError={actionError}
|
|
243
353
|
appearance={appearance}
|
|
244
354
|
plain={plain}
|
|
355
|
+
loadingOverlay={loadingOverlay}
|
|
245
356
|
/>
|
|
246
357
|
</SnapThemeProvider>
|
|
247
358
|
);
|
|
@@ -252,6 +363,33 @@ const cardStyles = StyleSheet.create({
|
|
|
252
363
|
card: { borderWidth: 1, minHeight: 120, overflow: "hidden" },
|
|
253
364
|
body: { paddingHorizontal: 16, paddingVertical: 16 },
|
|
254
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
|
+
},
|
|
255
393
|
warningOverlay: {
|
|
256
394
|
position: "absolute",
|
|
257
395
|
top: SNAP_MAX_HEIGHT,
|
package/src/ui/catalog.ts
CHANGED
|
@@ -58,7 +58,7 @@ export const snapJsonRenderCatalog = defineCatalog(snapJsonRenderSchema, {
|
|
|
58
58
|
item: {
|
|
59
59
|
props: itemProps,
|
|
60
60
|
description:
|
|
61
|
-
"Content row with title and optional description. Children render in the actions slot (right side) —
|
|
61
|
+
"Content row with title and optional description. Children render in the actions slot (right side) — badge, button, and icon elements are all valid. The item itself is not interactive, so avoid navigation-style icons (`chevron-right`, `arrow-right`, `external-link`) that imply the row navigates.",
|
|
62
62
|
},
|
|
63
63
|
item_group: {
|
|
64
64
|
props: itemGroupProps,
|
|
@@ -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.
|
|
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 {
|
|
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
|
-
|
|
123
|
-
|
|
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
|
|
177
|
-
* - action target URLs (submit, open_url, open_snap, open_mini_app): must
|
|
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 =
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|