mirror of
https://github.com/tooot-app/app
synced 2025-06-05 22:19:13 +02:00
Sharing works in simulator
This commit is contained in:
@ -27,6 +27,7 @@ import { addScreenshotListener } from 'expo-screen-capture'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Alert, Platform, StatusBar } from 'react-native'
|
||||
import ShareMenu from 'react-native-share-menu'
|
||||
import { useSelector } from 'react-redux'
|
||||
import * as Sentry from 'sentry-expo'
|
||||
import { useAppDispatch } from './store'
|
||||
@ -158,6 +159,77 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
|
||||
}
|
||||
}, [instanceActive, instances, deeplinked])
|
||||
|
||||
// Share Extension
|
||||
const handleShare = useCallback(
|
||||
(item?: {
|
||||
extraData?: { share: { mimeType: string; data: string }[] }
|
||||
}) => {
|
||||
if (instanceActive < 0) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!item ||
|
||||
!item.extraData ||
|
||||
!Array.isArray(item.extraData.share) ||
|
||||
!item.extraData.share.length
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
let text: string | undefined = undefined
|
||||
let images: { type: string; uri: string }[] = []
|
||||
let video: { type: string; uri: string } | undefined = undefined
|
||||
item.extraData.share.forEach((d, i) => {
|
||||
const typesImage = ['png', 'jpg', 'jpeg', 'gif']
|
||||
const typesVideo = ['mp4', 'm4v', 'mov', 'webm']
|
||||
const { mimeType, data } = d
|
||||
console.log('mimeType', mimeType)
|
||||
console.log('data', data)
|
||||
if (mimeType.startsWith('image/')) {
|
||||
if (!typesImage.includes(mimeType.split('/')[1])) {
|
||||
console.warn('Image type not supported:', mimeType.split('/')[1])
|
||||
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])
|
||||
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}`)
|
||||
}
|
||||
})
|
||||
navigationRef.navigate('Screen-Compose', {
|
||||
type: 'share',
|
||||
text,
|
||||
images,
|
||||
video
|
||||
})
|
||||
},
|
||||
[instanceActive]
|
||||
)
|
||||
useEffect(() => {
|
||||
console.log('getting intial share')
|
||||
ShareMenu.getInitialShare(handleShare)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
console.log('getting just share')
|
||||
const listener = ShareMenu.addNewShareListener(handleShare)
|
||||
return () => {
|
||||
listener.remove()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusBar backgroundColor={colors.backgroundDefault} />
|
||||
|
105
src/ShareExtension.tsx
Normal file
105
src/ShareExtension.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Appearance, Platform, Pressable, Text } from 'react-native'
|
||||
import { Circle } from 'react-native-animated-spinkit'
|
||||
import RNFS from 'react-native-fs'
|
||||
import { ShareMenuReactView } from 'react-native-share-menu'
|
||||
import uuid from 'react-native-uuid'
|
||||
|
||||
// mimeType
|
||||
// text/plain - text only, website URL, video?!
|
||||
// image/jpeg - image
|
||||
// video/mp4 - video
|
||||
|
||||
const colors = {
|
||||
primary: {
|
||||
light: 'rgb(18, 18, 18)',
|
||||
dark: 'rgb(180, 180, 180)'
|
||||
},
|
||||
background: {
|
||||
light: 'rgb(250, 250, 250)',
|
||||
dark: 'rgb(18, 18, 18)'
|
||||
}
|
||||
}
|
||||
|
||||
const clearDir = async (dir: string) => {
|
||||
try {
|
||||
const files = await RNFS.readDir(dir)
|
||||
for (const file of files) {
|
||||
await RNFS.unlink(file.path)
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const ShareExtension = () => {
|
||||
const [errorMessage, setErrorMessage] = useState<string>()
|
||||
|
||||
useEffect(() => {
|
||||
ShareMenuReactView.data().then(async ({ data }) => {
|
||||
console.log('length', data.length)
|
||||
const newData = []
|
||||
switch (Platform.OS) {
|
||||
case 'ios':
|
||||
for (const d of data) {
|
||||
if (d.data.startsWith('file:///')) {
|
||||
const extension = d.data.split('.').pop()?.toLowerCase()
|
||||
const filename = `${uuid.v4()}.${extension}`
|
||||
const groupDirectory = await RNFS.pathForGroup(
|
||||
'group.com.xmflsct.app.tooot'
|
||||
)
|
||||
await clearDir(groupDirectory)
|
||||
const newFilepath = `file://${groupDirectory}/${filename}`
|
||||
console.log('newFilepath', newFilepath)
|
||||
try {
|
||||
await RNFS.copyFile(d.data, newFilepath)
|
||||
newData.push({ ...d, data: newFilepath })
|
||||
} catch (err: any) {
|
||||
setErrorMessage(err.message)
|
||||
console.warn(err.message)
|
||||
}
|
||||
} else {
|
||||
newData.push(d)
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'android':
|
||||
break
|
||||
default:
|
||||
return
|
||||
}
|
||||
console.log('new data', newData)
|
||||
if (!errorMessage) {
|
||||
ShareMenuReactView.continueInApp({ share: newData })
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
const theme = Appearance.getColorScheme() || 'light'
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background[theme]
|
||||
}}
|
||||
onPress={() => {
|
||||
if (errorMessage) {
|
||||
ShareMenuReactView.dismissExtension(errorMessage)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!errorMessage ? (
|
||||
<Text style={{ fontSize: 16, color: colors.primary[theme] }}>
|
||||
{errorMessage}
|
||||
</Text>
|
||||
) : (
|
||||
<Circle size={18} color={colors.primary[theme]} />
|
||||
)}
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
export default ShareExtension
|
@ -17,7 +17,8 @@
|
||||
"conversation": "Toot DM",
|
||||
"reply": "Toot reply",
|
||||
"deleteEdit": "Toot",
|
||||
"edit": "Toot"
|
||||
"edit": "Toot",
|
||||
"share": "Toot"
|
||||
},
|
||||
"alert": {
|
||||
"default": {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import apiInstance from '@api/instance'
|
||||
import analytics from '@components/analytics'
|
||||
import { HeaderLeft, HeaderRight } from '@components/Header'
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack'
|
||||
@ -41,6 +42,7 @@ import { useSelector } from 'react-redux'
|
||||
import * as Sentry from 'sentry-expo'
|
||||
import ComposeDraftsList from './Compose/DraftsList'
|
||||
import ComposeEditAttachment from './Compose/EditAttachment'
|
||||
import { uploadAttachment } from './Compose/Root/Footer/addAttachment'
|
||||
import ComposeContext from './Compose/utils/createContext'
|
||||
import composeInitialState from './Compose/utils/initialState'
|
||||
import composeParseState from './Compose/utils/parseState'
|
||||
@ -135,7 +137,36 @@ 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) {
|
||||
formatText({
|
||||
textInput: 'text',
|
||||
composeDispatch,
|
||||
content: params.text,
|
||||
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 })
|
||||
}
|
||||
break
|
||||
case 'edit':
|
||||
case 'deleteEdit':
|
||||
if (params.incomingStatus.spoiler_text) {
|
||||
|
@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { Pressable, StyleSheet, View } from 'react-native'
|
||||
import { useSelector } from 'react-redux'
|
||||
import ComposeContext from '../utils/createContext'
|
||||
import addAttachment from './Footer/addAttachment'
|
||||
import chooseAndUploadAttachment from './Footer/addAttachment'
|
||||
|
||||
const ComposeActions: React.FC = () => {
|
||||
const { showActionSheetWithOptions } = useActionSheet()
|
||||
@ -41,7 +41,7 @@ const ComposeActions: React.FC = () => {
|
||||
analytics('compose_actions_attachment_press', {
|
||||
count: composeState.attachments.uploads.length
|
||||
})
|
||||
return await addAttachment({
|
||||
return await chooseAndUploadAttachment({
|
||||
composeDispatch,
|
||||
showActionSheetWithOptions
|
||||
})
|
||||
|
@ -27,7 +27,7 @@ import {
|
||||
import { Circle } from 'react-native-animated-spinkit'
|
||||
import ComposeContext from '../../utils/createContext'
|
||||
import { ExtendedAttachment } from '../../utils/types'
|
||||
import addAttachment from './addAttachment'
|
||||
import chooseAndUploadAttachment from './addAttachment'
|
||||
|
||||
export interface Props {
|
||||
accessibleRefAttachments: RefObject<View>
|
||||
@ -218,7 +218,7 @@ const ComposeAttachments: React.FC<Props> = ({ accessibleRefAttachments }) => {
|
||||
]}
|
||||
onPress={async () => {
|
||||
analytics('compose_attachment_add_container_press')
|
||||
await addAttachment({ composeDispatch, showActionSheetWithOptions })
|
||||
await chooseAndUploadAttachment({ composeDispatch, showActionSheetWithOptions })
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
@ -229,7 +229,7 @@ const ComposeAttachments: React.FC<Props> = ({ accessibleRefAttachments }) => {
|
||||
overlay
|
||||
onPress={async () => {
|
||||
analytics('compose_attachment_add_button_press')
|
||||
await addAttachment({ composeDispatch, showActionSheetWithOptions })
|
||||
await chooseAndUploadAttachment({ composeDispatch, showActionSheetWithOptions })
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
|
@ -17,114 +17,127 @@ export interface Props {
|
||||
) => void
|
||||
}
|
||||
|
||||
const addAttachment = async ({
|
||||
export const uploadAttachment = async ({
|
||||
composeDispatch,
|
||||
imageInfo
|
||||
}: {
|
||||
composeDispatch: Dispatch<ComposeAction>
|
||||
imageInfo: Pick<ImageInfo, 'type' | 'uri' | 'width' | 'height'>
|
||||
}) => {
|
||||
const hash = await Crypto.digestStringAsync(
|
||||
Crypto.CryptoDigestAlgorithm.SHA256,
|
||||
imageInfo.uri + Math.random()
|
||||
)
|
||||
|
||||
let attachmentType: string
|
||||
|
||||
switch (imageInfo.type) {
|
||||
case 'image':
|
||||
console.log('uri', imageInfo.uri)
|
||||
attachmentType = `image/${imageInfo.uri.split('.')[1]}`
|
||||
composeDispatch({
|
||||
type: 'attachment/upload/start',
|
||||
payload: {
|
||||
local: { ...imageInfo, local_thumbnail: imageInfo.uri, hash },
|
||||
uploading: true
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'video':
|
||||
attachmentType = `video/${imageInfo.uri.split('.')[1]}`
|
||||
VideoThumbnails.getThumbnailAsync(imageInfo.uri)
|
||||
.then(({ uri, width, height }) =>
|
||||
composeDispatch({
|
||||
type: 'attachment/upload/start',
|
||||
payload: {
|
||||
local: {
|
||||
...imageInfo,
|
||||
local_thumbnail: uri,
|
||||
hash,
|
||||
width,
|
||||
height
|
||||
},
|
||||
uploading: true
|
||||
}
|
||||
})
|
||||
)
|
||||
.catch(() =>
|
||||
composeDispatch({
|
||||
type: 'attachment/upload/start',
|
||||
payload: {
|
||||
local: { ...imageInfo, hash },
|
||||
uploading: true
|
||||
}
|
||||
})
|
||||
)
|
||||
break
|
||||
default:
|
||||
attachmentType = 'unknown'
|
||||
composeDispatch({
|
||||
type: 'attachment/upload/start',
|
||||
payload: {
|
||||
local: { ...imageInfo, hash },
|
||||
uploading: true
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
const uploadFailed = () => {
|
||||
composeDispatch({
|
||||
type: 'attachment/upload/fail',
|
||||
payload: hash
|
||||
})
|
||||
Alert.alert(
|
||||
i18next.t(
|
||||
'screenCompose:content.root.actions.attachment.failed.alert.title'
|
||||
),
|
||||
undefined,
|
||||
[
|
||||
{
|
||||
text: i18next.t(
|
||||
'screenCompose:content.root.actions.attachment.failed.alert.button'
|
||||
),
|
||||
onPress: () => {}
|
||||
}
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', {
|
||||
// @ts-ignore
|
||||
uri: imageInfo.uri,
|
||||
name: attachmentType,
|
||||
type: attachmentType
|
||||
})
|
||||
|
||||
return apiInstance<Mastodon.Attachment>({
|
||||
method: 'post',
|
||||
url: 'media',
|
||||
body: formData
|
||||
})
|
||||
.then(res => {
|
||||
if (res.body.id) {
|
||||
composeDispatch({
|
||||
type: 'attachment/upload/end',
|
||||
payload: { remote: res.body, local: imageInfo }
|
||||
})
|
||||
} else {
|
||||
uploadFailed()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
uploadFailed()
|
||||
})
|
||||
}
|
||||
|
||||
const chooseAndUploadAttachment = async ({
|
||||
composeDispatch,
|
||||
showActionSheetWithOptions
|
||||
}: Props): Promise<any> => {
|
||||
const uploader = async (imageInfo: ImageInfo) => {
|
||||
const hash = await Crypto.digestStringAsync(
|
||||
Crypto.CryptoDigestAlgorithm.SHA256,
|
||||
imageInfo.uri + Math.random()
|
||||
)
|
||||
|
||||
let attachmentType: string
|
||||
|
||||
switch (imageInfo.type) {
|
||||
case 'image':
|
||||
attachmentType = `image/${imageInfo.uri.split('.')[1]}`
|
||||
composeDispatch({
|
||||
type: 'attachment/upload/start',
|
||||
payload: {
|
||||
local: { ...imageInfo, local_thumbnail: imageInfo.uri, hash },
|
||||
uploading: true
|
||||
}
|
||||
})
|
||||
break
|
||||
case 'video':
|
||||
attachmentType = `video/${imageInfo.uri.split('.')[1]}`
|
||||
VideoThumbnails.getThumbnailAsync(imageInfo.uri)
|
||||
.then(({ uri }) =>
|
||||
composeDispatch({
|
||||
type: 'attachment/upload/start',
|
||||
payload: {
|
||||
local: { ...imageInfo, local_thumbnail: uri, hash },
|
||||
uploading: true
|
||||
}
|
||||
})
|
||||
)
|
||||
.catch(() =>
|
||||
composeDispatch({
|
||||
type: 'attachment/upload/start',
|
||||
payload: {
|
||||
local: { ...imageInfo, hash },
|
||||
uploading: true
|
||||
}
|
||||
})
|
||||
)
|
||||
break
|
||||
default:
|
||||
attachmentType = 'unknown'
|
||||
composeDispatch({
|
||||
type: 'attachment/upload/start',
|
||||
payload: {
|
||||
local: { ...imageInfo, hash },
|
||||
uploading: true
|
||||
}
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
const uploadFailed = () => {
|
||||
composeDispatch({
|
||||
type: 'attachment/upload/fail',
|
||||
payload: hash
|
||||
})
|
||||
Alert.alert(
|
||||
i18next.t(
|
||||
'screenCompose:content.root.actions.attachment.failed.alert.title'
|
||||
),
|
||||
undefined,
|
||||
[
|
||||
{
|
||||
text: i18next.t(
|
||||
'screenCompose:content.root.actions.attachment.failed.alert.button'
|
||||
),
|
||||
onPress: () => {}
|
||||
}
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
formData.append('file', {
|
||||
// @ts-ignore
|
||||
uri: imageInfo.uri,
|
||||
name: attachmentType,
|
||||
type: attachmentType
|
||||
})
|
||||
|
||||
return apiInstance<Mastodon.Attachment>({
|
||||
method: 'post',
|
||||
url: 'media',
|
||||
body: formData
|
||||
})
|
||||
.then(res => {
|
||||
if (res.body.id) {
|
||||
composeDispatch({
|
||||
type: 'attachment/upload/end',
|
||||
payload: { remote: res.body, local: imageInfo }
|
||||
})
|
||||
} else {
|
||||
uploadFailed()
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
uploadFailed()
|
||||
})
|
||||
}
|
||||
|
||||
const result = await mediaSelector({ showActionSheetWithOptions })
|
||||
await uploader(result)
|
||||
await uploadAttachment({ composeDispatch, imageInfo: result })
|
||||
}
|
||||
|
||||
export default addAttachment
|
||||
export default chooseAndUploadAttachment
|
||||
|
@ -38,6 +38,8 @@ const composeParseState = (
|
||||
params: NonNullable<RootStackParamList['Screen-Compose']>
|
||||
): ComposeState => {
|
||||
switch (params.type) {
|
||||
case 'share':
|
||||
return { ...composeInitialState, dirty: true, timestamp: Date.now() }
|
||||
case 'edit':
|
||||
case 'deleteEdit':
|
||||
return {
|
||||
|
@ -45,6 +45,12 @@ export type RootStackParamList = {
|
||||
type: 'conversation'
|
||||
accts: Mastodon.Account['acct'][]
|
||||
}
|
||||
| {
|
||||
type: 'share'
|
||||
text?: string
|
||||
images?: { type: string; uri: string }[]
|
||||
video?: { type: string; uri: string }
|
||||
}
|
||||
| undefined
|
||||
'Screen-ImagesViewer': {
|
||||
imageUrls: {
|
||||
|
Reference in New Issue
Block a user