Bluesky app fork with some witchin' additions 馃挮
1import {useEffect, useState} from 'react' 2import { 3 type LayoutChangeEvent, 4 type StyleProp, 5 View, 6 type ViewStyle, 7} from 'react-native' 8import {msg} from '@lingui/core/macro' 9import {useLingui} from '@lingui/react' 10import {Trans} from '@lingui/react/macro' 11 12import {useProfileFollowsQuery} from '#/state/queries/profile-follows' 13import {useSession} from '#/state/session' 14import { 15 useProgressGuide, 16 useProgressGuideControls, 17} from '#/state/shell/progress-guide' 18import {UserAvatar} from '#/view/com/util/UserAvatar' 19import {atoms as a, useBreakpoints, useLayoutBreakpoints, useTheme} from '#/alf' 20import {Button, ButtonIcon} from '#/components/Button' 21import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person' 22import {TimesLarge_Stroke2_Corner0_Rounded as Times} from '#/components/icons/Times' 23import {Text} from '#/components/Typography' 24import type * as bsky from '#/types/bsky' 25import {FollowDialog} from './FollowDialog' 26import {ProgressGuideTask} from './Task' 27 28const TOTAL_AVATARS = 10 29 30export function ProgressGuideList({style}: {style?: StyleProp<ViewStyle>}) { 31 const t = useTheme() 32 const {_} = useLingui() 33 const {gtPhone} = useBreakpoints() 34 const {rightNavVisible} = useLayoutBreakpoints() 35 const {currentAccount} = useSession() 36 const followProgressGuide = useProgressGuide('follow-10') 37 const followAndLikeProgressGuide = useProgressGuide('like-10-and-follow-7') 38 const guide = followProgressGuide || followAndLikeProgressGuide 39 const {endProgressGuide} = useProgressGuideControls() 40 const {data: follows} = useProfileFollowsQuery(currentAccount?.did, { 41 limit: TOTAL_AVATARS, 42 }) 43 44 const actualFollowsCount = follows?.pages?.[0]?.follows?.length ?? 0 45 46 // Clear stale guide if user already follows 10+ people 47 const shouldEndGuide = 48 guide?.guide === 'follow-10' && actualFollowsCount >= TOTAL_AVATARS 49 useEffect(() => { 50 if (shouldEndGuide) { 51 endProgressGuide() 52 } 53 }, [shouldEndGuide, endProgressGuide]) 54 55 if (shouldEndGuide) { 56 return null 57 } 58 59 // Inline layout when left nav visible but no right sidebar (800-1100px) 60 const inlineLayout = gtPhone && !rightNavVisible 61 62 if (guide) { 63 return ( 64 <View 65 style={[ 66 a.flex_col, 67 a.gap_md, 68 a.rounded_md, 69 t.atoms.bg_contrast_50, 70 a.p_lg, 71 style, 72 ]}> 73 <View style={[a.flex_row, a.align_center, a.justify_between]}> 74 <Text style={[t.atoms.text, a.font_semi_bold, a.text_md]}> 75 <Trans>Follow 10 people to get started</Trans> 76 </Text> 77 <Button 78 variant="ghost" 79 size="tiny" 80 color="secondary" 81 shape="round" 82 label={_(msg`Dismiss getting started guide`)} 83 onPress={endProgressGuide} 84 style={[a.bg_transparent, {marginTop: -6, marginRight: -6}]}> 85 <ButtonIcon icon={Times} size="xs" /> 86 </Button> 87 </View> 88 {guide.guide === 'follow-10' && ( 89 <View 90 style={[ 91 inlineLayout 92 ? [ 93 a.flex_row, 94 a.flex_wrap, 95 a.align_center, 96 a.justify_between, 97 a.gap_sm, 98 ] 99 : [a.flex_col, a.gap_md], 100 ]}> 101 <StackedAvatars follows={follows?.pages?.[0]?.follows} /> 102 <FollowDialog guide={guide} showArrow={inlineLayout} /> 103 </View> 104 )} 105 {guide.guide === 'like-10-and-follow-7' && ( 106 <> 107 <ProgressGuideTask 108 current={guide.numLikes + 1} 109 total={10 + 1} 110 title={_(msg`Like 10 posts`)} 111 subtitle={_(msg`Teach our algorithm what you like`)} 112 /> 113 <ProgressGuideTask 114 current={guide.numFollows + 1} 115 total={7 + 1} 116 title={_(msg`Follow 7 accounts`)} 117 subtitle={_(msg`Bluesky is better with friends!`)} 118 /> 119 </> 120 )} 121 </View> 122 ) 123 } 124 return null 125} 126 127function StackedAvatars({follows}: {follows?: bsky.profile.AnyProfileView[]}) { 128 const t = useTheme() 129 const [containerWidth, setContainerWidth] = useState(0) 130 131 const onLayout = (e: LayoutChangeEvent) => { 132 setContainerWidth(e.nativeEvent.layout.width) 133 } 134 135 // Overlap ratio (22% of avatar size) 136 const overlapRatio = 0.22 137 138 // Calculate avatar size to fill container width 139 // Formula: containerWidth = avatarSize * count - overlap * (count - 1) 140 // Where overlap = avatarSize * overlapRatio 141 const visiblePortions = TOTAL_AVATARS - overlapRatio * (TOTAL_AVATARS - 1) 142 const avatarSize = containerWidth > 0 ? containerWidth / visiblePortions : 0 143 const overlap = avatarSize * overlapRatio 144 const iconSize = avatarSize * 0.5 145 146 const followedAvatars = follows?.slice(0, TOTAL_AVATARS) ?? [] 147 const remainingSlots = TOTAL_AVATARS - followedAvatars.length 148 149 return ( 150 <View style={[a.flex_row, a.flex_1]} onLayout={onLayout}> 151 {containerWidth > 0 && ( 152 <> 153 {/* Show followed user avatars */} 154 {followedAvatars.map((follow, i) => ( 155 <View 156 key={follow.did} 157 style={[ 158 a.rounded_full, 159 a.border, 160 t.atoms.border_contrast_low, 161 { 162 marginLeft: i === 0 ? 0 : -overlap, 163 zIndex: TOTAL_AVATARS - i, 164 }, 165 ]}> 166 <UserAvatar 167 type="user" 168 size={avatarSize - 2} 169 avatar={follow.avatar} 170 noBorder 171 /> 172 </View> 173 ))} 174 {/* Show placeholder avatars for remaining slots */} 175 {Array(remainingSlots) 176 .fill(0) 177 .map((_, i) => ( 178 <View 179 key={`placeholder-${i}`} 180 style={[ 181 a.align_center, 182 a.justify_center, 183 a.rounded_full, 184 t.atoms.bg_contrast_300, 185 a.border, 186 t.atoms.border_contrast_low, 187 { 188 width: avatarSize, 189 height: avatarSize, 190 marginLeft: 191 followedAvatars.length === 0 && i === 0 ? 0 : -overlap, 192 zIndex: TOTAL_AVATARS - followedAvatars.length - i, 193 }, 194 ]}> 195 <PersonIcon 196 width={iconSize} 197 height={iconSize} 198 fill={t.atoms.bg_contrast_50.backgroundColor} 199 /> 200 </View> 201 ))} 202 </> 203 )} 204 </View> 205 ) 206}