diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index e1b00964..bb81e780 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -207,6 +207,7 @@ func serve(cctx *cli.Context) error { e.GET("/profile/:handleOrDID", server.WebProfile) e.GET("/profile/:handleOrDID/follows", server.WebGeneric) e.GET("/profile/:handleOrDID/followers", server.WebGeneric) + e.GET("/profile/:handleOrDID/known-followers", server.WebGeneric) e.GET("/profile/:handleOrDID/lists/:rkey", server.WebGeneric) e.GET("/profile/:handleOrDID/feed/:rkey", server.WebGeneric) e.GET("/profile/:handleOrDID/feed/:rkey/liked-by", server.WebGeneric) diff --git a/package.json b/package.json index 1ba0ab2e..5f9a898f 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" }, "dependencies": { - "@atproto/api": "^0.12.16", + "@atproto/api": "^0.12.18", "@bam.tech/react-native-image-resizer": "^3.0.4", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 8f8855d6..67b89e26 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' @@ -169,6 +170,13 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { title: title(msg`People followed by @${route.params.name}`), })} /> + ProfileKnownFollowersScreen} + options={({route}) => ({ + title: title(msg`Followers of @${route.params.name} that you know`), + })} + /> ProfileListScreen} diff --git a/src/components/KnownFollowers.tsx b/src/components/KnownFollowers.tsx new file mode 100644 index 00000000..b99fe339 --- /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>( + 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 ( + + ) + } + + 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 ( + + {({hovered, pressed}) => ( + <> + + {slice.map(({profile: prof, moderation}, i) => ( + + + + ))} + + + + Followed by{' '} + {count > 2 ? ( + <> + {slice.slice(0, 2).map(({profile: prof}, i) => ( + + {prof.displayName} + {i === 0 && ', '} + + ))} + {', '} + {plural(count - 2, { + one: 'and # other', + other: 'and # others', + })} + + ) : count === 2 ? ( + slice.map(({profile: prof}, i) => ( + + {prof.displayName} {i === 0 ? _(msg`and`) + ' ' : ''} + + )) + ) : ( + + {slice[0].profile.displayName} + + )} + + + )} + + ) +} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index caa861b6..403c2bb6 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 6845cccd..de711f5d 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 f4b8d770..f8a87a68 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 = ({ /> ) : undefined} + + {!isMe && + shouldShowKnownFollowers(profile.viewer?.knownFollowers) && ( + + + + )} )} diff --git a/src/screens/Profile/Header/Shell.tsx b/src/screens/Profile/Header/Shell.tsx index 553b38a3..82cba170 100644 --- a/src/screens/Profile/Header/Shell.tsx +++ b/src/screens/Profile/Header/Shell.tsx @@ -83,7 +83,7 @@ let ProfileHeaderShell = ({ {!isPlaceholderProfile && ( {isMe ? ( diff --git a/src/screens/Profile/KnownFollowers.tsx b/src/screens/Profile/KnownFollowers.tsx new file mode 100644 index 00000000..5cb45a11 --- /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 +} + +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 ( + + ) + } + + return ( + + + + } + ListFooterComponent={ + + } + // @ts-ignore our .web version only -prf + desktopFixedHeight + initialNumToRender={initialNumToRender} + windowSize={11} + /> + + ) +} diff --git a/src/state/queries/known-followers.ts b/src/state/queries/known-followers.ts new file mode 100644 index 00000000..adcbf4b5 --- /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, + 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, + }) +} diff --git a/yarn.lock b/yarn.lock index 321cd746..c56a56b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -34,10 +34,10 @@ jsonpointer "^5.0.0" leven "^3.1.0" -"@atproto/api@^0.12.16": - version "0.12.16" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.16.tgz#f5b5e06d75d379dafe79521d727ed8ad5516d3fc" - integrity sha512-v3lA/m17nkawDXiqgwXyaUSzJPeXJBMH8QKOoYxcDqN+8yG9LFlGe2ecGarXcbGQjYT0GJTAAW3Y/AaCOEwuLg== +"@atproto/api@^0.12.18": + version "0.12.18" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.12.18.tgz#490a6f22966a3b605c22154fe7befc78bf640821" + integrity sha512-Ii3J/uzmyw1qgnfhnvAsmuXa8ObRSCHelsF8TmQrgMWeXCbfypeS/VESm++1Z9+xHK7bHPOwSek3RmWB0cqEbQ== dependencies: "@atproto/common-web" "^0.3.0" "@atproto/lexicon" "^0.4.0"