about summary refs log tree commit diff
diff options
context:
space:
mode:
authorSamuel Newman <mozzius@protonmail.com>2024-08-21 21:26:25 +0100
committerGitHub <noreply@github.com>2024-08-21 15:26:25 -0500
commit56ab5e177fa2b24d0e5d9d969aa37532b96128da (patch)
tree2fa3db0ef9e46474aac00d5a593c5e5d592da9e3
parentddb0b80017c2b5bc158b8ff9da222abd5a8bf025 (diff)
downloadvoidsky-56ab5e177fa2b24d0e5d9d969aa37532b96128da.tar.zst
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>
-rw-r--r--bskyweb/cmd/bskyweb/server.go1
-rw-r--r--package.json2
-rw-r--r--src/Navigation.tsx124
-rw-r--r--src/lib/routes/types.ts1
-rw-r--r--src/routes.ts1
-rw-r--r--src/screens/Post/PostLikedBy.tsx (renamed from src/view/screens/PostLikedBy.tsx)11
-rw-r--r--src/screens/Post/PostQuotes.tsx33
-rw-r--r--src/screens/Post/PostRepostedBy.tsx (renamed from src/view/screens/PostRepostedBy.tsx)11
-rw-r--r--src/state/cache/post-shadow.ts4
-rw-r--r--src/state/cache/profile-shadow.ts2
-rw-r--r--src/state/queries/post-quotes.ts124
-rw-r--r--src/state/shell/composer.tsx1
-rw-r--r--src/view/com/composer/Composer.tsx18
-rw-r--r--src/view/com/post-thread/PostLikedBy.tsx4
-rw-r--r--src/view/com/post-thread/PostQuotes.tsx141
-rw-r--r--src/view/com/post-thread/PostRepostedBy.tsx4
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx30
-rw-r--r--src/view/com/util/post-ctrls/PostCtrls.tsx12
-rw-r--r--src/view/shell/Composer.ios.tsx1
-rw-r--r--src/view/shell/Composer.tsx1
-rw-r--r--src/view/shell/Composer.web.tsx1
-rw-r--r--yarn.lock15
22 files changed, 463 insertions, 79 deletions
diff --git a/bskyweb/cmd/bskyweb/server.go b/bskyweb/cmd/bskyweb/server.go
index fdef01ce7..203ed62f4 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 a4523d988..61064804b 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 79856879c..960e66bba 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<AllNavigatorParams>()
 
@@ -213,6 +214,13 @@ function commonScreens(Stack: typeof HomeTab, unreadCountLabel?: string) {
         })}
       />
       <Stack.Screen
+        name="PostQuotes"
+        getComponent={() => PostQuotesScreen}
+        options={({route}) => ({
+          title: title(msg`Post by @${route.params.name}`),
+        })}
+      />
+      <Stack.Screen
         name="ProfileFeed"
         getComponent={() => ProfileFeedScreen}
         options={{title: title(msg`Feed`)}}
diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts
index 0cc83b475..426665d07 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 c9e23e08c..2ae4126ac 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
index 5ff5a1932..c29e0aa24 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<CommonNavigatorParams, 'PostLikedBy'>
 export const PostLikedByScreen = ({route}: Props) => {
@@ -24,7 +25,7 @@ export const PostLikedByScreen = ({route}: Props) => {
   )
 
   return (
-    <View style={{flex: 1}}>
+    <View style={a.flex_1}>
       <ViewHeader title={_(msg`Liked By`)} />
       <PostLikedByComponent uri={uri} />
     </View>
diff --git a/src/screens/Post/PostQuotes.tsx b/src/screens/Post/PostQuotes.tsx
new file mode 100644
index 000000000..d670f3215
--- /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<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 style={a.flex_1}>
+      <ViewHeader title={_(msg`Quotes`)} />
+      <PostQuotesComponent uri={uri} />
+    </View>
+  )
+}
diff --git a/src/view/screens/PostRepostedBy.tsx b/src/screens/Post/PostRepostedBy.tsx
index eaacc6780..b15a6f6ee 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<CommonNavigatorParams, 'PostRepostedBy'>
 export const PostRepostedByScreen = ({route}: Props) => {
@@ -24,7 +25,7 @@ export const PostRepostedByScreen = ({route}: Props) => {
   )
 
   return (
-    <View style={{flex: 1}}>
+    <View style={a.flex_1}>
       <ViewHeader title={_(msg`Reposted By`)} />
       <PostRepostedByComponent uri={uri} />
     </View>
diff --git a/src/state/cache/post-shadow.ts b/src/state/cache/post-shadow.ts
index 48183739b..b37e9bd42 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 dc907664e..dda7a749e 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 000000000..be51eaab0
--- /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<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,
+    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<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
+        }
+      }
+    }
+  }
+}
+
+export function* findAllPostsInQueryData(
+  queryClient: QueryClient,
+  uri: string,
+): Generator<AppBskyFeedDefs.PostView, undefined> {
+  const queryDatas = queryClient.getQueriesData<
+    InfiniteData<AppBskyFeedGetQuotes.OutputSchema>
+  >({
+    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 e28d6b4ac..c99005489 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 dba37d82b..0efbe70e6 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 da230aade..c3e3f9e17 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 <ProfileCardWithFollowBtn key={item.actor.did} profile={item.actor} />
diff --git a/src/view/com/post-thread/PostQuotes.tsx b/src/view/com/post-thread/PostQuotes.tsx
new file mode 100644
index 000000000..d573d27a1
--- /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 <Post post={item.post} hideTopBorder={index === 0 && !isWeb} />
+}
+
+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 (
+      <ListMaybePlaceholder
+        isLoading={isLoadingUri || isLoadingQuotes}
+        isError={isError}
+      />
+    )
+  }
+
+  // loaded
+  // =
+  return (
+    <List
+      data={quotes}
+      renderItem={renderItem}
+      keyExtractor={keyExtractor}
+      refreshing={isPTRing}
+      onRefresh={onRefresh}
+      onEndReached={onEndReached}
+      onEndReachedThreshold={4}
+      ListHeaderComponent={<ListHeaderDesktop title={_(msg`Quotes`)} />}
+      ListFooterComponent={
+        <ListFooter
+          isFetchingNextPage={isFetchingNextPage}
+          error={cleanError(error)}
+          onRetry={fetchNextPage}
+          showEndMessage
+          endMessageText={_(msg`That's all, folks!`)}
+        />
+      }
+      // @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 9038549a5..0d1e86aec 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 <ProfileCardWithFollowBtn key={item.did} profile={item} />
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 0ff360143..26a5f2f03 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.
               <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]}>
