@farcaster/snap 2.3.1 → 2.5.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/button-orientation-utils.d.ts +3 -0
- package/dist/button-orientation-utils.js +25 -0
- package/dist/constants.d.ts +2 -1
- package/dist/constants.js +2 -1
- package/dist/react/components/action-button.js +5 -5
- package/dist/react/components/cell-grid.js +6 -2
- package/dist/react/components/item-group.js +2 -1
- package/dist/react/components/item-layout-context.d.ts +2 -0
- package/dist/react/components/item-layout-context.js +7 -0
- package/dist/react/components/item.js +40 -3
- package/dist/react/components/stack.js +46 -37
- package/dist/react/components/toggle-group.js +6 -4
- package/dist/react/snap-view-core.js +107 -17
- package/dist/react-native/components/item-layout-context.d.ts +2 -0
- package/dist/react-native/components/item-layout-context.js +6 -0
- package/dist/react-native/components/snap-action-button.js +15 -2
- package/dist/react-native/components/snap-cell-grid.js +30 -4
- package/dist/react-native/components/snap-item-group.js +16 -6
- package/dist/react-native/components/snap-item.js +56 -13
- package/dist/react-native/components/snap-stack.js +32 -33
- package/dist/react-native/components/snap-toggle-group.js +5 -4
- package/dist/react-native/fireworks-overlay.d.ts +1 -0
- package/dist/react-native/fireworks-overlay.js +125 -0
- package/dist/react-native/snap-view-core.js +7 -2
- package/dist/schemas.d.ts +1 -0
- package/dist/stack-horizontal-utils.d.ts +7 -5
- package/dist/stack-horizontal-utils.js +24 -14
- package/dist/ui/catalog.d.ts +60 -1
- package/dist/ui/catalog.js +3 -3
- package/dist/ui/cell-grid.d.ts +4 -0
- package/dist/ui/cell-grid.js +3 -4
- package/dist/ui/index.d.ts +2 -2
- package/dist/ui/index.js +1 -1
- package/dist/ui/item.d.ts +112 -1
- package/dist/ui/item.js +28 -2
- package/dist/ui/stack.d.ts +1 -0
- package/dist/ui/stack.js +3 -1
- package/dist/validator.js +19 -1
- package/llms.txt +3 -1
- package/package.json +1 -1
- package/src/button-orientation-utils.ts +36 -0
- package/src/constants.ts +2 -1
- package/src/react/components/action-button.tsx +5 -4
- package/src/react/components/cell-grid.tsx +6 -2
- package/src/react/components/item-group.tsx +19 -17
- package/src/react/components/item-layout-context.tsx +12 -0
- package/src/react/components/item.tsx +97 -4
- package/src/react/components/stack.tsx +51 -40
- package/src/react/components/toggle-group.tsx +6 -4
- package/src/react/snap-view-core.tsx +152 -28
- package/src/react-native/components/item-layout-context.tsx +10 -0
- package/src/react-native/components/snap-action-button.tsx +15 -2
- package/src/react-native/components/snap-cell-grid.tsx +36 -4
- package/src/react-native/components/snap-item-group.tsx +31 -17
- package/src/react-native/components/snap-item.tsx +92 -14
- package/src/react-native/components/snap-stack.tsx +37 -36
- package/src/react-native/components/snap-toggle-group.tsx +5 -4
- package/src/react-native/fireworks-overlay.tsx +176 -0
- package/src/react-native/snap-view-core.tsx +6 -1
- package/src/stack-horizontal-utils.ts +32 -13
- package/src/ui/catalog.ts +5 -4
- package/src/ui/cell-grid.ts +5 -5
- package/src/ui/index.ts +2 -2
- package/src/ui/item.ts +35 -5
- package/src/ui/stack.ts +3 -1
- package/src/validator.ts +29 -1
|
@@ -2,8 +2,14 @@ import type { ComponentRenderProps } from "@json-render/react-native";
|
|
|
2
2
|
import { Children, Fragment, type ReactNode } from "react";
|
|
3
3
|
import { StyleSheet, View } from "react-native";
|
|
4
4
|
import { useSnapTheme } from "../theme";
|
|
5
|
+
import { SnapItemGroupBorderProvider } from "./item-layout-context";
|
|
5
6
|
|
|
6
|
-
const GAP_MAP: Record<string, number> = {
|
|
7
|
+
const GAP_MAP: Record<string, number> = {
|
|
8
|
+
none: 0,
|
|
9
|
+
sm: 4,
|
|
10
|
+
md: 8,
|
|
11
|
+
lg: 12,
|
|
12
|
+
};
|
|
7
13
|
|
|
8
14
|
export function SnapItemGroup({
|
|
9
15
|
element: { props },
|
|
@@ -16,22 +22,30 @@ export function SnapItemGroup({
|
|
|
16
22
|
const items = Children.toArray(children);
|
|
17
23
|
|
|
18
24
|
return (
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
{
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
25
|
+
<SnapItemGroupBorderProvider value={border}>
|
|
26
|
+
<View
|
|
27
|
+
style={[
|
|
28
|
+
styles.group,
|
|
29
|
+
border && {
|
|
30
|
+
borderWidth: 1,
|
|
31
|
+
borderColor: colors.border,
|
|
32
|
+
borderRadius: 12,
|
|
33
|
+
},
|
|
34
|
+
{ gap },
|
|
35
|
+
]}
|
|
36
|
+
>
|
|
37
|
+
{items.map((child, i) => (
|
|
38
|
+
<Fragment key={i}>
|
|
39
|
+
{separator && i > 0 && (
|
|
40
|
+
<View
|
|
41
|
+
style={{ height: 1, backgroundColor: colors.border + "80" }}
|
|
42
|
+
/>
|
|
43
|
+
)}
|
|
44
|
+
{child}
|
|
45
|
+
</Fragment>
|
|
46
|
+
))}
|
|
47
|
+
</View>
|
|
48
|
+
</SnapItemGroupBorderProvider>
|
|
35
49
|
);
|
|
36
50
|
}
|
|
37
51
|
|
|
@@ -1,33 +1,98 @@
|
|
|
1
1
|
import type { ComponentRenderProps } from "@json-render/react-native";
|
|
2
|
+
import { Image } from "expo-image";
|
|
2
3
|
import type { ReactNode } from "react";
|
|
3
4
|
import { StyleSheet, Text, View } from "react-native";
|
|
4
5
|
import { useSnapStackDirection } from "../stack-direction-context";
|
|
5
6
|
import { useSnapTheme } from "../theme";
|
|
7
|
+
import { useSnapPalette } from "../use-snap-palette";
|
|
8
|
+
import { useSnapItemGroupHasBorder } from "./item-layout-context";
|
|
9
|
+
import { ICON_MAP } from "./snap-icon";
|
|
10
|
+
|
|
11
|
+
type ItemMediaConfig =
|
|
12
|
+
| {
|
|
13
|
+
variant: "icon";
|
|
14
|
+
name: string;
|
|
15
|
+
color?: string;
|
|
16
|
+
}
|
|
17
|
+
| {
|
|
18
|
+
variant: "image";
|
|
19
|
+
url: string;
|
|
20
|
+
alt?: string;
|
|
21
|
+
round?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function parseItemMedia(value: unknown): ItemMediaConfig | undefined {
|
|
25
|
+
if (!value || typeof value !== "object") return undefined;
|
|
26
|
+
|
|
27
|
+
const media = value as Record<string, unknown>;
|
|
28
|
+
if (media.variant === "icon" && typeof media.name === "string") {
|
|
29
|
+
return {
|
|
30
|
+
variant: "icon",
|
|
31
|
+
name: media.name,
|
|
32
|
+
color: typeof media.color === "string" ? media.color : undefined,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (media.variant === "image" && typeof media.url === "string") {
|
|
37
|
+
return {
|
|
38
|
+
variant: "image",
|
|
39
|
+
url: media.url,
|
|
40
|
+
alt: typeof media.alt === "string" ? media.alt : undefined,
|
|
41
|
+
round: typeof media.round === "boolean" ? media.round : undefined,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
6
47
|
|
|
7
48
|
export function SnapItem({
|
|
8
49
|
element: { props },
|
|
9
50
|
children,
|
|
10
51
|
}: ComponentRenderProps<Record<string, unknown>> & { children?: ReactNode }) {
|
|
11
52
|
const { colors } = useSnapTheme();
|
|
53
|
+
const { accentHex, hex } = useSnapPalette();
|
|
12
54
|
const title = String(props.title ?? "");
|
|
13
|
-
const description = props.description
|
|
14
|
-
|
|
15
|
-
|
|
55
|
+
const description = props.description ? String(props.description) : undefined;
|
|
56
|
+
const media = parseItemMedia(props.media);
|
|
57
|
+
const inBorderedGroup = useSnapItemGroupHasBorder();
|
|
16
58
|
/** Match web `Item className="flex-1"`: row peers must share width or title/description collapse. */
|
|
17
59
|
const rowPeer = useSnapStackDirection() === "horizontal";
|
|
60
|
+
const MediaIcon =
|
|
61
|
+
media?.variant === "icon" ? ICON_MAP[media.name] : undefined;
|
|
62
|
+
const mediaColor =
|
|
63
|
+
media?.variant === "icon" && media.color && media.color !== "accent"
|
|
64
|
+
? hex(media.color)
|
|
65
|
+
: accentHex;
|
|
18
66
|
|
|
19
|
-
const containerVariant = {
|
|
67
|
+
const containerVariant = {
|
|
68
|
+
paddingVertical: 6,
|
|
69
|
+
paddingHorizontal: inBorderedGroup ? 8 : 0,
|
|
70
|
+
columnGap: 8,
|
|
71
|
+
};
|
|
20
72
|
|
|
21
73
|
return (
|
|
22
74
|
<View
|
|
23
|
-
style={[
|
|
24
|
-
styles.container,
|
|
25
|
-
containerVariant,
|
|
26
|
-
rowPeer && styles.rowPeer,
|
|
27
|
-
]}
|
|
75
|
+
style={[styles.container, containerVariant, rowPeer && styles.rowPeer]}
|
|
28
76
|
>
|
|
77
|
+
{media?.variant === "icon" && MediaIcon ? (
|
|
78
|
+
<View style={styles.iconMedia}>
|
|
79
|
+
<MediaIcon size={20} color={mediaColor} />
|
|
80
|
+
</View>
|
|
81
|
+
) : null}
|
|
82
|
+
{media?.variant === "image" ? (
|
|
83
|
+
<View style={[styles.imageMedia, media.round && styles.roundImage]}>
|
|
84
|
+
<Image
|
|
85
|
+
source={{ uri: media.url }}
|
|
86
|
+
style={StyleSheet.absoluteFill}
|
|
87
|
+
contentFit="cover"
|
|
88
|
+
accessibilityLabel={media.alt || undefined}
|
|
89
|
+
/>
|
|
90
|
+
</View>
|
|
91
|
+
) : null}
|
|
29
92
|
<View style={styles.content}>
|
|
30
|
-
{title ?
|
|
93
|
+
{title ? (
|
|
94
|
+
<Text style={[styles.title, { color: colors.text }]}>{title}</Text>
|
|
95
|
+
) : null}
|
|
31
96
|
{description ? (
|
|
32
97
|
<Text style={[styles.description, { color: colors.textSecondary }]}>
|
|
33
98
|
{description}
|
|
@@ -55,19 +120,32 @@ const styles = StyleSheet.create({
|
|
|
55
120
|
content: {
|
|
56
121
|
flex: 1,
|
|
57
122
|
},
|
|
123
|
+
iconMedia: {
|
|
124
|
+
alignItems: "center",
|
|
125
|
+
justifyContent: "center",
|
|
126
|
+
},
|
|
127
|
+
imageMedia: {
|
|
128
|
+
width: 40,
|
|
129
|
+
height: 40,
|
|
130
|
+
borderRadius: 6,
|
|
131
|
+
overflow: "hidden",
|
|
132
|
+
backgroundColor: "#f3f4f6",
|
|
133
|
+
},
|
|
134
|
+
roundImage: {
|
|
135
|
+
borderRadius: 9999,
|
|
136
|
+
},
|
|
58
137
|
title: {
|
|
59
138
|
fontSize: 15,
|
|
60
139
|
lineHeight: 20,
|
|
61
140
|
fontWeight: "500",
|
|
62
141
|
},
|
|
63
142
|
description: {
|
|
64
|
-
fontSize:
|
|
65
|
-
lineHeight:
|
|
66
|
-
marginTop: 1,
|
|
143
|
+
fontSize: 12,
|
|
144
|
+
lineHeight: 16,
|
|
67
145
|
},
|
|
68
146
|
actions: {
|
|
69
147
|
marginLeft: "auto",
|
|
70
|
-
paddingLeft:
|
|
148
|
+
paddingLeft: 8,
|
|
71
149
|
flexDirection: "row",
|
|
72
150
|
alignItems: "center",
|
|
73
151
|
flexShrink: 0,
|
|
@@ -2,9 +2,10 @@ import type { ComponentRenderProps } from "@json-render/react-native";
|
|
|
2
2
|
import { Children, type ReactNode } from "react";
|
|
3
3
|
import { StyleSheet, View } from "react-native";
|
|
4
4
|
import {
|
|
5
|
+
childrenShouldUseHorizontalButtonLayout,
|
|
6
|
+
childrenAreAllButtons,
|
|
5
7
|
countRenderableChildren,
|
|
6
8
|
defaultHorizontalGapSize,
|
|
7
|
-
horizontalChildrenAreAllButtons,
|
|
8
9
|
} from "../../stack-horizontal-utils.js";
|
|
9
10
|
import {
|
|
10
11
|
SnapStackDirectionProvider,
|
|
@@ -13,7 +14,7 @@ import {
|
|
|
13
14
|
|
|
14
15
|
const VGAP: Record<string, number> = {
|
|
15
16
|
none: 0,
|
|
16
|
-
sm:
|
|
17
|
+
sm: 4,
|
|
17
18
|
md: 16,
|
|
18
19
|
lg: 24,
|
|
19
20
|
};
|
|
@@ -33,7 +34,7 @@ const JUSTIFY: Record<string, "flex-start" | "center" | "flex-end" | "space-betw
|
|
|
33
34
|
around: "space-around",
|
|
34
35
|
};
|
|
35
36
|
|
|
36
|
-
/** Equal-width cells for explicit `
|
|
37
|
+
/** Equal-width cells for explicit `equalWidth` / `columns` props. */
|
|
37
38
|
function wrapEqualColumnCells(children: ReactNode): ReactNode {
|
|
38
39
|
const cells = Children.toArray(children).filter(
|
|
39
40
|
(c) => c != null && c !== false,
|
|
@@ -50,17 +51,21 @@ export function SnapStack({
|
|
|
50
51
|
children,
|
|
51
52
|
}: ComponentRenderProps<Record<string, unknown>> & { children?: ReactNode }) {
|
|
52
53
|
const parentDirection = useSnapStackDirection();
|
|
53
|
-
const
|
|
54
|
+
const buttonContentUsesHorizontal =
|
|
55
|
+
childrenShouldUseHorizontalButtonLayout(children);
|
|
56
|
+
const direction =
|
|
57
|
+
buttonContentUsesHorizontal === undefined
|
|
58
|
+
? String(props.direction ?? "vertical")
|
|
59
|
+
: buttonContentUsesHorizontal
|
|
60
|
+
? "horizontal"
|
|
61
|
+
: "vertical";
|
|
54
62
|
const rawGap = props.gap;
|
|
55
63
|
const isHorizontal = direction === "horizontal";
|
|
56
64
|
const gapMap = isHorizontal ? HGAP : VGAP;
|
|
57
|
-
const
|
|
58
|
-
isHorizontal && horizontalChildrenAreAllButtons(children);
|
|
59
|
-
const buttonRowCount = buttonRowGrid
|
|
60
|
-
? countRenderableChildren(children)
|
|
61
|
-
: 0;
|
|
65
|
+
const allChildrenAreButtons = childrenAreAllButtons(children);
|
|
62
66
|
|
|
63
67
|
const columnsRaw = props.columns;
|
|
68
|
+
const equalWidth = props.equalWidth === true;
|
|
64
69
|
const columns =
|
|
65
70
|
typeof columnsRaw === "number" &&
|
|
66
71
|
columnsRaw >= 2 &&
|
|
@@ -69,49 +74,44 @@ export function SnapStack({
|
|
|
69
74
|
? columnsRaw
|
|
70
75
|
: undefined;
|
|
71
76
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
const equalWidthColumnCount =
|
|
78
|
+
columns ?? (equalWidth ? countRenderableChildren(children) : undefined);
|
|
79
|
+
const explicitEqualWidth =
|
|
80
|
+
isHorizontal &&
|
|
81
|
+
equalWidthColumnCount !== undefined &&
|
|
82
|
+
equalWidthColumnCount >= 1 &&
|
|
83
|
+
equalWidthColumnCount <= 6;
|
|
84
|
+
|
|
85
|
+
// Button-only stacks always default to sm; mixed horizontal stacks scale by child count.
|
|
86
|
+
// Vertical non-button stacks default to md.
|
|
87
|
+
const horizontalChildCount = isHorizontal
|
|
88
|
+
? (explicitEqualWidth
|
|
89
|
+
? equalWidthColumnCount
|
|
90
|
+
: countRenderableChildren(children))
|
|
79
91
|
: undefined;
|
|
80
92
|
const gap =
|
|
81
93
|
typeof rawGap === "number"
|
|
82
94
|
? rawGap
|
|
83
95
|
: typeof rawGap === "string" && rawGap in gapMap
|
|
84
96
|
? gapMap[rawGap]!
|
|
85
|
-
:
|
|
86
|
-
? gapMap
|
|
97
|
+
: allChildrenAreButtons
|
|
98
|
+
? gapMap.sm!
|
|
99
|
+
: isHorizontal
|
|
100
|
+
? gapMap[defaultHorizontalGapSize(horizontalChildCount)]!
|
|
87
101
|
: VGAP.md!;
|
|
88
|
-
const explicitColumnGrid =
|
|
89
|
-
isHorizontal && columns !== undefined && !buttonRowGrid;
|
|
90
|
-
|
|
91
102
|
const justify =
|
|
92
103
|
props.justify &&
|
|
93
|
-
(!isHorizontal ||
|
|
104
|
+
(!isHorizontal || !explicitEqualWidth)
|
|
94
105
|
? JUSTIFY[String(props.justify)]
|
|
95
106
|
: undefined;
|
|
96
107
|
|
|
97
108
|
const isRowChild = parentDirection === "horizontal";
|
|
98
109
|
|
|
99
110
|
const packedHorizontal =
|
|
100
|
-
isHorizontal &&
|
|
101
|
-
((buttonRowGrid &&
|
|
102
|
-
buttonRowCount >= 1 &&
|
|
103
|
-
buttonRowCount <= 6) ||
|
|
104
|
-
explicitColumnGrid);
|
|
111
|
+
isHorizontal && explicitEqualWidth;
|
|
105
112
|
|
|
106
113
|
let horizontalBody: ReactNode = children;
|
|
107
|
-
if (
|
|
108
|
-
isHorizontal &&
|
|
109
|
-
buttonRowGrid &&
|
|
110
|
-
buttonRowCount >= 1 &&
|
|
111
|
-
buttonRowCount <= 6
|
|
112
|
-
) {
|
|
113
|
-
horizontalBody = wrapEqualColumnCells(children);
|
|
114
|
-
} else if (isHorizontal && explicitColumnGrid && columns !== undefined) {
|
|
114
|
+
if (isHorizontal && explicitEqualWidth) {
|
|
115
115
|
horizontalBody = wrapEqualColumnCells(children);
|
|
116
116
|
}
|
|
117
117
|
|
|
@@ -163,7 +163,7 @@ const styles = StyleSheet.create({
|
|
|
163
163
|
width: "100%",
|
|
164
164
|
minWidth: 0,
|
|
165
165
|
},
|
|
166
|
-
/** Single row for packed equal-width cells
|
|
166
|
+
/** Single row for packed equal-width cells from explicit equal-width layout. */
|
|
167
167
|
horizontalPacked: {
|
|
168
168
|
flexDirection: "row",
|
|
169
169
|
flexWrap: "nowrap",
|
|
@@ -176,5 +176,6 @@ const styles = StyleSheet.create({
|
|
|
176
176
|
flexShrink: 1,
|
|
177
177
|
flexBasis: 0,
|
|
178
178
|
minWidth: 0,
|
|
179
|
+
alignSelf: "stretch",
|
|
179
180
|
},
|
|
180
181
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ComponentRenderProps } from "@json-render/react-native";
|
|
2
2
|
import { useStateStore } from "@json-render/react-native";
|
|
3
3
|
import { Pressable, StyleSheet, Text, View } from "react-native";
|
|
4
|
+
import { shouldUseHorizontalButtonContent } from "../../button-orientation-utils.js";
|
|
4
5
|
import { useSnapTheme } from "../theme";
|
|
5
6
|
|
|
6
7
|
export function SnapToggleGroup({
|
|
@@ -12,7 +13,6 @@ export function SnapToggleGroup({
|
|
|
12
13
|
const path = `/inputs/${name}`;
|
|
13
14
|
const label = props.label ? String(props.label) : undefined;
|
|
14
15
|
const isMultiple = Boolean(props.multiple);
|
|
15
|
-
const orientation = String(props.orientation ?? "horizontal");
|
|
16
16
|
const options = Array.isArray(props.options)
|
|
17
17
|
? (props.options as string[])
|
|
18
18
|
: [];
|
|
@@ -38,7 +38,7 @@ export function SnapToggleGroup({
|
|
|
38
38
|
return [];
|
|
39
39
|
})();
|
|
40
40
|
|
|
41
|
-
const isVertical =
|
|
41
|
+
const isVertical = !shouldUseHorizontalButtonContent(options);
|
|
42
42
|
|
|
43
43
|
const handlePress = (opt: string) => {
|
|
44
44
|
if (isMultiple) {
|
|
@@ -65,7 +65,7 @@ export function SnapToggleGroup({
|
|
|
65
65
|
const isSelected = selected.includes(opt);
|
|
66
66
|
return (
|
|
67
67
|
<Pressable
|
|
68
|
-
key={index}
|
|
68
|
+
key={`${opt}-${index}`}
|
|
69
69
|
style={({ pressed }) => [
|
|
70
70
|
styles.option,
|
|
71
71
|
{
|
|
@@ -117,7 +117,8 @@ const styles = StyleSheet.create({
|
|
|
117
117
|
justifyContent: "center",
|
|
118
118
|
},
|
|
119
119
|
optionHorizontal: {
|
|
120
|
-
|
|
120
|
+
flexGrow: 1,
|
|
121
|
+
flexShrink: 1,
|
|
121
122
|
},
|
|
122
123
|
optionText: {
|
|
123
124
|
fontSize: 13,
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { Animated, StyleSheet, View, useWindowDimensions } from "react-native";
|
|
3
|
+
|
|
4
|
+
const FIREWORK_COLORS = [
|
|
5
|
+
"#FFD700",
|
|
6
|
+
"#FF6B6B",
|
|
7
|
+
"#4ECDC4",
|
|
8
|
+
"#C4A7E7",
|
|
9
|
+
"#F6C177",
|
|
10
|
+
"#EBBCBA",
|
|
11
|
+
"#9CCFD8",
|
|
12
|
+
"#fff",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const BURST_COUNT = 5;
|
|
16
|
+
const PARTICLE_COUNT = 24;
|
|
17
|
+
|
|
18
|
+
type BurstData = {
|
|
19
|
+
id: number;
|
|
20
|
+
x: number;
|
|
21
|
+
y: number;
|
|
22
|
+
particles: Array<{
|
|
23
|
+
id: number;
|
|
24
|
+
vx: number;
|
|
25
|
+
vy: number;
|
|
26
|
+
color: string;
|
|
27
|
+
size: number;
|
|
28
|
+
}>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function FireworkBurst({ burst }: { burst: BurstData }) {
|
|
32
|
+
const flashAnim = useRef(new Animated.Value(0)).current;
|
|
33
|
+
const burstAnim = useRef(new Animated.Value(0)).current;
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const composite = Animated.parallel([
|
|
37
|
+
Animated.timing(flashAnim, {
|
|
38
|
+
toValue: 1,
|
|
39
|
+
duration: 400,
|
|
40
|
+
useNativeDriver: true,
|
|
41
|
+
}),
|
|
42
|
+
Animated.timing(burstAnim, {
|
|
43
|
+
toValue: 1,
|
|
44
|
+
duration: 1000,
|
|
45
|
+
useNativeDriver: true,
|
|
46
|
+
}),
|
|
47
|
+
]);
|
|
48
|
+
composite.start();
|
|
49
|
+
return () => composite.stop();
|
|
50
|
+
}, [flashAnim, burstAnim]);
|
|
51
|
+
|
|
52
|
+
const flashOpacity = flashAnim.interpolate({
|
|
53
|
+
inputRange: [0, 0.25, 1],
|
|
54
|
+
outputRange: [0, 1, 0],
|
|
55
|
+
});
|
|
56
|
+
const flashScale = flashAnim.interpolate({
|
|
57
|
+
inputRange: [0, 0.25, 1],
|
|
58
|
+
outputRange: [0, 2.5, 5],
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<>
|
|
63
|
+
<Animated.View
|
|
64
|
+
style={[
|
|
65
|
+
styles.flash,
|
|
66
|
+
{
|
|
67
|
+
left: burst.x - 6,
|
|
68
|
+
top: burst.y - 6,
|
|
69
|
+
opacity: flashOpacity,
|
|
70
|
+
transform: [{ scale: flashScale }],
|
|
71
|
+
},
|
|
72
|
+
]}
|
|
73
|
+
/>
|
|
74
|
+
{burst.particles.map((p) => {
|
|
75
|
+
const opacity = burstAnim.interpolate({
|
|
76
|
+
inputRange: [0, 0.65, 1],
|
|
77
|
+
outputRange: [1, 1, 0],
|
|
78
|
+
});
|
|
79
|
+
const translateX = burstAnim.interpolate({
|
|
80
|
+
inputRange: [0, 1],
|
|
81
|
+
outputRange: [0, p.vx],
|
|
82
|
+
});
|
|
83
|
+
const translateY = burstAnim.interpolate({
|
|
84
|
+
inputRange: [0, 1],
|
|
85
|
+
outputRange: [0, p.vy],
|
|
86
|
+
});
|
|
87
|
+
const scale = burstAnim.interpolate({
|
|
88
|
+
inputRange: [0, 1],
|
|
89
|
+
outputRange: [1, 0],
|
|
90
|
+
});
|
|
91
|
+
return (
|
|
92
|
+
<Animated.View
|
|
93
|
+
key={p.id}
|
|
94
|
+
style={[
|
|
95
|
+
styles.particle,
|
|
96
|
+
{
|
|
97
|
+
left: burst.x - p.size / 2,
|
|
98
|
+
top: burst.y - p.size / 2,
|
|
99
|
+
width: p.size,
|
|
100
|
+
height: p.size,
|
|
101
|
+
backgroundColor: p.color,
|
|
102
|
+
opacity,
|
|
103
|
+
transform: [{ translateX }, { translateY }, { scale }],
|
|
104
|
+
},
|
|
105
|
+
]}
|
|
106
|
+
/>
|
|
107
|
+
);
|
|
108
|
+
})}
|
|
109
|
+
</>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function FireworksOverlay() {
|
|
114
|
+
const { width, height } = useWindowDimensions();
|
|
115
|
+
|
|
116
|
+
const bursts = useMemo<(BurstData & { delay: number })[]>(
|
|
117
|
+
() =>
|
|
118
|
+
Array.from({ length: BURST_COUNT }, (_, b) => ({
|
|
119
|
+
id: b,
|
|
120
|
+
x: (0.15 + Math.random() * 0.7) * width,
|
|
121
|
+
y: (0.1 + Math.random() * 0.5) * height,
|
|
122
|
+
delay: b * 500 + Math.random() * 200,
|
|
123
|
+
particles: Array.from({ length: PARTICLE_COUNT }, (_, p) => {
|
|
124
|
+
const angle =
|
|
125
|
+
(p / PARTICLE_COUNT) * Math.PI * 2 + (Math.random() - 0.5) * 0.2;
|
|
126
|
+
const dist = 55 + Math.random() * 60;
|
|
127
|
+
return {
|
|
128
|
+
id: p,
|
|
129
|
+
vx: Math.cos(angle) * dist,
|
|
130
|
+
vy: Math.sin(angle) * dist,
|
|
131
|
+
color:
|
|
132
|
+
FIREWORK_COLORS[
|
|
133
|
+
Math.floor(Math.random() * FIREWORK_COLORS.length)
|
|
134
|
+
]!,
|
|
135
|
+
size: 3 + Math.random() * 3,
|
|
136
|
+
};
|
|
137
|
+
}),
|
|
138
|
+
})),
|
|
139
|
+
// stable on mount
|
|
140
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
141
|
+
[],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const [mountedBursts, setMountedBursts] = useState<number[]>([]);
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
const timers = bursts.map((burst, b) =>
|
|
148
|
+
setTimeout(() => {
|
|
149
|
+
setMountedBursts((prev) => [...prev, b]);
|
|
150
|
+
}, burst.delay),
|
|
151
|
+
);
|
|
152
|
+
return () => timers.forEach(clearTimeout);
|
|
153
|
+
}, [bursts]);
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<View style={StyleSheet.absoluteFill} pointerEvents="none">
|
|
157
|
+
{mountedBursts.map((b) => (
|
|
158
|
+
<FireworkBurst key={b} burst={bursts[b]!} />
|
|
159
|
+
))}
|
|
160
|
+
</View>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const styles = StyleSheet.create({
|
|
165
|
+
flash: {
|
|
166
|
+
position: "absolute",
|
|
167
|
+
width: 12,
|
|
168
|
+
height: 12,
|
|
169
|
+
borderRadius: 6,
|
|
170
|
+
backgroundColor: "#fff",
|
|
171
|
+
},
|
|
172
|
+
particle: {
|
|
173
|
+
position: "absolute",
|
|
174
|
+
borderRadius: 999,
|
|
175
|
+
},
|
|
176
|
+
});
|
|
@@ -2,6 +2,7 @@ import type { Spec } from "@json-render/core";
|
|
|
2
2
|
import { snapJsonRenderCatalog } from "@farcaster/snap/ui";
|
|
3
3
|
import { SnapCatalogView } from "./catalog-renderer";
|
|
4
4
|
import { ConfettiOverlay } from "./confetti-overlay";
|
|
5
|
+
import { FireworksOverlay } from "./fireworks-overlay";
|
|
5
6
|
import { useSnapTheme } from "./theme";
|
|
6
7
|
import {
|
|
7
8
|
type ReactNode,
|
|
@@ -128,10 +129,13 @@ export function SnapViewCoreInner({
|
|
|
128
129
|
}, [spec]);
|
|
129
130
|
|
|
130
131
|
const showConfetti = snap.effects?.includes("confetti") ?? false;
|
|
132
|
+
const showFireworks = snap.effects?.includes("fireworks") ?? false;
|
|
131
133
|
const [confettiKey, setConfettiKey] = useState(0);
|
|
134
|
+
const [fireworksKey, setFireworksKey] = useState(0);
|
|
132
135
|
useEffect(() => {
|
|
133
136
|
if (showConfetti) setConfettiKey((k) => k + 1);
|
|
134
|
-
|
|
137
|
+
if (showFireworks) setFireworksKey((k) => k + 1);
|
|
138
|
+
}, [showConfetti, showFireworks, snap]);
|
|
135
139
|
|
|
136
140
|
const handlersRef = useRef(handlers);
|
|
137
141
|
handlersRef.current = handlers;
|
|
@@ -213,6 +217,7 @@ export function SnapViewCoreInner({
|
|
|
213
217
|
onAction={handleAction}
|
|
214
218
|
/>
|
|
215
219
|
{showConfetti && <ConfettiOverlay key={confettiKey} />}
|
|
220
|
+
{showFireworks && <FireworksOverlay key={fireworksKey} />}
|
|
216
221
|
</View>
|
|
217
222
|
);
|
|
218
223
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Children, isValidElement, type ReactNode } from "react";
|
|
2
2
|
|
|
3
|
+
import { shouldUseHorizontalButtonContent } from "./button-orientation-utils.js";
|
|
4
|
+
|
|
3
5
|
/**
|
|
4
6
|
* True when every rendered child comes from a catalog `button` element.
|
|
5
7
|
* json-render passes `{ element: { type, props, ... } }` into each catalog component.
|
|
@@ -10,32 +12,49 @@ function isRenderableChild(c: ReactNode): boolean {
|
|
|
10
12
|
return true;
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
export function
|
|
15
|
+
export function childrenAreAllButtons(children: ReactNode): boolean {
|
|
16
|
+
return getButtonChildLabels(children) !== undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function getButtonChildLabels(children: ReactNode): string[] | undefined {
|
|
14
20
|
const items = Children.toArray(children).filter(isRenderableChild);
|
|
15
|
-
if (items.length === 0) return
|
|
21
|
+
if (items.length === 0) return undefined;
|
|
22
|
+
const labels: string[] = [];
|
|
16
23
|
for (const child of items) {
|
|
17
|
-
if (!isValidElement(child)) return
|
|
18
|
-
const
|
|
19
|
-
|
|
24
|
+
if (!isValidElement(child)) return undefined;
|
|
25
|
+
const element = (
|
|
26
|
+
child.props as {
|
|
27
|
+
element?: { type?: unknown; props?: Record<string, unknown> };
|
|
28
|
+
}
|
|
29
|
+
).element;
|
|
30
|
+
if (element?.type !== "button") return undefined;
|
|
31
|
+
labels.push(String(element.props?.label ?? ""));
|
|
20
32
|
}
|
|
21
|
-
return
|
|
33
|
+
return labels;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function childrenShouldUseHorizontalButtonLayout(
|
|
37
|
+
children: ReactNode,
|
|
38
|
+
): boolean | undefined {
|
|
39
|
+
const labels = getButtonChildLabels(children);
|
|
40
|
+
return labels ? shouldUseHorizontalButtonContent(labels) : undefined;
|
|
22
41
|
}
|
|
23
42
|
|
|
24
|
-
/** Direct snap catalog children under a stack (used for
|
|
43
|
+
/** Direct snap catalog children under a stack (used for horizontal gap defaults). */
|
|
25
44
|
export function countRenderableChildren(children: ReactNode): number {
|
|
26
45
|
return Children.toArray(children).filter(isRenderableChild).length;
|
|
27
46
|
}
|
|
28
47
|
|
|
29
48
|
/**
|
|
30
|
-
* Default horizontal stack gap as a t-shirt size, chosen by
|
|
31
|
-
* 2
|
|
49
|
+
* Default horizontal stack gap as a t-shirt size, chosen by direct child count:
|
|
50
|
+
* 2 children → lg, 3 children → md, 4+ children → sm. Unknown count falls back to md.
|
|
32
51
|
* Tighter gaps for denser layouts; authors can always override via the `gap` prop.
|
|
33
52
|
*/
|
|
34
53
|
export function defaultHorizontalGapSize(
|
|
35
|
-
|
|
54
|
+
childCount: number | undefined,
|
|
36
55
|
): "sm" | "md" | "lg" {
|
|
37
|
-
if (
|
|
38
|
-
if (
|
|
39
|
-
if (
|
|
56
|
+
if (childCount === undefined) return "md";
|
|
57
|
+
if (childCount <= 2) return "lg";
|
|
58
|
+
if (childCount === 3) return "md";
|
|
40
59
|
return "sm";
|
|
41
60
|
}
|