forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import React from 'react'
2import {type AppBskyActorDefs as ActorDefs} from '@atproto/api'
3import {msg} from '@lingui/macro'
4import {useLingui} from '@lingui/react'
5import {useNavigation} from '@react-navigation/native'
6
7import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
8import {type NavigationProp} from '#/lib/routes/types'
9import {cleanError} from '#/lib/strings/errors'
10import {logger} from '#/logger'
11import {isWeb} from '#/platform/detection'
12import {useProfileFollowsQuery} from '#/state/queries/profile-follows'
13import {useResolveDidQuery} from '#/state/queries/resolve-uri'
14import {useSession} from '#/state/session'
15import {FindContactsBannerNUX} from '#/components/contacts/FindContactsBannerNUX'
16import {PeopleRemove2_Stroke1_Corner0_Rounded as PeopleRemoveIcon} from '#/components/icons/PeopleRemove2'
17import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
18import {List} from '../util/List'
19import {ProfileCardWithFollowBtn} from './ProfileCard'
20
21function renderItem({
22 item,
23 index,
24 contextProfileDid,
25}: {
26 item: ActorDefs.ProfileView
27 index: number
28 contextProfileDid: string | undefined
29}) {
30 return (
31 <ProfileCardWithFollowBtn
32 key={item.did}
33 profile={item}
34 noBorder={index === 0}
35 position={index + 1}
36 contextProfileDid={contextProfileDid}
37 />
38 )
39}
40
41function keyExtractor(item: ActorDefs.ProfileViewBasic) {
42 return item.did
43}
44
45export function ProfileFollows({name}: {name: string}) {
46 const {_} = useLingui()
47 const initialNumToRender = useInitialNumToRender()
48 const {currentAccount} = useSession()
49 const navigation = useNavigation<NavigationProp>()
50
51 const onPressFindAccounts = React.useCallback(() => {
52 if (isWeb) {
53 navigation.navigate('Search', {})
54 } else {
55 navigation.navigate('SearchTab')
56 navigation.popToTop()
57 }
58 }, [navigation])
59
60 const [isPTRing, setIsPTRing] = React.useState(false)
61 const {
62 data: resolvedDid,
63 isLoading: isDidLoading,
64 error: resolveError,
65 } = useResolveDidQuery(name)
66 const {
67 data,
68 isLoading: isFollowsLoading,
69 isFetchingNextPage,
70 hasNextPage,
71 fetchNextPage,
72 error,
73 refetch,
74 } = useProfileFollowsQuery(resolvedDid)
75
76 const isError = !!resolveError || !!error
77 const isMe = resolvedDid === currentAccount?.did
78
79 const follows = React.useMemo(() => {
80 if (data?.pages) {
81 return data.pages.flatMap(page => page.follows)
82 }
83 return []
84 }, [data])
85
86 // Track pagination events - fire for page 3+ (pages 1-2 may auto-load)
87 const paginationTrackingRef = React.useRef<{
88 did: string | undefined
89 page: number
90 }>({did: undefined, page: 0})
91 React.useEffect(() => {
92 const currentPageCount = data?.pages?.length || 0
93 // Reset tracking when profile changes
94 if (paginationTrackingRef.current.did !== resolvedDid) {
95 paginationTrackingRef.current = {did: resolvedDid, page: currentPageCount}
96 return
97 }
98 if (
99 resolvedDid &&
100 currentPageCount >= 3 &&
101 currentPageCount > paginationTrackingRef.current.page
102 ) {
103 logger.metric('profile:following:paginate', {
104 contextProfileDid: resolvedDid,
105 itemCount: follows.length,
106 page: currentPageCount,
107 })
108 }
109 paginationTrackingRef.current.page = currentPageCount
110 }, [data?.pages?.length, resolvedDid, follows.length])
111
112 const onRefresh = React.useCallback(async () => {
113 setIsPTRing(true)
114 try {
115 await refetch()
116 } catch (err) {
117 logger.error('Failed to refresh follows', {error: err})
118 }
119 setIsPTRing(false)
120 }, [refetch, setIsPTRing])
121
122 const onEndReached = React.useCallback(async () => {
123 if (isFetchingNextPage || !hasNextPage || !!error) return
124 try {
125 await fetchNextPage()
126 } catch (err) {
127 logger.error('Failed to load more follows', {error: err})
128 }
129 }, [isFetchingNextPage, hasNextPage, error, fetchNextPage])
130
131 const renderItemWithContext = React.useCallback(
132 ({item, index}: {item: ActorDefs.ProfileView; index: number}) =>
133 renderItem({item, index, contextProfileDid: resolvedDid}),
134 [resolvedDid],
135 )
136
137 // track pageview
138 React.useEffect(() => {
139 if (resolvedDid) {
140 logger.metric('profile:following:view', {
141 contextProfileDid: resolvedDid,
142 isOwnProfile: isMe,
143 })
144 }
145 }, [resolvedDid, isMe])
146
147 // track seen items
148 const seenItemsRef = React.useRef<Set<string>>(new Set())
149 React.useEffect(() => {
150 seenItemsRef.current.clear()
151 }, [resolvedDid])
152 const onItemSeen = React.useCallback(
153 (item: ActorDefs.ProfileView) => {
154 if (seenItemsRef.current.has(item.did)) {
155 return
156 }
157 seenItemsRef.current.add(item.did)
158 const position = follows.findIndex(p => p.did === item.did) + 1
159 if (position === 0) {
160 return
161 }
162 logger.metric(
163 'profileCard:seen',
164 {
165 profileDid: item.did,
166 position,
167 ...(resolvedDid !== undefined && {contextProfileDid: resolvedDid}),
168 },
169 {statsig: false},
170 )
171 },
172 [follows, resolvedDid],
173 )
174
175 if (follows.length < 1) {
176 return (
177 <ListMaybePlaceholder
178 isLoading={isDidLoading || isFollowsLoading}
179 isError={isError}
180 emptyType="results"
181 emptyMessage={
182 isMe
183 ? _(msg`You are not following anyone yet`)
184 : _(msg`This user isn't following anyone.`)
185 }
186 errorMessage={cleanError(resolveError || error)}
187 onRetry={isError ? refetch : undefined}
188 sideBorders={false}
189 useEmptyState={true}
190 emptyStateIcon={PeopleRemoveIcon}
191 emptyStateButton={{
192 label: _(msg`See suggested accounts`),
193 text: _(msg`See suggested accounts`),
194 onPress: onPressFindAccounts,
195 size: 'tiny',
196 color: 'primary',
197 }}
198 />
199 )
200 }
201
202 return (
203 <List
204 data={follows}
205 renderItem={renderItemWithContext}
206 keyExtractor={keyExtractor}
207 refreshing={isPTRing}
208 onRefresh={onRefresh}
209 onEndReached={onEndReached}
210 onEndReachedThreshold={4}
211 onItemSeen={onItemSeen}
212 ListHeaderComponent={<FindContactsBannerNUX />}
213 ListFooterComponent={
214 <ListFooter
215 isFetchingNextPage={isFetchingNextPage}
216 error={cleanError(error)}
217 onRetry={fetchNextPage}
218 />
219 }
220 // @ts-ignore our .web version only -prf
221 desktopFixedHeight
222 initialNumToRender={initialNumToRender}
223 windowSize={11}
224 sideBorders={false}
225 />
226 )
227}