forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useCallback, useEffect} from 'react'
2import {type StyleProp, View, type ViewStyle} from 'react-native'
3import Animated, {
4 Easing,
5 interpolate,
6 type SharedValue,
7 useAnimatedStyle,
8 useSharedValue,
9 withDelay,
10 withTiming,
11} from 'react-native-reanimated'
12
13import {useSession} from '#/state/session'
14import {UserAvatar} from '#/view/com/util/UserAvatar'
15import {atoms as a, useTheme} from '#/alf'
16import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
17import type * as bsky from '#/types/bsky'
18
19type Props = {
20 animate?: boolean
21 profiles: bsky.profile.AnyProfileView[]
22 size?: 'small' | 'medium' | 'large' | number
23}
24
25export function AvatarBubbles({
26 animate = false,
27 profiles: allProfiles,
28 size = 'large',
29}: Props) {
30 const {currentAccount} = useSession()
31 const profiles =
32 allProfiles.length > 2
33 ? allProfiles.filter(p => p.did !== currentAccount?.did)
34 : allProfiles
35 const containerSize =
36 typeof size === 'number'
37 ? size
38 : size === 'small'
39 ? 40
40 : size === 'medium'
41 ? 56
42 : 120
43 const scale =
44 typeof size === 'number'
45 ? size / 120
46 : size === 'small'
47 ? 40 / 120
48 : size === 'medium'
49 ? 56 / 120
50 : 1
51 const marginOffset =
52 (typeof size === 'number' && size < 120) ||
53 size === 'small' ||
54 size === 'medium'
55 ? -2
56 : 0
57
58 const initialValue = animate ? 0 : 1
59 const p0 = useSharedValue(initialValue)
60 const p1 = useSharedValue(initialValue)
61 const p2 = useSharedValue(initialValue)
62 const p3 = useSharedValue(initialValue)
63
64 const animateScale = (p: Animated.SharedValue<number>, index: number) => {
65 p.set(0)
66 p.set(() =>
67 withDelay(
68 500 + index * 100,
69 withTiming(1, {
70 duration: 250,
71 easing: Easing.out(Easing.back(1.75)),
72 }),
73 ),
74 )
75 }
76
77 const playScaleAnimation = useCallback(() => {
78 animateScale(p0, 0)
79 animateScale(p1, 1)
80 animateScale(p2, 2)
81 animateScale(p3, 3)
82 }, [p0, p1, p2, p3])
83
84 useEffect(() => {
85 if (!animate) return
86 playScaleAnimation()
87 }, [animate, playScaleAnimation])
88
89 let avatars = (
90 <>
91 <AvatarBubble
92 profile={profiles[0]}
93 scale={p0}
94 size={76}
95 x={-2}
96 y={-2}
97 style={[a.z_20]}
98 includeProfileBorder
99 />
100 <AvatarBubble
101 profile={profiles[1]}
102 scale={p1}
103 size={76}
104 x={42}
105 y={42}
106 style={[a.z_10]}
107 includeProfileBorder
108 />
109 </>
110 )
111
112 if (profiles.length === 3) {
113 avatars = (
114 <>
115 <AvatarBubble
116 profile={profiles[0]}
117 scale={p0}
118 size={68}
119 x={-2}
120 y={-2}
121 />
122 <AvatarBubble
123 profile={profiles[1]}
124 scale={p1}
125 size={56}
126 x={38}
127 y={62}
128 />
129 <AvatarBubble
130 profile={profiles[2]}
131 scale={p2}
132 size={46}
133 x={71}
134 y={18}
135 />
136 </>
137 )
138 }
139
140 if (profiles.length >= 4) {
141 avatars = (
142 <>
143 <AvatarBubble
144 profile={profiles[0]}
145 scale={p0}
146 size={68}
147 x={-2}
148 y={-2}
149 />
150 <AvatarBubble
151 profile={profiles[1]}
152 scale={p1}
153 size={56}
154 x={60}
155 y={49}
156 />
157 <AvatarBubble
158 profile={profiles[2]}
159 scale={p2}
160 size={42}
161 x={14}
162 y={74}
163 />
164 <AvatarBubble profile={profiles[3]} scale={p3} size={32} x={72} y={9} />
165 </>
166 )
167 }
168
169 return (
170 <Animated.View
171 style={[
172 a.p_2xs,
173 {
174 height: containerSize,
175 width: containerSize,
176 },
177 ]}>
178 <View
179 style={[
180 {
181 marginTop: marginOffset,
182 marginLeft: marginOffset,
183 transform: [{scale}],
184 transformOrigin: 'top left',
185 },
186 ]}>
187 {avatars}
188 </View>
189 </Animated.View>
190 )
191}
192
193function AvatarBubble({
194 profile,
195 scale,
196 size,
197 style,
198 x,
199 y,
200 includeProfileBorder,
201}: {
202 profile?: bsky.profile.AnyProfileView
203 scale: SharedValue<number>
204 size: number
205 style?: StyleProp<ViewStyle>
206 x: number
207 y: number
208 includeProfileBorder?: boolean
209}) {
210 const t = useTheme()
211
212 const animatedStyle = useAnimatedStyle(() => ({
213 transform: [
214 {translateX: x},
215 {translateY: y},
216 {scale: interpolate(scale.get(), [0, 1], [0, 1])},
217 ],
218 }))
219
220 return (
221 <Animated.View
222 style={[
223 a.absolute,
224 a.rounded_full,
225 a.flex_grow_0,
226 includeProfileBorder && {
227 borderColor: t.atoms.text_inverted.color,
228 borderWidth: 2,
229 },
230 style,
231 animatedStyle,
232 ]}>
233 {profile ? (
234 <Avatar profile={profile} size={size} />
235 ) : (
236 <AvatarPlaceholder size={size} />
237 )}
238 </Animated.View>
239 )
240}
241
242function Avatar({
243 profile,
244 size = 76,
245}: {
246 profile: bsky.profile.AnyProfileView
247 size?: number
248}) {
249 return (
250 <UserAvatar
251 avatar={profile.avatar}
252 size={size}
253 type="user"
254 hideLiveBadge
255 noBorder
256 />
257 )
258}
259
260function AvatarPlaceholder({size = 76}: {size?: number}) {
261 const t = useTheme()
262
263 return (
264 <View
265 style={[
266 a.align_center,
267 a.justify_center,
268 a.rounded_full,
269 t.atoms.bg_contrast_200,
270 {
271 width: size,
272 height: size,
273 },
274 ]}>
275 <PersonIcon
276 width={size * 0.5}
277 height={size * 0.5}
278 fill={t.atoms.text_inverted.color}
279 />
280 </View>
281 )
282}