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