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

Some basic styling

This commit is contained in:
Zhiyuan Zheng
2020-11-23 00:07:32 +01:00
parent 6d6b808af2
commit fba1d0d531
40 changed files with 1381 additions and 270 deletions

View File

@ -0,0 +1,119 @@
import React from 'react'
import { ActivityIndicator, AppState, FlatList, Text, View } from 'react-native'
import { setFocusHandler, useInfiniteQuery } from 'react-query'
import TimelineNotifications from 'src/components/Timelines/Timeline/Notifications'
import TimelineDefault from 'src/components/Timelines/Timeline/Default'
import TimelineConversation from 'src/components/Timelines/Timeline/Conversation'
import { timelineFetch } from 'src/utils/fetches/timelineFetch'
import TimelineSeparator from './Timeline/Separator'
// Opening nesting hashtag pages
export interface Props {
page: App.Pages
hashtag?: string
list?: string
toot?: string
account?: string
disableRefresh?: boolean
scrollEnabled?: boolean
}
const Timeline: React.FC<Props> = ({
page,
hashtag,
list,
toot,
account,
disableRefresh = false,
scrollEnabled = true
}) => {
setFocusHandler(handleFocus => {
const handleAppStateChange = (appState: string) => {
if (appState === 'active') {
handleFocus()
}
}
AppState.addEventListener('change', handleAppStateChange)
return () => AppState.removeEventListener('change', handleAppStateChange)
})
const queryKey: App.QueryKey = [page, { page, hashtag, list, toot, account }]
const {
isLoading,
isFetchingMore,
isError,
isSuccess,
data,
fetchMore
} = useInfiniteQuery(queryKey, timelineFetch)
const flattenData = data ? data.flatMap(d => [...d?.toots]) : []
// const flattenPointer = data ? data.flatMap(d => [d?.pointer]) : []
let content
if (!isSuccess) {
content = <ActivityIndicator />
} else if (isError) {
content = <Text>Error message</Text>
} else {
content = (
<>
<FlatList
style={{ minHeight: '100%' }}
scrollEnabled={scrollEnabled} // For timeline in Account view
data={flattenData}
keyExtractor={({ id }) => id}
renderItem={({ item, index, separators }) => {
switch (page) {
case 'Conversations':
return <TimelineConversation key={index} item={item} />
case 'Notifications':
return (
<TimelineNotifications
key={index}
notification={item}
queryKey={queryKey}
/>
)
default:
return (
<TimelineDefault
key={index}
item={item}
queryKey={queryKey}
/>
)
}
}}
ItemSeparatorComponent={() => <TimelineSeparator />}
// require getItemLayout
// {...(flattenPointer[0] && { initialScrollIndex: flattenPointer[0] })}
{...(!disableRefresh && {
onRefresh: () =>
fetchMore(
{
direction: 'prev',
id: flattenData[0].id
},
{ previous: true }
),
refreshing: isLoading,
onEndReached: () => {
fetchMore({
direction: 'next',
id: flattenData[flattenData.length - 1].id
})
},
onEndReachedThreshold: 0.5
})}
/>
{isFetchingMore && <ActivityIndicator />}
</>
)
}
return <View>{content}</View>
}
export default Timeline

View File

@ -0,0 +1,75 @@
import React, { useMemo } from 'react'
import { Pressable, StyleSheet, View } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import Avatar from './Shared/Avatar'
import HeaderConversation from './Shared/HeaderConversation'
import Content from './Shared/Content'
import constants from 'src/utils/styles/constants'
export interface Props {
item: Mastodon.Conversation
}
// Unread and mark as unread
const TimelineConversation: React.FC<Props> = ({ item }) => {
const navigation = useNavigation()
const statusView = useMemo(() => {
return (
<View style={styles.statusView}>
<View style={styles.status}>
<Avatar uri={item.accounts[0].avatar} id={item.accounts[0].id} />
<View style={styles.details}>
<HeaderConversation
account={item.accounts[0]}
created_at={item.last_status?.created_at}
/>
{/* Can pass toot info to next page to speed up performance */}
<Pressable
onPress={() =>
item.last_status &&
navigation.navigate('Screen-Shared-Toot', {
toot: item.last_status.id
})
}
>
{item.last_status ? (
<Content
content={item.last_status.content}
emojis={item.last_status.emojis}
mentions={item.last_status.mentions}
spoiler_text={item.last_status.spoiler_text}
// tags={actualStatus.tags}
// style={{ flex: 1 }}
/>
) : (
<></>
)}
</Pressable>
</View>
</View>
</View>
)
}, [item])
return statusView
}
const styles = StyleSheet.create({
statusView: {
flex: 1,
flexDirection: 'column',
padding: constants.GLOBAL_PAGE_PADDING
},
status: {
flex: 1,
flexDirection: 'row'
},
details: {
flex: 1,
flexGrow: 1
}
})
export default TimelineConversation

View File

