From 56cf890debeb9872f791ccb992a5587f2c05fd9e Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Mon, 13 Mar 2023 16:01:43 -0500 Subject: Move to expo and react-navigation (#288) * WIP - adding expo * WIP - adding expo 2 * Fix tsc * Finish adding expo * Disable the 'require cycle' warning * Tweak plist * Modify some dependency versions to make expo happy * Fix icon fill * Get Web compiling for expo * 1.7 * Switch to react-navigation in expo2 (#287) * WIP Switch to react-navigation * WIP Switch to react-navigation 2 * WIP Switch to react-navigation 3 * Convert all screens to react navigation * Update BottomBar for react navigation * Update mobile menu to be react-native drawer * Fixes to drawer and bottombar * Factor out some helpers * Replace the navigation model with react-navigation * Restructure the shell folder and fix the header positioning * Restore the error boundary * Fix tsc * Implement not-found page * Remove react-native-gesture-handler (no longer used) * Handle notifee card presses * Handle all navigations from the state layer * Fix drawer behaviors * Fix two linking issues * Switch to our react-native-progress fork to fix an svg rendering issue * Get Web working with react-navigation * Refactor routes and navigation for a bit more clarity * Remove dead code * Rework Web shell to left/right nav to make this easier * Fix ViewHeader for desktop web * Hide profileheader back btn on desktop web * Move the compose button to the left nav * Implement reply prompt in threads for desktop web * Composer refactors * Factor out all platform-specific text input behaviors from the composer * Small fix * Update the web build to use tiptap for the composer * Tune up the mention autocomplete dropdown * Simplify the default avatar and banner * Fixes to link cards in web composer * Fix dropdowns on web * Tweak load latest on desktop * Add web beta message and feedback link * Fix up links in desktop web --- src/view/com/composer/Composer.tsx | 425 +++++++++++++++++++++++++++++++++++++ 1 file changed, 425 insertions(+) create mode 100644 src/view/com/composer/Composer.tsx (limited to 'src/view/com/composer/Composer.tsx') diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx new file mode 100644 index 000000000..e9b728d73 --- /dev/null +++ b/src/view/com/composer/Composer.tsx @@ -0,0 +1,425 @@ +import React, {useEffect, useRef, useState} from 'react' +import {observer} from 'mobx-react-lite' +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform, + SafeAreaView, + ScrollView, + StyleSheet, + TouchableOpacity, + TouchableWithoutFeedback, + View, +} from 'react-native' +import LinearGradient from 'react-native-linear-gradient' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {useAnalytics} from 'lib/analytics' +import {UserAutocompleteViewModel} from 'state/models/user-autocomplete-view' +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 {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 {SelectPhotoBtn} from './photos/SelectPhotoBtn' +import {OpenCameraBtn} from './photos/OpenCameraBtn' +import {SelectedPhotos} from './photos/SelectedPhotos' +import {usePalette} from 'lib/hooks/usePalette' +import QuoteEmbed from '../util/PostEmbeds/QuoteEmbed' +import {useExternalLinkFetch} from './useExternalLinkFetch' + +const MAX_TEXT_LENGTH = 256 + +export const ComposePost = observer(function ComposePost({ + replyTo, + onPost, + onClose, + quote: initQuote, +}: { + replyTo?: ComposerOpts['replyTo'] + onPost?: ComposerOpts['onPost'] + onClose: () => void + quote?: ComposerOpts['quote'] +}) { + const {track} = useAnalytics() + const pal = usePalette('default') + const store = useStores() + const textInput = useRef(null) + const [isProcessing, setIsProcessing] = useState(false) + const [processingState, setProcessingState] = useState('') + const [error, setError] = useState('') + const [text, setText] = useState('') + const [quote, setQuote] = useState( + initQuote, + ) + const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) + const [suggestedLinks, setSuggestedLinks] = useState>(new Set()) + const [selectedPhotos, setSelectedPhotos] = useState([]) + + const autocompleteView = React.useMemo( + () => new UserAutocompleteViewModel(store), + [store], + ) + + // HACK + // there's a bug with @mattermost/react-native-paste-input where if the input + // 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 = React.useCallback(() => { + textInput.current?.blur() + onClose() + }, [textInput, onClose]) + + // initial setup + useEffect(() => { + autocompleteView.setup() + }, [autocompleteView]) + + 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 onPressContainer = React.useCallback(() => { + textInput.current?.focus() + }, [textInput]) + + const onSelectPhotos = React.useCallback( + (photos: string[]) => { + track('Composer:SelectedPhotos') + setSelectedPhotos(photos) + }, + [track, setSelectedPhotos], + ) + + const onPressAddLinkCard = React.useCallback( + (uri: string) => { + setExtLink({uri, isLoading: true}) + }, + [setExtLink], + ) + + const onPhotoPasted = React.useCallback( + async (uri: string) => { + if (selectedPhotos.length >= 4) { + return + } + onSelectPhotos([...selectedPhotos, uri]) + }, + [selectedPhotos, onSelectPhotos], + ) + + const onPressPublish = React.useCallback(async () => { + if (isProcessing) { + return + } + if (text.length > MAX_TEXT_LENGTH) { + return + } + setError('') + if (text.trim().length === 0 && selectedPhotos.length === 0) { + setError('Did you want to say anything?') + return false + } + setIsProcessing(true) + try { + await apilib.post(store, { + rawText: text, + replyTo: replyTo?.uri, + images: selectedPhotos, + quote: quote, + extLink: extLink, + onStateChange: setProcessingState, + knownHandles: autocompleteView.knownHandles, + }) + track('Create Post', { + imageCount: selectedPhotos.length, + }) + } catch (e: any) { + if (extLink) { + setExtLink({ + ...extLink, + isLoading: true, + localThumb: undefined, + } as apilib.ExternalEmbedDraft) + } + setError(cleanError(e.message)) + setIsProcessing(false) + return + } + store.me.mainFeed.loadLatest() + 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 + + const selectTextInputLayout = + selectedPhotos.length !== 0 + ? styles.textInputLayoutWithPhoto + : styles.textInputLayoutWithoutPhoto + const selectTextInputPlaceholder = replyTo + ? 'Write your reply' + : selectedPhotos.length !== 0 + ? 'Write a comment' + : "What's up?" + + return ( + + + + + + Cancel + + + {isProcessing ? ( + + + + ) : canPost ? ( + + + + {replyTo ? 'Reply' : 'Post'} + + + + ) : ( + + Post + + )} + + {isProcessing ? ( + + {processingState} + + ) : undefined} + {error !== '' && ( + + + + + {error} + + )} + + {replyTo ? ( + + + + + {replyTo.author.displayName || replyTo.author.handle} + + + {replyTo.text} + + + + ) : undefined} + + + + + + + {quote ? ( + + + + ) : undefined} + + + {!selectedPhotos.length && extLink && ( + setExtLink(undefined)} + /> + )} + + {!extLink && + selectedPhotos.length === 0 && + suggestedLinks.size > 0 && + !quote ? ( + + {Array.from(suggestedLinks).map(url => ( + onPressAddLinkCard(url)}> + + Add link card: {url} + + + ))} + + ) : null} + + + + + + + + + + ) +}) + +const styles = StyleSheet.create({ + outer: { + flexDirection: 'column', + flex: 1, + padding: 15, + height: '100%', + }, + topbar: { + flexDirection: 'row', + alignItems: 'center', + paddingBottom: 10, + paddingHorizontal: 5, + height: 55, + }, + postBtn: { + borderRadius: 20, + paddingHorizontal: 20, + paddingVertical: 6, + }, + processingLine: { + borderRadius: 6, + paddingHorizontal: 8, + paddingVertical: 6, + marginBottom: 6, + }, + errorLine: { + flexDirection: 'row', + backgroundColor: colors.red1, + borderRadius: 6, + paddingHorizontal: 8, + paddingVertical: 6, + marginVertical: 6, + }, + errorIcon: { + borderWidth: 1, + borderColor: colors.red4, + color: colors.red4, + borderRadius: 30, + width: 16, + height: 16, + alignItems: 'center', + justifyContent: 'center', + marginRight: 5, + }, + textInputLayoutWithPhoto: { + flexWrap: 'wrap', + }, + textInputLayoutWithoutPhoto: { + flex: 1, + }, + textInputLayout: { + flexDirection: 'row', + borderTopWidth: 1, + paddingTop: 16, + }, + replyToLayout: { + flexDirection: 'row', + borderTopWidth: 1, + paddingTop: 16, + paddingBottom: 16, + }, + replyToPost: { + flex: 1, + paddingLeft: 13, + paddingRight: 8, + }, + addExtLinkBtn: { + borderWidth: 1, + borderRadius: 24, + paddingHorizontal: 16, + paddingVertical: 12, + marginBottom: 4, + }, + bottomBar: { + flexDirection: 'row', + paddingVertical: 10, + paddingRight: 5, + alignItems: 'center', + borderTopWidth: 1, + }, +}) -- cgit 1.4.1