show quote posts

This commit is contained in:
Samuel Newman
2024-08-01 11:29:36 +02:00
parent c3d8beee6d
commit ecf7bce186
9 changed files with 291 additions and 1 deletions
+1
View File
@@ -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)
+8
View File
@@ -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}`),
})}
/>
<Stack.Screen
name="PostQuotes"
getComponent={() => PostQuotesScreen}
options={({route}) => ({
title: title(msg`Post by @${route.params.name}`),
})}
/>
<Stack.Screen
name="ProfileFeed"
getComponent={() => ProfileFeedScreen}
+1
View File
@@ -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}
+1
View File
@@ -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',
+32
View File
@@ -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<CommonNavigatorParams, 'PostQuotes'>
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 (
<View>
<ViewHeader title={_(msg`Quotes`)} />
<PostQuotesComponent uri={uri} />
</View>
)
}
+2
View File
@@ -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)
+68
View File
@@ -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<AppBskyFeedGetQuotes.OutputSchema>,
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<AppBskyActorDefs.ProfileView, void> {
const queryDatas = queryClient.getQueriesData<
InfiniteData<AppBskyFeedGetQuotes.OutputSchema>
>({
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
}
}
}
}
}
+150
View File
@@ -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 (
<FeedItem
key={item.post.uri}
post={item.post}
record={item.record}
moderation={item.moderation}
hideTopBorder={index === 0}
reason={undefined}
parentAuthor={undefined}
feedContext={undefined}
showReplyTo={false}
/>
)
},
[],
)
if (isFetchingResolvedUri || !isFetched) {
return <LoadingScreen />
}
// error
// =
if (resolveError || isError) {
return (
<CenteredView>
<ErrorMessage
message={cleanError(resolveError || error)}
onPressTryAgain={onRefresh}
/>
</CenteredView>
)
}
// loaded
// =
return (
<List
data={quotes}
keyExtractor={item => item.did}
refreshing={isPTRing}
onRefresh={onRefresh}
onEndReached={onEndReached}
renderItem={renderItem}
initialNumToRender={15}
// FIXME(dan)
// eslint-disable-next-line react/no-unstable-nested-components
ListFooterComponent={() => (
<View style={styles.footer}>
{(isFetching || isFetchingNextPage) && <ActivityIndicator />}
</View>
)}
// @ts-ignore our .web version only -prf
desktopFixedHeight
/>
)
}
const styles = StyleSheet.create({
footer: {
height: 200,
paddingTop: 20,
},
})
+28 -1
View File
@@ -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.
<View style={[styles.expandedInfo, pal.border]}>
{post.repostCount != null && post.repostCount !== 0 ? (
@@ -382,6 +389,26 @@ let PostThreadItemLoaded = ({
</Text>
</Link>
) : null}
{post.quoteCount != null && post.quoteCount !== 0 ? (
<Link
style={styles.expandedInfoItem}
href={quotesHref}
title={quotesTitle}>
<Text
testID="quoteCount-expanded"
type="lg"
style={pal.textLight}>
<Text type="xl-bold" style={pal.text}>
{formatCount(post.quoteCount)}
</Text>{' '}
<Plural
value={post.quoteCount}
one="quote"
other="quotes"
/>
</Text>
</Link>
) : null}
</View>
) : null}
<View style={[s.pl10, s.pr10]}>