@ -0,0 +1,117 @@
import React, { useMemo } from 'react'
import { Dimensions, Pressable, StyleSheet, View } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import Actioned from './Shared/Actioned'
import Avatar from './Shared/Avatar'
import HeaderDefault from './Shared/HeaderDefault'
import Content from './Shared/Content'
import Poll from './Shared/Poll'
import Attachment from './Shared/Attachment'
import Card from './Shared/Card'
import ActionsStatus from './Shared/ActionsStatus'
import constants from 'src/utils/styles/constants'
export interface Props {
item: Mastodon.Status
queryKey: App.QueryKey
}
// When the poll is long
const TimelineDefault: React.FC<Props> = ({ item, queryKey }) => {
const navigation = useNavigation()
let actualStatus = item.reblog ? item.reblog : item
const statusView = useMemo(() => {
return (
<View style={styles.statusView}>
{item.reblog && (
<Actioned
action='reblog'
name={item.account.display_name || item.account.username}
emojis={item.account.emojis}
/>
)}
<View style={styles.status}>
<Avatar
uri={actualStatus.account.avatar}
id={actualStatus.account.id}
/>
<View style={styles.details}>
<HeaderDefault
queryKey={queryKey}
accountId={actualStatus.account.id}
domain={actualStatus.uri.split(new RegExp(/\/\/(.*?)\//))[1]}
name={
actualStatus.account.display_name ||
actualStatus.account.username
}
emojis={actualStatus.account.emojis}
account={actualStatus.account.acct}
created_at={item.created_at}
application={item.application}
/>
{/* Can pass toot info to next page to speed up performance */}
<Pressable
onPress={() =>
navigation.navigate('Screen-Shared-Toot', {
toot: actualStatus.id
})
}
>
{actualStatus.content ? (
<Content
content={actualStatus.content}
emojis={actualStatus.emojis}
mentions={actualStatus.mentions}
spoiler_text={actualStatus.spoiler_text}
// tags={actualStatus.tags}
// style={{ flex: 1 }}
/>
) : (
<></>
)}
{actualStatus.poll && <Poll poll={actualStatus.poll} />}
{actualStatus.media_attachments.length > 0 && (
<Attachment
media_attachments={actualStatus.media_attachments}
sensitive={actualStatus.sensitive}
width={
Dimensions.get('window').width -
constants.SPACING_M * 2 -
50 -
8
}
/>
)}
{actualStatus.card && <Card card={actualStatus.card} />}
</Pressable>
<ActionsStatus queryKey={queryKey} status={actualStatus} />
</View>
</View>
</View>
)
}, [item])
return statusView
}
const styles = StyleSheet.create({
statusView: {
flex: 1,
flexDirection: 'column',
padding: constants.GLOBAL_PAGE_PADDING
},
status: {
flex: 1,
flexDirection: 'row'
},
details: {
flex: 1,
flexGrow: 1
}
})
export default TimelineDefault

View File

@ -0,0 +1,113 @@
import React, { useMemo } from 'react'
import { Dimensions, Pressable, StyleSheet, View } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import Actioned from './Shared/Actioned'
import Avatar from './Shared/Avatar'
import HeaderDefault from './Shared/HeaderDefault'
import Content from './Shared/Content'
import Poll from './Shared/Poll'
import Attachment from './Shared/Attachment'
import Card from './Shared/Card'
import ActionsStatus from './Shared/ActionsStatus'
import constants from 'src/utils/styles/constants'
export interface Props {
notification: Mastodon.Notification
queryKey: App.QueryKey
}
const TimelineNotifications: React.FC<Props> = ({ notification, queryKey }) => {
const navigation = useNavigation()
const actualAccount = notification.status
? notification.status.account
: notification.account
const statusView = useMemo(() => {
return (
<View style={styles.notificationView}>
<Actioned
action={notification.type}
name={
notification.account.display_name || notification.account.username
}
emojis={notification.account.emojis}
notification
/>
<View style={styles.notification}>
<Avatar uri={actualAccount.avatar} id={actualAccount.id} />
<View style={styles.details}>
<HeaderDefault
name={actualAccount.display_name || actualAccount.username}
emojis={actualAccount.emojis}
account={actualAccount.acct}
created_at={notification.created_at}
/>
<Pressable
onPress={() =>
navigation.navigate('Screen-Shared-Toot', {
toot: notification.id
})
}
>
{notification.status ? (
<>
{notification.status.content && (
<Content
content={notification.status.content}
emojis={notification.status.emojis}
mentions={notification.status.mentions}
spoiler_text={notification.status.spoiler_text}
// tags={notification.notification.tags}
// style={{ flex: 1 }}
/>
)}
{notification.status.poll && (
<Poll poll={notification.status.poll} />
)}
{notification.status.media_attachments.length > 0 && (
<Attachment
media_attachments={notification.status.media_attachments}
sensitive={notification.status.sensitive}
width={Dimensions.get('window').width - 24 - 50 - 8}
/>
)}
{notification.status.card && (
<Card card={notification.status.card} />
)}
</>
) : (
<></>
)}
</Pressable>
{notification.status && (
<ActionsStatus queryKey={queryKey} status={notification.status} />
)}
</View>
</View>
</View>
)
}, [notification])
return statusView
}
const styles = StyleSheet.create({
notificationView: {
flex: 1,
flexDirection: 'column',
padding: constants.GLOBAL_PAGE_PADDING
},
notification: {
flex: 1,
flexDirection: 'row'
},
details: {
flex: 1,
flexGrow: 1
}
})
export default TimelineNotifications

View File

@ -0,0 +1,20 @@
import React from 'react'
import { StyleSheet, View } from 'react-native'
import constants from 'src/utils/styles/constants'
import { useTheme } from 'src/utils/styles/ThemeManager'
const TimelineSeparator = () => {
const { theme } = useTheme()
return <View style={[styles.base, { borderTopColor: theme.separator }]} />
}
const styles = StyleSheet.create({
base: {
borderTopWidth: 1,
marginLeft: constants.SPACING_M + constants.AVATAR_S + constants.SPACING_S
}
})
export default TimelineSeparator

View File

@ -0,0 +1,111 @@
import React from 'react'
import { StyleSheet, Text, View } from 'react-native'
import { Feather } from '@expo/vector-icons'
import Emojis from './Emojis'
import { useTheme } from 'src/utils/styles/ThemeManager'
import constants from 'src/utils/styles/constants'
export interface Props {
action: 'favourite' | 'follow' | 'mention' | 'poll' | 'reblog'
name?: string
emojis?: Mastodon.Emoji[]
notification?: boolean
}
const Actioned: React.FC<Props> = ({
action,
name,
emojis,
notification = false
}) => {
const { theme } = useTheme()
const iconColor = theme.primary
let icon
let content
switch (action) {
case 'favourite':
icon = (
<Feather
name='heart'
size={constants.FONT_SIZE_S}
color={iconColor}
style={styles.icon}
/>
)
content = `${name} 喜欢了你的嘟嘟`
break
case 'follow':
icon = (
<Feather
name='user-plus'
size={constants.FONT_SIZE_S}
color={iconColor}
style={styles.icon}
/>
)
content = `${name} 开始关注你`
break
case 'poll':
icon = (
<Feather
name='bar-chart-2'
size={constants.FONT_SIZE_S}
color='black'
style={styles.icon}
/>
)
content = `你参与的投票已结束`
break
case 'reblog':
icon = (
<Feather
name='repeat'
size={constants.FONT_SIZE_S}
color={iconColor}
style={styles.icon}
/>
)
content = `${name} 转嘟了${notification ? '你的嘟嘟' : ''}`
break
}
return (
<View style={styles.actioned}>
{icon}
{content ? (
<View style={styles.content}>
{emojis ? (
<Emojis
content={content}
emojis={emojis}
size={constants.FONT_SIZE_S}
/>
) : (
<Text>{content}</Text>
)}
</View>
) : (
<></>
)}
</View>
)
}
const styles = StyleSheet.create({
actioned: {
flexDirection: 'row',
marginBottom: constants.SPACING_S
},
icon: {
marginLeft: constants.AVATAR_S - constants.FONT_SIZE_S,
marginRight: constants.SPACING_S
},
content: {
flexDirection: 'row'
}
})
export default Actioned

View File

@ -0,0 +1,380 @@
import React, { useEffect, useState } from 'react'
import {
ActionSheetIOS,
Clipboard,
Modal,
Pressable,
StyleSheet,
Text,
View
} from 'react-native'
import Toast from 'react-native-toast-message'
import { useMutation, useQueryCache } from 'react-query'
import { Feather } from '@expo/vector-icons'
import client from 'src/api/client'
import { getLocalAccountId } from 'src/utils/slices/instancesSlice'
import { store } from 'src/store'
import { useTheme } from 'src/utils/styles/ThemeManager'
import constants from 'src/utils/styles/constants'
const fireMutation = async ({
id,
type,
stateKey,
prevState
}: {
id: string
type:
| 'favourite'
| 'reblog'
| 'bookmark'
| 'mute'
| 'pin'
| 'delete'
| 'account/mute'
stateKey:
| 'favourited'
| 'reblogged'
| 'bookmarked'
| 'muted'
| 'pinned'
| 'id'
prevState?: boolean
}) => {
let res
switch (type) {
case 'favourite':
case 'reblog':
case 'bookmark':
case 'mute':
case 'pin':
res = await client({
method: 'post',
instance: 'local',
endpoint: `statuses/${id}/${prevState ? 'un' : ''}${type}`
})
if (!res.body[stateKey] === prevState) {
if (type === 'bookmark' || 'mute' || 'pin')
Toast.show({
type: 'success',
position: 'bottom',
text1: '功能成功',
visibilityTime: 2000,
autoHide: true,
bottomOffset: 65
})
return Promise.resolve(res.body)
} else {
Toast.show({
type: 'error',
position: 'bottom',
text1: '请重试',
autoHide: false,
bottomOffset: 65
})
return Promise.reject()
}
break
case 'delete':
res = await client({
method: 'delete',
instance: 'local',
endpoint: `statuses/${id}`
})
if (res.body[stateKey] === id) {
Toast.show({
type: 'success',
position: 'bottom',
text1: '删除成功',
visibilityTime: 2000,
autoHide: true,
bottomOffset: 65
})
return Promise.resolve(res.body)
} else {
Toast.show({
type: 'error',
position: 'bottom',
text1: '请重试',
autoHide: false,
bottomOffset: 65
})
return Promise.reject()
}
break
}
}
export interface Props {
queryKey: App.QueryKey
status: Mastodon.Status
}
const ActionsStatus: React.FC<Props> = ({ queryKey, status }) => {
const { theme } = useTheme()
const iconColor = theme.secondary
const iconColorAction = (state: boolean) =>
state ? theme.primary : theme.secondary
const localAccountId = getLocalAccountId(store.getState())
const [modalVisible, setModalVisible] = useState(false)
const queryCache = useQueryCache()
const [mutateAction] = useMutation(fireMutation, {
onMutate: () => {
queryCache.cancelQueries(queryKey)
const prevData = queryCache.getQueryData(queryKey)
return prevData
},
onSuccess: (newData, params) => {
if (params.type === 'reblog') {
queryCache.invalidateQueries(['Following', { page: 'Following' }])
}
// queryCache.setQueryData(queryKey, (oldData: any) => {
// oldData &&
// oldData.map((paging: any) => {
// paging.toots.map(
// (status: Mastodon.Status | Mastodon.Notification, i: number) => {
// if (status.id === newData.id) {
// paging.toots[i] = newData
// }
// }
// )
// })
// return oldData
// })
return Promise.resolve()
},
onError: (err, variables, prevData) => {
queryCache.setQueryData(queryKey, prevData)
},
onSettled: () => {
queryCache.invalidateQueries(queryKey)
}
})
return (
<>
<View style={styles.actions}>
<Pressable style={styles.action}>
<Feather
name='message-circle'
color={iconColor}
size={constants.FONT_SIZE_M + 2}
/>
{status.replies_count > 0 && (
<Text
style={{
color: theme.secondary,
fontSize: constants.FONT_SIZE_M,
marginLeft: constants.SPACING_XS
}}
>
{status.replies_count}
</Text>
)}
</Pressable>
<Pressable
style={styles.action}
onPress={() =>
mutateAction({
id: status.id,
type: 'reblog',
stateKey: 'reblogged',
prevState: status.reblogged
})
}
>
<Feather
name='repeat'
color={iconColorAction(status.reblogged)}
size={constants.FONT_SIZE_M + 2}
/>
</Pressable>
<Pressable
style={styles.action}
onPress={() =>
mutateAction({
id: status.id,
type: 'favourite',
stateKey: 'favourited',
prevState: status.favourited
})
}
>
<Feather
name='heart'
color={iconColorAction(status.favourited)}
size={constants.FONT_SIZE_M + 2}
/>
</Pressable>
<Pressable
style={styles.action}
onPress={() =>
mutateAction({
id: status.id,
type: 'bookmark',
stateKey: 'bookmarked',
prevState: status.bookmarked
})
}
>
<Feather
name='bookmark'
color={iconColorAction(status.bookmarked)}
size={constants.FONT_SIZE_M + 2}
/>
</Pressable>
<Pressable style={styles.action} onPress={() => setModalVisible(true)}>
<Feather
name='share-2'
color={iconColor}
size={constants.FONT_SIZE_M + 2}
/>
</Pressable>
</View>
<Modal
animationType='fade'
presentationStyle='overFullScreen'
transparent
visible={modalVisible}
>
<Pressable
style={styles.modalBackground}
onPress={() => setModalVisible(false)}
>
<View style={styles.modalSheet}>
<Pressable
onPress={() =>
ActionSheetIOS.showShareActionSheetWithOptions(
{
url: status.uri,
excludedActivityTypes: [
'com.apple.UIKit.activity.Mail',
'com.apple.UIKit.activity.Print',
'com.apple.UIKit.activity.SaveToCameraRoll',
'com.apple.UIKit.activity.OpenInIBooks'
]
},
() => {},
() => {
setModalVisible(false)
Toast.show({
type: 'success',
position: 'bottom',
text1: '分享成功',
visibilityTime: 2000,
autoHide: true,
bottomOffset: 65
})
}
)
}
>
<Text></Text>
</Pressable>
<Pressable
onPress={() => {
Clipboard.setString(status.uri)
setModalVisible(false)
Toast.show({
type: 'success',
position: 'bottom',
text1: '链接复制成功',
visibilityTime: 2000,
autoHide: true,
bottomOffset: 65
})
}}
>
<Text></Text>
</Pressable>
{status.account.id === localAccountId && (
<Pressable
onPress={() => {
setModalVisible(false)
mutateAction({
id: status.id,
type: 'delete',
stateKey: 'id'
})
}}
>
<Text></Text>
</Pressable>
)}
<Text></Text>
<Pressable
onPress={() => {
setModalVisible(false)
mutateAction({
id: status.id,
type: 'mute',
stateKey: 'muted',
prevState: status.muted
})
}}
>
<Text>{status.muted ? '取消静音' : '静音'}</Text>
</Pressable>
{status.account.id === localAccountId && (
<Pressable
onPress={() => {
setModalVisible(false)
mutateAction({
id: status.id,
type: 'pin',
stateKey: 'pinned',
prevState: status.pinned
})
}}
>
<Text>{status.pinned ? '取消置顶' : '置顶'}</Text>
</Pressable>
)}
<Text></Text>
</View>
</Pressable>
</Modal>
</>
)
}
const styles = StyleSheet.create({
actions: {
width: '100%',
flex: 1,
flexDirection: 'row',
marginTop: constants.SPACING_M
},
action: {
width: '20%',
flexDirection: 'row',
justifyContent: 'center'
},
modalBackground: {
width: '100%',
height: '100%',
backgroundColor: 'rgba(0, 0, 0, 0.75)',
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'flex-end'
},
modalSheet: {
width: '100%',
height: '50%',
backgroundColor: 'white',
flex: 1
}
})
export default ActionsStatus

View File

@ -0,0 +1,87 @@
import React from 'react'
import { Text, View } from 'react-native'
import AttachmentImage from './Attachment/AttachmentImage'
import AttachmentVideo from './Attachment/AttachmentVideo'
export interface Props {
media_attachments: Mastodon.Attachment[]
sensitive: boolean
width: number
}
const Attachment: React.FC<Props> = ({
media_attachments,
sensitive,
width
}) => {
let attachment
let attachmentHeight
// if (width) {}
switch (media_attachments[0].type) {
case 'unknown':
attachment = <Text></Text>
attachmentHeight = 25
break
case 'image':
attachment = (
<AttachmentImage
media_attachments={media_attachments}
sensitive={sensitive}
width={width}
/>
)
attachmentHeight = width / 2
break
case 'gifv':
attachment = (
<AttachmentVideo
media_attachments={media_attachments}
sensitive={sensitive}
width={width}
/>
)
attachmentHeight =
(width / media_attachments[0].meta.original.width) *
media_attachments[0].meta.original.height
break
// Support multiple video
// Supoort when video meta is empty
// case 'video':
// attachment = (
// <AttachmentVideo
// media_attachments={media_attachments}
// sensitive={sensitive}
// width={width}
// />
// )
// attachmentHeight =
// (width / media_attachments[0].meta.original.width) *
// media_attachments[0].meta.original.height
// break
// case 'audio':
// attachment = (
// <AttachmentAudio
// media_attachments={media_attachments}
// sensitive={sensitive}
// width={width}
// />
// )
// break
}
return (
<View
style={{
width: width + 8,
height: attachmentHeight,
marginTop: 4,
marginLeft: -4
}}
>
{attachment}
</View>
)
}
export default Attachment

View File

@ -0,0 +1,106 @@
import React, { useEffect, useState } from 'react'
import { Button, Image, Modal, StyleSheet, Pressable, View } from 'react-native'
import ImageViewer from 'react-native-image-zoom-viewer'
export interface Props {
media_attachments: Mastodon.Attachment[]
sensitive: boolean
width: number
}
const AttachmentImage: React.FC<Props> = ({
media_attachments,
sensitive,
width
}) => {
const [mediaSensitive, setMediaSensitive] = useState(sensitive)
const [imageModalVisible, setImageModalVisible] = useState(false)
const [imageModalIndex, setImageModalIndex] = useState(0)
useEffect(() => {
if (sensitive && mediaSensitive === false) {
setTimeout(() => {
setMediaSensitive(true)
}, 10000)
}
}, [mediaSensitive])
let images: { url: string; width: number; height: number }[] = []
const imagesNode = media_attachments.map((m, i) => {
images.push({
url: m.url,
width: m.meta.original.width,
height: m.meta.original.height
})
return (
<Pressable
key={i}
style={{ flexGrow: 1, height: width / 2, margin: 4 }}
onPress={() => {
setImageModalIndex(i)
setImageModalVisible(true)
}}
>
<Image
source={{ uri: m.preview_url }}
style={styles.image}
blurRadius={mediaSensitive ? width / 5 : 0}
/>
</Pressable>
)
})
return (
<>
<View style={styles.media}>
{imagesNode}
{mediaSensitive && (
<View
style={{
position: 'absolute',
width: '100%',
height: '100%'
}}
>
<Button
title='Press me'
onPress={() => {
setMediaSensitive(false)
}}
/>
</View>
)}
</View>
<Modal
visible={imageModalVisible}
transparent={true}
animationType='fade'
>
<ImageViewer
imageUrls={images}
index={imageModalIndex}
onSwipeDown={() => setImageModalVisible(false)}
enableSwipeDown={true}
swipeDownThreshold={100}
useNativeDriver={true}
/>
</Modal>
</>
)
}
const styles = StyleSheet.create({
media: {
flex: 1,
flexDirection: 'column',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'stretch',
alignContent: 'stretch'
},
image: {
width: '100%',
height: '100%'
}
})
export default AttachmentImage

View File

@ -0,0 +1,72 @@
import React, { useRef, useState } from 'react'
import { Pressable, View } from 'react-native'
import { Video } from 'expo-av'
import { Feather } from '@expo/vector-icons'
export interface Props {
media_attachments: Mastodon.Attachment[]
sensitive: boolean
width: number
}
const AttachmentVideo: React.FC<Props> = ({
media_attachments,
sensitive,
width
}) => {
const videoPlayer = useRef()
const [mediaSensitive, setMediaSensitive] = useState(sensitive)
const [videoPlay, setVideoPlay] = useState(false)
const video = media_attachments[0]
const videoWidth = width
const videoHeight =
(width / video.meta.original.width) * video.meta.original.height
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
posterSourceThe={{ uri: video.preview_url }}
useNativeControls
shouldPlay={videoPlay}
/>
{!videoPlay && (
<Pressable
onPress={() => {
setMediaSensitive(false)
videoPlayer.current.presentFullscreenPlayer()
setVideoPlay(true)
}}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.25)'
}}
>
<Feather name='play' size={36} color='black' />
</Pressable>
)}
</View>
)
}
export default AttachmentVideo

