import client from '@api/client' import { HeaderLeft, HeaderRight } from '@components/Header' import { store } from '@root/store' import formatText from '@screens/Shared/Compose/formatText' import ComposeRoot from '@screens/Shared/Compose/Root' import { getLocalAccountPreferences } from '@utils/slices/instancesSlice' import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' import * as Crypto from 'expo-crypto' import React, { createContext, createRef, Dispatch, ReactNode, RefObject, useCallback, useEffect, useReducer, useState } from 'react' import { Alert, Keyboard, KeyboardAvoidingView, StyleSheet, Text, TextInput } from 'react-native' import { SafeAreaView } from 'react-native-safe-area-context' import { createNativeStackNavigator } from 'react-native-screens/native-stack' import { useQueryClient } from 'react-query' const Stack = createNativeStackNavigator() export type ComposeState = { spoiler: { active: boolean count: number raw: string formatted: ReactNode selection: { start: number; end: number } } text: { count: number raw: string formatted: ReactNode selection: { start: number; end: number } } tag?: { type: 'url' | 'accounts' | 'hashtags' text: string offset: number length: number } emoji: { active: boolean emojis: { title: string; data: Mastodon.Emoji[] }[] | undefined } poll: { active: boolean total: number options: { '0': string | undefined '1': string | undefined '2': string | undefined '3': string | undefined } multiple: boolean expire: | '300' | '1800' | '3600' | '21600' | '86400' | '259200' | '604800' | string } attachments: { sensitive: boolean uploads: (Mastodon.Attachment & { local_url?: string })[] } attachmentUploadProgress?: { progress: number; aspect?: number } visibility: 'public' | 'unlisted' | 'private' | 'direct' visibilityLock: boolean replyToStatus?: Mastodon.Status textInputFocus: { current: 'text' | 'spoiler' refs: { text: RefObject; spoiler: RefObject } } } export type ComposeAction = | { type: 'spoiler' payload: Partial } | { type: 'text' payload: Partial } | { type: 'tag' payload: ComposeState['tag'] } | { type: 'emoji' payload: ComposeState['emoji'] } | { type: 'poll' payload: Partial } | { type: 'attachments' payload: Partial } | { type: 'attachmentUploadProgress' payload: ComposeState['attachmentUploadProgress'] } | { type: 'attachmentEdit' payload: Mastodon.Attachment & { local_url?: string } } | { type: 'visibility' payload: ComposeState['visibility'] } | { type: 'textInputFocus' payload: Partial } const composeInitialState: ComposeState = { spoiler: { active: false, count: 0, raw: '', formatted: undefined, selection: { start: 0, end: 0 } }, text: { count: 0, raw: '', formatted: undefined, selection: { start: 0, end: 0 } }, tag: undefined, emoji: { active: false, emojis: undefined }, poll: { active: false, total: 2, options: { '0': undefined, '1': undefined, '2': undefined, '3': undefined }, multiple: false, expire: '86400' }, attachments: { sensitive: false, uploads: [] }, attachmentUploadProgress: undefined, visibility: 'public', visibilityLock: false, replyToStatus: undefined, textInputFocus: { current: 'text', refs: { text: createRef(), spoiler: createRef() } } } const composeExistingState = ({ type, incomingStatus, visibilityLock }: { type: 'reply' | 'conversation' | 'edit' incomingStatus: Mastodon.Status visibilityLock?: boolean }): ComposeState => { switch (type) { case 'edit': return { ...composeInitialState, ...(incomingStatus.spoiler_text?.length && { spoiler: { active: true, count: incomingStatus.spoiler_text.length, raw: incomingStatus.spoiler_text, formatted: incomingStatus.spoiler_text, selection: { start: 0, end: 0 } } }), text: { count: incomingStatus.text!.length, raw: incomingStatus.text!, formatted: undefined, selection: { start: 0, end: 0 } }, ...(incomingStatus.poll && { poll: { active: true, total: incomingStatus.poll.options.length, options: { '0': incomingStatus.poll.options[0]?.title || undefined, '1': incomingStatus.poll.options[1]?.title || undefined, '2': incomingStatus.poll.options[2]?.title || undefined, '3': incomingStatus.poll.options[3]?.title || undefined }, multiple: incomingStatus.poll.multiple, expire: '86400' // !!! } }), ...(incomingStatus.media_attachments && { attachments: { sensitive: incomingStatus.sensitive, uploads: incomingStatus.media_attachments } }), visibility: incomingStatus.visibility || getLocalAccountPreferences(store.getState())[ 'posting:default:visibility' ], ...(incomingStatus.visibility === 'direct' && { visibilityLock: true }) } case 'reply': const actualStatus = incomingStatus.reblog || incomingStatus const allMentions = Array.isArray(actualStatus.mentions) ? actualStatus.mentions.map(mention => `@${mention.acct}`) : [] let replyPlaceholder = allMentions.join(' ') if (replyPlaceholder.length === 0) { replyPlaceholder = `@${actualStatus.account.acct} ` } else { replyPlaceholder = replyPlaceholder + ' ' } return { ...composeInitialState, text: { count: replyPlaceholder.length, raw: replyPlaceholder, formatted: undefined, selection: { start: 0, end: 0 } }, ...(visibilityLock && { visibility: 'direct', visibilityLock: true }), replyToStatus: actualStatus } case 'conversation': return { ...composeInitialState, text: { count: incomingStatus.account.acct.length + 2, raw: `@${incomingStatus.account.acct} `, formatted: undefined, selection: { start: 0, end: 0 } }, visibility: 'direct', visibilityLock: true } } } const composeReducer = ( state: ComposeState, action: ComposeAction ): ComposeState => { switch (action.type) { case 'spoiler': return { ...state, spoiler: { ...state.spoiler, ...action.payload } } case 'text': return { ...state, text: { ...state.text, ...action.payload } } case 'tag': return { ...state, tag: action.payload } case 'emoji': return { ...state, emoji: action.payload } case 'poll': return { ...state, poll: { ...state.poll, ...action.payload } } case 'attachments': return { ...state, attachments: { ...state.attachments, ...action.payload } } case 'attachmentUploadProgress': return { ...state, attachmentUploadProgress: action.payload } case 'attachmentEdit': return { ...state, attachments: { ...state.attachments, uploads: state.attachments.uploads.map(upload => upload.id === action.payload.id ? action.payload : upload ) } } case 'visibility': return { ...state, visibility: action.payload } case 'textInputFocus': return { ...state, textInputFocus: { ...state.textInputFocus, ...action.payload } } default: throw new Error('Unexpected action') } } type ContextType = { composeState: ComposeState composeDispatch: Dispatch } export const ComposeContext = createContext({} as ContextType) export interface Props { route: { params: | { type?: 'reply' | 'conversation' | 'edit' incomingStatus: Mastodon.Status visibilityLock?: boolean } | undefined } navigation: any } const Compose: React.FC = ({ route: { params }, navigation }) => { const { theme } = useTheme() const queryClient = useQueryClient() const [hasKeyboard, setHasKeyboard] = useState(false) useEffect(() => { Keyboard.addListener('keyboardWillShow', _keyboardDidShow) Keyboard.addListener('keyboardWillHide', _keyboardDidHide) // cleanup function return () => { Keyboard.removeListener('keyboardWillShow', _keyboardDidShow) Keyboard.removeListener('keyboardWillHide', _keyboardDidHide) } }, []) const _keyboardDidShow = () => { setHasKeyboard(true) } const _keyboardDidHide = () => { setHasKeyboard(false) } const [composeState, composeDispatch] = useReducer( composeReducer, params?.type && params?.incomingStatus ? composeExistingState({ type: params.type, incomingStatus: params.incomingStatus, visibilityLock: params.visibilityLock }) : { ...composeInitialState, visibility: getLocalAccountPreferences(store.getState())[ 'posting:default:visibility' ] as ComposeState['visibility'] } ) const [isSubmitting, setIsSubmitting] = useState(false) useEffect(() => { switch (params?.type) { case 'edit': if (params.incomingStatus.spoiler_text) { formatText({ textInput: 'spoiler', composeDispatch, content: params.incomingStatus.spoiler_text, disableDebounce: true }) } formatText({ textInput: 'text', composeDispatch, content: params.incomingStatus.text!, disableDebounce: true }) break case 'reply': const actualStatus = params.incomingStatus.reblog || params.incomingStatus const allMentions = actualStatus.mentions.map( mention => `@${mention.acct}` ) let replyPlaceholder = allMentions.join(' ') if (replyPlaceholder.length === 0) { replyPlaceholder = `@${actualStatus.account.acct} ` } else { replyPlaceholder = replyPlaceholder + ' ' } formatText({ textInput: 'text', composeDispatch, content: replyPlaceholder, disableDebounce: true }) break case 'conversation': formatText({ textInput: 'text', composeDispatch, content: `@${params.incomingStatus.account.acct} `, disableDebounce: true }) break } }, [params?.type]) const tootPost = async () => { setIsSubmitting(true) if (composeState.text.count < 0) { Alert.alert('字数超限', '', [ { text: '返回继续编辑' } ]) } else { const formData = new FormData() if (params?.type === 'conversation' || params?.type === 'reply') { formData.append('in_reply_to_id', composeState.replyToStatus!.id) } if (composeState.spoiler.active) { formData.append('spoiler_text', composeState.spoiler.raw) } formData.append('status', composeState.text.raw) if (composeState.poll.active) { Object.values(composeState.poll.options) .filter(e => e?.length) .forEach(e => formData.append('poll[options][]', e!)) formData.append('poll[expires_in]', composeState.poll.expire) formData.append('poll[multiple]', composeState.poll.multiple.toString()) } if (composeState.attachments.uploads.length) { formData.append( 'sensitive', composeState.attachments.sensitive.toString() ) composeState.attachments.uploads.forEach(e => formData.append('media_ids[]', e!.id) ) } formData.append('visibility', composeState.visibility) client({ method: 'post', instance: 'local', url: 'statuses', headers: { 'Idempotency-Key': await Crypto.digestStringAsync( Crypto.CryptoDigestAlgorithm.SHA256, composeState.spoiler.raw + composeState.text.raw + composeState.poll.options['0'] + composeState.poll.options['1'] + composeState.poll.options['2'] + composeState.poll.options['3'] + composeState.poll.multiple + composeState.poll.expire + composeState.attachments.sensitive + composeState.attachments.uploads.map(upload => upload.id) + composeState.visibility + (params?.type === 'edit' ? Math.random() : '') ) }, body: formData }) .then( res => { if (res.body.id) { setIsSubmitting(false) Alert.alert('发布成功', '', [ { text: '好的', onPress: () => { queryClient.invalidateQueries(['Following']) navigation.goBack() } } ]) } else { setIsSubmitting(false) Alert.alert('发布失败', '', [ { text: '返回重试' } ]) } }, error => { setIsSubmitting(false) Alert.alert('发布失败', error.body, [ { text: '返回重试' } ]) } ) .catch(() => { setIsSubmitting(false) Alert.alert('发布失败', '', [ { text: '返回重试' } ]) }) } } const totalTextCount = (composeState.spoiler.active ? composeState.spoiler.count : 0) + composeState.text.count const rawCount = composeState.text.raw.length const postButtonText = { conversation: '回复私信', reply: '发布回复', edit: '发嘟嘟' } const headerLeft = useCallback( () => ( Alert.alert('确认取消编辑?', '', [ { text: '继续编辑', style: 'cancel' }, { text: '退出编辑', style: 'destructive', onPress: () => navigation.goBack() } ]) } /> ), [] ) const headerCenter = useCallback( () => ( 500 ? theme.red : theme.secondary } ]} > {totalTextCount} / 500 ), [totalTextCount] ) const headerRight = useCallback( () => ( tootPost()} loading={isSubmitting} disabled={rawCount < 1 || totalTextCount > 500} /> ), [isSubmitting, rawCount, totalTextCount] ) const screenComponent = useCallback( () => ( ), [] ) return ( ) } const styles = StyleSheet.create({ count: { textAlign: 'center', ...StyleConstants.FontStyle.M } }) export default Compose