about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--package.json2
-rw-r--r--src/lib/api/index.ts80
-rw-r--r--src/state/models/shell-ui.ts20
-rw-r--r--src/view/com/composer/ComposePost.tsx64
-rw-r--r--src/view/com/modals/Modal.tsx4
-rw-r--r--src/view/com/modals/Modal.web.tsx3
-rw-r--r--src/view/com/modals/Repost.tsx90
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx14
-rw-r--r--src/view/com/post/Post.tsx7
-rw-r--r--src/view/com/posts/FeedItem.tsx7
-rw-r--r--src/view/com/util/PostCtrls.tsx36
-rw-r--r--src/view/com/util/PostEmbeds/QuoteEmbed.tsx58
-rw-r--r--src/view/com/util/PostEmbeds/index.tsx28
-rw-r--r--src/view/com/util/PostMeta.tsx30
-rw-r--r--src/view/index.ts6
-rw-r--r--src/view/shell/mobile/Composer.tsx3
-rw-r--r--src/view/shell/mobile/index.tsx1
-rw-r--r--yarn.lock8
18 files changed, 392 insertions, 69 deletions
diff --git a/package.json b/package.json
index 66d300d34..3e4b16d7d 100644
--- a/package.json
+++ b/package.json
@@ -16,7 +16,7 @@
     "e2e": "detox test --configuration ios.sim.debug --take-screenshots all"
   },
   "dependencies": {
-    "@atproto/api": "^0.1.2",
+    "@atproto/api": "0.1.3",
     "@atproto/lexicon": "^0.0.4",
     "@atproto/xrpc": "^0.0.4",
     "@bam.tech/react-native-image-resizer": "^3.0.4",
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index ae156928e..3b8af44e8 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -2,6 +2,7 @@ import {
   AppBskyEmbedImages,
   AppBskyEmbedExternal,
   ComAtprotoBlobUpload,
+  AppBskyEmbedRecord,
 } from '@atproto/api'
 import {AtUri} from '../../third-party/uri'
 import {RootStoreModel} from 'state/models/root-store'
@@ -51,23 +52,32 @@ export async function uploadBlob(
   }
 }
 