View File

@ -0,0 +1,42 @@
import React from 'react'
import { Image, Pressable, StyleSheet } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import constants from 'src/utils/styles/constants'
export interface Props {
uri: string
id: string
}
const Avatar: React.FC<Props> = ({ uri, id }) => {
const navigation = useNavigation()
// Need to fix go back root
return (
<Pressable
style={styles.avatar}
onPress={() => {
navigation.navigate('Screen-Shared-Account', {
id: id
})
}}
>
<Image source={{ uri: uri }} style={styles.image} />
</Pressable>
)
}
const styles = StyleSheet.create({
avatar: {
width: constants.AVATAR_S,
height: constants.AVATAR_S,
marginRight: constants.SPACING_S
},
image: {
width: '100%',
height: '100%',
borderRadius: 8
}
})
export default Avatar

View File

@ -0,0 +1,59 @@
import React from 'react'
import { Image, Pressable, StyleSheet, Text, View } from 'react-native'
import { useNavigation } from '@react-navigation/native'
export interface Props {
card: Mastodon.Card
}
const Card: React.FC<Props> = ({ card }) => {
const navigation = useNavigation()
return (
card && (
<Pressable
style={styles.card}
onPress={() => {
navigation.navigate('Webview', {
uri: card.url
})
}}
>
{card.image && (
<View style={styles.left}>
<Image source={{ uri: card.image }} style={styles.image} />
</View>
)}
<View style={styles.right}>
<Text numberOfLines={1}>{card.title}</Text>
{card.description ? (
<Text numberOfLines={2}>{card.description}</Text>
) : (
<></>
)}
<Text numberOfLines={1}>{card.url}</Text>
</View>
</Pressable>
)
)
}
const styles = StyleSheet.create({
card: {
flex: 1,
flexDirection: 'row',
height: 70,
marginTop: 12
},
left: {
width: 70
},
image: {
width: '100%',
height: '100%'
},
right: {
flex: 1
}
})
export default Card

