1
0
mirror of https://github.com/tooot-app/app synced 2025-06-05 22:19:13 +02:00
This commit is contained in:
Zhiyuan Zheng
2022-06-05 17:58:18 +02:00
parent d6768c0f6f
commit fc8fdec12f
18 changed files with 282 additions and 318 deletions

7
src/@types/app.d.ts vendored
View File

@ -13,11 +13,4 @@ declare namespace App {
| 'Conversations'
| 'Bookmarks'
| 'Favourites'
interface IImageInfo {
uri: string
width: number
height: number
type?: 'image' | 'video'
}
}

View File

@ -178,8 +178,49 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
}
let text: string | undefined = undefined
let images: { type: string; uri: string }[] = []
let video: { type: string; uri: string } | undefined = undefined
let media: { path: string; mime: string }[] = []
const typesImage = ['png', 'jpg', 'jpeg', 'gif']
const typesVideo = ['mp4', 'm4v', 'mov', 'webm', 'mpeg']
const filterMedia = ({ path, mime }: { path: string; mime: string }) => {
if (mime.startsWith('image/')) {
if (!typesImage.includes(mime.split('/')[1])) {
console.warn('Image type not supported:', mime.split('/')[1])
displayMessage({
message: t('shareError.imageNotSupported', {
type: mime.split('/')[1]
}),
type: 'error',
theme
})
return
}
media.push({ path, mime })
} else if (mime.startsWith('video/')) {
if (!typesVideo.includes(mime.split('/')[1])) {
console.warn('Video type not supported:', mime.split('/')[1])
displayMessage({
message: t('shareError.videoNotSupported', {
type: mime.split('/')[1]
}),
type: 'error',
theme
})
return
}
media.push({ path, mime })
} else {
if (typesImage.includes(path.split('.').pop() || '')) {
media.push({ path: path, mime: 'image/jpg' })
return
}
if (typesVideo.includes(path.split('.').pop() || '')) {
media.push({ path: path, mime: 'video/mp4' })
return
}
text = !text ? path : text.concat(text, `\n${path}`)
}
}
switch (Platform.OS) {
case 'ios':
@ -187,55 +228,11 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
return
}
item.data.forEach(d => {
if (typeof d === 'string') return
const typesImage = ['png', 'jpg', 'jpeg', 'gif']
const typesVideo = ['mp4', 'm4v', 'mov', 'webm']
const { mimeType, data } = d
if (mimeType.startsWith('image/')) {
if (!typesImage.includes(mimeType.split('/')[1])) {
console.warn(
'Image type not supported:',
mimeType.split('/')[1]
)
displayMessage({
message: t('shareError.imageNotSupported', {
type: mimeType.split('/')[1]
}),
type: 'error',
theme
})
return
}
images.push({ type: mimeType.split('/')[1], uri: data })
} else if (mimeType.startsWith('video/')) {
if (!typesVideo.includes(mimeType.split('/')[1])) {
console.warn(
'Video type not supported:',
mimeType.split('/')[1]
)
displayMessage({
message: t('shareError.videoNotSupported', {
type: mimeType.split('/')[1]
}),
type: 'error',
theme
})
return
}
video = { type: mimeType.split('/')[1], uri: data }
} else {
if (typesImage.includes(data.split('.').pop() || '')) {
images.push({ type: data.split('.').pop()!, uri: data })
return
}
if (typesVideo.includes(data.split('.').pop() || '')) {
video = { type: data.split('.').pop()!, uri: data }
return
}
text = !text ? data : text.concat(text, `\n${data}`)
for (const d of item.data) {
if (typeof d !== 'string') {
filterMedia({ path: d.data, mime: d.mimeType })
}
})
}
break
case 'android':
if (!item.mimeType) {
@ -247,65 +244,16 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
} else {
tempData = item.data
}
tempData.forEach(d => {
const typesImage = ['png', 'jpg', 'jpeg', 'gif']
const typesVideo = ['mp4', 'm4v', 'mov', 'webm', 'mpeg']
if (item.mimeType!.startsWith('image/')) {
if (!typesImage.includes(item.mimeType.split('/')[1])) {
console.warn(
'Image type not supported:',
item.mimeType.split('/')[1]
)
displayMessage({
message: t('shareError.imageNotSupported', {
type: item.mimeType.split('/')[1]
}),
type: 'error',
theme
})
return
}
images.push({ type: item.mimeType.split('/')[1], uri: d })
} else if (item.mimeType.startsWith('video/')) {
if (!typesVideo.includes(item.mimeType.split('/')[1])) {
console.warn(
'Video type not supported:',
item.mimeType.split('/')[1]
)
displayMessage({
message: t('shareError.videoNotSupported', {
type: item.mimeType.split('/')[1]
}),
type: 'error',
theme
})
return
}
video = { type: item.mimeType.split('/')[1], uri: d }
} else {
if (typesImage.includes(d.split('.').pop() || '')) {
images.push({ type: d.split('.').pop()!, uri: d })
return
}
if (typesVideo.includes(d.split('.').pop() || '')) {
video = { type: d.split('.').pop()!, uri: d }
return
}
text = !text ? d : text.concat(text, `\n${d}`)
}
})
for (const d of item.data) {
filterMedia({ path: d, mime: item.mimeType })
}
break
}
if (!text && (!images || !images.length) && !video) {
if (!text && !media.length) {
return
} else {
navigationRef.navigate('Screen-Compose', {
type: 'share',
text,
images,
video
})
navigationRef.navigate('Screen-Compose', { type: 'share', text, media })
}
},
[instanceActive]

