Add post embeds (images and external links)

This commit is contained in:
Paul Frazee 2022-12-14 15:35:15 -06:00
parent 345ec83f26
commit 4966b2152e
30 changed files with 936 additions and 242 deletions

View file

@ -29,7 +29,6 @@ import {detectLinkables} from '../../../lib/strings'
import {UserLocalPhotosModel} from '../../../state/models/user-local-photos'
import {PhotoCarouselPicker} from './PhotoCarouselPicker'
import {SelectedPhoto} from './SelectedPhoto'
import {IMAGES_ENABLED} from '../../../build-flags'
const MAX_TEXT_LENGTH = 256
const DANGER_TEXT_LENGTH = MAX_TEXT_LENGTH
@ -46,6 +45,7 @@ export const ComposePost = observer(function ComposePost({
const store = useStores()
const textInput = useRef<TextInput>(null)
const [isProcessing, setIsProcessing] = useState(false)
const [processingState, setProcessingState] = useState('')
const [error, setError] = useState('')
const [text, setText] = useState('')
const [selectedPhotos, setSelectedPhotos] = useState<string[]>([])
@ -81,6 +81,10 @@ export const ComposePost = observer(function ComposePost({
}
}, [])
const onSelectPhotos = (photos: string[]) => {
setSelectedPhotos(photos)
}
const onChangeText = (newText: string) => {
setText(newText)
@ -109,15 +113,16 @@ export const ComposePost = observer(function ComposePost({
}
setIsProcessing(true)
try {
const replyRef = replyTo
? {uri: replyTo.uri, cid: replyTo.cid}
: undefined
await apilib.post(store, text, replyRef, autocompleteView.knownHandles)
} catch (e: any) {
console.error(`Failed to create post: ${e.toString()}`)
setError(
'Post failed to upload. Please check your Internet connection and try again.',
await apilib.post(
store,
text,
replyTo?.uri,
selectedPhotos,
autocompleteView.knownHandles,
setProcessingState,
)
} catch (e: any) {
setError(e.message)
setIsProcessing(false)
return
}
@ -189,6 +194,11 @@ export const ComposePost = observer(function ComposePost({
</View>
)}
</View>
{isProcessing ? (
<View style={styles.processingLine}>
<Text>{processingState}</Text>
</View>
) : undefined}
{error !== '' && (
<View style={styles.errorLine}>
<View style={styles.errorIcon}>
@ -198,7 +208,7 @@ export const ComposePost = observer(function ComposePost({
size={10}
/>
</View>
<Text style={s.red4}>{error}</Text>
<Text style={[s.red4, s.flex1]}>{error}</Text>
</View>
)}
{replyTo ? (
@ -240,18 +250,15 @@ export const ComposePost = observer(function ComposePost({
</View>
<SelectedPhoto
selectedPhotos={selectedPhotos}
setSelectedPhotos={setSelectedPhotos}
onSelectPhotos={onSelectPhotos}
/>
{IMAGES_ENABLED &&
localPhotos.photos != null &&
text === '' &&
selectedPhotos.length === 0 && (
<PhotoCarouselPicker
selectedPhotos={selectedPhotos}
setSelectedPhotos={setSelectedPhotos}
localPhotos={localPhotos}
/>
)}
{localPhotos.photos != null && selectedPhotos.length < 4 && (
<PhotoCarouselPicker
selectedPhotos={selectedPhotos}
onSelectPhotos={onSelectPhotos}
localPhotos={localPhotos}
/>
)}
<View style={styles.bottomBar}>
<View style={s.flex1} />
<Text style={[s.mr10, {color: progressColor}]}>
@ -322,6 +329,13 @@ const styles = StyleSheet.create({
paddingHorizontal: 20,
paddingVertical: 6,
},
processingLine: {
backgroundColor: colors.gray1,
borderRadius: 6,
paddingHorizontal: 8,
paddingVertical: 6,
marginBottom: 6,
},
errorLine: {
flexDirection: 'row',
backgroundColor: colors.red1,

View file

@ -8,48 +8,54 @@ import {
openCropper,
} from 'react-native-image-crop-picker'
const IMAGE_PARAMS = {
width: 500,
height: 500,
freeStyleCropEnabled: true,
forceJpg: true, // ios only
compressImageQuality: 0.7,
}
export const PhotoCarouselPicker = ({
selectedPhotos,
setSelectedPhotos,
onSelectPhotos,
localPhotos,
}: {
selectedPhotos: string[]
setSelectedPhotos: React.Dispatch<React.SetStateAction<string[]>>
onSelectPhotos: (v: string[]) => void
localPhotos: any
}) => {
const handleOpenCamera = useCallback(() => {
openCamera({
mediaType: 'photo',
cropping: true,
width: 1000,
height: 1000,
...IMAGE_PARAMS,
}).then(
item => {
setSelectedPhotos([item.path, ...selectedPhotos])
onSelectPhotos([item.path, ...selectedPhotos])
},
_err => {
// ignore
},
)
}, [selectedPhotos, setSelectedPhotos])
}, [selectedPhotos, onSelectPhotos])
const handleSelectPhoto = useCallback(
async (uri: string) => {
const img = await openCropper({
mediaType: 'photo',
path: uri,
width: 1000,
height: 1000,
...IMAGE_PARAMS,
})
setSelectedPhotos([img.path, ...selectedPhotos])
onSelectPhotos([img.path, ...selectedPhotos])
},
[selectedPhotos, setSelectedPhotos],
[selectedPhotos, onSelectPhotos],
)
const handleOpenGallery = useCallback(() => {
openPicker({
multiple: true,
maxFiles: 4,
maxFiles: 4 - selectedPhotos.length,
mediaType: 'photo',
}).then(async items => {
const result = []
@ -58,14 +64,13 @@ export const PhotoCarouselPicker = ({
const img = await openCropper({
mediaType: 'photo',
path: image.path,
width: 1000,
height: 1000,
...IMAGE_PARAMS,
})
result.push(img.path)
}
setSelectedPhotos([...result, ...selectedPhotos])
onSelectPhotos([...result, ...selectedPhotos])
})
}, [selectedPhotos, setSelectedPhotos])
}, [selectedPhotos, onSelectPhotos])
return (
<ScrollView

View file

@ -5,10 +5,10 @@ import {colors} from '../../lib/styles'
export const SelectedPhoto = ({
selectedPhotos,
setSelectedPhotos,
onSelectPhotos,
}: {
selectedPhotos: string[]
setSelectedPhotos: React.Dispatch<React.SetStateAction<string[]>>
onSelectPhotos: (v: string[]) => void
}) => {
const imageStyle =
selectedPhotos.length === 1
@ -19,11 +19,9 @@ export const SelectedPhoto = ({
const handleRemovePhoto = useCallback(
item => {
setSelectedPhotos(
selectedPhotos.filter(filterItem => filterItem !== item),
)
onSelectPhotos(selectedPhotos.filter(filterItem => filterItem !== item))
},
[selectedPhotos, setSelectedPhotos],
[selectedPhotos, onSelectPhotos],
)
return selectedPhotos.length !== 0 ? (
@ -57,8 +55,10 @@ const styles = StyleSheet.create({
marginTop: 16,
},
image: {
resizeMode: 'contain',
borderRadius: 8,
margin: 2,
backgroundColor: colors.gray1,
},
image250: {
width: 250,

View file

@ -0,0 +1,25 @@
import React from 'react'
import {Image, StyleSheet, useWindowDimensions, View} from 'react-native'
export function Component({uri}: {uri: string}) {
const winDim = useWindowDimensions()
const top = winDim.height / 2 - (winDim.width - 40) / 2 - 100
console.log(uri)
return (
<View style={[styles.container, {top}]}>
<Image style={styles.image} source={{uri}} />
</View>
)
}
const styles = StyleSheet.create({
container: {
position: 'absolute',
left: 0,
},
image: {
resizeMode: 'contain',
width: '100%',
aspectRatio: 1,
},
})

View file

@ -7,6 +7,7 @@ import {useStores} from '../../../state'
import * as models from '../../../state/models/shell-ui'
import * as ProfileImageLightbox from './ProfileImage'
import * as ImageLightbox from './Image'
export const Lightbox = observer(function Lightbox() {
const store = useStores()
@ -26,6 +27,12 @@ export const Lightbox = observer(function Lightbox() {
{...(store.shell.activeLightbox as models.ProfileImageLightbox)}
/>
)
} else if (store.shell.activeLightbox?.name === 'image') {
element = (
<ImageLightbox.Component
{...(store.shell.activeLightbox as models.ImageLightbox)}
/>
)
} else {
return <View />
}

View file

@ -167,7 +167,7 @@ export const PostThreadItem = observer(function PostThreadItem({
style={[styles.postText, styles.postTextLarge]}
/>
</View>
<PostEmbeds entities={record.entities} style={s.mb10} />
<PostEmbeds embed={item.embed} style={s.mb10} />
{item._isHighlightedPost && hasEngagement ? (
<View style={styles.expandedInfo}>
{item.repostCount ? (
@ -277,7 +277,7 @@ export const PostThreadItem = observer(function PostThreadItem({
style={[styles.postText]}
/>
</View>
<PostEmbeds entities={record.entities} style={{marginBottom: 10}} />
<PostEmbeds embed={item.embed} style={{marginBottom: 10}} />
<PostCtrls
replyCount={item.replyCount}
repostCount={item.repostCount}

View file

@ -198,7 +198,7 @@ export const FeedItem = observer(function FeedItem({
style={styles.postText}
/>
</View>
<PostEmbeds entities={record.entities} style={{marginBottom: 10}} />
<PostEmbeds embed={item.embed} style={{marginBottom: 10}} />
<PostCtrls
replyCount={item.replyCount}
repostCount={item.repostCount}

View file

@ -1,88 +1,170 @@
import React, {useEffect, useState} from 'react'
import {
ActivityIndicator,
Image,
ImageStyle,
StyleSheet,
StyleProp,
Text,
TouchableWithoutFeedback,
View,
ViewStyle,
} from 'react-native'
import {Entity} from '../../../third-party/api/src/client/types/app/bsky/feed/post'
import {
Record as PostRecord,
Entity,
} from '../../../third-party/api/src/client/types/app/bsky/feed/post'
import * as AppBskyEmbedImages from '../../../third-party/api/src/client/types/app/bsky/embed/images'
import * as AppBskyEmbedExternal from '../../../third-party/api/src/client/types/app/bsky/embed/external'
import {Link} from '../util/Link'
import {LinkMeta, getLikelyType, LikelyType} from '../../../lib/link-meta'
import {colors} from '../../lib/styles'
import {useStores} from '../../../state'
import {AutoSizedImage} from './images/AutoSizedImage'
type Embed =
| AppBskyEmbedImages.Presented
| AppBskyEmbedExternal.Presented
| {$type: string; [k: string]: unknown}
export function PostEmbeds({
entities,
embed,
style,
}: {
entities?: Entity[]
embed?: Embed
style?: StyleProp<ViewStyle>
}) {
const store = useStores()
const [linkMeta, setLinkMeta] = useState<LinkMeta | undefined>(undefined)
const link = entities?.find(
ent =>
ent.type === 'link' && getLikelyType(ent.value || '') === LikelyType.HTML,
)
useEffect(() => {
let aborted = false
store.linkMetas.getLinkMeta(link?.value || '').then(linkMeta => {
if (!aborted) {
setLinkMeta(linkMeta)
if (embed?.$type === 'app.bsky.embed.images#presented') {
const imgEmbed = embed as AppBskyEmbedImages.Presented
if (imgEmbed.images.length > 0) {
const Thumb = ({i, style}: {i: number; style: StyleProp<ImageStyle>}) => (
<AutoSizedImage
style={style}
uri={imgEmbed.images[i].thumb}
fullSizeUri={imgEmbed.images[i].fullsize}
/>
)
if (imgEmbed.images.length === 4) {
return (
<View style={styles.imagesContainer}>
<View style={styles.imagePair}>
<Thumb i={0} style={styles.imagePairItem} />
<View style={styles.imagesWidthSpacer} />
<Thumb i={1} style={styles.imagePairItem} />
</View>
<View style={styles.imagesHeightSpacer} />
<View style={styles.imagePair}>
<Thumb i={2} style={styles.imagePairItem} />
<View style={styles.imagesWidthSpacer} />
<Thumb i={3} style={styles.imagePairItem} />
</View>
</View>
)
} else if (imgEmbed.images.length === 3) {
return (
<View style={styles.imagesContainer}>
<View style={styles.imageWide}>
<Thumb i={0} style={styles.imageWideItem} />
</View>
<View style={styles.imagesHeightSpacer} />
<View style={styles.imagePair}>
<Thumb i={1} style={styles.imagePairItem} />
<View style={styles.imagesWidthSpacer} />
<Thumb i={2} style={styles.imagePairItem} />
</View>
</View>
)
} else if (imgEmbed.images.length === 2) {
return (
<View style={styles.imagesContainer}>
<View style={styles.imagePair}>
<Thumb i={0} style={styles.imagePairItem} />
<View style={styles.imagesWidthSpacer} />
<Thumb i={1} style={styles.imagePairItem} />
</View>
</View>
)
} else {
return (
<View style={styles.imagesContainer}>
<View style={styles.imageBig}>
<Thumb i={0} style={styles.imageBigItem} />
</View>
</View>
)
}
})
return () => {
aborted = true
}
}, [link])
if (!link) {
return <View />
}
return (
<Link style={[styles.outer, style]} href={link.value}>
{linkMeta ? (
<>
<Text numberOfLines={1} style={styles.title}>
{linkMeta.title || linkMeta.url}
if (embed?.$type === 'app.bsky.embed.external#presented') {
const externalEmbed = embed as AppBskyEmbedExternal.Presented
const link = externalEmbed.external
return (
<Link style={[styles.extOuter, style]} href={link.uri}>
{link.thumb ? (
<AutoSizedImage style={style} uri={link.thumb} />
) : undefined}
<Text numberOfLines={1} style={styles.extTitle}>
{link.title || link.uri}
</Text>
<Text numberOfLines={1} style={styles.extUrl}>
{link.uri}
</Text>
{link.description ? (
<Text numberOfLines={2} style={styles.extDescription}>
{link.description}
</Text>
<Text numberOfLines={1} style={styles.url}>
{linkMeta.url}
</Text>
{linkMeta.description ? (
<Text numberOfLines={2} style={styles.description}>
{linkMeta.description}
</Text>
) : undefined}
</>
) : (
<ActivityIndicator />
)}
</Link>
)
) : undefined}
</Link>
)
}
return <View />
}
const styles = StyleSheet.create({
outer: {
imagesContainer: {
marginBottom: 20,
},
imagesWidthSpacer: {
width: 5,
},
imagesHeightSpacer: {
height: 5,
},
imagePair: {
flexDirection: 'row',
},
imagePairItem: {
resizeMode: 'contain',
flex: 1,
borderRadius: 4,
},
imageWide: {},
imageWideItem: {
resizeMode: 'contain',
borderRadius: 4,
},
imageBig: {},
imageBigItem: {
borderRadius: 4,
},
extOuter: {
borderWidth: 1,
borderColor: colors.gray2,
borderRadius: 8,
padding: 10,
},
title: {
extImage: {
// TODO
},
extTitle: {
fontSize: 16,
fontWeight: 'bold',
},
description: {
extDescription: {
marginTop: 4,
fontSize: 15,
},
url: {
extUrl: {
color: colors.gray4,
},
})

View file

@ -0,0 +1,112 @@
import React, {useState, useEffect, useMemo} from 'react'
import {
Image,
ImageStyle,
LayoutChangeEvent,
StyleProp,
StyleSheet,
Text,
TouchableWithoutFeedback,
View,
} from 'react-native'
import {ImageLightbox} from '../../../../state/models/shell-ui'
import {useStores} from '../../../../state'
import {colors} from '../../../lib/styles'
const MAX_HEIGHT = 300
interface Dim {
width: number
height: number
}
export function AutoSizedImage({
uri,
fullSizeUri,
style,
}: {
uri: string
fullSizeUri?: string
style: StyleProp<ImageStyle>
}) {
const store = useStores()
const [error, setError] = useState<string | undefined>()
const [imgInfo, setImgInfo] = useState<Dim | undefined>()
const [containerInfo, setContainerInfo] = useState<Dim | undefined>()
const calculatedStyle = useMemo(() => {
if (imgInfo && containerInfo) {
// imgInfo.height / imgInfo.width = x / containerInfo.width
// x = imgInfo.height / imgInfo.width * containerInfo.width
return {
height: Math.min(
MAX_HEIGHT,
(imgInfo.height / imgInfo.width) * containerInfo.width,
),
}
}
return undefined
}, [imgInfo, containerInfo])
useEffect(() => {
Image.getSize(
uri,
(width: number, height: number) => {
setImgInfo({width, height})
},
(error: any) => {
setError(String(error))
},
)
}, [uri])
const onLayout = (evt: LayoutChangeEvent) => {
setContainerInfo({
width: evt.nativeEvent.layout.width,
height: evt.nativeEvent.layout.height,
})
}
const onPressImage = () => {
if (fullSizeUri) {
store.shell.openLightbox(new ImageLightbox(fullSizeUri))
}
}
return (
<View style={style}>
<TouchableWithoutFeedback onPress={onPressImage}>
{error ? (
<View style={[styles.container, styles.errorContainer]}>
<Text style={styles.error}>{error}</Text>
</View>
) : calculatedStyle ? (
<View style={styles.container}>
<Image style={calculatedStyle} source={{uri}} />
</View>
) : (
<View style={[style, styles.placeholder]} onLayout={onLayout} />
)}
</TouchableWithoutFeedback>
</View>
)
}
const styles = StyleSheet.create({
placeholder: {
width: '100%',
aspectRatio: 1,
backgroundColor: colors.gray1,
},
errorContainer: {
backgroundColor: colors.red1,
paddingHorizontal: 8,
paddingVertical: 4,
},
container: {
borderRadius: 8,
overflow: 'hidden',
},
error: {
color: colors.red5,
},
})