View File

@ -0,0 +1,61 @@
import React, { useState } from 'react'
import { Text } from 'react-native'
import Collapsible from 'react-native-collapsible'
import ParseContent from 'src/components/ParseContent'
import constants from 'src/utils/styles/constants'
import { useTheme } from 'src/utils/styles/ThemeManager'
export interface Props {
content: string
emojis: Mastodon.Emoji[]
mentions: Mastodon.Mention[]
spoiler_text?: string
}
const Content: React.FC<Props> = ({
content,
emojis,
mentions,
spoiler_text
}) => {
const { theme } = useTheme()
const [spoilerCollapsed, setSpoilerCollapsed] = useState(true)
return (
<>
{content &&
(spoiler_text ? (
<>
<Text>
{spoiler_text}{' '}
<Text
onPress={() => setSpoilerCollapsed(!spoilerCollapsed)}
style={{ color: theme.link }}
>
{spoilerCollapsed ? '点击展开' : '点击收起'}
</Text>
</Text>
<Collapsible collapsed={spoilerCollapsed}>
<ParseContent
content={content}
size={constants.FONT_SIZE_M}
emojis={emojis}
mentions={mentions}
/>
</Collapsible>
</>
) : (
<ParseContent
content={content}
size={constants.FONT_SIZE_M}
emojis={emojis}
mentions={mentions}
/>
))}
</>
)
}
export default Content

