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