diff options
Diffstat (limited to 'src/view/com/composer')
15 files changed, 925 insertions, 736 deletions
diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/Composer.tsx index f45c6340d..e9b728d73 100644 --- a/src/view/com/composer/ComposePost.tsx +++ b/src/view/com/composer/Composer.tsx @@ -1,74 +1,47 @@ -import React, {useEffect, useMemo, useRef, useState} from 'react' +import React, {useEffect, useRef, useState} from 'react' import {observer} from 'mobx-react-lite' import { ActivityIndicator, KeyboardAvoidingView, - NativeSyntheticEvent, Platform, SafeAreaView, ScrollView, StyleSheet, - TextInputSelectionChangeEventData, TouchableOpacity, TouchableWithoutFeedback, View, } from 'react-native' import LinearGradient from 'react-native-linear-gradient' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {useAnalytics} from 'lib/analytics' -import _isEqual from 'lodash.isequal' import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' -import {Autocomplete} from './autocomplete/Autocomplete' import {ExternalEmbed} from './ExternalEmbed' import {Text} from '../util/text/Text' import * as Toast from '../util/Toast' import {TextInput, TextInputRef} from './text-input/TextInput' import {CharProgress} from './char-progress/CharProgress' -import {TextLink} from '../util/Link' import {UserAvatar} from '../util/UserAvatar' import {useStores} from 'state/index' import * as apilib from 'lib/api/index' import {ComposerOpts} from 'state/models/shell-ui' import {s, colors, gradients} from 'lib/styles' import {cleanError} from 'lib/strings/errors' -import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection' -import {getLinkMeta} from 'lib/link-meta/link-meta' -import {getPostAsQuote} from 'lib/link-meta/bsky' -import {getImageDim, downloadAndResize} from 'lib/media/manip' -import {PhotoCarouselPicker} from './photos/PhotoCarouselPicker' -import {cropAndCompressFlow, pickImagesFlow} from '../../../lib/media/picker' -import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip' -import {isBskyPostUrl} from 'lib/strings/url-helpers' -import {SelectedPhoto} from './SelectedPhoto' +import {SelectPhotoBtn} from './photos/SelectPhotoBtn' +import {OpenCameraBtn} from './photos/OpenCameraBtn' +import {SelectedPhotos} from './photos/SelectedPhotos' import {usePalette} from 'lib/hooks/usePalette' -import { - POST_IMG_MAX_WIDTH, - POST_IMG_MAX_HEIGHT, - POST_IMG_MAX_SIZE, -} from 'lib/constants' -import {isWeb} from 'platform/detection' import QuoteEmbed from '../util/PostEmbeds/QuoteEmbed' +import {useExternalLinkFetch} from './useExternalLinkFetch' const MAX_TEXT_LENGTH = 256 -const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} - -interface Selection { - start: number - end: number -} export const ComposePost = observer(function ComposePost({ replyTo, - imagesOpen, onPost, onClose, quote: initQuote, }: { replyTo?: ComposerOpts['replyTo'] - imagesOpen?: ComposerOpts['imagesOpen'] onPost?: ComposerOpts['onPost'] onClose: () => void quote?: ComposerOpts['quote'] @@ -77,7 +50,6 @@ export const ComposePost = observer(function ComposePost({ const pal = usePalette('default') const store = useStores() const textInput = useRef<TextInputRef>(null) - const textInputSelection = useRef<Selection>({start: 0, end: 0}) const [isProcessing, setIsProcessing] = useState(false) const [processingState, setProcessingState] = useState('') const [error, setError] = useState('') @@ -85,15 +57,8 @@ export const ComposePost = observer(function ComposePost({ const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>( initQuote, ) - const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>( - undefined, - ) - const [suggestedExtLinks, setSuggestedExtLinks] = useState<Set<string>>( - new Set(), - ) - const [isSelectingPhotos, setIsSelectingPhotos] = useState( - imagesOpen || false, - ) + const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) + const [suggestedLinks, setSuggestedLinks] = useState<Set<string>>(new Set()) const [selectedPhotos, setSelectedPhotos] = useState<string[]>([]) const autocompleteView = React.useMemo<UserAutocompleteViewModel>( @@ -106,85 +71,16 @@ export const ComposePost = observer(function ComposePost({ // is focused during unmount, an exception will throw (seems that a blur method isnt implemented) // manually blurring before closing gets around that // -prf - const hackfixOnClose = () => { + const hackfixOnClose = React.useCallback(() => { textInput.current?.blur() onClose() - } + }, [textInput, onClose]) // initial setup useEffect(() => { autocompleteView.setup() }, [autocompleteView]) - // external link metadata-fetch flow - useEffect(() => { - let aborted = false - const cleanup = () => { - aborted = true - } - if (!extLink) { - return cleanup - } - if (!extLink.meta) { - if (isBskyPostUrl(extLink.uri)) { - getPostAsQuote(store, extLink.uri).then( - newQuote => { - if (aborted) { - return - } - setQuote(newQuote) - setExtLink(undefined) - }, - err => { - store.log.error('Failed to fetch post for quote embedding', {err}) - setExtLink(undefined) - }, - ) - } else { - getLinkMeta(store, 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: 2000, - height: 2000, - mode: 'contain', - maxSize: 1000000, - timeout: 15e3, - }) - .catch(() => undefined) - .then(localThumb => { - if (aborted) { - return - } - setExtLink({ - ...extLink, - isLoading: false, // done - localThumb, - }) - }) - return cleanup - } - if (extLink.isLoading) { - setExtLink({ - ...extLink, - isLoading: false, // done - }) - } - return cleanup - }, [store, extLink]) - useEffect(() => { // HACK // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view @@ -202,95 +98,36 @@ export const ComposePost = observer(function ComposePost({ } }, []) - const onPressContainer = () => { + const onPressContainer = React.useCallback(() => { textInput.current?.focus() - } - const onPressSelectPhotos = async () => { - track('ComposePost:SelectPhotos') - if (isWeb) { - if (selectedPhotos.length < 4) { - const images = await pickImagesFlow( - store, - 4 - selectedPhotos.length, - {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, - POST_IMG_MAX_SIZE, - ) - setSelectedPhotos([...selectedPhotos, ...images]) - } - } else { - if (isSelectingPhotos) { - setIsSelectingPhotos(false) - } else if (selectedPhotos.length < 4) { - setIsSelectingPhotos(true) - } - } - } - const onSelectPhotos = (photos: string[]) => { - track('ComposePost:SelectPhotos:Done') - setSelectedPhotos(photos) - if (photos.length >= 4) { - setIsSelectingPhotos(false) - } - } - const onPressAddLinkCard = (uri: string) => { - setExtLink({uri, isLoading: true}) - } - const onChangeText = (newText: string) => { - setText(newText) + }, [textInput]) - const prefix = getMentionAt(newText, textInputSelection.current?.start || 0) - if (prefix) { - autocompleteView.setActive(true) - autocompleteView.setPrefix(prefix.value) - } else { - autocompleteView.setActive(false) - } + const onSelectPhotos = React.useCallback( + (photos: string[]) => { + track('Composer:SelectedPhotos') + setSelectedPhotos(photos) + }, + [track, setSelectedPhotos], + ) - if (!extLink) { - const ents = extractEntities(newText)?.filter(ent => ent.type === 'link') - const set = new Set(ents ? ents.map(e => e.value) : []) - if (!_isEqual(set, suggestedExtLinks)) { - setSuggestedExtLinks(set) - } - } - } - const onPaste = async (err: string | undefined, uris: string[]) => { - if (err) { - return setError(cleanError(err)) - } - if (selectedPhotos.length >= 4) { - return - } - const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri)) - if (imgUri) { - let imgDim - try { - imgDim = await getImageDim(imgUri) - } catch (e) { - imgDim = {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT} + const onPressAddLinkCard = React.useCallback( + (uri: string) => { + setExtLink({uri, isLoading: true}) + }, + [setExtLink], + ) + + const onPhotoPasted = React.useCallback( + async (uri: string) => { + if (selectedPhotos.length >= 4) { + return } - const finalImgPath = await cropAndCompressFlow( - store, - imgUri, - imgDim, - {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, - POST_IMG_MAX_SIZE, - ) - onSelectPhotos([...selectedPhotos, finalImgPath]) - } - } - const onSelectionChange = ( - evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>, - ) => { - // NOTE we track the input selection using a ref to avoid excessive renders -prf - textInputSelection.current = evt.nativeEvent.selection - } - const onSelectAutocompleteItem = (item: string) => { - setText(insertMentionAt(text, textInputSelection.current?.start || 0, item)) - autocompleteView.setActive(false) - } - const onPressCancel = () => hackfixOnClose() - const onPressPublish = async () => { + onSelectPhotos([...selectedPhotos, uri]) + }, + [selectedPhotos, onSelectPhotos], + ) + + const onPressPublish = React.useCallback(async () => { if (isProcessing) { return } @@ -332,7 +169,22 @@ export const ComposePost = observer(function ComposePost({ onPost?.() hackfixOnClose() Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`) - } + }, [ + isProcessing, + text, + setError, + setIsProcessing, + replyTo, + autocompleteView.knownHandles, + extLink, + hackfixOnClose, + onPost, + quote, + selectedPhotos, + setExtLink, + store, + track, + ]) const canPost = text.length <= MAX_TEXT_LENGTH @@ -346,25 +198,6 @@ export const ComposePost = observer(function ComposePost({ ? 'Write a comment' : "What's up?" - const textDecorated = useMemo(() => { - let i = 0 - return detectLinkables(text).map(v => { - if (typeof v === 'string') { - return ( - <Text key={i++} style={[pal.text, styles.textInputFormatting]}> - {v} - </Text> - ) - } else { - return ( - <Text key={i++} style={[pal.link, styles.textInputFormatting]}> - {v.link} - </Text> - ) - } - }) - }, [text, pal.link, pal.text]) - return ( <KeyboardAvoidingView testID="composePostView" @@ -375,7 +208,7 @@ export const ComposePost = observer(function ComposePost({ <View style={styles.topbar}> <TouchableOpacity testID="composerCancelButton" - onPress={onPressCancel}> + onPress={hackfixOnClose}> <Text style={[pal.link, s.f18]}>Cancel</Text> </TouchableOpacity> <View style={s.flex1} /> @@ -423,19 +256,11 @@ export const ComposePost = observer(function ComposePost({ <ScrollView style={s.flex1}> {replyTo ? ( <View style={[pal.border, styles.replyToLayout]}> - <UserAvatar - handle={replyTo.author.handle} - displayName={replyTo.author.displayName} - avatar={replyTo.author.avatar} - size={50} - /> + <UserAvatar avatar={replyTo.author.avatar} size={50} /> <View style={styles.replyToPost}> - <TextLink - type="xl-medium" - href={`/profile/${replyTo.author.handle}`} - text={replyTo.author.displayName || replyTo.author.handle} - style={[pal.text]} - /> + <Text type="xl-medium" style={[pal.text]}> + {replyTo.author.displayName || replyTo.author.handle} + </Text> <Text type="post-text" style={pal.text} numberOfLines={6}> {replyTo.text} </Text> @@ -449,26 +274,18 @@ export const ComposePost = observer(function ComposePost({ styles.textInputLayout, selectTextInputLayout, ]}> - <UserAvatar - handle={store.me.handle || ''} - displayName={store.me.displayName} - avatar={store.me.avatar} - size={50} - /> + <UserAvatar avatar={store.me.avatar} size={50} /> <TextInput - testID="composerTextInput" - innerRef={textInput} - onChangeText={(str: string) => onChangeText(str)} - onPaste={onPaste} - onSelectionChange={onSelectionChange} + ref={textInput} + text={text} placeholder={selectTextInputPlaceholder} - style={[ - pal.text, - styles.textInput, - styles.textInputFormatting, - ]}> - {textDecorated} - </TextInput> + suggestedLinks={suggestedLinks} + autocompleteView={autocompleteView} + onTextChanged={setText} + onPhotoPasted={onPhotoPasted} + onSuggestedLinksChanged={setSuggestedLinks} + onError={setError} + /> </View> {quote ? ( @@ -477,7 +294,7 @@ export const ComposePost = observer(function ComposePost({ </View> ) : undefined} - <SelectedPhoto + <SelectedPhotos selectedPhotos={selectedPhotos} onSelectPhotos={onSelectPhotos} /> @@ -488,17 +305,12 @@ export const ComposePost = observer(function ComposePost({ /> )} </ScrollView> - {isSelectingPhotos && selectedPhotos.length < 4 ? ( - <PhotoCarouselPicker - selectedPhotos={selectedPhotos} - onSelectPhotos={onSelectPhotos} - /> - ) : !extLink && - selectedPhotos.length === 0 && - suggestedExtLinks.size > 0 && - !quote ? ( + {!extLink && + selectedPhotos.length === 0 && + suggestedLinks.size > 0 && + !quote ? ( <View style={s.mb5}> - {Array.from(suggestedExtLinks).map(url => ( + {Array.from(suggestedLinks).map(url => ( <TouchableOpacity key={`suggested-${url}`} style={[pal.borderDark, styles.addExtLinkBtn]} @@ -511,31 +323,19 @@ export const ComposePost = observer(function ComposePost({ </View> ) : null} <View style={[pal.border, styles.bottomBar]}> - {quote ? undefined : ( - <TouchableOpacity - testID="composerSelectPhotosButton" - onPress={onPressSelectPhotos} - style={[s.pl5]} - hitSlop={HITSLOP}> - <FontAwesomeIcon - icon={['far', 'image']} - style={ - (selectedPhotos.length < 4 - ? pal.link - : pal.textLight) as FontAwesomeIconStyle - } - size={24} - /> - </TouchableOpacity> - )} + <SelectPhotoBtn + enabled={!quote && selectedPhotos.length < 4} + selectedPhotos={selectedPhotos} + onSelectPhotos={setSelectedPhotos} + /> + <OpenCameraBtn + enabled={!quote && selectedPhotos.length < 4} + selectedPhotos={selectedPhotos} + onSelectPhotos={setSelectedPhotos} + /> <View style={s.flex1} /> <CharProgress count={text.length} /> </View> - <Autocomplete - active={autocompleteView.isActive} - items={autocompleteView.suggestions} - onSelect={onSelectAutocompleteItem} - /> </SafeAreaView> </TouchableWithoutFeedback> </KeyboardAvoidingView> @@ -597,18 +397,6 @@ const styles = StyleSheet.create({ borderTopWidth: 1, paddingTop: 16, }, - textInput: { - flex: 1, - padding: 5, - marginLeft: 8, - alignSelf: 'flex-start', - }, - textInputFormatting: { - fontSize: 18, - letterSpacing: 0.2, - fontWeight: '400', - lineHeight: 23.4, // 1.3*16 - }, replyToLayout: { flexDirection: 'row', borderTopWidth: 1, diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index 23dcaffd5..658023330 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -75,6 +75,7 @@ const styles = StyleSheet.create({ borderWidth: 1, borderRadius: 8, marginTop: 20, + marginBottom: 10, }, inner: { padding: 10, diff --git a/src/view/com/composer/Prompt.tsx b/src/view/com/composer/Prompt.tsx index 88d5de2bf..301b90093 100644 --- a/src/view/com/composer/Prompt.tsx +++ b/src/view/com/composer/Prompt.tsx @@ -4,12 +4,9 @@ import {UserAvatar} from '../util/UserAvatar' import {Text} from '../util/text/Text' import {usePalette} from 'lib/hooks/usePalette' import {useStores} from 'state/index' +import {isDesktopWeb} from 'platform/detection' -export function ComposePrompt({ - onPressCompose, -}: { - onPressCompose: (imagesOpen?: boolean) => void -}) { +export function ComposePrompt({onPressCompose}: {onPressCompose: () => void}) { const store = useStores() const pal = usePalette('default') return ( @@ -17,13 +14,13 @@ export function ComposePrompt({ testID="replyPromptBtn" style={[pal.view, pal.border, styles.prompt]} onPress={() => onPressCompose()}> - <UserAvatar - handle={store.me.handle} - avatar={store.me.avatar} - displayName={store.me.displayName} - size={38} - /> - <Text type="xl" style={[pal.text, styles.label]}> + <UserAvatar avatar={store.me.avatar} size={38} /> + <Text + type="xl" + style={[ + pal.text, + isDesktopWeb ? styles.labelDesktopWeb : styles.labelMobile, + ]}> Write your reply </Text> </TouchableOpacity> @@ -39,7 +36,10 @@ const styles = StyleSheet.create({ alignItems: 'center', borderTopWidth: 1, }, - label: { + labelMobile: { paddingLeft: 12, }, + labelDesktopWeb: { + paddingLeft: 20, + }, }) diff --git a/src/view/com/composer/autocomplete/Autocomplete.tsx b/src/view/com/composer/autocomplete/Autocomplete.tsx deleted file mode 100644 index 82fb239da..000000000 --- a/src/view/com/composer/autocomplete/Autocomplete.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import React, {useEffect} from 'react' -import { - Animated, - TouchableOpacity, - StyleSheet, - useWindowDimensions, -} from 'react-native' -import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' -import {usePalette} from 'lib/hooks/usePalette' -import {Text} from '../../util/text/Text' - -interface AutocompleteItem { - handle: string - displayName?: string -} - -export function Autocomplete({ - active, - items, - onSelect, -}: { - active: boolean - items: AutocompleteItem[] - onSelect: (item: string) => void -}) { - const pal = usePalette('default') - const winDim = useWindowDimensions() - const positionInterp = useAnimatedValue(0) - - useEffect(() => { - Animated.timing(positionInterp, { - toValue: active ? 1 : 0, - duration: 200, - useNativeDriver: false, - }).start() - }, [positionInterp, active]) - - const topAnimStyle = { - top: positionInterp.interpolate({ - inputRange: [0, 1], - outputRange: [winDim.height, winDim.height / 4], - }), - } - return ( - <Animated.View style={[styles.outer, pal.view, pal.border, topAnimStyle]}> - {items.map((item, i) => ( - <TouchableOpacity - testID="autocompleteButton" - key={i} - style={[pal.border, styles.item]} - onPress={() => onSelect(item.handle)}> - <Text type="md-medium" style={pal.text}> - {item.displayName || item.handle} - <Text type="sm" style={pal.textLight}> - @{item.handle} - </Text> - </Text> - </TouchableOpacity> - ))} - </Animated.View> - ) -} - -const styles = StyleSheet.create({ - outer: { - position: 'absolute', - left: 0, - right: 0, - bottom: 0, - borderTopWidth: 1, - }, - item: { - borderBottomWidth: 1, - paddingVertical: 16, - paddingHorizontal: 16, - }, -}) diff --git a/src/view/com/composer/autocomplete/Autocomplete.web.tsx b/src/view/com/composer/autocomplete/Autocomplete.web.tsx deleted file mode 100644 index b6be1c21e..000000000 --- a/src/view/com/composer/autocomplete/Autocomplete.web.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react' -import {TouchableOpacity, StyleSheet, View} from 'react-native' -import {usePalette} from 'lib/hooks/usePalette' -import {Text} from '../../util/text/Text' - -interface AutocompleteItem { - handle: string - displayName?: string -} - -export function Autocomplete({ - active, - items, - onSelect, -}: { - active: boolean - items: AutocompleteItem[] - onSelect: (item: string) => void -}) { - const pal = usePalette('default') - - if (!active) { - return <View /> - } - return ( - <View style={[styles.outer, pal.view, pal.border]}> - {items.map((item, i) => ( - <TouchableOpacity - testID="autocompleteButton" - key={i} - style={[pal.border, styles.item]} - onPress={() => onSelect(item.handle)}> - <Text type="md-medium" style={pal.text}> - {item.displayName || item.handle} - <Text type="sm" style={pal.textLight}> - @{item.handle} - </Text> - </Text> - </TouchableOpacity> - ))} - </View> - ) -} - -const styles = StyleSheet.create({ - outer: { - position: 'absolute', - left: 0, - right: 0, - top: '100%', - borderWidth: 1, - borderRadius: 8, - }, - item: { - borderBottomWidth: 1, - paddingVertical: 16, - paddingHorizontal: 16, - }, -}) diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx new file mode 100644 index 000000000..cf4a4c7d1 --- /dev/null +++ b/src/view/com/composer/photos/OpenCameraBtn.tsx @@ -0,0 +1,84 @@ +import React from 'react' +import {TouchableOpacity} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' +import {useStores} from 'state/index' +import {s} from 'lib/styles' +import {isDesktopWeb} from 'platform/detection' +import {openCamera} from 'lib/media/picker' +import {compressIfNeeded} from 'lib/media/manip' +import {useCameraPermission} from 'lib/hooks/usePermissions' +import { + POST_IMG_MAX_WIDTH, + POST_IMG_MAX_HEIGHT, + POST_IMG_MAX_SIZE, +} from 'lib/constants' + +const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} + +export function OpenCameraBtn({ + enabled, + selectedPhotos, + onSelectPhotos, +}: { + enabled: boolean + selectedPhotos: string[] + onSelectPhotos: (v: string[]) => void +}) { + const pal = usePalette('default') + const {track} = useAnalytics() + const store = useStores() + const {requestCameraAccessIfNeeded} = useCameraPermission() + + const onPressTakePicture = React.useCallback(async () => { + track('Composer:CameraOpened') + if (!enabled) { + return + } + try { + if (!(await requestCameraAccessIfNeeded())) { + return + } + const cameraRes = await openCamera(store, { + mediaType: 'photo', + width: POST_IMG_MAX_WIDTH, + height: POST_IMG_MAX_HEIGHT, + freeStyleCropEnabled: true, + }) + const img = await compressIfNeeded(cameraRes, POST_IMG_MAX_SIZE) + onSelectPhotos([...selectedPhotos, img.path]) + } catch (err: any) { + // ignore + store.log.warn('Error using camera', err) + } + }, [ + track, + store, + onSelectPhotos, + selectedPhotos, + enabled, + requestCameraAccessIfNeeded, + ]) + + if (isDesktopWeb) { + return <></> + } + + return ( + <TouchableOpacity + testID="openCameraButton" + onPress={onPressTakePicture} + style={[s.pl5]} + hitSlop={HITSLOP}> + <FontAwesomeIcon + icon="camera" + style={(enabled ? pal.link : pal.textLight) as FontAwesomeIconStyle} + size={24} + /> + </TouchableOpacity> + ) +} diff --git a/src/view/com/composer/photos/PhotoCarouselPicker.tsx b/src/view/com/composer/photos/PhotoCarouselPicker.tsx deleted file mode 100644 index 580e9746e..000000000 --- a/src/view/com/composer/photos/PhotoCarouselPicker.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import React, {useCallback} from 'react' -import {Image, StyleSheet, TouchableOpacity, ScrollView} from 'react-native' -import { - FontAwesomeIcon, - FontAwesomeIconStyle, -} from '@fortawesome/react-native-fontawesome' -import {useAnalytics} from 'lib/analytics' -import { - openPicker, - openCamera, - cropAndCompressFlow, -} from '../../../../lib/media/picker' -import { - UserLocalPhotosModel, - PhotoIdentifier, -} from 'state/models/user-local-photos' -import {compressIfNeeded} from 'lib/media/manip' -import {usePalette} from 'lib/hooks/usePalette' -import {useStores} from 'state/index' -import { - requestPhotoAccessIfNeeded, - requestCameraAccessIfNeeded, -} from 'lib/permissions' -import { - POST_IMG_MAX_WIDTH, - POST_IMG_MAX_HEIGHT, - POST_IMG_MAX_SIZE, -} from 'lib/constants' - -export const PhotoCarouselPicker = ({ - selectedPhotos, - onSelectPhotos, -}: { - selectedPhotos: string[] - onSelectPhotos: (v: string[]) => void -}) => { - const {track} = useAnalytics() - const pal = usePalette('default') - const store = useStores() - const [isSetup, setIsSetup] = React.useState<boolean>(false) - - const localPhotos = React.useMemo<UserLocalPhotosModel>( - () => new UserLocalPhotosModel(store), - [store], - ) - - React.useEffect(() => { - // initial setup - localPhotos.setup().then(() => { - setIsSetup(true) - }) - }, [localPhotos]) - - const handleOpenCamera = useCallback(async () => { - try { - if (!(await requestCameraAccessIfNeeded())) { - return - } - const cameraRes = await openCamera(store, { - mediaType: 'photo', - width: POST_IMG_MAX_WIDTH, - height: POST_IMG_MAX_HEIGHT, - freeStyleCropEnabled: true, - }) - const img = await compressIfNeeded(cameraRes, POST_IMG_MAX_SIZE) - onSelectPhotos([...selectedPhotos, img.path]) - } catch (err: any) { - // ignore - store.log.warn('Error using camera', err) - } - }, [store, selectedPhotos, onSelectPhotos]) - - const handleSelectPhoto = useCallback( - async (item: PhotoIdentifier) => { - track('PhotoCarouselPicker:PhotoSelected') - try { - const imgPath = await cropAndCompressFlow( - store, - item.node.image.uri, - { - width: item.node.image.width, - height: item.node.image.height, - }, - {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, - POST_IMG_MAX_SIZE, - ) - onSelectPhotos([...selectedPhotos, imgPath]) - } catch (err: any) { - // ignore - store.log.warn('Error selecting photo', err) - } - }, - [track, store, onSelectPhotos, selectedPhotos], - ) - - const handleOpenGallery = useCallback(async () => { - track('PhotoCarouselPicker:GalleryOpened') - if (!(await requestPhotoAccessIfNeeded())) { - return - } - const items = await openPicker(store, { - multiple: true, - maxFiles: 4 - selectedPhotos.length, - mediaType: 'photo', - }) - const result = [] - for (const image of items) { - result.push( - await cropAndCompressFlow( - store, - image.path, - image, - {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, - POST_IMG_MAX_SIZE, - ), - ) - } - onSelectPhotos([...selectedPhotos, ...result]) - }, [track, store, selectedPhotos, onSelectPhotos]) - - return ( - <ScrollView - testID="photoCarouselPickerView" - horizontal - style={[pal.view, styles.photosContainer]} - keyboardShouldPersistTaps="always" - showsHorizontalScrollIndicator={false}> - <TouchableOpacity - testID="openCameraButton" - style={[styles.galleryButton, pal.border, styles.photo]} - onPress={handleOpenCamera}> - <FontAwesomeIcon - icon="camera" - size={24} - style={pal.link as FontAwesomeIconStyle} - /> - </TouchableOpacity> - <TouchableOpacity - testID="openGalleryButton" - style={[styles.galleryButton, pal.border, styles.photo]} - onPress={handleOpenGallery}> - <FontAwesomeIcon - icon="image" - style={pal.link as FontAwesomeIconStyle} - size={24} - /> - </TouchableOpacity> - {isSetup && - localPhotos.photos.map((item: PhotoIdentifier, index: number) => ( - <TouchableOpacity - testID="openSelectPhotoButton" - key={`local-image-${index}`} - style={[pal.border, styles.photoButton]} - onPress={() => handleSelectPhoto(item)}> - <Image style={styles.photo} source={{uri: item.node.image.uri}} /> - </TouchableOpacity> - ))} - </ScrollView> - ) -} - -const styles = StyleSheet.create({ - photosContainer: { - width: '100%', - maxHeight: 96, - padding: 8, - overflow: 'hidden', - }, - galleryButton: { - borderWidth: 1, - alignItems: 'center', - justifyContent: 'center', - }, - photoButton: { - width: 75, - height: 75, - marginRight: 8, - borderWidth: 1, - borderRadius: 16, - }, - photo: { - width: 75, - height: 75, - marginRight: 8, - borderRadius: 16, - }, -}) diff --git a/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx b/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx deleted file mode 100644 index ff4350b0c..000000000 --- a/src/view/com/composer/photos/PhotoCarouselPicker.web.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react' - -// Not used on Web - -export const PhotoCarouselPicker = (_opts: { - selectedPhotos: string[] - onSelectPhotos: (v: string[]) => void -}) => { - return <></> -} diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx new file mode 100644 index 000000000..bdcb0534a --- /dev/null +++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx @@ -0,0 +1,94 @@ +import React from 'react' +import {TouchableOpacity} from 'react-native' +import { + FontAwesomeIcon, + FontAwesomeIconStyle, +} from '@fortawesome/react-native-fontawesome' +import {usePalette} from 'lib/hooks/usePalette' +import {useAnalytics} from 'lib/analytics' +import {useStores} from 'state/index' +import {s} from 'lib/styles' +import {isDesktopWeb} from 'platform/detection' +import {openPicker, cropAndCompressFlow, pickImagesFlow} from 'lib/media/picker' +import {usePhotoLibraryPermission} from 'lib/hooks/usePermissions' +import { + POST_IMG_MAX_WIDTH, + POST_IMG_MAX_HEIGHT, + POST_IMG_MAX_SIZE, +} from 'lib/constants' + +const HITSLOP = {left: 10, top: 10, right: 10, bottom: 10} + +export function SelectPhotoBtn({ + enabled, + selectedPhotos, + onSelectPhotos, +}: { + enabled: boolean + selectedPhotos: string[] + onSelectPhotos: (v: string[]) => void +}) { + const pal = usePalette('default') + const {track} = useAnalytics() + const store = useStores() + const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() + + const onPressSelectPhotos = React.useCallback(async () => { + track('Composer:GalleryOpened') + if (!enabled) { + return + } + if (isDesktopWeb) { + const images = await pickImagesFlow( + store, + 4 - selectedPhotos.length, + {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, + POST_IMG_MAX_SIZE, + ) + onSelectPhotos([...selectedPhotos, ...images]) + } else { + if (!(await requestPhotoAccessIfNeeded())) { + return + } + const items = await openPicker(store, { + multiple: true, + maxFiles: 4 - selectedPhotos.length, + mediaType: 'photo', + }) + const result = [] + for (const image of items) { + result.push( + await cropAndCompressFlow( + store, + image.path, + image, + {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, + POST_IMG_MAX_SIZE, + ), + ) + } + onSelectPhotos([...selectedPhotos, ...result]) + } + }, [ + track, + store, + onSelectPhotos, + selectedPhotos, + enabled, + requestPhotoAccessIfNeeded, + ]) + + return ( + <TouchableOpacity + testID="openGalleryBtn" + onPress={onPressSelectPhotos} + style={[s.pl5, s.pr20]} + hitSlop={HITSLOP}> + <FontAwesomeIcon + icon={['far', 'image']} + style={(enabled ? pal.link : pal.textLight) as FontAwesomeIconStyle} + size={24} + /> + </TouchableOpacity> + ) +} diff --git a/src/view/com/composer/SelectedPhoto.tsx b/src/view/com/composer/photos/SelectedPhotos.tsx index 6aeda33cd..c2a00ce53 100644 --- a/src/view/com/composer/SelectedPhoto.tsx +++ b/src/view/com/composer/photos/SelectedPhotos.tsx @@ -4,7 +4,7 @@ import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import Image from 'view/com/util/images/Image' import {colors} from 'lib/styles' -export const SelectedPhoto = ({ +export const SelectedPhotos = ({ selectedPhotos, onSelectPhotos, }: { diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx index be6150e11..2a40fb518 100644 --- a/src/view/com/composer/text-input/TextInput.tsx +++ b/src/view/com/composer/text-input/TextInput.tsx @@ -1,64 +1,222 @@ import React from 'react' import { NativeSyntheticEvent, - StyleProp, + StyleSheet, TextInputSelectionChangeEventData, - TextStyle, } from 'react-native' import PasteInput, { PastedFile, PasteInputRef, } from '@mattermost/react-native-paste-input' +import isEqual from 'lodash.isequal' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {Autocomplete} from './mobile/Autocomplete' +import {Text} from 'view/com/util/text/Text' +import {useStores} from 'state/index' +import {cleanError} from 'lib/strings/errors' +import {detectLinkables, extractEntities} from 'lib/strings/rich-text-detection' +import {getImageDim} from 'lib/media/manip' +import {cropAndCompressFlow} from 'lib/media/picker' +import {getMentionAt, insertMentionAt} from 'lib/strings/mention-manip' +import { + POST_IMG_MAX_WIDTH, + POST_IMG_MAX_HEIGHT, + POST_IMG_MAX_SIZE, +} from 'lib/constants' import {usePalette} from 'lib/hooks/usePalette' +import {useTheme} from 'lib/ThemeContext' -export type TextInputRef = PasteInputRef +export interface TextInputRef { + focus: () => void + blur: () => void +} interface TextInputProps { - testID: string - innerRef: React.Ref<TextInputRef> + text: string placeholder: string - style: StyleProp<TextStyle> - onChangeText: (str: string) => void - onSelectionChange?: - | ((e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void) - | undefined - onPaste: (err: string | undefined, uris: string[]) => void + suggestedLinks: Set<string> + autocompleteView: UserAutocompleteViewModel + onTextChanged: (v: string) => void + onPhotoPasted: (uri: string) => void + onSuggestedLinksChanged: (uris: Set<string>) => void + onError: (err: string) => void } -export function TextInput({ - testID, - innerRef, - placeholder, - style, - onChangeText, - onSelectionChange, - onPaste, - children, -}: React.PropsWithChildren<TextInputProps>) { - const pal = usePalette('default') - const onPasteInner = (err: string | undefined, files: PastedFile[]) => { - if (err) { - onPaste(err, []) - } else { - onPaste( - undefined, - files.map(f => f.uri), - ) - } - } - return ( - <PasteInput - testID={testID} - ref={innerRef} - multiline - scrollEnabled - onChangeText={(str: string) => onChangeText(str)} - onSelectionChange={onSelectionChange} - onPaste={onPasteInner} - placeholder={placeholder} - placeholderTextColor={pal.colors.textLight} - style={style}> - {children} - </PasteInput> - ) +interface Selection { + start: number + end: number } + +export const TextInput = React.forwardRef( + ( + { + text, + placeholder, + suggestedLinks, + autocompleteView, + onTextChanged, + onPhotoPasted, + onSuggestedLinksChanged, + onError, + }: TextInputProps, + ref, + ) => { + const pal = usePalette('default') + const store = useStores() + const textInput = React.useRef<PasteInputRef>(null) + const textInputSelection = React.useRef<Selection>({start: 0, end: 0}) + const theme = useTheme() + + React.useImperativeHandle(ref, () => ({ + focus: () => textInput.current?.focus(), + blur: () => textInput.current?.blur(), + })) + + React.useEffect(() => { + // HACK + // wait a moment before focusing the input to resolve some layout bugs with the keyboard-avoiding-view + // -prf + let to: NodeJS.Timeout | undefined + if (textInput.current) { + to = setTimeout(() => { + textInput.current?.focus() + }, 250) + } + return () => { + if (to) { + clearTimeout(to) + } + } + }, []) + + const onChangeText = React.useCallback( + (newText: string) => { + onTextChanged(newText) + + const prefix = getMentionAt( + newText, + textInputSelection.current?.start || 0, + ) + if (prefix) { + autocompleteView.setActive(true) + autocompleteView.setPrefix(prefix.value) + } else { + autocompleteView.setActive(false) + } + + const ents = extractEntities(newText)?.filter( + ent => ent.type === 'link', + ) + const set = new Set(ents ? ents.map(e => e.value) : []) + if (!isEqual(set, suggestedLinks)) { + onSuggestedLinksChanged(set) + } + }, + [ + onTextChanged, + autocompleteView, + suggestedLinks, + onSuggestedLinksChanged, + ], + ) + + const onPaste = React.useCallback( + async (err: string | undefined, files: PastedFile[]) => { + if (err) { + return onError(cleanError(err)) + } + const uris = files.map(f => f.uri) + const imgUri = uris.find(uri => /\.(jpe?g|png)$/.test(uri)) + if (imgUri) { + let imgDim + try { + imgDim = await getImageDim(imgUri) + } catch (e) { + imgDim = {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT} + } + const finalImgPath = await cropAndCompressFlow( + store, + imgUri, + imgDim, + {width: POST_IMG_MAX_WIDTH, height: POST_IMG_MAX_HEIGHT}, + POST_IMG_MAX_SIZE, + ) + onPhotoPasted(finalImgPath) + } + }, + [store, onError, onPhotoPasted], + ) + + const onSelectionChange = React.useCallback( + (evt: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => { + // NOTE we track the input selection using a ref to avoid excessive renders -prf + textInputSelection.current = evt.nativeEvent.selection + }, + [textInputSelection], + ) + + const onSelectAutocompleteItem = React.useCallback( + (item: string) => { + onChangeText( + insertMentionAt(text, textInputSelection.current?.start || 0, item), + ) + autocompleteView.setActive(false) + }, + [onChangeText, text, autocompleteView], + ) + + const textDecorated = React.useMemo(() => { + let i = 0 + return detectLinkables(text).map(v => { + if (typeof v === 'string') { + return ( + <Text key={i++} style={[pal.text, styles.textInputFormatting]}> + {v} + </Text> + ) + } else { + return ( + <Text key={i++} style={[pal.link, styles.textInputFormatting]}> + {v.link} + </Text> + ) + } + }) + }, [text, pal.link, pal.text]) + + return ( + <> + <PasteInput + testID="composerTextInput" + ref={textInput} + onChangeText={onChangeText} + onPaste={onPaste} + onSelectionChange={onSelectionChange} + placeholder={placeholder} + keyboardAppearance={theme.colorScheme} + style={[pal.text, styles.textInput, styles.textInputFormatting]}> + {textDecorated} + </PasteInput> + <Autocomplete + view={autocompleteView} + onSelect={onSelectAutocompleteItem} + /> + </> + ) + }, +) + +const styles = StyleSheet.create({ + textInput: { + flex: 1, + padding: 5, + marginLeft: 8, + alignSelf: 'flex-start', + }, + textInputFormatting: { + fontSize: 18, + letterSpacing: 0.2, + fontWeight: '400', + lineHeight: 23.4, // 1.3*16 + }, +}) diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx index 2b610850c..67ef836a0 100644 --- a/src/view/com/composer/text-input/TextInput.web.tsx +++ b/src/view/com/composer/text-input/TextInput.web.tsx @@ -1,58 +1,133 @@ import React from 'react' -import { - NativeSyntheticEvent, - StyleProp, - StyleSheet, - TextInput as RNTextInput, - TextInputSelectionChangeEventData, - TextStyle, -} from 'react-native' -import {usePalette} from 'lib/hooks/usePalette' -import {addStyle} from 'lib/styles' - -export type TextInputRef = RNTextInput +import {StyleSheet, View} from 'react-native' +import {useEditor, EditorContent, JSONContent} from '@tiptap/react' +import {Document} from '@tiptap/extension-document' +import {Link} from '@tiptap/extension-link' +import {Mention} from '@tiptap/extension-mention' +import {Paragraph} from '@tiptap/extension-paragraph' +import {Placeholder} from '@tiptap/extension-placeholder' +import {Text} from '@tiptap/extension-text' +import isEqual from 'lodash.isequal' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {createSuggestion} from './web/Autocomplete' + +export interface TextInputRef { + focus: () => void + blur: () => void +} interface TextInputProps { - testID: string - innerRef: React.Ref<TextInputRef> + text: string placeholder: string - style: StyleProp<TextStyle> - onChangeText: (str: string) => void - onSelectionChange?: - | ((e: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void) - | undefined - onPaste: (err: string | undefined, uris: string[]) => void + suggestedLinks: Set<string> + autocompleteView: UserAutocompleteViewModel + onTextChanged: (v: string) => void + onPhotoPasted: (uri: string) => void + onSuggestedLinksChanged: (uris: Set<string>) => void + onError: (err: string) => void } -export function TextInput({ - testID, - innerRef, - placeholder, - style, - onChangeText, - onSelectionChange, - children, -}: React.PropsWithChildren<TextInputProps>) { - const pal = usePalette('default') - style = addStyle(style, styles.input) - return ( - <RNTextInput - testID={testID} - ref={innerRef} - multiline - scrollEnabled - onChangeText={(str: string) => onChangeText(str)} - onSelectionChange={onSelectionChange} - placeholder={placeholder} - placeholderTextColor={pal.colors.textLight} - style={style}> - {children} - </RNTextInput> - ) +export const TextInput = React.forwardRef( + ( + { + text, + placeholder, + suggestedLinks, + autocompleteView, + onTextChanged, + // onPhotoPasted, TODO + onSuggestedLinksChanged, + }: // onError, TODO + TextInputProps, + ref, + ) => { + const editor = useEditor({ + extensions: [ + Document, + Link.configure({ + protocols: ['http', 'https'], + autolink: true, + }), + Mention.configure({ + HTMLAttributes: { + class: 'mention', + }, + suggestion: createSuggestion({autocompleteView}), + }), + Paragraph, + Placeholder.configure({ + placeholder, + }), + Text, + ], + content: text, + autofocus: true, + editable: true, + injectCSS: true, + onUpdate({editor: editorProp}) { + const json = editorProp.getJSON() + const newText = editorJsonToText(json).trim() + onTextChanged(newText) + + const newSuggestedLinks = new Set(editorJsonToLinks(json)) + if (!isEqual(newSuggestedLinks, suggestedLinks)) { + onSuggestedLinksChanged(newSuggestedLinks) + } + }, + }) + + React.useImperativeHandle(ref, () => ({ + focus: () => {}, // TODO + blur: () => {}, // TODO + })) + + return ( + <View style={styles.container}> + <EditorContent editor={editor} /> + </View> + ) + }, +) + +function editorJsonToText(json: JSONContent): string { + let text = '' + if (json.type === 'doc' || json.type === 'paragraph') { + if (json.content?.length) { + for (const node of json.content) { + text += editorJsonToText(node) + } + } + text += '\n' + } else if (json.type === 'text') { + text += json.text || '' + } else if (json.type === 'mention') { + text += json.attrs?.id || '' + } + return text +} + +function editorJsonToLinks(json: JSONContent): string[] { + let links: string[] = [] + if (json.content?.length) { + for (const node of json.content) { + links = links.concat(editorJsonToLinks(node)) + } + } + + const link = json.marks?.find(m => m.type === 'link') + if (link?.attrs?.href) { + links.push(link.attrs.href) + } + + return links } const styles = StyleSheet.create({ - input: { - minHeight: 140, + container: { + flex: 1, + alignSelf: 'flex-start', + padding: 5, + marginLeft: 8, + marginBottom: 10, }, }) diff --git a/src/view/com/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx new file mode 100644 index 000000000..424a8629f --- /dev/null +++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx @@ -0,0 +1,75 @@ +import React, {useEffect} from 'react' +import { + Animated, + TouchableOpacity, + StyleSheet, + useWindowDimensions, +} from 'react-native' +import {observer} from 'mobx-react-lite' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +import {useAnimatedValue} from 'lib/hooks/useAnimatedValue' +import {usePalette} from 'lib/hooks/usePalette' +import {Text} from 'view/com/util/text/Text' + +export const Autocomplete = observer( + ({ + view, + onSelect, + }: { + view: UserAutocompleteViewModel + onSelect: (item: string) => void + }) => { + const pal = usePalette('default') + const winDim = useWindowDimensions() + const positionInterp = useAnimatedValue(0) + + useEffect(() => { + Animated.timing(positionInterp, { + toValue: view.isActive ? 1 : 0, + duration: 200, + useNativeDriver: false, + }).start() + }, [positionInterp, view.isActive]) + + const topAnimStyle = { + top: positionInterp.interpolate({ + inputRange: [0, 1], + outputRange: [winDim.height, winDim.height / 4], + }), + } + return ( + <Animated.View style={[styles.outer, pal.view, pal.border, topAnimStyle]}> + {view.suggestions.map(item => ( + <TouchableOpacity + testID="autocompleteButton" + key={item.handle} + style={[pal.border, styles.item]} + onPress={() => onSelect(item.handle)}> + <Text type="md-medium" style={pal.text}> + {item.displayName || item.handle} + <Text type="sm" style={pal.textLight}> + @{item.handle} + </Text> + </Text> + </TouchableOpacity> + ))} + </Animated.View> + ) + }, +) + +const styles = StyleSheet.create({ + outer: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + borderTopWidth: 1, + }, + item: { + borderBottomWidth: 1, + paddingVertical: 16, + paddingHorizontal: 16, + height: 50, + }, +}) diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx new file mode 100644 index 000000000..fbe438969 --- /dev/null +++ b/src/view/com/composer/text-input/web/Autocomplete.tsx @@ -0,0 +1,157 @@ +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useState, +} from 'react' +import {ReactRenderer} from '@tiptap/react' +import tippy, {Instance as TippyInstance} from 'tippy.js' +import { + SuggestionOptions, + SuggestionProps, + SuggestionKeyDownProps, +} from '@tiptap/suggestion' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' + +interface MentionListRef { + onKeyDown: (props: SuggestionKeyDownProps) => boolean +} + +export function createSuggestion({ + autocompleteView, +}: { + autocompleteView: UserAutocompleteViewModel +}): Omit<SuggestionOptions, 'editor'> { + return { + async items({query}) { + autocompleteView.setActive(true) + await autocompleteView.setPrefix(query) + return autocompleteView.suggestions.slice(0, 8).map(s => s.handle) + }, + + render: () => { + let component: ReactRenderer<MentionListRef> | undefined + let popup: TippyInstance[] | undefined + + return { + onStart: props => { + component = new ReactRenderer(MentionList, { + props, + editor: props.editor, + }) + + if (!props.clientRect) { + return + } + + // @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf + popup = tippy('body', { + getReferenceClientRect: props.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + }) + }, + + onUpdate(props) { + component?.updateProps(props) + + if (!props.clientRect) { + return + } + + popup?.[0]?.setProps({ + // @ts-ignore getReferenceClientRect doesnt like that clientRect can return null -prf + getReferenceClientRect: props.clientRect, + }) + }, + + onKeyDown(props) { + if (props.event.key === 'Escape') { + popup?.[0]?.hide() + + return true + } + + return component?.ref?.onKeyDown(props) || false + }, + + onExit() { + popup?.[0]?.destroy() + component?.destroy() + }, + } + }, + } +} + +const MentionList = forwardRef<MentionListRef, SuggestionProps>( + (props: SuggestionProps, ref) => { + const [selectedIndex, setSelectedIndex] = useState(0) + + const selectItem = (index: number) => { + const item = props.items[index] + + if (item) { + props.command({id: item}) + } + } + + const upHandler = () => { + setSelectedIndex( + (selectedIndex + props.items.length - 1) % props.items.length, + ) + } + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % props.items.length) + } + + const enterHandler = () => { + selectItem(selectedIndex) + } + + useEffect(() => setSelectedIndex(0), [props.items]) + + useImperativeHandle(ref, () => ({ + onKeyDown: ({event}) => { + if (event.key === 'ArrowUp') { + upHandler() + return true + } + + if (event.key === 'ArrowDown') { + downHandler() + return true + } + + if (event.key === 'Enter') { + enterHandler() + return true + } + + return false + }, + })) + + return ( + <div className="items"> + {props.items.length ? ( + props.items.map((item, index) => ( + <button + className={`item ${index === selectedIndex ? 'is-selected' : ''}`} + key={index} + onClick={() => selectItem(index)}> + {item} + </button> + )) + ) : ( + <div className="item">No result</div> + )} + </div> + ) + }, +) diff --git a/src/view/com/composer/useExternalLinkFetch.ts b/src/view/com/composer/useExternalLinkFetch.ts new file mode 100644 index 000000000..75f833e84 --- /dev/null +++ b/src/view/com/composer/useExternalLinkFetch.ts @@ -0,0 +1,90 @@ +import {useState, useEffect} from 'react' +import {useStores} from 'state/index' +import * as apilib from 'lib/api/index' +import {getLinkMeta} from 'lib/link-meta/link-meta' +import {getPostAsQuote} from 'lib/link-meta/bsky' +import {downloadAndResize} from 'lib/media/manip' +import {isBskyPostUrl} from 'lib/strings/url-helpers' +import {ComposerOpts} from 'state/models/shell-ui' + +export function useExternalLinkFetch({ + setQuote, +}: { + setQuote: (opts: ComposerOpts['quote']) => void +}) { + const store = useStores() + const [extLink, setExtLink] = useState<apilib.ExternalEmbedDraft | undefined>( + undefined, + ) + + useEffect(() => { + let aborted = false + const cleanup = () => { + aborted = true + } + if (!extLink) { + return cleanup + } + if (!extLink.meta) { + if (isBskyPostUrl(extLink.uri)) { + getPostAsQuote(store, extLink.uri).then( + newQuote => { + if (aborted) { + return + } + setQuote(newQuote) + setExtLink(undefined) + }, + err => { + store.log.error('Failed to fetch post for quote embedding', {err}) + setExtLink(undefined) + }, + ) + } else { + getLinkMeta(store, 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) { + console.log('attempting download') + downloadAndResize({ + uri: extLink.meta.image, + width: 2000, + height: 2000, + mode: 'contain', + maxSize: 1000000, + timeout: 15e3, + }) + .catch(() => undefined) + .then(localThumb => { + if (aborted) { + return + } + setExtLink({ + ...extLink, + isLoading: false, // done + localThumb, + }) + }) + return cleanup + } + if (extLink.isLoading) { + setExtLink({ + ...extLink, + isLoading: false, // done + }) + } + return cleanup + }, [store, extLink, setQuote]) + + return {extLink, setExtLink} +} |