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