forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {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 // Hide if user already follows 10+ people
47 if (guide?.guide === 'follow-10' && actualFollowsCount >= TOTAL_AVATARS) {
48 return null
49 }
50
51 // Inline layout when left nav visible but no right sidebar (800-1100px)
52 const inlineLayout = gtPhone && !rightNavVisible
53
54 if (guide) {
55 return (
56 <View
57 style={[
58 a.flex_col,
59 a.gap_md,
60 a.rounded_md,
61 t.atoms.bg_contrast_50,
62 a.p_lg,
63 style,
64 ]}>
65 <View style={[a.flex_row, a.align_center, a.justify_between]}>
66 <Text style={[t.atoms.text, a.font_semi_bold, a.text_md]}>
67 <Trans>Follow 10 people to get started</Trans>
68 </Text>
69 <Button
70 variant="ghost"
71 size="tiny"
72 color="secondary"
73 shape="round"
74 label={_(msg`Dismiss getting started guide`)}
75 onPress={endProgressGuide}
76 style={[a.bg_transparent, {marginTop: -6, marginRight: -6}]}>
77 <ButtonIcon icon={Times} size="xs" />
78 </Button>
79 </View>
80 {guide.guide === 'follow-10' && (
81 <View
82 style={[
83 inlineLayout
84 ? [
85 a.flex_row,
86 a.flex_wrap,
87 a.align_center,
88 a.justify_between,
89 a.gap_sm,
90 ]
91 : [a.flex_col, a.gap_md],
92 ]}>
93 <StackedAvatars follows={follows?.pages?.[0]?.follows} />
94 <FollowDialog guide={guide} showArrow={inlineLayout} />
95 </View>
96 )}
97 {guide.guide === 'like-10-and-follow-7' && (
98 <>
99 <ProgressGuideTask
100 current={guide.numLikes + 1}
101 total={10 + 1}
102 title={_(msg`Like 10 posts`)}
103 subtitle={_(msg`Teach our algorithm what you like`)}
104 />
105 <ProgressGuideTask
106 current={guide.numFollows + 1}
107 total={7 + 1}
108 title={_(msg`Follow 7 accounts`)}
109 subtitle={_(msg`Bluesky is better with friends!`)}
110 />
111 </>
112 )}
113 </View>
114 )
115 }
116 return null
117}
118
119function StackedAvatars({follows}: {follows?: bsky.profile.AnyProfileView[]}) {
120 const t = useTheme()
121 const [containerWidth, setContainerWidth] = useState(0)
122
123 const onLayout = (e: LayoutChangeEvent) => {
124 setContainerWidth(e.nativeEvent.layout.width)
125 }
126
127 // Overlap ratio (22% of avatar size)
128 const overlapRatio = 0.22
129
130 // Calculate avatar size to fill container width
131 // Formula: containerWidth = avatarSize * count - overlap * (count - 1)
132 // Where overlap = avatarSize * overlapRatio
133 const visiblePortions = TOTAL_AVATARS - overlapRatio * (TOTAL_AVATARS - 1)
134 const avatarSize = containerWidth > 0 ? containerWidth / visiblePortions : 0
135 const overlap = avatarSize * overlapRatio
136 const iconSize = avatarSize * 0.5
137
138 const followedAvatars = follows?.slice(0, TOTAL_AVATARS) ?? []
139 const remainingSlots = TOTAL_AVATARS - followedAvatars.length
140
141 return (
142 <View style={[a.flex_row, a.flex_1]} onLayout={onLayout}>
143 {containerWidth > 0 && (
144 <>
145 {/* Show followed user avatars */}
146 {followedAvatars.map((follow, i) => (
147 <View
148 key={follow.did}
149 style={[
150 a.rounded_full,
151 a.border,
152 t.atoms.border_contrast_low,
153 {
154 marginLeft: i === 0 ? 0 : -overlap,
155 zIndex: TOTAL_AVATARS - i,
156 },
157 ]}>
158 <UserAvatar
159 type="user"
160 size={avatarSize - 2}
161 avatar={follow.avatar}
162 noBorder
163 />
164 </View>
165 ))}
166 {/* Show placeholder avatars for remaining slots */}
167 {Array(remainingSlots)
168 .fill(0)
169 .map((_, i) => (
170 <View
171 key={`placeholder-${i}`}
172 style={[
173 a.align_center,
174 a.justify_center,
175 a.rounded_full,
176 t.atoms.bg_contrast_300,
177 a.border,
178 t.atoms.border_contrast_low,
179 {
180 width: avatarSize,
181 height: avatarSize,
182 marginLeft:
183 followedAvatars.length === 0 && i === 0 ? 0 : -overlap,
184 zIndex: TOTAL_AVATARS - followedAvatars.length - i,
185 },
186 ]}>
187 <PersonIcon
188 width={iconSize}
189 height={iconSize}
190 fill={t.atoms.bg_contrast_50.backgroundColor}
191 />
192 </View>
193 ))}
194 </>
195 )}
196 </View>
197 )
198}