73-post-embeds (#253)

* update api to 0.1.3

* add repost modal with reposting functionality

* add quote post UI

* allow creation and view of quote posts

* Validate the post record before rendering a quote post

* Use createdAt in quote posts for now

* add web modal support

* Tune the quote post rendering

* Make did and declarationCid optional in postmeta

* Make did and declarationCid optional in postmeta

* dont allow image or link preview if quote post

* Handle no-text quote posts

* Tune the repost modal

* Tweak composer post text

* Fix lint

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
zio/stable
Ansh 2023-03-02 16:09:48 -08:00 committed by GitHub
parent f539659ac8
commit 75174a6c37
18 changed files with 392 additions and 69 deletions

View File

@ -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",

View File

@ -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 || ''},
{

View File

@ -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 {

View File

@ -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>

View File

@ -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 />
}

View File

@ -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
}

View File

@ -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,
},
})

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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')

View File

@ -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,
},
})

View File

@ -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)

View File

@ -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%',
},

View File

@ -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,

View File

@ -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>
)

View File

@ -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>
)

View File

@ -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"