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
parent
f539659ac8
commit
75174a6c37
|
@ -16,7 +16,7 @@
|
||||||
"e2e": "detox test --configuration ios.sim.debug --take-screenshots all"
|
"e2e": "detox test --configuration ios.sim.debug --take-screenshots all"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atproto/api": "^0.1.2",
|
"@atproto/api": "0.1.3",
|
||||||
"@atproto/lexicon": "^0.0.4",
|
"@atproto/lexicon": "^0.0.4",
|
||||||
"@atproto/xrpc": "^0.0.4",
|
"@atproto/xrpc": "^0.0.4",
|
||||||
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
"@bam.tech/react-native-image-resizer": "^3.0.4",
|
||||||
|
|
|
@ -2,6 +2,7 @@ import {
|
||||||
AppBskyEmbedImages,
|
AppBskyEmbedImages,
|
||||||
AppBskyEmbedExternal,
|
AppBskyEmbedExternal,
|
||||||
ComAtprotoBlobUpload,
|
ComAtprotoBlobUpload,
|
||||||
|
AppBskyEmbedRecord,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
import {AtUri} from '../../third-party/uri'
|
import {AtUri} from '../../third-party/uri'
|
||||||
import {RootStoreModel} from 'state/models/root-store'
|
import {RootStoreModel} from 'state/models/root-store'
|
||||||
|
@ -51,23 +52,32 @@ export async function uploadBlob(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function post(
|
interface PostOpts {
|
||||||
store: RootStoreModel,
|
rawText: string
|
||||||
rawText: string,
|
replyTo?: string
|
||||||
replyTo?: string,
|
quote?: {
|
||||||
extLink?: ExternalEmbedDraft,
|
uri: string
|
||||||
images?: string[],
|
cid: string
|
||||||
knownHandles?: Set<string>,
|
}
|
||||||
onStateChange?: (state: string) => void,
|
extLink?: ExternalEmbedDraft
|
||||||
) {
|
images?: string[]
|
||||||
let embed: AppBskyEmbedImages.Main | AppBskyEmbedExternal.Main | undefined
|
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
|
let reply
|
||||||
const text = new RichText(rawText, undefined, {
|
const text = new RichText(opts.rawText, undefined, {
|
||||||
cleanNewlines: true,
|
cleanNewlines: true,
|
||||||
}).text.trim()
|
}).text.trim()
|
||||||
|
|
||||||
onStateChange?.('Processing...')
|
opts.onStateChange?.('Processing...')
|
||||||
const entities = extractEntities(text, knownHandles)
|
const entities = extractEntities(text, opts.knownHandles)
|
||||||
if (entities) {
|
if (entities) {
|
||||||
for (const ent of entities) {
|
for (const ent of entities) {
|
||||||
if (ent.type === 'mention') {
|
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 = {
|
embed = {
|
||||||
$type: 'app.bsky.embed.images',
|
$type: 'app.bsky.embed.images',
|
||||||
images: [],
|
images: [],
|
||||||
} as AppBskyEmbedImages.Main
|
} as AppBskyEmbedImages.Main
|
||||||
let i = 1
|
let i = 1
|
||||||
for (const image of images) {
|
for (const image of opts.images) {
|
||||||
onStateChange?.(`Uploading image #${i++}...`)
|
opts.onStateChange?.(`Uploading image #${i++}...`)
|
||||||
const res = await uploadBlob(store, image, 'image/jpeg')
|
const res = await uploadBlob(store, image, 'image/jpeg')
|
||||||
embed.images.push({
|
embed.images.push({
|
||||||
image: {
|
image: {
|
||||||
|
@ -94,30 +112,28 @@ export async function post(
|
||||||
alt: '', // TODO supply alt text
|
alt: '', // TODO supply alt text
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
} else if (opts.extLink) {
|
||||||
|
|
||||||
if (!embed && extLink) {
|
|
||||||
let thumb
|
let thumb
|
||||||
if (extLink.localThumb) {
|
if (opts.extLink.localThumb) {
|
||||||
onStateChange?.('Uploading link thumbnail...')
|
opts.onStateChange?.('Uploading link thumbnail...')
|
||||||
let encoding
|
let encoding
|
||||||
if (extLink.localThumb.path.endsWith('.png')) {
|
if (opts.extLink.localThumb.path.endsWith('.png')) {
|
||||||
encoding = 'image/png'
|
encoding = 'image/png'
|
||||||
} else if (
|
} else if (
|
||||||
extLink.localThumb.path.endsWith('.jpeg') ||
|
opts.extLink.localThumb.path.endsWith('.jpeg') ||
|
||||||
extLink.localThumb.path.endsWith('.jpg')
|
opts.extLink.localThumb.path.endsWith('.jpg')
|
||||||
) {
|
) {
|
||||||
encoding = 'image/jpeg'
|
encoding = 'image/jpeg'
|
||||||
} else {
|
} else {
|
||||||
store.log.warn(
|
store.log.warn(
|
||||||
'Unexpected image format for thumbnail, skipping',
|
'Unexpected image format for thumbnail, skipping',
|
||||||
extLink.localThumb.path,
|
opts.extLink.localThumb.path,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (encoding) {
|
if (encoding) {
|
||||||
const thumbUploadRes = await uploadBlob(
|
const thumbUploadRes = await uploadBlob(
|
||||||
store,
|
store,
|
||||||
extLink.localThumb.path,
|
opts.extLink.localThumb.path,
|
||||||
encoding,
|
encoding,
|
||||||
)
|
)
|
||||||
thumb = {
|
thumb = {
|
||||||
|
@ -129,16 +145,16 @@ export async function post(
|
||||||
embed = {
|
embed = {
|
||||||
$type: 'app.bsky.embed.external',
|
$type: 'app.bsky.embed.external',
|
||||||
external: {
|
external: {
|
||||||
uri: extLink.uri,
|
uri: opts.extLink.uri,
|
||||||
title: extLink.meta?.title || '',
|
title: opts.extLink.meta?.title || '',
|
||||||
description: extLink.meta?.description || '',
|
description: opts.extLink.meta?.description || '',
|
||||||
thumb,
|
thumb,
|
||||||
},
|
},
|
||||||
} as AppBskyEmbedExternal.Main
|
} as AppBskyEmbedExternal.Main
|
||||||
}
|
}
|
||||||
|
|
||||||
if (replyTo) {
|
if (opts.replyTo) {
|
||||||
const replyToUrip = new AtUri(replyTo)
|
const replyToUrip = new AtUri(opts.replyTo)
|
||||||
const parentPost = await store.api.app.bsky.feed.post.get({
|
const parentPost = await store.api.app.bsky.feed.post.get({
|
||||||
user: replyToUrip.host,
|
user: replyToUrip.host,
|
||||||
rkey: replyToUrip.rkey,
|
rkey: replyToUrip.rkey,
|
||||||
|
@ -156,7 +172,7 @@ export async function post(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
onStateChange?.('Posting...')
|
opts.onStateChange?.('Posting...')
|
||||||
return await store.api.app.bsky.feed.post.create(
|
return await store.api.app.bsky.feed.post.create(
|
||||||
{did: store.me.did || ''},
|
{did: store.me.did || ''},
|
||||||
{
|
{
|
||||||
|
|
|
@ -44,6 +44,13 @@ export interface DeleteAccountModal {
|
||||||
name: 'delete-account'
|
name: 'delete-account'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RepostModal {
|
||||||
|
name: 'repost'
|
||||||
|
onRepost: () => void
|
||||||
|
onQuote: () => void
|
||||||
|
isReposted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
export type Modal =
|
export type Modal =
|
||||||
| ConfirmModal
|
| ConfirmModal
|
||||||
| EditProfileModal
|
| EditProfileModal
|
||||||
|
@ -52,6 +59,7 @@ export type Modal =
|
||||||
| ReportAccountModal
|
| ReportAccountModal
|
||||||
| CropImageModal
|
| CropImageModal
|
||||||
| DeleteAccountModal
|
| DeleteAccountModal
|
||||||
|
| RepostModal
|
||||||
|
|
||||||
interface LightboxModel {}
|
interface LightboxModel {}
|
||||||
|
|
||||||
|
@ -82,10 +90,22 @@ export interface ComposerOptsPostRef {
|
||||||
avatar?: string
|
avatar?: string
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export interface ComposerOptsQuote {
|
||||||
|
uri: string
|
||||||
|
cid: string
|
||||||
|
text: string
|
||||||
|
indexedAt: string
|
||||||
|
author: {
|
||||||
|
handle: string
|
||||||
|
displayName?: string
|
||||||
|
avatar?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
export interface ComposerOpts {
|
export interface ComposerOpts {
|
||||||
imagesOpen?: boolean
|
imagesOpen?: boolean
|
||||||
replyTo?: ComposerOptsPostRef
|
replyTo?: ComposerOptsPostRef
|
||||||
onPost?: () => void
|
onPost?: () => void
|
||||||
|
quote?: ComposerOptsQuote
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ShellUiModel {
|
export class ShellUiModel {
|
||||||
|
|
|
@ -48,6 +48,7 @@ import {
|
||||||
POST_IMG_MAX_SIZE,
|
POST_IMG_MAX_SIZE,
|
||||||
} from 'lib/constants'
|
} from 'lib/constants'
|
||||||
import {isWeb} from 'platform/detection'
|
import {isWeb} from 'platform/detection'
|
||||||
|
import QuoteEmbed from '../util/PostEmbeds/QuoteEmbed'
|
||||||
|
|
||||||
const MAX_TEXT_LENGTH = 256
|
const MAX_TEXT_LENGTH = 256
|
||||||
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
|
const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10}
|
||||||
|
@ -62,11 +63,13 @@ export const ComposePost = observer(function ComposePost({
|
||||||
imagesOpen,
|
imagesOpen,
|
||||||
onPost,
|
onPost,
|
||||||
onClose,
|
onClose,
|
||||||
|
quote,
|
||||||
}: {
|
}: {
|
||||||
replyTo?: ComposerOpts['replyTo']
|
replyTo?: ComposerOpts['replyTo']
|
||||||
imagesOpen?: ComposerOpts['imagesOpen']
|
imagesOpen?: ComposerOpts['imagesOpen']
|
||||||
onPost?: ComposerOpts['onPost']
|
onPost?: ComposerOpts['onPost']
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
quote?: ComposerOpts['quote']
|
||||||
}) {
|
}) {
|
||||||
const {track} = useAnalytics()
|
const {track} = useAnalytics()
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
|
@ -280,15 +283,15 @@ export const ComposePost = observer(function ComposePost({
|
||||||
}
|
}
|
||||||
setIsProcessing(true)
|
setIsProcessing(true)
|
||||||
try {
|
try {
|
||||||
await apilib.post(
|
await apilib.post(store, {
|
||||||
store,
|
rawText: text,
|
||||||
text,
|
replyTo: replyTo?.uri,
|
||||||
replyTo?.uri,
|
images: selectedPhotos,
|
||||||
extLink,
|
quote: quote,
|
||||||
selectedPhotos,
|
extLink: extLink,
|
||||||
autocompleteView.knownHandles,
|
onStateChange: setProcessingState,
|
||||||
setProcessingState,
|
knownHandles: autocompleteView.knownHandles,
|
||||||
)
|
})
|
||||||
track('Create Post', {
|
track('Create Post', {
|
||||||
imageCount: selectedPhotos.length,
|
imageCount: selectedPhotos.length,
|
||||||
})
|
})
|
||||||
|
@ -418,6 +421,7 @@ export const ComposePost = observer(function ComposePost({
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
|
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
pal.border,
|
pal.border,
|
||||||
|
@ -445,6 +449,13 @@ export const ComposePost = observer(function ComposePost({
|
||||||
{textDecorated}
|
{textDecorated}
|
||||||
</TextInput>
|
</TextInput>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{quote ? (
|
||||||
|
<View style={s.mt5}>
|
||||||
|
<QuoteEmbed quote={quote} />
|
||||||
|
</View>
|
||||||
|
) : undefined}
|
||||||
|
|
||||||
<SelectedPhoto
|
<SelectedPhoto
|
||||||
selectedPhotos={selectedPhotos}
|
selectedPhotos={selectedPhotos}
|
||||||
onSelectPhotos={onSelectPhotos}
|
onSelectPhotos={onSelectPhotos}
|
||||||
|
@ -463,7 +474,8 @@ export const ComposePost = observer(function ComposePost({
|
||||||
/>
|
/>
|
||||||
) : !extLink &&
|
) : !extLink &&
|
||||||
selectedPhotos.length === 0 &&
|
selectedPhotos.length === 0 &&
|
||||||
suggestedExtLinks.size > 0 ? (
|
suggestedExtLinks.size > 0 &&
|
||||||
|
!quote ? (
|
||||||
<View style={s.mb5}>
|
<View style={s.mb5}>
|
||||||
{Array.from(suggestedExtLinks).map(url => (
|
{Array.from(suggestedExtLinks).map(url => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
@ -478,21 +490,23 @@ export const ComposePost = observer(function ComposePost({
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
<View style={[pal.border, styles.bottomBar]}>
|
<View style={[pal.border, styles.bottomBar]}>
|
||||||
<TouchableOpacity
|
{quote ? undefined : (
|
||||||
testID="composerSelectPhotosButton"
|
<TouchableOpacity
|
||||||
onPress={onPressSelectPhotos}
|
testID="composerSelectPhotosButton"
|
||||||
style={[s.pl5]}
|
onPress={onPressSelectPhotos}
|
||||||
hitSlop={HITSLOP}>
|
style={[s.pl5]}
|
||||||
<FontAwesomeIcon
|
hitSlop={HITSLOP}>
|
||||||
icon={['far', 'image']}
|
<FontAwesomeIcon
|
||||||
style={
|
icon={['far', 'image']}
|
||||||
(selectedPhotos.length < 4
|
style={
|
||||||
? pal.link
|
(selectedPhotos.length < 4
|
||||||
: pal.textLight) as FontAwesomeIconStyle
|
? pal.link
|
||||||
}
|
: pal.textLight) as FontAwesomeIconStyle
|
||||||
size={24}
|
}
|
||||||
/>
|
size={24}
|
||||||
</TouchableOpacity>
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
<View style={s.flex1} />
|
<View style={s.flex1} />
|
||||||
<CharProgress count={text.length} />
|
<CharProgress count={text.length} />
|
||||||
</View>
|
</View>
|
||||||
|
|
|
@ -9,6 +9,7 @@ import * as ConfirmModal from './Confirm'
|
||||||
import * as EditProfileModal from './EditProfile'
|
import * as EditProfileModal from './EditProfile'
|
||||||
import * as ServerInputModal from './ServerInput'
|
import * as ServerInputModal from './ServerInput'
|
||||||
import * as ReportPostModal from './ReportPost'
|
import * as ReportPostModal from './ReportPost'
|
||||||
|
import * as RepostModal from './Repost'
|
||||||
import * as ReportAccountModal from './ReportAccount'
|
import * as ReportAccountModal from './ReportAccount'
|
||||||
import * as DeleteAccountModal from './DeleteAccount'
|
import * as DeleteAccountModal from './DeleteAccount'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
|
@ -61,6 +62,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
|
||||||
} else if (activeModal?.name === 'delete-account') {
|
} else if (activeModal?.name === 'delete-account') {
|
||||||
snapPoints = DeleteAccountModal.snapPoints
|
snapPoints = DeleteAccountModal.snapPoints
|
||||||
element = <DeleteAccountModal.Component />
|
element = <DeleteAccountModal.Component />
|
||||||
|
} else if (activeModal?.name === 'repost') {
|
||||||
|
snapPoints = RepostModal.snapPoints
|
||||||
|
element = <RepostModal.Component {...activeModal} />
|
||||||
} else {
|
} else {
|
||||||
element = <View />
|
element = <View />
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,6 +10,7 @@ import * as EditProfileModal from './EditProfile'
|
||||||
import * as ServerInputModal from './ServerInput'
|
import * as ServerInputModal from './ServerInput'
|
||||||
import * as ReportPostModal from './ReportPost'
|
import * as ReportPostModal from './ReportPost'
|
||||||
import * as ReportAccountModal from './ReportAccount'
|
import * as ReportAccountModal from './ReportAccount'
|
||||||
|
import * as RepostModal from './Repost'
|
||||||
import * as CropImageModal from './crop-image/CropImage.web'
|
import * as CropImageModal from './crop-image/CropImage.web'
|
||||||
|
|
||||||
export const ModalsContainer = observer(function ModalsContainer() {
|
export const ModalsContainer = observer(function ModalsContainer() {
|
||||||
|
@ -59,6 +60,8 @@ function Modal({modal}: {modal: ModalIface}) {
|
||||||
element = <ReportAccountModal.Component {...modal} />
|
element = <ReportAccountModal.Component {...modal} />
|
||||||
} else if (modal.name === 'crop-image') {
|
} else if (modal.name === 'crop-image') {
|
||||||
element = <CropImageModal.Component {...modal} />
|
element = <CropImageModal.Component {...modal} />
|
||||||
|
} else if (modal.name === 'repost') {
|
||||||
|
element = <RepostModal.Component {...modal} />
|
||||||
} else {
|
} else {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
})
|
|
@ -245,6 +245,13 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||||
itemCid={itemCid}
|
itemCid={itemCid}
|
||||||
itemHref={itemHref}
|
itemHref={itemHref}
|
||||||
itemTitle={itemTitle}
|
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}
|
isAuthor={item.post.author.did === store.me.did}
|
||||||
isReposted={!!item.post.viewer.repost}
|
isReposted={!!item.post.viewer.repost}
|
||||||
isUpvoted={!!item.post.viewer.upvote}
|
isUpvoted={!!item.post.viewer.upvote}
|
||||||
|
@ -329,6 +336,13 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||||
itemCid={itemCid}
|
itemCid={itemCid}
|
||||||
itemHref={itemHref}
|
itemHref={itemHref}
|
||||||
itemTitle={itemTitle}
|
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}
|
isAuthor={item.post.author.did === store.me.did}
|
||||||
replyCount={item.post.replyCount}
|
replyCount={item.post.replyCount}
|
||||||
repostCount={item.post.repostCount}
|
repostCount={item.post.repostCount}
|
||||||
|
|
|
@ -197,6 +197,13 @@ export const Post = observer(function Post({
|
||||||
itemCid={itemCid}
|
itemCid={itemCid}
|
||||||
itemHref={itemHref}
|
itemHref={itemHref}
|
||||||
itemTitle={itemTitle}
|
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}
|
isAuthor={item.post.author.did === store.me.did}
|
||||||
replyCount={item.post.replyCount}
|
replyCount={item.post.replyCount}
|
||||||
repostCount={item.post.repostCount}
|
repostCount={item.post.repostCount}
|
||||||
|
|
|
@ -226,6 +226,13 @@ export const FeedItem = observer(function ({
|
||||||
itemCid={itemCid}
|
itemCid={itemCid}
|
||||||
itemHref={itemHref}
|
itemHref={itemHref}
|
||||||
itemTitle={itemTitle}
|
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}
|
isAuthor={item.post.author.did === store.me.did}
|
||||||
replyCount={item.post.replyCount}
|
replyCount={item.post.replyCount}
|
||||||
repostCount={item.post.repostCount}
|
repostCount={item.post.repostCount}
|
||||||
|
|
|
@ -26,6 +26,7 @@ import {
|
||||||
} from 'lib/icons'
|
} from 'lib/icons'
|
||||||
import {s, colors} from 'lib/styles'
|
import {s, colors} from 'lib/styles'
|
||||||
import {useTheme} from 'lib/ThemeContext'
|
import {useTheme} from 'lib/ThemeContext'
|
||||||
|
import {useStores} from 'state/index'
|
||||||
|
|
||||||
interface PostCtrlsOpts {
|
interface PostCtrlsOpts {
|
||||||
itemUri: string
|
itemUri: string
|
||||||
|
@ -33,6 +34,13 @@ interface PostCtrlsOpts {
|
||||||
itemHref: string
|
itemHref: string
|
||||||
itemTitle: string
|
itemTitle: string
|
||||||
isAuthor: boolean
|
isAuthor: boolean
|
||||||
|
author: {
|
||||||
|
handle: string
|
||||||
|
displayName: string
|
||||||
|
avatar: string
|
||||||
|
}
|
||||||
|
text: string
|
||||||
|
indexedAt: string
|
||||||
big?: boolean
|
big?: boolean
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
replyCount?: number
|
replyCount?: number
|
||||||
|
@ -86,6 +94,7 @@ function ctrlAnimStyle(interp: Animated.Value) {
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function PostCtrls(opts: PostCtrlsOpts) {
|
export function PostCtrls(opts: PostCtrlsOpts) {
|
||||||
|
const store = useStores()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const defaultCtrlColor = React.useMemo(
|
const defaultCtrlColor = React.useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
@ -98,7 +107,8 @@ export function PostCtrls(opts: PostCtrlsOpts) {
|
||||||
// DISABLED see #135
|
// DISABLED see #135
|
||||||
// const repostRef = React.useRef<TriggerableAnimatedRef | null>(null)
|
// const repostRef = React.useRef<TriggerableAnimatedRef | null>(null)
|
||||||
// const likeRef = React.useRef<TriggerableAnimatedRef | null>(null)
|
// const likeRef = React.useRef<TriggerableAnimatedRef | null>(null)
|
||||||
const onPressToggleRepostWrapper = () => {
|
const onRepost = () => {
|
||||||
|
store.shell.closeModal()
|
||||||
if (!opts.isReposted) {
|
if (!opts.isReposted) {
|
||||||
ReactNativeHapticFeedback.trigger('impactMedium')
|
ReactNativeHapticFeedback.trigger('impactMedium')
|
||||||
setRepostMod(1)
|
setRepostMod(1)
|
||||||
|
@ -122,6 +132,30 @@ export function PostCtrls(opts: PostCtrlsOpts) {
|
||||||
.then(() => setRepostMod(0))
|
.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 = () => {
|
const onPressToggleUpvoteWrapper = () => {
|
||||||
if (!opts.isUpvoted) {
|
if (!opts.isUpvoted) {
|
||||||
ReactNativeHapticFeedback.trigger('impactMedium')
|
ReactNativeHapticFeedback.trigger('impactMedium')
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
})
|
|
@ -6,7 +6,12 @@ import {
|
||||||
ViewStyle,
|
ViewStyle,
|
||||||
Image as RNImage,
|
Image as RNImage,
|
||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import {AppBskyEmbedImages, AppBskyEmbedExternal} from '@atproto/api'
|
import {
|
||||||
|
AppBskyEmbedImages,
|
||||||
|
AppBskyEmbedExternal,
|
||||||
|
AppBskyEmbedRecord,
|
||||||
|
AppBskyFeedPost,
|
||||||
|
} from '@atproto/api'
|
||||||
import {Link} from '../Link'
|
import {Link} from '../Link'
|
||||||
import {AutoSizedImage} from '../images/AutoSizedImage'
|
import {AutoSizedImage} from '../images/AutoSizedImage'
|
||||||
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
|
import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
|
||||||
|
@ -17,8 +22,10 @@ import {saveImageModal} from 'lib/media/manip'
|
||||||
import YoutubeEmbed from './YoutubeEmbed'
|
import YoutubeEmbed from './YoutubeEmbed'
|
||||||
import ExternalLinkEmbed from './ExternalLinkEmbed'
|
import ExternalLinkEmbed from './ExternalLinkEmbed'
|
||||||
import {getYoutubeVideoId} from 'lib/strings/url-helpers'
|
import {getYoutubeVideoId} from 'lib/strings/url-helpers'
|
||||||
|
import QuoteEmbed from './QuoteEmbed'
|
||||||
|
|
||||||
type Embed =
|
type Embed =
|
||||||
|
| AppBskyEmbedRecord.Presented
|
||||||
| AppBskyEmbedImages.Presented
|
| AppBskyEmbedImages.Presented
|
||||||
| AppBskyEmbedExternal.Presented
|
| AppBskyEmbedExternal.Presented
|
||||||
| {$type: string; [k: string]: unknown}
|
| {$type: string; [k: string]: unknown}
|
||||||
|
@ -32,6 +39,25 @@ export function PostEmbeds({
|
||||||
}) {
|
}) {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const store = useStores()
|
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 (AppBskyEmbedImages.isPresented(embed)) {
|
||||||
if (embed.images.length > 0) {
|
if (embed.images.length > 0) {
|
||||||
const uris = embed.images.map(img => img.fullsize)
|
const uris = embed.images.map(img => img.fullsize)
|
||||||
|
|
|
@ -4,15 +4,17 @@ import {Text} from './text/Text'
|
||||||
import {ago} from 'lib/strings/time'
|
import {ago} from 'lib/strings/time'
|
||||||
import {usePalette} from 'lib/hooks/usePalette'
|
import {usePalette} from 'lib/hooks/usePalette'
|
||||||
import {useStores} from 'state/index'
|
import {useStores} from 'state/index'
|
||||||
|
import {UserAvatar} from './UserAvatar'
|
||||||
import {observer} from 'mobx-react-lite'
|
import {observer} from 'mobx-react-lite'
|
||||||
import FollowButton from '../profile/FollowButton'
|
import FollowButton from '../profile/FollowButton'
|
||||||
|
|
||||||
interface PostMetaOpts {
|
interface PostMetaOpts {
|
||||||
|
authorAvatar: string | undefined
|
||||||
authorHandle: string
|
authorHandle: string
|
||||||
authorDisplayName: string | undefined
|
authorDisplayName: string | undefined
|
||||||
timestamp: string
|
timestamp: string
|
||||||
did: string
|
did?: string
|
||||||
declarationCid: string
|
declarationCid?: string
|
||||||
showFollowBtn?: boolean
|
showFollowBtn?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,11 +29,18 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
|
||||||
// don't change this UI immediately, but rather upon future
|
// don't change this UI immediately, but rather upon future
|
||||||
// renders
|
// renders
|
||||||
const isFollowing = React.useMemo(
|
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],
|
[opts.did, store.me.follows],
|
||||||
)
|
)
|
||||||
|
|
||||||
if (opts.showFollowBtn && !isMe && !isFollowing) {
|
if (
|
||||||
|
opts.showFollowBtn &&
|
||||||
|
!isMe &&
|
||||||
|
!isFollowing &&
|
||||||
|
opts.did &&
|
||||||
|
opts.declarationCid
|
||||||
|
) {
|
||||||
// two-liner with follow button
|
// two-liner with follow button
|
||||||
return (
|
return (
|
||||||
<View style={[styles.metaTwoLine]}>
|
<View style={[styles.metaTwoLine]}>
|
||||||
|
@ -71,6 +80,16 @@ export const PostMeta = observer(function (opts: PostMetaOpts) {
|
||||||
// one-liner
|
// one-liner
|
||||||
return (
|
return (
|
||||||
<View style={styles.meta}>
|
<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]}>
|
<View style={[styles.metaItem, styles.maxWidth]}>
|
||||||
<Text
|
<Text
|
||||||
type="lg-bold"
|
type="lg-bold"
|
||||||
|
@ -107,6 +126,9 @@ const styles = StyleSheet.create({
|
||||||
metaItem: {
|
metaItem: {
|
||||||
paddingRight: 5,
|
paddingRight: 5,
|
||||||
},
|
},
|
||||||
|
avatar: {
|
||||||
|
alignSelf: 'center',
|
||||||
|
},
|
||||||
maxWidth: {
|
maxWidth: {
|
||||||
maxWidth: '80%',
|
maxWidth: '80%',
|
||||||
},
|
},
|
||||||
|
|
|
@ -8,7 +8,10 @@ import {faAngleUp} from '@fortawesome/free-solid-svg-icons/faAngleUp'
|
||||||
import {faArrowLeft} from '@fortawesome/free-solid-svg-icons/faArrowLeft'
|
import {faArrowLeft} from '@fortawesome/free-solid-svg-icons/faArrowLeft'
|
||||||
import {faArrowRight} from '@fortawesome/free-solid-svg-icons/faArrowRight'
|
import {faArrowRight} from '@fortawesome/free-solid-svg-icons/faArrowRight'
|
||||||
import {faArrowUp} from '@fortawesome/free-solid-svg-icons/faArrowUp'
|
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 {faArrowUpFromBracket} from '@fortawesome/free-solid-svg-icons/faArrowUpFromBracket'
|
||||||
import {faArrowUpRightFromSquare} from '@fortawesome/free-solid-svg-icons/faArrowUpRightFromSquare'
|
import {faArrowUpRightFromSquare} from '@fortawesome/free-solid-svg-icons/faArrowUpRightFromSquare'
|
||||||
import {faArrowsRotate} from '@fortawesome/free-solid-svg-icons/faArrowsRotate'
|
import {faArrowsRotate} from '@fortawesome/free-solid-svg-icons/faArrowsRotate'
|
||||||
|
@ -100,6 +103,7 @@ export function setup() {
|
||||||
faEllipsis,
|
faEllipsis,
|
||||||
faEnvelope,
|
faEnvelope,
|
||||||
faExclamation,
|
faExclamation,
|
||||||
|
faQuoteLeft,
|
||||||
farEyeSlash,
|
farEyeSlash,
|
||||||
faGear,
|
faGear,
|
||||||
faGlobe,
|
faGlobe,
|
||||||
|
|
|
@ -14,6 +14,7 @@ export const Composer = observer(
|
||||||
imagesOpen,
|
imagesOpen,
|
||||||
onPost,
|
onPost,
|
||||||
onClose,
|
onClose,
|
||||||
|
quote,
|
||||||
}: {
|
}: {
|
||||||
active: boolean
|
active: boolean
|
||||||
winHeight: number
|
winHeight: number
|
||||||
|
@ -21,6 +22,7 @@ export const Composer = observer(
|
||||||
imagesOpen?: ComposerOpts['imagesOpen']
|
imagesOpen?: ComposerOpts['imagesOpen']
|
||||||
onPost?: ComposerOpts['onPost']
|
onPost?: ComposerOpts['onPost']
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
quote?: ComposerOpts['quote']
|
||||||
}) => {
|
}) => {
|
||||||
const pal = usePalette('default')
|
const pal = usePalette('default')
|
||||||
const initInterp = useAnimatedValue(0)
|
const initInterp = useAnimatedValue(0)
|
||||||
|
@ -62,6 +64,7 @@ export const Composer = observer(
|
||||||
imagesOpen={imagesOpen}
|
imagesOpen={imagesOpen}
|
||||||
onPost={onPost}
|
onPost={onPost}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
|
quote={quote}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)
|
)
|
||||||
|
|
|
@ -550,6 +550,7 @@ export const MobileShell: React.FC = observer(() => {
|
||||||
replyTo={store.shell.composerOpts?.replyTo}
|
replyTo={store.shell.composerOpts?.replyTo}
|
||||||
imagesOpen={store.shell.composerOpts?.imagesOpen}
|
imagesOpen={store.shell.composerOpts?.imagesOpen}
|
||||||
onPost={store.shell.composerOpts?.onPost}
|
onPost={store.shell.composerOpts?.onPost}
|
||||||
|
quote={store.shell.composerOpts?.quote}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|
|
@ -19,10 +19,10 @@
|
||||||
jsonpointer "^5.0.0"
|
jsonpointer "^5.0.0"
|
||||||
leven "^3.1.0"
|
leven "^3.1.0"
|
||||||
|
|
||||||
"@atproto/api@^0.1.2":
|
"@atproto/api@0.1.3":
|
||||||
version "0.1.2"
|
version "0.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.1.2.tgz#66102f9203ba499432bc5aeb30cd19313ab2e4fc"
|
resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.1.3.tgz#4aa9ea7caad624a7eda7d22e03f076e4b0fb68fb"
|
||||||
integrity sha512-lDcFGkrk0J7rkIPSie18xS7sO3IL6DsosX8GgoeqCNeVaDuphBRaFCcpBUWf0q4fHrpmdgghGo4ulefyKHTIFQ==
|
integrity sha512-jEtE0Afxnkvth7/dZKYx9Gv1IpO2Jlmb8KzgRVPnyYyolI2GI4VTNs7mxxO/44cs8vKu2PN2zW+64XuaIY1JBA==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@atproto/xrpc" "*"
|
"@atproto/xrpc" "*"
|
||||||
typed-emitter "^2.1.0"
|
typed-emitter "^2.1.0"
|
||||||
|
|
Loading…
Reference in New Issue