View File

@ -0,0 +1,68 @@
import React from 'react'
import { Image, StyleSheet, Text } from 'react-native'
import { useTheme } from 'src/utils/styles/ThemeManager'
const regexEmoji = new RegExp(/(:[a-z0-9_]+:)/)
export interface Props {
content: string
emojis: Mastodon.Emoji[]
size: number
fontBold?: boolean
}
const Emojis: React.FC<Props> = ({
content,
emojis,
size,
fontBold = false
}) => {
const { theme } = useTheme()
const styles = StyleSheet.create({
text: {
fontSize: size,
lineHeight: size + 2,
color: theme.primary,
...(fontBold && { fontWeight: 'bold' })
},
image: {
width: size,
height: size
}
})
const hasEmojis = content.match(regexEmoji)
return hasEmojis ? (
<>
{content.split(regexEmoji).map((str, i) => {
if (str.match(regexEmoji)) {
const emojiShortcode = str.split(regexEmoji)[1]
const emojiIndex = emojis.findIndex(emoji => {
return emojiShortcode === `:${emoji.shortcode}:`
})
return emojiIndex === -1 ? (
<Text key={i} style={styles.text}>
{emojiShortcode}
</Text>
) : (
<Image
key={i}
source={{ uri: emojis[emojiIndex].url }}
style={styles.image}
/>
)
} else {
return (
<Text key={i} style={styles.text}>
{str}
</Text>
)
}
})}
</>
) : (
<Text style={styles.text}>{content}</Text>
)
}
export default Emojis

