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:
119
src/components/Timelines/Timeline.tsx
Normal file
119
src/components/Timelines/Timeline.tsx
Normal 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
|
75
src/components/Timelines/Timeline/Conversation.tsx
Normal file
75
src/components/Timelines/Timeline/Conversation.tsx
Normal 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
|
117
src/components/Timelines/Timeline/Default.tsx
Normal file
117
src/components/Timelines/Timeline/Default.tsx
Normal 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
|
113
src/components/Timelines/Timeline/Notifications.tsx
Normal file
113
src/components/Timelines/Timeline/Notifications.tsx
Normal 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
|
20
src/components/Timelines/Timeline/Separator.tsx
Normal file
20
src/components/Timelines/Timeline/Separator.tsx
Normal 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
|
111
src/components/Timelines/Timeline/Shared/Actioned.tsx
Normal file
111
src/components/Timelines/Timeline/Shared/Actioned.tsx
Normal 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
|
380
src/components/Timelines/Timeline/Shared/ActionsStatus.tsx
Normal file
380
src/components/Timelines/Timeline/Shared/ActionsStatus.tsx
Normal 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
|
87
src/components/Timelines/Timeline/Shared/Attachment.tsx
Normal file
87
src/components/Timelines/Timeline/Shared/Attachment.tsx
Normal 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
|
@ -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
|
@ -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
|
42
src/components/Timelines/Timeline/Shared/Avatar.tsx
Normal file
42
src/components/Timelines/Timeline/Shared/Avatar.tsx
Normal 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
|
59
src/components/Timelines/Timeline/Shared/Card.tsx
Normal file
59
src/components/Timelines/Timeline/Shared/Card.tsx
Normal 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
|
61
src/components/Timelines/Timeline/Shared/Content.tsx
Normal file
61
src/components/Timelines/Timeline/Shared/Content.tsx
Normal 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
|
68
src/components/Timelines/Timeline/Shared/Emojis.tsx
Normal file
68
src/components/Timelines/Timeline/Shared/Emojis.tsx
Normal 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
|
@ -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
|
370
src/components/Timelines/Timeline/Shared/HeaderDefault.tsx
Normal file
370
src/components/Timelines/Timeline/Shared/HeaderDefault.tsx
Normal 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
|
52
src/components/Timelines/Timeline/Shared/Poll.tsx
Normal file
52
src/components/Timelines/Timeline/Shared/Poll.tsx
Normal 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
|
Reference in New Issue
Block a user