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