my fork of the bluesky client

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 <me@haileyok.com>
Co-authored-by: Eric Bailey <git@esb.lol>

authored by samuel.fm

Samuel Newman
Hailey
Eric Bailey
and committed by
GitHub
56ab5e17 ddb0b800

+463 -79
+1
bskyweb/cmd/bskyweb/server.go
··· 255 255 e.GET("/profile/:handleOrDID/post/:rkey", server.WebPost) 256 256 e.GET("/profile/:handleOrDID/post/:rkey/liked-by", server.WebGeneric) 257 257 e.GET("/profile/:handleOrDID/post/:rkey/reposted-by", server.WebGeneric) 258 + e.GET("/profile/:handleOrDID/post/:rkey/quotes", server.WebGeneric) 258 259 259 260 // starter packs 260 261 e.GET("/starter-pack/:handleOrDID/:rkey", server.WebStarterPack)
+1 -1
package.json
··· 52 52 "open-analyzer": "EXPO_PUBLIC_OPEN_ANALYZER=1 yarn build-web" 53 53 }, 54 54 "dependencies": { 55 - "@atproto/api": "0.13.0", 55 + "@atproto/api": "^0.13.2", 56 56 "@bam.tech/react-native-image-resizer": "^3.0.4", 57 57 "@braintree/sanitize-url": "^6.0.2", 58 58 "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet",
+66 -58
src/Navigation.tsx
··· 15 15 StackActions, 16 16 } from '@react-navigation/native' 17 17 18 - import {timeout} from 'lib/async/timeout' 19 - import {useColorSchemeStyle} from 'lib/hooks/useColorSchemeStyle' 20 - import {usePalette} from 'lib/hooks/usePalette' 21 - import {buildStateObject} from 'lib/routes/helpers' 18 + import {init as initAnalytics} from '#/lib/analytics/analytics' 19 + import {timeout} from '#/lib/async/timeout' 20 + import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' 21 + import {usePalette} from '#/lib/hooks/usePalette' 22 + import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration' 23 + import {buildStateObject} from '#/lib/routes/helpers' 22 24 import { 23 25 AllNavigatorParams, 24 26 BottomTabNavigatorParams, ··· 28 30 MyProfileTabNavigatorParams, 29 31 NotificationsTabNavigatorParams, 30 32 SearchTabNavigatorParams, 31 - } from 'lib/routes/types' 32 - import {RouteParams, State} from 'lib/routes/types' 33 - import {bskyTitle} from 'lib/strings/headings' 34 - import {isAndroid, isNative, isWeb} from 'platform/detection' 33 + } from '#/lib/routes/types' 34 + import {RouteParams, State} from '#/lib/routes/types' 35 + import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig' 36 + import {bskyTitle} from '#/lib/strings/headings' 37 + import {isAndroid, isNative, isWeb} from '#/platform/detection' 38 + import {useModalControls} from '#/state/modals' 39 + import {useUnreadNotifications} from '#/state/queries/notifications/unread' 40 + import {useSession} from '#/state/session' 41 + import { 42 + shouldRequestEmailConfirmation, 43 + snoozeEmailConfirmationPrompt, 44 + } from '#/state/shell/reminders' 45 + import {AccessibilitySettingsScreen} from '#/view/screens/AccessibilitySettings' 46 + import {AppPasswords} from '#/view/screens/AppPasswords' 47 + import {CommunityGuidelinesScreen} from '#/view/screens/CommunityGuidelines' 48 + import {CopyrightPolicyScreen} from '#/view/screens/CopyrightPolicy' 49 + import {DebugModScreen} from '#/view/screens/DebugMod' 50 + import {FeedsScreen} from '#/view/screens/Feeds' 51 + import {HomeScreen} from '#/view/screens/Home' 52 + import {LanguageSettingsScreen} from '#/view/screens/LanguageSettings' 53 + import {ListsScreen} from '#/view/screens/Lists' 54 + import {LogScreen} from '#/view/screens/Log' 55 + import {ModerationBlockedAccounts} from '#/view/screens/ModerationBlockedAccounts' 56 + import {ModerationModlistsScreen} from '#/view/screens/ModerationModlists' 57 + import {ModerationMutedAccounts} from '#/view/screens/ModerationMutedAccounts' 58 + import {NotFoundScreen} from '#/view/screens/NotFound' 59 + import {NotificationsScreen} from '#/view/screens/Notifications' 60 + import {NotificationsSettingsScreen} from '#/view/screens/NotificationsSettings' 61 + import {PostThreadScreen} from '#/view/screens/PostThread' 35 62 import {PreferencesExternalEmbeds} from '#/view/screens/PreferencesExternalEmbeds' 36 - import {AppPasswords} from 'view/screens/AppPasswords' 37 - import {ModerationBlockedAccounts} from 'view/screens/ModerationBlockedAccounts' 38 - import {ModerationMutedAccounts} from 'view/screens/ModerationMutedAccounts' 39 - import {PreferencesFollowingFeed} from 'view/screens/PreferencesFollowingFeed' 40 - import {PreferencesThreads} from 'view/screens/PreferencesThreads' 41 - import {SavedFeeds} from 'view/screens/SavedFeeds' 63 + import {PreferencesFollowingFeed} from '#/view/screens/PreferencesFollowingFeed' 64 + import {PreferencesThreads} from '#/view/screens/PreferencesThreads' 65 + import {PrivacyPolicyScreen} from '#/view/screens/PrivacyPolicy' 66 + import {ProfileScreen} from '#/view/screens/Profile' 67 + import {ProfileFeedScreen} from '#/view/screens/ProfileFeed' 68 + import {ProfileFeedLikedByScreen} from '#/view/screens/ProfileFeedLikedBy' 69 + import {ProfileFollowersScreen} from '#/view/screens/ProfileFollowers' 70 + import {ProfileFollowsScreen} from '#/view/screens/ProfileFollows' 71 + import {ProfileListScreen} from '#/view/screens/ProfileList' 72 + import {SavedFeeds} from '#/view/screens/SavedFeeds' 73 + import {SearchScreen} from '#/view/screens/Search' 74 + import {SettingsScreen} from '#/view/screens/Settings' 75 + import {Storybook} from '#/view/screens/Storybook' 76 + import {SupportScreen} from '#/view/screens/Support' 77 + import {TermsOfServiceScreen} from '#/view/screens/TermsOfService' 78 + import {BottomBar} from '#/view/shell/bottom-bar/BottomBar' 79 + import {createNativeStackNavigatorWithAuth} from '#/view/shell/createNativeStackNavigatorWithAuth' 42 80 import {SharedPreferencesTesterScreen} from '#/screens/E2E/SharedPreferencesTesterScreen' 43 81 import HashtagScreen from '#/screens/Hashtag' 82 + import {MessagesConversationScreen} from '#/screens/Messages/Conversation' 83 + import {MessagesScreen} from '#/screens/Messages/List' 84 + import {MessagesSettingsScreen} from '#/screens/Messages/Settings' 44 85 import {ModerationScreen} from '#/screens/Moderation' 86 + import {PostLikedByScreen} from '#/screens/Post/PostLikedBy' 87 + import {PostQuotesScreen} from '#/screens/Post/PostQuotes' 88 + import {PostRepostedByScreen} from '#/screens/Post/PostRepostedBy' 45 89 import {ProfileKnownFollowersScreen} from '#/screens/Profile/KnownFollowers' 46 90 import {ProfileLabelerLikedByScreen} from '#/screens/Profile/ProfileLabelerLikedBy' 47 91 import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings' ··· 50 94 StarterPackScreenShort, 51 95 } from '#/screens/StarterPack/StarterPackScreen' 52 96 import {Wizard} from '#/screens/StarterPack/Wizard' 97 + import {router} from '#/routes' 53 98 import {Referrer} from '../modules/expo-bluesky-swiss-army' 54 - import {init as initAnalytics} from './lib/analytics/analytics' 55 - import {useWebScrollRestoration} from './lib/hooks/useWebScrollRestoration' 56 - import {attachRouteToLogEvents, logEvent} from './lib/statsig/statsig' 57 - import {router} from './routes' 58 - import {MessagesConversationScreen} from './screens/Messages/Conversation' 59 - import {MessagesScreen} from './screens/Messages/List' 60 - import {MessagesSettingsScreen} from './screens/Messages/Settings' 61 - import {useModalControls} from './state/modals' 62 - import {useUnreadNotifications} from './state/queries/notifications/unread' 63 - import {useSession} from './state/session' 64 - import { 65 - shouldRequestEmailConfirmation, 66 - snoozeEmailConfirmationPrompt, 67 - } from './state/shell/reminders' 68 - import {AccessibilitySettingsScreen} from './view/screens/AccessibilitySettings' 69 - import {CommunityGuidelinesScreen} from './view/screens/CommunityGuidelines' 70 - import {CopyrightPolicyScreen} from './view/screens/CopyrightPolicy' 71 - import {DebugModScreen} from './view/screens/DebugMod' 72 - import {FeedsScreen} from './view/screens/Feeds' 73 - import {HomeScreen} from './view/screens/Home' 74 - import {LanguageSettingsScreen} from './view/screens/LanguageSettings' 75 - import {ListsScreen} from './view/screens/Lists' 76 - import {LogScreen} from './view/screens/Log' 77 - import {ModerationModlistsScreen} from './view/screens/ModerationModlists' 78 - import {NotFoundScreen} from './view/screens/NotFound' 79 - import {NotificationsScreen} from './view/screens/Notifications' 80 - import {NotificationsSettingsScreen} from './view/screens/NotificationsSettings' 81 - import {PostLikedByScreen} from './view/screens/PostLikedBy' 82 - import {PostRepostedByScreen} from './view/screens/PostRepostedBy' 83 - import {PostThreadScreen} from './view/screens/PostThread' 84 - import {PrivacyPolicyScreen} from './view/screens/PrivacyPolicy' 85 - import {ProfileScreen} from './view/screens/Profile' 86 - import {ProfileFeedScreen} from './view/screens/ProfileFeed' 87 - import {ProfileFeedLikedByScreen} from './view/screens/ProfileFeedLikedBy' 88 - import {ProfileFollowersScreen} from './view/screens/ProfileFollowers' 89 - import {ProfileFollowsScreen} from './view/screens/ProfileFollows' 90 - import {ProfileListScreen} from './view/screens/ProfileList' 91 - import {SearchScreen} from './view/screens/Search' 92 - import {SettingsScreen} from './view/screens/Settings' 93 - import {Storybook} from './view/screens/Storybook' 94 - import {SupportScreen} from './view/screens/Support' 95 - import {TermsOfServiceScreen} from './view/screens/TermsOfService' 96 - import {BottomBar} from './view/shell/bottom-bar/BottomBar' 97 - import {createNativeStackNavigatorWithAuth} from './view/shell/createNativeStackNavigatorWithAuth' 98 99 99 100 const navigationRef = createNavigationContainerRef<AllNavigatorParams>() 100 101 ··· 208 209 <Stack.Screen 209 210 name="PostRepostedBy" 210 211 getComponent={() => PostRepostedByScreen} 212 + options={({route}) => ({ 213 + title: title(msg`Post by @${route.params.name}`), 214 + })} 215 + /> 216 + <Stack.Screen 217 + name="PostQuotes" 218 + getComponent={() => PostQuotesScreen} 211 219 options={({route}) => ({ 212 220 title: title(msg`Post by @${route.params.name}`), 213 221 })}
+1
src/lib/routes/types.ts
··· 20 20 PostThread: {name: string; rkey: string} 21 21 PostLikedBy: {name: string; rkey: string} 22 22 PostRepostedBy: {name: string; rkey: string} 23 + PostQuotes: {name: string; rkey: string} 23 24 ProfileFeed: {name: string; rkey: string} 24 25 ProfileFeedLikedBy: {name: string; rkey: string} 25 26 ProfileLabelerLikedBy: {name: string}
+1
src/routes.ts
··· 21 21 PostThread: '/profile/:name/post/:rkey', 22 22 PostLikedBy: '/profile/:name/post/:rkey/liked-by', 23 23 PostRepostedBy: '/profile/:name/post/:rkey/reposted-by', 24 + PostQuotes: '/profile/:name/post/:rkey/quotes', 24 25 ProfileFeed: '/profile/:name/feed/:rkey', 25 26 ProfileFeedLikedBy: '/profile/:name/feed/:rkey/liked-by', 26 27 ProfileLabelerLikedBy: '/profile/:name/labeler/liked-by',
+33
src/screens/Post/PostQuotes.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {useFocusEffect} from '@react-navigation/native' 6 + 7 + import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' 8 + import {makeRecordUri} from '#/lib/strings/url-helpers' 9 + import {useSetMinimalShellMode} from '#/state/shell' 10 + import {PostQuotes as PostQuotesComponent} from '#/view/com/post-thread/PostQuotes' 11 + import {ViewHeader} from '#/view/com/util/ViewHeader' 12 + import {atoms as a} from '#/alf' 13 + 14 + type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostQuotes'> 15 + export const PostQuotesScreen = ({route}: Props) => { 16 + const setMinimalShellMode = useSetMinimalShellMode() 17 + const {name, rkey} = route.params 18 + const uri = makeRecordUri(name, 'app.bsky.feed.post', rkey) 19 + const {_} = useLingui() 20 + 21 + useFocusEffect( 22 + React.useCallback(() => { 23 + setMinimalShellMode(false) 24 + }, [setMinimalShellMode]), 25 + ) 26 + 27 + return ( 28 + <View style={a.flex_1}> 29 + <ViewHeader title={_(msg`Quotes`)} /> 30 + <PostQuotesComponent uri={uri} /> 31 + </View> 32 + ) 33 + }
+4
src/state/cache/post-shadow.ts
··· 6 6 import {batchedUpdates} from '#/lib/batchedUpdates' 7 7 import {findAllPostsInQueryData as findAllPostsInNotifsQueryData} from '../queries/notifications/feed' 8 8 import {findAllPostsInQueryData as findAllPostsInFeedQueryData} from '../queries/post-feed' 9 + import {findAllPostsInQueryData as findAllPostsInQuoteQueryData} from '../queries/post-quotes' 9 10 import {findAllPostsInQueryData as findAllPostsInThreadQueryData} from '../queries/post-thread' 10 11 import {findAllPostsInQueryData as findAllPostsInSearchQueryData} from '../queries/search-posts' 11 12 import {castAsShadow, Shadow} from './types' ··· 128 129 } 129 130 } 130 131 for (let post of findAllPostsInSearchQueryData(queryClient, uri)) { 132 + yield post 133 + } 134 + for (let post of findAllPostsInQuoteQueryData(queryClient, uri)) { 131 135 yield post 132 136 } 133 137 }
+2
src/state/cache/profile-shadow.ts
··· 12 12 import {findAllProfilesInQueryData as findAllProfilesInMyMutedAccountsQueryData} from '../queries/my-muted-accounts' 13 13 import {findAllProfilesInQueryData as findAllProfilesInFeedsQueryData} from '../queries/post-feed' 14 14 import {findAllProfilesInQueryData as findAllProfilesInPostLikedByQueryData} from '../queries/post-liked-by' 15 + import {findAllProfilesInQueryData as findAllProfilesInPostQuotesQueryData} from '../queries/post-quotes' 15 16 import {findAllProfilesInQueryData as findAllProfilesInPostRepostedByQueryData} from '../queries/post-reposted-by' 16 17 import {findAllProfilesInQueryData as findAllProfilesInPostThreadQueryData} from '../queries/post-thread' 17 18 import {findAllProfilesInQueryData as findAllProfilesInProfileQueryData} from '../queries/profile' ··· 104 105 yield* findAllProfilesInMyMutedAccountsQueryData(queryClient, did) 105 106 yield* findAllProfilesInPostLikedByQueryData(queryClient, did) 106 107 yield* findAllProfilesInPostRepostedByQueryData(queryClient, did) 108 + yield* findAllProfilesInPostQuotesQueryData(queryClient, did) 107 109 yield* findAllProfilesInProfileQueryData(queryClient, did) 108 110 yield* findAllProfilesInProfileFollowersQueryData(queryClient, did) 109 111 yield* findAllProfilesInProfileFollowsQueryData(queryClient, did)
+124
src/state/queries/post-quotes.ts
··· 1 + import { 2 + AppBskyActorDefs, 3 + AppBskyEmbedRecord, 4 + AppBskyFeedDefs, 5 + AppBskyFeedGetQuotes, 6 + AtUri, 7 + } from '@atproto/api' 8 + import { 9 + InfiniteData, 10 + QueryClient, 11 + QueryKey, 12 + useInfiniteQuery, 13 + } from '@tanstack/react-query' 14 + 15 + import {useAgent} from '#/state/session' 16 + import { 17 + didOrHandleUriMatches, 18 + embedViewRecordToPostView, 19 + getEmbeddedPost, 20 + } from './util' 21 + 22 + const PAGE_SIZE = 30 23 + type RQPageParam = string | undefined 24 + 25 + const RQKEY_ROOT = 'post-quotes' 26 + export const RQKEY = (resolvedUri: string) => [RQKEY_ROOT, resolvedUri] 27 + 28 + export function usePostQuotesQuery(resolvedUri: string | undefined) { 29 + const agent = useAgent() 30 + return useInfiniteQuery< 31 + AppBskyFeedGetQuotes.OutputSchema, 32 + Error, 33 + InfiniteData<AppBskyFeedGetQuotes.OutputSchema>, 34 + QueryKey, 35 + RQPageParam 36 + >({ 37 + queryKey: RQKEY(resolvedUri || ''), 38 + async queryFn({pageParam}: {pageParam: RQPageParam}) { 39 + const res = await agent.api.app.bsky.feed.getQuotes({ 40 + uri: resolvedUri || '', 41 + limit: PAGE_SIZE, 42 + cursor: pageParam, 43 + }) 44 + return res.data 45 + }, 46 + initialPageParam: undefined, 47 + getNextPageParam: lastPage => lastPage.cursor, 48 + enabled: !!resolvedUri, 49 + select: data => { 50 + return { 51 + ...data, 52 + pages: data.pages.map(page => { 53 + return { 54 + ...page, 55 + posts: page.posts.filter(post => { 56 + if (post.embed && AppBskyEmbedRecord.isView(post.embed)) { 57 + if (AppBskyEmbedRecord.isViewDetached(post.embed.record)) { 58 + return false 59 + } 60 + } 61 + return true 62 + }), 63 + } 64 + }), 65 + } 66 + }, 67 + }) 68 + } 69 + 70 + export function* findAllProfilesInQueryData( 71 + queryClient: QueryClient, 72 + did: string, 73 + ): Generator<AppBskyActorDefs.ProfileView, void> { 74 + const queryDatas = queryClient.getQueriesData< 75 + InfiniteData<AppBskyFeedGetQuotes.OutputSchema> 76 + >({ 77 + queryKey: [RQKEY_ROOT], 78 + }) 79 + for (const [_queryKey, queryData] of queryDatas) { 80 + if (!queryData?.pages) { 81 + continue 82 + } 83 + for (const page of queryData?.pages) { 84 + for (const item of page.posts) { 85 + if (item.author.did === did) { 86 + yield item.author 87 + } 88 + const quotedPost = getEmbeddedPost(item.embed) 89 + if (quotedPost?.author.did === did) { 90 + yield quotedPost.author 91 + } 92 + } 93 + } 94 + } 95 + } 96 + 97 + export function* findAllPostsInQueryData( 98 + queryClient: QueryClient, 99 + uri: string, 100 + ): Generator<AppBskyFeedDefs.PostView, undefined> { 101 + const queryDatas = queryClient.getQueriesData< 102 + InfiniteData<AppBskyFeedGetQuotes.OutputSchema> 103 + >({ 104 + queryKey: [RQKEY_ROOT], 105 + }) 106 + const atUri = new AtUri(uri) 107 + for (const [_queryKey, queryData] of queryDatas) { 108 + if (!queryData?.pages) { 109 + continue 110 + } 111 + for (const page of queryData?.pages) { 112 + for (const post of page.posts) { 113 + if (didOrHandleUriMatches(atUri, post)) { 114 + yield post 115 + } 116 + 117 + const quotedPost = getEmbeddedPost(post.embed) 118 + if (quotedPost && didOrHandleUriMatches(atUri, quotedPost)) { 119 + yield embedViewRecordToPostView(quotedPost) 120 + } 121 + } 122 + } 123 + } 124 + }
+1
src/state/shell/composer.tsx
··· 34 34 replyTo?: ComposerOptsPostRef 35 35 onPost?: (postUri: string | undefined) => void 36 36 quote?: ComposerOptsQuote 37 + quoteCount?: number 37 38 mention?: string // handle of user to mention 38 39 openPicker?: (pos: DOMRect | undefined) => void 39 40 text?: string
+17 -1
src/view/com/composer/Composer.tsx
··· 116 116 replyTo, 117 117 onPost, 118 118 quote: initQuote, 119 + quoteCount, 119 120 mention: initMention, 120 121 openPicker, 121 122 text: initText, ··· 392 393 emitPostCreated() 393 394 } 394 395 setLangPrefs.savePostLanguageToHistory() 395 - onPost?.(postUri) 396 + if (quote) { 397 + // We want to wait for the quote count to update before we call `onPost`, which will refetch data 398 + whenAppViewReady(agent, quote.uri, res => { 399 + const thread = res.data.thread 400 + if ( 401 + AppBskyFeedDefs.isThreadViewPost(thread) && 402 + thread.post.quoteCount !== quoteCount 403 + ) { 404 + onPost?.(postUri) 405 + return true 406 + } 407 + return false 408 + }) 409 + } else { 410 + onPost?.(postUri) 411 + } 396 412 onClose() 397 413 Toast.show( 398 414 replyTo
+2 -2
src/view/com/post-thread/PostLikedBy.tsx
··· 8 8 import {useLikedByQuery} from '#/state/queries/post-liked-by' 9 9 import {useResolveUriQuery} from '#/state/queries/resolve-uri' 10 10 import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' 11 + import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' 12 + import {List} from '#/view/com/util/List' 11 13 import { 12 14 ListFooter, 13 15 ListHeaderDesktop, 14 16 ListMaybePlaceholder, 15 17 } from '#/components/Lists' 16 - import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' 17 - import {List} from '../util/List' 18 18 19 19 function renderItem({item}: {item: GetLikes.Like}) { 20 20 return <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
+141
src/view/com/post-thread/PostQuotes.tsx
··· 1 + import React, {useCallback, useState} from 'react' 2 + import { 3 + AppBskyFeedDefs, 4 + AppBskyFeedPost, 5 + ModerationDecision, 6 + } from '@atproto/api' 7 + import {msg} from '@lingui/macro' 8 + import {useLingui} from '@lingui/react' 9 + 10 + import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' 11 + import {cleanError} from '#/lib/strings/errors' 12 + import {logger} from '#/logger' 13 + import {isWeb} from '#/platform/detection' 14 + import {useModerationOpts} from '#/state/preferences/moderation-opts' 15 + import {usePostQuotesQuery} from '#/state/queries/post-quotes' 16 + import {useResolveUriQuery} from '#/state/queries/resolve-uri' 17 + import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' 18 + import {Post} from 'view/com/post/Post' 19 + import { 20 + ListFooter, 21 + ListHeaderDesktop, 22 + ListMaybePlaceholder, 23 + } from '#/components/Lists' 24 + import {List} from '../util/List' 25 + 26 + function renderItem({ 27 + item, 28 + index, 29 + }: { 30 + item: { 31 + post: AppBskyFeedDefs.PostView 32 + moderation: ModerationDecision 33 + record: AppBskyFeedPost.Record 34 + } 35 + index: number 36 + }) { 37 + return <Post post={item.post} hideTopBorder={index === 0 && !isWeb} /> 38 + } 39 + 40 + function keyExtractor(item: { 41 + post: AppBskyFeedDefs.PostView 42 + moderation: ModerationDecision 43 + record: AppBskyFeedPost.Record 44 + }) { 45 + return item.post.uri 46 + } 47 + 48 + export function PostQuotes({uri}: {uri: string}) { 49 + const {_} = useLingui() 50 + const initialNumToRender = useInitialNumToRender() 51 + 52 + const [isPTRing, setIsPTRing] = useState(false) 53 + 54 + const { 55 + data: resolvedUri, 56 + error: resolveError, 57 + isLoading: isLoadingUri, 58 + } = useResolveUriQuery(uri) 59 + const { 60 + data, 61 + isLoading: isLoadingQuotes, 62 + isFetchingNextPage, 63 + hasNextPage, 64 + fetchNextPage, 65 + error, 66 + refetch, 67 + } = usePostQuotesQuery(resolvedUri?.uri) 68 + 69 + const moderationOpts = useModerationOpts() 70 + 71 + const isError = Boolean(resolveError || error) 72 + 73 + const quotes = 74 + data?.pages 75 + .flatMap(page => 76 + page.posts.map(post => { 77 + if (!AppBskyFeedPost.isRecord(post.record) || !moderationOpts) { 78 + return null 79 + } 80 + const moderation = moderatePost(post, moderationOpts) 81 + return {post, record: post.record, moderation} 82 + }), 83 + ) 84 + .filter(item => item !== null) ?? [] 85 + 86 + const onRefresh = useCallback(async () => { 87 + setIsPTRing(true) 88 + try { 89 + await refetch() 90 + } catch (err) { 91 + logger.error('Failed to refresh quotes', {message: err}) 92 + } 93 + setIsPTRing(false) 94 + }, [refetch, setIsPTRing]) 95 + 96 + const onEndReached = useCallback(async () => { 97 + if (isFetchingNextPage || !hasNextPage || isError) return 98 + try { 99 + await fetchNextPage() 100 + } catch (err) { 101 + logger.error('Failed to load more quotes', {message: err}) 102 + } 103 + }, [isFetchingNextPage, hasNextPage, isError, fetchNextPage]) 104 + 105 + if (isLoadingUri || isLoadingQuotes || isError) { 106 + return ( 107 + <ListMaybePlaceholder 108 + isLoading={isLoadingUri || isLoadingQuotes} 109 + isError={isError} 110 + /> 111 + ) 112 + } 113 + 114 + // loaded 115 + // = 116 + return ( 117 + <List 118 + data={quotes} 119 + renderItem={renderItem} 120 + keyExtractor={keyExtractor} 121 + refreshing={isPTRing} 122 + onRefresh={onRefresh} 123 + onEndReached={onEndReached} 124 + onEndReachedThreshold={4} 125 + ListHeaderComponent={<ListHeaderDesktop title={_(msg`Quotes`)} />} 126 + ListFooterComponent={ 127 + <ListFooter 128 + isFetchingNextPage={isFetchingNextPage} 129 + error={cleanError(error)} 130 + onRetry={fetchNextPage} 131 + showEndMessage 132 + endMessageText={_(msg`That's all, folks!`)} 133 + /> 134 + } 135 + // @ts-ignore our .web version only -prf 136 + desktopFixedHeight 137 + initialNumToRender={initialNumToRender} 138 + windowSize={11} 139 + /> 140 + ) 141 + }
+2 -2
src/view/com/post-thread/PostRepostedBy.tsx
··· 8 8 import {usePostRepostedByQuery} from '#/state/queries/post-reposted-by' 9 9 import {useResolveUriQuery} from '#/state/queries/resolve-uri' 10 10 import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' 11 + import {ProfileCardWithFollowBtn} from '#/view/com/profile/ProfileCard' 12 + import {List} from '#/view/com/util/List' 11 13 import { 12 14 ListFooter, 13 15 ListHeaderDesktop, 14 16 ListMaybePlaceholder, 15 17 } from '#/components/Lists' 16 - import {ProfileCardWithFollowBtn} from '../profile/ProfileCard' 17 - import {List} from '../util/List' 18 18 19 19 function renderItem({item}: {item: ActorDefs.ProfileViewBasic}) { 20 20 return <ProfileCardWithFollowBtn key={item.did} profile={item} />
+29 -1
src/view/com/post-thread/PostThreadItem.tsx
··· 199 199 return makeProfileLink(post.author, 'post', urip.rkey, 'reposted-by') 200 200 }, [post.uri, post.author]) 201 201 const repostsTitle = _(msg`Reposts of this post`) 202 + const quotesHref = React.useMemo(() => { 203 + const urip = new AtUri(post.uri) 204 + return makeProfileLink(post.author, 'post', urip.rkey, 'quotes') 205 + }, [post.uri, post.author]) 206 + const quotesTitle = _(msg`Quotes of this post`) 202 207 203 208 const translatorUrl = getTranslatorLink( 204 209 record?.text || '', ··· 343 348 translatorUrl={translatorUrl} 344 349 needsTranslation={needsTranslation} 345 350 /> 346 - {post.repostCount !== 0 || post.likeCount !== 0 ? ( 351 + {post.repostCount !== 0 || 352 + post.likeCount !== 0 || 353 + post.quoteCount !== 0 ? ( 347 354 // Show this section unless we're *sure* it has no engagement. 348 355 <View style={[styles.expandedInfo, pal.border]}> 349 356 {post.repostCount != null && post.repostCount !== 0 ? ( ··· 382 389 </Text> 383 390 </Link> 384 391 ) : null} 392 + {post.quoteCount != null && post.quoteCount !== 0 ? ( 393 + <Link 394 + style={styles.expandedInfoItem} 395 + href={quotesHref} 396 + title={quotesTitle}> 397 + <Text 398 + testID="quoteCount-expanded" 399 + type="lg" 400 + style={pal.textLight}> 401 + <Text type="xl-bold" style={pal.text}> 402 + {formatCount(post.quoteCount)} 403 + </Text>{' '} 404 + <Plural 405 + value={post.quoteCount} 406 + one="quote" 407 + other="quotes" 408 + /> 409 + </Text> 410 + </Link> 411 + ) : null} 385 412 </View> 386 413 ) : null} 387 414 <View style={[s.pl10, s.pr10]}> ··· 391 418 record={record} 392 419 richText={richText} 393 420 onPressReply={onPressReply} 421 + onPostReply={onPostReply} 394 422 logContext="PostThreadItem" 395 423 /> 396 424 </View>
+9 -3
src/view/com/util/post-ctrls/PostCtrls.tsx
··· 58 58 feedContext, 59 59 style, 60 60 onPressReply, 61 + onPostReply, 61 62 logContext, 62 63 }: { 63 64 big?: boolean ··· 67 68 feedContext?: string | undefined 68 69 style?: StyleProp<ViewStyle> 69 70 onPressReply: () => void 71 + onPostReply?: (postUri: string | undefined) => void 70 72 logContext: 'FeedItem' | 'PostThreadItem' | 'Post' 71 73 }): React.ReactNode => { 72 74 const t = useTheme() ··· 169 171 author: post.author, 170 172 indexedAt: post.indexedAt, 171 173 }, 174 + quoteCount: post.quoteCount, 175 + onPost: onPostReply, 172 176 }) 173 177 }, [ 174 - openComposer, 178 + sendInteraction, 175 179 post.uri, 176 180 post.cid, 177 181 post.author, 178 182 post.indexedAt, 179 - record.text, 180 - sendInteraction, 183 + post.quoteCount, 181 184 feedContext, 185 + openComposer, 186 + record.text, 187 + onPostReply, 182 188 ]) 183 189 184 190 const onShare = useCallback(() => {
+6 -5
src/view/screens/PostLikedBy.tsx src/screens/Post/PostLikedBy.tsx
··· 4 4 import {useLingui} from '@lingui/react' 5 5 import {useFocusEffect} from '@react-navigation/native' 6 6 7 + import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' 8 + import {makeRecordUri} from '#/lib/strings/url-helpers' 7 9 import {useSetMinimalShellMode} from '#/state/shell' 8 - import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' 9 - import {makeRecordUri} from 'lib/strings/url-helpers' 10 - import {PostLikedBy as PostLikedByComponent} from '../com/post-thread/PostLikedBy' 11 - import {ViewHeader} from '../com/util/ViewHeader' 10 + import {PostLikedBy as PostLikedByComponent} from '#/view/com/post-thread/PostLikedBy' 11 + import {ViewHeader} from '#/view/com/util/ViewHeader' 12 + import {atoms as a} from '#/alf' 12 13 13 14 type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostLikedBy'> 14 15 export const PostLikedByScreen = ({route}: Props) => { ··· 24 25 ) 25 26 26 27 return ( 27 - <View style={{flex: 1}}> 28 + <View style={a.flex_1}> 28 29 <ViewHeader title={_(msg`Liked By`)} /> 29 30 <PostLikedByComponent uri={uri} /> 30 31 </View>
+6 -5
src/view/screens/PostRepostedBy.tsx src/screens/Post/PostRepostedBy.tsx
··· 4 4 import {useLingui} from '@lingui/react' 5 5 import {useFocusEffect} from '@react-navigation/native' 6 6 7 + import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' 8 + import {makeRecordUri} from '#/lib/strings/url-helpers' 7 9 import {useSetMinimalShellMode} from '#/state/shell' 8 - import {CommonNavigatorParams, NativeStackScreenProps} from 'lib/routes/types' 9 - import {makeRecordUri} from 'lib/strings/url-helpers' 10 - import {PostRepostedBy as PostRepostedByComponent} from '../com/post-thread/PostRepostedBy' 11 - import {ViewHeader} from '../com/util/ViewHeader' 10 + import {PostRepostedBy as PostRepostedByComponent} from '#/view/com/post-thread/PostRepostedBy' 11 + import {ViewHeader} from '#/view/com/util/ViewHeader' 12 + import {atoms as a} from '#/alf' 12 13 13 14 type Props = NativeStackScreenProps<CommonNavigatorParams, 'PostRepostedBy'> 14 15 export const PostRepostedByScreen = ({route}: Props) => { ··· 24 25 ) 25 26 26 27 return ( 27 - <View style={{flex: 1}}> 28 + <View style={a.flex_1}> 28 29 <ViewHeader title={_(msg`Reposted By`)} /> 29 30 <PostRepostedByComponent uri={uri} /> 30 31 </View>
+1
src/view/shell/Composer.ios.tsx
··· 33 33 replyTo={state?.replyTo} 34 34 onPost={state?.onPost} 35 35 quote={state?.quote} 36 + quoteCount={state?.quoteCount} 36 37 mention={state?.mention} 37 38 text={state?.text} 38 39 imageUris={state?.imageUris}
+1
src/view/shell/Composer.tsx
··· 55 55 replyTo={state.replyTo} 56 56 onPost={state.onPost} 57 57 quote={state.quote} 58 + quoteCount={state.quoteCount} 58 59 mention={state.mention} 59 60 text={state.text} 60 61 imageUris={state.imageUris}
+1
src/view/shell/Composer.web.tsx
··· 58 58 <ComposePost 59 59 replyTo={state.replyTo} 60 60 quote={state.quote} 61 + quoteCount={state?.quoteCount} 61 62 onPost={state.onPost} 62 63 mention={state.mention} 63 64 openPicker={onOpenPicker}
+14 -1
yarn.lock
··· 72 72 resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz#e743a2722b5d8732166f0a72aca8bd10e9bff106" 73 73 integrity sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg== 74 74 75 - "@atproto/api@0.13.0", "@atproto/api@^0.13.0": 75 + "@atproto/api@^0.13.0": 76 76 version "0.13.0" 77 77 resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.0.tgz#d1c65a407f1c3c6aba5be9425f4f739a01419bd8" 78 78 integrity sha512-04kzIDkoEVSP7zMVOT5ezCVQcOrbXWjGYO2YBc3/tBvQ90V1pl9I+mLyz1uUHE+wRE1IRWKACcWhAz8SrYz3pA== 79 + dependencies: 80 + "@atproto/common-web" "^0.3.0" 81 + "@atproto/lexicon" "^0.4.1" 82 + "@atproto/syntax" "^0.3.0" 83 + "@atproto/xrpc" "^0.6.0" 84 + await-lock "^2.2.2" 85 + multiformats "^9.9.0" 86 + tlds "^1.234.0" 87 + 88 + "@atproto/api@^0.13.2": 89 + version "0.13.2" 90 + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.2.tgz#392c7e37d03f28a9d3bc53b003f2d90cea4f1863" 91 + integrity sha512-AkCr+GbSJu+TSJzML/Ggh7CC61TKi4cQEOGmFHeI/0x9sa110UAAWHHRKom2vV09+cW5p/FMAtWvA05YR+v4jw== 79 92 dependencies: 80 93 "@atproto/common-web" "^0.3.0" 81 94 "@atproto/lexicon" "^0.4.1"