import { useNavigation, useTheme as useNavigationTheme, useRoute } from '@react-navigation/native' import React, { useCallback, useContext, useEffect, useRef, useState } from 'react' import { Linking, Platform, Pressable, ScrollView, StyleSheet, TextInput, View, ViewProps, Keyboard, } from 'react-native' import LinearGradient from 'react-native-linear-gradient' import { Heading, Icon, IconButton, IconString, Text, TextButton } from '../../components' import { ChatContext } from '../../contexts/chat_context' import { useCreateAndroidRippleColor, useTheme, useInteractionTintedBackgroundColor, useScalableNumberOfLines, useFontScale, } from '../../hooks' import { useAttachmentUploader } from '../../hooks/use_attachment_uploader' import { useBroadcastTypingStatus } from '../../hooks/use_broadcast_typing_status' import { useChatConfiguration } from '../../hooks/use_chat_configuration' import { useMessageCreateOrUpdate } from '../../hooks/use_message_create_or_update' import { useMessageDraft } from '../../hooks/use_message_draft' import { ConversationScreenProps } from '../../screens/conversation_screen' import { ConversationResource, MessageResource } from '../../types' import { DenormalizedAttachmentResourceForCreate, DenormalizedMessageAttachmentResourceForCreate, FileAttachment, } from '../../types/resources/denormalized_attachment_resource_for_create' import { Log, MAX_FONT_SIZE_MULTIPLIER_LANDMARK, platformFontWeightMedium, platformPressedOpacityStyle, } from '../../utils' import { pickAttachmentPreviewKind } from '../../utils/attachment_kind' import { canSubmitMessage } from '../../utils/can_submit_message' import { isInputEditable } from '../../utils/is_input_editable' import { hasMessageText, stripEmptyTextCharacters } from '../../utils/message_text' import { DocumentPicker, DocumentPickerResult, Haptic, ImagePicker, ImagePickerResult, } from '../../utils/native_adapters' import { tokens } from '../../vendor/tapestry/tokens' import { Button } from '../display/button' import BannerPrimitive from '../primitive/banner_primitive' import { MessageFormAttachmentFile } from './message_form/message_form_attachment_file' import { MessageFormAttachmentImage } from './message_form/message_form_attachment_image' import { MessageFormAttachmentVideo } from './message_form/message_form_attachment_video' export const MessageForm = { Root: MessageFormRoot, TextInput: MessageFormInput, SubmitButton: MessageFormSubmitBtn, AttachmentPicker: MessageFormAttachmentPicker, Commands: MessageFormCommands, } interface MessagesFormRootProps extends ViewProps { conversation: ConversationResource currentlyEditingMessage?: MessageResource | null replyRootId?: string | null replyRootAuthorFirstName?: string | null onFlaggedInMessageForm?: (id: string) => void onBeforeSubmit?: () => void | Promise } const MessageFormContext = React.createContext<{ text: string setText: (text: string) => void onSubmit: () => void disabled: boolean inputEditable: boolean canGiphy: boolean usingGiphy: boolean setUsingGiphy: (usingGiphy: boolean) => void attachmentUploader?: ReturnType currentlyEditingMessage?: MessageResource | null reset: () => void replyRootId?: string | null replyRootAuthorFirstName?: string | null }>({ text: '', setText: (_text: string) => {}, onSubmit: () => {}, disabled: false, inputEditable: true, canGiphy: false, usingGiphy: false, setUsingGiphy: (_usingGiphy: boolean) => {}, currentlyEditingMessage: null, reset: () => {}, replyRootId: undefined, replyRootAuthorFirstName: undefined, }) const GIPHY_MAX_LENGTH = 50 const MAX_MESSAGE_LENGTH = 5000 const PLACEHOLDER_FONT_SCALE_THRESHOLD = 1.7 function MessageFormRoot({ conversation, currentlyEditingMessage, children, replyRootId, replyRootAuthorFirstName, onFlaggedInMessageForm, onBeforeSubmit, }: MessagesFormRootProps) { const { giphyApiKey } = useContext(ChatContext) const canGiphy = !!giphyApiKey && !currentlyEditingMessage const styles = useMessageFormStyles() const { draft, saveDraft, clearDraft } = useMessageDraft() const [text, setText] = React.useState(() => { // Initialize from draft only when not editing a message return currentlyEditingMessage ? '' : draft?.text || '' }) const [usingGiphy, setUsingGiphy] = useState(false) const navigation = useNavigation() const route = useRoute() as ConversationScreenProps['route'] const { status, isPending, reset: resetMutation, mutateAsync, } = useMessageCreateOrUpdate({ conversationId: conversation.id, message: currentlyEditingMessage || undefined, replyRootId, }) const attachmentUploader = useAttachmentUploader({ conversationId: conversation.id, draftAttachments: currentlyEditingMessage ? undefined : draft?.attachments, onFlaggedInMessageForm, }) const resetAttachmentUploader = attachmentUploader.reset const reset = useCallback(() => { resetAttachmentUploader() resetMutation() setText('') setUsingGiphy(false) if (!currentlyEditingMessage) { clearDraft() } navigation.setParams({ editing_message_id: null, }) }, [resetAttachmentUploader, resetMutation, navigation, currentlyEditingMessage, clearDraft]) useEffect(() => { if (canGiphy && !usingGiphy && text.startsWith('/giphy ')) { setUsingGiphy(true) setText('') } }, [canGiphy, text, usingGiphy]) useEffect(() => { switch (status) { case 'success': reset() break } }, [reset, status]) useEffect(() => { if (route.params.clear_input) { reset() navigation.setParams({ ...route.params, clear_input: false }) } }, [reset, navigation, route.params]) // Save draft when text or attachments change (debounced) useEffect(() => { // Don't save drafts when editing a message if (currentlyEditingMessage) return const timeoutId = setTimeout(() => { saveDraft(text, attachmentUploader.attachments) }, 500) return () => clearTimeout(timeoutId) }, [text, attachmentUploader.attachments, saveDraft, currentlyEditingMessage]) const canSubmit = canSubmitMessage({ isPending, conversationDisabled: conversation.disabled ?? false, pendingUploads: !!attachmentUploader?.pendingUploads, hasFlaggedAttachments: !!attachmentUploader?.flaggedAttachmentCount, hasText: hasMessageText(text), hasSendableAttachments: !!attachmentUploader?.attachmentIds?.length, }) const disabled = !canSubmit const inputEditable = isInputEditable({ isPending, conversationDisabled: conversation.disabled ?? false, }) /* Opening a FormSheet on Android while the keyboard is visible breaks the FormSheet's height & position. This is a workaround to ensure we don't open the FormSheet while the keyboard is visible. */ const navigateToSendGiphyAfterKeyboardHides = useCallback(() => { if (!Keyboard.isVisible() || Platform.OS === 'ios') { navigation.navigate('SendGiphy', { conversation_id: conversation.id, reply_root_id: replyRootId, search_term: text, }) } else { const keyboardListener = Keyboard.addListener('keyboardDidHide', () => { navigation.navigate('SendGiphy', { conversation_id: conversation.id, reply_root_id: replyRootId, search_term: text, }) keyboardListener?.remove() }) } }, [conversation.id, navigation, replyRootId, text]) const catchingUp = useRef(false) // A send from a window behind the live edge would leave a gap above it, so a failed // catch-up stops the send and keeps what was typed. const catchUpBeforeSend = async () => { if (catchingUp.current) return false catchingUp.current = true try { await onBeforeSubmit?.() return true } catch (error) { Log.error('send stopped: live window fetch failed', error) return false } finally { catchingUp.current = false } } const handleSubmit = async () => { if (!canSubmit) return if (canGiphy && usingGiphy) { if (!(await catchUpBeforeSend())) return TextInput.State.blurTextInput(TextInput.State.currentlyFocusedInput()) Haptic.impactLight() navigateToSendGiphyAfterKeyboardHides() } else { let attachmentsForSubmit: DenormalizedAttachmentResourceForCreate[] = attachmentUploader.attachments .filter(a => a.status === 'success' && a.id) .map( (attachment): DenormalizedMessageAttachmentResourceForCreate => ({ type: 'MessageAttachment', id: attachment.id as string, file: attachment.file, }) ) const messageText = stripEmptyTextCharacters(text) if (currentlyEditingMessage) { mutateAsync({ text: messageText }) reset() } else { // The live-window fetch replaces the cache, which would wipe an optimistic row // added earlier. if (!(await catchUpBeforeSend())) return mutateAsync({ text: messageText, attachments: attachmentsForSubmit }) reset() } } } useEffect(() => { if (currentlyEditingMessage) { setText(currentlyEditingMessage.text || '') } }, [currentlyEditingMessage]) return ( {children} ) } function MessageFormAttachments() { const styles = useMessageFormStyles() const { attachmentUploader } = React.useContext(MessageFormContext) const numberOfAttachments = attachmentUploader?.attachments?.length || 0 const attachments = attachmentUploader?.attachments || [] if (numberOfAttachments === 0) { return null } return ( {attachments.map(attachment => { const kind = pickAttachmentPreviewKind(attachment.file.type, attachment.file.name) const remove = () => attachmentUploader?.removeAttachment(attachment) if (kind === 'video') { return ( ) } if (kind === 'file') { return ( ) } return ( ) })} ) } function getFileType(attachments: FileAttachment[]): 'image' | 'video' | 'file' { const kinds = new Set( attachments.map(({ file }) => { if (file.type.startsWith('image/')) return 'image' if (file.type.startsWith('video/')) return 'video' return 'file' }) ) return kinds.size === 1 ? [...kinds][0] : 'file' } function FlaggedContentBanner() { const styles = useMessageFormStyles() const { attachmentUploader } = React.useContext(MessageFormContext) const flaggedAttachments = attachmentUploader?.attachments.filter(a => a.flagged) ?? [] const flaggedCount = flaggedAttachments.length if (flaggedCount === 0) return null const isSingular = flaggedCount === 1 const fileType = getFileType(flaggedAttachments) const buttonTitle = isSingular ? `Remove flagged ${fileType}` : `Remove flagged ${fileType}s` return (