Rewrite upload media

This commit is contained in:
Zhiyuan Zheng 2020-12-30 00:56:25 +01:00
parent e841409523
commit 3473337442
No known key found for this signature in database
GPG Key ID: 078A93AB607D85E0
23 changed files with 1136 additions and 1108 deletions

View File

@ -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",

View File

@ -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)

View File

@ -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>
)

View File

@ -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 }

View File

@ -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)

View File

@ -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

View 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

View 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

View File

@ -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,

View File

@ -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

View File

@ -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>

View File

@ -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

View 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

View File

@ -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

View File

@ -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']

View File

@ -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,

View 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

View 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

View 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

View 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

View 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']>
}

View File

@ -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'

View File

@ -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"