Bluesky app fork with some witchin' additions 💫

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

authored by Paul Frazee and committed by GitHub 6588961d 27ee550d

Changed files
+262 -64
__mocks__
__tests__
lib
view
com
src
lib
state
lib
view
+1
__mocks__/react-native-fs.js
··· 1 + export default {}
+1
__mocks__/state-mock.ts
··· 311 311 loadLatest: jest.fn(), 312 312 update: jest.fn(), 313 313 checkForLatest: jest.fn().mockRejectedValue('Error checking for latest'), 314 + registerListeners: jest.fn().mockReturnValue(jest.fn()), 314 315 // 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 315 316 } as unknown as FeedModel 316 317
+1 -1
__tests__/lib/images.test.ts
··· 54 54 100, 55 55 100, 56 56 'JPEG', 57 - 1, 57 + 100, 58 58 undefined, 59 59 undefined, 60 60 undefined,
+1
__tests__/view/com/composer/ComposePost.test.tsx
··· 63 63 mockedRootStore, 64 64 'testing publish', 65 65 'testUri', 66 + undefined, 66 67 [], 67 68 new Set<string>(), 68 69 expect.anything(),
+1 -1
src/lib/strings.ts
··· 96 96 { 97 97 // links 98 98 const re = 99 - /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gm 99 + /(^|\s|\()((https?:\/\/[\S]+)|((?<domain>[a-z][a-z0-9]*(\.[a-z0-9]+)+)[\S]*))/gim 100 100 while ((match = re.exec(text))) { 101 101 let value = match[2] 102 102 if (!value.startsWith('http')) {
+50 -60
src/state/lib/api.ts
··· 15 15 import {extractEntities} from '../../lib/strings' 16 16 import {isNetworkError} from '../../lib/errors' 17 17 import {downloadAndResize} from '../../lib/images' 18 - import {getLikelyType, LikelyType, getLinkMeta} from '../../lib/link-meta' 18 + import { 19 + getLikelyType, 20 + LikelyType, 21 + getLinkMeta, 22 + LinkMeta, 23 + } from '../../lib/link-meta' 24 + import {Image} from '../../lib/images' 19 25 20 26 const TIMEOUT = 10e3 // 10s 21 27 ··· 23 29 AtpApi.xrpc.fetch = fetchHandler 24 30 } 25 31 32 + export interface ExternalEmbedDraft { 33 + uri: string 34 + isLoading: boolean 35 + meta?: LinkMeta 36 + localThumb?: Image 37 + } 38 + 26 39 export async function post( 27 40 store: RootStoreModel, 28 41 text: string, 29 42 replyTo?: string, 43 + extLink?: ExternalEmbedDraft, 30 44 images?: string[], 31 45 knownHandles?: Set<string>, 32 46 onStateChange?: (state: string) => void, ··· 67 81 } 68 82 } 69 83 70 - if (!embed && entities) { 71 - const link = entities.find( 72 - ent => 73 - ent.type === 'link' && 74 - getLikelyType(ent.value || '') === LikelyType.HTML, 75 - ) 76 - if (link) { 77 - try { 78 - onStateChange?.(`Fetching link metadata...`) 79 - let thumb 80 - const linkMeta = await getLinkMeta(link.value) 81 - if (linkMeta.image) { 82 - onStateChange?.(`Downloading link thumbnail...`) 83 - const thumbLocal = await downloadAndResize({ 84 - uri: linkMeta.image, 85 - width: 250, 86 - height: 250, 87 - mode: 'contain', 88 - maxSize: 100000, 89 - timeout: 15e3, 90 - }).catch(() => undefined) 91 - if (thumbLocal) { 92 - onStateChange?.(`Uploading link thumbnail...`) 93 - let encoding 94 - if (thumbLocal.uri.endsWith('.png')) { 95 - encoding = 'image/png' 96 - } else if ( 97 - thumbLocal.uri.endsWith('.jpeg') || 98 - thumbLocal.uri.endsWith('.jpg') 99 - ) { 100 - encoding = 'image/jpeg' 101 - } else { 102 - store.log.warn( 103 - 'Unexpected image format for thumbnail, skipping', 104 - thumbLocal.uri, 105 - ) 106 - } 107 - if (encoding) { 108 - const thumbUploadRes = await store.api.com.atproto.blob.upload( 109 - thumbLocal.uri, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts 110 - {encoding}, 111 - ) 112 - thumb = { 113 - cid: thumbUploadRes.data.cid, 114 - mimeType: encoding, 115 - } 116 - } 117 - } 84 + if (!embed && extLink) { 85 + let thumb 86 + if (extLink.localThumb) { 87 + onStateChange?.(`Uploading link thumbnail...`) 88 + let encoding 89 + if (extLink.localThumb.path.endsWith('.png')) { 90 + encoding = 'image/png' 91 + } else if ( 92 + extLink.localThumb.path.endsWith('.jpeg') || 93 + extLink.localThumb.path.endsWith('.jpg') 94 + ) { 95 + encoding = 'image/jpeg' 96 + } else { 97 + store.log.warn( 98 + 'Unexpected image format for thumbnail, skipping', 99 + extLink.localThumb.path, 100 + ) 101 + } 102 + if (encoding) { 103 + const thumbUploadRes = await store.api.com.atproto.blob.upload( 104 + extLink.localThumb.path, // this will be special-cased by the fetch monkeypatch in /src/state/lib/api.ts 105 + {encoding}, 106 + ) 107 + thumb = { 108 + cid: thumbUploadRes.data.cid, 109 + mimeType: encoding, 118 110 } 119 - embed = { 120 - $type: 'app.bsky.embed.external', 121 - external: { 122 - uri: link.value, 123 - title: linkMeta.title || linkMeta.url, 124 - description: linkMeta.description || '', 125 - thumb, 126 - }, 127 - } as AppBskyEmbedExternal.Main 128 - } catch (e: any) { 129 - store.log.warn(`Failed to fetch link meta for ${link.value}`, e) 130 111 } 131 112 } 113 + embed = { 114 + $type: 'app.bsky.embed.external', 115 + external: { 116 + uri: extLink.uri, 117 + title: extLink.meta?.title || '', 118 + description: extLink.meta?.description || '', 119 + thumb, 120 + }, 121 + } as AppBskyEmbedExternal.Main 132 122 } 133 123 134 124 if (replyTo) {
+81 -1
src/view/com/composer/ComposePost.tsx
··· 16 16 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 17 17 import {UserAutocompleteViewModel} from '../../../state/models/user-autocomplete-view' 18 18 import {Autocomplete} from './Autocomplete' 19 + import {ExternalEmbed} from './ExternalEmbed' 19 20 import {Text} from '../util/text/Text' 20 21 import * as Toast from '../util/Toast' 21 22 // @ts-ignore no type definition -prf ··· 28 29 import * as apilib from '../../../state/lib/api' 29 30 import {ComposerOpts} from '../../../state/models/shell-ui' 30 31 import {s, colors, gradients} from '../../lib/styles' 31 - import {detectLinkables} from '../../../lib/strings' 32 + import {detectLinkables, extractEntities} from '../../../lib/strings' 33 + import {getLinkMeta} from '../../../lib/link-meta' 34 + import {downloadAndResize} from '../../../lib/images' 32 35 import {UserLocalPhotosModel} from '../../../state/models/user-local-photos' 33 36 import {PhotoCarouselPicker} from './PhotoCarouselPicker' 34 37 import {SelectedPhoto} from './SelectedPhoto' ··· 56 59 const [processingState, setProcessingState] = useState('') 57 60 const [error, setError] = useState('') 58 61 const [text, setText] = useState('') 62 + const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>( 63 + undefined, 64 + ) 65 + const [attemptedExtLinks, setAttemptedExtLinks] = useState<string[]>([]) 59 66 const [isSelectingPhotos, setIsSelectingPhotos] = useState( 60 67 imagesOpen || false, 61 68 ) ··· 71 78 [store], 72 79 ) 73 80 81 + // initial setup 74 82 useEffect(() => { 75 83 autocompleteView.setup() 76 84 localPhotos.setup() 77 85 }, [autocompleteView, localPhotos]) 78 86 87 + // external link metadata-fetch flow 88 + useEffect(() => { 89 + let aborted = false 90 + const cleanup = () => { 91 + aborted = true 92 + } 93 + if (!extLink) { 94 + return cleanup 95 + } 96 + if (!extLink.meta) { 97 + getLinkMeta(extLink.uri).then(meta => { 98 + if (aborted) { 99 + return 100 + } 101 + setExtLink({ 102 + uri: extLink.uri, 103 + isLoading: !!meta.image, 104 + meta, 105 + }) 106 + }) 107 + return cleanup 108 + } 109 + if (extLink.isLoading && extLink.meta?.image && !extLink.localThumb) { 110 + downloadAndResize({ 111 + uri: extLink.meta.image, 112 + width: 250, 113 + height: 250, 114 + mode: 'contain', 115 + maxSize: 100000, 116 + timeout: 15e3, 117 + }) 118 + .catch(() => undefined) 119 + .then(localThumb => { 120 + setExtLink({ 121 + ...extLink, 122 + isLoading: false, // done 123 + localThumb, 124 + }) 125 + }) 126 + return cleanup 127 + } 128 + if (extLink.isLoading) { 129 + setExtLink({ 130 + ...extLink, 131 + isLoading: false, // done 132 + }) 133 + } 134 + }, [extLink]) 135 + 79 136 useEffect(() => { 80 137 // HACK 81 138 // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view ··· 119 176 } else { 120 177 autocompleteView.setActive(false) 121 178 } 179 + 180 + if (!extLink && /\s$/.test(newText)) { 181 + const ents = extractEntities(newText) 182 + const entLink = ents 183 + ?.filter( 184 + ent => ent.type === 'link' && !attemptedExtLinks.includes(ent.value), 185 + ) 186 + .pop() // use last 187 + if (entLink) { 188 + setExtLink({ 189 + uri: entLink.value, 190 + isLoading: true, 191 + }) 192 + setAttemptedExtLinks([...attemptedExtLinks, entLink.value]) 193 + } 194 + } 122 195 } 123 196 const onPressCancel = () => { 124 197 onClose() ··· 141 214 store, 142 215 text, 143 216 replyTo?.uri, 217 + extLink, 144 218 selectedPhotos, 145 219 autocompleteView.knownHandles, 146 220 setProcessingState, ··· 297 371 selectedPhotos={selectedPhotos} 298 372 onSelectPhotos={onSelectPhotos} 299 373 /> 374 + {!selectedPhotos.length && extLink && ( 375 + <ExternalEmbed 376 + link={extLink} 377 + onRemove={() => setExtLink(undefined)} 378 + /> 379 + )} 300 380 </ScrollView> 301 381 {isSelectingPhotos && 302 382 localPhotos.photos != null &&
+125
src/view/com/composer/ExternalEmbed.tsx
··· 1 + import React from 'react' 2 + import { 3 + ActivityIndicator, 4 + StyleSheet, 5 + TouchableWithoutFeedback, 6 + View, 7 + } from 'react-native' 8 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 9 + import {BlurView} from '@react-native-community/blur' 10 + import LinearGradient from 'react-native-linear-gradient' 11 + import {AutoSizedImage} from '../util/images/AutoSizedImage' 12 + import {Text} from '../util/text/Text' 13 + import {s, gradients} from '../../lib/styles' 14 + import {usePalette} from '../../lib/hooks/usePalette' 15 + import {ExternalEmbedDraft} from '../../../state/lib/api' 16 + 17 + export const ExternalEmbed = ({ 18 + link, 19 + onRemove, 20 + }: { 21 + link?: ExternalEmbedDraft 22 + onRemove: () => void 23 + }) => { 24 + const pal = usePalette('default') 25 + const palError = usePalette('error') 26 + if (!link) { 27 + return <View /> 28 + } 29 + return ( 30 + <View style={[styles.outer, pal.view, pal.border]}> 31 + {link.isLoading ? ( 32 + <View 33 + style={[ 34 + styles.image, 35 + styles.imageFallback, 36 + {backgroundColor: pal.colors.backgroundLight}, 37 + ]}> 38 + <ActivityIndicator size="large" style={styles.spinner} /> 39 + </View> 40 + ) : link.localThumb ? ( 41 + <AutoSizedImage 42 + uri={link.localThumb.path} 43 + containerStyle={styles.image} 44 + /> 45 + ) : ( 46 + <LinearGradient 47 + colors={[gradients.blueDark.start, gradients.blueDark.end]} 48 + start={{x: 0, y: 0}} 49 + end={{x: 1, y: 1}} 50 + style={[styles.image, styles.imageFallback]} 51 + /> 52 + )} 53 + <TouchableWithoutFeedback onPress={onRemove}> 54 + <BlurView style={styles.removeBtn} blurType="dark"> 55 + <FontAwesomeIcon size={18} icon="xmark" style={s.white} /> 56 + </BlurView> 57 + </TouchableWithoutFeedback> 58 + <View style={styles.inner}> 59 + {!!link.meta?.title && ( 60 + <Text type="sm-bold" numberOfLines={2} style={[pal.text]}> 61 + {link.meta.title} 62 + </Text> 63 + )} 64 + <Text type="sm" numberOfLines={1} style={[pal.textLight, styles.uri]}> 65 + {link.uri} 66 + </Text> 67 + {!!link.meta?.description && ( 68 + <Text 69 + type="sm" 70 + numberOfLines={2} 71 + style={[pal.text, styles.description]}> 72 + {link.meta.description} 73 + </Text> 74 + )} 75 + {!!link.meta?.error && ( 76 + <Text 77 + type="sm" 78 + numberOfLines={2} 79 + style={[{color: palError.colors.background}, styles.description]}> 80 + {link.meta.error} 81 + </Text> 82 + )} 83 + </View> 84 + </View> 85 + ) 86 + } 87 + 88 + const styles = StyleSheet.create({ 89 + outer: { 90 + borderWidth: 1, 91 + borderRadius: 8, 92 + marginTop: 20, 93 + }, 94 + inner: { 95 + padding: 10, 96 + }, 97 + image: { 98 + borderTopLeftRadius: 6, 99 + borderTopRightRadius: 6, 100 + width: '100%', 101 + height: 200, 102 + }, 103 + imageFallback: { 104 + height: 160, 105 + }, 106 + removeBtn: { 107 + position: 'absolute', 108 + top: 10, 109 + right: 10, 110 + width: 36, 111 + height: 36, 112 + borderRadius: 18, 113 + alignItems: 'center', 114 + justifyContent: 'center', 115 + }, 116 + spinner: { 117 + marginTop: 60, 118 + }, 119 + uri: { 120 + marginTop: 2, 121 + }, 122 + description: { 123 + marginTop: 4, 124 + }, 125 + })
+1 -1
src/view/com/util/PostEmbeds.tsx
··· 132 132 borderTopLeftRadius: 6, 133 133 borderTopRightRadius: 6, 134 134 width: '100%', 135 - height: 200, 135 + maxHeight: 200, 136 136 }, 137 137 extImageFallback: { 138 138 height: 160,