diff options
Diffstat (limited to 'src/view/com')
-rw-r--r-- | src/view/com/composer/ComposePost.tsx | 56 | ||||
-rw-r--r-- | src/view/com/composer/PhotoCarouselPicker.tsx | 35 | ||||
-rw-r--r-- | src/view/com/composer/SelectedPhoto.tsx | 12 | ||||
-rw-r--r-- | src/view/com/lightbox/Image.tsx | 25 | ||||
-rw-r--r-- | src/view/com/lightbox/Lightbox.tsx | 7 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThreadItem.tsx | 4 | ||||
-rw-r--r-- | src/view/com/posts/FeedItem.tsx | 2 | ||||
-rw-r--r-- | src/view/com/util/PostEmbeds.tsx | 180 | ||||
-rw-r--r-- | src/view/com/util/images/AutoSizedImage.tsx | 112 |
9 files changed, 339 insertions, 94 deletions
diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/ComposePost.tsx index 7f4de654c..c6d371bc6 100644 --- a/src/view/com/composer/ComposePost.tsx +++ b/src/view/com/composer/ComposePost.tsx @@ -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, diff --git a/src/view/com/composer/PhotoCarouselPicker.tsx b/src/view/com/composer/PhotoCarouselPicker.tsx index f4af4c61e..7095e9dd1 100644 --- a/src/view/com/composer/PhotoCarouselPicker.tsx +++ b/src/view/com/composer/PhotoCarouselPicker.tsx @@ -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 diff --git a/src/view/com/composer/SelectedPhoto.tsx b/src/view/com/composer/SelectedPhoto.tsx index 88209b3df..7711415f6 100644 --- a/src/view/com/composer/SelectedPhoto.tsx +++ b/src/view/com/composer/SelectedPhoto.tsx @@ -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, diff --git a/src/view/com/lightbox/Image.tsx b/src/view/com/lightbox/Image.tsx new file mode 100644 index 000000000..2e3307774 --- /dev/null +++ b/src/view/com/lightbox/Image.tsx @@ -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, + }, +}) diff --git a/src/view/com/lightbox/Lightbox.tsx b/src/view/com/lightbox/Lightbox.tsx index 9432f0151..198f20391 100644 --- a/src/view/com/lightbox/Lightbox.tsx +++ b/src/view/com/lightbox/Lightbox.tsx @@ -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 /> } diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index 8408eb6c0..ae13e0765 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -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} diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 761b3c902..2feb71a98 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -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} diff --git a/src/view/com/util/PostEmbeds.tsx b/src/view/com/util/PostEmbeds.tsx index 1591c658a..ea15dc9ca 100644 --- a/src/view/com/util/PostEmbeds.tsx +++ b/src/view/com/util/PostEmbeds.tsx @@ -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} - </Text> - <Text numberOfLines={1} style={styles.url}> - {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> - {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, }, }) diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx new file mode 100644 index 000000000..fedc94321 --- /dev/null +++ b/src/view/com/util/images/AutoSizedImage.tsx @@ -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, + }, +}) |