mirror of
https://github.com/tooot-app/app
synced 2025-02-28 17:48:12 +01:00
Rewrite upload media
This commit is contained in:
parent
e841409523
commit
3473337442
@ -36,6 +36,7 @@
|
||||
"expo-secure-store": "~9.3.0",
|
||||
"expo-splash-screen": "~0.8.1",
|
||||
"expo-status-bar": "~1.0.3",
|
||||
"expo-video-thumbnails": "~4.4.0",
|
||||
"expo-web-browser": "~8.6.0",
|
||||
"gl-react": "^4.0.1",
|
||||
"gl-react-blurhash": "^1.0.0",
|
||||
|
@ -9,16 +9,9 @@ import { StyleConstants } from '@root/utils/styles/constants'
|
||||
export interface Props {
|
||||
sensitiveShown: boolean
|
||||
video: Mastodon.AttachmentVideo | Mastodon.AttachmentGifv
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
const AttachmentVideo: React.FC<Props> = ({
|
||||
sensitiveShown,
|
||||
video,
|
||||
width,
|
||||
height
|
||||
}) => {
|
||||
const AttachmentVideo: React.FC<Props> = ({ sensitiveShown, video }) => {
|
||||
const videoPlayer = useRef<Video>(null)
|
||||
const [videoLoaded, setVideoLoaded] = useState(false)
|
||||
const [videoPosition, setVideoPosition] = useState<number>(0)
|
||||
|
@ -1,18 +1,15 @@
|
||||
import client from '@api/client'
|
||||
import { HeaderLeft, HeaderRight } from '@components/Header'
|
||||
import { toast } from '@root/components/toast'
|
||||
import { store } from '@root/store'
|
||||
import layoutAnimation from '@root/utils/styles/layoutAnimation'
|
||||
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,
|
||||
@ -23,292 +20,21 @@ import {
|
||||
Keyboard,
|
||||
KeyboardAvoidingView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput
|
||||
Text
|
||||
} from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
|
||||
import { useQueryClient } from 'react-query'
|
||||
import ComposeEditAttachment from './Compose/EditAttachment'
|
||||
import ComposeEditAttachmentRoot from './Compose/EditAttachment/Root'
|
||||
import composeInitialState from './Compose/utils/initialState'
|
||||
import composeParseState from './Compose/utils/parseState'
|
||||
import composeSend from './Compose/utils/post'
|
||||
import composeReducer from './Compose/utils/reducer'
|
||||
import { ComposeAction, ComposeState } from './Compose/utils/types'
|
||||
|
||||
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<TextInput>; spoiler: RefObject<TextInput> }
|
||||
}
|
||||
}
|
||||
|
||||
export type ComposeAction =
|
||||
| {
|
||||
type: 'spoiler'
|
||||
payload: Partial<ComposeState['spoiler']>
|
||||
}
|
||||
| {
|
||||
type: 'text'
|
||||
payload: Partial<ComposeState['text']>
|
||||
}
|
||||
| {
|
||||
type: 'tag'
|
||||
payload: ComposeState['tag']
|
||||
}
|
||||
| {
|
||||
type: 'emoji'
|
||||
payload: ComposeState['emoji']
|
||||
}
|
||||
| {
|
||||
type: 'poll'
|
||||
payload: Partial<ComposeState['poll']>
|
||||
}
|
||||
| {
|
||||
type: 'attachments'
|
||||
payload: Partial<ComposeState['attachments']>
|
||||
}
|
||||
| {
|
||||
type: 'attachmentUploadProgress'
|
||||
payload: ComposeState['attachmentUploadProgress']
|
||||
}
|
||||
| {
|
||||
type: 'attachmentEdit'
|
||||
payload: Mastodon.Attachment & { local_url?: string }
|
||||
}
|
||||
| {
|
||||
type: 'visibility'
|
||||
payload: ComposeState['visibility']
|
||||
}
|
||||
| {
|
||||
type: 'textInputFocus'
|
||||
payload: Partial<ComposeState['textInputFocus']>
|
||||
}
|
||||
|
||||
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<ComposeAction>
|
||||
@ -353,7 +79,7 @@ const Compose: React.FC<Props> = ({ route: { params }, navigation }) => {
|
||||
const [composeState, composeDispatch] = useReducer(
|
||||
composeReducer,
|
||||
params?.type && params?.incomingStatus
|
||||
? composeExistingState({
|
||||
? composeParseState({
|
||||
type: params.type,
|
||||
incomingStatus: params.incomingStatus,
|
||||
visibilityLock: params.visibilityLock
|
||||
@ -415,116 +141,9 @@ const Compose: React.FC<Props> = ({ route: { params }, navigation }) => {
|
||||
}
|
||||
}, [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: '回复私信',
|
||||
@ -539,12 +158,12 @@ const Compose: React.FC<Props> = ({ route: { params }, navigation }) => {
|
||||
content='退出编辑'
|
||||
onPress={() =>
|
||||
Alert.alert('确认取消编辑?', '', [
|
||||
{ text: '继续编辑', style: 'cancel' },
|
||||
{
|
||||
text: '退出编辑',
|
||||
style: 'destructive',
|
||||
onPress: () => navigation.goBack()
|
||||
}
|
||||
},
|
||||
{ text: '继续编辑', style: 'cancel' }
|
||||
])
|
||||
}
|
||||
/>
|
||||
@ -571,21 +190,29 @@ const Compose: React.FC<Props> = ({ route: { params }, navigation }) => {
|
||||
<HeaderRight
|
||||
type='text'
|
||||
content={params?.type ? postButtonText[params.type] : '发嘟嘟'}
|
||||
onPress={async () => tootPost()}
|
||||
onPress={() => {
|
||||
layoutAnimation()
|
||||
setIsSubmitting(true)
|
||||
composeSend(params, composeState)
|
||||
.then(() => {
|
||||
queryClient.invalidateQueries(['Following'])
|
||||
navigation.goBack()
|
||||
toast({ type: 'success', content: '发布成功' })
|
||||
})
|
||||
.catch(() => {
|
||||
setIsSubmitting(false)
|
||||
Alert.alert('发布失败', '', [
|
||||
{
|
||||
text: '返回重试'
|
||||
}
|
||||
])
|
||||
})
|
||||
}}
|
||||
loading={isSubmitting}
|
||||
disabled={rawCount < 1 || totalTextCount > 500}
|
||||
disabled={composeState.text.raw.length < 1 || totalTextCount > 500}
|
||||
/>
|
||||
),
|
||||
[isSubmitting, rawCount, totalTextCount]
|
||||
)
|
||||
|
||||
const screenComponent = useCallback(
|
||||
() => (
|
||||
<ComposeContext.Provider value={{ composeState, composeDispatch }}>
|
||||
<ComposeRoot />
|
||||
</ComposeContext.Provider>
|
||||
),
|
||||
[]
|
||||
[isSubmitting, composeState.text.raw, totalTextCount]
|
||||
)
|
||||
|
||||
return (
|
||||
@ -594,13 +221,20 @@ const Compose: React.FC<Props> = ({ route: { params }, navigation }) => {
|
||||
style={{ flex: 1 }}
|
||||
edges={hasKeyboard ? ['left', 'right'] : ['left', 'right', 'bottom']}
|
||||
>
|
||||
<Stack.Navigator>
|
||||
<Stack.Screen
|
||||
name='Screen-Shared-Compose-Root'
|
||||
options={{ headerLeft, headerCenter, headerRight }}
|
||||
component={screenComponent}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
<ComposeContext.Provider value={{ composeState, composeDispatch }}>
|
||||
<Stack.Navigator>
|
||||
<Stack.Screen
|
||||
name='Screen-Shared-Compose-Root'
|
||||
component={ComposeRoot}
|
||||
options={{ headerLeft, headerCenter, headerRight }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name='Screen-Shared-Compose-EditAttachment'
|
||||
component={ComposeEditAttachment}
|
||||
options={{ stackPresentation: 'modal' }}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
</ComposeContext.Provider>
|
||||
</SafeAreaView>
|
||||
</KeyboardAvoidingView>
|
||||
)
|
||||
|
@ -1,11 +1,11 @@
|
||||
import { Feather } from '@expo/vector-icons'
|
||||
import React, { RefObject, useCallback, useContext, useMemo } from 'react'
|
||||
import { ActionSheetIOS, StyleSheet, TextInput, View } from 'react-native'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import { ComposeContext } from '@screens/Shared/Compose'
|
||||
import addAttachments from '@screens/Shared/Compose/addAttachments'
|
||||
import { toast } from '@root/components/toast'
|
||||
import addAttachment from '@root/screens/Shared/Compose/addAttachment'
|
||||
import React, { useCallback, useContext, useMemo } from 'react'
|
||||
import { ActionSheetIOS, StyleSheet, View } from 'react-native'
|
||||
import layoutAnimation from '@root/utils/styles/layoutAnimation'
|
||||
|
||||
const ComposeActions: React.FC = () => {
|
||||
const { composeState, composeDispatch } = useContext(ComposeContext)
|
||||
@ -13,50 +13,33 @@ const ComposeActions: React.FC = () => {
|
||||
|
||||
const attachmentColor = useMemo(() => {
|
||||
if (composeState.poll.active) return theme.disabled
|
||||
if (composeState.attachmentUploadProgress) return theme.primary
|
||||
|
||||
if (composeState.attachments.uploads.length) {
|
||||
return theme.primary
|
||||
} else {
|
||||
return theme.secondary
|
||||
}
|
||||
}, [
|
||||
composeState.poll.active,
|
||||
composeState.attachments.uploads,
|
||||
composeState.attachmentUploadProgress
|
||||
])
|
||||
}, [composeState.poll.active, composeState.attachments.uploads])
|
||||
const attachmentOnPress = useCallback(async () => {
|
||||
if (composeState.poll.active) return
|
||||
if (composeState.attachmentUploadProgress) return
|
||||
|
||||
if (!composeState.attachments.uploads.length) {
|
||||
return await addAttachments({ composeState, composeDispatch })
|
||||
if (composeState.attachments.uploads.length < 4) {
|
||||
return await addAttachment({ composeDispatch })
|
||||
}
|
||||
}, [
|
||||
composeState.poll.active,
|
||||
composeState.attachments.uploads,
|
||||
composeState.attachmentUploadProgress
|
||||
])
|
||||
}, [composeState.poll.active, composeState.attachments.uploads])
|
||||
|
||||
const pollColor = useMemo(() => {
|
||||
if (composeState.attachments.uploads.length) return theme.disabled
|
||||
if (composeState.attachmentUploadProgress) return theme.disabled
|
||||
|
||||
if (composeState.poll.active) {
|
||||
return theme.primary
|
||||
} else {
|
||||
return theme.secondary
|
||||
}
|
||||
}, [
|
||||
composeState.poll.active,
|
||||
composeState.attachments.uploads,
|
||||
composeState.attachmentUploadProgress
|
||||
])
|
||||
}, [composeState.poll.active, composeState.attachments.uploads])
|
||||
const pollOnPress = useCallback(() => {
|
||||
if (
|
||||
!composeState.attachments.uploads.length &&
|
||||
!composeState.attachmentUploadProgress
|
||||
) {
|
||||
if (!composeState.attachments.uploads.length) {
|
||||
layoutAnimation()
|
||||
composeDispatch({
|
||||
type: 'poll',
|
||||
payload: { ...composeState.poll, active: !composeState.poll.active }
|
||||
@ -65,11 +48,7 @@ const ComposeActions: React.FC = () => {
|
||||
if (composeState.poll.active) {
|
||||
composeState.textInputFocus.refs.text.current?.focus()
|
||||
}
|
||||
}, [
|
||||
composeState.poll.active,
|
||||
composeState.attachments.uploads,
|
||||
composeState.attachmentUploadProgress
|
||||
])
|
||||
}, [composeState.poll.active, composeState.attachments.uploads])
|
||||
|
||||
const visibilityIcon = useMemo(() => {
|
||||
switch (composeState.visibility) {
|
||||
@ -114,6 +93,7 @@ const ComposeActions: React.FC = () => {
|
||||
if (composeState.spoiler.active) {
|
||||
composeState.textInputFocus.refs.text.current?.focus()
|
||||
}
|
||||
layoutAnimation()
|
||||
composeDispatch({
|
||||
type: 'spoiler',
|
||||
payload: { active: !composeState.spoiler.active }
|
||||
@ -132,11 +112,13 @@ const ComposeActions: React.FC = () => {
|
||||
const emojiOnPress = useCallback(() => {
|
||||
if (composeState.emoji.emojis) {
|
||||
if (composeState.emoji.active) {
|
||||
layoutAnimation()
|
||||
composeDispatch({
|
||||
type: 'emoji',
|
||||
payload: { ...composeState.emoji, active: false }
|
||||
})
|
||||
} else {
|
||||
layoutAnimation()
|
||||
composeDispatch({
|
||||
type: 'emoji',
|
||||
payload: { ...composeState.emoji, active: true }
|
||||
|
@ -1,4 +1,12 @@
|
||||
import React, { useCallback, useContext } from 'react'
|
||||
import { Feather } from '@expo/vector-icons'
|
||||
import Button from '@components/Button'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { ComposeContext } from '@screens/Shared/Compose'
|
||||
import addAttachment from '@screens/Shared/Compose/addAttachment'
|
||||
import { ExtendedAttachment } from '@screens/Shared/Compose/utils/types'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import React, { useCallback, useContext, useMemo } from 'react'
|
||||
import {
|
||||
FlatList,
|
||||
Image,
|
||||
@ -7,16 +15,8 @@ import {
|
||||
Text,
|
||||
View
|
||||
} from 'react-native'
|
||||
|
||||
import { ComposeContext } from '@screens/Shared/Compose'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import { useNavigation } from '@react-navigation/native'
|
||||
import { createShimmerPlaceholder } from 'react-native-shimmer-placeholder'
|
||||
import Button from '@components/Button'
|
||||
import addAttachments from '@screens/Shared/Compose/addAttachments'
|
||||
import { Feather } from '@expo/vector-icons'
|
||||
import { LinearGradient } from 'expo-linear-gradient'
|
||||
import { Chase } from 'react-native-animated-spinkit'
|
||||
import layoutAnimation from '@root/utils/styles/layoutAnimation'
|
||||
|
||||
const DEFAULT_HEIGHT = 200
|
||||
|
||||
@ -25,36 +25,52 @@ const ComposeAttachments: React.FC = () => {
|
||||
const { theme } = useTheme()
|
||||
const navigation = useNavigation()
|
||||
|
||||
const sensitiveOnPress = useCallback(
|
||||
() =>
|
||||
composeDispatch({
|
||||
type: 'attachments/sensitive',
|
||||
payload: { sensitive: !composeState.attachments.sensitive }
|
||||
}),
|
||||
[composeState.attachments.sensitive]
|
||||
)
|
||||
|
||||
const renderAttachment = useCallback(
|
||||
({
|
||||
item,
|
||||
index
|
||||
}: {
|
||||
item: Mastodon.Attachment & { local_url?: string }
|
||||
index: number
|
||||
}) => {
|
||||
({ item, index }: { item: ExtendedAttachment; index: number }) => {
|
||||
let calculatedWidth: number
|
||||
if (item.local) {
|
||||
calculatedWidth =
|
||||
(item.local.width / item.local.height) * DEFAULT_HEIGHT
|
||||
} else {
|
||||
if (item.remote) {
|
||||
if (item.remote.meta.original.aspect) {
|
||||
calculatedWidth = item.remote.meta.original.aspect * DEFAULT_HEIGHT
|
||||
} else if (
|
||||
item.remote.meta.original.width &&
|
||||
item.remote.meta.original.height
|
||||
) {
|
||||
calculatedWidth =
|
||||
(item.remote.meta.original.width /
|
||||
item.remote.meta.original.height) *
|
||||
DEFAULT_HEIGHT
|
||||
} else {
|
||||
calculatedWidth = DEFAULT_HEIGHT
|
||||
}
|
||||
} else {
|
||||
calculatedWidth = DEFAULT_HEIGHT
|
||||
}
|
||||
}
|
||||
return (
|
||||
<View key={index}>
|
||||
<View
|
||||
key={index}
|
||||
style={[styles.container, { width: calculatedWidth }]}
|
||||
>
|
||||
<Image
|
||||
style={[
|
||||
styles.image,
|
||||
{
|
||||
width:
|
||||
((item as Mastodon.AttachmentImage).meta?.original?.aspect ||
|
||||
(item as Mastodon.AttachmentVideo).meta?.original.width! /
|
||||
(item as Mastodon.AttachmentVideo).meta?.original
|
||||
.height! ||
|
||||
1) * DEFAULT_HEIGHT
|
||||
}
|
||||
]}
|
||||
style={styles.image}
|
||||
source={{
|
||||
uri:
|
||||
item.type === 'image'
|
||||
? item.local_url || item.preview_url
|
||||
: item.preview_url
|
||||
uri: item.local?.local_thumbnail || item.remote?.preview_url
|
||||
}}
|
||||
/>
|
||||
{(item as Mastodon.AttachmentVideo).meta?.original?.duration && (
|
||||
{item.remote?.meta?.original?.duration && (
|
||||
<Text
|
||||
style={[
|
||||
styles.duration,
|
||||
@ -64,114 +80,100 @@ const ComposeAttachments: React.FC = () => {
|
||||
}
|
||||
]}
|
||||
>
|
||||
{(item as Mastodon.AttachmentVideo).meta?.original.duration}
|
||||
{item.remote.meta.original.duration}
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
type='icon'
|
||||
content='x'
|
||||
spacing='M'
|
||||
round
|
||||
overlay
|
||||
style={styles.delete}
|
||||
onPress={() =>
|
||||
composeDispatch({
|
||||
type: 'attachments',
|
||||
payload: {
|
||||
uploads: composeState.attachments.uploads.filter(
|
||||
e => e.id !== item.id
|
||||
)
|
||||
{item.uploading ? (
|
||||
<View
|
||||
style={[
|
||||
styles.uploading,
|
||||
{ backgroundColor: theme.backgroundOverlay }
|
||||
]}
|
||||
>
|
||||
<Chase
|
||||
size={StyleConstants.Font.Size.L * 2}
|
||||
color={theme.primaryOverlay}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type='icon'
|
||||
content='x'
|
||||
spacing='M'
|
||||
round
|
||||
overlay
|
||||
style={styles.delete}
|
||||
onPress={() => {
|
||||
layoutAnimation()
|
||||
composeDispatch({
|
||||
type: 'attachment/delete',
|
||||
payload: item.remote!.id
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type='icon'
|
||||
content='edit'
|
||||
spacing='M'
|
||||
round
|
||||
overlay
|
||||
style={styles.edit}
|
||||
onPress={() =>
|
||||
navigation.navigate('Screen-Shared-Compose-EditAttachment', {
|
||||
index
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type='icon'
|
||||
content='edit'
|
||||
spacing='M'
|
||||
round
|
||||
overlay
|
||||
style={styles.edit}
|
||||
onPress={() =>
|
||||
navigation.navigate('Screen-Shared-Compose-EditAttachment', {
|
||||
attachment: item,
|
||||
composeDispatch
|
||||
})
|
||||
}
|
||||
/>
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const listFooter = useCallback(() => {
|
||||
const ShimmerPlaceholder = createShimmerPlaceholder(LinearGradient)
|
||||
|
||||
return (
|
||||
<ShimmerPlaceholder
|
||||
style={styles.progressContainer}
|
||||
visible={composeState.attachmentUploadProgress === undefined}
|
||||
width={
|
||||
(composeState.attachmentUploadProgress?.aspect || 3 / 2) *
|
||||
DEFAULT_HEIGHT
|
||||
}
|
||||
height={200}
|
||||
shimmerColors={theme.shimmer}
|
||||
const listFooter = useMemo(
|
||||
() => (
|
||||
<Pressable
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
width: DEFAULT_HEIGHT,
|
||||
backgroundColor: theme.backgroundOverlay
|
||||
}
|
||||
]}
|
||||
onPress={async () => await addAttachment({ composeDispatch })}
|
||||
>
|
||||
{composeState.attachments.uploads.length > 0 &&
|
||||
composeState.attachments.uploads[0].type === 'image' &&
|
||||
composeState.attachments.uploads.length < 4 && (
|
||||
<Pressable
|
||||
style={{
|
||||
width: DEFAULT_HEIGHT,
|
||||
height: DEFAULT_HEIGHT,
|
||||
backgroundColor: theme.border
|
||||
}}
|
||||
onPress={async () =>
|
||||
await addAttachments({ composeState, composeDispatch })
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type='icon'
|
||||
content='upload-cloud'
|
||||
spacing='M'
|
||||
round
|
||||
overlay
|
||||
onPress={async () =>
|
||||
await addAttachments({ composeState, composeDispatch })
|
||||
}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top:
|
||||
(DEFAULT_HEIGHT -
|
||||
StyleConstants.Spacing.M * 2 -
|
||||
StyleConstants.Font.Size.M) /
|
||||
2,
|
||||
left:
|
||||
(DEFAULT_HEIGHT -
|
||||
StyleConstants.Spacing.M * 2 -
|
||||
StyleConstants.Font.Size.M) /
|
||||
2
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
</ShimmerPlaceholder>
|
||||
)
|
||||
}, [composeState.attachmentUploadProgress, composeState.attachments.uploads])
|
||||
<Button
|
||||
type='icon'
|
||||
content='upload-cloud'
|
||||
spacing='M'
|
||||
round
|
||||
overlay
|
||||
onPress={async () => await addAttachment({ composeDispatch })}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top:
|
||||
(DEFAULT_HEIGHT -
|
||||
StyleConstants.Spacing.M * 2 -
|
||||
StyleConstants.Font.Size.M) /
|
||||
2,
|
||||
left:
|
||||
(DEFAULT_HEIGHT -
|
||||
StyleConstants.Spacing.M * 2 -
|
||||
StyleConstants.Font.Size.M) /
|
||||
2
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={styles.base}>
|
||||
<Pressable
|
||||
style={styles.sensitive}
|
||||
onPress={() =>
|
||||
composeDispatch({
|
||||
type: 'attachments',
|
||||
payload: { sensitive: !composeState.attachments.sensitive }
|
||||
})
|
||||
}
|
||||
>
|
||||
<Pressable style={styles.sensitive} onPress={sensitiveOnPress}>
|
||||
<Feather
|
||||
name={composeState.attachments.sensitive ? 'check-circle' : 'circle'}
|
||||
size={StyleConstants.Font.Size.L}
|
||||
@ -181,17 +183,17 @@ const ComposeAttachments: React.FC = () => {
|
||||
标记媒体为敏感内容
|
||||
</Text>
|
||||
</Pressable>
|
||||
<View style={styles.imageContainer}>
|
||||
<FlatList
|
||||
horizontal
|
||||
extraData={composeState.attachments.uploads.length}
|
||||
data={composeState.attachments.uploads}
|
||||
renderItem={renderAttachment}
|
||||
ListFooterComponent={listFooter}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps='handled'
|
||||
/>
|
||||
</View>
|
||||
<FlatList
|
||||
horizontal
|
||||
keyExtractor={item => item.local!.uri || item.remote!.url}
|
||||
data={composeState.attachments.uploads}
|
||||
renderItem={renderAttachment}
|
||||
ListFooterComponent={
|
||||
composeState.attachments.uploads.length < 4 ? listFooter : null
|
||||
}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps='handled'
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@ -212,46 +214,41 @@ const styles = StyleSheet.create({
|
||||
...StyleConstants.FontStyle.M,
|
||||
marginLeft: StyleConstants.Spacing.S
|
||||
},
|
||||
imageContainer: {
|
||||
height: DEFAULT_HEIGHT
|
||||
},
|
||||
image: {
|
||||
flex: 1,
|
||||
container: {
|
||||
height: DEFAULT_HEIGHT,
|
||||
marginLeft: StyleConstants.Spacing.Global.PagePadding,
|
||||
marginTop: StyleConstants.Spacing.Global.PagePadding,
|
||||
marginBottom: StyleConstants.Spacing.Global.PagePadding
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
},
|
||||
duration: {
|
||||
position: 'absolute',
|
||||
bottom:
|
||||
StyleConstants.Spacing.Global.PagePadding + StyleConstants.Spacing.S,
|
||||
left: StyleConstants.Spacing.Global.PagePadding + StyleConstants.Spacing.S,
|
||||
bottom: StyleConstants.Spacing.S,
|
||||
left: StyleConstants.Spacing.S,
|
||||
...StyleConstants.FontStyle.S,
|
||||
paddingLeft: StyleConstants.Spacing.S,
|
||||
paddingRight: StyleConstants.Spacing.S,
|
||||
paddingTop: StyleConstants.Spacing.XS,
|
||||
paddingBottom: StyleConstants.Spacing.XS
|
||||
},
|
||||
uploading: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
},
|
||||
delete: {
|
||||
position: 'absolute',
|
||||
top: StyleConstants.Spacing.Global.PagePadding + StyleConstants.Spacing.S,
|
||||
top: StyleConstants.Spacing.S,
|
||||
right: StyleConstants.Spacing.S
|
||||
},
|
||||
edit: {
|
||||
position: 'absolute',
|
||||
bottom:
|
||||
StyleConstants.Spacing.Global.PagePadding + StyleConstants.Spacing.S,
|
||||
bottom: StyleConstants.Spacing.S,
|
||||
right: StyleConstants.Spacing.S
|
||||
},
|
||||
progressContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: DEFAULT_HEIGHT,
|
||||
marginLeft: StyleConstants.Spacing.Global.PagePadding,
|
||||
marginTop: StyleConstants.Spacing.Global.PagePadding,
|
||||
marginBottom: StyleConstants.Spacing.Global.PagePadding
|
||||
}
|
||||
})
|
||||
|
||||
export default ComposeAttachments
|
||||
export default React.memo(ComposeAttachments, () => true)
|
||||
|
@ -1,41 +1,24 @@
|
||||
import client from '@root/api/client'
|
||||
import { HeaderLeft, HeaderRight } from '@root/components/Header'
|
||||
import { ComposeContext } from '@screens/Shared/Compose'
|
||||
import React, {
|
||||
Dispatch,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import {
|
||||
Alert,
|
||||
Animated,
|
||||
Dimensions,
|
||||
Image,
|
||||
KeyboardAvoidingView,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
View
|
||||
} from 'react-native'
|
||||
import { Alert, KeyboardAvoidingView } from 'react-native'
|
||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
||||
import Svg, { Circle, G, Path } from 'react-native-svg'
|
||||
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
|
||||
|
||||
import { HeaderLeft, HeaderRight } from '@components/Header'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import { PanGestureHandler } from 'react-native-gesture-handler'
|
||||
import { ComposeAction } from '@screens/Shared/Compose'
|
||||
import client from '@api/client'
|
||||
import AttachmentVideo from '@root/components/Timelines/Timeline/Shared/Attachment/Video'
|
||||
import ComposeEditAttachmentRoot from './EditAttachment/Root'
|
||||
|
||||
const Stack = createNativeStackNavigator()
|
||||
|
||||
export interface Props {
|
||||
route: {
|
||||
params: {
|
||||
attachment: Mastodon.Attachment & { local_url: string }
|
||||
composeDispatch: Dispatch<ComposeAction>
|
||||
index: number
|
||||
}
|
||||
}
|
||||
navigation: any
|
||||
@ -43,27 +26,32 @@ export interface Props {
|
||||
|
||||
const ComposeEditAttachment: React.FC<Props> = ({
|
||||
route: {
|
||||
params: { attachment, composeDispatch }
|
||||
params: { index }
|
||||
},
|
||||
navigation
|
||||
}) => {
|
||||
const { theme } = useTheme()
|
||||
const { composeState, composeDispatch } = useContext(ComposeContext)
|
||||
const theAttachment = composeState.attachments.uploads[index].remote!
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const [altText, setAltText] = useState<string | undefined>(
|
||||
attachment.description
|
||||
theAttachment.description
|
||||
)
|
||||
const focus = useRef({ x: 0, y: 0 })
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = navigation.addListener('beforeRemove', () => {
|
||||
let needUpdate = false
|
||||
if (altText) {
|
||||
attachment.description = altText
|
||||
|
||||
if (theAttachment.description !== altText) {
|
||||
theAttachment.description = altText
|
||||
needUpdate = true
|
||||
}
|
||||
if (attachment.type === 'image') {
|
||||
|
||||
if (theAttachment.type === 'image') {
|
||||
if (focus.current.x !== 0 || focus.current.y !== 0) {
|
||||
attachment.meta!.focus = {
|
||||
theAttachment.meta!.focus = {
|
||||
x: focus.current.x > 1 ? 1 : focus.current.x,
|
||||
y: focus.current.y > 1 ? 1 : focus.current.y
|
||||
}
|
||||
@ -71,286 +59,100 @@ const ComposeEditAttachment: React.FC<Props> = ({
|
||||
}
|
||||
}
|
||||
if (needUpdate) {
|
||||
composeDispatch({ type: 'attachmentEdit', payload: attachment })
|
||||
composeDispatch({ type: 'attachment/edit', payload: theAttachment })
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [navigation, altText])
|
||||
}, [focus, altText])
|
||||
|
||||
const videoPlayback = useCallback(() => {
|
||||
return (
|
||||
<AttachmentVideo
|
||||
media_attachments={[attachment as Mastodon.AttachmentVideo]}
|
||||
width={Dimensions.get('screen').width}
|
||||
const headerLeft = useCallback(
|
||||
() => (
|
||||
<HeaderLeft
|
||||
type='text'
|
||||
content='取消'
|
||||
onPress={() => navigation.goBack()}
|
||||
/>
|
||||
)
|
||||
}, [])
|
||||
|
||||
const imageFocus = useCallback(() => {
|
||||
const imageDimensionis = {
|
||||
width: Dimensions.get('screen').width,
|
||||
height:
|
||||
Dimensions.get('screen').width /
|
||||
(attachment as Mastodon.AttachmentImage).meta?.original?.aspect!
|
||||
}
|
||||
|
||||
const panFocus = useRef(
|
||||
new Animated.ValueXY(
|
||||
(attachment as Mastodon.AttachmentImage).meta?.focus?.x &&
|
||||
(attachment as Mastodon.AttachmentImage).meta?.focus?.y
|
||||
? {
|
||||
x:
|
||||
((attachment as Mastodon.AttachmentImage).meta!.focus!.x *
|
||||
imageDimensionis.width) /
|
||||
2,
|
||||
y:
|
||||
(-(attachment as Mastodon.AttachmentImage).meta!.focus!.y *
|
||||
imageDimensionis.height) /
|
||||
2
|
||||
}
|
||||
: { x: 0, y: 0 }
|
||||
)
|
||||
).current
|
||||
const panX = panFocus.x.interpolate({
|
||||
inputRange: [-imageDimensionis.width / 2, imageDimensionis.width / 2],
|
||||
outputRange: [-imageDimensionis.width / 2, imageDimensionis.width / 2],
|
||||
extrapolate: 'clamp'
|
||||
})
|
||||
const panY = panFocus.y.interpolate({
|
||||
inputRange: [-imageDimensionis.height / 2, imageDimensionis.height / 2],
|
||||
outputRange: [-imageDimensionis.height / 2, imageDimensionis.height / 2],
|
||||
extrapolate: 'clamp'
|
||||
})
|
||||
panFocus.addListener(e => {
|
||||
focus.current = {
|
||||
x: e.x / (imageDimensionis.width / 2),
|
||||
y: -e.y / (imageDimensionis.height / 2)
|
||||
}
|
||||
})
|
||||
const handleGesture = Animated.event(
|
||||
[{ nativeEvent: { translationX: panFocus.x, translationY: panFocus.y } }],
|
||||
{ useNativeDriver: true }
|
||||
)
|
||||
const onHandlerStateChange = useCallback(() => {
|
||||
panFocus.extractOffset()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={{ overflow: 'hidden' }}>
|
||||
<Image
|
||||
style={{
|
||||
width: imageDimensionis.width,
|
||||
height: imageDimensionis.height
|
||||
}}
|
||||
source={{
|
||||
uri: attachment.local_url || attachment.preview_url
|
||||
}}
|
||||
/>
|
||||
<PanGestureHandler
|
||||
onGestureEvent={handleGesture}
|
||||
onHandlerStateChange={onHandlerStateChange}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: 'absolute',
|
||||
top: -1000 + imageDimensionis.height / 2,
|
||||
left: -1000 + imageDimensionis.width / 2,
|
||||
transform: [{ translateX: panX }, { translateY: panY }]
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Svg width='2000' height='2000' viewBox='0 0 2000 2000'>
|
||||
<G>
|
||||
<G id='Mask'>
|
||||
<Path
|
||||
d={
|
||||
'M2000,0 L2000,2000 L0,2000 L0,0 L2000,0 Z M1000,967 C981.774603,967 967,981.774603 967,1000 C967,1018.2254 981.774603,1033 1000,1033 C1018.2254,1033 1033,1018.2254 1033,1000 C1033,981.774603 1018.2254,967 1000,967 Z'
|
||||
}
|
||||
fill={theme.backgroundOverlay}
|
||||
/>
|
||||
<G transform='translate(967, 967)'>
|
||||
<Circle
|
||||
stroke={theme.background}
|
||||
strokeWidth='2'
|
||||
cx='33'
|
||||
cy='33'
|
||||
r='33'
|
||||
/>
|
||||
<Circle fill={theme.background} cx='33' cy='33' r='2' />
|
||||
</G>
|
||||
</G>
|
||||
</G>
|
||||
</Svg>
|
||||
</Animated.View>
|
||||
</PanGestureHandler>
|
||||
</View>
|
||||
<Text style={[styles.imageFocusText, { color: theme.primary }]}>
|
||||
在预览图上拖动圆圈,以选择缩略图的焦点。
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
}, [])
|
||||
|
||||
const altTextInput = useCallback(() => {
|
||||
return (
|
||||
<View style={styles.altTextContainer}>
|
||||
<Text style={[styles.altTextInputHeading, { color: theme.primary }]}>
|
||||
为附件添加文字说明
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.altTextInput, { borderColor: theme.border }]}
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
maxLength={1500}
|
||||
multiline
|
||||
onChangeText={e => setAltText(e)}
|
||||
placeholder={
|
||||
'你可以为附件添加文字说明,以便更多人可以查看他们(包括视力障碍或视力受损人士)。\n\n优质的描述应该简洁明了,但要准确地描述照片中的内容,以便用户理解其含义。'
|
||||
),
|
||||
[]
|
||||
)
|
||||
const headerRight = useCallback(
|
||||
() => (
|
||||
<HeaderRight
|
||||
type='text'
|
||||
content='应用'
|
||||
loading={isSubmitting}
|
||||
onPress={() => {
|
||||
if (!altText && focus.current.x === 0 && focus.current.y === 0) {
|
||||
navigation.goBack()
|
||||
return
|
||||
}
|
||||
placeholderTextColor={theme.secondary}
|
||||
scrollEnabled
|
||||
value={altText}
|
||||
/>
|
||||
<Text style={[styles.altTextLength, { color: theme.secondary }]}>
|
||||
{altText?.length || 0} / 1500
|
||||
</Text>
|
||||
</View>
|
||||
)
|
||||
}, [altText])
|
||||
setIsSubmitting(true)
|
||||
const formData = new FormData()
|
||||
if (altText) {
|
||||
formData.append('description', altText)
|
||||
}
|
||||
if (focus.current.x !== 0 || focus.current.y !== 0) {
|
||||
formData.append('focus', `${focus.current.x},${focus.current.y}`)
|
||||
}
|
||||
|
||||
client({
|
||||
method: 'put',
|
||||
instance: 'local',
|
||||
url: `media/${theAttachment.id}`,
|
||||
body: formData
|
||||
})
|
||||
.then(() => {
|
||||
Alert.alert('修改成功', '', [
|
||||
{
|
||||
text: '好的',
|
||||
onPress: () => {
|
||||
navigation.goBack()
|
||||
}
|
||||
}
|
||||
])
|
||||
})
|
||||
.catch(() => {
|
||||
setIsSubmitting(false)
|
||||
Alert.alert('修改失败', '', [
|
||||
{
|
||||
text: '返回重试',
|
||||
style: 'cancel'
|
||||
}
|
||||
])
|
||||
})
|
||||
}}
|
||||
/>
|
||||
),
|
||||
|
||||
[]
|
||||
)
|
||||
|
||||
const children = useCallback(
|
||||
() => (
|
||||
<ComposeEditAttachmentRoot
|
||||
index={index}
|
||||
focus={focus}
|
||||
altText={altText}
|
||||
setAltText={setAltText}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView behavior='padding' style={{ flex: 1 }}>
|
||||
<SafeAreaView style={{ flex: 1 }} edges={['right', 'bottom', 'left']}>
|
||||
<Stack.Navigator screenOptions={{ headerHideShadow: true }}>
|
||||
<SafeAreaView style={{ flex: 1 }} edges={['left', 'right', 'bottom']}>
|
||||
<Stack.Navigator>
|
||||
<Stack.Screen
|
||||
name='Screen-Shared-Compose-EditAttachment-Root'
|
||||
options={{
|
||||
title: '编辑附件',
|
||||
headerLeft: () => (
|
||||
<HeaderLeft
|
||||
type='text'
|
||||
content='取消'
|
||||
onPress={() => navigation.goBack()}
|
||||
/>
|
||||
),
|
||||
headerRight: () => (
|
||||
<HeaderRight
|
||||
type='text'
|
||||
content='应用'
|
||||
onPress={() => {
|
||||
const formData = new FormData()
|
||||
|
||||
if (altText) {
|
||||
formData.append('description', altText)
|
||||
}
|
||||
|
||||
if (focus.current.x !== 0 || focus.current.y !== 0) {
|
||||
formData.append(
|
||||
'focus',
|
||||
`${focus.current.x},${focus.current.y}`
|
||||
)
|
||||
}
|
||||
|
||||
client({
|
||||
method: 'put',
|
||||
instance: 'local',
|
||||
url: `media/${attachment.id}`,
|
||||
...(formData && { body: formData })
|
||||
})
|
||||
.then(
|
||||
res => {
|
||||
if (res.body.id === attachment.id) {
|
||||
Alert.alert('修改成功', '', [
|
||||
{
|
||||
text: '好的',
|
||||
onPress: () => {
|
||||
navigation.goBack()
|
||||
}
|
||||
}
|
||||
])
|
||||
} else {
|
||||
Alert.alert('修改失败', '', [
|
||||
{
|
||||
text: '返回重试'
|
||||
}
|
||||
])
|
||||
}
|
||||
},
|
||||
error => {
|
||||
Alert.alert('修改失败', error.body, [
|
||||
{
|
||||
text: '返回重试'
|
||||
}
|
||||
])
|
||||
}
|
||||
)
|
||||
.catch(() => {
|
||||
Alert.alert('修改失败', '', [
|
||||
{
|
||||
text: '返回重试'
|
||||
}
|
||||
])
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
>
|
||||
{() => {
|
||||
switch (attachment.type) {
|
||||
case 'image':
|
||||
return (
|
||||
<ScrollView style={{ flex: 1 }}>
|
||||
{imageFocus()}
|
||||
{altTextInput()}
|
||||
</ScrollView>
|
||||
)
|
||||
case 'video':
|
||||
case 'gifv':
|
||||
return (
|
||||
<ScrollView style={{ flex: 1 }}>
|
||||
{videoPlayback()}
|
||||
{altTextInput()}
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}}
|
||||
</Stack.Screen>
|
||||
children={children}
|
||||
options={{ headerLeft, headerRight, headerCenter: () => null }}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
</SafeAreaView>
|
||||
</KeyboardAvoidingView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
imageFocusText: {
|
||||
...StyleConstants.FontStyle.M,
|
||||
padding: StyleConstants.Spacing.Global.PagePadding
|
||||
},
|
||||
altTextContainer: { padding: StyleConstants.Spacing.Global.PagePadding },
|
||||
altTextInputHeading: {
|
||||
...StyleConstants.FontStyle.M,
|
||||
fontWeight: StyleConstants.Font.Weight.Bold
|
||||
},
|
||||
altTextInput: {
|
||||
height: 200,
|
||||
...StyleConstants.FontStyle.M,
|
||||
marginTop: StyleConstants.Spacing.M,
|
||||
marginBottom: StyleConstants.Spacing.S,
|
||||
padding: StyleConstants.Spacing.Global.PagePadding,
|
||||
paddingTop: StyleConstants.Spacing.S * 1.5,
|
||||
borderWidth: StyleSheet.hairlineWidth
|
||||
},
|
||||
altTextLength: {
|
||||
textAlign: 'right',
|
||||
marginRight: StyleConstants.Spacing.S,
|
||||
...StyleConstants.FontStyle.S,
|
||||
marginBottom: StyleConstants.Spacing.M
|
||||
}
|
||||
})
|
||||
|
||||
export default ComposeEditAttachment
|
||||
|
146
src/screens/Shared/Compose/EditAttachment/Image.tsx
Normal file
146
src/screens/Shared/Compose/EditAttachment/Image.tsx
Normal file
@ -0,0 +1,146 @@
|
||||
import { StyleConstants } from '@root/utils/styles/constants'
|
||||
import { useTheme } from '@root/utils/styles/ThemeManager'
|
||||
import { ComposeContext } from '@screens/Shared/Compose'
|
||||
import React, { MutableRefObject, useCallback, useContext, useRef } from 'react'
|
||||
import {
|
||||
Animated,
|
||||
Dimensions,
|
||||
Image,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View
|
||||
} from 'react-native'
|
||||
import { PanGestureHandler } from 'react-native-gesture-handler'
|
||||
import Svg, { Circle, G, Path } from 'react-native-svg'
|
||||
|
||||
export interface Props {
|
||||
index: number
|
||||
focus: MutableRefObject<{
|
||||
x: number
|
||||
y: number
|
||||
}>
|
||||
}
|
||||
|
||||
const ComposeEditAttachmentImage: React.FC<Props> = ({ index, focus }) => {
|
||||
const { theme } = useTheme()
|
||||
|
||||
const { composeState } = useContext(ComposeContext)
|
||||
const theAttachmentRemote = composeState.attachments.uploads[index].remote!
|
||||
const theAttachmentLocal = composeState.attachments.uploads[index].local!
|
||||
|
||||
const imageDimensionis = {
|
||||
width: Dimensions.get('screen').width,
|
||||
height:
|
||||
Dimensions.get('screen').width /
|
||||
(theAttachmentRemote as Mastodon.AttachmentImage).meta?.original?.aspect!
|
||||
}
|
||||
|
||||
const panFocus = useRef(
|
||||
new Animated.ValueXY(
|
||||
(theAttachmentRemote as Mastodon.AttachmentImage).meta?.focus?.x &&
|
||||
(theAttachmentRemote as Mastodon.AttachmentImage).meta?.focus?.y
|
||||
? {
|
||||
x:
|
||||
((theAttachmentRemote as Mastodon.AttachmentImage).meta!.focus!
|
||||
.x *
|
||||
imageDimensionis.width) /
|
||||
2,
|
||||
y:
|
||||
(-(theAttachmentRemote as Mastodon.AttachmentImage).meta!.focus!
|
||||
.y *
|
||||
imageDimensionis.height) /
|
||||
2
|
||||
}
|
||||
: { x: 0, y: 0 }
|
||||
)
|
||||
).current
|
||||
const panX = panFocus.x.interpolate({
|
||||
inputRange: [-imageDimensionis.width / 2, imageDimensionis.width / 2],
|
||||
outputRange: [-imageDimensionis.width / 2, imageDimensionis.width / 2],
|
||||
extrapolate: 'clamp'
|
||||
})
|
||||
const panY = panFocus.y.interpolate({
|
||||
inputRange: [-imageDimensionis.height / 2, imageDimensionis.height / 2],
|
||||
outputRange: [-imageDimensionis.height / 2, imageDimensionis.height / 2],
|
||||
extrapolate: 'clamp'
|
||||
})
|
||||
panFocus.addListener(e => {
|
||||
focus.current = {
|
||||
x: e.x / (imageDimensionis.width / 2),
|
||||
y: -e.y / (imageDimensionis.height / 2)
|
||||
}
|
||||
})
|
||||
const handleGesture = Animated.event(
|
||||
[{ nativeEvent: { translationX: panFocus.x, translationY: panFocus.y } }],
|
||||
{ useNativeDriver: true }
|
||||
)
|
||||
const onHandlerStateChange = useCallback(() => {
|
||||
panFocus.extractOffset()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<View style={{ overflow: 'hidden' }}>
|
||||
<Image
|
||||
style={{
|
||||
width: imageDimensionis.width,
|
||||
height: imageDimensionis.height
|
||||
}}
|
||||
source={{
|
||||
uri: theAttachmentLocal.uri || theAttachmentRemote.preview_url
|
||||
}}
|
||||
/>
|
||||
<PanGestureHandler
|
||||
onGestureEvent={handleGesture}
|
||||
onHandlerStateChange={onHandlerStateChange}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: 'absolute',
|
||||
top: -1000 + imageDimensionis.height / 2,
|
||||
left: -1000 + imageDimensionis.width / 2,
|
||||
transform: [{ translateX: panX }, { translateY: panY }]
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Svg width='2000' height='2000' viewBox='0 0 2000 2000'>
|
||||
<G>
|
||||
<G id='Mask'>
|
||||
<Path
|
||||
d={
|
||||
'M2000,0 L2000,2000 L0,2000 L0,0 L2000,0 Z M1000,967 C981.774603,967 967,981.774603 967,1000 C967,1018.2254 981.774603,1033 1000,1033 C1018.2254,1033 1033,1018.2254 1033,1000 C1033,981.774603 1018.2254,967 1000,967 Z'
|
||||
}
|
||||
fill={theme.backgroundOverlay}
|
||||
/>
|
||||
<G transform='translate(967, 967)'>
|
||||
<Circle
|
||||
stroke={theme.background}
|
||||
strokeWidth='2'
|
||||
cx='33'
|
||||
cy='33'
|
||||
r='33'
|
||||
/>
|
||||
<Circle fill={theme.background} cx='33' cy='33' r='2' />
|
||||
</G>
|
||||
</G>
|
||||
</G>
|
||||
</Svg>
|
||||
</Animated.View>
|
||||
</PanGestureHandler>
|
||||
</View>
|
||||
<Text style={[styles.imageFocusText, { color: theme.primary }]}>
|
||||
在预览图上拖动圆圈,以选择缩略图的焦点。
|
||||
</Text>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
imageFocusText: {
|
||||
...StyleConstants.FontStyle.M,
|
||||
padding: StyleConstants.Spacing.Global.PagePadding
|
||||
}
|
||||
})
|
||||
|
||||
export default ComposeEditAttachmentImage
|
104
src/screens/Shared/Compose/EditAttachment/Root.tsx
Normal file
104
src/screens/Shared/Compose/EditAttachment/Root.tsx
Normal file
@ -0,0 +1,104 @@
|
||||
import AttachmentVideo from '@components/Timelines/Timeline/Shared/Attachment/Video'
|
||||
import { ComposeContext } from '@screens/Shared/Compose'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import React, {
|
||||
Dispatch,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useContext,
|
||||
useMemo
|
||||
} from 'react'
|
||||
import { ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'
|
||||
import ComposeEditAttachmentImage from './Image'
|
||||
|
||||
export interface Props {
|
||||
index: number
|
||||
focus: MutableRefObject<{
|
||||
x: number
|
||||
y: number
|
||||
}>
|
||||
altText: string | undefined
|
||||
setAltText: Dispatch<SetStateAction<string | undefined>>
|
||||
}
|
||||
|
||||
const ComposeEditAttachmentRoot: React.FC<Props> = ({
|
||||
index,
|
||||
focus,
|
||||
altText,
|
||||
setAltText
|
||||
}) => {
|
||||
const { theme } = useTheme()
|
||||
const { composeState } = useContext(ComposeContext)
|
||||
const theAttachment = composeState.attachments.uploads[index].remote!
|
||||
|
||||
const mediaDisplay = useMemo(() => {
|
||||
switch (theAttachment.type) {
|
||||
case 'image':
|
||||
return <ComposeEditAttachmentImage index={index} focus={focus} />
|
||||
case 'video':
|
||||
case 'gifv':
|
||||
return (
|
||||
<AttachmentVideo
|
||||
sensitiveShown={false}
|
||||
video={theAttachment as Mastodon.AttachmentVideo}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return null
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ScrollView>
|
||||
{mediaDisplay}
|
||||
<View style={styles.altTextContainer}>
|
||||
<Text style={[styles.altTextInputHeading, { color: theme.primary }]}>
|
||||
为附件添加文字说明
|
||||
</Text>
|
||||
<TextInput
|
||||
autoFocus
|
||||
style={[styles.altTextInput, { borderColor: theme.border }]}
|
||||
autoCapitalize='none'
|
||||
autoCorrect={false}
|
||||
maxLength={1500}
|
||||
multiline
|
||||
onChangeText={e => setAltText(e)}
|
||||
placeholder={
|
||||
'你可以为附件添加文字说明,以便更多人可以查看他们(包括视力障碍或视力受损人士)。\n\n优质的描述应该简洁明了,但要准确地描述照片中的内容,以便用户理解其含义。'
|
||||
}
|
||||
placeholderTextColor={theme.secondary}
|
||||
scrollEnabled
|
||||
value={altText}
|
||||
/>
|
||||
<Text style={[styles.altTextLength, { color: theme.secondary }]}>
|
||||
{altText?.length || 0} / 1500
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
altTextContainer: { padding: StyleConstants.Spacing.Global.PagePadding },
|
||||
altTextInputHeading: {
|
||||
...StyleConstants.FontStyle.M,
|
||||
fontWeight: StyleConstants.Font.Weight.Bold
|
||||
},
|
||||
altTextInput: {
|
||||
height: 200,
|
||||
...StyleConstants.FontStyle.M,
|
||||
marginTop: StyleConstants.Spacing.M,
|
||||
marginBottom: StyleConstants.Spacing.S,
|
||||
padding: StyleConstants.Spacing.Global.PagePadding,
|
||||
paddingTop: StyleConstants.Spacing.S * 1.5,
|
||||
borderWidth: StyleSheet.hairlineWidth
|
||||
},
|
||||
altTextLength: {
|
||||
textAlign: 'right',
|
||||
marginRight: StyleConstants.Spacing.S,
|
||||
...StyleConstants.FontStyle.S,
|
||||
marginBottom: StyleConstants.Spacing.M
|
||||
}
|
||||
})
|
||||
|
||||
export default ComposeEditAttachmentRoot
|
@ -154,7 +154,8 @@ const styles = StyleSheet.create({
|
||||
base: {
|
||||
flex: 1,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderRadius: 6
|
||||
borderRadius: 6,
|
||||
margin: StyleConstants.Spacing.Global.PagePadding
|
||||
},
|
||||
options: {
|
||||
marginTop: StyleConstants.Spacing.M,
|
||||
|
@ -47,7 +47,8 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
paddingTop: StyleConstants.Spacing.Global.PagePadding
|
||||
paddingTop: StyleConstants.Spacing.Global.PagePadding,
|
||||
margin: StyleConstants.Spacing.Global.PagePadding
|
||||
},
|
||||
details: {
|
||||
flex: 1
|
||||
|
@ -25,7 +25,6 @@ import {
|
||||
View,
|
||||
FlatList,
|
||||
Pressable,
|
||||
ProgressViewIOS,
|
||||
StyleSheet,
|
||||
Text,
|
||||
Image
|
||||
@ -95,7 +94,6 @@ const ListItem = React.memo(
|
||||
)
|
||||
return (
|
||||
<Pressable
|
||||
key={item.url}
|
||||
onPress={onPress}
|
||||
style={styles.suggestion}
|
||||
children={children}
|
||||
@ -110,7 +108,7 @@ const ComposeRoot: React.FC = () => {
|
||||
|
||||
const { composeState, composeDispatch } = useContext(ComposeContext)
|
||||
|
||||
const { isFetching, isSuccess, data, refetch } = useQuery(
|
||||
const { isFetching, data, refetch } = useQuery(
|
||||
[
|
||||
'Search',
|
||||
{
|
||||
@ -159,7 +157,7 @@ const ComposeRoot: React.FC = () => {
|
||||
const listEmpty = useMemo(() => {
|
||||
if (isFetching) {
|
||||
return (
|
||||
<View style={styles.loading}>
|
||||
<View key='listEmpty' style={styles.loading}>
|
||||
<Chase
|
||||
size={StyleConstants.Font.Size.M * 1.25}
|
||||
color={theme.secondary}
|
||||
@ -169,35 +167,27 @@ const ComposeRoot: React.FC = () => {
|
||||
}
|
||||
}, [isFetching])
|
||||
|
||||
const listKey = useCallback(
|
||||
(item: Mastodon.Account | Mastodon.Tag) => item.url,
|
||||
[isSuccess]
|
||||
)
|
||||
const listItem = useCallback(
|
||||
({ item }) =>
|
||||
isSuccess ? (
|
||||
<ListItem
|
||||
item={item}
|
||||
composeState={composeState}
|
||||
composeDispatch={composeDispatch}
|
||||
/>
|
||||
) : null,
|
||||
[isSuccess]
|
||||
({ item }) => (
|
||||
<ListItem
|
||||
item={item}
|
||||
composeState={composeState}
|
||||
composeDispatch={composeDispatch}
|
||||
/>
|
||||
),
|
||||
[composeState]
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={styles.base}>
|
||||
<ProgressViewIOS
|
||||
progress={composeState.attachmentUploadProgress?.progress || 0}
|
||||
progressViewStyle='bar'
|
||||
/>
|
||||
<FlatList
|
||||
keyboardShouldPersistTaps='handled'
|
||||
ListHeaderComponent={<ComposeRootHeader />}
|
||||
ListFooterComponent={<ComposeRootFooter />}
|
||||
ListHeaderComponent={ComposeRootHeader}
|
||||
ListFooterComponent={ComposeRootFooter}
|
||||
ListEmptyComponent={listEmpty}
|
||||
data={data as Mastodon.Account[] & Mastodon.Tag[]}
|
||||
keyExtractor={listKey}
|
||||
renderItem={listItem}
|
||||
keyExtractor={(item: any) => item.acct || item.name}
|
||||
/>
|
||||
<ComposeActions />
|
||||
</View>
|
||||
|
@ -1,6 +1,4 @@
|
||||
import React, { useContext } from 'react'
|
||||
import { StyleSheet, View } from 'react-native'
|
||||
import { StyleConstants } from '@utils/styles/constants'
|
||||
import { ComposeContext } from '@screens/Shared/Compose'
|
||||
import ComposeAttachments from '@screens/Shared/Compose/Attachments'
|
||||
import ComposeEmojis from '@screens/Shared/Compose/Emojis'
|
||||
@ -12,49 +10,12 @@ const ComposeRootFooter: React.FC = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{composeState.emoji.active && (
|
||||
<View style={styles.emojis}>
|
||||
<ComposeEmojis />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{(composeState.attachments.uploads.length > 0 ||
|
||||
composeState.attachmentUploadProgress) && (
|
||||
<View style={styles.attachments}>
|
||||
<ComposeAttachments />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{composeState.poll.active && (
|
||||
<View style={styles.poll} key='poll'>
|
||||
<ComposePoll />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{composeState.replyToStatus && (
|
||||
<View style={styles.reply}>
|
||||
<ComposeReply />
|
||||
</View>
|
||||
)}
|
||||
{composeState.emoji.active ? <ComposeEmojis /> : null}
|
||||
{composeState.attachments.uploads.length ? <ComposeAttachments /> : null}
|
||||
{composeState.poll.active ? <ComposePoll /> : null}
|
||||
{composeState.replyToStatus ? <ComposeReply /> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
emojis: {
|
||||
flex: 1
|
||||
},
|
||||
attachments: {
|
||||
flex: 1
|
||||
},
|
||||
poll: {
|
||||
flex: 1,
|
||||
padding: StyleConstants.Spacing.Global.PagePadding
|
||||
},
|
||||
reply: {
|
||||
flex: 1,
|
||||
padding: StyleConstants.Spacing.Global.PagePadding
|
||||
}
|
||||
})
|
||||
|
||||
export default ComposeRootFooter
|
||||
|
128
src/screens/Shared/Compose/addAttachment.ts
Normal file
128
src/screens/Shared/Compose/addAttachment.ts
Normal file
@ -0,0 +1,128 @@
|
||||
import client from '@api/client'
|
||||
import * as ImagePicker from 'expo-image-picker'
|
||||
import { ImageInfo } from 'expo-image-picker/build/ImagePicker.types'
|
||||
import * as VideoThumbnails from 'expo-video-thumbnails'
|
||||
import { Dispatch } from 'react'
|
||||
import { ActionSheetIOS, Alert } from 'react-native'
|
||||
import { ComposeAction } from './utils/types'
|
||||
|
||||
export interface Props {
|
||||
composeDispatch: Dispatch<ComposeAction>
|
||||
}
|
||||
|
||||
const addAttachment = async ({ composeDispatch }: Props): Promise<any> => {
|
||||
const uploadAttachment = (result: ImageInfo) => {
|
||||
switch (result.type) {
|
||||
case 'image':
|
||||
composeDispatch({
|
||||
type: 'attachment/upload/start',
|
||||
payload: {
|
||||
local: { ...result, local_thumbnail: result.uri },
|
||||
uploading: true
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'video':
|
||||
VideoThumbnails.getThumbnailAsync(result.uri)
|
||||
.then(({ uri }) =>
|
||||
composeDispatch({
|
||||
type: 'attachment/upload/start',
|
||||
payload: {
|
||||
local: { ...result, local_thumbnail: uri },
|
||||
uploading: true
|
||||
}
|
||||
})
|
||||
)
|
||||
.catch(() =>
|
||||
composeDispatch({
|
||||
type: 'attachment/upload/start',
|
||||
payload: {
|
||||
local: result,
|
||||
uploading: true
|
||||
}
|
||||
})
|
||||
)
|
||||
break
|
||||
default:
|
||||
composeDispatch({
|
||||
type: 'attachment/upload/start',
|
||||
payload: {
|
||||
local: result,
|
||||
uploading: true
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', {
|
||||
// @ts-ignore
|
||||
uri: result.uri,
|
||||
name: result.uri.split('/').pop(),
|
||||
type: 'image/jpeg/jpg'
|
||||
})
|
||||
|
||||
client({
|
||||
method: 'post',
|
||||
instance: 'local',
|
||||
url: 'media',
|
||||
body: formData
|
||||
})
|
||||
.then(({ body }: { body: Mastodon.Attachment }) => {
|
||||
if (body.id) {
|
||||
composeDispatch({
|
||||
type: 'attachment/upload/end',
|
||||
payload: { remote: body, local: result }
|
||||
})
|
||||
return Promise.resolve()
|
||||
} else {
|
||||
Alert.alert('上传失败', '', [
|
||||
{
|
||||
text: '返回重试',
|
||||
onPress: () => {}
|
||||
}
|
||||
])
|
||||
return Promise.reject()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
Alert.alert('上传失败', '', [
|
||||
{
|
||||
text: '返回重试',
|
||||
onPress: () => {}
|
||||
}
|
||||
])
|
||||
return Promise.reject()
|
||||
})
|
||||
}
|
||||
|
||||
ActionSheetIOS.showActionSheetWithOptions(
|
||||
{
|
||||
options: ['从相册选取', '现照', '取消'],
|
||||
cancelButtonIndex: 2
|
||||
},
|
||||
async buttonIndex => {
|
||||
if (buttonIndex === 0) {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.All,
|
||||
exif: false
|
||||
})
|
||||
|
||||
if (!result.cancelled) {
|
||||
uploadAttachment(result)
|
||||
}
|
||||
} else if (buttonIndex === 1) {
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.All,
|
||||
exif: false
|
||||
})
|
||||
|
||||
if (!result.cancelled) {
|
||||
uploadAttachment(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export default addAttachment
|
@ -1,118 +0,0 @@
|
||||
import { Dispatch } from 'react'
|
||||
import { ActionSheetIOS, Alert } from 'react-native'
|
||||
import * as ImagePicker from 'expo-image-picker'
|
||||
|
||||
import { ComposeAction, ComposeState } from '@screens/Shared/Compose'
|
||||
import client from '@api/client'
|
||||
import { ImageInfo } from 'expo-image-picker/build/ImagePicker.types'
|
||||
|
||||
const uploadAttachment = async ({
|
||||
result,
|
||||
composeState,
|
||||
composeDispatch
|
||||
}: {
|
||||
result: NonNullable<ImageInfo>
|
||||
composeState: ComposeState
|
||||
composeDispatch: Dispatch<ComposeAction>
|
||||
}) => {
|
||||
const formData = new FormData()
|
||||
// @ts-ignore
|
||||
formData.append('file', {
|
||||
// @ts-ignore
|
||||
uri: result.uri,
|
||||
name: result.uri.split('/').pop(),
|
||||
type: 'image/jpeg/jpg'
|
||||
})
|
||||
|
||||
client({
|
||||
method: 'post',
|
||||
instance: 'local',
|
||||
url: 'media',
|
||||
body: formData,
|
||||
onUploadProgress: p => {
|
||||
composeDispatch({
|
||||
type: 'attachmentUploadProgress',
|
||||
payload: {
|
||||
progress: p.loaded / p.total,
|
||||
aspect: result.width / result.height
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
.then(({ body }: { body: Mastodon.Attachment & { local_url: string } }) => {
|
||||
composeDispatch({
|
||||
type: 'attachmentUploadProgress',
|
||||
payload: undefined
|
||||
})
|
||||
if (body.id) {
|
||||
body.local_url = result.uri
|
||||
composeDispatch({
|
||||
type: 'attachments',
|
||||
payload: { uploads: composeState.attachments.uploads.concat([body]) }
|
||||
})
|
||||
return Promise.resolve()
|
||||
} else {
|
||||
Alert.alert('上传失败', '', [
|
||||
{
|
||||
text: '返回重试',
|
||||
onPress: () =>
|
||||
composeDispatch({
|
||||
type: 'attachmentUploadProgress',
|
||||
payload: undefined
|
||||
})
|
||||
}
|
||||
])
|
||||
return Promise.reject()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
Alert.alert('上传失败', '', [
|
||||
{
|
||||
text: '返回重试',
|
||||
onPress: () =>
|
||||
composeDispatch({
|
||||
type: 'attachmentUploadProgress',
|
||||
payload: undefined
|
||||
})
|
||||
}
|
||||
])
|
||||
return Promise.reject()
|
||||
})
|
||||
}
|
||||
|
||||
const addAttachments = async ({
|
||||
...params
|
||||
}: {
|
||||
composeState: ComposeState
|
||||
composeDispatch: Dispatch<ComposeAction>
|
||||
}): Promise<any> => {
|
||||
ActionSheetIOS.showActionSheetWithOptions(
|
||||
{
|
||||
options: ['从相册选取', '现照', '取消'],
|
||||
cancelButtonIndex: 2
|
||||
},
|
||||
async buttonIndex => {
|
||||
if (buttonIndex === 0) {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.All,
|
||||
exif: false
|
||||
})
|
||||
|
||||
if (!result.cancelled) {
|
||||
await uploadAttachment({ result, ...params })
|
||||
}
|
||||
} else if (buttonIndex === 1) {
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.All,
|
||||
exif: false
|
||||
})
|
||||
|
||||
if (!result.cancelled) {
|
||||
await uploadAttachment({ result, ...params })
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export default addAttachments
|
@ -4,7 +4,7 @@ import { Text } from 'react-native'
|
||||
import { FetchOptions } from 'react-query/types/core/query'
|
||||
import Autolinker from '@root/modules/autolinker'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import { ComposeAction, ComposeState } from '@screens/Shared/Compose'
|
||||
import { ComposeAction, ComposeState } from './utils/types'
|
||||
|
||||
export interface Params {
|
||||
textInput: ComposeState['textInputFocus']['current']
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { Dispatch } from 'react'
|
||||
import { ComposeAction, ComposeState } from '@screens/Shared/Compose'
|
||||
import formatText from './formatText'
|
||||
import { ComposeAction, ComposeState } from './utils/types'
|
||||
|
||||
const updateText = ({
|
||||
composeState,
|
||||
|
42
src/screens/Shared/Compose/utils/initialState.ts
Normal file
42
src/screens/Shared/Compose/utils/initialState.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { createRef } from "react"
|
||||
import { ComposeState } from "./types"
|
||||
|
||||
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: [] },
|
||||
visibility: 'public',
|
||||
visibilityLock: false,
|
||||
replyToStatus: undefined,
|
||||
textInputFocus: {
|
||||
current: 'text',
|
||||
refs: { text: createRef(), spoiler: createRef() }
|
||||
}
|
||||
}
|
||||
|
||||
export default composeInitialState
|
104
src/screens/Shared/Compose/utils/parseState.ts
Normal file
104
src/screens/Shared/Compose/utils/parseState.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import { getLocalAccountPreferences } from '@root/utils/slices/instancesSlice'
|
||||
import { store } from '@root/store'
|
||||
import composeInitialState from './initialState'
|
||||
import { ComposeState } from './types'
|
||||
|
||||
const composeParseState = ({
|
||||
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.map(media => ({
|
||||
remote: media
|
||||
}))
|
||||
}
|
||||
}),
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default composeParseState
|
74
src/screens/Shared/Compose/utils/post.ts
Normal file
74
src/screens/Shared/Compose/utils/post.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import client from '@root/api/client'
|
||||
import { Props } from '@screens/Shared/Compose'
|
||||
import { ComposeState } from '@screens/Shared/Compose/utils/types'
|
||||
import * as Crypto from 'expo-crypto'
|
||||
|
||||
const composeSend = async (
|
||||
params: Props['route']['params'],
|
||||
composeState: ComposeState
|
||||
) => {
|
||||
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.filter(
|
||||
upload => upload.remote && upload.remote.id
|
||||
).length
|
||||
) {
|
||||
formData.append('sensitive', composeState.attachments.sensitive.toString())
|
||||
composeState.attachments.uploads.forEach(e =>
|
||||
formData.append('media_ids[]', e.remote!.id!)
|
||||
)
|
||||
}
|
||||
|
||||
formData.append('visibility', composeState.visibility)
|
||||
|
||||
const res = await 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.remote?.id) +
|
||||
composeState.visibility +
|
||||
(params?.type === 'edit' ? Math.random() : '')
|
||||
)
|
||||
},
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (res.body.id) {
|
||||
return Promise.resolve()
|
||||
} else {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
export default composeSend
|
77
src/screens/Shared/Compose/utils/reducer.ts
Normal file
77
src/screens/Shared/Compose/utils/reducer.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { ComposeAction, ComposeState } from "./types"
|
||||
|
||||
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/sensitive':
|
||||
return {
|
||||
...state,
|
||||
attachments: { ...state.attachments, ...action.payload }
|
||||
}
|
||||
case 'attachment/upload/start':
|
||||
return {
|
||||
...state,
|
||||
attachments: {
|
||||
...state.attachments,
|
||||
uploads: state.attachments.uploads.concat([action.payload])
|
||||
}
|
||||
}
|
||||
case 'attachment/upload/end':
|
||||
return {
|
||||
...state,
|
||||
attachments: {
|
||||
...state.attachments,
|
||||
uploads: state.attachments.uploads.map(upload =>
|
||||
upload.local?.uri === action.payload.local?.uri
|
||||
? { ...upload, remote: action.payload.remote, uploading: false }
|
||||
: upload
|
||||
)
|
||||
}
|
||||
}
|
||||
case 'attachment/delete':
|
||||
return {
|
||||
...state,
|
||||
attachments: {
|
||||
...state.attachments,
|
||||
uploads: state.attachments.uploads.filter(
|
||||
upload => upload.remote!.id !== action.payload
|
||||
)
|
||||
}
|
||||
}
|
||||
case 'attachment/edit':
|
||||
return {
|
||||
...state,
|
||||
attachments: {
|
||||
...state.attachments,
|
||||
uploads: state.attachments.uploads.map(upload =>
|
||||
upload.remote!.id === action.payload!.id
|
||||
? { ...upload, remote: 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')
|
||||
}
|
||||
}
|
||||
|
||||
export default composeReducer
|
112
src/screens/Shared/Compose/utils/types.d.ts
vendored
Normal file
112
src/screens/Shared/Compose/utils/types.d.ts
vendored
Normal file
@ -0,0 +1,112 @@
|
||||
export type ExtendedAttachment = {
|
||||
remote?: Mastodon.Attachment
|
||||
local?: ImageInfo & { local_thumbnail?: string }
|
||||
uploading?: boolean
|
||||
}
|
||||
|
||||
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: ExtendedAttachment[]
|
||||
}
|
||||
visibility: 'public' | 'unlisted' | 'private' | 'direct'
|
||||
visibilityLock: boolean
|
||||
replyToStatus?: Mastodon.Status
|
||||
textInputFocus: {
|
||||
current: 'text' | 'spoiler'
|
||||
refs: { text: RefObject<TextInput>; spoiler: RefObject<TextInput> }
|
||||
}
|
||||
}
|
||||
|
||||
export type ComposeAction =
|
||||
| {
|
||||
type: 'spoiler'
|
||||
payload: Partial<ComposeState['spoiler']>
|
||||
}
|
||||
| {
|
||||
type: 'text'
|
||||
payload: Partial<ComposeState['text']>
|
||||
}
|
||||
| {
|
||||
type: 'tag'
|
||||
payload: ComposeState['tag']
|
||||
}
|
||||
| {
|
||||
type: 'emoji'
|
||||
payload: ComposeState['emoji']
|
||||
}
|
||||
| {
|
||||
type: 'poll'
|
||||
payload: Partial<ComposeState['poll']>
|
||||
}
|
||||
| {
|
||||
type: 'attachments/sensitive'
|
||||
payload: Pick<ComposeState['attachments'], 'sensitive'>
|
||||
}
|
||||
| {
|
||||
type: 'attachment/upload/start'
|
||||
payload: Pick<ExtendedAttachment, 'local' | 'uploading'>
|
||||
}
|
||||
| {
|
||||
type: 'attachment/upload/end'
|
||||
payload: { remote: Mastodon.Attachment; local: ImageInfo }
|
||||
}
|
||||
| {
|
||||
type: 'attachment/delete'
|
||||
payload: NonNullable<ExtendedAttachment['remote']>['id']
|
||||
}
|
||||
| {
|
||||
type: 'attachment/edit'
|
||||
payload: ExtendedAttachment['remote']
|
||||
}
|
||||
| {
|
||||
type: 'visibility'
|
||||
payload: ComposeState['visibility']
|
||||
}
|
||||
| {
|
||||
type: 'textInputFocus'
|
||||
payload: Partial<ComposeState['textInputFocus']>
|
||||
}
|
@ -56,14 +56,6 @@ const sharedScreens = (Stack: any) => {
|
||||
stackPresentation: 'fullScreenModal'
|
||||
}}
|
||||
/>,
|
||||
<Stack.Screen
|
||||
key='Screen-Shared-Compose-EditAttachment'
|
||||
name='Screen-Shared-Compose-EditAttachment'
|
||||
component={ComposeEditAttachment}
|
||||
options={{
|
||||
stackPresentation: 'modal'
|
||||
}}
|
||||
/>,
|
||||
<Stack.Screen
|
||||
key='Screen-Shared-Search'
|
||||
name='Screen-Shared-Search'
|
||||
|
@ -4215,6 +4215,11 @@ expo-updates@~0.3.5:
|
||||
fbemitter "^2.1.1"
|
||||
uuid "^3.4.0"
|
||||
|
||||
expo-video-thumbnails@~4.4.0:
|
||||
version "4.4.0"
|
||||
resolved "https://registry.yarnpkg.com/expo-video-thumbnails/-/expo-video-thumbnails-4.4.0.tgz#962763c31d7768823f3162574c7805ffe4e04240"
|
||||
integrity sha512-eykJaV9zt/HruYYAiptDoXy9rBDgrLMe1Sok9hKeQycqQPzs0Go5mEuU08TO4fm+lhSgCVBkJUDhZecDIQFinQ==
|
||||
|
||||
expo-web-browser@^8.6.0, expo-web-browser@~8.6.0:
|
||||
version "8.6.0"
|
||||
resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-8.6.0.tgz#121ad81b2597b8273739b630c278a6f6937b95ae"
|
||||
|
Loading…
x
Reference in New Issue
Block a user