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

View File

@ -85,44 +85,48 @@ const MenuRow: React.FC<Props> = ({
</View>
</View>
<View style={styles.back}>
{content && content.length ? (
<>
<Text
style={[
styles.content,
{
color: theme.secondary,
opacity: !iconBack && loading ? 0 : 1
}
]}
numberOfLines={1}
>
{content}
</Text>
{loading && !iconBack && loadingSpinkit}
</>
) : null}
{switchValue !== undefined ? (
<Switch
value={switchValue}
onValueChange={switchOnValueChange}
disabled={switchDisabled}
trackColor={{ true: theme.blue, false: theme.disabled }}
/>
) : null}
{iconBack ? (
<>
<Feather
name={iconBack}
size={StyleConstants.Font.Size.M + 2}
color={theme[iconBackColor]}
style={[styles.iconBack, { opacity: loading ? 0 : 1 }]}
{(content && content.length) ||
switchValue !== undefined ||
iconBack ? (
<View style={styles.back}>
{content && content.length ? (
<>
<Text
style={[
styles.content,
{
color: theme.secondary,
opacity: !iconBack && loading ? 0 : 1
}
]}
numberOfLines={1}
>
{content}
</Text>
{loading && !iconBack && loadingSpinkit}
</>
) : null}
{switchValue !== undefined ? (
<Switch
value={switchValue}
onValueChange={switchOnValueChange}
disabled={switchDisabled}
trackColor={{ true: theme.blue, false: theme.disabled }}
/>
{loading && loadingSpinkit}
</>
) : null}
</View>
) : null}
{iconBack ? (
<>
<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>
</Pressable>
)
@ -139,17 +143,16 @@ const styles = StyleSheet.create({
paddingRight: StyleConstants.Spacing.Global.PagePadding
},
front: {
flex: 1,
flexBasis: '70%',
flex: 2,
flexDirection: 'row',
alignItems: 'center'
},
back: {
flex: 1,
flexBasis: '30%',
flexDirection: 'row',
justifyContent: 'flex-end',
alignItems: 'center'
alignItems: 'center',
marginLeft: StyleConstants.Spacing.M
},
iconFront: {
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 { Feather } from '@expo/vector-icons'
import { StyleConstants } from '@utils/styles/constants'
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 {
status: QueryStatus
@ -13,7 +14,8 @@ export interface Props {
}
const TimelineEmpty: React.FC<Props> = ({ status, refetch }) => {
const { theme } = useTheme()
const { mode, theme } = useTheme()
const { t, i18n } = useTranslation('timeline')
const children = useMemo(() => {
switch (status) {
@ -30,9 +32,13 @@ const TimelineEmpty: React.FC<Props> = ({ status, refetch }) => {
color={theme.primary}
/>
<Text style={[styles.error, { color: theme.primary }]}>
{t('empty.error.message')}
</Text>
<Button type='text' content='重试' onPress={() => refetch()} />
<Button
type='text'
content={t('empty.error.button')}
onPress={() => refetch()}
/>
</>
)
case 'success':
@ -44,12 +50,12 @@ const TimelineEmpty: React.FC<Props> = ({ status, refetch }) => {
color={theme.primary}
/>
<Text style={[styles.error, { color: theme.primary }]}>
{t('empty.success.message')}
</Text>
</>
)
}
}, [status])
}, [mode, i18n.language, status])
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 { 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 { 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 {
account: Mastodon.Account
@ -17,6 +18,7 @@ const TimelineActioned: React.FC<Props> = ({
action,
notification = false
}) => {
const { t } = useTranslation('timeline')
const { theme } = useTheme()
const navigation = useNavigation()
const name = account.display_name || account.username
@ -41,7 +43,7 @@ const TimelineActioned: React.FC<Props> = ({
color={iconColor}
style={styles.icon}
/>
{content('置顶')}
{content(t('shared.actioned.pinned'))}
</>
)
break
@ -55,7 +57,7 @@ const TimelineActioned: React.FC<Props> = ({
style={styles.icon}
/>
<Pressable onPress={onPress}>
{content(`${name} 喜欢了你的嘟嘟`)}
{content(t('shared.actioned.favourite', { name }))}
</Pressable>
</>
)
@ -70,7 +72,7 @@ const TimelineActioned: React.FC<Props> = ({
style={styles.icon}
/>
<Pressable onPress={onPress}>
{content(`${name} 开始关注你`)}
{content(t('shared.actioned.follow', { name }))}
</Pressable>
</>
)
@ -84,7 +86,7 @@ const TimelineActioned: React.FC<Props> = ({
color={iconColor}
style={styles.icon}
/>
{content('你参与的投票已结束')}
{content(t('shared.actioned.poll'))}
</>
)
break
@ -98,7 +100,11 @@ const TimelineActioned: React.FC<Props> = ({
style={styles.icon}
/>
<Pressable onPress={onPress}>
{content(`${name} 转嘟了${notification ? '你的嘟嘟' : ''}`)}
{content(
notification
? t('shared.actioned.reblog.notification', { name })
: t('shared.actioned.reblog.default', { name })
)}
</Pressable>
</>
)

View File

@ -8,40 +8,10 @@ import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import { findIndex } from 'lodash'
import React, { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { ActionSheetIOS, Pressable, StyleSheet, Text, View } from 'react-native'
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 {
queryKey: QueryKey.Timeline
status: Mastodon.Status
@ -50,14 +20,32 @@ export interface Props {
const TimelineActions: React.FC<Props> = ({ queryKey, status, reblog }) => {
const navigation = useNavigation()
const { t } = useTranslation()
const { theme } = useTheme()
const iconColor = theme.secondary
const iconColorAction = (state: boolean) =>
state ? theme.primary : theme.secondary
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, {
onMutate: ({ id, type, stateKey, prevState }) => {
onMutate: ({ type, stateKey, state }) => {
queryClient.cancelQueries(queryKey)
const oldData = queryClient.getQueryData(queryKey)
@ -75,7 +63,7 @@ const TimelineActions: React.FC<Props> = ({ queryKey, status, reblog }) => {
: reblog
? 'reblog.id'
: 'id',
id
status.id
])
if (tempIndex >= 0) {
tootIndex = tempIndex
@ -94,14 +82,14 @@ const TimelineActions: React.FC<Props> = ({ queryKey, status, reblog }) => {
} else {
if (queryKey[0] === 'Notifications') {
old!.pages[pageIndex].toots[tootIndex].status[stateKey] =
typeof prevState === 'boolean' ? !prevState : true
typeof state === 'boolean' ? !state : true
} else {
if (reblog) {
old!.pages[pageIndex].toots[tootIndex].reblog![stateKey] =
typeof prevState === 'boolean' ? !prevState : true
typeof state === 'boolean' ? !state : true
} else {
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
},
onError: (err, _, oldData) => {
onError: (_, { type }, oldData) => {
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)
}
})
@ -133,30 +126,27 @@ const TimelineActions: React.FC<Props> = ({ queryKey, status, reblog }) => {
const onPressReblog = useCallback(
() =>
mutate({
id: status.id,
type: 'reblog',
stateKey: 'reblogged',
prevState: status.reblogged
state: status.reblogged
}),
[status.reblogged]
)
const onPressFavourite = useCallback(
() =>
mutate({
id: status.id,
type: 'favourite',
stateKey: 'favourited',
prevState: status.favourited
state: status.favourited
}),
[status.favourited]
)
const onPressBookmark = useCallback(
() =>
mutate({
id: status.id,
type: 'bookmark',
stateKey: 'bookmarked',
prevState: status.bookmarked
state: status.bookmarked
}),
[status.bookmarked]
)

View File

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

View File

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

View File

@ -1,6 +1,7 @@
import { ParseHTML } from '@components/Parse'
import { StyleConstants } from '@utils/styles/constants'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { View } from 'react-native'
export interface Props {
@ -14,6 +15,8 @@ const TimelineContent: React.FC<Props> = ({
numberOfLines,
highlighted = false
}) => {
const { t } = useTranslation('timeline')
return (
<>
{status.spoiler_text ? (
@ -35,7 +38,7 @@ const TimelineContent: React.FC<Props> = ({
mentions={status.mentions}
tags={status.tags}
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 { StyleConstants } from '@utils/styles/constants'
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 {
hasNextPage?: boolean
@ -18,13 +19,16 @@ const TimelineEnd: React.FC<Props> = ({ hasNextPage }) => {
<Chase size={StyleConstants.Font.Size.L} color={theme.secondary} />
) : (
<Text style={[styles.text, { color: theme.secondary }]}>
{' '}
<Feather
name='coffee'
size={StyleConstants.Font.Size.S}
color={theme.secondary}
/>{' '}
<Trans
i18nKey='timeline:shared.end.message' // optional -> fallbacks to defaults if not provided
components={[
<Feather
name='coffee'
size={StyleConstants.Font.Size.S}
color={theme.secondary}
/>
]}
/>
</Text>
)}
</View>

View File

@ -1,42 +1,41 @@
import client from '@api/client'
import haptics from '@components/haptics'
import { ParseEmojis } from '@components/Parse'
import relativeTime from '@components/relativeTime'
import { toast } from '@components/toast'
import { Feather } from '@expo/vector-icons'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
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 HeaderSharedAccount from './HeaderShared/Account'
import HeaderSharedCreated from './HeaderShared/Created'
export interface Props {
queryKey: QueryKey.Timeline
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 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, {
onMutate: () => {
queryClient.cancelQueries(queryKey)
@ -56,14 +55,14 @@ const HeaderConversation: React.FC<Props> = ({ queryKey, conversation }) => {
},
onError: (err, _, oldData) => {
haptics('Error')
toast({ type: 'error', content: '请重试', autoHide: false })
toast({ type: 'error', message: '请重试', autoHide: false })
queryClient.setQueryData(queryKey, oldData)
}
})
const { theme } = useTheme()
const actionOnPress = useCallback(() => mutate({ id: conversation.id }), [])
const actionOnPress = useCallback(() => mutate(), [])
const actionChildren = useMemo(
() => (
@ -78,31 +77,14 @@ const HeaderConversation: React.FC<Props> = ({ queryKey, conversation }) => {
return (
<View style={styles.base}>
<View style={styles.nameAndDate}>
<View style={styles.namdAndAccount}>
<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.nameAndMeta}>
<HeaderSharedAccount account={conversation.accounts[0]} />
<View style={styles.meta}>
{conversation.last_status?.created_at && (
<Text style={[styles.created_at, { color: theme.secondary }]}>
{relativeTime(conversation.last_status?.created_at)}
</Text>
)}
{conversation.last_status?.created_at ? (
<HeaderSharedCreated
created_at={conversation.last_status?.created_at}
/>
) : null}
{conversation.unread && (
<Feather name='circle' color={theme.blue} style={styles.unread} />
)}
@ -123,16 +105,8 @@ const styles = StyleSheet.create({
flex: 1,
flexDirection: 'row'
},
nameAndDate: {
width: '80%'
},
namdAndAccount: {
flexDirection: 'row',
alignItems: 'center'
},
account: {
flexShrink: 1,
marginLeft: StyleConstants.Spacing.XS
nameAndMeta: {
flex: 4
},
meta: {
flexDirection: 'row',
@ -147,7 +121,7 @@ const styles = StyleSheet.create({
marginLeft: StyleConstants.Spacing.XS
},
action: {
flexBasis: '20%',
flex: 1,
flexDirection: 'row',
justifyContent: 'center'
}

View File

@ -1,7 +1,4 @@
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 { getLocalUrl } from '@utils/slices/instancesSlice'
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 HeaderDefaultActionsDomain from '@components/Timelines/Timeline/Shared/HeaderDefault/ActionsDomain'
import HeaderDefaultActionsStatus from '@components/Timelines/Timeline/Shared/HeaderDefault/ActionsStatus'
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import React, { useCallback, useMemo, useState } from 'react'
import { Pressable, StyleSheet, View } from 'react-native'
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 {
queryKey?: QueryKey.Timeline
@ -27,32 +28,12 @@ const TimelineHeaderDefault: React.FC<Props> = ({
const domain = status.uri
? 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 localDomain = useSelector(getLocalUrl)
const [since, setSince] = useState(relativeTime(status.created_at))
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 onPressApplication = useCallback(
async () =>
status.application!.website &&
(await openLink(status.application!.website)),
[]
)
const pressableAction = useMemo(
() => (
@ -67,42 +48,12 @@ const TimelineHeaderDefault: React.FC<Props> = ({
return (
<View style={styles.base}>
<View style={queryKey ? { flexBasis: '80%' } : { flexBasis: '100%' }}>
<View style={styles.nameAndAccount}>
<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.accountAndMeta}>
<HeaderSharedAccount account={status.account} />
<View style={styles.meta}>
<View>
<Text style={[styles.created_at, { color: theme.secondary }]}>
{since}
</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>
)}
<HeaderSharedCreated created_at={status.created_at} />
<HeaderSharedVisibility visibility={status.visibility} />
<HeaderSharedApplication application={status.application} />
</View>
</View>
@ -154,16 +105,8 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'baseline'
},
nameAndMeta: {
flexBasis: '80%'
},
nameAndAccount: {
flexDirection: 'row',
alignItems: 'center'
},
account: {
flex: 1,
marginLeft: StyleConstants.Spacing.XS
accountAndMeta: {
flex: 4
},
meta: {
flexDirection: 'row',
@ -174,15 +117,8 @@ const styles = StyleSheet.create({
created_at: {
...StyleConstants.FontStyle.S
},
visibility: {
marginLeft: StyleConstants.Spacing.S
},
application: {
...StyleConstants.FontStyle.S,
marginLeft: StyleConstants.Spacing.S
},
action: {
flexBasis: '20%',
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
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 haptics from '@components/haptics'
import { MenuContainer, MenuHeader, MenuRow } from '@components/Menu'
import { toast } from '@components/toast'
import haptics from '@root/components/haptics'
const fireMutation = async ({
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
}
}
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useMutation, useQueryClient } from 'react-query'
export interface Props {
queryKey?: QueryKey.Timeline
account: Pick<Mastodon.Account, 'id' | 'username' | 'acct' | 'url'>
account: Pick<Mastodon.Account, 'id' | 'acct'>
setBottomSheetVisible: React.Dispatch<React.SetStateAction<boolean>>
}
@ -67,51 +17,103 @@ const HeaderDefaultActionsAccount: React.FC<Props> = ({
account,
setBottomSheetVisible
}) => {
const { t } = useTranslation()
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, {
onSettled: () => {
onSuccess: (_, { type }) => {
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)
}
})
return (
<MenuContainer>
<MenuHeader heading='关于账户' />
<MenuHeader
heading={t('timeline:shared.header.default.actions.account.heading')}
/>
<MenuRow
onPress={() => {
setBottomSheetVisible(false)
mutate({
type: 'mute',
id: account.id,
stateKey: 'muting'
})
mutate({ type: 'mute' })
}}
iconFront='eye-off'
title={`隐藏 @${account.acct} 的嘟嘟`}
title={t('timeline:shared.header.default.actions.account.mute.button', {
acct: account.acct
})}
/>
<MenuRow
onPress={() => {
setBottomSheetVisible(false)
mutate({
type: 'block',
id: account.id,
stateKey: 'blocking'
})
mutate({ type: 'block' })
}}
iconFront='x-circle'
title={`屏蔽用户 @${account.acct}`}
title={t(
'timeline:shared.header.default.actions.account.block.button',
{
acct: account.acct
}
)}
/>
<MenuRow
onPress={() => {
setBottomSheetVisible(false)
mutate({
type: 'reports',
id: account.id
})
mutate({ type: 'reports' })
}}
iconFront='flag'
title={`举报 @${account.acct}`}
title={t(
'timeline:shared.header.default.actions.account.report.button',
{
acct: account.acct
}
)}
/>
</MenuContainer>
)

View File

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

View File

@ -1,59 +1,14 @@
import { useNavigation } from '@react-navigation/native'
import React from 'react'
import { findIndex } from 'lodash'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { Alert } from 'react-native'
import { useMutation, useQueryClient } from 'react-query'
import client from '@api/client'
import haptics from '@components/haptics'
import { MenuContainer, MenuHeader, MenuRow } from '@components/Menu'
import { TimelineData } from '@components/Timelines/Timeline'
import { toast } from '@components/toast'
import { TimelineData } from '@root/components/Timelines/Timeline'
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
}
}
import { useNavigation } from '@react-navigation/native'
export interface Props {
queryKey: QueryKey.Timeline
@ -67,19 +22,56 @@ const HeaderDefaultActionsStatus: React.FC<Props> = ({
setBottomSheetVisible
}) => {
const navigation = useNavigation()
const { t } = useTranslation()
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, {
onMutate: ({ id, type, stateKey, prevState }) => {
onMutate: ({ type, state }) => {
queryClient.cancelQueries(queryKey)
const oldData = queryClient.getQueryData(queryKey)
switch (type) {
case 'mute':
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 => {
let tootIndex = -1
const pageIndex = findIndex(old?.pages, page => {
const tempIndex = findIndex(page.toots, ['id', id])
const tempIndex = findIndex(page.toots, ['id', status.id])
if (tempIndex >= 0) {
tootIndex = tempIndex
return true
@ -89,9 +81,8 @@ const HeaderDefaultActionsStatus: React.FC<Props> = ({
})
if (pageIndex >= 0 && tootIndex >= 0) {
old!.pages[pageIndex].toots[tootIndex][
stateKey as 'muted' | 'pinned'
] = typeof prevState === 'boolean' ? !prevState : true
old!.pages[pageIndex].toots[tootIndex][mapTypeToProp[type]] =
typeof state === 'boolean' ? !state : true // State could be null from response
}
return old
@ -105,7 +96,7 @@ const HeaderDefaultActionsStatus: React.FC<Props> = ({
...old,
pages: old?.pages.map(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
},
onError: (err, _, oldData) => {
toast({ type: 'error', content: '请重试' })
onError: (_, { type }, oldData) => {
toast({
type: 'error',
message: t('common:toastMessage.success.message', {
function: t(
`timeline:shared.header.default.actions.status.${type}.function`
)
})
})
queryClient.setQueryData(queryKey, oldData)
}
})
return (
<MenuContainer>
<MenuHeader heading='关于嘟嘟' />
<MenuHeader
heading={t('timeline:shared.header.default.actions.status.heading')}
/>
<MenuRow
onPress={() => {
setBottomSheetVisible(false)
mutate({
type: 'delete',
id: status.id,
stateKey: 'id'
})
mutate({ type: 'delete' })
}}
iconFront='trash'
title='删除嘟嘟'
title={t('timeline:shared.header.default.actions.status.delete.button')}
/>
<MenuRow
onPress={() => {
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',
onPress: async () => {
await client({
@ -160,7 +160,14 @@ const HeaderDefaultActionsStatus: React.FC<Props> = ({
})
})
.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'
title='删除并重新编辑'
title={t('timeline:shared.header.default.actions.status.edit.button')}
/>
<MenuRow
onPress={() => {
setBottomSheetVisible(false)
mutate({
type: 'mute',
id: status.id,
stateKey: 'muted',
prevState: status.muted
})
mutate({ type: 'mute', state: status.muted })
}}
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. */}
{(status.visibility === 'public' || status.visibility === 'unlisted') && (
<MenuRow
onPress={() => {
setBottomSheetVisible(false)
mutate({
type: 'pin',
id: status.id,
stateKey: 'pinned',
prevState: status.pinned
})
mutate({ type: 'pin', state: status.pinned })
}}
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>

View File

@ -1,33 +1,26 @@
import client from '@api/client'
import { Feather } from '@expo/vector-icons'
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 { relationshipFetch } from '@utils/fetches/relationshipFetch'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
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 { 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 {
notification: Mastodon.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 [since, setSince] = useState(relativeTime(notification.created_at))
const { status, data, refetch } = useQuery(
['Relationship', { id: notification.account.id }],
relationshipFetch,
@ -39,19 +32,6 @@ const TimelineHeaderNotification: React.FC<Props> = ({ notification }) => {
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(() => {
client({
method: 'post',
@ -72,7 +52,7 @@ const TimelineHeaderNotification: React.FC<Props> = ({ notification }) => {
return Promise.resolve()
} else {
haptics('Error')
toast({ type: 'error', content: '请重试', autoHide: false })
toast({ type: 'error', message: '请重试', autoHide: false })
return Promise.reject()
}
})
@ -127,44 +107,22 @@ const TimelineHeaderNotification: React.FC<Props> = ({ notification }) => {
return (
<View style={styles.base}>
<View style={styles.nameAndMeta}>
<View style={styles.nameAndAccount}>
<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.accountAndMeta}>
<HeaderSharedAccount
account={
notification.status
? notification.status.account
: notification.account
}
/>
<View style={styles.meta}>
<View>
<Text style={[styles.created_at, { color: theme.secondary }]}>
{since}
</Text>
</View>
{notification.status?.visibility === 'private' && (
<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>
)}
<HeaderSharedCreated created_at={notification.created_at} />
<HeaderSharedVisibility
visibility={notification.status?.visibility}
/>
<HeaderSharedApplication
application={notification.status?.application}
/>
</View>
</View>
@ -180,16 +138,8 @@ const styles = StyleSheet.create({
flex: 1,
flexDirection: 'row'
},
nameAndMeta: {
width: '80%'
},
nameAndAccount: {
flexDirection: 'row',
alignItems: 'center'
},
account: {
flexShrink: 1,
marginLeft: StyleConstants.Spacing.XS
accountAndMeta: {
flex: 4
},
meta: {
flexDirection: 'row',
@ -197,18 +147,8 @@ const styles = StyleSheet.create({
marginTop: StyleConstants.Spacing.XS,
marginBottom: StyleConstants.Spacing.S
},
created_at: {
...StyleConstants.FontStyle.S
},
visibility: {
marginLeft: StyleConstants.Spacing.S
},
application: {
...StyleConstants.FontStyle.S,
marginLeft: StyleConstants.Spacing.S
},
relationship: {
flexBasis: '20%',
flex: 1,
flexDirection: 'row',
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 { findIndex } from 'lodash'
import React, { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { useMutation, useQueryClient } from 'react-query'
@ -55,6 +56,7 @@ const TimelinePoll: React.FC<Props> = ({
sameAccount
}) => {
const { mode, theme } = useTheme()
const { t, i18n } = useTranslation('timeline')
const queryClient = useQueryClient()
const [allOptions, setAllOptions] = useState(
@ -107,7 +109,7 @@ const TimelinePoll: React.FC<Props> = ({
mutation.mutate({ id: poll.id, options: allOptions })
}
type='text'
content='投票'
content={t('shared.poll.meta.button.vote')}
loading={mutation.isLoading}
disabled={allOptions.filter(o => o !== false).length === 0}
/>
@ -118,9 +120,8 @@ const TimelinePoll: React.FC<Props> = ({
<View style={styles.button}>
<Button
onPress={() => mutation.mutate({ id: poll.id })}
{...(mutation.isLoading ? { icon: 'loader' } : { text: '刷新' })}
type='text'
content='刷新'
content={t('shared.poll.meta.button.refresh')}
loading={mutation.isLoading}
/>
</View>
@ -133,17 +134,19 @@ const TimelinePoll: React.FC<Props> = ({
if (poll.expired) {
return (
<Text style={[styles.expiration, { color: theme.secondary }]}>
{t('shared.poll.meta.expiration.expired')}
</Text>
)
} else {
return (
<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>
)
}
}, [mode])
}, [mode, poll.expired, poll.expires_at])
const isSelected = useCallback(
(index: number): any =>
@ -241,7 +244,7 @@ const TimelinePoll: React.FC<Props> = ({
<View style={styles.meta}>
{pollButton}
<Text style={[styles.votes, { color: theme.secondary }]}>
{poll.voters_count || 0}{' • '}
{t('shared.poll.meta.voted', { count: poll.voters_count })}
</Text>
{pollExpiration}
</View>
@ -270,7 +273,9 @@ const styles = StyleSheet.create({
optionPercentage: {
...StyleConstants.FontStyle.M,
alignSelf: 'center',
marginLeft: StyleConstants.Spacing.S
marginLeft: StyleConstants.Spacing.S,
flexBasis: '20%',
textAlign: 'center'
},
background: {
height: StyleConstants.Spacing.XS,

View File

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

View File

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

View File

@ -17,5 +17,7 @@ export default {
sharedAccount: require('./screens/sharedAccount').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 {
buttons: {
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
},
fieldLeft: {
flexBasis: '30%',
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
@ -72,7 +72,7 @@ const styles = StyleSheet.create({
},
fieldCheck: { marginLeft: StyleConstants.Spacing.XS },
fieldRight: {
flexBasis: '70%',
flex: 3,
alignItems: 'center',
justifyContent: 'center',
paddingLeft: StyleConstants.Spacing.S,

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import { Props } from '@screens/Shared/Compose'
import { ComposeState } from '@screens/Shared/Compose/utils/types'
import * as Crypto from 'expo-crypto'
const composeSend = async (
const composePost = async (
params: Props['route']['params'],
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 = (
state: ComposeState,