forked from
jollywhoppers.com/witchsky.app
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}