diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/lib/strings.ts | 2 | ||||
-rw-r--r-- | src/state/lib/api.ts | 110 | ||||
-rw-r--r-- | src/view/com/composer/ComposePost.tsx | 82 | ||||
-rw-r--r-- | src/view/com/composer/ExternalEmbed.tsx | 125 | ||||
-rw-r--r-- | src/view/com/util/PostEmbeds.tsx | 2 |
5 files changed, 258 insertions, 63 deletions
diff --git a/src/lib/strings.ts b/src/lib/strings.ts index 77d8298ac..04d8656f7 100644 --- a/src/lib/strings.ts +++ b/src/lib/strings.ts @@ -96,7 +96,7 @@ export function extractEntities( { // links const re = - /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gm + /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim while ((match = re.exec(text))) { let value = match[2] if (!value.startsWith('http')) { diff --git a/src/state/lib/api.ts b/src/state/lib/api.ts index fd020aeee..1dfbf5090 100644 --- a/src/state/lib/api.ts +++ b/src/state/lib/api.ts @@ -15,7 +15,13 @@ import {RootStoreModel} from '../models/root-store' import {extractEntities} from '../../lib/strings' import {isNetworkError} from '../../lib/errors' import {downloadAndResize} from '../../lib/images' -import {getLikelyType, LikelyType, getLinkMeta} from '../../lib/link-meta' +import { + getLikelyType, + LikelyType, + getLinkMeta, + LinkMeta, +} from '../../lib/link-meta' +import {Image} from '../../lib/images' const TIMEOUT = 10e3 // 10s @@ -23,10 +29,18 @@ export function doPolyfill() { AtpApi.xrpc.fetch = fetchHandler } +export interface ExternalEmbedDraft { + uri: string + isLoading: boolean + meta?: LinkMeta + localThumb?: Image +} + export async function post( store: RootStoreModel, text: string, replyTo?: string, + extLink?: ExternalEmbedDraft, images?: string[], knownHandles?: Set<string>, onStateChange?: (state: string) => void, @@ -67,68 +81,44 @@ export async function post( } } - if (!embed && entities) { - const link = entities.find( - ent => - ent.type === 'link' && - getLikelyType(ent.value || '') === LikelyType.HTML, - ) - if (link) { - try { - onStateChange?.(`Fetching link metadata...`) - let thumb - const linkMeta = await getLinkMeta(link.value) - if (linkMeta.image) { - onStateChange?.(`Downloading link thumbnail...`) - const thumbLocal = await downloadAndResize({ - uri: linkMeta.image, - width: 250, - height: 250, - mode: 'contain', - maxSize: 100000, - timeout: 15e3, - }).catch(() => undefined) - if (thumbLocal) { - onStateChange?.(`Uploading link thumbnail...`) - let encoding - if (thumbLocal.uri.endsWith('.png')) { - encoding = 'image/png' - } else if ( - thumbLocal.uri.endsWith('.jpeg') || - thumbLocal.uri.endsWith('.jpg') - ) { - encoding = 'image/jpeg' - } else { - store.log.warn( - 'Unexpected image format for thumbnail, skipping', - thumbLocal.uri, - ) - } - if (encoding) { - const thumbUploadRes = await store.api.com.atproto.blob.upload( - thumbLocal.uri, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts - {encoding}, - ) - thumb = { - cid: thumbUploadRes.data.cid, - mimeType: encoding, - } - } - } + if (!embed && extLink) { + let thumb + if (extLink.localThumb) { + onStateChange?.(`Uploading link thumbnail...`) + let encoding + if (extLink.localThumb.path.endsWith('.png')) { + encoding = 'image/png' + } else if ( + extLink.localThumb.path.endsWith('.jpeg') || + extLink.localThumb.path.endsWith('.jpg') + ) { + encoding = 'image/jpeg' + } else { + store.log.warn( + 'Unexpected image format for thumbnail, skipping', + extLink.localThumb.path, + ) + } + if (encoding) { + const thumbUploadRes = await store.api.com.atproto.blob.upload( + extLink.localThumb.path, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts + {encoding}, + ) + thumb = { + cid: thumbUploadRes.data.cid, + mimeType: encoding, } - embed = { - $type: 'app.bsky.embed.external', - external: { - uri: link.value, - title: linkMeta.title || linkMeta.url, - description: linkMeta.description || '', - thumb, - }, - } as AppBskyEmbedExternal.Main - } catch (e: any) { - store.log.warn(`Failed to fetch link meta for ${link.value}`, e) } } + embed = { + $type: 'app.bsky.embed.external', + external: { + uri: extLink.uri, + title: extLink.meta?.title || '', + description: extLink.meta?.description || '', + thumb, + }, + } as AppBskyEmbedExternal.Main } if (replyTo) { diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/ComposePost.tsx index abdcd04ec..6a959d41e 100644 --- a/src/view/com/composer/ComposePost.tsx +++ b/src/view/com/composer/ComposePost.tsx @@ -16,6 +16,7 @@ import LinearGradient from 'react-native-linear-gradient' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {UserAutocompleteViewModel} from '../../../state/models/user-autocomplete-view' import {Autocomplete} from './Autocomplete' +import {ExternalEmbed} from './ExternalEmbed' import {Text} from '../util/text/Text' import * as Toast from '../util/Toast' // @ts-ignore no type definition -prf @@ -28,7 +29,9 @@ import {useStores} from '../../../state' import * as apilib from '../../../state/lib/api' import {ComposerOpts} from '../../../state/models/shell-ui' import {s, colors, gradients} from '../../lib/styles' -import {detectLinkables} from '../../../lib/strings' +import {detectLinkables, extractEntities} from '../../../lib/strings' +import {getLinkMeta} from '../../../lib/link-meta' +import {downloadAndResize} from '../../../lib/images' import {UserLocalPhotosModel} from '../../../state/models/user-local-photos' import {PhotoCarouselPicker} from './PhotoCarouselPicker' import {SelectedPhoto} from './SelectedPhoto' @@ -56,6 +59,10 @@ export const ComposePost = observer(function ComposePost({ const [processingState, setProcessingState] = useState('') const [error, setError] = useState('') const [text, setText] = useState('') + const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>( + undefined, + ) + const [attemptedExtLinks, setAttemptedExtLinks] = useState<string[]>([]) const [isSelectingPhotos, setIsSelectingPhotos] = useState( imagesOpen || false, ) @@ -71,11 +78,61 @@ export const ComposePost = observer(function ComposePost({ [store], ) + // initial setup useEffect(() => { autocompleteView.setup() localPhotos.setup() }, [autocompleteView, localPhotos]) + // external link metadata-fetch flow + useEffect(() => { + let aborted = false + const cleanup = () => { + aborted = true + } + if (!extLink) { + return cleanup + } + if (!extLink.meta) { + getLinkMeta(extLink.uri).then(meta => { + if (aborted) { + return + } + setExtLink({ + uri: extLink.uri, + isLoading: !!meta.image, + meta, + }) + }) + return cleanup + } + if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) { + downloadAndResize({ + uri: extLink.meta.image, + width: 250, + height: 250, + mode: 'contain', + maxSize: 100000, + timeout: 15e3, + }) + .catch(() => undefined) + .then(localThumb => { + setExtLink({ + ...extLink, + isLoading: false, // done + localThumb, + }) + }) + return cleanup + } + if (extLink.isLoading) { + setExtLink({ + ...extLink, + isLoading: false, // done + }) + } + }, [extLink]) + useEffect(() => { // HACK // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view @@ -119,6 +176,22 @@ export const ComposePost = observer(function ComposePost({ } else { autocompleteView.setActive(false) } + + if (!extLink && /\s$/.test(newText)) { + const ents = extractEntities(newText) + const entLink = ents + ?.filter( + ent => ent.type === 'link' && !attemptedExtLinks.includes(ent.value), + ) + .pop() // use last + if (entLink) { + setExtLink({ + uri: entLink.value, + isLoading: true, + }) + setAttemptedExtLinks([...attemptedExtLinks, entLink.value]) + } + } } const onPressCancel = () => { onClose() @@ -141,6 +214,7 @@ export const ComposePost = observer(function ComposePost({ store, text, replyTo?.uri, + extLink, selectedPhotos, autocompleteView.knownHandles, setProcessingState, @@ -297,6 +371,12 @@ export const ComposePost = observer(function ComposePost({ selectedPhotos={selectedPhotos} onSelectPhotos={onSelectPhotos} /> + {!selectedPhotos.length && extLink && ( + <ExternalEmbed + link={extLink} + onRemove={() => setExtLink(undefined)} + /> + )} </ScrollView> {isSelectingPhotos && localPhotos.photos != null && diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx new file mode 100644 index 000000000..7eaec5f04 --- /dev/null +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -0,0 +1,125 @@ +import React from 'react' +import { + ActivityIndicator, + StyleSheet, + TouchableWithoutFeedback, + View, +} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {BlurView} from '@react-native-community/blur' +import LinearGradient from 'react-native-linear-gradient' +import {AutoSizedImage} from '../util/images/AutoSizedImage' +import {Text} from '../util/text/Text' +import {s, gradients} from '../../lib/styles' +import {usePalette} from '../../lib/hooks/usePalette' +import {ExternalEmbedDraft} from '../../../state/lib/api' + +export const ExternalEmbed = ({ + link, + onRemove, +}: { + link?: ExternalEmbedDraft + onRemove: () => void +}) => { + const pal = usePalette('default') + const palError = usePalette('error') + if (!link) { + return <View /> + } + return ( + <View style={[styles.outer, pal.view, pal.border]}> + {link.isLoading ? ( + <View + style={[ + styles.image, + styles.imageFallback, + {backgroundColor: pal.colors.backgroundLight}, + ]}> + <ActivityIndicator size="large" style={styles.spinner} /> + </View> + ) : link.localThumb ? ( + <AutoSizedImage + uri={link.localThumb.path} + containerStyle={styles.image} + /> + ) : ( + <LinearGradient + colors={[gradients.blueDark.start, gradients.blueDark.end]} + start={{x: 0, y: 0}} + end={{x: 1, y: 1}} + style={[styles.image, styles.imageFallback]} + /> + )} + <TouchableWithoutFeedback onPress={onRemove}> + <BlurView style={styles.removeBtn} blurType="dark"> + <FontAwesomeIcon size={18} icon="xmark" style={s.white} /> + </BlurView> + </TouchableWithoutFeedback> + <View style={styles.inner}> + {!!link.meta?.title && ( + <Text type="sm-bold" numberOfLines={2} style={[pal.text]}> + {link.meta.title} + </Text> + )} + <Text type="sm" numberOfLines={1} style={[pal.textLight, styles.uri]}> + {link.uri} + </Text> + {!!link.meta?.description && ( + <Text + type="sm" + numberOfLines={2} + style={[pal.text, styles.description]}> + {link.meta.description} + </Text> + )} + {!!link.meta?.error && ( + <Text + type="sm" + numberOfLines={2} + style={[{color: palError.colors.background}, styles.description]}> + {link.meta.error} + </Text> + )} + </View> + </View> + ) +} + +const styles = StyleSheet.create({ + outer: { + borderWidth: 1, + borderRadius: 8, + marginTop: 20, + }, + inner: { + padding: 10, + }, + image: { + borderTopLeftRadius: 6, + borderTopRightRadius: 6, + width: '100%', + height: 200, + }, + imageFallback: { + height: 160, + }, + removeBtn: { + position: 'absolute', + top: 10, + right: 10, + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + }, + spinner: { + marginTop: 60, + }, + uri: { + marginTop: 2, + }, + description: { + marginTop: 4, + }, +}) diff --git a/src/view/com/util/PostEmbeds.tsx b/src/view/com/util/PostEmbeds.tsx index bb98f55db..3fb93ed48 100644 --- a/src/view/com/util/PostEmbeds.tsx +++ b/src/view/com/util/PostEmbeds.tsx @@ -132,7 +132,7 @@ const styles = StyleSheet.create({ borderTopLeftRadius: 6, borderTopRightRadius: 6, width: '100%', - height: 200, + maxHeight: 200, }, extImageFallback: { height: 160, |