diff options
author | Eric Bailey <git@esb.lol> | 2024-06-11 17:42:28 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-06-11 15:42:28 -0700 |
commit | bb0a6a4b6c4e86c62d599c424dae35c9ee9d200d (patch) | |
tree | dc4732574140d62efe2bb205193c93996b05a98e /src | |
parent | 7011ac8f72ed18153ea485b6cce2e18040de2dc9 (diff) | |
download | voidsky-bb0a6a4b6c4e86c62d599c424dae35c9ee9d200d.tar.zst |
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>
Diffstat (limited to 'src')
-rw-r--r-- | src/Navigation.tsx | 8 | ||||
-rw-r--r-- | src/components/KnownFollowers.tsx | 200 | ||||
-rw-r--r-- | src/lib/routes/types.ts | 1 | ||||
-rw-r--r-- | src/routes.ts | 1 | ||||
-rw-r--r-- | src/screens/Profile/Header/ProfileHeaderStandard.tsx | 14 | ||||
-rw-r--r-- | src/screens/Profile/Header/Shell.tsx | 2 | ||||
-rw-r--r-- | src/screens/Profile/KnownFollowers.tsx | 134 | ||||
-rw-r--r-- | src/state/queries/known-followers.ts | 34 |
8 files changed, 393 insertions, 1 deletions
diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 8f8855d67..67b89e262 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -41,6 +41,7 @@ import {PreferencesThreads} from 'view/screens/PreferencesThreads' import {SavedFeeds} from 'view/screens/SavedFeeds' import HashtagScreen from '#/screens/Hashtag' import {ModerationScreen} from '#/screens/Moderation' +import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers' import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' import {init as initAnalytics} from './lib/analytics/analytics' import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration' @@ -170,6 +171,13 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { })} /> <Stack.Screen + name="ProfileKnownFollowers" + getComponent={() => ProfileKnownFollowersScreen} + options={({route}) => ({ + title: title(msg`Followers of @${route.params.name} that you know`), + })} + /> + <Stack.Screen name="ProfileList" getComponent={() => ProfileListScreen} options={{title: title(msg`List`), requireAuth: true}} diff --git a/src/components/KnownFollowers.tsx b/src/components/KnownFollowers.tsx new file mode 100644 index 000000000..b99fe3398 --- /dev/null +++ b/src/components/KnownFollowers.tsx @@ -0,0 +1,200 @@ +import React from 'react' +import {View} from 'react-native' +import {AppBskyActorDefs, moderateProfile, ModerationOpts} from '@atproto/api' +import {msg, plural, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {makeProfileLink} from '#/lib/routes/links' +import {sanitizeDisplayName} from 'lib/strings/display-names' +import {UserAvatar} from '#/view/com/util/UserAvatar' +import {atoms as a, useTheme} from '#/alf' +import {Link} from '#/components/Link' +import {Text} from '#/components/Typography' + +const AVI_SIZE = 30 +const AVI_BORDER = 1 + +/** + * Shared logic to determine if `KnownFollowers` should be shown. + * + * Checks the # of actual returned users instead of the `count` value, because + * `count` includes blocked users and `followers` does not. + */ +export function shouldShowKnownFollowers( + knownFollowers?: AppBskyActorDefs.KnownFollowers, +) { + return knownFollowers && knownFollowers.followers.length > 0 +} + +export function KnownFollowers({ + profile, + moderationOpts, +}: { + profile: AppBskyActorDefs.ProfileViewDetailed + moderationOpts: ModerationOpts +}) { + const cache = React.useRef<Map<string, AppBskyActorDefs.KnownFollowers>>( + new Map(), + ) + + /* + * Results for `knownFollowers` are not sorted consistently, so when + * revalidating we can see a flash of this data updating. This cache prevents + * this happening for screens that remain in memory. When pushing a new + * screen, or once this one is popped, this cache is empty, so new data is + * displayed. + */ + if (profile.viewer?.knownFollowers && !cache.current.has(profile.did)) { + cache.current.set(profile.did, profile.viewer.knownFollowers) + } + + const cachedKnownFollowers = cache.current.get(profile.did) + + if (cachedKnownFollowers && shouldShowKnownFollowers(cachedKnownFollowers)) { + return ( + <KnownFollowersInner + profile={profile} + cachedKnownFollowers={cachedKnownFollowers} + moderationOpts={moderationOpts} + /> + ) + } + + return null +} + +function KnownFollowersInner({ + profile, + moderationOpts, + cachedKnownFollowers, +}: { + profile: AppBskyActorDefs.ProfileViewDetailed + moderationOpts: ModerationOpts + cachedKnownFollowers: AppBskyActorDefs.KnownFollowers +}) { + const t = useTheme() + const {_} = useLingui() + + const textStyle = [ + a.flex_1, + a.text_sm, + a.leading_snug, + t.atoms.text_contrast_medium, + ] + + // list of users, minus blocks + const returnedCount = cachedKnownFollowers.followers.length + // db count, includes blocks + const fullCount = cachedKnownFollowers.count + // knownFollowers can return up to 5 users, but will exclude blocks + // therefore, if we have less 5 users, use whichever count is lower + const count = + returnedCount < 5 ? Math.min(fullCount, returnedCount) : fullCount + + const slice = cachedKnownFollowers.followers.slice(0, 3).map(f => { + const moderation = moderateProfile(f, moderationOpts) + return { + profile: { + ...f, + displayName: sanitizeDisplayName( + f.displayName || f.handle, + moderation.ui('displayName'), + ), + }, + moderation, + } + }) + + return ( + <Link + label={_( + msg`Press to view followers of this account that you also follow`, + )} + to={makeProfileLink(profile, 'known-followers')} + style={[ + a.flex_1, + a.flex_row, + a.gap_md, + a.align_center, + {marginLeft: -AVI_BORDER}, + ]}> + {({hovered, pressed}) => ( + <> + <View + style={[ + { + height: AVI_SIZE, + width: AVI_SIZE + (slice.length - 1) * a.gap_md.gap, + }, + pressed && { + opacity: 0.5, + }, + ]}> + {slice.map(({profile: prof, moderation}, i) => ( + <View + key={prof.did} + style={[ + a.absolute, + a.rounded_full, + { + borderWidth: AVI_BORDER, + borderColor: t.atoms.bg.backgroundColor, + width: AVI_SIZE + AVI_BORDER * 2, + height: AVI_SIZE + AVI_BORDER * 2, + left: i * a.gap_md.gap, + zIndex: AVI_BORDER - i, + }, + ]}> + <UserAvatar + size={AVI_SIZE} + avatar={prof.avatar} + moderation={moderation.ui('avatar')} + /> + </View> + ))} + </View> + + <Text + style={[ + textStyle, + hovered && { + textDecorationLine: 'underline', + textDecorationColor: t.atoms.text_contrast_medium.color, + }, + pressed && { + opacity: 0.5, + }, + ]} + numberOfLines={2}> + <Trans>Followed by</Trans>{' '} + {count > 2 ? ( + <> + {slice.slice(0, 2).map(({profile: prof}, i) => ( + <Text key={prof.did} style={textStyle}> + {prof.displayName} + {i === 0 && ', '} + </Text> + ))} + {', '} + {plural(count - 2, { + one: 'and # other', + other: 'and # others', + })} + </> + ) : count === 2 ? ( + slice.map(({profile: prof}, i) => ( + <Text key={prof.did} style={textStyle}> + {prof.displayName} {i === 0 ? _(msg`and`) + ' ' : ''} + </Text> + )) + ) : ( + <Text key={slice[0].profile.did} style={textStyle}> + {slice[0].profile.displayName} + </Text> + )} + </Text> + </> + )} + </Link> + ) +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index caa861b6e..403c2bb67 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -15,6 +15,7 @@ export type CommonNavigatorParams = { Profile: {name: string; hideBackButton?: boolean} ProfileFollowers: {name: string} ProfileFollows: {name: string} + ProfileKnownFollowers: {name: string} ProfileList: {name: string; rkey: string} PostThread: {name: string; rkey: string} PostLikedBy: {name: string; rkey: string} diff --git a/src/routes.ts b/src/routes.ts index 6845cccd0..de711f5dc 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -15,6 +15,7 @@ export const router = new Router({ Profile: ['/profile/:name', '/profile/:name/rss'], ProfileFollowers: '/profile/:name/followers', ProfileFollows: '/profile/:name/follows', + ProfileKnownFollowers: '/profile/:name/known-followers', ProfileList: '/profile/:name/lists/:rkey', PostThread: '/profile/:name/post/:rkey', PostLikedBy: '/profile/:name/post/:rkey/liked-by', diff --git a/src/screens/Profile/Header/ProfileHeaderStandard.tsx b/src/screens/Profile/Header/ProfileHeaderStandard.tsx index f4b8d7705..f8a87a68e 100644 --- a/src/screens/Profile/Header/ProfileHeaderStandard.tsx +++ b/src/screens/Profile/Header/ProfileHeaderStandard.tsx @@ -30,6 +30,10 @@ import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {MessageProfileButton} from '#/components/dms/MessageProfileButton' import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check' import {PlusLarge_Stroke2_Corner0_Rounded as Plus} from '#/components/icons/Plus' +import { + KnownFollowers, + shouldShowKnownFollowers, +} from '#/components/KnownFollowers' import * as Prompt from '#/components/Prompt' import {RichText} from '#/components/RichText' import {ProfileHeaderDisplayName} from './DisplayName' @@ -268,6 +272,16 @@ let ProfileHeaderStandard = ({ /> </View> ) : undefined} + + {!isMe && + shouldShowKnownFollowers(profile.viewer?.knownFollowers) && ( + <View style={[a.flex_row, a.align_center, a.gap_sm, a.pt_md]}> + <KnownFollowers + profile={profile} + moderationOpts={moderationOpts} + /> + </View> + )} </> )} </View> diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index 553b38a3b..82cba1704 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -83,7 +83,7 @@ let ProfileHeaderShell = ({ {!isPlaceholderProfile && ( <View - style={[a.px_lg, a.pb_sm]} + style={[a.px_lg, a.py_xs]} pointerEvents={isIOS ? 'auto' : 'box-none'}> {isMe ? ( <LabelsOnMe details={{did: profile.did}} labels={profile.labels} /> diff --git a/src/screens/Profile/KnownFollowers.tsx b/src/screens/Profile/KnownFollowers.tsx new file mode 100644 index 000000000..5cb45a11e --- /dev/null +++ b/src/screens/Profile/KnownFollowers.tsx @@ -0,0 +1,134 @@ +import React from 'react' +import {View} from 'react-native' +import {AppBskyActorDefs} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' + +import {cleanError} from '#/lib/strings/errors' +import {logger} from '#/logger' +import {useProfileKnownFollowersQuery} from '#/state/queries/known-followers' +import {useResolveDidQuery} from '#/state/queries/resolve-uri' +import {useSetMinimalShellMode} from '#/state/shell' +import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' +import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' +import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' +import {List} from '#/view/com/util/List' +import {ViewHeader} from '#/view/com/util/ViewHeader' +import { + ListFooter, + ListHeaderDesktop, + ListMaybePlaceholder, +} from '#/components/Lists' + +function renderItem({item}: {item: AppBskyActorDefs.ProfileViewBasic}) { + return <ProfileCardWithFollowBtn key={item.did} profile={item} /> +} + +function keyExtractor(item: AppBskyActorDefs.ProfileViewBasic) { + return item.did +} + +type Props = NativeStackScreenProps< + CommonNavigatorParams, + 'ProfileKnownFollowers' +> +export const ProfileKnownFollowersScreen = ({route}: Props) => { + const {_} = useLingui() + const setMinimalShellMode = useSetMinimalShellMode() + const initialNumToRender = useInitialNumToRender() + + const {name} = route.params + + const [isPTRing, setIsPTRing] = React.useState(false) + const { + data: resolvedDid, + isLoading: isDidLoading, + error: resolveError, + } = useResolveDidQuery(route.params.name) + const { + data, + isLoading: isFollowersLoading, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + error, + refetch, + } = useProfileKnownFollowersQuery(resolvedDid) + + const onRefresh = React.useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh followers', {message: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) + + const onEndReached = React.useCallback(async () => { + if (isFetchingNextPage || !hasNextPage || !!error) return + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more followers', {message: err}) + } + }, [isFetchingNextPage, hasNextPage, error, fetchNextPage]) + + const followers = React.useMemo(() => { + if (data?.pages) { + return data.pages.flatMap(page => page.followers) + } + return [] + }, [data]) + + const isError = Boolean(resolveError || error) + + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) + + if (followers.length < 1) { + return ( + <ListMaybePlaceholder + isLoading={isDidLoading || isFollowersLoading} + isError={isError} + emptyType="results" + emptyMessage={_(msg`You don't follow any users who follow @${name}.`)} + errorMessage={cleanError(resolveError || error)} + onRetry={isError ? refetch : undefined} + /> + ) + } + + return ( + <View style={{flex: 1}}> + <ViewHeader title={_(msg`Followers you know`)} /> + <List + data={followers} + renderItem={renderItem} + keyExtractor={keyExtractor} + refreshing={isPTRing} + onRefresh={onRefresh} + onEndReached={onEndReached} + onEndReachedThreshold={4} + ListHeaderComponent={ + <ListHeaderDesktop title={_(msg`Followers you know`)} /> + } + ListFooterComponent={ + <ListFooter + isFetchingNextPage={isFetchingNextPage} + error={cleanError(error)} + onRetry={fetchNextPage} + /> + } + // @ts-ignore our .web version only -prf + desktopFixedHeight + initialNumToRender={initialNumToRender} + windowSize={11} + /> + </View> + ) +} diff --git a/src/state/queries/known-followers.ts b/src/state/queries/known-followers.ts new file mode 100644 index 000000000..adcbf4b50 --- /dev/null +++ b/src/state/queries/known-followers.ts @@ -0,0 +1,34 @@ +import {AppBskyGraphGetKnownFollowers} from '@atproto/api' +import {InfiniteData, QueryKey, useInfiniteQuery} from '@tanstack/react-query' + +import {useAgent} from '#/state/session' + +const PAGE_SIZE = 50 +type RQPageParam = string | undefined + +const RQKEY_ROOT = 'profile-known-followers' +export const RQKEY = (did: string) => [RQKEY_ROOT, did] + +export function useProfileKnownFollowersQuery(did: string | undefined) { + const agent = useAgent() + return useInfiniteQuery< + AppBskyGraphGetKnownFollowers.OutputSchema, + Error, + InfiniteData<AppBskyGraphGetKnownFollowers.OutputSchema>, + QueryKey, + RQPageParam + >({ + queryKey: RQKEY(did || ''), + async queryFn({pageParam}: {pageParam: RQPageParam}) { + const res = await agent.app.bsky.graph.getKnownFollowers({ + actor: did!, + limit: PAGE_SIZE, + cursor: pageParam, + }) + return res.data + }, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + enabled: !!did, + }) +} |