View File

@ -0,0 +1,66 @@
import React from 'react'
import { StyleSheet, Text, View } from 'react-native'
import relativeTime from 'src/utils/relativeTime'
import Emojis from './Emojis'
export interface Props {
account: Mastodon.Account
created_at?: Mastodon.Status['created_at']
}
const HeaderConversation: React.FC<Props> = ({ account, created_at }) => {
return (
<View>
<View style={styles.nameAndDate}>
<View style={styles.name}>
{account.emojis ? (
<Emojis
content={account.display_name || account.username}
emojis={account.emojis}
size={14}
/>
) : (
<Text numberOfLines={1}>
{account.display_name || account.username}
</Text>
)}
</View>
{created_at && (
<View>
<Text style={styles.created_at}>{relativeTime(created_at)}</Text>
</View>
)}
</View>
<Text style={styles.account} numberOfLines={1}>
@{account.acct}
</Text>
</View>
)
}
const styles = StyleSheet.create({
nameAndDate: {
width: '100%',
flexDirection: 'row',
justifyContent: 'space-between'
},
name: {
flexDirection: 'row',
marginRight: 8,
fontWeight: '900'
},
created_at: {
fontSize: 12,
lineHeight: 12,
marginTop: 8,
marginBottom: 8,
marginRight: 8
},
account: {
lineHeight: 14,
flexShrink: 1
}
})
export default HeaderConversation

