1
0
mirror of https://github.com/tooot-app/app synced 2025-04-03 21:21:01 +02:00

Rewrite account page

This commit is contained in:
Zhiyuan Zheng 2020-12-27 16:25:29 +01:00
parent 2fff322de7
commit c7cc3f1f27
No known key found for this signature in database
GPG Key ID: 078A93AB607D85E0
20 changed files with 785 additions and 466 deletions

View File

@ -46,7 +46,7 @@ const Button: React.FC<Props> = ({
}) => {
const { theme } = useTheme()
useLayoutEffect(() => layoutAnimation(), [loading, disabled])
useLayoutEffect(() => layoutAnimation(), [content, loading, disabled])
const loadingSpinkit = useMemo(
() => (

View File

@ -100,7 +100,9 @@ const Timeline: React.FC<Props> = ({
({ item, index }) => {
switch (page) {
case 'Conversations':
return <TimelineConversation item={item} queryKey={queryKey} />
return (
<TimelineConversation conversation={item} queryKey={queryKey} />
)
case 'Notifications':
return (
<TimelineNotifications notification={item} queryKey={queryKey} />

View File

@ -7,32 +7,55 @@ import TimelineHeaderConversation from '@components/Timelines/Timeline/Shared/He
import TimelineContent from '@components/Timelines/Timeline/Shared/Content'
import { StyleConstants } from '@utils/styles/constants'
import TimelineActions from '@components/Timelines/Timeline/Shared/Actions'
import client from '@root/api/client'
import { useMutation, useQueryClient } from 'react-query'
export interface Props {
item: Mastodon.Conversation
conversation: Mastodon.Conversation
queryKey: QueryKey.Timeline
highlighted?: boolean
}
// Unread and mark as unread
const fireMutation = async ({ id }: { id: Mastodon.Conversation['id'] }) => {
const res = await client({
method: 'post',
instance: 'local',
url: `conversations/${id}/read`
})
if (res.body.id === id) {
return Promise.resolve()
} else {
return Promise.reject()
}
}
const TimelineConversation: React.FC<Props> = ({
item,
conversation,
queryKey,
highlighted = false
}) => {
const queryClient = useQueryClient()
const { mutate } = useMutation(fireMutation, {
onSettled: () => {
queryClient.invalidateQueries(queryKey)
}
})
const navigation = useNavigation()
const conversationOnPress = useCallback(
() =>
item.last_status &&
const conversationOnPress = useCallback(() => {
if (conversation.last_status) {
conversation.unread && mutate({ id: conversation.id })
navigation.navigate('Screen-Shared-Toot', {
toot: item.last_status
}),
[]
)
toot: conversation.last_status
})
}
}, [])
const conversationChildren = useMemo(() => {
return (
item.last_status && (
conversation.last_status && (
<View
style={{
paddingTop: highlighted ? StyleConstants.Spacing.S : 0,
@ -42,7 +65,7 @@ const TimelineConversation: React.FC<Props> = ({
}}
>
<TimelineContent
status={item.last_status}
status={conversation.last_status}
highlighted={highlighted}
/>
</View>
@ -53,12 +76,13 @@ const TimelineConversation: React.FC<Props> = ({
return (
<View style={styles.conversationView}>
<View style={styles.header}>
<TimelineAvatar queryKey={queryKey} account={item.accounts[0]} />
<TimelineAvatar
queryKey={queryKey}
account={conversation.accounts[0]}
/>
<TimelineHeaderConversation
queryKey={queryKey}
id={item.id}
account={item.accounts[0]}
created_at={item.last_status?.created_at}
conversation={conversation}
/>
</View>
@ -76,7 +100,7 @@ const TimelineConversation: React.FC<Props> = ({
>
<TimelineActions
queryKey={queryKey}
status={item.last_status!}
status={conversation.last_status!}
reblog={false}
sameAccountRoot={false}
/>

View File

@ -12,9 +12,7 @@ import Emojis from '@components/Timelines/Timeline/Shared/Emojis'
export interface Props {
queryKey: QueryKey.Timeline
id: string
account: Mastodon.Account
created_at?: Mastodon.Status['created_at']
conversation: Mastodon.Conversation
}
const fireMutation = async ({ id }: { id: string }) => {
@ -37,12 +35,7 @@ const fireMutation = async ({ id }: { id: string }) => {
}
}
const HeaderConversation: React.FC<Props> = ({
queryKey,
id,
account,
created_at
}) => {
const HeaderConversation: React.FC<Props> = ({ queryKey, conversation }) => {
const queryClient = useQueryClient()
const { mutate } = useMutation(fireMutation, {
onMutate: () => {
@ -51,7 +44,9 @@ const HeaderConversation: React.FC<Props> = ({
queryClient.setQueryData(queryKey, (old: any) =>
old.pages.map((paging: any) => ({
toots: paging.toots.filter((toot: any) => toot.id !== id),
toots: paging.toots.filter(
(toot: Mastodon.Conversation) => toot.id !== conversation.id
),
pointer: paging.pointer
}))
)
@ -66,7 +61,7 @@ const HeaderConversation: React.FC<Props> = ({
const { theme } = useTheme()
const actionOnPress = useCallback(() => mutate({ id }), [])
const actionOnPress = useCallback(() => mutate({ id: conversation.id }), [])
const actionChildren = useMemo(
() => (
@ -83,10 +78,13 @@ const HeaderConversation: React.FC<Props> = ({
<View style={styles.base}>
<View style={styles.nameAndDate}>
<View style={styles.name}>
{account.emojis ? (
{conversation.accounts[0].emojis ? (
<Emojis
content={account.display_name || account.username}
emojis={account.emojis}
content={
conversation.accounts[0].display_name ||
conversation.accounts[0].username
}
emojis={conversation.accounts[0].emojis}
size={StyleConstants.Font.Size.M}
fontBold={true}
/>
@ -95,24 +93,27 @@ const HeaderConversation: React.FC<Props> = ({
numberOfLines={1}
style={[styles.nameWithoutEmoji, { color: theme.primary }]}
>
{account.display_name || account.username}
{conversation.accounts[0].display_name ||
conversation.accounts[0].username}
</Text>
)}
<Text
style={[styles.account, { color: theme.secondary }]}
numberOfLines={1}
>
@{account.acct}
@{conversation.accounts[0].acct}
</Text>
</View>
{created_at && (
<View style={styles.meta}>
<View style={styles.meta}>
{conversation.last_status?.created_at && (
<Text style={[styles.created_at, { color: theme.secondary }]}>
{relativeTime(created_at)}
{relativeTime(conversation.last_status?.created_at)}
</Text>
</View>
)}
)}
{conversation.unread && (
<Feather name='circle' color={theme.blue} style={styles.unread} />
)}
</View>
</View>
<Pressable
@ -152,6 +153,9 @@ const styles = StyleSheet.create({
created_at: {
fontSize: StyleConstants.Font.Size.S
},
unread: {
marginLeft: StyleConstants.Spacing.XS
},
action: {
flexBasis: '20%',
flexDirection: 'row',

View File

@ -15,7 +15,6 @@ import AccountNav from '../Shared/Account/Nav'
import layoutAnimation from '@root/utils/styles/layoutAnimation'
const ScreenMeRoot: React.FC = () => {
layoutAnimation()
const localRegistered = useSelector(getLocalUrl)
const scrollRef = useRef<ScrollView>(null)

View File

@ -138,6 +138,7 @@ const Login: React.FC = () => {
StyleConstants.Spacing.Global.PagePadding * 4
}
height={StyleConstants.Font.Size.M}
shimmerColors={theme.shimmer}
>
<ParseContent content={content!} size={'M'} numberOfLines={5} />
</ShimmerPlaceholder>
@ -217,6 +218,7 @@ const Login: React.FC = () => {
stopAutoRun
width={StyleConstants.Font.Size.M * 4}
height={StyleConstants.Font.Size.M}
shimmerColors={theme.shimmer}
>
<Text
style={[styles.instanceInfoContent, { color: theme.primary }]}
@ -236,6 +238,7 @@ const Login: React.FC = () => {
stopAutoRun
width={StyleConstants.Font.Size.M * 4}
height={StyleConstants.Font.Size.M}
shimmerColors={theme.shimmer}
>
<Text
style={[styles.instanceInfoContent, { color: theme.primary }]}
@ -255,6 +258,7 @@ const Login: React.FC = () => {
stopAutoRun
width={StyleConstants.Font.Size.M * 4}
height={StyleConstants.Font.Size.M}
shimmerColors={theme.shimmer}
>
<Text
style={[styles.instanceInfoContent, { color: theme.primary }]}

View File

@ -74,7 +74,6 @@ const ScreenSharedAccount: React.FC<Props> = ({
},
navigation
}) => {
layoutAnimation()
const localAccountId = useSelector(getLocalAccountId)
const { data } = useQuery(['Account', { id: account.id }], accountFetch)

View File

@ -1,3 +1,4 @@
import { useTheme } from '@root/utils/styles/ThemeManager'
import React, { Dispatch, useEffect, useState } from 'react'
import { Dimensions, Image, StyleSheet, View } from 'react-native'
import { AccountAction, AccountState } from '../Account'
@ -15,6 +16,7 @@ const AccountHeader: React.FC<Props> = ({
account,
limitHeight = false
}) => {
const { theme } = useTheme()
const [ratio, setRatio] = useState(accountState.headerRatio)
let isMounted = false
@ -43,23 +45,25 @@ const AccountHeader: React.FC<Props> = ({
setRatio(limitHeight ? accountState.headerRatio : height / width)
})
} else {
isMounted && setRatio(1 / 5)
isMounted && setRatio(1 / 3)
}
}, [account, isMounted])
const windowWidth = Dimensions.get('window').width
return (
<View style={[styles.imageContainer, { height: windowWidth * ratio }]}>
<View
style={{
height: windowWidth * ratio,
backgroundColor: theme.disabled
}}
>
<Image source={{ uri: account?.header }} style={styles.image} />
</View>
)
}
const styles = StyleSheet.create({
imageContainer: {
backgroundColor: 'lightgray'
},
image: {
width: '100%',
height: '100%'

View File

@ -1,50 +1,15 @@
import React, { createRef, Dispatch, useEffect, useMemo, useState } from 'react'
import { Animated, Image, StyleSheet, Text, View } from 'react-native'
import { createShimmerPlaceholder } from 'react-native-shimmer-placeholder'
import { Feather } from '@expo/vector-icons'
import ParseContent from '@components/ParseContent'
import { useTheme } from '@utils/styles/ThemeManager'
import { StyleConstants } from '@utils/styles/constants'
import { useTranslation } from 'react-i18next'
import Emojis from '@components/Timelines/Timeline/Shared/Emojis'
import { LinearGradient } from 'expo-linear-gradient'
import React, { createRef, Dispatch, useCallback, useEffect } from 'react'
import { Animated, StyleSheet, View } from 'react-native'
import AccountInformationAvatar from './Information/Avatar'
import AccountInformationName from './Information/Name'
import AccountInformationAccount from './Information/Account'
import AccountInformationCreated from './Information/Created'
import AccountInformationStats from './Information/Stats'
import AccountInformationActions from './Information/Actions'
import AccountInformationFields from './Information/Fields'
import AccountInformationNotes from './Information/Notes'
import { AccountAction } from '../Account'
import Button from '@root/components/Button'
import { useMutation, useQuery, useQueryClient } from 'react-query'
import { relationshipFetch } from '@root/utils/fetches/relationshipFetch'
import client from '@root/api/client'
import { useNavigation } from '@react-navigation/native'
import getCurrentTab from '@root/utils/getCurrentTab'
const fireMutation = async ({
type,
id,
stateKey,
prevState
}: {
type: 'follow'
id: string
stateKey: 'following'
prevState: boolean
}) => {
let res
switch (type) {
case 'follow':
res = await client({
method: 'post',
instance: 'local',
url: `accounts/${id}/${prevState ? 'un' : ''}${type}`
})
if (res.body[stateKey] === !prevState) {
return Promise.resolve()
} else {
return Promise.reject()
}
break
}
}
export interface Props {
accountDispatch?: Dispatch<AccountAction>
@ -57,325 +22,63 @@ const AccountInformation: React.FC<Props> = ({
account,
disableActions = false
}) => {
const navigation = useNavigation()
const { t } = useTranslation('sharedAccount')
const { theme } = useTheme()
const [avatarLoaded, setAvatarLoaded] = useState(false)
const relationshipQueryKey = ['Relationship', { id: account?.id }]
const { status, data, refetch } = useQuery(
relationshipQueryKey,
relationshipFetch,
{
enabled: false
}
)
useEffect(() => {
if (account?.id) {
refetch()
}
}, [account])
const queryClient = useQueryClient()
const { mutate, status: mutateStatus } = useMutation(fireMutation, {
onMutate: () => {
queryClient.cancelQueries(relationshipQueryKey)
const oldData = queryClient.getQueryData(relationshipQueryKey)
queryClient.setQueryData(relationshipQueryKey, (old: any) => {
old && (old.following = !old?.following)
return old
})
return oldData
},
onError: (err, _, oldData) => {
queryClient.setQueryData(relationshipQueryKey, oldData)
},
onSettled: () => {
queryClient.invalidateQueries(['Following'])
}
})
const ShimmerPlaceholder = createShimmerPlaceholder(LinearGradient)
const shimmerAvatarRef = createRef<any>()
const shimmerNameRef = createRef<any>()
const shimmerAccountRef = createRef<any>()
const shimmerCreatedRef = createRef<any>()
const shimmerStatTootRef = createRef<any>()
const shimmerStatFolloingRef = createRef<any>()
const shimmerStatFollowerRef = createRef<any>()
const shimmerStatsRef = createRef<any>()
useEffect(() => {
const informationAnimated = Animated.stagger(400, [
Animated.parallel([
shimmerAvatarRef.current!.getAnimated(),
shimmerNameRef.current!.getAnimated(),
shimmerAccountRef.current!.getAnimated(),
shimmerCreatedRef.current!.getAnimated(),
shimmerStatTootRef.current!.getAnimated(),
shimmerStatFolloingRef.current!.getAnimated(),
shimmerStatFollowerRef.current!.getAnimated()
shimmerAvatarRef.current?.getAnimated(),
shimmerNameRef.current?.getAnimated(),
shimmerAccountRef.current?.getAnimated(),
shimmerCreatedRef.current?.getAnimated(),
shimmerStatsRef.current?.ref1.getAnimated(),
shimmerStatsRef.current?.ref2.getAnimated(),
shimmerStatsRef.current?.ref3.getAnimated()
])
])
Animated.loop(informationAnimated).start()
}, [])
const followingButton = useMemo(
() => (
<Button
type='text'
content={`${data?.following ? '正在' : ''}关注`}
onPress={() =>
mutate({
type: 'follow',
id: account!.id,
stateKey: 'following',
prevState: data!.following
})
const onLayout = useCallback(
({ nativeEvent }) =>
accountDispatch &&
accountDispatch({
type: 'informationLayout',
payload: {
y: nativeEvent.layout.y,
height: nativeEvent.layout.height
}
loading={
status !== 'success' ||
(mutateStatus !== 'success' && mutateStatus !== 'idle')
}
/>
),
[data, status, mutateStatus]
}),
[]
)
return (
<View
style={styles.base}
onLayout={({ nativeEvent }) =>
accountDispatch &&
accountDispatch({
type: 'informationLayout',
payload: {
y: nativeEvent.layout.y,
height: nativeEvent.layout.height
}
})
}
>
<View style={styles.base} onLayout={onLayout}>
{/* <Text>Moved or not: {account.moved}</Text> */}
<View style={styles.avatarAndActions}>
<ShimmerPlaceholder
ref={shimmerAvatarRef}
visible={avatarLoaded}
width={StyleConstants.Avatar.L}
height={StyleConstants.Avatar.L}
>
<Image
source={{ uri: account?.avatar }}
style={styles.avatar}
onLoadEnd={() => setAvatarLoaded(true)}
/>
</ShimmerPlaceholder>
{!disableActions && (
<View style={styles.actions}>
<Button
type='icon'
content='mail'
round
onPress={() =>
navigation.navigate(getCurrentTab(navigation), {
screen: 'Screen-Shared-Compose',
params: {
type: 'conversation',
incomingStatus: { account }
}
})
}
style={{ marginRight: StyleConstants.Spacing.S }}
/>
{followingButton}
</View>
)}
<AccountInformationAvatar ref={shimmerAvatarRef} account={account} />
{!disableActions && <AccountInformationActions account={account} />}
</View>
<ShimmerPlaceholder
ref={shimmerNameRef}
visible={account !== undefined}
width={StyleConstants.Font.Size.L * 8}
height={StyleConstants.Font.Size.L}
style={styles.display_name}
>
<View>
{account?.emojis ? (
<Emojis
content={account?.display_name || account?.username}
emojis={account.emojis}
size={StyleConstants.Font.Size.L}
fontBold={true}
/>
) : (
<Text
style={{
color: theme.primary,
fontSize: StyleConstants.Font.Size.L,
fontWeight: StyleConstants.Font.Weight.Bold
}}
>
{account?.display_name || account?.username}
</Text>
)}
</View>
</ShimmerPlaceholder>
<AccountInformationName ref={shimmerNameRef} account={account} />
<ShimmerPlaceholder
ref={shimmerAccountRef}
visible={account !== undefined}
width={StyleConstants.Font.Size.M * 8}
height={StyleConstants.Font.Size.M}
style={{ marginBottom: StyleConstants.Spacing.L }}
>
<View style={styles.account}>
<Text
style={{
color: theme.secondary,
fontSize: StyleConstants.Font.Size.M
}}
selectable
>
@{account?.acct}
</Text>
{account?.locked && (
<Feather
name='lock'
style={styles.account_types}
color={theme.secondary}
/>
)}
{account?.bot && (
<Feather
name='hard-drive'
style={styles.account_types}
color={theme.secondary}
/>
)}
</View>
</ShimmerPlaceholder>
<AccountInformationAccount ref={shimmerAccountRef} account={account} />
{account?.fields && account.fields.length > 0 && (
<View style={[styles.fields, { borderTopColor: theme.border }]}>
{account.fields.map((field, index) => (
<View
key={index}
style={[styles.field, { borderBottomColor: theme.border }]}
>
<View
style={[styles.fieldLeft, { borderRightColor: theme.border }]}
>
<ParseContent
content={field.name}
size={'M'}
emojis={account.emojis}
showFullLink
/>
{field.verified_at && (
<Feather
name='check-circle'
size={StyleConstants.Font.Size.M}
color={theme.primary}
style={styles.fieldCheck}
/>
)}
</View>
<View style={styles.fieldRight}>
<ParseContent
content={field.value}
size={'M'}
emojis={account.emojis}
showFullLink
/>
</View>
</View>
))}
</View>
<AccountInformationFields account={account} />
)}
{account?.note && account.note.length && account.note !== '<p></p>' ? (
{account?.note && account.note.length > 0 && account.note !== '<p></p>' && (
// Empty notes might generate empty p tag
<View style={styles.note}>
<ParseContent
content={account.note}
size={'M'}
emojis={account.emojis}
/>
</View>
) : null}
<AccountInformationNotes account={account} />
)}
<ShimmerPlaceholder
ref={shimmerCreatedRef}
visible={account !== undefined}
width={StyleConstants.Font.Size.S * 8}
height={StyleConstants.Font.Size.S}
style={{ marginBottom: StyleConstants.Spacing.M }}
>
<View style={styles.created_at}>
<Feather
name='calendar'
size={StyleConstants.Font.Size.S}
color={theme.secondary}
style={styles.created_at_icon}
/>
<Text
style={{
color: theme.secondary,
fontSize: StyleConstants.Font.Size.S
}}
>
{t('content.created_at', {
date: new Date(account?.created_at!).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
})}
</Text>
</View>
</ShimmerPlaceholder>
<AccountInformationCreated ref={shimmerCreatedRef} account={account} />
<View style={styles.stats}>
<ShimmerPlaceholder
ref={shimmerStatTootRef}
visible={account !== undefined}
width={StyleConstants.Font.Size.S * 5}
height={StyleConstants.Font.Size.S}
>
<Text style={[styles.stat, { color: theme.primary }]}>
{t('content.summary.statuses_count', {
count: account?.statuses_count || 0
})}
</Text>
</ShimmerPlaceholder>
<ShimmerPlaceholder
ref={shimmerStatFolloingRef}
visible={account !== undefined}
width={StyleConstants.Font.Size.S * 5}
height={StyleConstants.Font.Size.S}
>
<Text
style={[styles.stat, { color: theme.primary, textAlign: 'center' }]}
>
{t('content.summary.followers_count', {
count: account?.followers_count || 0
})}
</Text>
</ShimmerPlaceholder>
<ShimmerPlaceholder
ref={shimmerStatFollowerRef}
visible={account !== undefined}
width={StyleConstants.Font.Size.S * 5}
height={StyleConstants.Font.Size.S}
>
<Text
style={[styles.stat, { color: theme.primary, textAlign: 'right' }]}
>
{t('content.summary.following_count', {
count: account?.following_count || 0
})}
</Text>
</ShimmerPlaceholder>
</View>
<AccountInformationStats ref={shimmerStatsRef} account={account} />
</View>
)
}
@ -388,71 +91,6 @@ const styles = StyleSheet.create({
avatarAndActions: {
flexDirection: 'row',
justifyContent: 'space-between'
},
avatar: {
width: StyleConstants.Avatar.L,
height: StyleConstants.Avatar.L,
borderRadius: 8
},
actions: {
alignSelf: 'flex-end',
flexDirection: 'row'
},
display_name: {
flexDirection: 'row',
marginTop: StyleConstants.Spacing.M,
marginBottom: StyleConstants.Spacing.XS
},
account: {
flexDirection: 'row',
alignItems: 'center'
},
account_types: { marginLeft: StyleConstants.Spacing.S },
fields: {
borderTopWidth: StyleSheet.hairlineWidth,
marginBottom: StyleConstants.Spacing.M
},
field: {
flex: 1,
flexDirection: 'row',
borderBottomWidth: StyleSheet.hairlineWidth,
paddingTop: StyleConstants.Spacing.S,
paddingBottom: StyleConstants.Spacing.S
},
fieldLeft: {
flexBasis: '30%',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRightWidth: 1,
paddingLeft: StyleConstants.Spacing.S,
paddingRight: StyleConstants.Spacing.S
},
fieldCheck: { marginLeft: StyleConstants.Spacing.XS },
fieldRight: {
flexBasis: '70%',
alignItems: 'center',
justifyContent: 'center',
paddingLeft: StyleConstants.Spacing.S,
paddingRight: StyleConstants.Spacing.S
},
note: {
marginBottom: StyleConstants.Spacing.L
},
created_at: {
flexDirection: 'row',
alignItems: 'center'
},
created_at_icon: {
marginRight: StyleConstants.Spacing.XS
},
stats: {
flex: 1,
flexDirection: 'row',
justifyContent: 'space-between'
},
stat: {
fontSize: StyleConstants.Font.Size.S
}
})

View File

@ -0,0 +1,64 @@
import { Feather } from '@expo/vector-icons'
import { StyleConstants } from '@root/utils/styles/constants'
import { useTheme } from '@root/utils/styles/ThemeManager'
import { LinearGradient } from 'expo-linear-gradient'
import React, { forwardRef } from 'react'
import { StyleSheet, Text, View } from 'react-native'
import ShimmerPlaceholder, {
createShimmerPlaceholder
} from 'react-native-shimmer-placeholder'
export interface Props {
account: Mastodon.Account | undefined
}
const AccountInformationAccount = forwardRef<ShimmerPlaceholder, Props>(
({ account }, ref) => {
const { theme } = useTheme()
const ShimmerPlaceholder = createShimmerPlaceholder(LinearGradient)
return (
<ShimmerPlaceholder
ref={ref}
visible={account?.acct !== undefined}
width={StyleConstants.Font.Size.M * 8}
height={StyleConstants.Font.Size.M}
style={{ marginBottom: StyleConstants.Spacing.L }}
shimmerColors={theme.shimmer}
>
<View style={styles.account}>
<Text
style={{
color: theme.secondary,
fontSize: StyleConstants.Font.Size.M
}}
selectable
>
@{account?.acct}
</Text>
{account?.locked && (
<Feather name='lock' style={styles.type} color={theme.secondary} />
)}
{account?.bot && (
<Feather
name='hard-drive'
style={styles.type}
color={theme.secondary}
/>
)}
</View>
</ShimmerPlaceholder>
)
}
)
const styles = StyleSheet.create({
account: {
flexDirection: 'row',
alignItems: 'center'
},
type: { marginLeft: StyleConstants.Spacing.S }
})
export default AccountInformationAccount

View File

@ -0,0 +1,160 @@
import { useNavigation } from '@react-navigation/native'
import client from '@root/api/client'
import Button from '@root/components/Button'
import { toast } from '@root/components/toast'
import { relationshipFetch } from '@root/utils/fetches/relationshipFetch'
import getCurrentTab from '@root/utils/getCurrentTab'
import { StyleConstants } from '@root/utils/styles/constants'
import { useTheme } from '@root/utils/styles/ThemeManager'
import React, { useEffect, useMemo } from 'react'
import { StyleSheet, View } from 'react-native'
import { useMutation, useQuery, useQueryClient } from 'react-query'
export interface Props {
account: Mastodon.Account | undefined
}
const fireMutation = async ({
type,
id,
prevState
}: {
type: 'follow' | 'block'
id: string
prevState: boolean
}) => {
let res
switch (type) {
case 'follow':
case 'block':
res = await client({
method: 'post',
instance: 'local',
url: `accounts/${id}/${prevState ? 'un' : ''}${type}`
})
if (res.body.id === id) {
return Promise.resolve(res.body)
} else {
return Promise.reject()
}
}
}
const AccountInformationActions: React.FC<Props> = ({ account }) => {
const { theme } = useTheme()
const navigation = useNavigation()
const relationshipQueryKey = ['Relationship', { id: account?.id }]
const query = useQuery(relationshipQueryKey, relationshipFetch, {
enabled: false
})
useEffect(() => {
if (account?.id) {
query.refetch()
}
}, [account])
const queryClient = useQueryClient()
const mutation = useMutation(fireMutation, {
onSuccess: data => queryClient.setQueryData(relationshipQueryKey, data),
onError: () => toast({ type: 'error', content: '关注失败,请重试' })
})
const mainAction = useMemo(() => {
let content: string
let onPress: () => void
if (query.isError) {
content = '读取错误'
onPress = () => {}
} else {
if (query.data?.blocked_by) {
content = '被用户屏蔽'
onPress = () => null
} else {
if (query.data?.blocking) {
content = '取消屏蔽'
onPress = () =>
mutation.mutate({
type: 'block',
id: account!.id,
prevState: query.data?.blocking
})
} else {
if (query.data?.following) {
content = '取消关注'
onPress = () =>
mutation.mutate({
type: 'follow',
id: account!.id,
prevState: query.data?.following
})
} else {
if (query.data?.requested) {
content = '取消关注请求'
onPress = () =>
mutation.mutate({
type: 'follow',
id: account!.id,
prevState: query.data?.requested
})
} else {
content = '关注'
onPress = () =>
mutation.mutate({
type: 'follow',
id: account!.id,
prevState: false
})
}
}
}
}
}
return (
<Button
type='text'
content={content}
onPress={onPress}
loading={query.isLoading || mutation.isLoading}
disabled={query.isError || query.data?.blocked_by}
/>
)
}, [theme, query, mutation])
return (
<View style={styles.actions}>
{query.data && !query.data.blocked_by && (
<Button
type='icon'
content='mail'
round
onPress={() =>
navigation.navigate(getCurrentTab(navigation), {
screen: 'Screen-Shared-Compose',
params: {
type: 'conversation',
incomingStatus: { account }
}
})
}
style={styles.actionConversation}
/>
)}
{mainAction}
</View>
)
}
const styles = StyleSheet.create({
actions: {
alignSelf: 'flex-end',
flexDirection: 'row'
},
actionConversation: { marginRight: StyleConstants.Spacing.S },
error: {
fontSize: StyleConstants.Font.Size.S
}
})
export default AccountInformationActions

View File

@ -0,0 +1,47 @@
import { StyleConstants } from '@root/utils/styles/constants'
import { useTheme } from '@root/utils/styles/ThemeManager'
import { LinearGradient } from 'expo-linear-gradient'
import React, { forwardRef, useState } from 'react'
import { Image, StyleSheet } from 'react-native'
import ShimmerPlaceholder, {
createShimmerPlaceholder
} from 'react-native-shimmer-placeholder'
export interface Props {
account: Mastodon.Account | undefined
}
const AccountInformationAvatar = forwardRef<ShimmerPlaceholder, Props>(
({ account }, ref) => {
const { theme } = useTheme()
const [avatarLoaded, setAvatarLoaded] = useState(false)
const ShimmerPlaceholder = createShimmerPlaceholder(LinearGradient)
return (
<ShimmerPlaceholder
ref={ref}
visible={avatarLoaded}
width={StyleConstants.Avatar.L}
height={StyleConstants.Avatar.L}
shimmerColors={theme.shimmer}
>
<Image
source={{ uri: account?.avatar }}
style={styles.avatar}
onLoadEnd={() => setAvatarLoaded(true)}
/>
</ShimmerPlaceholder>
)
}
)
const styles = StyleSheet.create({
avatar: {
width: StyleConstants.Avatar.L,
height: StyleConstants.Avatar.L,
borderRadius: 8
}
})
export default AccountInformationAvatar

View File

@ -0,0 +1,68 @@
import { Feather } from '@expo/vector-icons'
import { StyleConstants } from '@root/utils/styles/constants'
import { useTheme } from '@root/utils/styles/ThemeManager'
import { LinearGradient } from 'expo-linear-gradient'
import React, { forwardRef } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, Text, View } from 'react-native'
import ShimmerPlaceholder, {
createShimmerPlaceholder
} from 'react-native-shimmer-placeholder'
export interface Props {
account: Mastodon.Account | undefined
}
const AccountInformationCreated = forwardRef<ShimmerPlaceholder, Props>(
({ account }, ref) => {
const { theme } = useTheme()
const { t } = useTranslation('sharedAccount')
const ShimmerPlaceholder = createShimmerPlaceholder(LinearGradient)
return (
<ShimmerPlaceholder
ref={ref}
visible={account?.created_at !== undefined}
width={StyleConstants.Font.Size.S * 8}
height={StyleConstants.Font.Size.S}
style={{ marginBottom: StyleConstants.Spacing.M }}
shimmerColors={theme.shimmer}
>
<View style={styles.created}>
<Feather
name='calendar'
size={StyleConstants.Font.Size.S}
color={theme.secondary}
style={styles.icon}
/>
<Text
style={{
color: theme.secondary,
fontSize: StyleConstants.Font.Size.S
}}
>
{t('content.created_at', {
date: new Date(account?.created_at!).toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
})}
</Text>
</View>
</ShimmerPlaceholder>
)
}
)
const styles = StyleSheet.create({
created: {
flexDirection: 'row',
alignItems: 'center'
},
icon: {
marginRight: StyleConstants.Spacing.XS
}
})
export default AccountInformationCreated

View File

@ -0,0 +1,83 @@
import { Feather } from '@expo/vector-icons'
import ParseContent from '@root/components/ParseContent'
import { StyleConstants } from '@root/utils/styles/constants'
import { useTheme } from '@root/utils/styles/ThemeManager'
import React from 'react'
import { StyleSheet, View } from 'react-native'
export interface Props {
account: Mastodon.Account
}
const AccountInformationFields: React.FC<Props> = ({ account }) => {
const { theme } = useTheme()
return (
<View style={[styles.fields, { borderTopColor: theme.border }]}>
{account.fields.map((field, index) => (
<View
key={index}
style={[styles.field, { borderBottomColor: theme.border }]}
>
<View style={[styles.fieldLeft, { borderRightColor: theme.border }]}>
<ParseContent
content={field.name}
size={'M'}
emojis={account.emojis}
showFullLink
/>
{field.verified_at && (
<Feather
name='check-circle'
size={StyleConstants.Font.Size.M}
color={theme.primary}
style={styles.fieldCheck}
/>
)}
</View>
<View style={styles.fieldRight}>
<ParseContent
content={field.value}
size={'M'}
emojis={account.emojis}
showFullLink
/>
</View>
</View>
))}
</View>
)
}
const styles = StyleSheet.create({
fields: {
borderTopWidth: StyleSheet.hairlineWidth,
marginBottom: StyleConstants.Spacing.M
},
field: {
flex: 1,
flexDirection: 'row',
borderBottomWidth: StyleSheet.hairlineWidth,
paddingTop: StyleConstants.Spacing.S,
paddingBottom: StyleConstants.Spacing.S
},
fieldLeft: {
flexBasis: '30%',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
borderRightWidth: 1,
paddingLeft: StyleConstants.Spacing.S,
paddingRight: StyleConstants.Spacing.S
},
fieldCheck: { marginLeft: StyleConstants.Spacing.XS },
fieldRight: {
flexBasis: '70%',
alignItems: 'center',
justifyContent: 'center',
paddingLeft: StyleConstants.Spacing.S,
paddingRight: StyleConstants.Spacing.S
}
})
export default AccountInformationFields

View File

@ -0,0 +1,64 @@
import Emojis from '@root/components/Timelines/Timeline/Shared/Emojis'
import { StyleConstants } from '@root/utils/styles/constants'
import { useTheme } from '@root/utils/styles/ThemeManager'
import { LinearGradient } from 'expo-linear-gradient'
import React, { forwardRef } from 'react'
import { StyleSheet, Text, View } from 'react-native'
import ShimmerPlaceholder, {
createShimmerPlaceholder
} from 'react-native-shimmer-placeholder'
export interface Props {
account: Mastodon.Account | undefined
}
const AccountInformationName = forwardRef<ShimmerPlaceholder, Props>(
({ account }, ref) => {
const { theme } = useTheme()
const ShimmerPlaceholder = createShimmerPlaceholder(LinearGradient)
return (
<ShimmerPlaceholder
ref={ref}
visible={
account?.display_name !== undefined || account?.username !== undefined
}
width={StyleConstants.Font.Size.L * 8}
height={StyleConstants.Font.Size.L}
style={styles.name}
shimmerColors={theme.shimmer}
>
<View>
{account?.emojis ? (
<Emojis
content={account?.display_name || account?.username}
emojis={account.emojis}
size={StyleConstants.Font.Size.L}
fontBold={true}
/>
) : (
<Text
style={{
color: theme.primary,
fontSize: StyleConstants.Font.Size.L,
fontWeight: StyleConstants.Font.Weight.Bold
}}
>
{account?.display_name || account?.username}
</Text>
)}
</View>
</ShimmerPlaceholder>
)
}
)
const styles = StyleSheet.create({
name: {
flexDirection: 'row',
marginTop: StyleConstants.Spacing.M,
marginBottom: StyleConstants.Spacing.XS
}
})
export default AccountInformationName

View File

@ -0,0 +1,28 @@
import ParseContent from '@root/components/ParseContent'
import { StyleConstants } from '@root/utils/styles/constants'
import React from 'react'
import { StyleSheet, View } from 'react-native'
export interface Props {
account: Mastodon.Account
}
const AccountInformationNotes: React.FC<Props> = ({ account }) => {
return (
<View style={styles.note}>
<ParseContent
content={account.note!}
size={'M'}
emojis={account.emojis}
/>
</View>
)
}
const styles = StyleSheet.create({
note: {
marginBottom: StyleConstants.Spacing.L
}
})
export default AccountInformationNotes

View File

@ -0,0 +1,95 @@
import { StyleConstants } from '@root/utils/styles/constants'
import { useTheme } from '@root/utils/styles/ThemeManager'
import { LinearGradient } from 'expo-linear-gradient'
import React, { createRef, forwardRef, useImperativeHandle } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, Text, View } from 'react-native'
import ShimmerPlaceholder, {
createShimmerPlaceholder
} from 'react-native-shimmer-placeholder'
export interface Props {
account: Mastodon.Account | undefined
}
const AccountInformationStats = forwardRef<any, Props>(({ account }, ref) => {
const { theme } = useTheme()
const { t } = useTranslation('sharedAccount')
const ShimmerPlaceholder = createShimmerPlaceholder(LinearGradient)
const ref1 = createRef<ShimmerPlaceholder>()
const ref2 = createRef<ShimmerPlaceholder>()
const ref3 = createRef<ShimmerPlaceholder>()
useImperativeHandle(ref, () => ({
get ref1 () {
return ref1.current
},
get ref2 () {
return ref2.current
},
get ref3 () {
return ref3.current
}
}))
return (
<View style={styles.stats}>
<ShimmerPlaceholder
ref={ref1}
visible={account !== undefined}
width={StyleConstants.Font.Size.S * 5}
height={StyleConstants.Font.Size.S}
shimmerColors={theme.shimmer}
>
<Text style={[styles.stat, { color: theme.primary }]}>
{t('content.summary.statuses_count', {
count: account?.statuses_count || 0
})}
</Text>
</ShimmerPlaceholder>
<ShimmerPlaceholder
ref={ref2}
visible={account !== undefined}
width={StyleConstants.Font.Size.S * 5}
height={StyleConstants.Font.Size.S}
shimmerColors={theme.shimmer}
>
<Text
style={[styles.stat, { color: theme.primary, textAlign: 'center' }]}
>
{t('content.summary.followers_count', {
count: account?.followers_count || 0
})}
</Text>
</ShimmerPlaceholder>
<ShimmerPlaceholder
ref={ref3}
visible={account !== undefined}
width={StyleConstants.Font.Size.S * 5}
height={StyleConstants.Font.Size.S}
shimmerColors={theme.shimmer}
>
<Text
style={[styles.stat, { color: theme.primary, textAlign: 'right' }]}
>
{t('content.summary.following_count', {
count: account?.following_count || 0
})}
</Text>
</ShimmerPlaceholder>
</View>
)
})
const styles = StyleSheet.create({
stats: {
flex: 1,
flexDirection: 'row',
justifyContent: 'space-between'
},
stat: {
fontSize: StyleConstants.Font.Size.S
}
})
export default AccountInformationStats

View File

@ -63,7 +63,7 @@ const ScreenSharedAnnouncements: React.FC = ({
showAll ? announcement : !announcement.read
)
})
const { mutate } = useMutation(fireMutation, {
const queryMutation = useMutation(fireMutation, {
onSettled: () => {
refetch()
}
@ -77,7 +77,13 @@ const ScreenSharedAnnouncements: React.FC = ({
const renderItem = useCallback(
({ item, index }: { item: Mastodon.Announcement; index: number }) => (
<View key={index} style={styles.announcementContainer}>
<View
key={index}
style={[
styles.announcementContainer,
{ backgroundColor: theme.background }
]}
>
<Pressable
style={styles.pressable}
onPress={() => navigation.goBack()}
@ -108,9 +114,17 @@ const ScreenSharedAnnouncements: React.FC = ({
{item.reactions?.map(reaction => (
<Pressable
key={reaction.name}
style={[styles.reaction, { borderColor: theme.primary }]}
style={[
styles.reaction,
{
borderColor: reaction.me ? theme.disabled : theme.primary,
backgroundColor: reaction.me
? theme.disabled
: theme.background
}
]}
onPress={() =>
mutate({
queryMutation.mutate({
announcementId: item.id,
type: 'reaction',
name: reaction.name,
@ -149,14 +163,21 @@ const ScreenSharedAnnouncements: React.FC = ({
) : null}
<Button
type='text'
content='标记已读'
onPress={() => mutate({ type: 'dismiss', announcementId: item.id })}
style={styles.button}
content={item.read ? '已读' : '标记阅读'}
loading={queryMutation.isLoading}
disabled={item.read}
onPress={() =>
!item.read &&
queryMutation.mutate({
type: 'dismiss',
announcementId: item.id
})
}
/>
</View>
</View>
),
[]
[theme]
)
const onMomentumScrollEnd = useCallback(
@ -240,9 +261,13 @@ const styles = StyleSheet.create({
scrollView: {
marginBottom: StyleConstants.Spacing.Global.PagePadding / 2
},
reactions: { flexDirection: 'row', flexWrap: 'wrap' },
reactions: {
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: StyleConstants.Spacing.Global.PagePadding / 2
},
reaction: {
borderWidth: StyleSheet.hairlineWidth,
borderWidth: 1,
padding: StyleConstants.Spacing.Global.PagePadding / 2,
marginTop: StyleConstants.Spacing.Global.PagePadding / 2,
marginBottom: StyleConstants.Spacing.Global.PagePadding / 2,
@ -261,9 +286,6 @@ const styles = StyleSheet.create({
fontSize: StyleConstants.Font.Size.S,
marginLeft: StyleConstants.Spacing.S
},
button: {
marginTop: StyleConstants.Spacing.Global.PagePadding / 2
},
indicators: {
flexDirection: 'row',
justifyContent: 'center',

View File

@ -117,6 +117,7 @@ const ComposeAttachments: React.FC = () => {
DEFAULT_HEIGHT
}
height={200}
shimmerColors={theme.shimmer}
>
{composeState.attachments.uploads.length > 0 &&
composeState.attachments.uploads[0].type === 'image' &&

View File

@ -12,6 +12,7 @@ export type ColorDefinitions =
| 'backgroundGradientEnd'
| 'backgroundOverlay'
| 'border'
| 'shimmer'
const themeColors: {
[key in ColorDefinitions]: {
@ -63,6 +64,18 @@ const themeColors: {
border: {
light: 'rgba(18, 18, 18, 0.3)',
dark: 'rgba(255, 255, 255, 0.3)'
},
shimmer: {
light: [
'rgba(18, 18, 18, 0.05)',
'rgba(18, 18, 18, 0.15)',
'rgba(18, 18, 18, 0.05)'
],
dark: [
'rgba(250, 250, 250, 0.05)',
'rgba(250, 250, 250, 0.15)',
'rgba(250, 250, 250, 0.05)'
]
}
}
@ -83,10 +96,10 @@ const themes = {
...DefaultTheme.colors,
primary: themeColors.primary.light,
background: themeColors.background.light,
card: themeColors.background.light || 'rgba(249, 249, 249, 0.94)',
card: themeColors.background.light,
text: themeColors.primary.light,
border: themeColors.border.light,
notification: 'rgb(255, 59, 48)'
notification: themeColors.red.light
}
},
dark: {
@ -95,10 +108,10 @@ const themes = {
...DarkTheme.colors,
primary: themeColors.primary.dark,
background: themeColors.background.dark,
card: themeColors.background.dark || 'rgba(22, 22, 22, 0.94)',
card: themeColors.background.dark,
text: themeColors.primary.dark,
border: themeColors.border.dark,
notification: 'rgb(255, 69, 58)'
notification: themeColors.red.dark
}
}
}