From 56ab5e177fa2b24d0e5d9d969aa37532b96128da Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Wed, 21 Aug 2024 21:26:25 +0100 Subject: [PATCH] Show quote posts (#4865) * show quote posts * fix filter * fix keyExtractor * move likedby and repostedby to new file structure * use modern list component * remove relative imports * update quotes count after quoting * call `onPost` after updating quote count * Revert "update quotes count after quoting" This reverts commit 1f1887730a210c57c1e5a0eb0f47c42c42cf1b4b. * implement * update like count in quotes list * only add `onPostReply` where needed * Filter quotes with detached embeds * Bump SDK * Don't show error for no results --------- Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com> Co-authored-by: Hailey Co-authored-by: Eric Bailey --- bskyweb/cmd/bskyweb/server.go | 1 + package.json | 2 +- src/Navigation.tsx | 124 ++++++++------- src/lib/routes/types.ts | 1 + src/routes.ts | 1 + .../screens => screens/Post}/PostLikedBy.tsx | 11 +- src/screens/Post/PostQuotes.tsx | 33 ++++ .../Post}/PostRepostedBy.tsx | 11 +- src/state/cache/post-shadow.ts | 4 + src/state/cache/profile-shadow.ts | 2 + src/state/queries/post-quotes.ts | 124 +++++++++++++++ src/state/shell/composer.tsx | 1 + src/view/com/composer/Composer.tsx | 18 ++- src/view/com/post-thread/PostLikedBy.tsx | 4 +- src/view/com/post-thread/PostQuotes.tsx | 141 ++++++++++++++++++ src/view/com/post-thread/PostRepostedBy.tsx | 4 +- src/view/com/post-thread/PostThreadItem.tsx | 30 +++- src/view/com/util/post-ctrls/PostCtrls.tsx | 12 +- src/view/shell/Composer.ios.tsx | 1 + src/view/shell/Composer.tsx | 1 + src/view/shell/Composer.web.tsx | 1 + yarn.lock | 15 +- 22 files changed, 463 insertions(+), 79 deletions(-) rename src/{view/screens => screens/Post}/PostLikedBy.tsx (69%) create mode 100644 src/screens/Post/PostQuotes.tsx rename src/{view/screens => screens/Post}/PostRepostedBy.tsx (69%) create mode 100644 src/state/queries/post-quotes.ts create mode 100644 src/view/com/post-thread/PostQuotes.tsx diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go index fdef01ce..203ed62f 100644 --- a/bskyweb/cmd/bskyweb/server.go +++ b/bskyweb/cmd/bskyweb/server.go @@ -255,6 +255,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/package.json b/package.json index a4523d98..61064804 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" }, "dependencies": { - "@atproto/api": "0.13.0", + "@atproto/api": "^0.13.2", "@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 79856879..960e66bb 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -15,10 +15,12 @@ import { StackActions, } from '@react-navigation/native' -import {timeout} from 'lib/async/timeout' -import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' -import {usePalette} from 'lib/hooks/usePalette' -import {buildStateObject} from 'lib/routes/helpers' +import {init as initAnalytics} from '#/lib/analytics/analytics' +import {timeout} from '#/lib/async/timeout' +import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' +import {usePalette} from '#/lib/hooks/usePalette' +import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration' +import {buildStateObject} from '#/lib/routes/helpers' import { AllNavigatorParams, BottomTabNavigatorParams, @@ -28,20 +30,62 @@ import { MyProfileTabNavigatorParams, NotificationsTabNavigatorParams, SearchTabNavigatorParams, -} from 'lib/routes/types' -import {RouteParams, State} from 'lib/routes/types' -import {bskyTitle} from 'lib/strings/headings' -import {isAndroid, isNative, isWeb} from 'platform/detection' +} from '#/lib/routes/types' +import {RouteParams, State} from '#/lib/routes/types' +import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig' +import {bskyTitle} from '#/lib/strings/headings' +import {isAndroid, isNative, isWeb} from '#/platform/detection' +import {useModalControls} from '#/state/modals' +import {useUnreadNotifications} from '#/state/queries/notifications/unread' +import {useSession} from '#/state/session' +import { + shouldRequestEmailConfirmation, + snoozeEmailConfirmationPrompt, +} from '#/state/shell/reminders' +import {AccessibilitySettingsScreen} from '#/view/screens/AccessibilitySettings' +import {AppPasswords} from '#/view/screens/AppPasswords' +import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines' +import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy' +import {DebugModScreen} from '#/view/screens/DebugMod' +import {FeedsScreen} from '#/view/screens/Feeds' +import {HomeScreen} from '#/view/screens/Home' +import {LanguageSettingsScreen} from '#/view/screens/LanguageSettings' +import {ListsScreen} from '#/view/screens/Lists' +import {LogScreen} from '#/view/screens/Log' +import {ModerationBlockedAccounts} from '#/view/screens/ModerationBlockedAccounts' +import {ModerationModlistsScreen} from '#/view/screens/ModerationModlists' +import {ModerationMutedAccounts} from '#/view/screens/ModerationMutedAccounts' +import {NotFoundScreen} from '#/view/screens/NotFound' +import {NotificationsScreen} from '#/view/screens/Notifications' +import {NotificationsSettingsScreen} from '#/view/screens/NotificationsSettings' +import {PostThreadScreen} from '#/view/screens/PostThread' import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds' -import {AppPasswords} from 'view/screens/AppPasswords' -import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts' -import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts' -import {PreferencesFollowingFeed} from 'view/screens/PreferencesFollowingFeed' -import {PreferencesThreads} from 'view/screens/PreferencesThreads' -import {SavedFeeds} from 'view/screens/SavedFeeds' +import {PreferencesFollowingFeed} from '#/view/screens/PreferencesFollowingFeed' +import {PreferencesThreads} from '#/view/screens/PreferencesThreads' +import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy' +import {ProfileScreen} from '#/view/screens/Profile' +import {ProfileFeedScreen} from '#/view/screens/ProfileFeed' +import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy' +import {ProfileFollowersScreen} from '#/view/screens/ProfileFollowers' +import {ProfileFollowsScreen} from '#/view/screens/ProfileFollows' +import {ProfileListScreen} from '#/view/screens/ProfileList' +import {SavedFeeds} from '#/view/screens/SavedFeeds' +import {SearchScreen} from '#/view/screens/Search' +import {SettingsScreen} from '#/view/screens/Settings' +import {Storybook} from '#/view/screens/Storybook' +import {SupportScreen} from '#/view/screens/Support' +import {TermsOfServiceScreen} from '#/view/screens/TermsOfService' +import {BottomBar} from '#/view/shell/bottom-bar/BottomBar' +import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth' import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen' import HashtagScreen from '#/screens/Hashtag' +import {MessagesConversationScreen} from '#/screens/Messages/Conversation' +import {MessagesScreen} from '#/screens/Messages/List' +import {MessagesSettingsScreen} from '#/screens/Messages/Settings' import {ModerationScreen} from '#/screens/Moderation' +import {PostLikedByScreen} from '#/screens/Post/PostLikedBy' +import {PostQuotesScreen} from '#/screens/Post/PostQuotes' +import {PostRepostedByScreen} from '#/screens/Post/PostRepostedBy' import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers' import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings' @@ -50,51 +94,8 @@ import { StarterPackScreenShort, } from '#/screens/StarterPack/StarterPackScreen' import {Wizard} from '#/screens/StarterPack/Wizard' +import {router} from '#/routes' import {Referrer} from '../modules/expo-bluesky-swiss-army' -import {init as initAnalytics} from './lib/analytics/analytics' -import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration' -import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig' -import {router} from './routes' -import {MessagesConversationScreen} from './screens/Messages/Conversation' -import {MessagesScreen} from './screens/Messages/List' -import {MessagesSettingsScreen} from './screens/Messages/Settings' -import {useModalControls} from './state/modals' -import {useUnreadNotifications} from './state/queries/notifications/unread' -import {useSession} from './state/session' -import { - shouldRequestEmailConfirmation, - snoozeEmailConfirmationPrompt, -} from './state/shell/reminders' -import {AccessibilitySettingsScreen} from './view/screens/AccessibilitySettings' -import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines' -import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy' -import {DebugModScreen} from './view/screens/DebugMod' -import {FeedsScreen} from './view/screens/Feeds' -import {HomeScreen} from './view/screens/Home' -import {LanguageSettingsScreen} from './view/screens/LanguageSettings' -import {ListsScreen} from './view/screens/Lists' -import {LogScreen} from './view/screens/Log' -import {ModerationModlistsScreen} from './view/screens/ModerationModlists' -import {NotFoundScreen} from './view/screens/NotFound' -import {NotificationsScreen} from './view/screens/Notifications' -import {NotificationsSettingsScreen} from './view/screens/NotificationsSettings' -import {PostLikedByScreen} from './view/screens/PostLikedBy' -import {PostRepostedByScreen} from './view/screens/PostRepostedBy' -import {PostThreadScreen} from './view/screens/PostThread' -import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy' -import {ProfileScreen} from './view/screens/Profile' -import {ProfileFeedScreen} from './view/screens/ProfileFeed' -import {ProfileFeedLikedByScreen} from './view/screens/ProfileFeedLikedBy' -import {ProfileFollowersScreen} from './view/screens/ProfileFollowers' -import {ProfileFollowsScreen} from './view/screens/ProfileFollows' -import {ProfileListScreen} from './view/screens/ProfileList' -import {SearchScreen} from './view/screens/Search' -import {SettingsScreen} from './view/screens/Settings' -import {Storybook} from './view/screens/Storybook' -import {SupportScreen} from './view/screens/Support' -import {TermsOfServiceScreen} from './view/screens/TermsOfService' -import {BottomBar} from './view/shell/bottom-bar/BottomBar' -import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth' const navigationRef = createNavigationContainerRef() @@ -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 0cc83b47..426665d0 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 c9e23e08..2ae4126a 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/view/screens/PostLikedBy.tsx b/src/screens/Post/PostLikedBy.tsx similarity index 69% rename from src/view/screens/PostLikedBy.tsx rename to src/screens/Post/PostLikedBy.tsx index 5ff5a193..c29e0aa2 100644 --- a/src/view/screens/PostLikedBy.tsx +++ b/src/screens/Post/PostLikedBy.tsx @@ -4,11 +4,12 @@ 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 {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' -import {makeRecordUri} from 'lib/strings/url-helpers' -import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy' -import {ViewHeader} from '../com/util/ViewHeader' +import {PostLikedBy as PostLikedByComponent} from '#/view/com/post-thread/PostLikedBy' +import {ViewHeader} from '#/view/com/util/ViewHeader' +import {atoms as a} from '#/alf' type Props = NativeStackScreenProps export const PostLikedByScreen = ({route}: Props) => { @@ -24,7 +25,7 @@ export const PostLikedByScreen = ({route}: Props) => { ) return ( - + diff --git a/src/screens/Post/PostQuotes.tsx b/src/screens/Post/PostQuotes.tsx new file mode 100644 index 00000000..d670f321 --- /dev/null +++ b/src/screens/Post/PostQuotes.tsx @@ -0,0 +1,33 @@ +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' +import {atoms as a} from '#/alf' + +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/view/screens/PostRepostedBy.tsx b/src/screens/Post/PostRepostedBy.tsx similarity index 69% rename from src/view/screens/PostRepostedBy.tsx rename to src/screens/Post/PostRepostedBy.tsx index eaacc678..b15a6f6e 100644 --- a/src/view/screens/PostRepostedBy.tsx +++ b/src/screens/Post/PostRepostedBy.tsx @@ -4,11 +4,12 @@ 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 {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' -import {makeRecordUri} from 'lib/strings/url-helpers' -import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy' -import {ViewHeader} from '../com/util/ViewHeader' +import {PostRepostedBy as PostRepostedByComponent} from '#/view/com/post-thread/PostRepostedBy' +import {ViewHeader} from '#/view/com/util/ViewHeader' +import {atoms as a} from '#/alf' type Props = NativeStackScreenProps export const PostRepostedByScreen = ({route}: Props) => { @@ -24,7 +25,7 @@ export const PostRepostedByScreen = ({route}: Props) => { ) return ( - + diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts index 48183739..b37e9bd4 100644 --- a/src/state/cache/post-shadow.ts +++ b/src/state/cache/post-shadow.ts @@ -6,6 +6,7 @@ import EventEmitter from 'eventemitter3' import {batchedUpdates} from '#/lib/batchedUpdates' import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '../queries/notifications/feed' import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '../queries/post-feed' +import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '../queries/post-quotes' import {findAllPostsInQueryData as findAllPostsInThreadQueryData} from '../queries/post-thread' import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '../queries/search-posts' import {castAsShadow, Shadow} from './types' @@ -130,4 +131,7 @@ function* findPostsInCache( for (let post of findAllPostsInSearchQueryData(queryClient, uri)) { yield post } + for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) { + yield post + } } diff --git a/src/state/cache/profile-shadow.ts b/src/state/cache/profile-shadow.ts index dc907664..dda7a749 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 00000000..be51eaab --- /dev/null +++ b/src/state/queries/post-quotes.ts @@ -0,0 +1,124 @@ +import { + AppBskyActorDefs, + AppBskyEmbedRecord, + AppBskyFeedDefs, + AppBskyFeedGetQuotes, + AtUri, +} from '@atproto/api' +import { + InfiniteData, + QueryClient, + QueryKey, + useInfiniteQuery, +} from '@tanstack/react-query' + +import {useAgent} from '#/state/session' +import { + didOrHandleUriMatches, + embedViewRecordToPostView, + getEmbeddedPost, +} from './util' + +const PAGE_SIZE = 30 +type RQPageParam = string | undefined + +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, + select: data => { + return { + ...data, + pages: data.pages.map(page => { + return { + ...page, + posts: page.posts.filter(post => { + if (post.embed && AppBskyEmbedRecord.isView(post.embed)) { + if (AppBskyEmbedRecord.isViewDetached(post.embed.record)) { + return false + } + } + return true + }), + } + }), + } + }, + }) +} + +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 + } + } + } + } +} + +export function* findAllPostsInQueryData( + queryClient: QueryClient, + uri: string, +): Generator { + const queryDatas = queryClient.getQueriesData< + InfiniteData + >({ + queryKey: [RQKEY_ROOT], + }) + const atUri = new AtUri(uri) + for (const [_queryKey, queryData] of queryDatas) { + if (!queryData?.pages) { + continue + } + for (const page of queryData?.pages) { + for (const post of page.posts) { + if (didOrHandleUriMatches(atUri, post)) { + yield post + } + + const quotedPost = getEmbeddedPost(post.embed) + if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) { + yield embedViewRecordToPostView(quotedPost) + } + } + } + } +} diff --git a/src/state/shell/composer.tsx b/src/state/shell/composer.tsx index e28d6b4a..c9900548 100644 --- a/src/state/shell/composer.tsx +++ b/src/state/shell/composer.tsx @@ -34,6 +34,7 @@ export interface ComposerOpts { replyTo?: ComposerOptsPostRef onPost?: (postUri: string | undefined) => void quote?: ComposerOptsQuote + quoteCount?: number mention?: string // handle of user to mention openPicker?: (pos: DOMRect | undefined) => void text?: string diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index dba37d82..0efbe70e 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -116,6 +116,7 @@ export const ComposePost = observer(function ComposePost({ replyTo, onPost, quote: initQuote, + quoteCount, mention: initMention, openPicker, text: initText, @@ -392,7 +393,22 @@ export const ComposePost = observer(function ComposePost({ emitPostCreated() } setLangPrefs.savePostLanguageToHistory() - onPost?.(postUri) + if (quote) { + // We want to wait for the quote count to update before we call `onPost`, which will refetch data + whenAppViewReady(agent, quote.uri, res => { + const thread = res.data.thread + if ( + AppBskyFeedDefs.isThreadViewPost(thread) && + thread.post.quoteCount !== quoteCount + ) { + onPost?.(postUri) + return true + } + return false + }) + } else { + onPost?.(postUri) + } onClose() Toast.show( replyTo diff --git a/src/view/com/post-thread/PostLikedBy.tsx b/src/view/com/post-thread/PostLikedBy.tsx index da230aad..c3e3f9e1 100644 --- a/src/view/com/post-thread/PostLikedBy.tsx +++ b/src/view/com/post-thread/PostLikedBy.tsx @@ -8,13 +8,13 @@ import {logger} from '#/logger' import {useLikedByQuery} from '#/state/queries/post-liked-by' import {useResolveUriQuery} from '#/state/queries/resolve-uri' import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' +import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' +import {List} from '#/view/com/util/List' import { ListFooter, ListHeaderDesktop, ListMaybePlaceholder, } from '#/components/Lists' -import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' -import {List} from '../util/List' function renderItem({item}: {item: GetLikes.Like}) { return diff --git a/src/view/com/post-thread/PostQuotes.tsx b/src/view/com/post-thread/PostQuotes.tsx new file mode 100644 index 00000000..d573d27a --- /dev/null +++ b/src/view/com/post-thread/PostQuotes.tsx @@ -0,0 +1,141 @@ +import React, {useCallback, useState} from 'react' +import { + AppBskyFeedDefs, + AppBskyFeedPost, + ModerationDecision, +} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' +import {cleanError} from '#/lib/strings/errors' +import {logger} from '#/logger' +import {isWeb} from '#/platform/detection' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {usePostQuotesQuery} from '#/state/queries/post-quotes' +import {useResolveUriQuery} from '#/state/queries/resolve-uri' +import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' +import {Post} from 'view/com/post/Post' +import { + ListFooter, + ListHeaderDesktop, + ListMaybePlaceholder, +} from '#/components/Lists' +import {List} from '../util/List' + +function renderItem({ + item, + index, +}: { + item: { + post: AppBskyFeedDefs.PostView + moderation: ModerationDecision + record: AppBskyFeedPost.Record + } + index: number +}) { + return +} + +function keyExtractor(item: { + post: AppBskyFeedDefs.PostView + moderation: ModerationDecision + record: AppBskyFeedPost.Record +}) { + return item.post.uri +} + +export function PostQuotes({uri}: {uri: string}) { + const {_} = useLingui() + const initialNumToRender = useInitialNumToRender() + + const [isPTRing, setIsPTRing] = useState(false) + + const { + data: resolvedUri, + error: resolveError, + isLoading: isLoadingUri, + } = useResolveUriQuery(uri) + const { + data, + isLoading: isLoadingQuotes, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + error, + refetch, + } = usePostQuotesQuery(resolvedUri?.uri) + + const moderationOpts = useModerationOpts() + + const isError = Boolean(resolveError || error) + + const quotes = + 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(item => item !== null) ?? [] + + 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 (isFetchingNextPage || !hasNextPage || isError) return + try { + await fetchNextPage() + } catch (err) { + logger.error('Failed to load more quotes', {message: err}) + } + }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) + + if (isLoadingUri || isLoadingQuotes || isError) { + return ( + + ) + } + + // loaded + // = + return ( + } + ListFooterComponent={ + + } + // @ts-ignore our .web version only -prf + desktopFixedHeight + initialNumToRender={initialNumToRender} + windowSize={11} + /> + ) +} diff --git a/src/view/com/post-thread/PostRepostedBy.tsx b/src/view/com/post-thread/PostRepostedBy.tsx index 9038549a..0d1e86ae 100644 --- a/src/view/com/post-thread/PostRepostedBy.tsx +++ b/src/view/com/post-thread/PostRepostedBy.tsx @@ -8,13 +8,13 @@ import {logger} from '#/logger' import {usePostRepostedByQuery} from '#/state/queries/post-reposted-by' import {useResolveUriQuery} from '#/state/queries/resolve-uri' import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' +import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' +import {List} from '#/view/com/util/List' import { ListFooter, ListHeaderDesktop, ListMaybePlaceholder, } from '#/components/Lists' -import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' -import {List} from '../util/List' function renderItem({item}: {item: ActorDefs.ProfileViewBasic}) { return diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 0ff36014..26a5f2f0 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} @@ -391,6 +418,7 @@ let PostThreadItemLoaded = ({ record={record} richText={richText} onPressReply={onPressReply} + onPostReply={onPostReply} logContext="PostThreadItem" /> diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx index 478b8f0f..ad586384 100644 --- a/src/view/com/util/post-ctrls/PostCtrls.tsx +++ b/src/view/com/util/post-ctrls/PostCtrls.tsx @@ -58,6 +58,7 @@ let PostCtrls = ({ feedContext, style, onPressReply, + onPostReply, logContext, }: { big?: boolean @@ -67,6 +68,7 @@ let PostCtrls = ({ feedContext?: string | undefined style?: StyleProp onPressReply: () => void + onPostReply?: (postUri: string | undefined) => void logContext: 'FeedItem' | 'PostThreadItem' | 'Post' }): React.ReactNode => { const t = useTheme() @@ -169,16 +171,20 @@ let PostCtrls = ({ author: post.author, indexedAt: post.indexedAt, }, + quoteCount: post.quoteCount, + onPost: onPostReply, }) }, [ - openComposer, + sendInteraction, post.uri, post.cid, post.author, post.indexedAt, - record.text, - sendInteraction, + post.quoteCount, feedContext, + openComposer, + record.text, + onPostReply, ]) const onShare = useCallback(() => { diff --git a/src/view/shell/Composer.ios.tsx b/src/view/shell/Composer.ios.tsx index a732e0cd..7d378080 100644 --- a/src/view/shell/Composer.ios.tsx +++ b/src/view/shell/Composer.ios.tsx @@ -33,6 +33,7 @@ export const Composer = observer(function ComposerImpl({}: { replyTo={state?.replyTo} onPost={state?.onPost} quote={state?.quote} + quoteCount={state?.quoteCount} mention={state?.mention} text={state?.text} imageUris={state?.imageUris} diff --git a/src/view/shell/Composer.tsx b/src/view/shell/Composer.tsx index b978d6b8..1c97df9c 100644 --- a/src/view/shell/Composer.tsx +++ b/src/view/shell/Composer.tsx @@ -55,6 +55,7 @@ export const Composer = observer(function ComposerImpl({ replyTo={state.replyTo} onPost={state.onPost} quote={state.quote} + quoteCount={state.quoteCount} mention={state.mention} text={state.text} imageUris={state.imageUris} diff --git a/src/view/shell/Composer.web.tsx b/src/view/shell/Composer.web.tsx index 64353db2..5d80dc42 100644 --- a/src/view/shell/Composer.web.tsx +++ b/src/view/shell/Composer.web.tsx @@ -58,6 +58,7 @@ export function Composer({}: {winHeight: number}) {