@@ -391,6 +418,7 @@ let PostThreadItemLoaded = ({
                 record={record}
                 richText={richText}
                 onPressReply={onPressReply}
+                onPostReply={onPostReply}
                 logContext="PostThreadItem"
               />
             </View>
diff --git a/src/view/com/util/post-ctrls/PostCtrls.tsx b/src/view/com/util/post-ctrls/PostCtrls.tsx
index 478b8f0f8..ad5863846 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<ViewStyle>
   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 a732e0cde..7d3780801 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 b978d6b85..1c97df9c3 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 64353db23..5d80dc422 100644
--- a/src/view/shell/Composer.web.tsx
+++ b/src/view/shell/Composer.web.tsx
@@ -58,6 +58,7 @@ export function Composer({}: {winHeight: number}) {
         <ComposePost
           replyTo={state.replyTo}
           quote={state.quote}
+          quoteCount={state?.quoteCount}
           onPost={state.onPost}
           mention={state.mention}
           openPicker={onOpenPicker}
diff --git a/yarn.lock b/yarn.lock
index cd0508d6a..995c548b7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -72,7 +72,7 @@
   resolved "https://registry.yarnpkg.com/@atproto-labs/simple-store/-/simple-store-0.1.1.tgz#e743a2722b5d8732166f0a72aca8bd10e9bff106"
   integrity sha512-WKILW2b3QbAYKh+w5U2x6p5FqqLl0nAeLwGeDY+KjX01K4Dq3vQTR9b/qNp0jZm48CabPQVrqCv0PPU9LgRRRg==
 
-"@atproto/api@0.13.0", "@atproto/api@^0.13.0":
+"@atproto/api@^0.13.0":
   version "0.13.0"
   resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.0.tgz#d1c65a407f1c3c6aba5be9425f4f739a01419bd8"
   integrity sha512-04kzIDkoEVSP7zMVOT5ezCVQcOrbXWjGYO2YBc3/tBvQ90V1pl9I+mLyz1uUHE+wRE1IRWKACcWhAz8SrYz3pA==
@@ -85,6 +85,19 @@
     multiformats "^9.9.0"
     tlds "^1.234.0"
 
+"@atproto/api@^0.13.2":
+  version "0.13.2"
+  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.13.2.tgz#392c7e37d03f28a9d3bc53b003f2d90cea4f1863"
+  integrity sha512-AkCr+GbSJu+TSJzML/Ggh7CC61TKi4cQEOGmFHeI/0x9sa110UAAWHHRKom2vV09+cW5p/FMAtWvA05YR+v4jw==
+  dependencies:
+    "@atproto/common-web" "^0.3.0"
+    "@atproto/lexicon" "^0.4.1"
+    "@atproto/syntax" "^0.3.0"
+    "@atproto/xrpc" "^0.6.0"
+    await-lock "^2.2.2"
+    multiformats "^9.9.0"
+    tlds "^1.234.0"
+
 "@atproto/aws@^0.2.2":
   version "0.2.2"
   resolved "https://registry.yarnpkg.com/@atproto/aws/-/aws-0.2.2.tgz#703e5e06f288bcf61c6d99a990738f1e7299e653"