View File

@ -0,0 +1,370 @@
import React, { useEffect, useState } from 'react'
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native'
import { useNavigation } from '@react-navigation/native'
import { Feather } from '@expo/vector-icons'
import Toast from 'react-native-toast-message'
import { useMutation, useQueryCache } from 'react-query'
import Emojis from './Emojis'
import relativeTime from 'src/utils/relativeTime'
import client from 'src/api/client'
import { getLocalAccountId, getLocalUrl } from 'src/utils/slices/instancesSlice'
import { store } from 'src/store'
import { useTheme } from 'src/utils/styles/ThemeManager'
import constants from 'src/utils/styles/constants'
const fireMutation = async ({
id,
type,
stateKey
}: {
id: string
type: 'mute' | 'block' | 'domain_blocks' | 'reports'
stateKey?: 'muting' | 'blocking'
}) => {
let res
switch (type) {
case 'mute':
case 'block':
res = await client({
method: 'post',
instance: 'local',
endpoint: `accounts/${id}/${type}`
})
if (res.body[stateKey] === true) {
Toast.show({
type: 'success',
position: 'bottom',
text1: '功能成功',
visibilityTime: 2000,
autoHide: true,
bottomOffset: 65
})
return Promise.resolve()
} else {
Toast.show({
type: 'error',
position: 'bottom',
text1: '请重试',
autoHide: false,
bottomOffset: 65
})
return Promise.reject()
}
break
case 'domain_blocks':
res = await client({
method: 'post',
instance: 'local',
endpoint: `domain_blocks`,
query: {
domain: id || ''
}
})
if (!res.body.error) {
Toast.show({
type: 'success',
position: 'bottom',
text1: '隐藏域名成功',
visibilityTime: 2000,
autoHide: true,
bottomOffset: 65
})
return Promise.resolve()
} else {
Toast.show({
type: 'error',
position: 'bottom',
text1: '隐藏域名失败,请重试',
autoHide: false,
bottomOffset: 65
})
return Promise.reject()
}
break
// case 'reports':
// res = await client({
// method: 'post',
// instance: 'local',
// endpoint: `reports`,
// query: {
// domain: id || ''
// }
// })
// if (!res.body.error) {
// Toast.show({
// type: 'success',
// position: 'bottom',
// text1: '隐藏域名成功',
// visibilityTime: 2000,
// autoHide: true,
// bottomOffset: 65
// })
// return Promise.resolve()
// } else {
// Toast.show({
// type: 'error',
// position: 'bottom',
// text1: '隐藏域名失败,请重试',
// autoHide: false,
// bottomOffset: 65
// })
// return Promise.reject()
// }
// break
}
}
export interface Props {
queryKey: App.QueryKey
accountId: string
domain: string
name: string
emojis?: Mastodon.Emoji[]
account: string
created_at: string
application?: Mastodon.Application
}
const HeaderDefault: React.FC<Props> = ({
queryKey,
accountId,
domain,
name,
emojis,
account,
created_at,
application
}) => {
const { theme } = useTheme()
const navigation = useNavigation()
const localAccountId = getLocalAccountId(store.getState())
const localDomain = getLocalUrl(store.getState())
const [since, setSince] = useState(relativeTime(created_at))
const [modalVisible, setModalVisible] = useState(false)
const queryCache = useQueryCache()
const [mutateAction] = useMutation(fireMutation, {
onMutate: () => {
queryCache.cancelQueries(queryKey)
const prevData = queryCache.getQueryData(queryKey)
return prevData
},
onSuccess: (newData, params) => {
if (params.type === 'domain_blocks') {
console.log('clearing cache')
queryCache.invalidateQueries(['Following', { page: 'Following' }])
}
// queryCache.setQueryData(queryKey, (oldData: any) => {
// oldData &&
// oldData.map((paging: any) => {
// paging.toots.map(
// (status: Mastodon.Status | Mastodon.Notification, i: number) => {
// if (status.id === newData.id) {
// paging.toots[i] = newData
// }
// }
// )
// })
// return oldData
// })
return Promise.resolve()
},
onError: (err, variables, prevData) => {
queryCache.setQueryData(queryKey, prevData)
},
onSettled: () => {
queryCache.invalidateQueries(queryKey)
}
})
// causing full re-render
useEffect(() => {
setTimeout(() => {
setSince(relativeTime(created_at))
}, 1000)
}, [since])
return (
<View>
<View style={styles.nameAndAction}>
<View style={styles.name}>
{emojis ? (
<Emojis
content={name}
emojis={emojis}
size={constants.FONT_SIZE_M}
fontBold={true}
/>
) : (
<Text numberOfLines={1} style={{ color: theme.primary }}>
{name}
</Text>
)}
<Text
style={[styles.account, { color: theme.secondary }]}
numberOfLines={1}
>
@{account}
</Text>
</View>
{accountId !== localAccountId && domain !== localDomain && (
<Pressable
style={styles.action}
onPress={() => setModalVisible(true)}
>
<Feather
name='more-horizontal'
color={theme.secondary}
size={constants.FONT_SIZE_M + 2}
/>
</Pressable>
)}
</View>
<View style={styles.meta}>
<View>
<Text style={[styles.created_at, { color: theme.secondary }]}>
{since}
</Text>
</View>
{application && application.name !== 'Web' && (
<View>
<Text
onPress={() => {
navigation.navigate('Webview', {
uri: application.website
})
}}
style={[styles.application, { color: theme.secondary }]}
>
- {application.name}
</Text>
</View>
)}
</View>
<Modal
animationType='fade'
presentationStyle='overFullScreen'
transparent
visible={modalVisible}
>
<Pressable
style={styles.modalBackground}
onPress={() => setModalVisible(false)}
>
<View style={styles.modalSheet}>
{accountId !== localAccountId && (
<Pressable
onPress={() => {
setModalVisible(false)
mutateAction({
id: accountId,
type: 'mute',
stateKey: 'muting'
})
}}
>
<Text></Text>
</Pressable>
)}
{accountId !== localAccountId && (
<Pressable
onPress={() => {
setModalVisible(false)
mutateAction({
id: accountId,
type: 'block',
stateKey: 'blocking'
})
}}
>
<Text></Text>
</Pressable>
)}
{domain !== localDomain && (
<Pressable
onPress={() => {
setModalVisible(false)
mutateAction({
id: domain,
type: 'domain_blocks'
})
}}
>
<Text></Text>
</Pressable>
)}
{accountId !== localAccountId && (
<Pressable
onPress={() => {
setModalVisible(false)
mutateAction({
id: accountId,
type: 'reports'
})
}}
>
<Text></Text>
</Pressable>
)}
</View>
</Pressable>
</Modal>
</View>
)
}
const styles = StyleSheet.create({
nameAndAction: {
width: '100%',
flexDirection: 'row',
justifyContent: 'space-between'
},
name: {
flexBasis: '80%',
flexDirection: 'row'
},
action: {
flexBasis: '20%',
alignItems: 'center'
},
account: {
flexShrink: 1,
marginLeft: constants.SPACING_XS,
lineHeight: constants.FONT_SIZE_M + 2
},
meta: {
flexDirection: 'row',
marginTop: constants.SPACING_XS,
marginBottom: constants.SPACING_S
},
created_at: {
fontSize: constants.FONT_SIZE_S
},
application: {
fontSize: constants.FONT_SIZE_S,
marginLeft: constants.SPACING_S
},
modalBackground: {
width: '100%',
height: '100%',
backgroundColor: 'rgba(0, 0, 0, 0.75)',
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'flex-end'
},
modalSheet: {
width: '100%',
height: '50%',
backgroundColor: 'white',
flex: 1
}
})
export default HeaderDefault

View File

@ -0,0 +1,52 @@
import React from 'react'
import { StyleSheet, Text, View } from 'react-native'
import Emojis from './Emojis'
export interface Props {
poll: Mastodon.Poll
}
// When haven't voted, result should not be shown but intead let people vote
const Poll: React.FC<Props> = ({ poll }) => {
return (
<View>
{poll.options.map((option, index) => (
<View key={index}>
<View style={{ flexDirection: 'row' }}>
<Text>
{Math.round((option.votes_count / poll.votes_count) * 100)}%
</Text>
<Emojis
content={option.title}
emojis={poll.emojis}
size={14}
/>
</View>
<View
style={{
width: `${Math.round(
(option.votes_count / poll.votes_count) * 100
)}%`,
height: 5,
backgroundColor: 'blue'
}}
/>
</View>
))}
</View>
)
}
const styles = StyleSheet.create({
avatar: {
width: 50,
height: 50,
marginRight: 8
},
image: {
width: '100%',
height: '100%'
}
})
export default Poll