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:
		
							
								
								
									
										1
									
								
								src/@types/untyped.d.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								src/@types/untyped.d.ts
									
									
									
									
										vendored
									
									
								
							@@ -1,2 +1,3 @@
 | 
			
		||||
declare module 'gl-react-blurhash'
 | 
			
		||||
declare module 'react-native-toast-message'
 | 
			
		||||
declare module 'react-native-htmlview'
 | 
			
		||||
 
 | 
			
		||||
@@ -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={{
 | 
			
		||||
 
 | 
			
		||||
@@ -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>
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
  }
 | 
			
		||||
 
 | 
			
		||||
@@ -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)
 | 
			
		||||
@@ -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
 | 
			
		||||
@@ -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
 | 
			
		||||
)
 | 
			
		||||
@@ -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
 | 
			
		||||
@@ -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
 | 
			
		||||
@@ -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>
 | 
			
		||||
 
 | 
			
		||||
@@ -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()
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										130
									
								
								src/screens/Shared/ImagesViewer.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										130
									
								
								src/screens/Shared/ImagesViewer.tsx
									
									
									
									
									
										Normal 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)
 | 
			
		||||
@@ -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'
 | 
			
		||||
      }}
 | 
			
		||||
    />
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -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)',
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user