-export async function post(
-  store: RootStoreModel,
-  rawText: string,
-  replyTo?: string,
-  extLink?: ExternalEmbedDraft,
-  images?: string[],
-  knownHandles?: Set<string>,
-  onStateChange?: (state: string) => void,
-) {
-  let embed: AppBskyEmbedImages.Main | AppBskyEmbedExternal.Main | undefined
+interface PostOpts {
+  rawText: string
+  replyTo?: string
+  quote?: {
+    uri: string
+    cid: string
+  }
+  extLink?: ExternalEmbedDraft
+  images?: string[]
+  knownHandles?: Set<string>
+  onStateChange?: (state: string) => void
+}
+
+export async function post(store: RootStoreModel, opts: PostOpts) {
+  let embed:
+    | AppBskyEmbedImages.Main
+    | AppBskyEmbedExternal.Main
+    | AppBskyEmbedRecord.Main
+    | undefined
   let reply
-  const text = new RichText(rawText, undefined, {
+  const text = new RichText(opts.rawText, undefined, {
     cleanNewlines: true,
   }).text.trim()
 
-  onStateChange?.('Processing...')
-  const entities = extractEntities(text, knownHandles)
+  opts.onStateChange?.('Processing...')
+  const entities = extractEntities(text, opts.knownHandles)
   if (entities) {
     for (const ent of entities) {
       if (ent.type === 'mention') {
@@ -77,14 +87,22 @@ export async function post(
     }
   }
 
-  if (images?.length) {
+  if (opts.quote) {
+    embed = {
+      $type: 'app.bsky.embed.record',
+      record: {
+        uri: opts.quote.uri,
+        cid: opts.quote.cid,
+      },
+    } as AppBskyEmbedRecord.Main
+  } else if (opts.images?.length) {
     embed = {
       $type: 'app.bsky.embed.images',
       images: [],
     } as AppBskyEmbedImages.Main
     let i = 1
-    for (const image of images) {
-      onStateChange?.(`Uploading image #${i++}...`)
+    for (const image of opts.images) {
+      opts.onStateChange?.(`Uploading image #${i++}...`)
       const res = await uploadBlob(store, image, 'image/jpeg')
       embed.images.push({
         image: {
@@ -94,30 +112,28 @@ export async function post(
         alt: '', // TODO supply alt text
       })
     }
-  }
-
-  if (!embed && extLink) {
+  } else if (opts.extLink) {
     let thumb
-    if (extLink.localThumb) {
-      onStateChange?.('Uploading link thumbnail...')
+    if (opts.extLink.localThumb) {
+      opts.onStateChange?.('Uploading link thumbnail...')
       let encoding
-      if (extLink.localThumb.path.endsWith('.png')) {
+      if (opts.extLink.localThumb.path.endsWith('.png')) {
         encoding = 'image/png'
       } else if (
-        extLink.localThumb.path.endsWith('.jpeg') ||
-        extLink.localThumb.path.endsWith('.jpg')
+        opts.extLink.localThumb.path.endsWith('.jpeg') ||
+        opts.extLink.localThumb.path.endsWith('.jpg')
       ) {
         encoding = 'image/jpeg'
       } else {
         store.log.warn(
           'Unexpected image format for thumbnail, skipping',
-          extLink.localThumb.path,
+          opts.extLink.localThumb.path,
         )
       }
       if (encoding) {
         const thumbUploadRes = await uploadBlob(
           store,
-          extLink.localThumb.path,
+          opts.extLink.localThumb.path,
           encoding,
         )
         thumb = {
@@ -129,16 +145,16 @@ export async function post(
     embed = {
       $type: 'app.bsky.embed.external',
       external: {
-        uri: extLink.uri,
-        title: extLink.meta?.title || '',
-        description: extLink.meta?.description || '',
+        uri: opts.extLink.uri,
+        title: opts.extLink.meta?.title || '',
+        description: opts.extLink.meta?.description || '',
         thumb,
       },
     } as AppBskyEmbedExternal.Main
   }
 
-  if (replyTo) {
-    const replyToUrip = new AtUri(replyTo)
+  if (opts.replyTo) {
+    const replyToUrip = new AtUri(opts.replyTo)
     const parentPost = await store.api.app.bsky.feed.post.get({
       user: replyToUrip.host,
       rkey: replyToUrip.rkey,
@@ -156,7 +172,7 @@ export async function post(
   }
 
   try {
-    onStateChange?.('Posting...')
+    opts.onStateChange?.('Posting...')
     return await store.api.app.bsky.feed.post.create(
       {did: store.me.did || ''},
       {
diff --git a/src/state/models/shell-ui.ts b/src/state/models/shell-ui.ts
index 640bed0b3..1b0e350a2 100644
--- a/src/state/models/shell-ui.ts
+++ b/src/state/models/shell-ui.ts
@@ -44,6 +44,13 @@ export interface DeleteAccountModal {
   name: 'delete-account'
 }
 
+export interface RepostModal {
+  name: 'repost'
+  onRepost: () => void
+  onQuote: () => void
+  isReposted: boolean
+}
+
 export type Modal =
   | ConfirmModal
   | EditProfileModal
@@ -52,6 +59,7 @@ export type Modal =
   | ReportAccountModal
   | CropImageModal
   | DeleteAccountModal
+  | RepostModal
 
 interface LightboxModel {}
 
@@ -82,10 +90,22 @@ export interface ComposerOptsPostRef {
     avatar?: string
   }
 }
+export interface ComposerOptsQuote {
+  uri: string
+  cid: string
+  text: string
+  indexedAt: string
+  author: {
+    handle: string
+    displayName?: string
+    avatar?: string
+  }
+}
 export interface ComposerOpts {
   imagesOpen?: boolean
   replyTo?: ComposerOptsPostRef
   onPost?: () => void
+  quote?: ComposerOptsQuote
 }
 
 export class ShellUiModel {
diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/ComposePost.tsx
index e3befafff..ad6a8ec66 100644
--- a/src/view/com/composer/ComposePost.tsx
+++ b/src/view/com/composer/ComposePost.tsx
@@ -48,6 +48,7 @@ import {
   POST_IMG_MAX_SIZE,
 } from 'lib/constants'
 import {isWeb} from 'platform/detection'
+import QuoteEmbed from '../util/PostEmbeds/QuoteEmbed'
 
 const MAX_TEXT_LENGTH = 256
 const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
@@ -62,11 +63,13 @@ export const ComposePost = observer(function ComposePost({
   imagesOpen,
   onPost,
   onClose,
+  quote,
 }: {
   replyTo?: ComposerOpts['replyTo']
   imagesOpen?: ComposerOpts['imagesOpen']
   onPost?: ComposerOpts['onPost']
   onClose: () => void
+  quote?: ComposerOpts['quote']
 }) {
   const {track} = useAnalytics()
   const pal = usePalette('default')
@@ -280,15 +283,15 @@ export const ComposePost = observer(function ComposePost({
     }
     setIsProcessing(true)
     try {
-      await apilib.post(
-        store,
-        text,
-        replyTo?.uri,
-        extLink,
-        selectedPhotos,
-        autocompleteView.knownHandles,
-        setProcessingState,
-      )
+      await apilib.post(store, {
+        rawText: text,
+        replyTo: replyTo?.uri,
+        images: selectedPhotos,
+        quote: quote,
+        extLink: extLink,
+        onStateChange: setProcessingState,
+        knownHandles: autocompleteView.knownHandles,
+      })
       track('Create Post', {
         imageCount: selectedPhotos.length,
       })
@@ -418,6 +421,7 @@ export const ComposePost = observer(function ComposePost({
                 </View>
               </View>
             ) : undefined}
+
             <View
               style={[
                 pal.border,
@@ -445,6 +449,13 @@ export const ComposePost = observer(function ComposePost({
                 {textDecorated}
               </TextInput>
             </View>
+
+            {quote ? (
+              <View style={s.mt5}>
+                <QuoteEmbed quote={quote} />
+              </View>
+            ) : undefined}
+
             <SelectedPhoto
               selectedPhotos={selectedPhotos}
               onSelectPhotos={onSelectPhotos}
@@ -463,7 +474,8 @@ export const ComposePost = observer(function ComposePost({
             />
           ) : !extLink &&
             selectedPhotos.length === 0 &&
-            suggestedExtLinks.size > 0 ? (
+            suggestedExtLinks.size > 0 &&
+            !quote ? (
             <View style={s.mb5}>
               {Array.from(suggestedExtLinks).map(url => (
                 <TouchableOpacity
@@ -478,21 +490,23 @@ export const ComposePost = observer(function ComposePost({
             </View>
           ) : null}
           <View style={[pal.border, styles.bottomBar]}>
-            <TouchableOpacity
-              testID="composerSelectPhotosButton"
-              onPress={onPressSelectPhotos}
-              style={[s.pl5]}
-              hitSlop={HITSLOP}>
-              <FontAwesomeIcon
-                icon={['far', 'image']}
-                style={
-                  (selectedPhotos.length < 4
-                    ? pal.link
-                    : pal.textLight) as FontAwesomeIconStyle
-                }
-                size={24}
-              />
-            </TouchableOpacity>
+            {quote ? undefined : (
+              <TouchableOpacity
+                testID="composerSelectPhotosButton"
+                onPress={onPressSelectPhotos}
+                style={[s.pl5]}
+                hitSlop={HITSLOP}>
+                <FontAwesomeIcon
+                  icon={['far', 'image']}
+                  style={
+                    (selectedPhotos.length < 4
+                      ? pal.link
+                      : pal.textLight) as FontAwesomeIconStyle
+                  }
+                  size={24}
+                />
+              </TouchableOpacity>
+            )}
             <View style={s.flex1} />
             <CharProgress count={text.length} />
           </View>
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 2b4457c4b..f939442ba 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -9,6 +9,7 @@ import * as ConfirmModal from './Confirm'
 import * as EditProfileModal from './EditProfile'
 import * as ServerInputModal from './ServerInput'
 import * as ReportPostModal from './ReportPost'
+import * as RepostModal from './Repost'
 import * as ReportAccountModal from './ReportAccount'
 import * as DeleteAccountModal from './DeleteAccount'
 import {usePalette} from 'lib/hooks/usePalette'
@@ -61,6 +62,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
   } else if (activeModal?.name === 'delete-account') {
     snapPoints = DeleteAccountModal.snapPoints
     element = <DeleteAccountModal.Component />
+  } else if (activeModal?.name === 'repost') {
+    snapPoints = RepostModal.snapPoints
+    element = <RepostModal.Component {...activeModal} />
   } else {
     element = <View />
   }
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 38b526d29..b10b60be8 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -10,6 +10,7 @@ import * as EditProfileModal from './EditProfile'
 import * as ServerInputModal from './ServerInput'
 import * as ReportPostModal from './ReportPost'
 import * as ReportAccountModal from './ReportAccount'
+import * as RepostModal from './Repost'
 import * as CropImageModal from './crop-image/CropImage.web'
 
 export const ModalsContainer = observer(function ModalsContainer() {
@@ -59,6 +60,8 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <ReportAccountModal.Component {...modal} />
   } else if (modal.name === 'crop-image') {
     element = <CropImageModal.Component {...modal} />
+  } else if (modal.name === 'repost') {
+    element = <RepostModal.Component {...modal} />
   } else {
     return null
   }
diff --git a/src/view/com/modals/Repost.tsx b/src/view/com/modals/Repost.tsx
new file mode 100644
index 000000000..6ab15317b
--- /dev/null
+++ b/src/view/com/modals/Repost.tsx
@@ -0,0 +1,90 @@
+import React from 'react'
+import {StyleSheet, TouchableOpacity, View} from 'react-native'
+import LinearGradient from 'react-native-linear-gradient'
+import {useStores} from 'state/index'
+import {s, colors, gradients} from 'lib/styles'
+import {Text} from '../util/text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {RepostIcon} from 'lib/icons'
+import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
+
+export const snapPoints = [250]
+
+export function Component({
+  onRepost,
+  onQuote,
+  isReposted,
+}: {
+  onRepost: () => void
+  onQuote: () => void
+  isReposted: boolean
+}) {
+  const store = useStores()
+  const pal = usePalette('default')
+  const onPress = async () => {
+    store.shell.closeModal()
+  }
+
+  return (
+    <View style={[s.flex1, pal.view, styles.container]}>
+      <View style={s.pb20}>
+        <TouchableOpacity style={[styles.actionBtn]} onPress={onRepost}>
+          <RepostIcon strokeWidth={2} size={24} />
+          <Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}>
+            {!isReposted ? 'Repost' : 'Undo repost'}
+          </Text>
+        </TouchableOpacity>
+        <TouchableOpacity style={[styles.actionBtn]} onPress={onQuote}>
+          <FontAwesomeIcon icon="quote-left" size={24} style={s.blue3} />
+          <Text type="title-lg" style={[styles.actionBtnLabel, pal.text]}>
+            Quote Post
+          </Text>
+        </TouchableOpacity>
+      </View>
+      <TouchableOpacity onPress={onPress}>
+        <LinearGradient
+          colors={[gradients.blueLight.start, gradients.blueLight.end]}
+          start={{x: 0, y: 0}}
+          end={{x: 1, y: 1}}
+          style={[styles.btn]}>
+          <Text style={[s.white, s.bold, s.f18]}>Cancel</Text>
+        </LinearGradient>
+      </TouchableOpacity>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    paddingHorizontal: 30,
+  },
+  title: {
+    textAlign: 'center',
+    fontWeight: 'bold',
+    fontSize: 24,
+    marginBottom: 12,
+  },
+  description: {
+    textAlign: 'center',
+    fontSize: 17,
+    paddingHorizontal: 22,
+    marginBottom: 10,
+  },
+  btn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    width: '100%',
+    borderRadius: 32,
+    padding: 14,
+    backgroundColor: colors.gray1,
+  },
+  actionBtn: {
+    flexDirection: 'row',
+    alignItems: 'center',
+  },
+  actionBtnLabel: {
+    paddingHorizontal: 14,
+    paddingVertical: 16,
+  },
+})
diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx
index 65bae0192..a95d91795 100644
--- a/src/view/com/post-thread/PostThreadItem.tsx
+++ b/src/view/com/post-thread/PostThreadItem.tsx
@@ -245,6 +245,13 @@ export const PostThreadItem = observer(function PostThreadItem({
                 itemCid={itemCid}
                 itemHref={itemHref}
                 itemTitle={itemTitle}
+                author={{
+                  avatar: item.post.author.avatar!,
+                  handle: item.post.author.handle,
+                  displayName: item.post.author.displayName!,
+                }}
+                text={item.richText?.text || record.text}
+                indexedAt={item.post.indexedAt}
                 isAuthor={item.post.author.did === store.me.did}
                 isReposted={!!item.post.viewer.repost}
                 isUpvoted={!!item.post.viewer.upvote}
@@ -329,6 +336,13 @@ export const PostThreadItem = observer(function PostThreadItem({
                 itemCid={itemCid}
                 itemHref={itemHref}
                 itemTitle={itemTitle}
+                author={{
+                  avatar: item.post.author.avatar!,
+                  handle: item.post.author.handle,
+                  displayName: item.post.author.displayName!,
+                }}
+                text={item.richText?.text || record.text}
+                indexedAt={item.post.indexedAt}
                 isAuthor={item.post.author.did === store.me.did}
                 replyCount={item.post.replyCount}
                 repostCount={item.post.repostCount}
diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx
index c0ff95416..e8e6781f7 100644
--- a/src/view/com/post/Post.tsx
+++ b/src/view/com/post/Post.tsx
@@ -197,6 +197,13 @@ export const Post = observer(function Post({
             itemCid={itemCid}
             itemHref={itemHref}
             itemTitle={itemTitle}
+            author={{
+              avatar: item.post.author.avatar!,
+              handle: item.post.author.handle,
+              displayName: item.post.author.displayName!,
+            }}
+            indexedAt={item.post.indexedAt}
+            text={item.richText?.text || record.text}
             isAuthor={item.post.author.did === store.me.did}
             replyCount={item.post.replyCount}
             repostCount={item.post.repostCount}
diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx
index c3e9f61fa..1847827c3 100644
--- a/src/view/com/posts/FeedItem.tsx
+++ b/src/view/com/posts/FeedItem.tsx
@@ -226,6 +226,13 @@ export const FeedItem = observer(function ({
               itemCid={itemCid}
               itemHref={itemHref}
               itemTitle={itemTitle}
+              author={{
+                avatar: item.post.author.avatar!,
+                handle: item.post.author.handle,
+                displayName: item.post.author.displayName!,
+              }}
+              text={item.richText?.text || record.text}
+              indexedAt={item.post.indexedAt}
               isAuthor={item.post.author.did === store.me.did}
               replyCount={item.post.replyCount}
               repostCount={item.post.repostCount}
diff --git a/src/view/com/util/PostCtrls.tsx b/src/view/com/util/PostCtrls.tsx
index e42c5e63b..cb4dfab26 100644
--- a/src/view/com/util/PostCtrls.tsx
+++ b/src/view/com/util/PostCtrls.tsx
@@ -26,6 +26,7 @@ import {
 } from 'lib/icons'
 import {s, colors} from 'lib/styles'
 import {useTheme} from 'lib/ThemeContext'
+import {useStores} from 'state/index'
 
 interface PostCtrlsOpts {
   itemUri: string
@@ -33,6 +34,13 @@ interface PostCtrlsOpts {
   itemHref: string
   itemTitle: string
   isAuthor: boolean
+  author: {
+    handle: string
+    displayName: string
+    avatar: string
+  }
+  text: string
+  indexedAt: string
   big?: boolean
   style?: StyleProp<ViewStyle>
   replyCount?: number
@@ -86,6 +94,7 @@ function ctrlAnimStyle(interp: Animated.Value) {
 */
 
 export function PostCtrls(opts: PostCtrlsOpts) {
+  const store = useStores()
   const theme = useTheme()
   const defaultCtrlColor = React.useMemo(
     () => ({
@@ -98,7 +107,8 @@ export function PostCtrls(opts: PostCtrlsOpts) {
   // DISABLED see #135
   // const repostRef = React.useRef<TriggerableAnimatedRef | null>(null)
   // const likeRef = React.useRef<TriggerableAnimatedRef | null>(null)
-  const onPressToggleRepostWrapper = () => {
+  const onRepost = () => {
+    store.shell.closeModal()
     if (!opts.isReposted) {
       ReactNativeHapticFeedback.trigger('impactMedium')
       setRepostMod(1)
@@ -122,6 +132,30 @@ export function PostCtrls(opts: PostCtrlsOpts) {
         .then(() => setRepostMod(0))
     }
   }
+
+  const onQuote = () => {
+    store.shell.closeModal()
+    store.shell.openComposer({
+      quote: {
+        uri: opts.itemUri,
+        cid: opts.itemCid,
+        text: opts.text,
+        author: opts.author,
+        indexedAt: opts.indexedAt,
+      },
+    })
+    ReactNativeHapticFeedback.trigger('impactMedium')
+  }
+
+  const onPressToggleRepostWrapper = () => {
+    store.shell.openModal({
+      name: 'repost',
+      onRepost: onRepost,
+      onQuote: onQuote,
+      isReposted: opts.isReposted,
+    })
+  }
+
   const onPressToggleUpvoteWrapper = () => {
     if (!opts.isUpvoted) {
       ReactNativeHapticFeedback.trigger('impactMedium')
diff --git a/src/view/com/util/PostEmbeds/QuoteEmbed.tsx b/src/view/com/util/PostEmbeds/QuoteEmbed.tsx
new file mode 100644
index 000000000..76b71a53d
--- /dev/null
+++ b/src/view/com/util/PostEmbeds/QuoteEmbed.tsx
@@ -0,0 +1,58 @@
+import {StyleSheet} from 'react-native'
+import React from 'react'
+import {AtUri} from '../../../../third-party/uri'
+import {PostMeta} from '../PostMeta'
+import {Link} from '../Link'
+import {Text} from '../text/Text'
+import {usePalette} from 'lib/hooks/usePalette'
+import {ComposerOptsQuote} from 'state/models/shell-ui'
+
+const QuoteEmbed = ({quote}: {quote: ComposerOptsQuote}) => {
+  const pal = usePalette('default')
+  const itemUrip = new AtUri(quote.uri)
+  const itemHref = `/profile/${quote.author.handle}/post/${itemUrip.rkey}`
+  const itemTitle = `Post by ${quote.author.handle}`
+  const isEmpty = React.useMemo(
+    () => quote.text.trim().length === 0,
+    [quote.text],
+  )
+  return (
+    <Link
+      style={[styles.container, pal.border]}
+      href={itemHref}
+      title={itemTitle}>
+      <PostMeta
+        authorAvatar={quote.author.avatar}
+        authorHandle={quote.author.handle}
+        authorDisplayName={quote.author.displayName}
+        timestamp={quote.indexedAt}
+      />
+      <Text type="post-text" style={pal.text} numberOfLines={6}>
+        {isEmpty ? (
+          <Text style={pal.link} lineHeight={1.5}>
+            View post
+          </Text>
+        ) : (
+          quote.text
+        )}
+      </Text>
+    </Link>
+  )
+}
+
+export default QuoteEmbed
+
+const styles = StyleSheet.create({
+  container: {
+    borderRadius: 8,
+    paddingVertical: 8,
+    paddingHorizontal: 12,
+    marginVertical: 8,
+    borderWidth: 1,
+  },
+  quotePost: {
+    flex: 1,
+    paddingLeft: 13,
+    paddingRight: 8,
+  },
+})
diff --git a/src/view/com/util/PostEmbeds/index.tsx b/src/view/com/util/PostEmbeds/index.tsx
index d2186b600..3d3356712 100644
--- a/src/view/com/util/PostEmbeds/index.tsx
+++ b/src/view/com/util/PostEmbeds/index.tsx
@@ -6,7 +6,12 @@ import {
   ViewStyle,
   Image as RNImage,
 } from 'react-native'
-import {AppBskyEmbedImages, AppBskyEmbedExternal} from '@atproto/api'
+import {
+  AppBskyEmbedImages,
+  AppBskyEmbedExternal,
+  AppBskyEmbedRecord,
+  AppBskyFeedPost,
+} from '@atproto/api'
 import {Link} from '../Link'
 import {AutoSizedImage} from '../images/AutoSizedImage'
 import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
@@ -17,8 +22,10 @@ import {saveImageModal} from 'lib/media/manip'
 import YoutubeEmbed from './YoutubeEmbed'
 import ExternalLinkEmbed from './ExternalLinkEmbed'
 import {getYoutubeVideoId} from 'lib/strings/url-helpers'
+import QuoteEmbed from './QuoteEmbed'
 
 type Embed =
+  | AppBskyEmbedRecord.Presented
   | AppBskyEmbedImages.Presented
   | AppBskyEmbedExternal.Presented
   | {$type: string; [k: string]: unknown}
@@ -32,6 +39,25 @@ export function PostEmbeds({
 }) {
   const pal = usePalette('default')
   const store = useStores()
+  if (AppBskyEmbedRecord.isPresented(embed)) {
+    if (
+      AppBskyEmbedRecord.isPresentedRecord(embed.record) &&
+      AppBskyFeedPost.isRecord(embed.record.record) &&
+      AppBskyFeedPost.validateRecord(embed.record.record).success
+    ) {
+      return (
+        <QuoteEmbed
+          quote={{
+            author: embed.record.author,
+            cid: embed.record.cid,
+            uri: embed.record.uri,
+            indexedAt: embed.record.record.createdAt, // TODO
+            text: embed.record.record.text,
+          }}
+        />
+      )
+    }
+  }
   if (AppBskyEmbedImages.isPresented(embed)) {
     if (embed.images.length > 0) {
       const uris = embed.images.map(img => img.fullsize)
diff --git a/src/view/com/util/PostMeta.tsx b/src/view/com/util/PostMeta.tsx
index a07d91899..0c5d41cab 100644
--- a/src/view/com/util/PostMeta.tsx
+++ b/src/view/com/util/PostMeta.tsx
@@ -4,15 +4,17 @@ import {Text} from './text/Text'
 import {ago} from 'lib/strings/time'
 import {usePalette} from 'lib/hooks/usePalette'
 import {useStores} from 'state/index'
+import {UserAvatar} from './UserAvatar'
 import {observer} from 'mobx-react-lite'
 import FollowButton from '../profile/FollowButton'
 
 interface PostMetaOpts {
+  authorAvatar: string | undefined
   authorHandle: string
   authorDisplayName: string | undefined
   timestamp: string
-  did: string
-  declarationCid: string
+  did?: string
+  declarationCid?: string
   showFollowBtn?: boolean
 }
 
@@ -27,11 +29,18 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
   //      don't change this UI immediately, but rather upon future
   //      renders
   const isFollowing = React.useMemo(
-    () => store.me.follows.isFollowing(opts.did),
+    () =>
+      typeof opts.did === 'string' && store.me.follows.isFollowing(opts.did),
     [opts.did, store.me.follows],
   )
 
-  if (opts.showFollowBtn && !isMe && !isFollowing) {
+  if (
+    opts.showFollowBtn &&
+    !isMe &&
+    !isFollowing &&
+    opts.did &&
+    opts.declarationCid
+  ) {
     // two-liner with follow button
     return (
       <View style={[styles.metaTwoLine]}>
@@ -71,6 +80,16 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
   // one-liner
   return (
     <View style={styles.meta}>
+      {typeof opts.authorAvatar !== 'undefined' && (
+        <View style={[styles.metaItem, styles.avatar]}>
+          <UserAvatar
+            avatar={opts.authorAvatar}
+            handle={opts.authorHandle}
+            displayName={opts.authorDisplayName}
+            size={16}
+          />
+        </View>
+      )}
       <View style={[styles.metaItem, styles.maxWidth]}>
         <Text
           type="lg-bold"
@@ -107,6 +126,9 @@ const styles = StyleSheet.create({
   metaItem: {
     paddingRight: 5,
   },
+  avatar: {
+    alignSelf: 'center',
+  },
   maxWidth: {
     maxWidth: '80%',
   },
diff --git a/src/view/index.ts b/src/view/index.ts
index 7cd2c1dfd..c2ad84671 100644
--- a/src/view/index.ts
+++ b/src/view/index.ts
@@ -8,7 +8,10 @@ import {faAngleUp} from '@fortawesome/free-solid-svg-icons/faAngleUp'
 import {faArrowLeft} from '@fortawesome/free-solid-svg-icons/faArrowLeft'
 import {faArrowRight} from '@fortawesome/free-solid-svg-icons/faArrowRight'
 import {faArrowUp} from '@fortawesome/free-solid-svg-icons/faArrowUp'
-import {faArrowRightFromBracket} from '@fortawesome/free-solid-svg-icons'
+import {
+  faArrowRightFromBracket,
+  faQuoteLeft,
+} from '@fortawesome/free-solid-svg-icons'
 import {faArrowUpFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowUpFromBracket'
 import {faArrowUpRightFromSquare} from '@fortawesome/free-solid-svg-icons/faArrowUpRightFromSquare'
 import {faArrowsRotate} from '@fortawesome/free-solid-svg-icons/faArrowsRotate'
@@ -100,6 +103,7 @@ export function setup() {
     faEllipsis,
     faEnvelope,
     faExclamation,
+    faQuoteLeft,
     farEyeSlash,
     faGear,
     faGlobe,
diff --git a/src/view/shell/mobile/Composer.tsx b/src/view/shell/mobile/Composer.tsx
index 304c17725..5fca118bd 100644
--- a/src/view/shell/mobile/Composer.tsx
+++ b/src/view/shell/mobile/Composer.tsx
@@ -14,6 +14,7 @@ export const Composer = observer(
     imagesOpen,
     onPost,
     onClose,
+    quote,
   }: {
     active: boolean
     winHeight: number
@@ -21,6 +22,7 @@ export const Composer = observer(
     imagesOpen?: ComposerOpts['imagesOpen']
     onPost?: ComposerOpts['onPost']
     onClose: () => void
+    quote?: ComposerOpts['quote']
   }) => {
     const pal = usePalette('default')
     const initInterp = useAnimatedValue(0)
@@ -62,6 +64,7 @@ export const Composer = observer(
           imagesOpen={imagesOpen}
           onPost={onPost}
           onClose={onClose}
+          quote={quote}
         />
       </Animated.View>
     )
diff --git a/src/view/shell/mobile/index.tsx b/src/view/shell/mobile/index.tsx
index 80403a6de..89a834ee1 100644
--- a/src/view/shell/mobile/index.tsx
+++ b/src/view/shell/mobile/index.tsx
@@ -550,6 +550,7 @@ export const MobileShell: React.FC = observer(() => {
         replyTo={store.shell.composerOpts?.replyTo}
         imagesOpen={store.shell.composerOpts?.imagesOpen}
         onPost={store.shell.composerOpts?.onPost}
+        quote={store.shell.composerOpts?.quote}
       />
     </View>
   )
diff --git a/yarn.lock b/yarn.lock
index 4886fac77..6583e0a9a 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -19,10 +19,10 @@
     jsonpointer "^5.0.0"
     leven "^3.1.0"
 
-"@atproto/api@^0.1.2":
-  version "0.1.2"
-  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.1.2.tgz#66102f9203ba499432bc5aeb30cd19313ab2e4fc"
-  integrity sha512-lDcFGkrk0J7rkIPSie18xS7sO3IL6DsosX8GgoeqCNeVaDuphBRaFCcpBUWf0q4fHrpmdgghGo4ulefyKHTIFQ==
+"@atproto/api@0.1.3":
+  version "0.1.3"
+  resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.1.3.tgz#4aa9ea7caad624a7eda7d22e03f076e4b0fb68fb"
+  integrity sha512-jEtE0Afxnkvth7/dZKYx9Gv1IpO2Jlmb8KzgRVPnyYyolI2GI4VTNs7mxxO/44cs8vKu2PN2zW+64XuaIY1JBA==
   dependencies:
     "@atproto/xrpc" "*"
     typed-emitter "^2.1.0"