Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add KnownFollowers component to standard profile header (#4420)

* Add KnownFollowers component to standard profile header

* Prep for known followers screen

* Add known followers screen

* Tighten space

* Add pressed state

* Edit title

* Vertically center

* Don't show if no known followers

* Bump sdk

* Use actual followers.length to show

* Updates to show logic, space

* Prevent fresh data from applying to cached screens

* Tighten space

* Better label

* Oxford comma

* Fix count logic

* Add bskyweb route

* Useless ternary

* Minor spacing tweak

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>

authored by

Eric Bailey
Paul Frazee
and committed by
GitHub
bb0a6a4b 7011ac8f

+399 -6
+1
bskyweb/cmd/bskyweb/server.go
··· 207 207 e.GET("/profile/:handleOrDID", server.WebProfile) 208 208 e.GET("/profile/:handleOrDID/follows", server.WebGeneric) 209 209 e.GET("/profile/:handleOrDID/followers", server.WebGeneric) 210 + e.GET("/profile/:handleOrDID/known-followers", server.WebGeneric) 210 211 e.GET("/profile/:handleOrDID/lists/:rkey", server.WebGeneric) 211 212 e.GET("/profile/:handleOrDID/feed/:rkey", server.WebGeneric) 212 213 e.GET("/profile/:handleOrDID/feed/:rkey/liked-by", server.WebGeneric)
+1 -1
package.json
··· 49 49 "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" 50 50 }, 51 51 "dependencies": { 52 - "@atproto/api": "^0.12.16", 52 + "@atproto/api": "^0.12.18", 53 53 "@bam.tech/react-native-image-resizer": "^3.0.4", 54 54 "@braintree/sanitize-url": "^6.0.2", 55 55 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
+8
src/Navigation.tsx
··· 41 41 import {SavedFeeds} from 'view/screens/SavedFeeds' 42 42 import HashtagScreen from '#/screens/Hashtag' 43 43 import {ModerationScreen} from '#/screens/Moderation' 44 + import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers' 44 45 import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' 45 46 import {init as initAnalytics} from './lib/analytics/analytics' 46 47 import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration' ··· 167 168 getComponent={() => ProfileFollowsScreen} 168 169 options={({route}) => ({ 169 170 title: title(msg`People followed by @${route.params.name}`), 171 + })} 172 + /> 173 + <Stack.Screen 174 + name="ProfileKnownFollowers" 175 + getComponent={() => ProfileKnownFollowersScreen} 176 + options={({route}) => ({ 177 + title: title(msg`Followers of @${route.params.name} that you know`), 170 178 })} 171 179 /> 172 180 <Stack.Screen
+200
src/components/KnownFollowers.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' 4 + import {msg, plural, Trans} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {makeProfileLink} from '#/lib/routes/links' 8 + import {sanitizeDisplayName} from 'lib/strings/display-names' 9 + import {UserAvatar} from '#/view/com/util/UserAvatar' 10 + import {atoms as a, useTheme} from '#/alf' 11 + import {Link} from '#/components/Link' 12 + import {Text} from '#/components/Typography' 13 + 14 + const AVI_SIZE = 30 15 + const AVI_BORDER = 1 16 + 17 + /** 18 + * Shared logic to determine if `KnownFollowers` should be shown. 19 + * 20 + * Checks the # of actual returned users instead of the `count` value, because 21 + * `count` includes blocked users and `followers` does not. 22 + */ 23 + export function shouldShowKnownFollowers( 24 + knownFollowers?: AppBskyActorDefs.KnownFollowers, 25 + ) { 26 + return knownFollowers && knownFollowers.followers.length > 0 27 + } 28 + 29 + export function KnownFollowers({ 30 + profile, 31 + moderationOpts, 32 + }: { 33 + profile: AppBskyActorDefs.ProfileViewDetailed 34 + moderationOpts: ModerationOpts 35 + }) { 36 + const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>( 37 + new Map(), 38 + ) 39 + 40 + /* 41 + * Results for `knownFollowers` are not sorted consistently, so when 42 + * revalidating we can see a flash of this data updating. This cache prevents 43 + * this happening for screens that remain in memory. When pushing a new 44 + * screen, or once this one is popped, this cache is empty, so new data is 45 + * displayed. 46 + */ 47 + if (profile.viewer?.knownFollowers && !cache.current.has(profile.did)) { 48 + cache.current.set(profile.did, profile.viewer.knownFollowers) 49 + } 50 + 51 + const cachedKnownFollowers = cache.current.get(profile.did) 52 + 53 + if (cachedKnownFollowers && shouldShowKnownFollowers(cachedKnownFollowers)) { 54 + return ( 55 + <KnownFollowersInner 56 + profile={profile} 57 + cachedKnownFollowers={cachedKnownFollowers} 58 + moderationOpts={moderationOpts} 59 + /> 60 + ) 61 + } 62 + 63 + return null 64 + } 65 + 66 + function KnownFollowersInner({ 67 + profile, 68 + moderationOpts, 69 + cachedKnownFollowers, 70 + }: { 71 + profile: AppBskyActorDefs.ProfileViewDetailed 72 + moderationOpts: ModerationOpts 73 + cachedKnownFollowers: AppBskyActorDefs.KnownFollowers 74 + }) { 75 + const t = useTheme() 76 + const {_} = useLingui() 77 + 78 + const textStyle = [ 79 + a.flex_1, 80 + a.text_sm, 81 + a.leading_snug, 82 + t.atoms.text_contrast_medium, 83 + ] 84 + 85 + // list of users, minus blocks 86 + const returnedCount = cachedKnownFollowers.followers.length 87 + // db count, includes blocks 88 + const fullCount = cachedKnownFollowers.count 89 + // knownFollowers can return up to 5 users, but will exclude blocks 90 + // therefore, if we have less 5 users, use whichever count is lower 91 + const count = 92 + returnedCount < 5 ? Math.min(fullCount, returnedCount) : fullCount 93 + 94 + const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => { 95 + const moderation = moderateProfile(f, moderationOpts) 96 + return { 97 + profile: { 98 + ...f, 99 + displayName: sanitizeDisplayName( 100 + f.displayName || f.handle, 101 + moderation.ui('displayName'), 102 + ), 103 + }, 104 + moderation, 105 + } 106 + }) 107 + 108 + return ( 109 + <Link 110 + label={_( 111 + msg`Press to view followers of this account that you also follow`, 112 + )} 113 + to={makeProfileLink(profile, 'known-followers')} 114 + style={[ 115 + a.flex_1, 116 + a.flex_row, 117 + a.gap_md, 118 + a.align_center, 119 + {marginLeft: -AVI_BORDER}, 120 + ]}> 121 + {({hovered, pressed}) => ( 122 + <> 123 + <View 124 + style={[ 125 + { 126 + height: AVI_SIZE, 127 + width: AVI_SIZE + (slice.length - 1) * a.gap_md.gap, 128 + }, 129 + pressed && { 130 + opacity: 0.5, 131 + }, 132 + ]}> 133 + {slice.map(({profile: prof, moderation}, i) => ( 134 + <View 135 + key={prof.did} 136 + style={[ 137 + a.absolute, 138 + a.rounded_full, 139 + { 140 + borderWidth: AVI_BORDER, 141 + borderColor: t.atoms.bg.backgroundColor, 142 + width: AVI_SIZE + AVI_BORDER * 2, 143 + height: AVI_SIZE + AVI_BORDER * 2, 144 + left: i * a.gap_md.gap, 145 + zIndex: AVI_BORDER - i, 146 + }, 147 + ]}> 148 + <UserAvatar 149 + size={AVI_SIZE} 150 + avatar={prof.avatar} 151 + moderation={moderation.ui('avatar')} 152 + /> 153 + </View> 154 + ))} 155 + </View> 156 + 157 + <Text 158 + style={[ 159 + textStyle, 160 + hovered && { 161 + textDecorationLine: 'underline', 162 + textDecorationColor: t.atoms.text_contrast_medium.color, 163 + }, 164 + pressed && { 165 + opacity: 0.5, 166 + }, 167 + ]} 168 + numberOfLines={2}> 169 + <Trans>Followed by</Trans>{' '} 170 + {count > 2 ? ( 171 + <> 172 + {slice.slice(0, 2).map(({profile: prof}, i) => ( 173 + <Text key={prof.did} style={textStyle}> 174 + {prof.displayName} 175 + {i === 0 && ', '} 176 + </Text> 177 + ))} 178 + {', '} 179 + {plural(count - 2, { 180 + one: 'and # other', 181 + other: 'and # others', 182 + })} 183 + </> 184 + ) : count === 2 ? ( 185 + slice.map(({profile: prof}, i) => ( 186 + <Text key={prof.did} style={textStyle}> 187 + {prof.displayName} {i === 0 ? _(msg`and`) + ' ' : ''} 188 + </Text> 189 + )) 190 + ) : ( 191 + <Text key={slice[0].profile.did} style={textStyle}> 192 + {slice[0].profile.displayName} 193 + </Text> 194 + )} 195 + </Text> 196 + </> 197 + )} 198 + </Link> 199 + ) 200 + }
+1
src/lib/routes/types.ts
··· 15 15 Profile: {name: string; hideBackButton?: boolean} 16 16 ProfileFollowers: {name: string} 17 17 ProfileFollows: {name: string} 18 + ProfileKnownFollowers: {name: string} 18 19 ProfileList: {name: string; rkey: string} 19 20 PostThread: {name: string; rkey: string} 20 21 PostLikedBy: {name: string; rkey: string}
+1
src/routes.ts
··· 15 15 Profile: ['/profile/:name', '/profile/:name/rss'], 16 16 ProfileFollowers: '/profile/:name/followers', 17 17 ProfileFollows: '/profile/:name/follows', 18 + ProfileKnownFollowers: '/profile/:name/known-followers', 18 19 ProfileList: '/profile/:name/lists/:rkey', 19 20 PostThread: '/profile/:name/post/:rkey', 20 21 PostLikedBy: '/profile/:name/post/:rkey/liked-by',
+14
src/screens/Profile/Header/ProfileHeaderStandard.tsx
··· 30 30 import {MessageProfileButton} from '#/components/dms/MessageProfileButton' 31 31 import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' 32 32 import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' 33 + import { 34 + KnownFollowers, 35 + shouldShowKnownFollowers, 36 + } from '#/components/KnownFollowers' 33 37 import * as Prompt from '#/components/Prompt' 34 38 import {RichText} from '#/components/RichText' 35 39 import {ProfileHeaderDisplayName} from './DisplayName' ··· 268 272 /> 269 273 </View> 270 274 ) : undefined} 275 + 276 + {!isMe && 277 + shouldShowKnownFollowers(profile.viewer?.knownFollowers) && ( 278 + <View style={[a.flex_row, a.align_center, a.gap_sm, a.pt_md]}> 279 + <KnownFollowers 280 + profile={profile} 281 + moderationOpts={moderationOpts} 282 + /> 283 + </View> 284 + )} 271 285 </> 272 286 )} 273 287 </View>
+1 -1
src/screens/Profile/Header/Shell.tsx
··· 83 83 84 84 {!isPlaceholderProfile && ( 85 85 <View 86 - style={[a.px_lg, a.pb_sm]} 86 + style={[a.px_lg, a.py_xs]} 87 87 pointerEvents={isIOS ? 'auto' : 'box-none'}> 88 88 {isMe ? ( 89 89 <LabelsOnMe details={{did: profile.did}} labels={profile.labels} />
+134
src/screens/Profile/KnownFollowers.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {AppBskyActorDefs} from '@atproto/api' 4 + import {msg} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + import {useFocusEffect} from '@react-navigation/native' 7 + 8 + import {cleanError} from '#/lib/strings/errors' 9 + import {logger} from '#/logger' 10 + import {useProfileKnownFollowersQuery} from '#/state/queries/known-followers' 11 + import {useResolveDidQuery} from '#/state/queries/resolve-uri' 12 + import {useSetMinimalShellMode} from '#/state/shell' 13 + import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' 14 + import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' 15 + import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' 16 + import {List} from '#/view/com/util/List' 17 + import {ViewHeader} from '#/view/com/util/ViewHeader' 18 + import { 19 + ListFooter, 20 + ListHeaderDesktop, 21 + ListMaybePlaceholder, 22 + } from '#/components/Lists' 23 + 24 + function renderItem({item}: {item: AppBskyActorDefs.ProfileViewBasic}) { 25 + return <ProfileCardWithFollowBtn key={item.did} profile={item} /> 26 + } 27 + 28 + function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic) { 29 + return item.did 30 + } 31 + 32 + type Props = NativeStackScreenProps< 33 + CommonNavigatorParams, 34 + 'ProfileKnownFollowers' 35 + > 36 + export const ProfileKnownFollowersScreen = ({route}: Props) => { 37 + const {_} = useLingui() 38 + const setMinimalShellMode = useSetMinimalShellMode() 39 + const initialNumToRender = useInitialNumToRender() 40 + 41 + const {name} = route.params 42 + 43 + const [isPTRing, setIsPTRing] = React.useState(false) 44 + const { 45 + data: resolvedDid, 46 + isLoading: isDidLoading, 47 + error: resolveError, 48 + } = useResolveDidQuery(route.params.name) 49 + const { 50 + data, 51 + isLoading: isFollowersLoading, 52 + isFetchingNextPage, 53 + hasNextPage, 54 + fetchNextPage, 55 + error, 56 + refetch, 57 + } = useProfileKnownFollowersQuery(resolvedDid) 58 + 59 + const onRefresh = React.useCallback(async () => { 60 + setIsPTRing(true) 61 + try { 62 + await refetch() 63 + } catch (err) { 64 + logger.error('Failed to refresh followers', {message: err}) 65 + } 66 + setIsPTRing(false) 67 + }, [refetch, setIsPTRing]) 68 + 69 + const onEndReached = React.useCallback(async () => { 70 + if (isFetchingNextPage || !hasNextPage || !!error) return 71 + try { 72 + await fetchNextPage() 73 + } catch (err) { 74 + logger.error('Failed to load more followers', {message: err}) 75 + } 76 + }, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) 77 + 78 + const followers = React.useMemo(() => { 79 + if (data?.pages) { 80 + return data.pages.flatMap(page => page.followers) 81 + } 82 + return [] 83 + }, [data]) 84 + 85 + const isError = Boolean(resolveError || error) 86 + 87 + useFocusEffect( 88 + React.useCallback(() => { 89 + setMinimalShellMode(false) 90 + }, [setMinimalShellMode]), 91 + ) 92 + 93 + if (followers.length < 1) { 94 + return ( 95 + <ListMaybePlaceholder 96 + isLoading={isDidLoading || isFollowersLoading} 97 + isError={isError} 98 + emptyType="results" 99 + emptyMessage={_(msg`You don't follow any users who follow @${name}.`)} 100 + errorMessage={cleanError(resolveError || error)} 101 + onRetry={isError ? refetch : undefined} 102 + /> 103 + ) 104 + } 105 + 106 + return ( 107 + <View style={{flex: 1}}> 108 + <ViewHeader title={_(msg`Followers you know`)} /> 109 + <List 110 + data={followers} 111 + renderItem={renderItem} 112 + keyExtractor={keyExtractor} 113 + refreshing={isPTRing} 114 + onRefresh={onRefresh} 115 + onEndReached={onEndReached} 116 + onEndReachedThreshold={4} 117 + ListHeaderComponent={ 118 + <ListHeaderDesktop title={_(msg`Followers you know`)} /> 119 + } 120 + ListFooterComponent={ 121 + <ListFooter 122 + isFetchingNextPage={isFetchingNextPage} 123 + error={cleanError(error)} 124 + onRetry={fetchNextPage} 125 + /> 126 + } 127 + // @ts-ignore our .web version only -prf 128 + desktopFixedHeight 129 + initialNumToRender={initialNumToRender} 130 + windowSize={11} 131 + /> 132 + </View> 133 + ) 134 + }
+34
src/state/queries/known-followers.ts
··· 1 + import {AppBskyGraphGetKnownFollowers} from '@atproto/api' 2 + import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query' 3 + 4 + import {useAgent} from '#/state/session' 5 + 6 + const PAGE_SIZE = 50 7 + type RQPageParam = string | undefined 8 + 9 + const RQKEY_ROOT = 'profile-known-followers' 10 + export const RQKEY = (did: string) => [RQKEY_ROOT, did] 11 + 12 + export function useProfileKnownFollowersQuery(did: string | undefined) { 13 + const agent = useAgent() 14 + return useInfiniteQuery< 15 + AppBskyGraphGetKnownFollowers.OutputSchema, 16 + Error, 17 + InfiniteData<AppBskyGraphGetKnownFollowers.OutputSchema>, 18 + QueryKey, 19 + RQPageParam 20 + >({ 21 + queryKey: RQKEY(did || ''), 22 + async queryFn({pageParam}: {pageParam: RQPageParam}) { 23 + const res = await agent.app.bsky.graph.getKnownFollowers({ 24 + actor: did!, 25 + limit: PAGE_SIZE, 26 + cursor: pageParam, 27 + }) 28 + return res.data 29 + }, 30 + initialPageParam: undefined, 31 + getNextPageParam: lastPage => lastPage.cursor, 32 + enabled: !!did, 33 + }) 34 + }
+4 -4
yarn.lock
··· 34 34 jsonpointer "^5.0.0" 35 35 leven "^3.1.0" 36 36 37 - "@atproto/api@^0.12.16": 38 - version "0.12.16" 39 - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.16.tgz#f5b5e06d75d379dafe79521d727ed8ad5516d3fc" 40 - integrity sha512-v3lA/m17nkawDXiqgwXyaUSzJPeXJBMH8QKOoYxcDqN+8yG9LFlGe2ecGarXcbGQjYT0GJTAAW3Y/AaCOEwuLg== 37 + "@atproto/api@^0.12.18": 38 + version "0.12.18" 39 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.18.tgz#490a6f22966a3b605c22154fe7befc78bf640821" 40 + integrity sha512-Ii3J/uzmyw1qgnfhnvAsmuXa8ObRSCHelsF8TmQrgMWeXCbfypeS/VESm++1Z9+xHK7bHPOwSek3RmWB0cqEbQ== 41 41 dependencies: 42 42 "@atproto/common-web" "^0.3.0" 43 43 "@atproto/lexicon" "^0.4.0"