mirror of
https://github.com/tooot-app/app
synced 2025-06-05 22:19:13 +02:00
Fxied #353
This commit is contained in:
@ -7,6 +7,7 @@ import { chunk, forEach, groupBy, sortBy } from 'lodash'
|
||||
import React, {
|
||||
Dispatch,
|
||||
MutableRefObject,
|
||||
PropsWithChildren,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@ -57,7 +58,7 @@ export interface Props {
|
||||
maxLength?: number
|
||||
}
|
||||
|
||||
const ComponentEmojis: React.FC<Props> = ({
|
||||
const ComponentEmojis: React.FC<Props & PropsWithChildren> = ({
|
||||
enabled = false,
|
||||
value,
|
||||
setValue,
|
||||
|
@ -27,18 +27,6 @@ const EmojisList = React.memo(
|
||||
const { emojisState, emojisDispatch } = useContext(EmojisContext)
|
||||
const { colors } = useTheme()
|
||||
|
||||
const listHeader = useCallback(
|
||||
({ section: { title } }) => (
|
||||
<CustomText
|
||||
fontStyle='S'
|
||||
style={{ position: 'absolute', color: colors.secondary }}
|
||||
>
|
||||
{title}
|
||||
</CustomText>
|
||||
),
|
||||
[]
|
||||
)
|
||||
|
||||
const listItem = useCallback(
|
||||
({ index, item }: { item: Mastodon.Emoji[]; index: number }) => {
|
||||
return (
|
||||
@ -112,7 +100,14 @@ const EmojisList = React.memo(
|
||||
keyboardShouldPersistTaps='always'
|
||||
sections={emojisState.emojis}
|
||||
keyExtractor={item => item[0].shortcode}
|
||||
renderSectionHeader={listHeader}
|
||||
renderSectionHeader={({ section: { title } }) => (
|
||||
<CustomText
|
||||
fontStyle='S'
|
||||
style={{ position: 'absolute', color: colors.secondary }}
|
||||
>
|
||||
{title}
|
||||
</CustomText>
|
||||
)}
|
||||
renderItem={listItem}
|
||||
windowSize={4}
|
||||
/>
|
||||
|
@ -4,7 +4,6 @@ import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import React, {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
@ -81,10 +80,6 @@ const Input: React.FC<Props> = ({
|
||||
}
|
||||
: { start: 0, end: 0 }
|
||||
)
|
||||
const onSelectionChange = useCallback(
|
||||
({ nativeEvent: { selection } }) => (selectionRange.current = selection),
|
||||
[]
|
||||
)
|
||||
|
||||
const [inputFocused, setInputFocused] = useState(false)
|
||||
useEffect(() => {
|
||||
@ -128,7 +123,9 @@ const Input: React.FC<Props> = ({
|
||||
: undefined
|
||||
}}
|
||||
onChangeText={setValue}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onSelectionChange={({ nativeEvent: { selection } }) =>
|
||||
(selectionRange.current = selection)
|
||||
}
|
||||
value={value}
|
||||
{...(multiline && {
|
||||
multiline,
|
||||
|
@ -88,21 +88,6 @@ const ComponentInstance: React.FC<Props> = ({
|
||||
}
|
||||
}, [domain])
|
||||
|
||||
const onSubmitEditing = useCallback(
|
||||
({ nativeEvent: { text } }) => {
|
||||
analytics('instance_textinput_submit', { match: text === domain })
|
||||
if (
|
||||
text === domain &&
|
||||
instanceQuery.isSuccess &&
|
||||
instanceQuery.data &&
|
||||
instanceQuery.data.uri
|
||||
) {
|
||||
processUpdate()
|
||||
}
|
||||
},
|
||||
[domain, instanceQuery.isSuccess, instanceQuery.data]
|
||||
)
|
||||
|
||||
const requestAuth = useMemo(() => {
|
||||
if (
|
||||
domain &&
|
||||
@ -180,7 +165,17 @@ const ComponentInstance: React.FC<Props> = ({
|
||||
clearButtonMode='never'
|
||||
keyboardType='url'
|
||||
textContentType='URL'
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
onSubmitEditing={({ nativeEvent: { text } }) => {
|
||||
analytics('instance_textinput_submit', { match: text === domain })
|
||||
if (
|
||||
text === domain &&
|
||||
instanceQuery.isSuccess &&
|
||||
instanceQuery.data &&
|
||||
instanceQuery.data.uri
|
||||
) {
|
||||
processUpdate()
|
||||
}
|
||||
}}
|
||||
placeholder={' ' + t('server.textInput.placeholder')}
|
||||
placeholderTextColor={colors.secondary}
|
||||
returnKeyType='go'
|
||||
|
@ -215,7 +215,7 @@ const ParseHTML = React.memo(
|
||||
}
|
||||
|
||||
const renderNodeCallback = useCallback(
|
||||
(node, index) =>
|
||||
(node: any, index: any) =>
|
||||
renderNode({
|
||||
routeParams: route.params,
|
||||
colors,
|
||||
@ -231,7 +231,7 @@ const ParseHTML = React.memo(
|
||||
}),
|
||||
[]
|
||||
)
|
||||
const textComponent = useCallback(({ children }) => {
|
||||
const textComponent = useCallback(({ children }: any) => {
|
||||
if (children) {
|
||||
return (
|
||||
<ParseEmojis
|
||||
@ -246,26 +246,24 @@ const ParseHTML = React.memo(
|
||||
}
|
||||
}, [])
|
||||
const rootComponent = useCallback(
|
||||
({ children }) => {
|
||||
({ children }: any) => {
|
||||
const { t } = useTranslation('componentParse')
|
||||
|
||||
const [expandAllow, setExpandAllow] = useState(false)
|
||||
const [expanded, setExpanded] = useState(highlighted)
|
||||
|
||||
const onTextLayout = useCallback(({ nativeEvent }) => {
|
||||
if (
|
||||
numberOfLines === 1 ||
|
||||
nativeEvent.lines.length >= numberOfLines + 5
|
||||
) {
|
||||
setExpandAllow(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View style={{ overflow: 'hidden' }}>
|
||||
<CustomText
|
||||
children={children}
|
||||
onTextLayout={onTextLayout}
|
||||
onTextLayout={({ nativeEvent }) => {
|
||||
if (
|
||||
numberOfLines === 1 ||
|
||||
nativeEvent.lines.length >= numberOfLines + 5
|
||||
) {
|
||||
setExpandAllow(true)
|
||||
}
|
||||
}}
|
||||
numberOfLines={
|
||||
expandAllow ? (expanded ? 999 : numberOfLines) : undefined
|
||||
}
|
||||
|
@ -64,17 +64,6 @@ const Timeline: React.FC<Props> = ({
|
||||
? data.pages?.flatMap(page => [...page.body])
|
||||
: []
|
||||
|
||||
const ItemSeparatorComponent = useCallback(
|
||||
({ leadingItem }) =>
|
||||
queryKey[1].page === 'Toot' && queryKey[1].toot === leadingItem.id ? (
|
||||
<ComponentSeparator extraMarginLeft={0} />
|
||||
) : (
|
||||
<ComponentSeparator
|
||||
extraMarginLeft={StyleConstants.Avatar.M + StyleConstants.Spacing.S}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
)
|
||||
const onEndReached = useCallback(
|
||||
() => !disableInfinity && !isFetchingNextPage && fetchNextPage(),
|
||||
[isFetchingNextPage]
|
||||
@ -151,7 +140,17 @@ const Timeline: React.FC<Props> = ({
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={<TimelineEmpty queryKey={queryKey} />}
|
||||
ItemSeparatorComponent={ItemSeparatorComponent}
|
||||
ItemSeparatorComponent={({ leadingItem }) =>
|
||||
queryKey[1].page === 'Toot' && queryKey[1].toot === leadingItem.id ? (
|
||||
<ComponentSeparator extraMarginLeft={0} />
|
||||
) : (
|
||||
<ComponentSeparator
|
||||
extraMarginLeft={
|
||||
StyleConstants.Avatar.M + StyleConstants.Spacing.S
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
maintainVisibleContentPosition={
|
||||
isFetching
|
||||
? {
|
||||
|
@ -9,7 +9,7 @@ import { StyleConstants } from '@utils/styles/constants'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import React, { RefObject, useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FlatList, Platform, StyleSheet, Text, View } from 'react-native'
|
||||
import { FlatList, LayoutChangeEvent, Platform, StyleSheet, Text, View } from 'react-native'
|
||||
import { Circle } from 'react-native-animated-spinkit'
|
||||
import Animated, {
|
||||
Extrapolate,
|
||||
@ -169,7 +169,7 @@ const TimelineRefresh: React.FC<Props> = ({
|
||||
|
||||
const arrowStage = useSharedValue(0)
|
||||
const onLayout = useCallback(
|
||||
({ nativeEvent }) => {
|
||||
({ nativeEvent }: LayoutChangeEvent) => {
|
||||
if (nativeEvent.layout.x + nativeEvent.layout.width > textRight) {
|
||||
setTextRight(nativeEvent.layout.x + nativeEvent.layout.width)
|
||||
}
|
||||
|
@ -1,15 +1,8 @@
|
||||
import analytics from '@components/analytics'
|
||||
import { ActionSheetOptions } from '@expo/react-native-action-sheet'
|
||||
import { store } from '@root/store'
|
||||
import { getInstanceConfigurationStatusMaxAttachments } from '@utils/slices/instancesSlice'
|
||||
import { manipulateAsync, SaveFormat } from 'expo-image-manipulator'
|
||||
import * as ExpoImagePicker from 'expo-image-picker'
|
||||
import i18next from 'i18next'
|
||||
import { Alert, Linking, Platform } from 'react-native'
|
||||
import ImagePicker, {
|
||||
Image,
|
||||
ImageOrVideo
|
||||
} from 'react-native-image-crop-picker'
|
||||
import { Asset, launchImageLibrary } from 'react-native-image-picker'
|
||||
|
||||
export interface Props {
|
||||
mediaType?: 'photo' | 'video'
|
||||
@ -28,43 +21,7 @@ const mediaSelector = async ({
|
||||
maximum,
|
||||
indicateMaximum = false,
|
||||
showActionSheetWithOptions
|
||||
}: Props): Promise<({ uri: string } & Omit<ImageOrVideo, 'path'>)[]> => {
|
||||
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:')
|
||||
}
|
||||
}
|
||||
]
|
||||
)
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
}: Props): Promise<Asset[]> => {
|
||||
const _maximum =
|
||||
maximum ||
|
||||
getInstanceConfigurationStatusMaxAttachments(store.getState()) ||
|
||||
@ -105,79 +62,30 @@ const mediaSelector = async ({
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const selectImage = async () => {
|
||||
const images = await ImagePicker.openPicker({
|
||||
const images = await launchImageLibrary({
|
||||
mediaType: 'photo',
|
||||
includeExif: false,
|
||||
multiple: true,
|
||||
minFiles: 1,
|
||||
maxFiles: _maximum,
|
||||
smartAlbums: ['UserLibrary'],
|
||||
writeTempFile: false,
|
||||
loadingLabelText: ''
|
||||
}).catch(() => {})
|
||||
...(resize && { maxWidth: resize.width, maxHeight: resize.height }),
|
||||
includeBase64: false,
|
||||
includeExtra: false,
|
||||
selectionLimit: _maximum
|
||||
})
|
||||
|
||||
if (!images) {
|
||||
if (!images.assets) {
|
||||
return reject()
|
||||
}
|
||||
|
||||
// react-native-image-crop-picker may return HEIC as JPG that causes upload failure
|
||||
if (Platform.OS === 'ios') {
|
||||
for (const [index, image] of images.entries()) {
|
||||
if (image.mime === 'image/heic') {
|
||||
const converted = await manipulateAsync(image.sourceURL!, [], {
|
||||
base64: false,
|
||||
compress: 0.8,
|
||||
format: SaveFormat.JPEG
|
||||
})
|
||||
images[index] = {
|
||||
...images[index],
|
||||
sourceURL: converted.uri,
|
||||
mime: 'image/jpeg'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!resize) {
|
||||
return resolve(
|
||||
images.map(image => ({
|
||||
...image,
|
||||
uri: image.sourceURL || `file://${image.path}`
|
||||
}))
|
||||
)
|
||||
} else {
|
||||
const croppedImages: Image[] = []
|
||||
for (const image of images) {
|
||||
const croppedImage = await ImagePicker.openCropper({
|
||||
mediaType: 'photo',
|
||||
path: image.sourceURL || image.path,
|
||||
width: resize.width,
|
||||
height: resize.height,
|
||||
cropperChooseText: i18next.t('common:buttons.apply'),
|
||||
cropperCancelText: i18next.t('common:buttons.cancel'),
|
||||
hideBottomControls: true
|
||||
}).catch(() => {})
|
||||
croppedImage && croppedImages.push(croppedImage)
|
||||
}
|
||||
return resolve(
|
||||
croppedImages.map(image => ({
|
||||
...image,
|
||||
uri: `file://${image.path}`
|
||||
}))
|
||||
)
|
||||
}
|
||||
return resolve(images.assets)
|
||||
}
|
||||
const selectVideo = async () => {
|
||||
const video = await ImagePicker.openPicker({
|
||||
const video = await launchImageLibrary({
|
||||
mediaType: 'video',
|
||||
includeExif: false,
|
||||
loadingLabelText: ''
|
||||
}).catch(() => {})
|
||||
includeBase64: false,
|
||||
includeExtra: false,
|
||||
selectionLimit: 1
|
||||
})
|
||||
|
||||
if (video) {
|
||||
return resolve([
|
||||
{ ...video, uri: video.sourceURL || `file://${video.path}` }
|
||||
])
|
||||
if (video.assets?.[0]) {
|
||||
return resolve(video.assets)
|
||||
} else {
|
||||
return reject()
|
||||
}
|
||||
@ -189,10 +97,6 @@ const mediaSelector = async ({
|
||||
cancelButtonIndex: mediaType ? 1 : 2
|
||||
},
|
||||
async buttonIndex => {
|
||||
if (!(await checkLibraryPermission())) {
|
||||
return reject()
|
||||
}
|
||||
|
||||
switch (mediaType) {
|
||||
case 'photo':
|
||||
if (buttonIndex === 0) {
|
||||
|
@ -1,149 +0,0 @@
|
||||
import { store } from '@root/store'
|
||||
import { getInstanceConfigurationMediaAttachments } from '@utils/slices/instancesSlice'
|
||||
import { Action, manipulateAsync, SaveFormat } from 'expo-image-manipulator'
|
||||
import i18next from 'i18next'
|
||||
import { Platform } from 'react-native'
|
||||
import ImagePicker from 'react-native-image-crop-picker'
|
||||
|
||||
export interface Props {
|
||||
type: 'image' | 'video'
|
||||
uri: string // This can be pure path or uri starting with file://
|
||||
mime?: string
|
||||
transform: {
|
||||
imageFormat?: SaveFormat.JPEG | SaveFormat.PNG
|
||||
resize?: boolean
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
}
|
||||
|
||||
const getFileExtension = (uri: string) => {
|
||||
const extension = uri.split('.').pop()
|
||||
// Using mime type standard of jpeg
|
||||
return extension === 'jpg' ? 'jpeg' : extension
|
||||
}
|
||||
|
||||
const mediaTransformation = async ({
|
||||
type,
|
||||
uri,
|
||||
mime,
|
||||
transform
|
||||
}: Props): Promise<{
|
||||
uri: string
|
||||
mime: string
|
||||
width: number
|
||||
height: number
|
||||
}> => {
|
||||
const configurationMediaAttachments =
|
||||
getInstanceConfigurationMediaAttachments(store.getState())
|
||||
|
||||
const fileExtension = getFileExtension(uri)
|
||||
|
||||
switch (type) {
|
||||
case 'image':
|
||||
if (mime === 'image/gif' || fileExtension === 'gif') {
|
||||
return Promise.reject('GIFs should not be transformed')
|
||||
}
|
||||
let targetFormat: SaveFormat.JPEG | SaveFormat.PNG = SaveFormat.JPEG
|
||||
|
||||
const supportedImageTypes =
|
||||
configurationMediaAttachments.supported_mime_types.filter(mime =>
|
||||
mime.startsWith('image/')
|
||||
)
|
||||
|
||||
// @ts-ignore
|
||||
const transformations: Action[] = [
|
||||
!transform.resize && (transform.width || transform.height)
|
||||
? {
|
||||
resize: { width: transform.width, height: transform.height }
|
||||
}
|
||||
: null
|
||||
].filter(t => !!t)
|
||||
|
||||
if (mime) {
|
||||
if (
|
||||
mime !== `image/${fileExtension}` ||
|
||||
!supportedImageTypes.includes(mime)
|
||||
) {
|
||||
targetFormat = transform.imageFormat || SaveFormat.JPEG
|
||||
} else {
|
||||
targetFormat = mime.split('/').pop() as any
|
||||
}
|
||||
} else {
|
||||
if (!fileExtension) {
|
||||
return Promise.reject('Unable to get file extension')
|
||||
}
|
||||
if (!supportedImageTypes.includes(`image/${fileExtension}`)) {
|
||||
targetFormat = transform.imageFormat || SaveFormat.JPEG
|
||||
} else {
|
||||
targetFormat = fileExtension as any
|
||||
}
|
||||
}
|
||||
|
||||
const converted = await manipulateAsync(uri, transformations, {
|
||||
base64: false,
|
||||
compress: Platform.OS === 'ios' ? 0.8 : 1,
|
||||
format: targetFormat
|
||||
})
|
||||
|
||||
if (transform.resize) {
|
||||
const resized = await ImagePicker.openCropper({
|
||||
mediaType: 'photo',
|
||||
path: converted.uri,
|
||||
width: transform.width,
|
||||
height: transform.height,
|
||||
cropperChooseText: i18next.t('common:buttons.apply'),
|
||||
cropperCancelText: i18next.t('common:buttons.cancel'),
|
||||
hideBottomControls: true
|
||||
})
|
||||
if (!resized) {
|
||||
return Promise.reject('Resize failed')
|
||||
} else {
|
||||
return {
|
||||
uri: resized.path,
|
||||
mime: resized.mime,
|
||||
width: resized.width,
|
||||
height: resized.height
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
uri: converted.uri,
|
||||
mime: transform.imageFormat || SaveFormat.JPEG,
|
||||
width: converted.width,
|
||||
height: converted.height
|
||||
}
|
||||
}
|
||||
case 'video':
|
||||
const supportedVideoTypes =
|
||||
configurationMediaAttachments.supported_mime_types.filter(mime =>
|
||||
mime.startsWith('video/')
|
||||
)
|
||||
|
||||
if (mime) {
|
||||
if (mime !== `video/${fileExtension}`) {
|
||||
console.warn('Video mime type and file extension does not match')
|
||||
}
|
||||
if (!supportedVideoTypes.includes(mime)) {
|
||||
return Promise.reject('Video file type is not supported')
|
||||
}
|
||||
} else {
|
||||
if (!fileExtension) {
|
||||
return Promise.reject('Unable to get file extension')
|
||||
}
|
||||
if (!supportedVideoTypes.includes(`video/${fileExtension}`)) {
|
||||
return Promise.reject('Video file type is not supported')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
uri: uri,
|
||||
mime: mime || `video/${fileExtension}`,
|
||||
width: 0,
|
||||
height: 0
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
export default mediaTransformation
|
Reference in New Issue
Block a user