1
0
mirror of https://github.com/tooot-app/app synced 2025-04-15 10:47:46 +02:00

Translated Timeline components

This commit is contained in:
Zhiyuan Zheng 2021-01-01 23:10:47 +01:00
parent d5ccc95704
commit ea465c828a
No known key found for this signature in database
GPG Key ID: 078A93AB607D85E0
31 changed files with 729 additions and 561 deletions

View File

@ -1,4 +1,3 @@
import 'react-native-gesture-handler'
import NetInfo from '@react-native-community/netinfo' import NetInfo from '@react-native-community/netinfo'
import client from '@root/api/client' import client from '@root/api/client'
import Index from '@root/Index' import Index from '@root/Index'
@ -173,4 +172,4 @@ const App: React.FC = () => {
) )
} }
export default App export default React.memo(App, () => true)

View File

@ -70,7 +70,7 @@ const Index: React.FC<Props> = ({ localCorrupt }) => {
const showLocalCorrect = localCorrupt const showLocalCorrect = localCorrupt
? toast({ ? toast({
type: 'error', type: 'error',
content: '登录已过期', message: '登录已过期',
description: '请重新登录', description: '请重新登录',
autoHide: false autoHide: false
}) })
@ -253,7 +253,6 @@ const Index: React.FC<Props> = ({ localCorrupt }) => {
<NavigationContainer <NavigationContainer
ref={navigationRef} ref={navigationRef}
theme={themes[mode]} theme={themes[mode]}
// key={i18n.language}
onReady={navigationContainerOnReady} onReady={navigationContainerOnReady}
onStateChange={navigationContainerOnStateChange} onStateChange={navigationContainerOnStateChange}
> >

View File

@ -85,44 +85,48 @@ const MenuRow: React.FC<Props> = ({
</View> </View>
</View> </View>
<View style={styles.back}> {(content && content.length) ||
{content && content.length ? ( switchValue !== undefined ||
<> iconBack ? (
<Text <View style={styles.back}>
style={[ {content && content.length ? (
styles.content, <>
{ <Text
color: theme.secondary, style={[
opacity: !iconBack && loading ? 0 : 1 styles.content,
} {
]} color: theme.secondary,
numberOfLines={1} opacity: !iconBack && loading ? 0 : 1
> }
{content} ]}
</Text> numberOfLines={1}
{loading && !iconBack && loadingSpinkit} >
</> {content}
) : null} </Text>
{switchValue !== undefined ? ( {loading && !iconBack && loadingSpinkit}
<Switch </>
value={switchValue} ) : null}
onValueChange={switchOnValueChange} {switchValue !== undefined ? (
disabled={switchDisabled} <Switch
trackColor={{ true: theme.blue, false: theme.disabled }} value={switchValue}
/> onValueChange={switchOnValueChange}
) : null} disabled={switchDisabled}
{iconBack ? ( trackColor={{ true: theme.blue, false: theme.disabled }}
<>
<Feather
name={iconBack}
size={StyleConstants.Font.Size.M + 2}
color={theme[iconBackColor]}
style={[styles.iconBack, { opacity: loading ? 0 : 1 }]}
/> />
{loading && loadingSpinkit} ) : null}
</> {iconBack ? (
) : null} <>
</View> <Feather
name={iconBack}
size={StyleConstants.Font.Size.M + 2}
color={theme[iconBackColor]}
style={[styles.iconBack, { opacity: loading ? 0 : 1 }]}
/>
{loading && loadingSpinkit}
</>
) : null}
</View>
) : null}
</View> </View>
</Pressable> </Pressable>
) )
@ -139,17 +143,16 @@ const styles = StyleSheet.create({
paddingRight: StyleConstants.Spacing.Global.PagePadding paddingRight: StyleConstants.Spacing.Global.PagePadding
}, },
front: { front: {
flex: 1, flex: 2,
flexBasis: '70%',
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center' alignItems: 'center'
}, },
back: { back: {
flex: 1, flex: 1,
flexBasis: '30%',
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'flex-end', justifyContent: 'flex-end',
alignItems: 'center' alignItems: 'center',
marginLeft: StyleConstants.Spacing.M
}, },
iconFront: { iconFront: {
marginRight: 8 marginRight: 8

View File

@ -1,11 +1,12 @@
import React, { useMemo } from 'react'
import { StyleSheet, Text, View } from 'react-native'
import { Chase } from 'react-native-animated-spinkit'
import { QueryStatus } from 'react-query'
import Button from '@components/Button' import Button from '@components/Button'
import { Feather } from '@expo/vector-icons' import { Feather } from '@expo/vector-icons'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, Text, View } from 'react-native'
import { Chase } from 'react-native-animated-spinkit'
import { QueryStatus } from 'react-query'
export interface Props { export interface Props {
status: QueryStatus status: QueryStatus
@ -13,7 +14,8 @@ export interface Props {
} }
const TimelineEmpty: React.FC<Props> = ({ status, refetch }) => { const TimelineEmpty: React.FC<Props> = ({ status, refetch }) => {
const { theme } = useTheme() const { mode, theme } = useTheme()
const { t, i18n } = useTranslation('timeline')
const children = useMemo(() => { const children = useMemo(() => {
switch (status) { switch (status) {
@ -30,9 +32,13 @@ const TimelineEmpty: React.FC<Props> = ({ status, refetch }) => {
color={theme.primary} color={theme.primary}
/> />
<Text style={[styles.error, { color: theme.primary }]}> <Text style={[styles.error, { color: theme.primary }]}>
{t('empty.error.message')}
</Text> </Text>
<Button type='text' content='重试' onPress={() => refetch()} /> <Button
type='text'
content={t('empty.error.button')}
onPress={() => refetch()}
/>
</> </>
) )
case 'success': case 'success':
@ -44,12 +50,12 @@ const TimelineEmpty: React.FC<Props> = ({ status, refetch }) => {
color={theme.primary} color={theme.primary}
/> />
<Text style={[styles.error, { color: theme.primary }]}> <Text style={[styles.error, { color: theme.primary }]}>
{t('empty.success.message')}
</Text> </Text>
</> </>
) )
} }
}, [status]) }, [mode, i18n.language, status])
return <View style={styles.base} children={children} /> return <View style={styles.base} children={children} />
} }

View File

@ -1,10 +1,11 @@
import { ParseEmojis } from '@components/Parse'
import { Feather } from '@expo/vector-icons' import { Feather } from '@expo/vector-icons'
import { useTheme } from '@utils/styles/ThemeManager'
import { StyleConstants } from '@utils/styles/constants'
import React, { useCallback, useMemo } from 'react'
import { Pressable, StyleSheet, View } from 'react-native'
import { ParseEmojis } from '@root/components/Parse'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { Pressable, StyleSheet, View } from 'react-native'
export interface Props { export interface Props {
account: Mastodon.Account account: Mastodon.Account
@ -17,6 +18,7 @@ const TimelineActioned: React.FC<Props> = ({
action, action,
notification = false notification = false
}) => { }) => {
const { t } = useTranslation('timeline')
const { theme } = useTheme() const { theme } = useTheme()
const navigation = useNavigation() const navigation = useNavigation()
const name = account.display_name || account.username const name = account.display_name || account.username
@ -41,7 +43,7 @@ const TimelineActioned: React.FC<Props> = ({
color={iconColor} color={iconColor}
style={styles.icon} style={styles.icon}
/> />
{content('置顶')} {content(t('shared.actioned.pinned'))}
</> </>
) )
break break
@ -55,7 +57,7 @@ const TimelineActioned: React.FC<Props> = ({
style={styles.icon} style={styles.icon}
/> />
<Pressable onPress={onPress}> <Pressable onPress={onPress}>
{content(`${name} 喜欢了你的嘟嘟`)} {content(t('shared.actioned.favourite', { name }))}
</Pressable> </Pressable>
</> </>
) )
@ -70,7 +72,7 @@ const TimelineActioned: React.FC<Props> = ({
style={styles.icon} style={styles.icon}
/> />
<Pressable onPress={onPress}> <Pressable onPress={onPress}>
{content(`${name} 开始关注你`)} {content(t('shared.actioned.follow', { name }))}
</Pressable> </Pressable>
</> </>
) )
@ -84,7 +86,7 @@ const TimelineActioned: React.FC<Props> = ({
color={iconColor} color={iconColor}
style={styles.icon} style={styles.icon}
/> />
{content('你参与的投票已结束')} {content(t('shared.actioned.poll'))}
</> </>
) )
break break
@ -98,7 +100,11 @@ const TimelineActioned: React.FC<Props> = ({
style={styles.icon} style={styles.icon}
/> />
<Pressable onPress={onPress}> <Pressable onPress={onPress}>
{content(`${name} 转嘟了${notification ? '你的嘟嘟' : ''}`)} {content(
notification
? t('shared.actioned.reblog.notification', { name })
: t('shared.actioned.reblog.default', { name })
)}
</Pressable> </Pressable>
</> </>
) )

View File

@ -8,40 +8,10 @@ import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import { findIndex } from 'lodash' import { findIndex } from 'lodash'
import React, { useCallback, useMemo } from 'react' import React, { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { ActionSheetIOS, Pressable, StyleSheet, Text, View } from 'react-native' import { ActionSheetIOS, Pressable, StyleSheet, Text, View } from 'react-native'
import { useMutation, useQueryClient } from 'react-query' import { useMutation, useQueryClient } from 'react-query'
const fireMutation = async ({
id,
type,
stateKey,
prevState
}: {
id: string
type: 'favourite' | 'reblog' | 'bookmark'
stateKey: 'favourited' | 'reblogged' | 'bookmarked'
prevState?: boolean
}) => {
let res
switch (type) {
case 'favourite':
case 'reblog':
case 'bookmark':
res = await client({
method: 'post',
instance: 'local',
url: `statuses/${id}/${prevState ? 'un' : ''}${type}`
}) // bug in response from Mastodon
if (!res.body[stateKey] === prevState) {
return Promise.resolve(res.body)
} else {
return Promise.reject()
}
break
}
}
export interface Props { export interface Props {
queryKey: QueryKey.Timeline queryKey: QueryKey.Timeline
status: Mastodon.Status status: Mastodon.Status
@ -50,14 +20,32 @@ export interface Props {
const TimelineActions: React.FC<Props> = ({ queryKey, status, reblog }) => { const TimelineActions: React.FC<Props> = ({ queryKey, status, reblog }) => {
const navigation = useNavigation() const navigation = useNavigation()
const { t } = useTranslation()
const { theme } = useTheme() const { theme } = useTheme()
const iconColor = theme.secondary const iconColor = theme.secondary
const iconColorAction = (state: boolean) => const iconColorAction = (state: boolean) =>
state ? theme.primary : theme.secondary state ? theme.primary : theme.secondary
const queryClient = useQueryClient() const queryClient = useQueryClient()
const fireMutation = useCallback(
async ({
type,
state
}: {
type: 'favourite' | 'reblog' | 'bookmark'
stateKey: 'favourited' | 'reblogged' | 'bookmarked'
state?: boolean
}) => {
return client({
method: 'post',
instance: 'local',
url: `statuses/${status.id}/${state ? 'un' : ''}${type}`
}) // bug in response from Mastodon
},
[]
)
const { mutate } = useMutation(fireMutation, { const { mutate } = useMutation(fireMutation, {
onMutate: ({ id, type, stateKey, prevState }) => { onMutate: ({ type, stateKey, state }) => {
queryClient.cancelQueries(queryKey) queryClient.cancelQueries(queryKey)
const oldData = queryClient.getQueryData(queryKey) const oldData = queryClient.getQueryData(queryKey)
@ -75,7 +63,7 @@ const TimelineActions: React.FC<Props> = ({ queryKey, status, reblog }) => {
: reblog : reblog
? 'reblog.id' ? 'reblog.id'
: 'id', : 'id',
id status.id
]) ])
if (tempIndex >= 0) { if (tempIndex >= 0) {
tootIndex = tempIndex tootIndex = tempIndex
@ -94,14 +82,14 @@ const TimelineActions: React.FC<Props> = ({ queryKey, status, reblog }) => {
} else { } else {
if (queryKey[0] === 'Notifications') { if (queryKey[0] === 'Notifications') {
old!.pages[pageIndex].toots[tootIndex].status[stateKey] = old!.pages[pageIndex].toots[tootIndex].status[stateKey] =
typeof prevState === 'boolean' ? !prevState : true typeof state === 'boolean' ? !state : true
} else { } else {
if (reblog) { if (reblog) {
old!.pages[pageIndex].toots[tootIndex].reblog![stateKey] = old!.pages[pageIndex].toots[tootIndex].reblog![stateKey] =
typeof prevState === 'boolean' ? !prevState : true typeof state === 'boolean' ? !state : true
} else { } else {
old!.pages[pageIndex].toots[tootIndex][stateKey] = old!.pages[pageIndex].toots[tootIndex][stateKey] =
typeof prevState === 'boolean' ? !prevState : true typeof state === 'boolean' ? !state : true
} }
} }
} }
@ -114,9 +102,14 @@ const TimelineActions: React.FC<Props> = ({ queryKey, status, reblog }) => {
return oldData return oldData
}, },
onError: (err, _, oldData) => { onError: (_, { type }, oldData) => {
haptics('Error') haptics('Error')
toast({ type: 'error', content: '请重试' }) toast({
type: 'error',
message: t('common:toastMessage.success.message', {
function: t(`timeline:shared.actions.${type}.function`)
})
})
queryClient.setQueryData(queryKey, oldData) queryClient.setQueryData(queryKey, oldData)
} }
}) })
@ -133,30 +126,27 @@ const TimelineActions: React.FC<Props> = ({ queryKey, status, reblog }) => {
const onPressReblog = useCallback( const onPressReblog = useCallback(
() => () =>
mutate({ mutate({
id: status.id,
type: 'reblog', type: 'reblog',
stateKey: 'reblogged', stateKey: 'reblogged',
prevState: status.reblogged state: status.reblogged
}), }),
[status.reblogged] [status.reblogged]
) )
const onPressFavourite = useCallback( const onPressFavourite = useCallback(
() => () =>
mutate({ mutate({
id: status.id,
type: 'favourite', type: 'favourite',
stateKey: 'favourited', stateKey: 'favourited',
prevState: status.favourited state: status.favourited
}), }),
[status.favourited] [status.favourited]
) )
const onPressBookmark = useCallback( const onPressBookmark = useCallback(
() => () =>
mutate({ mutate({
id: status.id,
type: 'bookmark', type: 'bookmark',
stateKey: 'bookmarked', stateKey: 'bookmarked',
prevState: status.bookmarked state: status.bookmarked
}), }),
[status.bookmarked] [status.bookmarked]
) )

View File

@ -1,13 +1,14 @@
import Button from '@components/Button' import Button from '@components/Button'
import haptics from '@components/haptics'
import AttachmentAudio from '@components/Timelines/Timeline/Shared/Attachment/Audio' import AttachmentAudio from '@components/Timelines/Timeline/Shared/Attachment/Audio'
import AttachmentImage from '@components/Timelines/Timeline/Shared/Attachment/Image' import AttachmentImage from '@components/Timelines/Timeline/Shared/Attachment/Image'
import AttachmentUnsupported from '@components/Timelines/Timeline/Shared/Attachment/Unsupported' import AttachmentUnsupported from '@components/Timelines/Timeline/Shared/Attachment/Unsupported'
import AttachmentVideo from '@components/Timelines/Timeline/Shared/Attachment/Video' import AttachmentVideo from '@components/Timelines/Timeline/Shared/Attachment/Video'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import haptics from '@root/components/haptics'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import layoutAnimation from '@utils/styles/layoutAnimation' import layoutAnimation from '@utils/styles/layoutAnimation'
import React, { useCallback, useMemo, useState } from 'react' import React, { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Pressable, StyleSheet, View } from 'react-native' import { Pressable, StyleSheet, View } from 'react-native'
import { IImageInfo } from 'react-native-image-zoom-viewer/built/image-viewer.type' import { IImageInfo } from 'react-native-image-zoom-viewer/built/image-viewer.type'
@ -16,6 +17,8 @@ export interface Props {
} }
const TimelineAttachment: React.FC<Props> = ({ status }) => { const TimelineAttachment: React.FC<Props> = ({ status }) => {
const { t } = useTranslation('timeline')
const [sensitiveShown, setSensitiveShown] = useState(status.sensitive) const [sensitiveShown, setSensitiveShown] = useState(status.sensitive)
const onPressBlurView = useCallback(() => { const onPressBlurView = useCallback(() => {
layoutAnimation() layoutAnimation()
@ -106,7 +109,7 @@ const TimelineAttachment: React.FC<Props> = ({ status }) => {
<Pressable style={styles.sensitiveBlur}> <Pressable style={styles.sensitiveBlur}>
<Button <Button
type='text' type='text'
content='显示敏感内容' content={t('shared.attachment.sensitive.button')}
overlay overlay
onPress={onPressBlurView} onPress={onPressBlurView}
/> />

View File

@ -2,9 +2,10 @@ import Button from '@components/Button'
import openLink from '@components/openLink' import openLink from '@components/openLink'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import { Surface } from 'gl-react-expo'
import { Blurhash } from 'gl-react-blurhash' import { Blurhash } from 'gl-react-blurhash'
import { Surface } from 'gl-react-expo'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, Text, View } from 'react-native' import { StyleSheet, Text, View } from 'react-native'
export interface Props { export interface Props {
@ -16,7 +17,9 @@ const AttachmentUnsupported: React.FC<Props> = ({
sensitiveShown, sensitiveShown,
attachment attachment
}) => { }) => {
const { t } = useTranslation('timeline')
const { theme } = useTheme() const { theme } = useTheme()
return ( return (
<View style={styles.base}> <View style={styles.base}>
{attachment.blurhash ? ( {attachment.blurhash ? (
@ -38,12 +41,12 @@ const AttachmentUnsupported: React.FC<Props> = ({
{ color: attachment.blurhash ? theme.background : theme.primary } { color: attachment.blurhash ? theme.background : theme.primary }
]} ]}
> >
{t('shared.attachment.unsupported.text')}
</Text> </Text>
{attachment.remote_url ? ( {attachment.remote_url ? (
<Button <Button
type='text' type='text'
content='尝试远程链接' content={t('shared.attachment.unsupported.button')}
size='S' size='S'
overlay overlay
onPress={async () => await openLink(attachment.remote_url!)} onPress={async () => await openLink(attachment.remote_url!)}

View File

@ -1,6 +1,7 @@
import { ParseHTML } from '@components/Parse' import { ParseHTML } from '@components/Parse'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import React from 'react' import React from 'react'
import { useTranslation } from 'react-i18next'
import { View } from 'react-native' import { View } from 'react-native'
export interface Props { export interface Props {
@ -14,6 +15,8 @@ const TimelineContent: React.FC<Props> = ({
numberOfLines, numberOfLines,
highlighted = false highlighted = false
}) => { }) => {
const { t } = useTranslation('timeline')
return ( return (
<> <>
{status.spoiler_text ? ( {status.spoiler_text ? (
@ -35,7 +38,7 @@ const TimelineContent: React.FC<Props> = ({
mentions={status.mentions} mentions={status.mentions}
tags={status.tags} tags={status.tags}
numberOfLines={0} numberOfLines={0}
expandHint='隐藏内容' expandHint={t('shared.content.expandHint')}
/> />
</> </>
) : ( ) : (

View File

@ -1,9 +1,10 @@
import React from 'react'
import { StyleSheet, Text, View } from 'react-native'
import { Chase } from 'react-native-animated-spinkit'
import { Feather } from '@expo/vector-icons' import { Feather } from '@expo/vector-icons'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react'
import { Trans } from 'react-i18next'
import { StyleSheet, Text, View } from 'react-native'
import { Chase } from 'react-native-animated-spinkit'
export interface Props { export interface Props {
hasNextPage?: boolean hasNextPage?: boolean
@ -18,13 +19,16 @@ const TimelineEnd: React.FC<Props> = ({ hasNextPage }) => {
<Chase size={StyleConstants.Font.Size.L} color={theme.secondary} /> <Chase size={StyleConstants.Font.Size.L} color={theme.secondary} />
) : ( ) : (
<Text style={[styles.text, { color: theme.secondary }]}> <Text style={[styles.text, { color: theme.secondary }]}>
{' '} <Trans
<Feather i18nKey='timeline:shared.end.message' // optional -> fallbacks to defaults if not provided
name='coffee' components={[
size={StyleConstants.Font.Size.S} <Feather
color={theme.secondary} name='coffee'
/>{' '} size={StyleConstants.Font.Size.S}
color={theme.secondary}
/>
]}
/>
</Text> </Text>
)} )}
</View> </View>

View File

@ -1,42 +1,41 @@
import client from '@api/client' import client from '@api/client'
import haptics from '@components/haptics' import haptics from '@components/haptics'
import { ParseEmojis } from '@components/Parse'
import relativeTime from '@components/relativeTime'
import { toast } from '@components/toast' import { toast } from '@components/toast'
import { Feather } from '@expo/vector-icons' import { Feather } from '@expo/vector-icons'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useMemo } from 'react' import React, { useCallback, useMemo } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native' import { Pressable, StyleSheet, View } from 'react-native'
import { useMutation, useQueryClient } from 'react-query' import { useMutation, useQueryClient } from 'react-query'
import HeaderSharedAccount from './HeaderShared/Account'
import HeaderSharedCreated from './HeaderShared/Created'
export interface Props { export interface Props {
queryKey: QueryKey.Timeline queryKey: QueryKey.Timeline
conversation: Mastodon.Conversation conversation: Mastodon.Conversation
} }
const fireMutation = async ({ id }: { id: string }) => {
const res = await client({
method: 'delete',
instance: 'local',
url: `conversations/${id}`
})
if (!res.body.error) {
toast({ type: 'success', content: '删除私信成功' })
return Promise.resolve()
} else {
toast({
type: 'error',
content: '删除私信失败,请重试',
autoHide: false
})
return Promise.reject()
}
}
const HeaderConversation: React.FC<Props> = ({ queryKey, conversation }) => { const HeaderConversation: React.FC<Props> = ({ queryKey, conversation }) => {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const fireMutation = useCallback(async () => {
const res = await client({
method: 'delete',
instance: 'local',
url: `conversations/${conversation.id}`
})
if (!res.body.error) {
toast({ type: 'success', message: '删除私信成功' })
return Promise.resolve()
} else {
toast({
type: 'error',
message: '删除私信失败,请重试',
autoHide: false
})
return Promise.reject()
}
}, [])
const { mutate } = useMutation(fireMutation, { const { mutate } = useMutation(fireMutation, {
onMutate: () => { onMutate: () => {
queryClient.cancelQueries(queryKey) queryClient.cancelQueries(queryKey)
@ -56,14 +55,14 @@ const HeaderConversation: React.FC<Props> = ({ queryKey, conversation }) => {
}, },
onError: (err, _, oldData) => { onError: (err, _, oldData) => {
haptics('Error') haptics('Error')
toast({ type: 'error', content: '请重试', autoHide: false }) toast({ type: 'error', message: '请重试', autoHide: false })
queryClient.setQueryData(queryKey, oldData) queryClient.setQueryData(queryKey, oldData)
} }
}) })
const { theme } = useTheme() const { theme } = useTheme()
const actionOnPress = useCallback(() => mutate({ id: conversation.id }), []) const actionOnPress = useCallback(() => mutate(), [])
const actionChildren = useMemo( const actionChildren = useMemo(
() => ( () => (
@ -78,31 +77,14 @@ const HeaderConversation: React.FC<Props> = ({ queryKey, conversation }) => {
return ( return (
<View style={styles.base}> <View style={styles.base}>
<View style={styles.nameAndDate}> <View style={styles.nameAndMeta}>
<View style={styles.namdAndAccount}> <HeaderSharedAccount account={conversation.accounts[0]} />
<Text numberOfLines={1}>
<ParseEmojis
content={
conversation.accounts[0].display_name ||
conversation.accounts[0].username
}
emojis={conversation.accounts[0].emojis}
fontBold
/>
</Text>
<Text
style={[styles.account, { color: theme.secondary }]}
numberOfLines={1}
>
@{conversation.accounts[0].acct}
</Text>
</View>
<View style={styles.meta}> <View style={styles.meta}>
{conversation.last_status?.created_at && ( {conversation.last_status?.created_at ? (
<Text style={[styles.created_at, { color: theme.secondary }]}> <HeaderSharedCreated
{relativeTime(conversation.last_status?.created_at)} created_at={conversation.last_status?.created_at}
</Text> />
)} ) : null}
{conversation.unread && ( {conversation.unread && (
<Feather name='circle' color={theme.blue} style={styles.unread} /> <Feather name='circle' color={theme.blue} style={styles.unread} />
)} )}
@ -123,16 +105,8 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
flexDirection: 'row' flexDirection: 'row'
}, },
nameAndDate: { nameAndMeta: {
width: '80%' flex: 4
},
namdAndAccount: {
flexDirection: 'row',
alignItems: 'center'
},
account: {
flexShrink: 1,
marginLeft: StyleConstants.Spacing.XS
}, },
meta: { meta: {
flexDirection: 'row', flexDirection: 'row',
@ -147,7 +121,7 @@ const styles = StyleSheet.create({
marginLeft: StyleConstants.Spacing.XS marginLeft: StyleConstants.Spacing.XS
}, },
action: { action: {
flexBasis: '20%', flex: 1,
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'center' justifyContent: 'center'
} }

View File

@ -1,7 +1,4 @@
import BottomSheet from '@components/BottomSheet' import BottomSheet from '@components/BottomSheet'
import openLink from '@components/openLink'
import { ParseEmojis } from '@components/Parse'
import relativeTime from '@components/relativeTime'
import { Feather } from '@expo/vector-icons' import { Feather } from '@expo/vector-icons'
import { getLocalUrl } from '@utils/slices/instancesSlice' import { getLocalUrl } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
@ -9,9 +6,13 @@ import { useTheme } from '@utils/styles/ThemeManager'
import HeaderDefaultActionsAccount from '@components/Timelines/Timeline/Shared/HeaderDefault/ActionsAccount' import HeaderDefaultActionsAccount from '@components/Timelines/Timeline/Shared/HeaderDefault/ActionsAccount'
import HeaderDefaultActionsDomain from '@components/Timelines/Timeline/Shared/HeaderDefault/ActionsDomain' import HeaderDefaultActionsDomain from '@components/Timelines/Timeline/Shared/HeaderDefault/ActionsDomain'
import HeaderDefaultActionsStatus from '@components/Timelines/Timeline/Shared/HeaderDefault/ActionsStatus' import HeaderDefaultActionsStatus from '@components/Timelines/Timeline/Shared/HeaderDefault/ActionsStatus'
import React, { useCallback, useEffect, useMemo, useState } from 'react' import React, { useCallback, useMemo, useState } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native' import { Pressable, StyleSheet, View } from 'react-native'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import HeaderSharedApplication from './HeaderShared/Application'
import HeaderSharedVisibility from './HeaderShared/Visibility'
import HeaderSharedCreated from './HeaderShared/Created'
import HeaderSharedAccount from './HeaderShared/Account'
export interface Props { export interface Props {
queryKey?: QueryKey.Timeline queryKey?: QueryKey.Timeline
@ -27,32 +28,12 @@ const TimelineHeaderDefault: React.FC<Props> = ({
const domain = status.uri const domain = status.uri
? status.uri.split(new RegExp(/\/\/(.*?)\//))[1] ? status.uri.split(new RegExp(/\/\/(.*?)\//))[1]
: '' : ''
const name = status.account.display_name || status.account.username
const emojis = status.account.emojis
const account = status.account.acct
const { theme } = useTheme() const { theme } = useTheme()
const localDomain = useSelector(getLocalUrl) const localDomain = useSelector(getLocalUrl)
const [since, setSince] = useState(relativeTime(status.created_at))
const [modalVisible, setBottomSheetVisible] = useState(false) const [modalVisible, setBottomSheetVisible] = useState(false)
// causing full re-render
useEffect(() => {
const timer = setTimeout(() => {
setSince(relativeTime(status.created_at))
}, 1000)
return () => {
clearTimeout(timer)
}
}, [since])
const onPressAction = useCallback(() => setBottomSheetVisible(true), []) const onPressAction = useCallback(() => setBottomSheetVisible(true), [])
const onPressApplication = useCallback(
async () =>
status.application!.website &&
(await openLink(status.application!.website)),
[]
)
const pressableAction = useMemo( const pressableAction = useMemo(
() => ( () => (
@ -67,42 +48,12 @@ const TimelineHeaderDefault: React.FC<Props> = ({
return ( return (
<View style={styles.base}> <View style={styles.base}>
<View style={queryKey ? { flexBasis: '80%' } : { flexBasis: '100%' }}> <View style={styles.accountAndMeta}>
<View style={styles.nameAndAccount}> <HeaderSharedAccount account={status.account} />
<Text numberOfLines={1}>
<ParseEmojis content={name} emojis={emojis} fontBold />
</Text>
<Text
style={[styles.account, { color: theme.secondary }]}
numberOfLines={1}
>
@{account}
</Text>
</View>
<View style={styles.meta}> <View style={styles.meta}>
<View> <HeaderSharedCreated created_at={status.created_at} />
<Text style={[styles.created_at, { color: theme.secondary }]}> <HeaderSharedVisibility visibility={status.visibility} />
{since} <HeaderSharedApplication application={status.application} />
</Text>
</View>
{status.visibility === 'private' && (
<Feather
name='lock'
size={StyleConstants.Font.Size.S}
color={theme.secondary}
style={styles.visibility}
/>
)}
{status.application && status.application.name !== 'Web' && (
<View>
<Text
onPress={onPressApplication}
style={[styles.application, { color: theme.secondary }]}
>
- {status.application.name}
</Text>
</View>
)}
</View> </View>
</View> </View>
@ -154,16 +105,8 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'baseline' alignItems: 'baseline'
}, },
nameAndMeta: { accountAndMeta: {
flexBasis: '80%' flex: 4
},
nameAndAccount: {
flexDirection: 'row',
alignItems: 'center'
},
account: {
flex: 1,
marginLeft: StyleConstants.Spacing.XS
}, },
meta: { meta: {
flexDirection: 'row', flexDirection: 'row',
@ -174,15 +117,8 @@ const styles = StyleSheet.create({
created_at: { created_at: {
...StyleConstants.FontStyle.S ...StyleConstants.FontStyle.S
}, },
visibility: {
marginLeft: StyleConstants.Spacing.S
},
application: {
...StyleConstants.FontStyle.S,
marginLeft: StyleConstants.Spacing.S
},
action: { action: {
flexBasis: '20%', flex: 1,
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'center', justifyContent: 'center',
paddingBottom: StyleConstants.Spacing.S paddingBottom: StyleConstants.Spacing.S

View File

@ -1,64 +1,14 @@
import React from 'react'
import { useMutation, useQueryClient } from 'react-query'
import client from '@api/client' import client from '@api/client'
import haptics from '@components/haptics'
import { MenuContainer, MenuHeader, MenuRow } from '@components/Menu' import { MenuContainer, MenuHeader, MenuRow } from '@components/Menu'
import { toast } from '@components/toast' import { toast } from '@components/toast'
import haptics from '@root/components/haptics' import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
const fireMutation = async ({ import { useMutation, useQueryClient } from 'react-query'
type,
id,
stateKey
}: {
type: 'mute' | 'block' | 'reports'
id: string
stateKey?: 'muting' | 'blocking'
}) => {
let res
switch (type) {
case 'mute':
case 'block':
res = await client({
method: 'post',
instance: 'local',
url: `accounts/${id}/${type}`
})
if (res.body[stateKey!] === true) {
toast({ type: 'success', content: '功能成功' })
return Promise.resolve()
} else {
toast({ type: 'error', content: '功能错误', autoHide: false })
return Promise.reject()
}
break
case 'reports':
res = await client({
method: 'post',
instance: 'local',
url: `reports`,
params: {
account_id: id!
}
})
if (!res.body.error) {
toast({ type: 'success', content: '举报账户成功' })
return Promise.resolve()
} else {
toast({
type: 'error',
content: '举报账户失败,请重试',
autoHide: false
})
return Promise.reject()
}
break
}
}
export interface Props { export interface Props {
queryKey?: QueryKey.Timeline queryKey?: QueryKey.Timeline
account: Pick<Mastodon.Account, 'id' | 'username' | 'acct' | 'url'> account: Pick<Mastodon.Account, 'id' | 'acct'>
setBottomSheetVisible: React.Dispatch<React.SetStateAction<boolean>> setBottomSheetVisible: React.Dispatch<React.SetStateAction<boolean>>
} }
@ -67,51 +17,103 @@ const HeaderDefaultActionsAccount: React.FC<Props> = ({
account, account,
setBottomSheetVisible setBottomSheetVisible
}) => { }) => {
const { t } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const fireMutation = useCallback(
async ({ type }: { type: 'mute' | 'block' | 'reports' }) => {
switch (type) {
case 'mute':
case 'block':
return client({
method: 'post',
instance: 'local',
url: `accounts/${account.id}/${type}`
})
break
case 'reports':
return client({
method: 'post',
instance: 'local',
url: `reports`,
params: {
account_id: account.id!
}
})
break
}
},
[]
)
const { mutate } = useMutation(fireMutation, { const { mutate } = useMutation(fireMutation, {
onSettled: () => { onSuccess: (_, { type }) => {
haptics('Success') haptics('Success')
toast({
type: 'success',
message: t('common:toastMessage.success.message', {
function: t(
`timeline:shared.header.default.actions.account.${type}.function`,
{ acct: account.acct }
)
})
})
},
onError: (_, { type }) => {
haptics('Error')
toast({
type: 'error',
message: t('common:toastMessage.error.message', {
function: t(
`timeline:shared.header.default.actions.account.${type}.function`,
{ acct: account.acct }
)
})
})
},
onSettled: () => {
queryKey && queryClient.invalidateQueries(queryKey) queryKey && queryClient.invalidateQueries(queryKey)
} }
}) })
return ( return (
<MenuContainer> <MenuContainer>
<MenuHeader heading='关于账户' /> <MenuHeader
heading={t('timeline:shared.header.default.actions.account.heading')}
/>
<MenuRow <MenuRow
onPress={() => { onPress={() => {
setBottomSheetVisible(false) setBottomSheetVisible(false)
mutate({ mutate({ type: 'mute' })
type: 'mute',
id: account.id,
stateKey: 'muting'
})
}} }}
iconFront='eye-off' iconFront='eye-off'
title={`隐藏 @${account.acct} 的嘟嘟`} title={t('timeline:shared.header.default.actions.account.mute.button', {
acct: account.acct
})}
/> />
<MenuRow <MenuRow
onPress={() => { onPress={() => {
setBottomSheetVisible(false) setBottomSheetVisible(false)
mutate({ mutate({ type: 'block' })
type: 'block',
id: account.id,
stateKey: 'blocking'
})
}} }}
iconFront='x-circle' iconFront='x-circle'
title={`屏蔽用户 @${account.acct}`} title={t(
'timeline:shared.header.default.actions.account.block.button',
{
acct: account.acct
}
)}
/> />
<MenuRow <MenuRow
onPress={() => { onPress={() => {
setBottomSheetVisible(false) setBottomSheetVisible(false)
mutate({ mutate({ type: 'reports' })
type: 'reports',
id: account.id
})
}} }}
iconFront='flag' iconFront='flag'
title={`举报 @${account.acct}`} title={t(
'timeline:shared.header.default.actions.account.report.button',
{
acct: account.acct
}
)}
/> />
</MenuContainer> </MenuContainer>
) )

View File

@ -1,33 +1,11 @@
import React from 'react'
import { useMutation, useQueryClient } from 'react-query'
import client from '@api/client' import client from '@api/client'
import MenuContainer from '@components/Menu/Container' import MenuContainer from '@components/Menu/Container'
import MenuHeader from '@components/Menu/Header' import MenuHeader from '@components/Menu/Header'
import MenuRow from '@components/Menu/Row' import MenuRow from '@components/Menu/Row'
import { toast } from '@components/toast' import { toast } from '@components/toast'
import React, { useCallback } from 'react'
const fireMutation = async ({ domain }: { domain: string }) => { import { useTranslation } from 'react-i18next'
const res = await client({ import { useMutation, useQueryClient } from 'react-query'
method: 'post',
instance: 'local',
url: `domain_blocks`,
params: {
domain: domain!
}
})
if (!res.body.error) {
toast({ type: 'success', content: '隐藏域名成功' })
return Promise.resolve()
} else {
toast({
type: 'error',
content: '隐藏域名失败,请重试',
autoHide: false
})
return Promise.reject()
}
}
export interface Props { export interface Props {
queryKey: QueryKey.Timeline queryKey: QueryKey.Timeline
@ -40,23 +18,46 @@ const HeaderDefaultActionsDomain: React.FC<Props> = ({
domain, domain,
setBottomSheetVisible setBottomSheetVisible
}) => { }) => {
const { t } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const fireMutation = useCallback(() => {
return client({
method: 'post',
instance: 'local',
url: `domain_blocks`,
params: {
domain: domain!
}
})
}, [])
const { mutate } = useMutation(fireMutation, { const { mutate } = useMutation(fireMutation, {
onSettled: () => { onSettled: () => {
toast({
type: 'success',
message: t('common:toastMessage.success.message', {
function: t(
`timeline:shared.header.default.actions.domain.block.function`
)
})
})
queryClient.invalidateQueries(queryKey) queryClient.invalidateQueries(queryKey)
} }
}) })
return ( return (
<MenuContainer> <MenuContainer>
<MenuHeader heading='关于域名' /> <MenuHeader
heading={t(`timeline:shared.header.default.actions.domain.heading`)}
/>
<MenuRow <MenuRow
onPress={() => { onPress={() => {
setBottomSheetVisible(false) setBottomSheetVisible(false)
mutate({ domain }) mutate()
}} }}
iconFront='cloud-off' iconFront='cloud-off'
title={`屏蔽域名 ${domain}`} title={t(`timeline:shared.header.default.actions.domain.block.button`, {
domain
})}
/> />
</MenuContainer> </MenuContainer>
) )

View File

@ -1,59 +1,14 @@
import { useNavigation } from '@react-navigation/native' import { findIndex } from 'lodash'
import React from 'react' import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Alert } from 'react-native' import { Alert } from 'react-native'
import { useMutation, useQueryClient } from 'react-query' import { useMutation, useQueryClient } from 'react-query'
import client from '@api/client' import client from '@api/client'
import haptics from '@components/haptics'
import { MenuContainer, MenuHeader, MenuRow } from '@components/Menu' import { MenuContainer, MenuHeader, MenuRow } from '@components/Menu'
import { TimelineData } from '@components/Timelines/Timeline'
import { toast } from '@components/toast' import { toast } from '@components/toast'
import { TimelineData } from '@root/components/Timelines/Timeline' import { useNavigation } from '@react-navigation/native'
import { findIndex } from 'lodash'
const fireMutation = async ({
id,
type,
stateKey,
prevState
}: {
id: string
type: 'mute' | 'pin' | 'delete'
stateKey: 'muted' | 'pinned' | 'id'
prevState?: boolean
}) => {
let res
switch (type) {
case 'mute':
case 'pin':
res = await client({
method: 'post',
instance: 'local',
url: `statuses/${id}/${prevState ? 'un' : ''}${type}`
}) // bug in response from Mastodon
if (!res.body[stateKey] === prevState) {
toast({ type: 'success', content: '功能成功' })
return Promise.resolve(res.body)
} else {
toast({ type: 'error', content: '功能错误' })
return Promise.reject()
}
break
case 'delete':
res = await client({
method: 'delete',
instance: 'local',
url: `statuses/${id}`
})
if (res.body[stateKey] === id) {
toast({ type: 'success', content: '删除成功' })
return Promise.resolve(res.body)
} else {
toast({ type: 'error', content: '删除失败' })
return Promise.reject()
}
break
}
}
export interface Props { export interface Props {
queryKey: QueryKey.Timeline queryKey: QueryKey.Timeline
@ -67,19 +22,56 @@ const HeaderDefaultActionsStatus: React.FC<Props> = ({
setBottomSheetVisible setBottomSheetVisible
}) => { }) => {
const navigation = useNavigation() const navigation = useNavigation()
const { t } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const fireMutation = useCallback(
({ type, state }: { type: 'mute' | 'pin' | 'delete'; state?: boolean }) => {
switch (type) {
case 'mute':
case 'pin':
return client({
method: 'post',
instance: 'local',
url: `statuses/${status.id}/${state ? 'un' : ''}${type}`
}) // bug in response from Mastodon, but onMutate ignore the error in response
break
case 'delete':
return client({
method: 'delete',
instance: 'local',
url: `statuses/${status.id}`
})
break
}
},
[]
)
enum mapTypeToProp {
mute = 'muted',
pin = 'pinned'
}
const { mutate } = useMutation(fireMutation, { const { mutate } = useMutation(fireMutation, {
onMutate: ({ id, type, stateKey, prevState }) => { onMutate: ({ type, state }) => {
queryClient.cancelQueries(queryKey) queryClient.cancelQueries(queryKey)
const oldData = queryClient.getQueryData(queryKey) const oldData = queryClient.getQueryData(queryKey)
switch (type) { switch (type) {
case 'mute': case 'mute':
case 'pin': case 'pin':
haptics('Success')
toast({
type: 'success',
message: t('common:toastMessage.success.message', {
function: t(
`timeline:shared.header.default.actions.status.${type}.function`
)
})
})
queryClient.setQueryData<TimelineData>(queryKey, old => { queryClient.setQueryData<TimelineData>(queryKey, old => {
let tootIndex = -1 let tootIndex = -1
const pageIndex = findIndex(old?.pages, page => { const pageIndex = findIndex(old?.pages, page => {
const tempIndex = findIndex(page.toots, ['id', id]) const tempIndex = findIndex(page.toots, ['id', status.id])
if (tempIndex >= 0) { if (tempIndex >= 0) {
tootIndex = tempIndex tootIndex = tempIndex
return true return true
@ -89,9 +81,8 @@ const HeaderDefaultActionsStatus: React.FC<Props> = ({
}) })
if (pageIndex >= 0 && tootIndex >= 0) { if (pageIndex >= 0 && tootIndex >= 0) {
old!.pages[pageIndex].toots[tootIndex][ old!.pages[pageIndex].toots[tootIndex][mapTypeToProp[type]] =
stateKey as 'muted' | 'pinned' typeof state === 'boolean' ? !state : true // State could be null from response
] = typeof prevState === 'boolean' ? !prevState : true
} }
return old return old
@ -105,7 +96,7 @@ const HeaderDefaultActionsStatus: React.FC<Props> = ({
...old, ...old,
pages: old?.pages.map(paging => ({ pages: old?.pages.map(paging => ({
...paging, ...paging,
toots: paging.toots.filter(toot => toot.id !== id) toots: paging.toots.filter(toot => toot.id !== status.id)
})) }))
} }
) )
@ -114,36 +105,45 @@ const HeaderDefaultActionsStatus: React.FC<Props> = ({
return oldData return oldData
}, },
onError: (err, _, oldData) => { onError: (_, { type }, oldData) => {
toast({ type: 'error', content: '请重试' }) toast({
type: 'error',
message: t('common:toastMessage.success.message', {
function: t(
`timeline:shared.header.default.actions.status.${type}.function`
)
})
})
queryClient.setQueryData(queryKey, oldData) queryClient.setQueryData(queryKey, oldData)
} }
}) })
return ( return (
<MenuContainer> <MenuContainer>
<MenuHeader heading='关于嘟嘟' /> <MenuHeader
heading={t('timeline:shared.header.default.actions.status.heading')}
/>
<MenuRow <MenuRow
onPress={() => { onPress={() => {
setBottomSheetVisible(false) setBottomSheetVisible(false)
mutate({ mutate({ type: 'delete' })
type: 'delete',
id: status.id,
stateKey: 'id'
})
}} }}
iconFront='trash' iconFront='trash'
title='删除嘟嘟' title={t('timeline:shared.header.default.actions.status.delete.button')}
/> />
<MenuRow <MenuRow
onPress={() => { onPress={() => {
Alert.alert( Alert.alert(
'确认删除嘟嘟?', t('timeline:shared.header.default.actions.status.edit.alert.title'),
'你确定要删除这条嘟文并重新编辑它吗?所有相关的转嘟和喜欢都会被清除,回复将会失去关联。', t(
'timeline:shared.header.default.actions.status.edit.alert.message'
),
[ [
{ text: '取消', style: 'cancel' }, { text: t('common:buttons.cancel'), style: 'cancel' },
{ {
text: '删除并重新编辑', text: t(
'timeline:shared.header.default.actions.status.edit.alert.confirm'
),
style: 'destructive', style: 'destructive',
onPress: async () => { onPress: async () => {
await client({ await client({
@ -160,7 +160,14 @@ const HeaderDefaultActionsStatus: React.FC<Props> = ({
}) })
}) })
.catch(() => { .catch(() => {
toast({ type: 'error', content: '删除失败' }) toast({
type: 'error',
message: t('common:toastMessage.success.message', {
function: t(
`timeline:shared.header.default.actions.status.edit.function`
)
})
})
}) })
} }
} }
@ -168,35 +175,41 @@ const HeaderDefaultActionsStatus: React.FC<Props> = ({
) )
}} }}
iconFront='trash' iconFront='trash'
title='删除并重新编辑' title={t('timeline:shared.header.default.actions.status.edit.button')}
/> />
<MenuRow <MenuRow
onPress={() => { onPress={() => {
setBottomSheetVisible(false) setBottomSheetVisible(false)
mutate({ mutate({ type: 'mute', state: status.muted })
type: 'mute',
id: status.id,
stateKey: 'muted',
prevState: status.muted
})
}} }}
iconFront='volume-x' iconFront='volume-x'
title={status.muted ? '取消静音对话' : '静音对话'} title={
status.muted
? t(
'timeline:shared.header.default.actions.status.mute.button.negative'
)
: t(
'timeline:shared.header.default.actions.status.mute.button.positive'
)
}
/> />
{/* Also note that reblogs cannot be pinned. */} {/* Also note that reblogs cannot be pinned. */}
{(status.visibility === 'public' || status.visibility === 'unlisted') && ( {(status.visibility === 'public' || status.visibility === 'unlisted') && (
<MenuRow <MenuRow
onPress={() => { onPress={() => {
setBottomSheetVisible(false) setBottomSheetVisible(false)
mutate({ mutate({ type: 'pin', state: status.pinned })
type: 'pin',
id: status.id,
stateKey: 'pinned',
prevState: status.pinned
})
}} }}
iconFront='anchor' iconFront='anchor'
title={status.pinned ? '取消置顶' : '置顶'} title={
status.pinned
? t(
'timeline:shared.header.default.actions.status.pin.button.negative'
)
: t(
'timeline:shared.header.default.actions.status.pin.button.positive'
)
}
/> />
)} )}
</MenuContainer> </MenuContainer>

View File

@ -1,33 +1,26 @@
import client from '@api/client' import client from '@api/client'
import { Feather } from '@expo/vector-icons' import { Feather } from '@expo/vector-icons'
import haptics from '@components/haptics' import haptics from '@components/haptics'
import openLink from '@components/openLink'
import { ParseEmojis } from '@components/Parse'
import relativeTime from '@components/relativeTime'
import { toast } from '@components/toast' import { toast } from '@components/toast'
import { relationshipFetch } from '@utils/fetches/relationshipFetch' import { relationshipFetch } from '@utils/fetches/relationshipFetch'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useEffect, useMemo, useState } from 'react' import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native' import { Pressable, StyleSheet, View } from 'react-native'
import { Chase } from 'react-native-animated-spinkit' import { Chase } from 'react-native-animated-spinkit'
import { useQuery } from 'react-query' import { useQuery } from 'react-query'
import HeaderSharedApplication from './HeaderShared/Application'
import HeaderSharedVisibility from './HeaderShared/Visibility'
import HeaderSharedCreated from './HeaderShared/Created'
import HeaderSharedAccount from './HeaderShared/Account'
export interface Props { export interface Props {
notification: Mastodon.Notification notification: Mastodon.Notification
} }
const TimelineHeaderNotification: React.FC<Props> = ({ notification }) => { const TimelineHeaderNotification: React.FC<Props> = ({ notification }) => {
const actualAccount = notification.status
? notification.status.account
: notification.account
const name = actualAccount.display_name || actualAccount.username
const emojis = actualAccount.emojis
const account = actualAccount.acct
const { theme } = useTheme() const { theme } = useTheme()
const [since, setSince] = useState(relativeTime(notification.created_at))
const { status, data, refetch } = useQuery( const { status, data, refetch } = useQuery(
['Relationship', { id: notification.account.id }], ['Relationship', { id: notification.account.id }],
relationshipFetch, relationshipFetch,
@ -39,19 +32,6 @@ const TimelineHeaderNotification: React.FC<Props> = ({ notification }) => {
Mastodon.Relationship | undefined Mastodon.Relationship | undefined
>() >()
useEffect(() => {
setTimeout(() => {
setSince(relativeTime(notification.created_at))
}, 1000)
}, [since])
const applicationOnPress = useCallback(
async () =>
notification.status?.application.website &&
(await openLink(notification.status.application.website)),
[]
)
const relationshipOnPress = useCallback(() => { const relationshipOnPress = useCallback(() => {
client({ client({
method: 'post', method: 'post',
@ -72,7 +52,7 @@ const TimelineHeaderNotification: React.FC<Props> = ({ notification }) => {
return Promise.resolve() return Promise.resolve()
} else { } else {
haptics('Error') haptics('Error')
toast({ type: 'error', content: '请重试', autoHide: false }) toast({ type: 'error', message: '请重试', autoHide: false })
return Promise.reject() return Promise.reject()
} }
}) })
@ -127,44 +107,22 @@ const TimelineHeaderNotification: React.FC<Props> = ({ notification }) => {
return ( return (
<View style={styles.base}> <View style={styles.base}>
<View style={styles.nameAndMeta}> <View style={styles.accountAndMeta}>
<View style={styles.nameAndAccount}> <HeaderSharedAccount
<Text numberOfLines={1}> account={
<ParseEmojis content={name} emojis={emojis} fontBold /> notification.status
</Text> ? notification.status.account
<Text : notification.account
style={[styles.account, { color: theme.secondary }]} }
numberOfLines={1} />
>
@{account}
</Text>
</View>
<View style={styles.meta}> <View style={styles.meta}>
<View> <HeaderSharedCreated created_at={notification.created_at} />
<Text style={[styles.created_at, { color: theme.secondary }]}> <HeaderSharedVisibility
{since} visibility={notification.status?.visibility}
</Text> />
</View> <HeaderSharedApplication
{notification.status?.visibility === 'private' && ( application={notification.status?.application}
<Feather />
name='lock'
size={StyleConstants.Font.Size.S}
color={theme.secondary}
style={styles.visibility}
/>
)}
{notification.status?.application &&
notification.status?.application.name !== 'Web' && (
<View>
<Text
onPress={applicationOnPress}
style={[styles.application, { color: theme.secondary }]}
>
- {notification.status?.application.name}
</Text>
</View>
)}
</View> </View>
</View> </View>
@ -180,16 +138,8 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
flexDirection: 'row' flexDirection: 'row'
}, },
nameAndMeta: { accountAndMeta: {
width: '80%' flex: 4
},
nameAndAccount: {
flexDirection: 'row',
alignItems: 'center'
},
account: {
flexShrink: 1,
marginLeft: StyleConstants.Spacing.XS
}, },
meta: { meta: {
flexDirection: 'row', flexDirection: 'row',
@ -197,18 +147,8 @@ const styles = StyleSheet.create({
marginTop: StyleConstants.Spacing.XS, marginTop: StyleConstants.Spacing.XS,
marginBottom: StyleConstants.Spacing.S marginBottom: StyleConstants.Spacing.S
}, },
created_at: {
...StyleConstants.FontStyle.S
},
visibility: {
marginLeft: StyleConstants.Spacing.S
},
application: {
...StyleConstants.FontStyle.S,
marginLeft: StyleConstants.Spacing.S
},
relationship: { relationship: {
flexBasis: '20%', flex: 1,
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'center' justifyContent: 'center'
} }

View File

@ -0,0 +1,41 @@
import { ParseEmojis } from '@root/components/Parse'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react'
import { StyleSheet, Text, View } from 'react-native'
export interface Props {
account: Mastodon.Account
}
const HeaderSharedAccount: React.FC<Props> = ({ account }) => {
const { theme } = useTheme()
return (
<View style={styles.base}>
<Text numberOfLines={1}>
<ParseEmojis
content={account.display_name || account.username}
emojis={account.emojis}
fontBold
/>
</Text>
<Text style={[styles.acct, { color: theme.secondary }]} numberOfLines={1}>
@{account.acct}
</Text>
</View>
)
}
const styles = StyleSheet.create({
base: {
flexDirection: 'row',
alignItems: 'center'
},
acct: {
flexShrink: 1,
marginLeft: StyleConstants.Spacing.XS
}
})
export default HeaderSharedAccount

View File

@ -0,0 +1,35 @@
import openLink from '@components/openLink'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, Text } from 'react-native'
export interface Props {
application?: Mastodon.Application
}
const HeaderSharedApplication: React.FC<Props> = ({ application }) => {
const { theme } = useTheme()
const { t } = useTranslation('timeline')
return application && application.name !== 'Web' ? (
<Text
onPress={async () =>
application.website && (await openLink(application.website))
}
style={[styles.application, { color: theme.secondary }]}
>
{t('shared.header.shared.application', { application: application.name })}
</Text>
) : null
}
const styles = StyleSheet.create({
application: {
...StyleConstants.FontStyle.S,
marginLeft: StyleConstants.Spacing.S
}
})
export default HeaderSharedApplication

View File

@ -0,0 +1,35 @@
import relativeTime from '@components/relativeTime'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, Text } from 'react-native'
export interface Props {
created_at: Mastodon.Status['created_at']
}
const HeaderSharedCreated: React.FC<Props> = ({ created_at }) => {
const { theme } = useTheme()
const { i18n } = useTranslation()
const [since, setSince] = useState(relativeTime(created_at, i18n.language))
useEffect(() => {
const timer = setTimeout(() => {
setSince(relativeTime(created_at, i18n.language))
}, 1000)
return () => clearTimeout(timer)
}, [since])
return (
<Text style={[styles.created_at, { color: theme.secondary }]}>{since}</Text>
)
}
const styles = StyleSheet.create({
created_at: {
...StyleConstants.FontStyle.S
}
})
export default React.memo(HeaderSharedCreated, () => true)

View File

@ -0,0 +1,30 @@
import { Feather } from '@expo/vector-icons'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react'
import { StyleSheet } from 'react-native'
export interface Props {
visibility?: Mastodon.Status['visibility']
}
const HeaderSharedVisibility: React.FC<Props> = ({ visibility }) => {
const { theme } = useTheme()
return visibility && visibility === 'private' ? (
<Feather
name='lock'
size={StyleConstants.Font.Size.S}
color={theme.secondary}
style={styles.visibility}
/>
) : null
}
const styles = StyleSheet.create({
visibility: {
marginLeft: StyleConstants.Spacing.S
}
})
export default HeaderSharedVisibility

View File

@ -9,6 +9,7 @@ import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import { findIndex } from 'lodash' import { findIndex } from 'lodash'
import React, { useCallback, useMemo, useState } from 'react' import React, { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Pressable, StyleSheet, Text, View } from 'react-native' import { Pressable, StyleSheet, Text, View } from 'react-native'
import { useMutation, useQueryClient } from 'react-query' import { useMutation, useQueryClient } from 'react-query'
@ -55,6 +56,7 @@ const TimelinePoll: React.FC<Props> = ({
sameAccount sameAccount
}) => { }) => {
const { mode, theme } = useTheme() const { mode, theme } = useTheme()
const { t, i18n } = useTranslation('timeline')
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [allOptions, setAllOptions] = useState( const [allOptions, setAllOptions] = useState(
@ -107,7 +109,7 @@ const TimelinePoll: React.FC<Props> = ({
mutation.mutate({ id: poll.id, options: allOptions }) mutation.mutate({ id: poll.id, options: allOptions })
} }
type='text' type='text'
content='投票' content={t('shared.poll.meta.button.vote')}
loading={mutation.isLoading} loading={mutation.isLoading}
disabled={allOptions.filter(o => o !== false).length === 0} disabled={allOptions.filter(o => o !== false).length === 0}
/> />
@ -118,9 +120,8 @@ const TimelinePoll: React.FC<Props> = ({
<View style={styles.button}> <View style={styles.button}>
<Button <Button
onPress={() => mutation.mutate({ id: poll.id })} onPress={() => mutation.mutate({ id: poll.id })}
{...(mutation.isLoading ? { icon: 'loader' } : { text: '刷新' })}
type='text' type='text'
content='刷新' content={t('shared.poll.meta.button.refresh')}
loading={mutation.isLoading} loading={mutation.isLoading}
/> />
</View> </View>
@ -133,17 +134,19 @@ const TimelinePoll: React.FC<Props> = ({
if (poll.expired) { if (poll.expired) {
return ( return (
<Text style={[styles.expiration, { color: theme.secondary }]}> <Text style={[styles.expiration, { color: theme.secondary }]}>
{t('shared.poll.meta.expiration.expired')}
</Text> </Text>
) )
} else { } else {
return ( return (
<Text style={[styles.expiration, { color: theme.secondary }]}> <Text style={[styles.expiration, { color: theme.secondary }]}>
{relativeTime(poll.expires_at)} {t('shared.poll.meta.expiration.until', {
at: relativeTime(poll.expires_at, i18n.language)
})}
</Text> </Text>
) )
} }
}, [mode]) }, [mode, poll.expired, poll.expires_at])
const isSelected = useCallback( const isSelected = useCallback(
(index: number): any => (index: number): any =>
@ -241,7 +244,7 @@ const TimelinePoll: React.FC<Props> = ({
<View style={styles.meta}> <View style={styles.meta}>
{pollButton} {pollButton}
<Text style={[styles.votes, { color: theme.secondary }]}> <Text style={[styles.votes, { color: theme.secondary }]}>
{poll.voters_count || 0}{' • '} {t('shared.poll.meta.voted', { count: poll.voters_count })}
</Text> </Text>
{pollExpiration} {pollExpiration}
</View> </View>
@ -270,7 +273,9 @@ const styles = StyleSheet.create({
optionPercentage: { optionPercentage: {
...StyleConstants.FontStyle.M, ...StyleConstants.FontStyle.M,
alignSelf: 'center', alignSelf: 'center',
marginLeft: StyleConstants.Spacing.S marginLeft: StyleConstants.Spacing.S,
flexBasis: '20%',
textAlign: 'center'
}, },
background: { background: {
height: StyleConstants.Spacing.XS, height: StyleConstants.Spacing.XS,

View File

@ -1,6 +1,4 @@
import { store } from '@root/store' const relativeTime = (date: string, language: string) => {
const relativeTime = (date: string) => {
const units = { const units = {
year: 24 * 60 * 60 * 1000 * 365, year: 24 * 60 * 60 * 1000 * 365,
month: (24 * 60 * 60 * 1000 * 365) / 12, month: (24 * 60 * 60 * 1000 * 365) / 12,
@ -10,7 +8,7 @@ const relativeTime = (date: string) => {
second: 1000 second: 1000
} }
const rtf = new Intl.RelativeTimeFormat(store.getState().settings.language, { const rtf = new Intl.RelativeTimeFormat(language, {
numeric: 'auto' numeric: 'auto'
}) })

View File

@ -9,7 +9,7 @@ import { StyleConstants } from '@utils/styles/constants'
export interface Params { export interface Params {
type: 'success' | 'error' | 'warning' type: 'success' | 'error' | 'warning'
position?: 'top' | 'bottom' position?: 'top' | 'bottom'
content: string message: string
description?: string description?: string
autoHide?: boolean autoHide?: boolean
onShow?: () => void onShow?: () => void
@ -19,14 +19,14 @@ export interface Params {
type Config = { type Config = {
type: Params['type'] type: Params['type']
position: Params['position'] position: Params['position']
text1: Params['content'] text1: Params['message']
text2: Params['description'] text2: Params['description']
} }
const toast = ({ const toast = ({
type, type,
position = 'top', position = 'top',
content, message,
description, description,
autoHide = true, autoHide = true,
onShow, onShow,
@ -35,7 +35,7 @@ const toast = ({
Toast.show({ Toast.show({
type, type,
position, position,
text1: content, text1: message,
text2: description, text2: description,
visibilityTime: 1500, visibilityTime: 1500,
autoHide, autoHide,

View File

@ -17,5 +17,7 @@ export default {
sharedAccount: require('./screens/sharedAccount').default, sharedAccount: require('./screens/sharedAccount').default,
sharedToot: require('./screens/sharedToot').default, sharedToot: require('./screens/sharedToot').default,
sharedAnnouncements: require('./screens/sharedAnnouncements').default sharedAnnouncements: require('./screens/sharedAnnouncements').default,
timeline: require('./components/timeline').default
} }

View File

@ -1,5 +1,16 @@
export default { export default {
buttons: { buttons: {
cancel: '取消' cancel: '取消'
},
toastMessage: {
success: {
message: '{{function}}成功'
},
warning: {
message: ''
},
error: {
message: '{{function}}失败,请重试'
}
} }
} }

View File

@ -0,0 +1,127 @@
export default {
empty: {
error: {
message: '加载错误',
button: '重试'
},
success: {
message: '🈳🈚1一物'
}
},
shared: {
actioned: {
pinned: '置顶',
favourite: '{{name}} 喜欢了你的嘟嘟',
follow: '{{name}} 开始关注你',
poll: '您参与的投票已结束',
reblog: {
default: '{{name}} 转嘟了',
notification: '{{name}} 转嘟了您的嘟文'
}
},
actions: {
favourite: {
function: '喜欢嘟文'
// button: '隐藏 {{acct}} 的嘟文'
},
reblog: {
function: '转嘟'
// button: '屏蔽 {{acct}}'
},
bookmark: {
function: '收藏嘟文'
// button: '举报 {{acct}}'
}
},
attachment: {
sensitive: {
button: '显示敏感内容'
},
unsupported: {
text: '文件读取错误',
button: '尝试远程链接'
}
},
content: {
expandHint: '隐藏内容'
},
end: {
message: '居然刷到底了,喝杯 <0 /> 吧'
},
header: {
shared: {
application: '发自于 {{application}}'
},
default: {
actions: {
account: {
heading: '关于用户',
mute: {
function: '隐藏 {{acct}} 的嘟文',
button: '隐藏 {{acct}} 的嘟文'
},
block: {
function: '屏蔽 {{acct}}',
button: '屏蔽 {{acct}}'
},
report: {
function: '举报 {{acct}}',
button: '举报 {{acct}}'
}
},
domain: {
heading: '关于域名',
block: {
function: '屏蔽域名',
button: '屏蔽域名 {{domain}}'
}
},
status: {
heading: '关于嘟嘟',
delete: {
function: '删除',
button: '删除次条嘟文'
},
edit: {
function: '删除',
button: '删除并重新编辑次条嘟文',
alert: {
title: '确认删除嘟嘟?',
message:
'你确定要删除这条嘟文并重新编辑它吗?所有相关的转嘟和喜欢都会被清除,回复将会失去关联。',
confirm: '删除并重新编辑'
}
},
mute: {
function: '静音',
button: {
positive: '静音此条嘟文及对话',
negative: '取消静音此条嘟文及对话'
}
},
pin: {
function: '置顶',
button: {
positive: '置顶此条嘟文',
negative: '取消置顶此条嘟文'
}
}
}
}
}
},
poll: {
meta: {
button: {
vote: '投票',
refresh: '刷新'
},
expiration: {
expired: '投票已结束',
until: '{{at}}截止'
},
voted: '已投{{count}}人 • '
}
}
}
}

View File

@ -62,7 +62,7 @@ const styles = StyleSheet.create({
paddingBottom: StyleConstants.Spacing.S paddingBottom: StyleConstants.Spacing.S
}, },
fieldLeft: { fieldLeft: {
flexBasis: '30%', flex: 1,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@ -72,7 +72,7 @@ const styles = StyleSheet.create({
}, },
fieldCheck: { marginLeft: StyleConstants.Spacing.XS }, fieldCheck: { marginLeft: StyleConstants.Spacing.XS },
fieldRight: { fieldRight: {
flexBasis: '70%', flex: 3,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
paddingLeft: StyleConstants.Spacing.S, paddingLeft: StyleConstants.Spacing.S,

View File

@ -1,13 +1,14 @@
import client from '@api/client' import client from '@api/client'
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'
import Button from '@components/Button' import Button from '@components/Button'
import haptics from '@components/haptics' import haptics from '@components/haptics'
import { ParseHTML } from '@components/Parse' import { ParseHTML } from '@components/Parse'
import relativeTime from '@components/relativeTime' import relativeTime from '@components/relativeTime'
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs'
import { announcementFetch } from '@utils/fetches/announcementsFetch' import { announcementFetch } from '@utils/fetches/announcementsFetch'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useEffect, useState } from 'react' import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { import {
Dimensions, Dimensions,
Image, Image,
@ -56,6 +57,7 @@ const ScreenSharedAnnouncements: React.FC = ({
const { theme } = useTheme() const { theme } = useTheme()
const bottomTabBarHeight = useBottomTabBarHeight() const bottomTabBarHeight = useBottomTabBarHeight()
const [index, setIndex] = useState(0) const [index, setIndex] = useState(0)
const { t, i18n } = useTranslation()
const queryKey = ['Announcements', { showAll }] const queryKey = ['Announcements', { showAll }]
const { data, refetch } = useQuery(queryKey, announcementFetch, { const { data, refetch } = useQuery(queryKey, announcementFetch, {
@ -100,7 +102,7 @@ const ScreenSharedAnnouncements: React.FC = ({
]} ]}
> >
<Text style={[styles.published, { color: theme.secondary }]}> <Text style={[styles.published, { color: theme.secondary }]}>
{relativeTime(item.published_at)} {relativeTime(item.published_at, i18n.language)}
</Text> </Text>
<ScrollView style={styles.scrollView} showsVerticalScrollIndicator> <ScrollView style={styles.scrollView} showsVerticalScrollIndicator>
<ParseHTML <ParseHTML

View File

@ -22,7 +22,7 @@ import ComposeEditAttachment from './Compose/EditAttachment'
import ComposeContext from './Compose/utils/createContext' import ComposeContext from './Compose/utils/createContext'
import composeInitialState from './Compose/utils/initialState' import composeInitialState from './Compose/utils/initialState'
import composeParseState from './Compose/utils/parseState' import composeParseState from './Compose/utils/parseState'
import composeSend from './Compose/utils/post' import composePost from './Compose/utils/post'
import composeReducer from './Compose/utils/reducer' import composeReducer from './Compose/utils/reducer'
import { ComposeState } from './Compose/utils/types' import { ComposeState } from './Compose/utils/types'
@ -171,7 +171,7 @@ const Compose: React.FC<Props> = ({ route: { params }, navigation }) => {
onPress={() => { onPress={() => {
layoutAnimation() layoutAnimation()
setIsSubmitting(true) setIsSubmitting(true)
composeSend(params, composeState) composePost(params, composeState)
.then(() => { .then(() => {
haptics('Success') haptics('Success')
queryClient.invalidateQueries(['Following']) queryClient.invalidateQueries(['Following'])
@ -191,7 +191,7 @@ const Compose: React.FC<Props> = ({ route: { params }, navigation }) => {
disabled={composeState.text.raw.length < 1 || totalTextCount > 500} disabled={composeState.text.raw.length < 1 || totalTextCount > 500}
/> />
), ),
[isSubmitting, composeState.text.raw, totalTextCount] [isSubmitting, totalTextCount, composeState]
) )
return ( return (

View File

@ -3,7 +3,7 @@ import { Props } from '@screens/Shared/Compose'
import { ComposeState } from '@screens/Shared/Compose/utils/types' import { ComposeState } from '@screens/Shared/Compose/utils/types'
import * as Crypto from 'expo-crypto' import * as Crypto from 'expo-crypto'
const composeSend = async ( const composePost = async (
params: Props['route']['params'], params: Props['route']['params'],
composeState: ComposeState composeState: ComposeState
) => { ) => {
@ -71,4 +71,4 @@ const composeSend = async (
} }
} }
export default composeSend export default composePost

View File

@ -1,4 +1,4 @@
import { ComposeAction, ComposeState } from "./types" import { ComposeAction, ComposeState } from './types'
const composeReducer = ( const composeReducer = (
state: ComposeState, state: ComposeState,