diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index 8da291fe56..a91a1ccbf5 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -241,6 +241,7 @@ func serve(cctx *cli.Context) error { e.GET("/profile/:handleOrDID/post/:rkey", server.WebPost) e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric) e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric) + e.GET("/profile/:handleOrDID/post/:rkey/quotes", server.WebGeneric) // starter packs e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack) diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 79856879c3..1a8550a88f 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -58,6 +58,7 @@ import {router} from './routes' import {MessagesConversationScreen} from './screens/Messages/Conversation' import {MessagesScreen} from './screens/Messages/List' import {MessagesSettingsScreen} from './screens/Messages/Settings' +import {PostQuotesScreen} from './screens/Post/PostQuotes' import {useModalControls} from './state/modals' import {useUnreadNotifications} from './state/queries/notifications/unread' import {useSession} from './state/session' @@ -212,6 +213,13 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) { title: title(msg`Post by @${route.params.name}`), })} /> + PostQuotesScreen} + options={({route}) => ({ + title: title(msg`Post by @${route.params.name}`), + })} + /> ProfileFeedScreen} diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index 0cc83b475a..426665d07d 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -20,6 +20,7 @@ export type CommonNavigatorParams = { PostThread: {name: string; rkey: string} PostLikedBy: {name: string; rkey: string} PostRepostedBy: {name: string; rkey: string} + PostQuotes: {name: string; rkey: string} ProfileFeed: {name: string; rkey: string} ProfileFeedLikedBy: {name: string; rkey: string} ProfileLabelerLikedBy: {name: string} diff --git a/src/routes.ts b/src/routes.ts index c9e23e08c8..2ae4126ace 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -21,6 +21,7 @@ export const router = new Router({ PostThread: '/profile/:name/post/:rkey', PostLikedBy: '/profile/:name/post/:rkey/liked-by', PostRepostedBy: '/profile/:name/post/:rkey/reposted-by', + PostQuotes: '/profile/:name/post/:rkey/quotes', ProfileFeed: '/profile/:name/feed/:rkey', ProfileFeedLikedBy: '/profile/:name/feed/:rkey/liked-by', ProfileLabelerLikedBy: '/profile/:name/labeler/liked-by', diff --git a/src/screens/Post/PostQuotes.tsx b/src/screens/Post/PostQuotes.tsx new file mode 100644 index 0000000000..751b9c326d --- /dev/null +++ b/src/screens/Post/PostQuotes.tsx @@ -0,0 +1,32 @@ +import React from 'react' +import {View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useFocusEffect} from '@react-navigation/native' + +import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' +import {makeRecordUri} from '#/lib/strings/url-helpers' +import {useSetMinimalShellMode} from '#/state/shell' +import {PostQuotes as PostQuotesComponent} from '#/view/com/post-thread/PostQuotes' +import {ViewHeader} from '#/view/com/util/ViewHeader' + +type Props = NativeStackScreenProps +export const PostQuotesScreen = ({route}: Props) => { + const setMinimalShellMode = useSetMinimalShellMode() + const {name, rkey} = route.params + const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) + const {_} = useLingui() + + useFocusEffect( + React.useCallback(() => { + setMinimalShellMode(false) + }, [setMinimalShellMode]), + ) + + return ( + + + + + ) +} diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts index dc907664e2..dda7a749e5 100644 --- a/src/state/cache/profile-shadow.ts +++ b/src/state/cache/profile-shadow.ts @@ -12,6 +12,7 @@ import {findAllProfilesInQueryData as findAllProfilesInMyBlockedAccountsQueryDat import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '../queries/my-muted-accounts' import {findAllProfilesInQueryData as findAllProfilesInFeedsQueryData} from '../queries/post-feed' import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '../queries/post-liked-by' +import {findAllProfilesInQueryData as findAllProfilesInPostQuotesQueryData} from '../queries/post-quotes' import {findAllProfilesInQueryData as findAllProfilesInPostRepostedByQueryData} from '../queries/post-reposted-by' import {findAllProfilesInQueryData as findAllProfilesInPostThreadQueryData} from '../queries/post-thread' import {findAllProfilesInQueryData as findAllProfilesInProfileQueryData} from '../queries/profile' @@ -104,6 +105,7 @@ function* findProfilesInCache( yield* findAllProfilesInMyMutedAccountsQueryData(queryClient, did) yield* findAllProfilesInPostLikedByQueryData(queryClient, did) yield* findAllProfilesInPostRepostedByQueryData(queryClient, did) + yield* findAllProfilesInPostQuotesQueryData(queryClient, did) yield* findAllProfilesInProfileQueryData(queryClient, did) yield* findAllProfilesInProfileFollowersQueryData(queryClient, did) yield* findAllProfilesInProfileFollowsQueryData(queryClient, did) diff --git a/src/state/queries/post-quotes.ts b/src/state/queries/post-quotes.ts new file mode 100644 index 0000000000..817bba26b2 --- /dev/null +++ b/src/state/queries/post-quotes.ts @@ -0,0 +1,68 @@ +import {AppBskyActorDefs, AppBskyFeedGetQuotes} from '@atproto/api' +import { + InfiniteData, + QueryClient, + QueryKey, + useInfiniteQuery, +} from '@tanstack/react-query' + +import {useAgent} from '#/state/session' +import {getEmbeddedPost} from './util' + +const PAGE_SIZE = 30 +type RQPageParam = string | undefined + +// TODO refactor invalidate on mutate? +const RQKEY_ROOT = 'post-quotes' +export const RQKEY = (resolvedUri: string) => [RQKEY_ROOT, resolvedUri] + +export function usePostQuotesQuery(resolvedUri: string | undefined) { + const agent = useAgent() + return useInfiniteQuery< + AppBskyFeedGetQuotes.OutputSchema, + Error, + InfiniteData, + QueryKey, + RQPageParam + >({ + queryKey: RQKEY(resolvedUri || ''), + async queryFn({pageParam}: {pageParam: RQPageParam}) { + const res = await agent.api.app.bsky.feed.getQuotes({ + uri: resolvedUri || '', + limit: PAGE_SIZE, + cursor: pageParam, + }) + return res.data + }, + initialPageParam: undefined, + getNextPageParam: lastPage => lastPage.cursor, + enabled: !!resolvedUri, + }) +} + +export function* findAllProfilesInQueryData( + queryClient: QueryClient, + did: string, +): Generator { + const queryDatas = queryClient.getQueriesData< + InfiniteData + >({ + queryKey: [RQKEY_ROOT], + }) + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData?.pages) { + continue + } + for (const page of queryData?.pages) { + for (const item of page.posts) { + if (item.author.did === did) { + yield item.author + } + const quotedPost = getEmbeddedPost(item.embed) + if (quotedPost?.author.did === did) { + yield quotedPost.author + } + } + } + } +} diff --git a/src/view/com/post-thread/PostQuotes.tsx b/src/view/com/post-thread/PostQuotes.tsx new file mode 100644 index 0000000000..09296fa94a --- /dev/null +++ b/src/view/com/post-thread/PostQuotes.tsx @@ -0,0 +1,150 @@ +import React, {useCallback, useMemo, useState} from 'react' +import {ActivityIndicator, StyleSheet, View} from 'react-native' +import { + AppBskyFeedDefs, + AppBskyFeedPost, + ModerationDecision, +} from '@atproto/api' + +import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' +import {cleanError} from '#/lib/strings/errors' +import {logger} from '#/logger' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {usePostQuotesQuery} from '#/state/queries/post-quotes' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import {FeedItem} from '../posts/FeedItem' +import {ErrorMessage} from '../util/error/ErrorMessage' +import {List} from '../util/List' +import {LoadingScreen} from '../util/LoadingScreen' +import {CenteredView} from '../util/Views' + +export function PostQuotes({uri}: {uri: string}) { + const [isPTRing, setIsPTRing] = useState(false) + const { + data: resolvedUri, + error: resolveError, + isFetching: isFetchingResolvedUri, + } = useResolveUriQuery(uri) + const { + data, + isFetching, + isFetched, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + isError, + error, + refetch, + } = usePostQuotesQuery(resolvedUri?.uri) + const moderationOpts = useModerationOpts() + + const quotes = useMemo(() => { + if (data?.pages) { + return data.pages + .flatMap(page => page.posts) + .map(post => { + if (!AppBskyFeedPost.isRecord(post.record) || !moderationOpts) { + return null + } + const moderation = moderatePost(post, moderationOpts) + return {post, record: post.record, moderation} + }) + .filter(x => Boolean(x)) + } + }, [data, moderationOpts]) + + const onRefresh = useCallback(async () => { + setIsPTRing(true) + try { + await refetch() + } catch (err) { + logger.error('Failed to refresh quotes', {message: err}) + } + setIsPTRing(false) + }, [refetch, setIsPTRing]) + + const onEndReached = useCallback(async () => { + if (isFetching || !hasNextPage || isError) return + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more quotes', {message: err}) + } + }, [isFetching, hasNextPage, isError, fetchNextPage]) + + const renderItem = useCallback( + ({ + item, + index, + }: { + item: { + post: AppBskyFeedDefs.PostView + moderation: ModerationDecision + record: AppBskyFeedPost.Record + } + index: number + }) => { + return ( + + ) + }, + [], + ) + + if (isFetchingResolvedUri || !isFetched) { + return + } + + // error + // = + if (resolveError || isError) { + return ( + + + + ) + } + + // loaded + // = + return ( + item.did} + refreshing={isPTRing} + onRefresh={onRefresh} + onEndReached={onEndReached} + renderItem={renderItem} + initialNumToRender={15} + // FIXME(dan) + // eslint-disable-next-line react/no-unstable-nested-components + ListFooterComponent={() => ( + + {(isFetching || isFetchingNextPage) && } + + )} + // @ts-ignore our .web version only -prf + desktopFixedHeight + /> + ) +} + +const styles = StyleSheet.create({ + footer: { + height: 200, + paddingTop: 20, + }, +}) diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 63dac300a8..1215f70465 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -199,6 +199,11 @@ let PostThreadItemLoaded = ({ return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by') }, [post.uri, post.author]) const repostsTitle = _(msg`Reposts of this post`) + const quotesHref = React.useMemo(() => { + const urip = new AtUri(post.uri) + return makeProfileLink(post.author, 'post', urip.rkey, 'quotes') + }, [post.uri, post.author]) + const quotesTitle = _(msg`Quotes of this post`) const translatorUrl = getTranslatorLink( record?.text || '', @@ -343,7 +348,9 @@ let PostThreadItemLoaded = ({ translatorUrl={translatorUrl} needsTranslation={needsTranslation} /> - {post.repostCount !== 0 || post.likeCount !== 0 ? ( + {post.repostCount !== 0 || + post.likeCount !== 0 || + post.quoteCount !== 0 ? ( // Show this section unless we're *sure* it has no engagement. {post.repostCount != null && post.repostCount !== 0 ? ( @@ -382,6 +389,26 @@ let PostThreadItemLoaded = ({ ) : null} + {post.quoteCount != null && post.quoteCount !== 0 ? ( + + + + {formatCount(post.quoteCount)} + {' '} + + + + ) : null} ) : null}