Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
at main 225 lines 6.6 kB view raw
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}