View File

@ -1,17 +1,20 @@
import analytics from '@components/analytics'
import { ActionSheetOptions } from '@expo/react-native-action-sheet'
import * as ImageManipulator from 'expo-image-manipulator'
import * as ImagePicker from 'expo-image-picker'
import {
ImageInfo,
UIImagePickerPresentationStyle
} from 'expo-image-picker/build/ImagePicker.types'
import { store } from '@root/store'
import { getInstanceConfigurationStatusMaxAttachments } from '@utils/slices/instancesSlice'
import * as ExpoImagePicker from 'expo-image-picker'
import i18next from 'i18next'
import { Alert, Linking, Platform } from 'react-native'
import { Alert, Linking } from 'react-native'
import ImagePicker, {
Image,
ImageOrVideo
} from 'react-native-image-crop-picker'
export interface Props {
mediaTypes?: ImagePicker.MediaTypeOptions
resize?: { width?: number; height?: number } // Resize mode contain
mediaType?: 'photo' | 'video'
resize?: { width?: number; height?: number }
maximum?: number
indicateMaximum?: boolean
showActionSheetWithOptions: (
options: ActionSheetOptions,
callback: (i?: number | undefined) => void | Promise<void>
@ -19,134 +22,157 @@ export interface Props {
}
const mediaSelector = async ({
mediaTypes = ImagePicker.MediaTypeOptions.All,
mediaType,
resize,
maximum,
indicateMaximum = false,
showActionSheetWithOptions
}: Props): Promise<ImageInfo> => {
return new Promise((resolve, reject) => {
const resolveResult = async (result: ImageInfo) => {
if (resize && result.type === 'image') {
let newResult: ImageManipulator.ImageResult
if (resize.width && resize.height) {
if (resize.width / resize.height > result.width / result.height) {
newResult = await ImageManipulator.manipulateAsync(result.uri, [
{ resize: { width: resize.width } }
])
} else {
newResult = await ImageManipulator.manipulateAsync(result.uri, [
{ resize: { height: resize.height } }
])
}: Props): Promise<ImageOrVideo[]> => {
const checkLibraryPermission = async (): Promise<boolean> => {
const { status } =
await ExpoImagePicker.requestMediaLibraryPermissionsAsync()
if (status !== 'granted') {
Alert.alert(
i18next.t('componentMediaSelector:library.alert.title'),
i18next.t('componentMediaSelector:library.alert.message'),
[
{
text: i18next.t('common:buttons.cancel'),
style: 'cancel',
onPress: () =>
analytics('mediaSelector_nopermission', {
action: 'cancel'
})
},
{
text: i18next.t(
'componentMediaSelector:library.alert.buttons.settings'
),
style: 'default',
onPress: () => {
analytics('mediaSelector_nopermission', {
action: 'settings'
})
Linking.openURL('app-settings:')
}
}
} else {
newResult = await ImageManipulator.manipulateAsync(result.uri, [
{ resize }
])
}
resolve({ ...newResult, cancelled: false })
]
)
return false
} else {
return true
}
}
const _maximum =
maximum ||
getInstanceConfigurationStatusMaxAttachments(store.getState()) ||
4
const options = () => {
switch (mediaType) {
case 'photo':
return [
i18next.t(
'componentMediaSelector:options.image',
indicateMaximum ? { context: 'max', max: _maximum } : undefined
),
i18next.t('common:buttons.cancel')
]
case 'video':
return [
i18next.t(
'componentMediaSelector:options.video',
indicateMaximum ? { context: 'max', max: 1 } : undefined
),
i18next.t('common:buttons.cancel')
]
default:
return [
i18next.t(
'componentMediaSelector:options.image',
indicateMaximum ? { context: 'max', max: _maximum } : undefined
),
i18next.t(
'componentMediaSelector:options.video',
indicateMaximum ? { context: 'max', max: 1 } : undefined
),
i18next.t('common:buttons.cancel')
]
}
}
return new Promise((resolve, reject) => {
const selectImage = async () => {
const images = await ImagePicker.openPicker({
mediaType: 'photo',
includeExif: false,
multiple: true,
minFiles: 1,
maxFiles: _maximum
}).catch(() => {})
if (!images) {
return reject()
}
if (!resize) {
return resolve(images)
} else {
resolve(result)
const croppedImages: Image[] = []
for (const image of images) {
const croppedImage = await ImagePicker.openCropper({
mediaType: 'photo',
path: image.path,
width: resize.width,
height: resize.height
}).catch(() => {})
croppedImage && croppedImages.push(croppedImage)
}
return resolve(croppedImages)
}
}
const selectVideo = async () => {
const video = await ImagePicker.openPicker({
mediaType: 'video',
includeExif: false
}).catch(() => {})
if (video) {
return resolve([video])
} else {
return reject()
}
}
showActionSheetWithOptions(
{
title: i18next.t('componentMediaSelector:title'),
options: [
i18next.t('componentMediaSelector:options.library'),
i18next.t('componentMediaSelector:options.photo'),
i18next.t('componentMediaSelector:options.cancel')
],
cancelButtonIndex: 2
options: options(),
cancelButtonIndex: mediaType ? 1 : 2
},
async buttonIndex => {
if (buttonIndex === 0) {
const { status } =
await ImagePicker.requestMediaLibraryPermissionsAsync()
if (status !== 'granted') {
Alert.alert(
i18next.t('componentMediaSelector:library.alert.title'),
i18next.t('componentMediaSelector:library.alert.message'),
[
{
text: i18next.t(
'componentMediaSelector:library.alert.buttons.cancel'
),
style: 'cancel',
onPress: () =>
analytics('mediaSelector_nopermission', {
action: 'cancel'
})
},
{
text: i18next.t(
'componentMediaSelector:library.alert.buttons.settings'
),
style: 'default',
onPress: () => {
analytics('mediaSelector_nopermission', {
action: 'settings'
})
Linking.openURL('app-settings:')
}
}
]
)
} else {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes,
exif: false,
presentationStyle:
Platform.OS === 'ios' && parseInt(Platform.Version) < 13
? UIImagePickerPresentationStyle.FULL_SCREEN
: UIImagePickerPresentationStyle.AUTOMATIC
})
if (!(await checkLibraryPermission())) {
return reject()
}
if (!result.cancelled) {
await resolveResult(result)
switch (mediaType) {
case 'photo':
if (buttonIndex === 0) {
await selectImage()
}
}
} else if (buttonIndex === 1) {
const { status } = await ImagePicker.requestCameraPermissionsAsync()
if (status !== 'granted') {
Alert.alert(
i18next.t('componentMediaSelector:photo.alert.title'),
i18next.t('componentMediaSelector:photo.alert.message'),
[
{
text: i18next.t(
'componentMediaSelector:photo.alert.buttons.cancel'
),
style: 'cancel',
onPress: () => {
analytics('compose_addattachment_camera_nopermission', {
action: 'cancel'
})
}
},
{
text: i18next.t(
'componentMediaSelector:photo.alert.buttons.settings'
),
style: 'default',
onPress: () => {
analytics('compose_addattachment_camera_nopermission', {
action: 'settings'
})
Linking.openURL('app-settings:')
}
}
]
)
} else {
const result = await ImagePicker.launchCameraAsync({
mediaTypes,
exif: false
})
if (!result.cancelled) {
await resolveResult(result)
break
case 'video':
if (buttonIndex === 0) {
await selectVideo()
}
}
break
default:
if (buttonIndex === 0) {
await selectImage()
} else if (buttonIndex === 1) {
await selectVideo()
}
break
}
}
)

View File

@ -1,27 +1,17 @@
{
"title": "Select media source",
"options": {
"library": "Upload from library",
"photo": "Take a photo",
"cancel": "$t(common:buttons.cancel)"
"image": "Upload photos",
"image_max": "Upload photos (max {{max}})",
"video": "Upload video",
"video_max": "Upload video (max {{max}})"
},
"library": {
"alert": {
"title": "No permission",
"message": "Require photo library read permission to upload",
"buttons": {
"settings": "Update setting",
"cancel": "$t(common:buttons.cancel)"
}
}
},
"photo": {
"alert": {
"title": "No permission",
"message": "Require camera usage permission to upload",
"buttons": {
"settings": "Update setting",
"cancel": "$t(common:buttons.cancel)"
"settings": "Update setting"
}
}
}

View File

@ -136,18 +136,6 @@ const ScreenCompose: React.FC<RootStackScreenProps<'Screen-Compose'>> = ({
])
useEffect(() => {
const uploadImage = async ({
type,
uri
}: {
type: 'image' | 'video'
uri: string
}) => {
await uploadAttachment({
composeDispatch,
imageInfo: { type, uri, width: 100, height: 100 }
})
}
switch (params?.type) {
case 'share':
if (params.text) {
@ -158,12 +146,13 @@ const ScreenCompose: React.FC<RootStackScreenProps<'Screen-Compose'>> = ({
disableDebounce: true
})
}
if (params.images?.length) {
params.images.forEach(image => {
uploadImage({ type: 'image', uri: image.uri })
})
} else if (params.video) {
uploadImage({ type: 'video', uri: params.video.uri })
if (params.media?.length) {
for (const m of params.media) {
uploadAttachment({
composeDispatch,
media: { ...m, width: 100, height: 100 }
})
}
}
break
case 'edit':

View File

@ -1,5 +1,4 @@
import * as Crypto from 'expo-crypto'
import { ImageInfo } from 'expo-image-picker/build/ImagePicker.types'
import * as VideoThumbnails from 'expo-video-thumbnails'
import { Dispatch } from 'react'
import { Alert } from 'react-native'
@ -8,6 +7,7 @@ import { ActionSheetOptions } from '@expo/react-native-action-sheet'
import i18next from 'i18next'
import apiInstance from '@api/instance'
import mediaSelector from '@components/mediaSelector'
import { ImageOrVideo } from 'react-native-image-crop-picker'
export interface Props {
composeDispatch: Dispatch<ComposeAction>
@ -19,39 +19,38 @@ export interface Props {
export const uploadAttachment = async ({
composeDispatch,
imageInfo
media
}: {
composeDispatch: Dispatch<ComposeAction>
imageInfo: Pick<ImageInfo, 'type' | 'uri' | 'width' | 'height'>
media: Pick<ImageOrVideo, 'path' | 'mime' | 'width' | 'height'>
}) => {
const hash = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
imageInfo.uri + Math.random()
media.path + Math.random()
)
let attachmentType: string
switch (imageInfo.type) {
switch (media.mime.split('/')[0]) {
case 'image':
console.log('uri', imageInfo.uri)
attachmentType = `image/${imageInfo.uri.split('.')[1]}`
attachmentType = `image/${media.path.split('.')[1]}`
composeDispatch({
type: 'attachment/upload/start',
payload: {
local: { ...imageInfo, local_thumbnail: imageInfo.uri, hash },
local: { ...media, type: 'image', local_thumbnail: media.path, hash },
uploading: true
}
})
break
case 'video':
attachmentType = `video/${imageInfo.uri.split('.')[1]}`
VideoThumbnails.getThumbnailAsync(imageInfo.uri)
attachmentType = `video/${media.path.split('.')[1]}`
VideoThumbnails.getThumbnailAsync(media.path)
.then(({ uri, width, height }) =>
composeDispatch({
type: 'attachment/upload/start',
payload: {
local: {
...imageInfo,
...media,
type: 'video',
local_thumbnail: uri,
hash,
width,
@ -65,7 +64,7 @@ export const uploadAttachment = async ({
composeDispatch({
type: 'attachment/upload/start',
payload: {
local: { ...imageInfo, hash },
local: { ...media, type: 'video', hash },
uploading: true
}
})
@ -76,7 +75,7 @@ export const uploadAttachment = async ({
composeDispatch({
type: 'attachment/upload/start',
payload: {
local: { ...imageInfo, hash },
local: { ...media, type: 'unknown', hash },
uploading: true
}
})
@ -106,14 +105,14 @@ export const uploadAttachment = async ({
const formData = new FormData()
formData.append('file', {
// @ts-ignore
uri: imageInfo.uri,
uri: `file://${media.path}`,
name: attachmentType,
type: attachmentType
})
} as any)
return apiInstance<Mastodon.Attachment>({
method: 'post',
version: 'v2',
url: 'media',
body: formData
})
@ -121,7 +120,7 @@ export const uploadAttachment = async ({
if (res.body.id) {
composeDispatch({
type: 'attachment/upload/end',
payload: { remote: res.body, local: imageInfo }
payload: { remote: res.body, local: media }
})
} else {
uploadFailed()
@ -136,8 +135,13 @@ const chooseAndUploadAttachment = async ({
composeDispatch,
showActionSheetWithOptions
}: Props): Promise<any> => {
const result = await mediaSelector({ showActionSheetWithOptions })
await uploadAttachment({ composeDispatch, imageInfo: result })
const result = await mediaSelector({
indicateMaximum: true,
showActionSheetWithOptions
})
for (const media of result) {
uploadAttachment({ composeDispatch, media })
}
}
export default chooseAndUploadAttachment

View File

@ -83,9 +83,9 @@ const ComposeTextInput: React.FC = () => {
if (nativeEvent.linkUri) {
uploadAttachment({
composeDispatch,
imageInfo: {
uri: nativeEvent.linkUri,
type: 'image',
media: {
path: nativeEvent.linkUri,
mime: 'image/gif',
width: 100,
height: 100
}

View File

@ -1,6 +1,12 @@
import { ImageOrVideo } from 'react-native-image-crop-picker'
export type ExtendedAttachment = {
remote?: Mastodon.Attachment
local?: App.IImageInfo & { local_thumbnail?: string; hash?: string }
local?: Pick<ImageOrVideo, 'path' | 'width' | 'height'> & {
type: 'image' | 'video' | 'unknown'
local_thumbnail?: string
hash?: string
}
uploading?: boolean
}

View File

@ -3,7 +3,6 @@ import { MenuRow } from '@components/Menu'
import { useActionSheet } from '@expo/react-native-action-sheet'
import { useProfileMutation, useProfileQuery } from '@utils/queryHooks/profile'
import { useTheme } from '@utils/styles/ThemeManager'
import * as ImagePicker from 'expo-image-picker'
import React, { RefObject } from 'react'
import { useTranslation } from 'react-i18next'
import FlashMessage from 'react-native-flash-message'
@ -30,9 +29,13 @@ const ProfileAvatarHeader: React.FC<Props> = ({ type, messageRef }) => {
iconBack='ChevronRight'
onPress={async () => {
const image = await mediaSelector({
mediaType: 'photo',
maximum: 1,
showActionSheetWithOptions,
mediaTypes: ImagePicker.MediaTypeOptions.Images,
resize: { width: 400, height: 400 }
resize:
type === 'avatar'
? { width: 400, height: 400 }
: { width: 1500, height: 500 }
})
mutation.mutate({
theme,
@ -43,7 +46,7 @@ const ProfileAvatarHeader: React.FC<Props> = ({ type, messageRef }) => {
failed: true
},
type,
data: image.uri
data: image[0].path
})
}}
/>

View File

@ -52,8 +52,7 @@ export type RootStackParamList = {
| {
type: 'share'
text?: string
images?: { type: string; uri: string }[]
video?: { type: string; uri: string }
media?: { path: string; mime: string }[]
}
| undefined
'Screen-ImagesViewer': {

View File

@ -71,11 +71,10 @@ const mutationFunction = async ({ type, data }: MutationVarsProfile) => {
})
} else if (type === 'avatar' || type === 'header') {
formData.append(type, {
// @ts-ignore
uri: data,
uri: `file://${data}`,
name: 'image/jpeg',
type: 'image/jpeg'
})
} as any)
} else {
// @ts-ignore
formData.append(type, data)