1
0
mirror of https://github.com/tooot-app/app synced 2025-06-05 22:19:13 +02:00

Rewrite attachment views

This commit is contained in:
Zhiyuan Zheng
2020-12-25 18:20:09 +01:00
parent dca138eb5c
commit 6eeb86921c
17 changed files with 789 additions and 323 deletions

View File

@ -1,2 +1,3 @@
declare module 'gl-react-blurhash'
declare module 'react-native-toast-message'
declare module 'react-native-htmlview'

View File

@ -4,7 +4,6 @@ import {
NavigationContainer,
NavigationContainerRef
} from '@react-navigation/native'
import { enableScreens } from 'react-native-screens'
import React, { useEffect, useMemo, useRef } from 'react'
import { StatusBar } from 'react-native'
@ -33,7 +32,6 @@ import { announcementFetch } from './utils/fetches/announcementsFetch'
import client from './api/client'
import { timelineFetch } from './utils/fetches/timelineFetch'
enableScreens()
const Tab = createBottomTabNavigator<RootStackParamList>()
export type RootStackParamList = {
@ -206,7 +204,8 @@ export const Index: React.FC<Props> = ({ localCorrupt }) => {
name='Screen-Notifications'
component={ScreenNotifications}
options={{
tabBarBadge: prevNotification.unread ? '' : undefined,
tabBarBadge:
prevNotification && prevNotification.unread ? '' : undefined,
tabBarBadgeStyle: { transform: [{ scale: 0.5 }] }
}}
listeners={{

View File

@ -75,7 +75,7 @@ const TimelineDefault: React.FC<Props> = ({
/>
)}
{actualStatus.media_attachments.length > 0 && (
<TimelineAttachment status={actualStatus} width={contentWidth} />
<TimelineAttachment status={actualStatus} contentWidth={contentWidth} />
)}
{actualStatus.card && <TimelineCard card={actualStatus.card} />}
</View>

View File

@ -1,71 +1,23 @@
import { BlurView } from 'expo-blur'
import React, { useCallback, useEffect, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import AttachmentImage from '@components/Timelines/Timeline/Shared/Attachment/AttachmentImage'
import AttachmentVideo from '@components/Timelines/Timeline/Shared/Attachment/AttachmentVideo'
import AttachmentImage from '@root/components/Timelines/Timeline/Shared/Attachment/Image'
import AttachmentVideo from '@root/components/Timelines/Timeline/Shared/Attachment/Video'
import { IImageInfo } from 'react-native-image-zoom-viewer/built/image-viewer.type'
import { useNavigation } from '@react-navigation/native'
import AttachmentUnsupported from './Attachment/Unsupported'
export interface Props {
status: Pick<Mastodon.Status, 'media_attachments' | 'sensitive'>
width: number
contentWidth: number
}
const TimelineAttachment: React.FC<Props> = ({ status, width }) => {
const { mode, theme } = useTheme()
const allTypes = status.media_attachments.map(m => m.type)
let attachment
let attachmentHeight
const TimelineAttachment: React.FC<Props> = ({ status, contentWidth }) => {
const { theme } = useTheme()
if (allTypes.includes('image')) {
attachment = (
<AttachmentImage
media_attachments={status.media_attachments}
width={width}
/>
)
attachmentHeight = (width / 16) * 9
} else if (allTypes.includes('gifv')) {
attachment = (
<AttachmentVideo
media_attachments={status.media_attachments}
width={width}
/>
)
attachmentHeight =
status.media_attachments[0].meta?.original?.width &&
status.media_attachments[0].meta?.original?.height
? (width / status.media_attachments[0].meta.original.width) *
status.media_attachments[0].meta.original.height
: (width / 16) * 9
} else if (allTypes.includes('video')) {
attachment = (
<AttachmentVideo
media_attachments={status.media_attachments}
width={width}
/>
)
attachmentHeight =
status.media_attachments[0].meta?.original?.width &&
status.media_attachments[0].meta?.original?.height
? (width / status.media_attachments[0].meta.original.width) *
status.media_attachments[0].meta.original.height
: (width / 16) * 9
} else if (allTypes.includes('audio')) {
// attachment = (
// <AttachmentAudio
// media_attachments={media_attachments}
// sensitive={sensitive}
// width={width}
// />
// )
} else {
attachment = <Text></Text>
attachmentHeight = 25
}
const [sensitiveShown, setSensitiveShown] = useState(true)
const [sensitiveShown, setSensitiveShown] = useState(status.sensitive)
const onPressBlurView = useCallback(() => {
setSensitiveShown(false)
}, [])
@ -77,40 +29,118 @@ const TimelineAttachment: React.FC<Props> = ({ status, width }) => {
}
}, [sensitiveShown])
let imageUrls: (IImageInfo & {
preview_url: Mastodon.AttachmentImage['preview_url']
remote_url?: Mastodon.AttachmentImage['remote_url']
imageIndex: number
})[] = []
const navigation = useNavigation()
const navigateToImagesViewer = (imageIndex: number) =>
navigation.navigate('Screen-Shared-ImagesViewer', {
imageUrls,
imageIndex
})
const attachments = useMemo(
() =>
status.media_attachments.map((attachment, index) => {
switch (attachment.type) {
case 'image':
imageUrls.push({
url: attachment.url,
width: attachment.meta?.original?.width,
height: attachment.meta?.original?.height,
preview_url: attachment.preview_url,
remote_url: attachment.remote_url,
imageIndex: index
})
return (
<AttachmentImage
key={index}
sensitiveShown={sensitiveShown}
image={attachment}
imageIndex={index}
navigateToImagesViewer={navigateToImagesViewer}
/>
)
case 'video':
return (
<AttachmentVideo
key={index}
sensitiveShown={sensitiveShown}
video={attachment}
width={contentWidth}
height={(contentWidth / 16) * 9}
/>
)
case 'gifv':
return (
<AttachmentVideo
key={index}
sensitiveShown={sensitiveShown}
video={attachment}
width={contentWidth}
height={(contentWidth / 16) * 9}
/>
)
case 'audio':
return <Text key={index}></Text>
default:
return <AttachmentUnsupported key={index} attachment={attachment} />
}
}),
[sensitiveShown]
)
return (
<View
style={{
width: width + StyleConstants.Spacing.XS,
height: attachmentHeight,
marginTop: StyleConstants.Spacing.S,
marginLeft: -StyleConstants.Spacing.XS / 2
}}
style={[
styles.base,
{ width: contentWidth, height: (contentWidth / 16) * 9 }
]}
>
{attachment}
{attachments}
{status.sensitive && sensitiveShown && (
<BlurView tint={mode} intensity={100} style={styles.blurView}>
<Pressable onPress={onPressBlurView} style={styles.blurViewPressable}>
<Text style={[styles.sensitiveText, { color: theme.primary }]}>
<Pressable style={styles.sensitiveBlur}>
<Pressable
onPress={onPressBlurView}
style={[
styles.sensitiveBlurButton,
{ backgroundColor: theme.backgroundOverlay }
]}
>
<Text
style={[styles.sensitiveText, { color: theme.primaryOverlay }]}
>
</Text>
</Pressable>
</BlurView>
</Pressable>
)}
</View>
)
}
const styles = StyleSheet.create({
blurView: {
base: {
marginTop: StyleConstants.Spacing.S,
flex: 1,
flexDirection: 'row',
flexWrap: 'wrap',
alignContent: 'stretch'
},
sensitiveBlur: {
position: 'absolute',
width: '100%',
height: '100%'
},
blurViewPressable: {
height: '100%',
flex: 1,
justifyContent: 'center',
alignItems: 'center'
},
sensitiveBlurButton: {
padding: StyleConstants.Spacing.S,
borderRadius: 6
},
sensitiveText: {
fontSize: StyleConstants.Font.Size.M
}

View File

@ -1,80 +0,0 @@
import React, { useState } from 'react'
import { Image, Modal, StyleSheet, Pressable, View } from 'react-native'
import ImageViewer from 'react-native-image-zoom-viewer'
import { StyleConstants } from '@utils/styles/constants'
export interface Props {
media_attachments: Mastodon.Attachment[]
width: number
}
const AttachmentImage: React.FC<Props> = ({ media_attachments }) => {
const [imageModalVisible, setImageModalVisible] = useState(false)
const [imageModalIndex, setImageModalIndex] = useState(0)
let images: {
url: string
width: number | undefined
height: number | undefined
}[] = []
const imagesNode = media_attachments.map((m, i) => {
images.push({
url: m.url,
width: m.meta?.original?.width || undefined,
height: m.meta?.original?.height || undefined
})
return (
<Pressable
key={i}
style={[styles.imageContainer]}
onPress={() => {
setImageModalIndex(i)
setImageModalVisible(true)
}}
>
<Image source={{ uri: m.preview_url }} style={styles.image} />
</Pressable>
)
})
return (
<>
<View style={[styles.media]}>{imagesNode}</View>
<Modal
visible={imageModalVisible}
transparent={true}
animationType='fade'
>
<ImageViewer
imageUrls={images}
index={imageModalIndex}
onSwipeDown={() => setImageModalVisible(false)}
enableSwipeDown={true}
swipeDownThreshold={100}
useNativeDriver={true}
saveToLocalByLongPress={false}
/>
</Modal>
</>
)
}
const styles = StyleSheet.create({
media: {
flex: 1,
flexDirection: 'row',
flexWrap: 'wrap',
alignContent: 'stretch'
},
imageContainer: {
flex: 1,
flexBasis: '50%',
padding: StyleConstants.Spacing.XS / 2
},
image: {
flex: 1
}
})
export default React.memo(AttachmentImage, () => true)

View File

@ -1,61 +0,0 @@
import React, { useCallback, useRef, useState } from 'react'
import { View } from 'react-native'
import { Video } from 'expo-av'
import { ButtonRound } from '@components/Button'
export interface Props {
media_attachments: Mastodon.Attachment[]
width: number
}
const AttachmentVideo: React.FC<Props> = ({ media_attachments, width }) => {
const videoPlayer = useRef<Video>(null)
const [videoPlay, setVideoPlay] = useState(false)
const video = media_attachments[0]
const videoWidth = width
const videoHeight =
video.meta?.original?.width && video.meta?.original?.height
? (width / video.meta.original.width) * video.meta.original.height
: (width / 16) * 9
const playOnPress = useCallback(() => {
// @ts-ignore
videoPlayer.current.presentFullscreenPlayer()
setVideoPlay(true)
}, [])
return (
<View
style={{
width: videoWidth,
height: videoHeight
}}
>
<Video
ref={videoPlayer}
source={{ uri: video.remote_url || video.url }}
style={{
width: videoWidth,
height: videoHeight
}}
resizeMode='cover'
usePoster
posterSource={{ uri: video.preview_url }}
useNativeControls
shouldPlay={videoPlay}
/>
{videoPlayer.current && !videoPlay && (
<ButtonRound
icon='play'
size='L'
onPress={playOnPress}
styles={{ top: videoHeight / 2, left: videoWidth / 2 }}
coordinate='center'
/>
)}
</View>
)
}
export default AttachmentVideo

View File

@ -0,0 +1,96 @@
import { Surface } from 'gl-react-expo'
import { Blurhash } from 'gl-react-blurhash'
import React, { useCallback, useEffect, useState } from 'react'
import { Image, StyleSheet, Pressable } from 'react-native'
import { StyleConstants } from '@utils/styles/constants'
import layoutAnimation from '@root/utils/styles/layoutAnimation'
export interface Props {
sensitiveShown: boolean
image: Mastodon.AttachmentImage
imageIndex: number
navigateToImagesViewer: (imageIndex: number) => void
}
const AttachmentImage: React.FC<Props> = ({
sensitiveShown,
image,
imageIndex,
navigateToImagesViewer
}) => {
layoutAnimation()
const [imageVisible, setImageVisible] = useState<string>()
const [imageLoadingFailed, setImageLoadingFailed] = useState(false)
useEffect(
() =>
Image.getSize(
image.preview_url,
() => setImageVisible(image.preview_url),
() => {
Image.getSize(
image.url,
() => setImageVisible(image.url),
() =>
image.remote_url
? Image.getSize(
image.remote_url,
() => setImageVisible(image.remote_url),
() => setImageLoadingFailed(true)
)
: setImageLoadingFailed(true)
)
}
),
[]
)
const children = useCallback(() => {
if (imageVisible && !sensitiveShown) {
return <Image source={{ uri: imageVisible }} style={styles.image} />
} else {
return (
<Surface
style={{
width: '100%',
height: '100%',
position: 'absolute',
top: StyleConstants.Spacing.XS / 2,
left: StyleConstants.Spacing.XS / 2
}}
>
<Blurhash hash={image.blurhash} />
</Surface>
)
}
}, [imageVisible, sensitiveShown])
const onPress = useCallback(() => {
if (imageVisible && !sensitiveShown) {
navigateToImagesViewer(imageIndex)
}
}, [imageVisible, sensitiveShown])
return (
<Pressable
style={[styles.imageContainer]}
children={children}
onPress={onPress}
/>
)
}
const styles = StyleSheet.create({
imageContainer: {
flex: 1,
flexBasis: '50%',
padding: StyleConstants.Spacing.XS / 2
},
image: {
flex: 1
}
})
export default React.memo(
AttachmentImage,
(prev, next) => prev.sensitiveShown === next.sensitiveShown
)

View File

@ -0,0 +1,41 @@
import React from 'react'
import { StyleSheet, Text, View } from 'react-native'
import { ButtonRow } from '@components/Button'
import { useTheme } from '@root/utils/styles/ThemeManager'
import { StyleConstants } from '@root/utils/styles/constants'
import openLink from '@root/utils/openLink'
export interface Props {
attachment: Mastodon.Attachment
}
const AttachmentUnsupported: React.FC<Props> = ({ attachment }) => {
const { theme } = useTheme()
return (
<View style={styles.base}>
<Text style={[styles.text, { color: theme.primary }]}></Text>
{attachment.remote_url ? (
<ButtonRow
text='尝试远程链接'
size='S'
onPress={async () => await openLink(attachment.remote_url!)}
/>
) : null}
</View>
)
}
const styles = StyleSheet.create({
base: {
flex: 1,
justifyContent: 'center',
alignItems: 'center'
},
text: {
fontSize: StyleConstants.Font.Size.S,
textAlign: 'center',
marginBottom: StyleConstants.Spacing.S
}
})
export default AttachmentUnsupported

View File

@ -0,0 +1,88 @@
import React, { useCallback, useRef, useState } from 'react'
import { Pressable, StyleSheet } from 'react-native'
import { Video } from 'expo-av'
import { ButtonRow } from '@components/Button'
import layoutAnimation from '@root/utils/styles/layoutAnimation'
import { Surface } from 'gl-react-expo'
import { Blurhash } from 'gl-react-blurhash'
export interface Props {
sensitiveShown: boolean
video: Mastodon.AttachmentVideo | Mastodon.AttachmentGifv
width: number
height: number
}
const AttachmentVideo: React.FC<Props> = ({
sensitiveShown,
video,
width,
height
}) => {
layoutAnimation()
const videoPlayer = useRef<Video>(null)
const [videoLoaded, setVideoLoaded] = useState(false)
const [videoPosition, setVideoPosition] = useState<number>(0)
const playOnPress = useCallback(async () => {
if (!videoLoaded) {
await videoPlayer.current?.loadAsync({ uri: video.url })
}
await videoPlayer.current?.setPositionAsync(videoPosition)
await videoPlayer.current?.presentFullscreenPlayer()
videoPlayer.current?.playAsync()
videoPlayer.current?.setOnPlaybackStatusUpdate(props => {
if (props.isLoaded) {
setVideoLoaded(true)
}
// @ts-ignore
if (props.positionMillis) {
// @ts-ignore
setVideoPosition(props.positionMillis)
}
})
}, [videoLoaded, videoPosition])
return (
<>
<Video
ref={videoPlayer}
style={{
width,
height
}}
resizeMode='cover'
usePoster
posterSource={{ uri: video.preview_url }}
useNativeControls={false}
/>
<Pressable style={styles.overlay}>
{sensitiveShown ? (
<Surface
style={{
width: '100%',
height: '100%'
}}
>
<Blurhash hash={video.blurhash} />
</Surface>
) : (
<ButtonRow icon='play' size='L' onPress={playOnPress} />
)}
</Pressable>
</>
)
}
const styles = StyleSheet.create({
overlay: {
position: 'absolute',
width: '100%',
height: '100%',
flex: 1,
justifyContent: 'center',
alignItems: 'center'
}
})
export default AttachmentVideo

View File

@ -5,15 +5,13 @@ import ParseContent from '@root/components/ParseContent'
import { announcementFetch } from '@root/utils/fetches/announcementsFetch'
import { StyleConstants } from '@root/utils/styles/constants'
import { useTheme } from '@root/utils/styles/ThemeManager'
import { BlurView } from 'expo-blur'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import {
Dimensions,
Image,
Pressable,
StyleSheet,
Text,
TextInput,
View
} from 'react-native'
import { FlatList, ScrollView } from 'react-native-gesture-handler'
@ -53,10 +51,9 @@ const ScreenSharedAnnouncements: React.FC = ({
},
navigation
}) => {
const { mode, theme } = useTheme()
const { theme } = useTheme()
const bottomTabBarHeight = useBottomTabBarHeight()
const [index, setIndex] = useState(0)
const invisibleTextInputRef = useRef<TextInput>(null)
const queryKey = ['Announcements', { showAll }]
const { data, refetch } = useQuery(queryKey, announcementFetch, {
@ -170,17 +167,7 @@ const ScreenSharedAnnouncements: React.FC = ({
)
return (
<SafeAreaView style={styles.base}>
<TextInput
style={styles.invisibleTextInput}
ref={invisibleTextInputRef}
keyboardType='ascii-capable'
/>
<BlurView
intensity={90}
tint={mode}
style={{ ...StyleSheet.absoluteFillObject }}
/>
<SafeAreaView style={[styles.base, { backgroundColor: theme.background }]}>
<View style={[styles.header, { height: bottomTabBarHeight }]}>
<Text style={[styles.headerText, { color: theme.primary }]}></Text>
</View>

View File

@ -27,7 +27,7 @@ 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 '@components/Timelines/Timeline/Shared/Attachment/AttachmentVideo'
import AttachmentVideo from '@root/components/Timelines/Timeline/Shared/Attachment/Video'
const Stack = createNativeStackNavigator()

View File

@ -0,0 +1,130 @@
import { HeaderLeft, HeaderRight } from '@root/components/Header'
import { StyleConstants } from '@root/utils/styles/constants'
import { findIndex } from 'lodash'
import React, { useCallback, useState } from 'react'
import { ActionSheetIOS, Image, StyleSheet, Text } from 'react-native'
import ImageViewer from 'react-native-image-zoom-viewer'
import { IImageInfo } from 'react-native-image-zoom-viewer/built/image-viewer.type'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
const Stack = createNativeStackNavigator()
export interface Props {
route: {
params: {
imageUrls: (IImageInfo & {
preview_url: Mastodon.AttachmentImage['preview_url']
remote_url: Mastodon.AttachmentImage['remote_url']
imageIndex: number
})[]
imageIndex: number
}
}
}
const TheImage = ({
style,
source,
imageUrls,
imageIndex
}: {
style: any
source: { uri: string }
imageUrls: (IImageInfo & {
preview_url: Mastodon.AttachmentImage['preview_url']
remote_url: Mastodon.AttachmentImage['remote_url']
imageIndex: number
})[]
imageIndex: number
}) => {
const [imageVisible, setImageVisible] = useState(false)
Image.getSize(source.uri, () => setImageVisible(true))
return (
<Image
style={style}
source={{
uri: imageVisible
? source.uri
: imageUrls[findIndex(imageUrls, ['imageIndex', imageIndex])]
.preview_url
}}
/>
)
}
const ScreenSharedImagesViewer: React.FC<Props> = ({
route: {
params: { imageUrls, imageIndex }
},
navigation
}) => {
const safeAreaInsets = useSafeAreaInsets()
const initialIndex = findIndex(imageUrls, ['imageIndex', imageIndex])
const [currentIndex, setCurrentIndex] = useState(initialIndex)
const component = useCallback(
() => (
<ImageViewer
style={{ flex: 1, marginBottom: 44 + safeAreaInsets.bottom }}
imageUrls={imageUrls}
index={initialIndex}
onSwipeDown={() => navigation.goBack()}
enableSwipeDown={true}
swipeDownThreshold={100}
useNativeDriver={true}
saveToLocalByLongPress={false}
renderIndicator={() => <></>}
onChange={index => index !== undefined && setCurrentIndex(index)}
renderImage={props => (
<TheImage {...props} imageUrls={imageUrls} imageIndex={imageIndex} />
)}
/>
),
[]
)
return (
<Stack.Navigator>
<Stack.Screen
name='Screen-Shared-ImagesViewer-Root'
component={component}
options={{
contentStyle: { backgroundColor: 'black' },
headerStyle: { backgroundColor: 'black' },
headerLeft: () => (
<HeaderLeft icon='x' onPress={() => navigation.goBack()} />
),
headerCenter: () => (
<Text style={styles.headerCenter}>
{currentIndex + 1} / {imageUrls.length}
</Text>
),
headerRight: () => (
<HeaderRight
icon='share'
onPress={() =>
ActionSheetIOS.showShareActionSheetWithOptions(
{
url: imageUrls[currentIndex].url
},
() => null,
() => null
)
}
/>
)
}}
/>
</Stack.Navigator>
)
}
const styles = StyleSheet.create({
headerCenter: {
color: 'white',
fontSize: StyleConstants.Font.Size.M
}
})
export default React.memo(ScreenSharedImagesViewer, () => true)

View File

@ -7,7 +7,9 @@ import Compose from '@screens/Shared/Compose'
import ComposeEditAttachment from '@screens/Shared/Compose/EditAttachment'
import ScreenSharedSearch from '@screens/Shared/Search'
import React from 'react'
import { Text } from 'react-native'
import { useTranslation } from 'react-i18next'
import ScreenSharedImagesViewer from './ImagesViewer'
const sharedScreens = (Stack: any) => {
const { t } = useTranslation()
@ -87,6 +89,15 @@ const sharedScreens = (Stack: any) => {
stackPresentation: 'transparentModal',
stackAnimation: 'fade'
}}
/>,
<Stack.Screen
key='Screen-Shared-ImagesViewer'
name='Screen-Shared-ImagesViewer'
component={ScreenSharedImagesViewer}
options={{
stackPresentation: 'transparentModal',
stackAnimation: 'none'
}}
/>
]
}

View File

@ -52,7 +52,7 @@ const themeColors: {
},
backgroundOverlay: {
light: 'rgba(0, 0, 0, 0.5)',
dark: 'rgb(255, 255, 255, 0.5)'
dark: 'rgba(255, 255, 255, 0.5)'
},
link: {
light: 'rgb(0, 122, 255)',