import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react' import {observer} from 'mobx-react-lite' import { ActivityIndicator, BackHandler, Keyboard, KeyboardAvoidingView, Platform, Pressable, 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 {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 * as apilib from 'lib/api/index' import {ComposerOpts} from 'state/shell/composer' import {s, colors, gradients} from 'lib/styles' 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 {ThreadgateBtn} from './threadgate/ThreadgateBtn' import {usePalette} from 'lib/hooks/usePalette' import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' import {useExternalLinkFetch} from './useExternalLinkFetch' import {isWeb, isNative, isAndroid, isIOS} from 'platform/detection' import {QuoteEmbed} from '../util/post-embeds/QuoteEmbed' 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' import {SuggestedLanguage} from './select-language/SuggestedLanguage' import {insertMentionAt} from 'lib/strings/mention-manip' import {Trans, msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useModals} from '#/state/modals' import {useRequireAltTextEnabled} from '#/state/preferences' import { useLanguagePrefs, useLanguagePrefsApi, toPostLanguages, } from '#/state/preferences/languages' import {useSession, getAgent} from '#/state/session' import {useProfileQuery} from '#/state/queries/profile' import {useComposerControls} from '#/state/shell/composer' import {emitPostCreated} from '#/state/events' import {ThreadgateSetting} from '#/state/queries/threadgate' import {logger} from '#/logger' import {ComposerReplyTo} from 'view/com/composer/ComposerReplyTo' import * as Prompt from '#/components/Prompt' import {useDialogStateControlContext} from 'state/dialogs' import {logEvent} from '#/lib/statsig/statsig' type Props = ComposerOpts export const ComposePost = observer(function ComposePost({ replyTo, onPost, quote: initQuote, mention: initMention, openPicker, text: initText, imageUris: initImageUris, }: Props) { const {currentAccount} = useSession() const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) const {isModalActive} = useModals() const {closeComposer} = useComposerControls() const {track} = useAnalytics() const pal = usePalette('default') const {isDesktop, isMobile} = useWebMediaQueries() const {_} = useLingui() const requireAltTextEnabled = useRequireAltTextEnabled() const langPrefs = useLanguagePrefs() const setLangPrefs = useLanguagePrefsApi() const textInput = useRef(null) const discardPromptControl = Prompt.usePromptControl() const {closeAllDialogs} = useDialogStateControlContext() 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: initText ? initText : initMention ? insertMentionAt( `@${initMention}`, initMention.length + 1, `${initMention}`, ) // insert mention if passed in : '', }), ) const graphemeLength = useMemo(() => { return shortenLinks(richtext).graphemeLength }, [richtext]) const [quote, setQuote] = useState( initQuote, ) const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) const [labels, setLabels] = useState([]) const [threadgate, setThreadgate] = useState([]) const [suggestedLinks, setSuggestedLinks] = useState>(new Set()) const gallery = useMemo( () => new GalleryModel(initImageUris), [initImageUris], ) const onClose = useCallback(() => { closeComposer() }, [closeComposer]) const insets = useSafeAreaInsets() const viewStyles = useMemo( () => ({ paddingBottom: isAndroid || (isIOS && !isKeyboardVisible) ? insets.bottom : 0, paddingTop: isAndroid ? insets.top : isMobile ? 15 : 0, }), [insets, isKeyboardVisible, isMobile], ) const onPressCancel = useCallback(() => { if (graphemeLength > 0 || !gallery.isEmpty) { closeAllDialogs() if (Keyboard) { Keyboard.dismiss() } discardPromptControl.open() } else { onClose() } }, [ graphemeLength, gallery.isEmpty, closeAllDialogs, discardPromptControl, onClose, ]) // android back button useEffect(() => { if (!isAndroid) { return } const backHandler = BackHandler.addEventListener( 'hardwareBackPress', () => { onPressCancel() return true }, ) return () => { backHandler.remove() } }, [onPressCancel]) // listen to escape key on desktop web const onEscape = useCallback( (e: KeyboardEvent) => { if (e.key === 'Escape') { onPressCancel() } }, [onPressCancel], ) useEffect(() => { if (isWeb && !isModalActive) { window.addEventListener('keydown', onEscape) return () => window.removeEventListener('keydown', onEscape) } }, [onEscape, isModalActive]) 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 () => { if (isProcessing || graphemeLength > MAX_GRAPHEME_LENGTH) { return } if (requireAltTextEnabled && gallery.needsAltText) { return } setError('') if (richtext.text.trim().length === 0 && gallery.isEmpty && !extLink) { setError(_(msg`Did you want to say anything?`)) return } if (extLink?.isLoading) { setError(_(msg`Please wait for your link card to finish loading`)) return } setIsProcessing(true) let postUri try { postUri = ( await apilib.post(getAgent(), { rawText: richtext.text, replyTo: replyTo?.uri, images: gallery.images, quote, extLink, labels, threadgate, onStateChange: setProcessingState, langs: toPostLanguages(langPrefs.postLanguage), }) ).uri } catch (e: any) { logger.error(e, { message: `Composer: create post failed`, hasImages: gallery.size > 0, }) if (extLink) { setExtLink({ ...extLink, isLoading: true, localThumb: undefined, } as apilib.ExternalEmbedDraft) } setError(cleanError(e.message)) setIsProcessing(false) return } finally { if (postUri) { logEvent('post:create', { imageCount: gallery.size, isReply: replyTo != null, hasLink: extLink != null, hasQuote: quote != null, langs: langPrefs.postLanguage, logContext: 'Composer', }) } track('Create Post', { imageCount: gallery.size, }) if (replyTo && replyTo.uri) track('Post:Reply') } if (postUri && !replyTo) { emitPostCreated() } setLangPrefs.savePostLanguageToHistory() onPost?.() onClose() Toast.show( replyTo ? _(msg`Your reply has been published`) : _(msg`Your post has been published`), ) } const canPost = useMemo( () => graphemeLength <= MAX_GRAPHEME_LENGTH && (!requireAltTextEnabled || !gallery.needsAltText), [graphemeLength, requireAltTextEnabled, gallery.needsAltText], ) const selectTextInputPlaceholder = replyTo ? _(msg`Write your reply`) : _(msg`What's up?`) const canSelectImages = useMemo(() => gallery.size < 4, [gallery.size]) const hasMedia = gallery.size > 0 || Boolean(extLink) const onEmojiButtonPress = useCallback(() => { openPicker?.(textInput.current?.getCursorPosition()) }, [openPicker]) return ( Cancel {isProcessing ? ( <> {processingState} ) : ( <> {replyTo ? null : ( )} {canPost ? ( {replyTo ? ( Reply ) : ( Post )} ) : ( Post )} )} {requireAltTextEnabled && gallery.needsAltText && ( One or more images is missing alt text. )} {error !== '' && ( {error} )} {replyTo ? : 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={_(msg`Add link card`)} accessibilityHint={_( msg`Creates a card with a thumbnail. The card links to ${url}`, )}> Add link card:{' '} {toShortUrl(url)} ))} ) : null} {canSelectImages ? ( <> ) : null} {!isMobile ? ( ) : null} ) }) const styles = StyleSheet.create({ outer: { flexDirection: 'column', flex: 1, height: '100%', }, topbar: { flexDirection: 'row', alignItems: 'center', paddingTop: 6, paddingBottom: 4, paddingHorizontal: 20, height: 55, gap: 4, }, topbarDesktop: { paddingTop: 10, paddingBottom: 10, }, postBtn: { borderRadius: 20, paddingHorizontal: 20, paddingVertical: 6, marginLeft: 12, }, 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: { flexDirection: 'row', borderTopWidth: 1, paddingTop: 16, }, textInputLayoutMobile: { flex: 1, }, 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, }, })