From 6588961d2e075ed047857d71346e3a63282ee58f Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Wed, 18 Jan 2023 18:14:46 -0600 Subject: [PATCH] Update composer to preview external link cards (#52) * Fetch external link metadata during compose so the user can preview and remove the embed * Add missing mocks * Update tests to match recent changes --- __mocks__/react-native-fs.js | 1 + __mocks__/state-mock.ts | 1 + __tests__/lib/images.test.ts | 2 +- .../view/com/composer/ComposePost.test.tsx | 1 + src/lib/strings.ts | 2 +- src/state/lib/api.ts | 110 +++++++-------- src/view/com/composer/ComposePost.tsx | 82 +++++++++++- src/view/com/composer/ExternalEmbed.tsx | 125 ++++++++++++++++++ src/view/com/util/PostEmbeds.tsx | 2 +- 9 files changed, 262 insertions(+), 64 deletions(-) create mode 100644 __mocks__/react-native-fs.js create mode 100644 src/view/com/composer/ExternalEmbed.tsx diff --git a/__mocks__/react-native-fs.js b/__mocks__/react-native-fs.js new file mode 100644 index 00000000..b1c6ea43 --- /dev/null +++ b/__mocks__/react-native-fs.js @@ -0,0 +1 @@ +export default {} diff --git a/__mocks__/state-mock.ts b/__mocks__/state-mock.ts index b26e6251..129f9c85 100644 --- a/__mocks__/state-mock.ts +++ b/__mocks__/state-mock.ts @@ -311,6 +311,7 @@ export const mockedFeedStore = { loadLatest: jest.fn(), update: jest.fn(), checkForLatest: jest.fn().mockRejectedValue('Error checking for latest'), + registerListeners: jest.fn().mockReturnValue(jest.fn()), // unknown required because of the missing private methods: _xLoading, _xIdle, _pendingWork, _initialLoad, _loadLatest, _loadMore, _update, _replaceAll, _appendAll, _prependAll, _updateAll, _getFeed, loadMoreCursor, pollCursor, _loadPromise, _updatePromise, _loadLatestPromise, _loadMorePromise } as unknown as FeedModel diff --git a/__tests__/lib/images.test.ts b/__tests__/lib/images.test.ts index d53a5bc0..952b0ca4 100644 --- a/__tests__/lib/images.test.ts +++ b/__tests__/lib/images.test.ts @@ -54,7 +54,7 @@ describe('downloadAndResize', () => { 100, 100, 'JPEG', - 1, + 100, undefined, undefined, undefined, diff --git a/__tests__/view/com/composer/ComposePost.test.tsx b/__tests__/view/com/composer/ComposePost.test.tsx index 84377f62..5c5a6181 100644 --- a/__tests__/view/com/composer/ComposePost.test.tsx +++ b/__tests__/view/com/composer/ComposePost.test.tsx @@ -63,6 +63,7 @@ describe('ComposePost', () => { mockedRootStore, 'testing publish', 'testUri', + undefined, [], new Set(), expect.anything(), diff --git a/src/lib/strings.ts b/src/lib/strings.ts index 77d8298a..04d8656f 100644 --- a/src/lib/strings.ts +++ b/src/lib/strings.ts @@ -96,7 +96,7 @@ export function extractEntities( { // links const re = - /(^|\s|\()((https?:\/\/[\S]+)|((?[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gm + /(^|\s|\()((https?:\/\/[\S]+)|((?[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 fd020aee..1dfbf509 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, 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 abdcd04e..6a959d41 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( + undefined, + ) + const [attemptedExtLinks, setAttemptedExtLinks] = useState([]) 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 && ( + setExtLink(undefined)} + /> + )} {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 00000000..7eaec5f0 --- /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 + } + return ( + + {link.isLoading ? ( + + + + ) : link.localThumb ? ( + + ) : ( + + )} + + + + + + + {!!link.meta?.title && ( + + {link.meta.title} + + )} + + {link.uri} + + {!!link.meta?.description && ( + + {link.meta.description} + + )} + {!!link.meta?.error && ( + + {link.meta.error} + + )} + + + ) +} + +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 bb98f55d..3fb93ed4 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,