Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at theme-changes 282 lines 5.7 kB view raw
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}