import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {observer} from 'mobx-react-lite' import { ActivityIndicator, Keyboard, KeyboardAvoidingView, Platform, ScrollView, StyleSheet, TouchableOpacity, View, } from 'react-native' import {useSafeAreaInsets} from 'react-native-safe-area-context' import LinearGradient from 'react-native-linear-gradient' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {RichText} from '@atproto/api' import {useAnalytics} from 'lib/analytics/analytics' import {UserAutocompleteModel} from 'state/models/discovery/user-autocomplete' import {useIsKeyboardVisible} from 'lib/hooks/useIsKeyboardVisible' import {ExternalEmbed} from './ExternalEmbed' import {Text} from '../util/text/Text' import * as Toast from '../util/Toast' // TODO: Prevent naming components that coincide with RN primitives // due to linting false positives 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/ui/shell' import {s, colors, gradients} from 'lib/styles' import {sanitizeDisplayName} from 'lib/strings/display-names' import {sanitizeHandle} from 'lib/strings/handles' import {cleanError} from 'lib/strings/errors' import {shortenLinks} from 'lib/strings/rich-text-manip' import {toShortUrl} from 'lib/strings/url-helpers' import {SelectPhotoBtn} from './photos/SelectPhotoBtn' import {OpenCameraBtn} from './photos/OpenCameraBtn' import {usePalette} from 'lib/hooks/usePalette' import QuoteEmbed from '../util/post-embeds/QuoteEmbed' import {useExternalLinkFetch} from './useExternalLinkFetch' import {isDesktopWeb, isAndroid, isIOS} from 'platform/detection' import {GalleryModel} from 'state/models/media/gallery' import {Gallery} from './photos/Gallery' import {MAX_GRAPHEME_LENGTH} from 'lib/constants' import {LabelsBtn} from './labels/LabelsBtn' import {SelectLangBtn} from './select-language/SelectLangBtn' type Props = ComposerOpts & { onClose: () => void } export const ComposePost = observer(function ComposePost({ replyTo, onPost, onClose, quote: initQuote, }: Props) { const {track} = useAnalytics() const pal = usePalette('default') const store = useStores() const textInput = useRef(null) const [isKeyboardVisible] = useIsKeyboardVisible({iosUseWillEvents: true}) const [isProcessing, setIsProcessing] = useState(false) const [processingState, setProcessingState] = useState('') const [error, setError] = useState('') const [richtext, setRichText] = useState(new RichText({text: ''})) const graphemeLength = useMemo(() => { return shortenLinks(richtext).graphemeLength }, [richtext]) const [quote, setQuote] = useState( initQuote, ) const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) const [labels, setLabels] = useState([]) const [suggestedLinks, setSuggestedLinks] = useState>(new Set()) const gallery = useMemo(() => new GalleryModel(store), [store]) const autocompleteView = useMemo( () => new UserAutocompleteModel(store), [store], ) const insets = useSafeAreaInsets() const viewStyles = useMemo( () => ({ paddingBottom: isAndroid || (isIOS && !isKeyboardVisible) ? insets.bottom : 0, paddingTop: isAndroid ? insets.top : isDesktopWeb ? 0 : 15, }), [insets, isKeyboardVisible], ) const onPressCancel = useCallback(() => { if (graphemeLength > 0 || !gallery.isEmpty) { if (store.shell.activeModals.some(modal => modal.name === 'confirm')) { store.shell.closeModal() } if (Keyboard) { Keyboard.dismiss() } store.shell.openModal({ name: 'confirm', title: 'Discard draft', onPressConfirm: onClose, onPressCancel: () => { store.shell.closeModal() }, message: "Are you sure you'd like to discard this draft?", confirmBtnText: 'Discard', confirmBtnStyle: {backgroundColor: colors.red4}, }) } else { onClose() } }, [store, onClose, graphemeLength, gallery]) // initial setup useEffect(() => { autocompleteView.setup() }, [autocompleteView]) // listen to escape key on desktop web const onEscape = useCallback( (e: KeyboardEvent) => { if (e.key === 'Escape') { onPressCancel() } }, [onPressCancel], ) useEffect(() => { if (isDesktopWeb) { window.addEventListener('keydown', onEscape) return () => window.removeEventListener('keydown', onEscape) } }, [onEscape]) const onPressAddLinkCard = useCallback( (uri: string) => { setExtLink({uri, isLoading: true}) }, [setExtLink], ) const onPhotoPasted = useCallback( async (uri: string) => { track('Composer:PastedPhotos') await gallery.paste(uri) }, [gallery, track], ) const onPressPublish = async (rt: RichText) => { if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) { return } if (store.preferences.requireAltTextEnabled && gallery.needsAltText) { return } setError('') if (rt.text.trim().length === 0 && gallery.isEmpty) { setError('Did you want to say anything?') return } setIsProcessing(true) try { await apilib.post(store, { rawText: rt.text, replyTo: replyTo?.uri, images: gallery.images, quote, extLink, labels, onStateChange: setProcessingState, knownHandles: autocompleteView.knownHandles, langs: store.preferences.postLanguages, }) } catch (e: any) { if (extLink) { setExtLink({ ...extLink, isLoading: true, localThumb: undefined, } as apilib.ExternalEmbedDraft) } setError(cleanError(e.message)) setIsProcessing(false) return } finally { track('Create Post', { imageCount: gallery.size, }) if (replyTo && replyTo.uri) track('Post:Reply') } if (!replyTo) { store.me.mainFeed.onPostCreated() } onPost?.() onClose() Toast.show(`Your ${replyTo ? 'reply' : 'post'} has been published`) } const canPost = useMemo( () => graphemeLength <= MAX_GRAPHEME_LENGTH && (!store.preferences.requireAltTextEnabled || !gallery.needsAltText), [ graphemeLength, store.preferences.requireAltTextEnabled, gallery.needsAltText, ], ) const selectTextInputPlaceholder = replyTo ? 'Write your reply' : `What's up?` const canSelectImages = useMemo(() => gallery.size < 4, [gallery.size]) const hasMedia = gallery.size > 0 || Boolean(extLink) return ( Cancel {isProcessing ? ( ) : canPost ? ( { onPressPublish(richtext) }} accessibilityRole="button" accessibilityLabel={replyTo ? 'Publish reply' : 'Publish post'} accessibilityHint={ replyTo ? 'Double tap to publish your reply' : 'Double tap to publish your post' }> {replyTo ? 'Reply' : 'Post'} ) : ( Post )} {isProcessing ? ( {processingState} ) : undefined} {store.preferences.requireAltTextEnabled && gallery.needsAltText && ( One or more images is missing alt text. )} {error !== '' && ( {error} )} {replyTo ? ( {sanitizeDisplayName( replyTo.author.displayName || sanitizeHandle(replyTo.author.handle), )} {replyTo.text} ) : undefined} {gallery.isEmpty && extLink && ( setExtLink(undefined)} /> )} {quote ? ( ) : undefined} {!extLink && suggestedLinks.size > 0 ? ( {Array.from(suggestedLinks) .slice(0, 3) .map(url => ( onPressAddLinkCard(url)} accessibilityRole="button" accessibilityLabel="Add link card" accessibilityHint={`Creates a card with a thumbnail. The card links to ${url}`}> Add link card:{' '} {toShortUrl(url)} ))} ) : null} {canSelectImages ? ( <> ) : null} ) }) const styles = StyleSheet.create({ outer: { flexDirection: 'column', flex: 1, height: '100%', }, topbar: { flexDirection: 'row', alignItems: 'center', paddingTop: isDesktopWeb ? 10 : undefined, paddingBottom: isDesktopWeb ? 10 : 4, paddingHorizontal: 20, height: 55, }, postBtn: { borderRadius: 20, paddingHorizontal: 20, paddingVertical: 6, }, processingLine: { borderRadius: 6, paddingHorizontal: 8, paddingVertical: 6, marginHorizontal: 15, marginBottom: 6, }, errorLine: { flexDirection: 'row', backgroundColor: colors.red1, borderRadius: 6, marginHorizontal: 15, paddingHorizontal: 8, paddingVertical: 6, marginVertical: 6, }, reminderLine: { flexDirection: 'row', alignItems: 'center', borderRadius: 6, marginHorizontal: 15, paddingHorizontal: 8, paddingVertical: 6, marginBottom: 6, }, errorIcon: { borderWidth: 1, borderColor: colors.red4, color: colors.red4, borderRadius: 30, width: 16, height: 16, alignItems: 'center', justifyContent: 'center', marginRight: 5, }, scrollView: { flex: 1, paddingHorizontal: 15, }, textInputLayout: { flex: isDesktopWeb ? undefined : 1, 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, marginHorizontal: 10, marginBottom: 4, }, bottomBar: { flexDirection: 'row', paddingVertical: 10, paddingLeft: 15, paddingRight: 20, alignItems: 'center', borderTopWidth: 1, }, })