1
0
mirror of https://github.com/tooot-app/app synced 2025-05-29 10:24:24 +02:00

Merge pull request #687 from tooot-app/main

Release v4.8.6
This commit is contained in:
xmflsct 2023-01-30 14:35:31 +01:00 committed by GitHub
commit af5bfecb06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
82 changed files with 772 additions and 310 deletions

View File

@ -1,6 +1,3 @@
name({
'default' => "tooot"
})
keywords({ keywords({
'default' => "Mastodon,tooot,social,decentralized,长毛象,社交,去中心" 'default' => "Mastodon,tooot,social,decentralized,长毛象,社交,去中心"
}) })

View File

@ -0,0 +1 @@
../../en-US/name.txt

View File

@ -1 +0,0 @@
tooot

View File

@ -0,0 +1 @@
../../zh-Hans/name.txt

View File

@ -0,0 +1 @@
../en-US/name.txt

View File

@ -0,0 +1 @@
tooot - multilingual Mastodon app

View File

@ -1 +1 @@
Open source Mastodon client Simple, just works

View File

@ -0,0 +1 @@
tooot - 探索联邦宇宙

View File

@ -1 +1 @@
开源毛象客户端 简约,想你所想

View File

@ -1,6 +1,6 @@
{ {
"name": "tooot", "name": "tooot",
"version": "4.8.5", "version": "4.8.6",
"description": "tooot for Mastodon", "description": "tooot for Mastodon",
"author": "xmflsct <me@xmflsct.com>", "author": "xmflsct <me@xmflsct.com>",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",

View File

@ -2,6 +2,7 @@ import { ActionSheetProvider } from '@expo/react-native-action-sheet'
import * as Sentry from '@sentry/react-native' import * as Sentry from '@sentry/react-native'
import { QueryClientProvider } from '@tanstack/react-query' import { QueryClientProvider } from '@tanstack/react-query'
import AccessibilityManager from '@utils/accessibility/AccessibilityManager' import AccessibilityManager from '@utils/accessibility/AccessibilityManager'
import { connectVerify } from '@utils/api/helpers/connect'
import getLanguage from '@utils/helpers/getLanguage' import getLanguage from '@utils/helpers/getLanguage'
import { queryClient } from '@utils/queryHooks' import { queryClient } from '@utils/queryHooks'
import audio from '@utils/startup/audio' import audio from '@utils/startup/audio'
@ -23,6 +24,10 @@ import { enableFreeze } from 'react-native-screens'
import i18n from './i18n' import i18n from './i18n'
import Screens from './screens' import Screens from './screens'
export const GLOBAL: { connect?: boolean } = {
connect: undefined
}
Platform.select({ Platform.select({
android: LogBox.ignoreLogs(['Setting a timer for a long period of time']) android: LogBox.ignoreLogs(['Setting a timer for a long period of time'])
}) })
@ -50,20 +55,29 @@ const App: React.FC = () => {
await migrateFromAsyncStorage() await migrateFromAsyncStorage()
setHasMigrated(true) setHasMigrated(true)
} catch {} } catch {}
}
const useConnect = getGlobalStorage.boolean('app.connect')
GLOBAL.connect = useConnect
log('log', 'App', `connect: ${useConnect}`)
if (useConnect) {
await connectVerify()
.then(() => log('log', 'App', 'connected'))
.catch(() => log('warn', 'App', 'connect verify failed'))
}
log('log', 'App', 'loading from MMKV')
const account = getGlobalStorage.string('account.active')
if (account) {
await setAccount(account)
} else { } else {
log('log', 'App', 'loading from MMKV') log('log', 'App', 'No active account available')
const account = getGlobalStorage.string('account.active') const accounts = getGlobalStorage.object('accounts')
if (account) { if (accounts?.length) {
await setAccount(account) log('log', 'App', `Setting active account ${accounts[accounts.length - 1]}`)
await setAccount(accounts[accounts.length - 1])
} else { } else {
log('log', 'App', 'No active account available') setGlobalStorage('account.active', undefined)
const accounts = getGlobalStorage.object('accounts')
if (accounts?.length) {
log('log', 'App', `Setting active account ${accounts[accounts.length - 1]}`)
await setAccount(accounts[accounts.length - 1])
} else {
setGlobalStorage('account.active', undefined)
}
} }
} }

View File

@ -45,6 +45,7 @@ const ComponentAccount: React.FC<PropsWithChildren & Props> = ({ account, props,
borderRadius: 8, borderRadius: 8,
marginRight: StyleConstants.Spacing.S marginRight: StyleConstants.Spacing.S
}} }}
dim
/> />
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<CustomText numberOfLines={1}> <CustomText numberOfLines={1}>

View File

@ -1,9 +1,13 @@
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { ReadableAccountType, setAccount } from '@utils/storage/actions' import { ReadableAccountType, setAccount } from '@utils/storage/actions'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react' import React from 'react'
import Button from './Button' import { Pressable } from 'react-native'
import GracefullyImage from './GracefullyImage'
import haptics from './haptics' import haptics from './haptics'
import Icon from './Icon'
import CustomText from './Text'
interface Props { interface Props {
account: ReadableAccountType account: ReadableAccountType
@ -11,26 +15,56 @@ interface Props {
} }
const AccountButton: React.FC<Props> = ({ account, additionalActions }) => { const AccountButton: React.FC<Props> = ({ account, additionalActions }) => {
const { colors } = useTheme()
const navigation = useNavigation() const navigation = useNavigation()
return ( return (
<Button <Pressable
type='text'
selected={account.active}
style={{ style={{
marginBottom: StyleConstants.Spacing.M, flexDirection: 'row',
marginRight: StyleConstants.Spacing.M alignItems: 'center',
paddingVertical: StyleConstants.Spacing.S,
paddingHorizontal: StyleConstants.Spacing.S * 1.5,
borderColor: account.active ? colors.blue : colors.border,
borderWidth: 1,
borderRadius: 99,
marginBottom: StyleConstants.Spacing.S
}} }}
content={account.acct} onPress={async () => {
onPress={() => { await setAccount(account.key)
haptics('Light') haptics('Light')
setAccount(account.key)
navigation.goBack() navigation.goBack()
if (additionalActions) { if (additionalActions) {
additionalActions() additionalActions()
} }
}} }}
/> >
<GracefullyImage
uri={{ original: account.avatar_static }}
dimension={{
width: StyleConstants.Font.Size.L,
height: StyleConstants.Font.Size.L
}}
style={{ borderRadius: StyleConstants.Font.Size.L / 2, overflow: 'hidden' }}
/>
<CustomText
fontStyle='M'
fontWeight={account.active ? 'Bold' : 'Normal'}
style={{
color: account.active ? colors.blue : colors.primaryDefault,
marginLeft: StyleConstants.Spacing.S
}}
children={account.acct}
/>
{account.active ? (
<Icon
name='check'
size={StyleConstants.Font.Size.L}
color={colors.blue}
style={{ marginLeft: StyleConstants.Spacing.S }}
/>
) : null}
</Pressable>
) )
} }

View File

@ -2,6 +2,7 @@ import { emojis } from '@components/Emojis'
import Icon from '@components/Icon' import Icon from '@components/Icon'
import CustomText from '@components/Text' import CustomText from '@components/Text'
import { useAccessibility } from '@utils/accessibility/AccessibilityManager' import { useAccessibility } from '@utils/accessibility/AccessibilityManager'
import { connectImage } from '@utils/api/helpers/connect'
import { StorageAccount } from '@utils/storage/account' import { StorageAccount } from '@utils/storage/account'
import { getAccountStorage, setAccountStorage } from '@utils/storage/actions' import { getAccountStorage, setAccountStorage } from '@utils/storage/actions'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
@ -133,7 +134,7 @@ const EmojisList = () => {
emoji: emoji.shortcode emoji: emoji.shortcode
})} })}
accessibilityHint={t('screenCompose:content.root.footer.emojis.accessibilityHint')} accessibilityHint={t('screenCompose:content.root.footer.emojis.accessibilityHint')}
source={{ uri }} source={connectImage({ uri })}
style={{ width: 32, height: 32 }} style={{ width: 32, height: 32 }}
/> />
</Pressable> </Pressable>

View File

@ -2,8 +2,7 @@ import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import { Fragment } from 'react' import { Fragment } from 'react'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { View, ViewStyle } from 'react-native' import { Pressable, View, ViewStyle } from 'react-native'
import { TouchableNativeFeedback } from 'react-native-gesture-handler'
import Icon from './Icon' import Icon from './Icon'
import CustomText from './Text' import CustomText from './Text'
@ -19,7 +18,7 @@ export const Filter: React.FC<Props> = ({ onPress, filter, button, style }) => {
const { colors } = useTheme() const { colors } = useTheme()
return ( return (
<TouchableNativeFeedback onPress={onPress}> <Pressable onPress={onPress}>
<View <View
style={{ style={{
paddingVertical: StyleConstants.Spacing.S, paddingVertical: StyleConstants.Spacing.S,
@ -106,6 +105,6 @@ export const Filter: React.FC<Props> = ({ onPress, filter, button, style }) => {
/> />
)} )}
</View> </View>
</TouchableNativeFeedback> </Pressable>
) )
} }

View File

@ -1,4 +1,5 @@
import { useAccessibility } from '@utils/accessibility/AccessibilityManager' import { useAccessibility } from '@utils/accessibility/AccessibilityManager'
import { connectImage } from '@utils/api/helpers/connect'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { import {
@ -37,6 +38,7 @@ export interface Props {
height: number height: number
}> }>
> >
dim?: boolean
} }
const GracefullyImage = ({ const GracefullyImage = ({
@ -49,14 +51,15 @@ const GracefullyImage = ({
onPress, onPress,
style, style,
imageStyle, imageStyle,
setImageDimensions setImageDimensions,
dim
}: Props) => { }: Props) => {
const { reduceMotionEnabled } = useAccessibility() const { reduceMotionEnabled } = useAccessibility()
const { colors } = useTheme() const { colors, theme } = useTheme()
const [imageLoaded, setImageLoaded] = useState(false) const [imageLoaded, setImageLoaded] = useState(false)
const [currentUri, setCurrentUri] = useState<string | undefined>(uri.original || uri.remote) const [currentUri, setCurrentUri] = useState<string | undefined>(uri.original || uri.remote)
const source = { const source: { uri?: string } = {
uri: reduceMotionEnabled && uri.static ? uri.static : currentUri uri: reduceMotionEnabled && uri.static ? uri.static : currentUri
} }
useEffect(() => { useEffect(() => {
@ -90,12 +93,12 @@ const GracefullyImage = ({
> >
{uri.preview && !imageLoaded ? ( {uri.preview && !imageLoaded ? (
<FastImage <FastImage
source={{ uri: uri.preview }} source={connectImage({ uri: uri.preview })}
style={[styles.placeholder, { backgroundColor: colors.shimmerDefault }]} style={[styles.placeholder, { backgroundColor: colors.shimmerDefault }]}
/> />
) : null} ) : null}
<FastImage <FastImage
source={source} source={connectImage(source)}
style={[{ flex: 1 }, imageStyle]} style={[{ flex: 1 }, imageStyle]}
onLoad={() => { onLoad={() => {
setImageLoaded(true) setImageLoaded(true)
@ -110,6 +113,14 @@ const GracefullyImage = ({
}} }}
/> />
{blurhashView()} {blurhashView()}
{dim && theme !== 'light' ? (
<View
style={[
styles.placeholder,
{ backgroundColor: 'black', opacity: theme === 'dark_lighter' ? 0.18 : 0.36 }
]}
/>
) : null}
</Pressable> </Pressable>
) )
} }

View File

@ -1,11 +1,12 @@
import CustomText from '@components/Text' import CustomText from '@components/Text'
import { useAccessibility } from '@utils/accessibility/AccessibilityManager' import { useAccessibility } from '@utils/accessibility/AccessibilityManager'
import { connectImage } from '@utils/api/helpers/connect'
import { useGlobalStorage } from '@utils/storage/actions' import { useGlobalStorage } from '@utils/storage/actions'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { adaptiveScale } from '@utils/styles/scaling' import { adaptiveScale } from '@utils/styles/scaling'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react' import React from 'react'
import { Platform, TextStyle } from 'react-native' import { ColorValue, Platform, TextStyle } from 'react-native'
import FastImage from 'react-native-fast-image' import FastImage from 'react-native-fast-image'
const regexEmoji = new RegExp(/(:[A-Za-z0-9_]+:)/) const regexEmoji = new RegExp(/(:[A-Za-z0-9_]+:)/)
@ -14,6 +15,7 @@ export interface Props {
content?: string content?: string
emojis?: Mastodon.Emoji[] emojis?: Mastodon.Emoji[]
size?: 'S' | 'M' | 'L' size?: 'S' | 'M' | 'L'
color?: ColorValue
adaptiveSize?: boolean adaptiveSize?: boolean
fontBold?: boolean fontBold?: boolean
style?: TextStyle style?: TextStyle
@ -23,6 +25,7 @@ const ParseEmojis: React.FC<Props> = ({
content, content,
emojis, emojis,
size = 'M', size = 'M',
color,
adaptiveSize = false, adaptiveSize = false,
fontBold = false, fontBold = false,
style style
@ -41,13 +44,13 @@ const ParseEmojis: React.FC<Props> = ({
adaptiveSize ? adaptiveFontsize : 0 adaptiveSize ? adaptiveFontsize : 0
) )
const { colors, theme } = useTheme() const { colors } = useTheme()
return ( return (
<CustomText <CustomText
style={[ style={[
{ {
color: colors.primaryDefault, color: color || colors.primaryDefault,
fontSize: adaptedFontsize, fontSize: adaptedFontsize,
lineHeight: adaptedLineheight lineHeight: adaptedLineheight
}, },
@ -75,7 +78,7 @@ const ParseEmojis: React.FC<Props> = ({
<CustomText key={emojiShortcode + i}> <CustomText key={emojiShortcode + i}>
{i === 0 ? ' ' : undefined} {i === 0 ? ' ' : undefined}
<FastImage <FastImage
source={{ uri: uri.trim() }} source={connectImage({ uri: uri.trim() })}
style={{ style={{
width: adaptedFontsize, width: adaptedFontsize,
height: adaptedFontsize, height: adaptedFontsize,

View File

@ -16,11 +16,12 @@ import { ElementType, parseDocument } from 'htmlparser2'
import i18next from 'i18next' import i18next from 'i18next'
import React, { useContext, useRef, useState } from 'react' import React, { useContext, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Platform, Pressable, Text, View } from 'react-native' import { ColorValue, Platform, Pressable, Text, View } from 'react-native'
export interface Props { export interface Props {
content: string content: string
size?: 'S' | 'M' | 'L' size?: 'S' | 'M' | 'L'
color?: ColorValue
adaptiveSize?: boolean adaptiveSize?: boolean
showFullLink?: boolean showFullLink?: boolean
numberOfLines?: number numberOfLines?: number
@ -34,6 +35,7 @@ export interface Props {
const ParseHTML: React.FC<Props> = ({ const ParseHTML: React.FC<Props> = ({
content, content,
size = 'M', size = 'M',
color,
adaptiveSize = false, adaptiveSize = false,
showFullLink = false, showFullLink = false,
numberOfLines = 10, numberOfLines = 10,
@ -58,6 +60,7 @@ const ParseHTML: React.FC<Props> = ({
const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>() const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
const { params } = useRoute() const { params } = useRoute()
const { colors } = useTheme() const { colors } = useTheme()
const colorPrimary = color || colors.primaryDefault
const { t } = useTranslation('componentParse') const { t } = useTranslation('componentParse')
if (!expandHint) { if (!expandHint) {
expandHint = t('HTML.defaultHint') expandHint = t('HTML.defaultHint')
@ -111,6 +114,7 @@ const ParseHTML: React.FC<Props> = ({
content={content} content={content}
emojis={status?.emojis || emojis} emojis={status?.emojis || emojis}
size={size} size={size}
color={colorPrimary}
adaptiveSize={adaptiveSize} adaptiveSize={adaptiveSize}
/> />
) )
@ -181,7 +185,7 @@ const ParseHTML: React.FC<Props> = ({
return ( return (
<Text <Text
key={index} key={index}
style={{ color: matchedMention ? colors.blue : colors.primaryDefault }} style={{ color: matchedMention ? colors.blue : colorPrimary }}
onPress={() => onPress={() =>
matchedMention && matchedMention &&
!disableDetails && !disableDetails &&

View File

@ -1,5 +1,5 @@
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { ColorValue, TouchableNativeFeedback, View } from 'react-native' import { ColorValue, Pressable, View } from 'react-native'
import { SwipeListView } from 'react-native-swipe-list-view' import { SwipeListView } from 'react-native-swipe-list-view'
import haptics from './haptics' import haptics from './haptics'
import Icon, { IconName } from './Icon' import Icon, { IconName } from './Icon'
@ -25,7 +25,7 @@ export const SwipeToActions = <T extends unknown>({
renderHiddenItem={({ item }) => ( renderHiddenItem={({ item }) => (
<View style={{ flex: 1, flexDirection: 'row', justifyContent: 'flex-end' }}> <View style={{ flex: 1, flexDirection: 'row', justifyContent: 'flex-end' }}>
{actions.map((action, index) => ( {actions.map((action, index) => (
<TouchableNativeFeedback <Pressable
key={index} key={index}
onPress={() => { onPress={() => {
haptics(action.haptic || 'Light') haptics(action.haptic || 'Light')
@ -43,7 +43,7 @@ export const SwipeToActions = <T extends unknown>({
> >
<Icon name={action.icon} color='white' size={StyleConstants.Font.Size.L} /> <Icon name={action.icon} color='white' size={StyleConstants.Font.Size.L} />
</View> </View>
</TouchableNativeFeedback> </Pressable>
))} ))}
</View> </View>
)} )}

View File

@ -88,6 +88,7 @@ const TimelineConversation: React.FC<Props> = ({ conversation, queryKey, highlig
: StyleConstants.Avatar.M : StyleConstants.Avatar.M
}} }}
style={{ flex: 1, flexBasis: '50%' }} style={{ flex: 1, flexBasis: '50%' }}
dim
/> />
))} ))}
</View> </View>

View File

@ -33,6 +33,7 @@ export interface Props {
item: Mastodon.Status & { _pinned?: boolean } // For account page, internal property item: Mastodon.Status & { _pinned?: boolean } // For account page, internal property
queryKey?: QueryKeyTimeline queryKey?: QueryKeyTimeline
highlighted?: boolean highlighted?: boolean
suppressSpoiler?: boolean // Same content as the main thread, can be dimmed
disableDetails?: boolean disableDetails?: boolean
disableOnPress?: boolean disableOnPress?: boolean
isConversation?: boolean isConversation?: boolean
@ -44,6 +45,7 @@ const TimelineDefault: React.FC<Props> = ({
item, item,
queryKey, queryKey,
highlighted = false, highlighted = false,
suppressSpoiler = false,
disableDetails = false, disableDetails = false,
disableOnPress = false, disableOnPress = false,
isConversation = false, isConversation = false,
@ -170,6 +172,7 @@ const TimelineDefault: React.FC<Props> = ({
detectedLanguage, detectedLanguage,
excludeMentions, excludeMentions,
highlighted, highlighted,
suppressSpoiler,
inThread: queryKey?.[1].page === 'Toot', inThread: queryKey?.[1].page === 'Toot',
disableDetails, disableDetails,
disableOnPress, disableOnPress,

View File

@ -17,9 +17,9 @@ import Animated, {
Extrapolate, Extrapolate,
interpolate, interpolate,
runOnJS, runOnJS,
SharedValue,
useAnimatedReaction, useAnimatedReaction,
useAnimatedStyle, useAnimatedStyle,
useDerivedValue,
useSharedValue, useSharedValue,
withTiming withTiming
} from 'react-native-reanimated' } from 'react-native-reanimated'
@ -27,9 +27,10 @@ import Animated, {
export interface Props { export interface Props {
flRef: RefObject<FlatList<any>> flRef: RefObject<FlatList<any>>
queryKey: QueryKeyTimeline queryKey: QueryKeyTimeline
fetchingActive: React.MutableRefObject<boolean> isFetchingPrev: SharedValue<boolean>
scrollY: Animated.SharedValue<number> setFetchedCount: React.Dispatch<React.SetStateAction<number | null>>
fetchingType: Animated.SharedValue<0 | 1 | 2> scrollY: SharedValue<number>
fetchingType: SharedValue<0 | 1 | 2>
disableRefresh?: boolean disableRefresh?: boolean
readMarker?: 'read_marker_following' readMarker?: 'read_marker_following'
} }
@ -41,7 +42,8 @@ export const SEPARATION_Y_2 = -(CONTAINER_HEIGHT * 1.5 + StyleConstants.Font.Siz
const TimelineRefresh: React.FC<Props> = ({ const TimelineRefresh: React.FC<Props> = ({
flRef, flRef,
queryKey, queryKey,
fetchingActive, isFetchingPrev,
setFetchedCount,
scrollY, scrollY,
fetchingType, fetchingType,
disableRefresh = false, disableRefresh = false,
@ -55,20 +57,11 @@ const TimelineRefresh: React.FC<Props> = ({
} }
const PREV_PER_BATCH = 1 const PREV_PER_BATCH = 1
const prevActive = useRef<boolean>(false)
const prevCache = useRef<(Mastodon.Status | Mastodon.Notification | Mastodon.Conversation)[]>() const prevCache = useRef<(Mastodon.Status | Mastodon.Notification | Mastodon.Conversation)[]>()
const prevStatusId = useRef<Mastodon.Status['id']>() const prevStatusId = useRef<Mastodon.Status['id']>()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const { refetch, isRefetching } = useTimelineQuery({ ...queryKey[1] }) const { refetch } = useTimelineQuery({ ...queryKey[1] })
useDerivedValue(() => {
if (prevActive.current || isRefetching) {
fetchingActive.current = true
} else {
fetchingActive.current = false
}
}, [prevActive.current, isRefetching])
const { t } = useTranslation('componentTimeline') const { t } = useTranslation('componentTimeline')
const { colors } = useTheme() const { colors } = useTheme()
@ -96,7 +89,7 @@ const TimelineRefresh: React.FC<Props> = ({
const arrowStage = useSharedValue(0) const arrowStage = useSharedValue(0)
useAnimatedReaction( useAnimatedReaction(
() => { () => {
if (fetchingActive.current) { if (isFetchingPrev.value) {
return false return false
} }
switch (arrowStage.value) { switch (arrowStage.value) {
@ -128,13 +121,12 @@ const TimelineRefresh: React.FC<Props> = ({
if (data) { if (data) {
runOnJS(haptics)('Light') runOnJS(haptics)('Light')
} }
}, }
[fetchingActive.current]
) )
const fetchAndScrolled = useSharedValue(false) const fetchAndScrolled = useSharedValue(false)
const runFetchPrevious = async () => { const runFetchPrevious = async () => {
if (prevActive.current) return if (isFetchingPrev.value) return
const firstPage = const firstPage =
queryClient.getQueryData< queryClient.getQueryData<
@ -143,7 +135,7 @@ const TimelineRefresh: React.FC<Props> = ({
> >
>(queryKey)?.pages[0] >(queryKey)?.pages[0]
prevActive.current = true isFetchingPrev.value = true
prevStatusId.current = firstPage?.body[0]?.id prevStatusId.current = firstPage?.body[0]?.id
await queryFunctionTimeline({ await queryFunctionTimeline({
@ -151,7 +143,9 @@ const TimelineRefresh: React.FC<Props> = ({
pageParam: firstPage?.links?.prev, pageParam: firstPage?.links?.prev,
meta: {} meta: {}
}) })
.then(res => { .then(async res => {
setFetchedCount(res.body.length)
if (!res.body.length) return if (!res.body.length) return
queryClient.setQueryData< queryClient.setQueryData<
@ -172,7 +166,7 @@ const TimelineRefresh: React.FC<Props> = ({
}) })
.then(async nextLength => { .then(async nextLength => {
if (!nextLength) { if (!nextLength) {
prevActive.current = false isFetchingPrev.value = false
return return
} }
@ -209,7 +203,7 @@ const TimelineRefresh: React.FC<Props> = ({
} }
}) })
} }
prevActive.current = false isFetchingPrev.value = false
}) })
} }
@ -218,6 +212,18 @@ const TimelineRefresh: React.FC<Props> = ({
if (readMarker) { if (readMarker) {
setAccountStorage([{ key: readMarker, value: undefined }]) setAccountStorage([{ key: readMarker, value: undefined }])
} }
queryClient.setQueryData<
InfiniteData<
PagedResponse<(Mastodon.Status | Mastodon.Notification | Mastodon.Conversation)[]>
>
>(queryKey, old => {
if (!old) return old
return {
pages: [old.pages[0]],
pageParams: [old.pageParams[0]]
}
})
await refetch() await refetch()
setTimeout(() => flRef.current?.scrollToOffset({ offset: 0 }), 50) setTimeout(() => flRef.current?.scrollToOffset({ offset: 0 }), 50)
} }

View File

@ -83,11 +83,9 @@ const AttachmentAudio: React.FC<Props> = ({ total, index, sensitiveShown, audio
<> <>
{audio.preview_url ? ( {audio.preview_url ? (
<GracefullyImage <GracefullyImage
uri={{ uri={{ original: audio.preview_url, remote: audio.preview_remote_url }}
original: audio.preview_url,
remote: audio.preview_remote_url
}}
style={styles.background} style={styles.background}
dim
/> />
) : null} ) : null}
<Button <Button

View File

@ -39,6 +39,7 @@ const AttachmentImage = ({
blurhash={image.blurhash} blurhash={image.blurhash}
onPress={() => navigateToImagesViewer(image.id)} onPress={() => navigateToImagesViewer(image.id)}
style={{ aspectRatio: aspectRatio({ total, index, ...image.meta?.original }) }} style={{ aspectRatio: aspectRatio({ total, index, ...image.meta?.original }) }}
dim
/> />
</View> </View>
<AttachmentAltText sensitiveShown={sensitiveShown} text={image.description} /> <AttachmentAltText sensitiveShown={sensitiveShown} text={image.description} />

View File

@ -31,8 +31,7 @@ const TimelineAvatar: React.FC<Props> = ({ account }) => {
}) })
})} })}
onPress={() => onPress={() =>
!disableOnPress && !disableOnPress && navigation.push('Tab-Shared-Account', { account: actualAccount })
navigation.push('Tab-Shared-Account', { account: actualAccount })
} }
uri={{ original: actualAccount.avatar, static: actualAccount.avatar_static }} uri={{ original: actualAccount.avatar, static: actualAccount.avatar_static }}
dimension={ dimension={
@ -51,6 +50,7 @@ const TimelineAvatar: React.FC<Props> = ({ account }) => {
overflow: 'hidden', overflow: 'hidden',
marginRight: StyleConstants.Spacing.S marginRight: StyleConstants.Spacing.S
}} }}
dim
/> />
) )
} }

View File

@ -86,6 +86,7 @@ const TimelineCard: React.FC = () => {
blurhash={status.card.blurhash} blurhash={status.card.blurhash}
style={{ flexBasis: StyleConstants.Font.LineHeight.M * 5 }} style={{ flexBasis: StyleConstants.Font.LineHeight.M * 5 }}
imageStyle={{ borderTopLeftRadius: 6, borderBottomLeftRadius: 6 }} imageStyle={{ borderTopLeftRadius: 6, borderBottomLeftRadius: 6 }}
dim
/> />
) : null} ) : null}
<View style={{ flex: 1, padding: StyleConstants.Spacing.S }}> <View style={{ flex: 1, padding: StyleConstants.Spacing.S }}>

View File

@ -14,7 +14,7 @@ export interface Props {
} }
const TimelineContent: React.FC<Props> = ({ notificationOwnToot = false, setSpoilerExpanded }) => { const TimelineContent: React.FC<Props> = ({ notificationOwnToot = false, setSpoilerExpanded }) => {
const { status, highlighted, inThread } = useContext(StatusContext) const { status, highlighted, suppressSpoiler, inThread } = useContext(StatusContext)
if (!status || typeof status.content !== 'string' || !status.content.length) return null if (!status || typeof status.content !== 'string' || !status.content.length) return null
const { colors } = useTheme() const { colors } = useTheme()
@ -35,6 +35,7 @@ const TimelineContent: React.FC<Props> = ({ notificationOwnToot = false, setSpoi
size={highlighted ? 'L' : 'M'} size={highlighted ? 'L' : 'M'}
adaptiveSize adaptiveSize
numberOfLines={999} numberOfLines={999}
color={suppressSpoiler ? colors.disabled : undefined}
/> />
{inThread ? ( {inThread ? (
<CustomText <CustomText

View File

@ -15,6 +15,7 @@ type StatusContextType = {
excludeMentions?: React.MutableRefObject<Mastodon.Mention[]> excludeMentions?: React.MutableRefObject<Mastodon.Mention[]>
highlighted?: boolean highlighted?: boolean
suppressSpoiler?: boolean
inThread?: boolean inThread?: boolean
disableDetails?: boolean disableDetails?: boolean
disableOnPress?: boolean disableOnPress?: boolean

View File

@ -15,8 +15,8 @@ const TimelineFullConversation = () => {
return queryKey && return queryKey &&
queryKey[1].page !== 'Toot' && queryKey[1].page !== 'Toot' &&
status.in_reply_to_account_id && status.in_reply_to_account_id &&
(status.mentions.length === 0 || (status.mentions?.length === 0 ||
status.mentions.filter(mention => mention.id !== status.in_reply_to_account_id).length) ? ( status.mentions?.filter(mention => mention.id !== status.in_reply_to_account_id).length) ? (
<CustomText <CustomText
fontStyle='S' fontStyle='S'
style={{ style={{

View File

@ -22,7 +22,7 @@ const HeaderSharedReplies: React.FC = () => {
excludeMentions && excludeMentions &&
(excludeMentions.current = (excludeMentions.current =
mentionsBeginning?.length && status?.mentions mentionsBeginning?.length && status?.mentions
? status.mentions.filter(mention => mentionsBeginning.includes(`@${mention.username}`)) ? status.mentions?.filter(mention => mentionsBeginning.includes(`@${mention.username}`))
: []) : [])
return excludeMentions?.current.length ? ( return excludeMentions?.current.length ? (

View File

@ -1,4 +1,5 @@
import ComponentSeparator from '@components/Separator' import ComponentSeparator from '@components/Separator'
import CustomText from '@components/Text'
import TimelineDefault from '@components/Timeline/Default' import TimelineDefault from '@components/Timeline/Default'
import { useScrollToTop } from '@react-navigation/native' import { useScrollToTop } from '@react-navigation/native'
import { UseInfiniteQueryOptions } from '@tanstack/react-query' import { UseInfiniteQueryOptions } from '@tanstack/react-query'
@ -11,9 +12,21 @@ import {
} from '@utils/storage/actions' } from '@utils/storage/actions'
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, { RefObject, useRef } from 'react' import React, { RefObject, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { FlatList, FlatListProps, Platform, RefreshControl } from 'react-native' import { FlatList, FlatListProps, Platform, RefreshControl } from 'react-native'
import Animated, { useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated' import Animated, {
Easing,
runOnJS,
useAnimatedReaction,
useAnimatedScrollHandler,
useAnimatedStyle,
useDerivedValue,
useSharedValue,
withDelay,
withSequence,
withTiming
} from 'react-native-reanimated'
import TimelineEmpty from './Empty' import TimelineEmpty from './Empty'
import TimelineFooter from './Footer' import TimelineFooter from './Footer'
import TimelineRefresh, { SEPARATION_Y_1, SEPARATION_Y_2 } from './Refresh' import TimelineRefresh, { SEPARATION_Y_1, SEPARATION_Y_2 } from './Refresh'
@ -42,9 +55,10 @@ const Timeline: React.FC<Props> = ({
readMarker = undefined, readMarker = undefined,
customProps customProps
}) => { }) => {
const { colors } = useTheme() const { colors, theme } = useTheme()
const { t } = useTranslation('componentTimeline')
const { data, refetch, isFetching, isLoading, fetchNextPage, isFetchingNextPage } = const { data, refetch, isFetching, isLoading, isRefetching, fetchNextPage, isFetchingNextPage } =
useTimelineQuery({ useTimelineQuery({
...queryKey[1], ...queryKey[1],
options: { options: {
@ -57,7 +71,47 @@ const Timeline: React.FC<Props> = ({
}) })
const flRef = useRef<FlatList>(null) const flRef = useRef<FlatList>(null)
const fetchingActive = useRef<boolean>(false) const isFetchingPrev = useSharedValue<boolean>(false)
const [fetchedCount, setFetchedCount] = useState<number | null>(null)
const fetchedNoticeHeight = useSharedValue<number>(100)
const notifiedFetchedNotice = useSharedValue<boolean>(false)
useAnimatedReaction(
() => isFetchingPrev.value,
(curr, prev) => {
if (curr === true && prev === false) {
notifiedFetchedNotice.value = true
}
}
)
useAnimatedReaction(
() => fetchedCount,
(curr, prev) => {
if (curr !== null && prev === null) {
notifiedFetchedNotice.value = false
}
},
[fetchedCount]
)
const fetchedNoticeTop = useDerivedValue(() => {
if (notifiedFetchedNotice.value || fetchedCount !== null) {
return withSequence(
withTiming(fetchedNoticeHeight.value + 16 + 4),
withDelay(
2000,
withTiming(
0,
{ easing: Easing.out(Easing.ease) },
finished => finished && runOnJS(setFetchedCount)(null)
)
)
)
} else {
return 0
}
}, [fetchedCount])
const fetchedNoticeAnimate = useAnimatedStyle(() => ({
transform: [{ translateY: fetchedNoticeTop.value }]
}))
const scrollY = useSharedValue(0) const scrollY = useSharedValue(0)
const fetchingType = useSharedValue<0 | 1 | 2>(0) const fetchingType = useSharedValue<0 | 1 | 2>(0)
@ -95,7 +149,12 @@ const Timeline: React.FC<Props> = ({
const marker = readMarker ? getAccountStorage.string(readMarker) : undefined const marker = readMarker ? getAccountStorage.string(readMarker) : undefined
const firstItemId = viewableItems.filter(item => item.isViewable)[0]?.item.id const firstItemId = viewableItems.filter(item => item.isViewable)[0]?.item.id
if (!fetchingActive.current && firstItemId && firstItemId > (marker || '0')) { if (
!isFetchingPrev.value &&
!isRefetching &&
firstItemId &&
firstItemId > (marker || '0')
) {
setAccountStorage([{ key: readMarker, value: firstItemId }]) setAccountStorage([{ key: readMarker, value: firstItemId }])
} else { } else {
// setAccountStorage([{ key: readMarker, value: '109519141378761752' }]) // setAccountStorage([{ key: readMarker, value: '109519141378761752' }])
@ -135,7 +194,8 @@ const Timeline: React.FC<Props> = ({
<TimelineRefresh <TimelineRefresh
flRef={flRef} flRef={flRef}
queryKey={queryKey} queryKey={queryKey}
fetchingActive={fetchingActive} isFetchingPrev={isFetchingPrev}
setFetchedCount={setFetchedCount}
scrollY={scrollY} scrollY={scrollY}
fetchingType={fetchingType} fetchingType={fetchingType}
disableRefresh={disableRefresh} disableRefresh={disableRefresh}
@ -176,6 +236,44 @@ const Timeline: React.FC<Props> = ({
{...androidRefreshControl} {...androidRefreshControl}
{...customProps} {...customProps}
/> />
{!disableRefresh ? (
<Animated.View
style={[
{
position: 'absolute',
alignSelf: 'center',
top: -fetchedNoticeHeight.value - 16,
paddingVertical: StyleConstants.Spacing.S,
paddingHorizontal: StyleConstants.Spacing.M,
backgroundColor: colors.backgroundDefault,
shadowColor: colors.primaryDefault,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: theme === 'light' ? 0.16 : 0.24,
borderRadius: 99,
justifyContent: 'center',
alignItems: 'center'
},
fetchedNoticeAnimate
]}
onLayout={({
nativeEvent: {
layout: { height }
}
}) => (fetchedNoticeHeight.value = height)}
>
<CustomText
fontStyle='S'
style={{ color: colors.primaryDefault }}
children={
fetchedCount !== null
? fetchedCount > 0
? t('refresh.fetched.found', { count: fetchedCount })
: t('refresh.fetched.none')
: t('refresh.fetching')
}
/>
</Animated.View>
) : null}
</> </>
) )
} }

View File

@ -197,7 +197,7 @@ const menuStatus = ({
hidden: hidden:
!ownAccount && !ownAccount &&
queryKey[1].page !== 'Notifications' && queryKey[1].page !== 'Notifications' &&
!status.mentions.find( !status.mentions?.find(
mention => mention.acct === accountAcct && mention.username === accountAcct mention => mention.acct === accountAcct && mention.username === accountAcct
) && ) &&
!status.muted !status.muted

View File

@ -16,7 +16,7 @@
"action_true": "Deixa de silenciar l'usuari" "action_true": "Deixa de silenciar l'usuari"
}, },
"followAs": { "followAs": {
"trigger": "", "trigger": "Segueix com...",
"succeed_default": "Seguint a @{{target}} com @{{source}}", "succeed_default": "Seguint a @{{target}} com @{{source}}",
"succeed_locked": "Enviada la sol·licitud de seguiment a @{{target}} com {{source}}, pendent d'aprovar-la", "succeed_locked": "Enviada la sol·licitud de seguiment a @{{target}} com {{source}}, pendent d'aprovar-la",
"failed": "Segueix com" "failed": "Segueix com"

View File

@ -16,7 +16,12 @@
}, },
"refresh": { "refresh": {
"fetchPreviousPage": "Més recent des d'aquí", "fetchPreviousPage": "Més recent des d'aquí",
"refetch": "A l'últim" "refetch": "A l'últim",
"fetching": "Obtenint publicacions...",
"fetched": {
"none": "No n'hi ha de noves",
"found": "S'ha obtingut {{count}}"
}
}, },
"shared": { "shared": {
"actioned": { "actioned": {

View File

@ -46,7 +46,7 @@
"name": "Etiquetes seguides" "name": "Etiquetes seguides"
}, },
"fontSize": { "fontSize": {
"name": "Mida de la font de la publicació" "name": "Mida de la font"
}, },
"language": { "language": {
"name": "Idioma" "name": "Idioma"
@ -136,7 +136,7 @@
}, },
"preferences": { "preferences": {
"visibility": { "visibility": {
"title": "Visibilitat de la publicació per defecte", "title": "Visibilitat per defecte",
"options": { "options": {
"public": "Públic", "public": "Públic",
"unlisted": "Sense llistar", "unlisted": "Sense llistar",
@ -147,11 +147,11 @@
"title": "Marca el contingut com a sensible" "title": "Marca el contingut com a sensible"
}, },
"media": { "media": {
"title": "Mostra el contingut multimèdia", "title": "Multimèdia",
"options": { "options": {
"default": "Amaga el contingut multimèdia com a sensible", "default": "Amaga els sensibles",
"show_all": "Mostra sempre el contingut multimèdia", "show_all": "Mostra'ls sempre",
"hide_all": "Amaga sempre el contingut multimèdia" "hide_all": "Amaga'ls sempre"
} }
}, },
"spoilers": { "spoilers": {
@ -178,7 +178,7 @@
"context": "Aplica a <0 />", "context": "Aplica a <0 />",
"contexts": { "contexts": {
"home": "Seguits i llistes", "home": "Seguits i llistes",
"notifications": "Notificació", "notifications": "Notificacions",
"public": "federat", "public": "federat",
"thread": "Conversa", "thread": "Conversa",
"account": "Perfil" "account": "Perfil"
@ -199,17 +199,17 @@
"context": "Aplica a", "context": "Aplica a",
"contexts": { "contexts": {
"home": "Seguits i llistes", "home": "Seguits i llistes",
"notifications": "Notificació", "notifications": "Notificacions",
"public": "Línia de temps federada", "public": "Línia de temps federada",
"thread": "Vista de conversa", "thread": "Vista de conversa",
"account": "Vista del perfil" "account": "Vista del perfil"
}, },
"action": "Quan coincideix", "action": "Quan coincideix",
"actions": { "actions": {
"warn": "", "warn": "Contret però pot ser revelat",
"hide": "Amagat completament" "hide": "Amagat completament"
}, },
"keywords": "Coincidències per aquestes paraules claus", "keywords": "Coincidències amb",
"keyword": "Paraula clau", "keyword": "Paraula clau",
"statuses": "Coincideixen aquestes publicacions" "statuses": "Coincideixen aquestes publicacions"
}, },

View File

@ -16,7 +16,12 @@
}, },
"refresh": { "refresh": {
"fetchPreviousPage": "", "fetchPreviousPage": "",
"refetch": "" "refetch": "",
"fetching": "",
"fetched": {
"none": "",
"found": ""
}
}, },
"shared": { "shared": {
"actioned": { "actioned": {

View File

@ -16,7 +16,12 @@
}, },
"refresh": { "refresh": {
"fetchPreviousPage": "Neuere Einträge", "fetchPreviousPage": "Neuere Einträge",
"refetch": "Zum letzten" "refetch": "Zum letzten",
"fetching": "",
"fetched": {
"none": "",
"found": ""
}
}, },
"shared": { "shared": {
"actioned": { "actioned": {

View File

@ -16,7 +16,12 @@
}, },
"refresh": { "refresh": {
"fetchPreviousPage": "Νεότερες από εδώ", "fetchPreviousPage": "Νεότερες από εδώ",
"refetch": "Μέχρι την τελευταία" "refetch": "Μέχρι την τελευταία",
"fetching": "",
"fetched": {
"none": "",
"found": ""
}
}, },
"shared": { "shared": {
"actioned": { "actioned": {

View File

@ -16,7 +16,12 @@
}, },
"refresh": { "refresh": {
"fetchPreviousPage": "Newer from here", "fetchPreviousPage": "Newer from here",
"refetch": "To latest" "refetch": "To latest",
"fetching": "Fetching newer toots ...",
"fetched": {
"none": "No newer toot",
"found": "Fetched {{count}} toots"
}
}, },
"shared": { "shared": {
"actioned": { "actioned": {

View File

@ -16,7 +16,7 @@
"action_true": "Dejar de silenciar al usuario" "action_true": "Dejar de silenciar al usuario"
}, },
"followAs": { "followAs": {
"trigger": "", "trigger": "Seguir como...",
"succeed_default": "Siguiendo @{{target}} como @{{source}}", "succeed_default": "Siguiendo @{{target}} como @{{source}}",
"succeed_locked": "Enviado la solicitud de seguimiento a @{{target}} como {{source}}, pendiente de aprobación", "succeed_locked": "Enviado la solicitud de seguimiento a @{{target}} como {{source}}, pendiente de aprobación",
"failed": "Seguir como" "failed": "Seguir como"

View File

@ -16,7 +16,12 @@
}, },
"refresh": { "refresh": {
"fetchPreviousPage": "Más reciente desde aquí", "fetchPreviousPage": "Más reciente desde aquí",
"refetch": "Al último" "refetch": "Al último",
"fetching": "Obteniendo publicaciones...",
"fetched": {
"none": "No hay nuevas",
"found": "Se ha obtenido {{count}}"
}
}, },
"shared": { "shared": {
"actioned": { "actioned": {

View File

@ -136,7 +136,7 @@
}, },
"preferences": { "preferences": {
"visibility": { "visibility": {
"title": "Visibilidad de publicación predeterminada", "title": "Visibilidad por defecto",
"options": { "options": {
"public": "Público", "public": "Público",
"unlisted": "No listado", "unlisted": "No listado",
@ -147,11 +147,11 @@
"title": "Marcar el contenido multimedia como sensibles por defecto" "title": "Marcar el contenido multimedia como sensibles por defecto"
}, },
"media": { "media": {
"title": "Mostrar el contenido multimedia", "title": "Multimedia",
"options": { "options": {
"default": "Ocultar los contenidos multimedia marcados como sensibles", "default": "Ocultar los sensibles",
"show_all": "Mostrar siempre el contenido multimedia", "show_all": "Mostrar siempre",
"hide_all": "Siempre ocultar el contenido multimedia" "hide_all": "Ocultar siempre"
} }
}, },
"spoilers": { "spoilers": {
@ -178,7 +178,7 @@
"context": "Se aplica en <0 />", "context": "Se aplica en <0 />",
"contexts": { "contexts": {
"home": "Seguidos y listas", "home": "Seguidos y listas",
"notifications": "Notificación", "notifications": "Notificaciones",
"public": "Federado", "public": "Federado",
"thread": "Conversación", "thread": "Conversación",
"account": "Perfil" "account": "Perfil"
@ -199,17 +199,17 @@
"context": "Se aplica en", "context": "Se aplica en",
"contexts": { "contexts": {
"home": "Seguidos y listas", "home": "Seguidos y listas",
"notifications": "Notificación", "notifications": "Notificaciones",
"public": "Cronología federada", "public": "Cronología federada",
"thread": "Vista de conversación", "thread": "Vista de conversación",
"account": "Vista de perfil" "account": "Vista de perfil"
}, },
"action": "Al coincidir", "action": "Al coincidir",
"actions": { "actions": {
"warn": "", "warn": "Contraído pero puede ser revelado",
"hide": "Oculto completamente" "hide": "Oculto completamente"
}, },
"keywords": "Coincide con estas palabras clave", "keywords": "Coincide con",
"keyword": "Palabra clave", "keyword": "Palabra clave",
"statuses": "Coincide con estas publicaciones" "statuses": "Coincide con estas publicaciones"
}, },

View File

@ -16,7 +16,12 @@
}, },
"refresh": { "refresh": {
"fetchPreviousPage": "Berrienak hemendik hasita", "fetchPreviousPage": "Berrienak hemendik hasita",
"refetch": "Azkenekora arte" "refetch": "Azkenekora arte",
"fetching": "",
"fetched": {
"none": "",
"found": ""
}
}, },
"shared": { "shared": {
"actioned": { "actioned": {

View File

@ -16,7 +16,12 @@
}, },
"refresh": { "refresh": {
"fetchPreviousPage": "Plus récent à partir d'ici", "fetchPreviousPage": "Plus récent à partir d'ici",
"refetch": "À la dernière" "refetch": "À la dernière",
"fetching": "",
"fetched": {
"none": "",
"found": ""
}
}, },
"shared": { "shared": {
"actioned": { "actioned": {

View File

@ -16,7 +16,12 @@
}, },
"refresh": { "refresh": {
"fetchPreviousPage": "Più recenti da qui", "fetchPreviousPage": "Più recenti da qui",
"refetch": "Al più recente" "refetch": "Al più recente",
"fetching": "",
"fetched": {
"none": "",
"found": ""
}
}, },
"shared": { "shared": {
"actioned": { "actioned": {

View File

@ -16,7 +16,12 @@
}, },
"refresh": { "refresh": {
"fetchPreviousPage": "前のページを取得", "fetchPreviousPage": "前のページを取得",
"refetch": "更新" "refetch": "更新",
"fetching": "",
"fetched": {
"none": "",
"found": ""
}
}, },
"shared": { "shared": {
"actioned": { "actioned": {

View File

@ -16,7 +16,12 @@
}, },
"refresh": { "refresh": {
"fetchPreviousPage": "이 시점에 이어서 불러오기", "fetchPreviousPage": "이 시점에 이어서 불러오기",
"refetch": "최신 내용 불러오기" "refetch": "최신 내용 불러오기",
"fetching": "",
"fetched": {
"none": "",
"found": ""
}
}, },
"shared": { "shared": {
"actioned": { "actioned": {

View File

@ -16,7 +16,12 @@
}, },
"refresh": { "refresh": {
"fetchPreviousPage": "Nieuwere vanaf hier", "fetchPreviousPage": "Nieuwere vanaf hier",
"refetch": "Naar nieuwste" "refetch": "Naar nieuwste",
"fetching": "Nieuwere toots ophalen...",
"fetched": {
"none": "Geen nieuwere toot",
"found": "{{count}} toots opgehaald"
}
}, },
"shared": { "shared": {
"actioned": { "actioned": {

View File

@ -16,7 +16,12 @@
}, },
"refresh": { "refresh": {
"fetchPreviousPage": "Nowsze", "fetchPreviousPage": "Nowsze",
"refetch": "Do najnowszych" "refetch": "Do najnowszych",
"fetching": "",
"fetched": {
"none": "",
"found": ""
}
}, },
"shared": { "shared": {
"actioned": { "actioned": {

View File

@ -5,7 +5,7 @@
"cancel": "Cancelar", "cancel": "Cancelar",
"discard": "Descartar", "discard": "Descartar",
"continue": "Continuar", "continue": "Continuar",
"create": "", "create": "Criar",
"delete": "Excluir", "delete": "Excluir",
"done": "Concluído", "done": "Concluído",
"confirm": "Confirmar" "confirm": "Confirmar"

View File

@ -6,9 +6,9 @@
"action_false": "Seguir usuário", "action_false": "Seguir usuário",
"action_true": "Deixar de seguir usuário" "action_true": "Deixar de seguir usuário"
}, },
"inLists": "", "inLists": "Listas contendo usuário ...",
"showBoosts": { "showBoosts": {
"action_false": "", "action_false": "Mostrar boosts do usuário",
"action_true": "" "action_true": ""
}, },
"mute": { "mute": {
@ -21,7 +21,7 @@
"succeed_locked": "", "succeed_locked": "",
"failed": "" "failed": ""
}, },
"blockReport": "", "blockReport": "Bloquear e denunciar",
"block": { "block": {
"action_false": "Bloquear usuário", "action_false": "Bloquear usuário",
"action_true": "Desbloquear usuário", "action_true": "Desbloquear usuário",
@ -56,11 +56,11 @@
}, },
"hashtag": { "hashtag": {
"follow": { "follow": {
"action_false": "", "action_false": "Seguir",
"action_true": "" "action_true": "Deixar de seguir"
}, },
"filter": { "filter": {
"action": "" "action": "Filtrar hashtag ..."
} }
}, },
"share": { "share": {
@ -99,8 +99,8 @@
"action_true": "Desafixar toot" "action_true": "Desafixar toot"
}, },
"filter": { "filter": {
"action_false": "", "action_false": "Filtrar toot ...",
"action_true": "" "action_true": "Gerenciar filtros ..."
} }
} }
} }

View File

@ -16,7 +16,12 @@
}, },
"refresh": { "refresh": {
"fetchPreviousPage": "Mais novo aqui", "fetchPreviousPage": "Mais novo aqui",
"refetch": "Mais recente" "refetch": "Mais recente",
"fetching": "Buscando toots mais recentes...",
"fetched": {
"none": "Nenhum novo toot",
"found": ""
}
}, },
"shared": { "shared": {
"actioned": { "actioned": {
@ -116,7 +121,7 @@
"accessibilityHint": "Conta do usuário" "accessibilityHint": "Conta do usuário"
} }
}, },
"application": "", "application": "via {{application}}",
"edited": { "edited": {
"accessibilityLabel": "Toot editado" "accessibilityLabel": "Toot editado"
}, },

View File

@ -61,7 +61,7 @@
"name": "Criar uma lista" "name": "Criar uma lista"
}, },
"listEdit": { "listEdit": {
"name": "" "name": "Editar Detalhes da Lista"
}, },
"lists": { "lists": {
"name": "Listas" "name": "Listas"
@ -116,7 +116,7 @@
"empty": "Nenhum usuário adicionado a esta lista" "empty": "Nenhum usuário adicionado a esta lista"
}, },
"listEdit": { "listEdit": {
"heading": "", "heading": "Editar detalhes da lista",
"title": "Título", "title": "Título",
"repliesPolicy": { "repliesPolicy": {
"heading": "Mostrar respostas para:", "heading": "Mostrar respostas para:",
@ -144,7 +144,7 @@
} }
}, },
"sensitive": { "sensitive": {
"title": "" "title": "Marcar mídia como sensível por padrão"
}, },
"media": { "media": {
"title": "", "title": "",
@ -166,22 +166,22 @@
}, },
"web_only": { "web_only": {
"title": "", "title": "",
"description": "" "description": "As configurações abaixo só podem ser atualizadas usando a interface web"
} }
}, },
"preferencesFilters": { "preferencesFilters": {
"expired": "", "expired": "",
"keywords_one": "", "keywords_one": "{{count}} palavra-chave",
"keywords_other": "", "keywords_other": "{{count}} palavras-chave",
"statuses_one": "", "statuses_one": "{{count}} toot",
"statuses_other": "", "statuses_other": "{{count}} toots",
"context": "", "context": "Aplica-se em <0 />",
"contexts": { "contexts": {
"home": "", "home": "seguindo e listas",
"notifications": "", "notifications": "notificação",
"public": "", "public": "global",
"thread": "", "thread": "",
"account": "" "account": "perfil"
} }
}, },
"preferencesFilter": { "preferencesFilter": {
@ -196,22 +196,22 @@
"604800": "Após 1 semana", "604800": "Após 1 semana",
"18144000": "Após 1 mês" "18144000": "Após 1 mês"
}, },
"context": "", "context": "Aplica-se em",
"contexts": { "contexts": {
"home": "", "home": "Seguindo e listas",
"notifications": "", "notifications": "Notificação",
"public": "", "public": "Linha do tempo global",
"thread": "", "thread": "",
"account": "" "account": ""
}, },
"action": "", "action": "",
"actions": { "actions": {
"warn": "", "warn": "Recolhido, mas pode ser revelado",
"hide": "" "hide": "Esconder completamente"
}, },
"keywords": "", "keywords": "",
"keyword": "", "keyword": "Palavra-chave",
"statuses": "" "statuses": "Corresponde a estes toots"
}, },
"profile": { "profile": {
"feedback": { "feedback": {
@ -252,7 +252,7 @@
"label": "Rótulo", "label": "Rótulo",
"content": "Conteúdo" "content": "Conteúdo"
}, },
"mediaSelectionFailed": "" "mediaSelectionFailed": "Falha no processamento da imagem. Por favor, tente novamente."
}, },
"push": { "push": {
"notAvailable": "Seu telefone não suporta notificação de envio de tooot", "notAvailable": "Seu telefone não suporta notificação de envio de tooot",
@ -343,7 +343,7 @@
"heading": "Tema escuro", "heading": "Tema escuro",
"options": { "options": {
"lighter": "Padrão", "lighter": "Padrão",
"darker": "" "darker": "Preto verdadeiro"
} }
}, },
"browser": { "browser": {
@ -354,7 +354,7 @@
} }
}, },
"autoplayGifv": { "autoplayGifv": {
"heading": "" "heading": "Reproduzir GIFs automaticamente na linha do tempo"
}, },
"feedback": { "feedback": {
"heading": "Pedidos de Funcionalidades" "heading": "Pedidos de Funcionalidades"
@ -392,37 +392,37 @@
"suspended": "Conta suspensa pelos moderadores do seu servidor" "suspended": "Conta suspensa pelos moderadores do seu servidor"
}, },
"accountInLists": { "accountInLists": {
"name": "", "name": "Listas de @{{username}}",
"inLists": "", "inLists": "",
"notInLists": "" "notInLists": "Outras listas"
}, },
"attachments": { "attachments": {
"name": "<0 /><1>\"s mídia</1>" "name": "<0 /><1>\"s mídia</1>"
}, },
"filter": { "filter": {
"name": "", "name": "Adicionar ao filtro",
"existed": "" "existed": "Existe nestes filtros"
}, },
"history": { "history": {
"name": "Histórico de Edição" "name": "Histórico de Edição"
}, },
"report": { "report": {
"name": "", "name": "Denuncia {{acct}}",
"report": "", "report": "Denunciar",
"forward": { "forward": {
"heading": "" "heading": "Encaminhar anonimamente para o servidor remoto {{instance}}"
}, },
"reasons": { "reasons": {
"heading": "", "heading": "O que há de errado com essa conta?",
"spam": "", "spam": "É spam",
"other": "", "other": "É outra coisa",
"violation": "" "violation": "Viola as regras do servidor"
}, },
"comment": { "comment": {
"heading": "" "heading": "Deseja nos dizer mais alguma coisa?"
}, },
"violatedRules": { "violatedRules": {
"heading": "" "heading": "Regras violadas do servidor"
} }
}, },
"search": { "search": {
@ -442,7 +442,7 @@
} }
}, },
"trending": { "trending": {
"tags": "" "tags": "Hashtags em alta"
} }
}, },
"sections": { "sections": {
@ -451,13 +451,13 @@
"statuses": "Toot" "statuses": "Toot"
}, },
"notFound": "Não foi possível encontrar <bold>{{searchTerm}}</bold> {{type}} relacionado", "notFound": "Não foi possível encontrar <bold>{{searchTerm}}</bold> {{type}} relacionado",
"noResult": "" "noResult": "Não foi possível encontrar nada, tente um termo diferente"
}, },
"toot": { "toot": {
"name": "Discussões", "name": "Discussões",
"remoteFetch": { "remoteFetch": {
"title": "", "title": "",
"message": "" "message": "O conteúdo global nem sempre está disponível na instância local. Estes conteúdos são obtidos de instâncias remotas e marcados. Você pode interagir com esses conteúdos normalmente."
} }
}, },
"users": { "users": {

View File

@ -16,7 +16,12 @@
}, },
"refresh": { "refresh": {
"fetchPreviousPage": "", "fetchPreviousPage": "",
"refetch": "" "refetch": "",
"fetching": "",
"fetched": {
"none": "",
"found": ""
}
}, },
"shared": { "shared": {
"actioned": { "actioned": {

View File

@ -16,7 +16,12 @@
}, },
"refresh": { "refresh": {
"fetchPreviousPage": "Nyare härifrån", "fetchPreviousPage": "Nyare härifrån",
"refetch": "Till senaste" "refetch": "Till senaste",
"fetching": "",
"fetched": {
"none": "",
"found": ""
}
}, },
"shared": { "shared": {
"actioned": { "actioned": {

View File

@ -16,7 +16,12 @@
}, },
"refresh": { "refresh": {
"fetchPreviousPage": "З цього моменту", "fetchPreviousPage": "З цього моменту",
"refetch": "До кінця" "refetch": "До кінця",
"fetching": "Отримання нових дмухів ...",
"fetched": {
"none": "Немає нових дмухів",
"found": "Отримано {{count}} дмухів"
}
}, },
"shared": { "shared": {
"actioned": { "actioned": {

View File

@ -16,7 +16,12 @@
}, },
"refresh": { "refresh": {
"fetchPreviousPage": "Trước đó", "fetchPreviousPage": "Trước đó",
"refetch": "Trang cuối" "refetch": "Trang cuối",
"fetching": "",
"fetched": {
"none": "",
"found": ""
}
}, },
"shared": { "shared": {
"actioned": { "actioned": {

View File

@ -16,7 +16,12 @@
}, },
"refresh": { "refresh": {
"fetchPreviousPage": "较新于此的嘟嘟", "fetchPreviousPage": "较新于此的嘟嘟",
"refetch": "最新的嘟嘟" "refetch": "最新的嘟嘟",
"fetching": "获取较新的嘟文…",
"fetched": {
"none": "没有更新的嘟文",
"found": "已获取 {{count}} 条嘟文"
}
}, },
"shared": { "shared": {
"actioned": { "actioned": {

View File

@ -16,7 +16,12 @@
}, },
"refresh": { "refresh": {
"fetchPreviousPage": "較新的嘟文", "fetchPreviousPage": "較新的嘟文",
"refetch": "到最新的位置" "refetch": "到最新的位置",
"fetching": "取得較新的嘟文 …",
"fetched": {
"none": "沒有更新的嘟文",
"found": "已取得 {{count}} 條嘟文"
}
}, },
"shared": { "shared": {
"actioned": { "actioned": {

View File

@ -92,7 +92,7 @@ const ScreenAccountSelection = ({
const { colors } = useTheme() const { colors } = useTheme()
const { t } = useTranslation('screenAccountSelection') const { t } = useTranslation('screenAccountSelection')
const accounts = getReadableAccounts(true) const accounts = getReadableAccounts()
return ( return (
<ScrollView <ScrollView
@ -129,7 +129,7 @@ const ScreenAccountSelection = ({
return ( return (
<AccountButton <AccountButton
key={index} key={index}
account={account} account={{ ...account, active: false }}
additionalActions={() => additionalActions={() =>
navigationRef.navigate('Screen-Compose', { navigationRef.navigate('Screen-Compose', {
type: 'share', type: 'share',

View File

@ -6,6 +6,7 @@ import RelativeTime from '@components/RelativeTime'
import CustomText from '@components/Text' import CustomText from '@components/Text'
import { BlurView } from '@react-native-community/blur' import { BlurView } from '@react-native-community/blur'
import { useAccessibility } from '@utils/accessibility/AccessibilityManager' import { useAccessibility } from '@utils/accessibility/AccessibilityManager'
import { connectImage } from '@utils/api/helpers/connect'
import { RootStackScreenProps } from '@utils/navigation/navigators' import { RootStackScreenProps } from '@utils/navigation/navigators'
import { useAnnouncementMutation, useAnnouncementQuery } from '@utils/queryHooks/announcement' import { useAnnouncementMutation, useAnnouncementQuery } from '@utils/queryHooks/announcement'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
@ -139,9 +140,9 @@ const ScreenAnnouncements: React.FC<RootStackScreenProps<'Screen-Announcements'>
> >
{reaction.url ? ( {reaction.url ? (
<FastImage <FastImage
source={{ source={connectImage({
uri: reduceMotionEnabled ? reaction.static_url : reaction.url uri: reduceMotionEnabled ? reaction.static_url : reaction.url
}} })}
style={{ style={{
width: StyleConstants.Font.LineHeight.M + 3, width: StyleConstants.Font.LineHeight.M + 3,
height: StyleConstants.Font.LineHeight.M height: StyleConstants.Font.LineHeight.M

View File

@ -3,6 +3,7 @@ import Icon from '@components/Icon'
import { SwipeToActions } from '@components/SwipeToActions' import { SwipeToActions } from '@components/SwipeToActions'
import CustomText from '@components/Text' import CustomText from '@components/Text'
import HeaderSharedCreated from '@components/Timeline/Shared/HeaderShared/Created' import HeaderSharedCreated from '@components/Timeline/Shared/HeaderShared/Created'
import { connectImage } from '@utils/api/helpers/connect'
import apiInstance from '@utils/api/instance' import apiInstance from '@utils/api/instance'
import { ScreenComposeStackScreenProps } from '@utils/navigation/navigators' import { ScreenComposeStackScreenProps } from '@utils/navigation/navigators'
import { getAccountStorage, setAccountStorage, useAccountStorage } from '@utils/storage/actions' import { getAccountStorage, setAccountStorage, useAccountStorage } from '@utils/storage/actions'
@ -154,9 +155,11 @@ const ComposeDraftsList: React.FC<ScreenComposeStackScreenProps<'Screen-Compose-
4, 4,
marginLeft: index !== 0 ? StyleConstants.Spacing.S : 0 marginLeft: index !== 0 ? StyleConstants.Spacing.S : 0
}} }}
source={{ source={
uri: attachment.local?.thumbnail || attachment.remote?.preview_url attachment.local?.thumbnail
}} ? { uri: attachment.local?.thumbnail }
: connectImage({ uri: attachment.remote?.preview_url })
}
/> />
))} ))}
</View> </View>

View File

@ -6,6 +6,7 @@ import { MAX_MEDIA_ATTACHMENTS } from '@components/mediaSelector'
import CustomText from '@components/Text' import CustomText from '@components/Text'
import { useActionSheet } from '@expo/react-native-action-sheet' import { useActionSheet } from '@expo/react-native-action-sheet'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { connectImage } from '@utils/api/helpers/connect'
import { featureCheck } from '@utils/helpers/featureCheck' import { featureCheck } from '@utils/helpers/featureCheck'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import layoutAnimation from '@utils/styles/layoutAnimation' import layoutAnimation from '@utils/styles/layoutAnimation'
@ -105,7 +106,11 @@ const ComposeAttachments: React.FC<Props> = ({ accessibleRefAttachments }) => {
> >
<FastImage <FastImage
style={{ width: '100%', height: '100%' }} style={{ width: '100%', height: '100%' }}
source={{ uri: item.local?.thumbnail || item.remote?.preview_url }} source={
item.local?.thumbnail
? { uri: item.local?.thumbnail }
: connectImage({ uri: item.remote?.preview_url })
}
/> />
{item.remote?.meta?.original?.duration ? ( {item.remote?.meta?.original?.duration ? (
<CustomText <CustomText
@ -164,7 +169,8 @@ const ComposeAttachments: React.FC<Props> = ({ accessibleRefAttachments }) => {
haptics('Success') haptics('Success')
}} }}
/> />
{composeState.type === 'edit' && featureCheck('edit_media_details') ? ( {composeState.type !== 'edit' ||
(composeState.type === 'edit' && featureCheck('edit_media_details')) ? (
<Button <Button
accessibilityLabel={t('content.root.footer.attachments.edit.accessibilityLabel', { accessibilityLabel={t('content.root.footer.attachments.edit.accessibilityLabel', {
attachment: index + 1 attachment: index + 1

View File

@ -96,10 +96,10 @@ const Collections: React.FC = () => {
iconBack='chevron-right' iconBack='chevron-right'
title={t('screenTabs:me.stacks.push.name')} title={t('screenTabs:me.stacks.push.name')}
content={ content={
typeof instancePush.global === 'boolean' typeof instancePush?.global === 'boolean'
? t('screenTabs:me.root.push.content', { ? t('screenTabs:me.root.push.content', {
defaultValue: 'false', defaultValue: 'false',
context: instancePush.global.toString() context: instancePush?.global.toString()
}) })
: undefined : undefined
} }

View File

@ -1,16 +1,18 @@
import haptics from '@components/haptics' import haptics from '@components/haptics'
import { MenuContainer, MenuRow } from '@components/Menu' import { MenuContainer, MenuRow } from '@components/Menu'
import { useActionSheet } from '@expo/react-native-action-sheet' import { useActionSheet } from '@expo/react-native-action-sheet'
import { androidActionSheetStyles } from '@utils/helpers/androidActionSheetStyles' import { LOCALES } from '@i18n/locales'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { connectVerify } from '@utils/api/helpers/connect'
import { androidActionSheetStyles } from '@utils/helpers/androidActionSheetStyles'
import { useGlobalStorage } from '@utils/storage/actions' import { useGlobalStorage } from '@utils/storage/actions'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import * as Localization from 'expo-localization' import * as Localization from 'expo-localization'
import React from 'react' import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Linking, Platform } from 'react-native' import { Linking, Platform } from 'react-native'
import { GLOBAL } from '../../../../App'
import { mapFontsizeToName } from '../SettingsFontsize' import { mapFontsizeToName } from '../SettingsFontsize'
import { LOCALES } from '@i18n/locales'
const SettingsApp: React.FC = () => { const SettingsApp: React.FC = () => {
const navigation = useNavigation<any>() const navigation = useNavigation<any>()
@ -24,6 +26,23 @@ const SettingsApp: React.FC = () => {
const [browser, setBrowser] = useGlobalStorage.string('app.browser') const [browser, setBrowser] = useGlobalStorage.string('app.browser')
const [autoplayGifv, setAutoplayGifv] = useGlobalStorage.boolean('app.auto_play_gifv') const [autoplayGifv, setAutoplayGifv] = useGlobalStorage.boolean('app.auto_play_gifv')
const [connect, setConnect] = useGlobalStorage.boolean('app.connect')
const [showConnect, setShowConnect] = useState(connect)
useEffect(() => {
connectVerify()
.then(() => {
setShowConnect(true)
})
.catch(() => {
if (connect) {
GLOBAL.connect = false
setConnect(false)
} else {
setShowConnect(false)
}
})
}, [])
return ( return (
<MenuContainer> <MenuContainer>
<MenuRow <MenuRow
@ -152,6 +171,16 @@ const SettingsApp: React.FC = () => {
switchValue={autoplayGifv} switchValue={autoplayGifv}
switchOnValueChange={() => setAutoplayGifv(!autoplayGifv)} switchOnValueChange={() => setAutoplayGifv(!autoplayGifv)}
/> />
{showConnect ? (
<MenuRow
title='使用代理'
switchValue={connect || false}
switchOnValueChange={() => {
GLOBAL.connect = !connect
setConnect(!connect)
}}
/>
) : null}
</MenuContainer> </MenuContainer>
) )
} }

View File

@ -93,6 +93,7 @@ const AccountAttachments: React.FC = () => {
dimension={{ width: width, height: width }} dimension={{ width: width, height: width }}
style={{ marginLeft: StyleConstants.Spacing.Global.PagePadding }} style={{ marginLeft: StyleConstants.Spacing.Global.PagePadding }}
onPress={() => navigation.push('Tab-Shared-Toot', { toot: item })} onPress={() => navigation.push('Tab-Shared-Toot', { toot: item })}
dim
/> />
) )
} }

View File

@ -28,6 +28,7 @@ const AccountHeader: React.FC = () => {
) )
} }
}} }}
dim
/> />
) )
} }

View File

@ -42,6 +42,7 @@ const AccountInformationAvatar: React.FC = () => {
} }
} }
}} }}
dim
/> />
) )
} }

View File

@ -35,6 +35,9 @@ const TabSharedAccount: React.FC<TabSharedStackScreenProps<'Tab-Shared-Account'>
account, account,
_local: true, _local: true,
options: { options: {
placeholderData: (account._remote
? { ...account, id: undefined }
: account) as Mastodon.Account,
onSuccess: a => { onSuccess: a => {
if (account._remote) { if (account._remote) {
setQueryKey([ setQueryKey([

View File

@ -7,6 +7,7 @@ import TimelineDefault from '@components/Timeline/Default'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import apiGeneral from '@utils/api/general' import apiGeneral from '@utils/api/general'
import apiInstance from '@utils/api/instance' import apiInstance from '@utils/api/instance'
import { appendRemote } from '@utils/helpers/appendRemote'
import { urlMatcher } from '@utils/helpers/urlMatcher' import { urlMatcher } from '@utils/helpers/urlMatcher'
import { TabSharedStackScreenProps } from '@utils/navigation/navigators' import { TabSharedStackScreenProps } from '@utils/navigation/navigators'
import { queryClient } from '@utils/queryHooks' import { queryClient } from '@utils/queryHooks'
@ -206,26 +207,7 @@ const TabSharedToot: React.FC<TabSharedStackScreenProps<'Tab-Shared-Toot'>> = ({
if (localMatch) { if (localMatch) {
return localMatch return localMatch
} else { } else {
return { return appendRemote.status(ancestor)
...ancestor,
_remote: true,
account: { ...ancestor.account, _remote: true },
mentions: ancestor.mentions.map(mention => ({
...mention,
_remote: true
})),
...(ancestor.reblog && {
reblog: {
...ancestor.reblog,
_remote: true,
account: { ...ancestor.reblog.account, _remote: true },
mentions: ancestor.reblog.mentions.map(mention => ({
...mention,
_remote: true
}))
}
})
}
} }
}) })
} }
@ -268,23 +250,7 @@ const TabSharedToot: React.FC<TabSharedStackScreenProps<'Tab-Shared-Toot'>> = ({
if (localMatch) { if (localMatch) {
return { ...localMatch, _level: remote._level } return { ...localMatch, _level: remote._level }
} else { } else {
return { return appendRemote.status(remote)
...remote,
_remote: true,
account: { ...remote.account, _remote: true },
mentions: remote.mentions.map(mention => ({ ...mention, _remote: true })),
...(remote.reblog && {
reblog: {
...remote.reblog,
_remote: true,
account: { ...remote.reblog.account, _remote: true },
mentions: remote.reblog.mentions.map(mention => ({
...mention,
_remote: true
}))
}
})
}
} }
}) })
} }
@ -380,8 +346,13 @@ const TabSharedToot: React.FC<TabSharedStackScreenProps<'Tab-Shared-Toot'>> = ({
<TimelineDefault <TimelineDefault
item={item} item={item}
queryKey={item._remote ? queryKey.remote : queryKey.local} queryKey={item._remote ? queryKey.remote : queryKey.local}
highlighted={toot.id === item.id || item.id === 'cached'} highlighted={toot.id === item.id}
isConversation={toot.id !== item.id && item.id !== 'cached'} suppressSpoiler={
toot.id !== item.id &&
!!toot.spoiler_text?.length &&
toot.spoiler_text === item.spoiler_text
}
isConversation={toot.id !== item.id}
noBackground noBackground
/> />
{/* <CustomText {/* <CustomText

View File

@ -4,14 +4,12 @@ import Icon from '@components/Icon'
import { Loading } from '@components/Loading' import { Loading } from '@components/Loading'
import ComponentSeparator from '@components/Separator' import ComponentSeparator from '@components/Separator'
import CustomText from '@components/Text' import CustomText from '@components/Text'
import apiInstance from '@utils/api/instance'
import { TabSharedStackScreenProps } from '@utils/navigation/navigators' import { TabSharedStackScreenProps } from '@utils/navigation/navigators'
import { SearchResult } from '@utils/queryHooks/search'
import { QueryKeyUsers, useUsersQuery } from '@utils/queryHooks/users' import { QueryKeyUsers, useUsersQuery } from '@utils/queryHooks/users'
import { flattenPages } from '@utils/queryHooks/utils' import { flattenPages } from '@utils/queryHooks/utils'
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, { useEffect, useState } from 'react' import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { View } from 'react-native' import { View } from 'react-native'
import { FlatList } from 'react-native-gesture-handler' import { FlatList } from 'react-native-gesture-handler'
@ -36,8 +34,6 @@ const TabSharedUsers: React.FC<TabSharedStackScreenProps<'Tab-Shared-Users'>> =
...queryKey[1] ...queryKey[1]
}) })
const [isSearching, setIsSearching] = useState<number | null>(null)
return ( return (
<FlatList <FlatList
windowSize={7} windowSize={7}
@ -46,38 +42,10 @@ const TabSharedUsers: React.FC<TabSharedStackScreenProps<'Tab-Shared-Users'>> =
minHeight: '100%', minHeight: '100%',
paddingVertical: StyleConstants.Spacing.Global.PagePadding paddingVertical: StyleConstants.Spacing.Global.PagePadding
}} }}
renderItem={({ item, index }) => ( renderItem={({ item }) => (
<ComponentAccount <ComponentAccount
account={item} account={item}
props={{ props={{ onPress: () => navigation.push('Tab-Shared-Account', { account: item }) }}
disabled: isSearching === index,
onPress: () => {
if (data?.pages[0]?.remoteData) {
setIsSearching(index)
apiInstance<SearchResult>({
version: 'v2',
method: 'get',
url: 'search',
params: {
q: `@${item.acct}`,
type: 'accounts',
limit: 1,
resolve: true
}
})
.then(res => {
setIsSearching(null)
if (res.body.accounts[0]) {
navigation.push('Tab-Shared-Account', { account: res.body.accounts[0] })
}
})
.catch(() => setIsSearching(null))
} else {
navigation.push('Tab-Shared-Account', { account: item })
}
}
}}
children={<Loading />}
/> />
)} )}
onEndReached={() => hasNextPage && !isFetchingNextPage && fetchNextPage()} onEndReached={() => hasNextPage && !isFetchingNextPage && fetchNextPage()}

View File

@ -2,11 +2,11 @@ import GracefullyImage from '@components/GracefullyImage'
import haptics from '@components/haptics' import haptics from '@components/haptics'
import Icon from '@components/Icon' import Icon from '@components/Icon'
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs' import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'
import { RootStackScreenProps, ScreenTabsStackParamList } from '@utils/navigation/navigators' import { ScreenTabsStackParamList } from '@utils/navigation/navigators'
import { getGlobalStorage, useAccountStorage, useGlobalStorage } from '@utils/storage/actions' import { getGlobalStorage, useAccountStorage, useGlobalStorage } from '@utils/storage/actions'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react' import React from 'react'
import { Platform } from 'react-native' import { Platform, View } from 'react-native'
import TabLocal from './Local' import TabLocal from './Local'
import TabMe from './Me' import TabMe from './Me'
import TabNotifications from './Notifications' import TabNotifications from './Notifications'
@ -14,7 +14,7 @@ import TabPublic from './Public'
const Tab = createBottomTabNavigator<ScreenTabsStackParamList>() const Tab = createBottomTabNavigator<ScreenTabsStackParamList>()
const ScreenTabs = ({ navigation }: RootStackScreenProps<'Screen-Tabs'>) => { const ScreenTabs = () => {
const { colors } = useTheme() const { colors } = useTheme()
const [accountActive] = useGlobalStorage.string('account.active') const [accountActive] = useGlobalStorage.string('account.active')
@ -50,19 +50,19 @@ const ScreenTabs = ({ navigation }: RootStackScreenProps<'Screen-Tabs'>) => {
return <Icon name='bell' size={size} color={color} /> return <Icon name='bell' size={size} color={color} />
case 'Tab-Me': case 'Tab-Me':
return ( return (
<GracefullyImage <View style={{ flexDirection: 'row', alignItems: 'center' }}>
uri={{ original: avatarStatic }} <GracefullyImage
dimension={{ uri={{ original: avatarStatic }}
width: size, dimension={{ width: size, height: size }}
height: size style={{
}} borderRadius: size,
style={{ overflow: 'hidden',
borderRadius: size, borderWidth: focused ? 2 : 0,
overflow: 'hidden', borderColor: focused ? colors.primaryDefault : color
borderWidth: focused ? 2 : 0, }}
borderColor: focused ? colors.secondary : color />
}} <Icon name='more-vertical' size={size / 1.5} color={colors.secondary} />
/> </View>
) )
default: default:
return <Icon name='alert-octagon' size={size} color={color} /> return <Icon name='alert-octagon' size={size} color={color} />
@ -74,13 +74,13 @@ const ScreenTabs = ({ navigation }: RootStackScreenProps<'Screen-Tabs'>) => {
<Tab.Screen name='Tab-Public' component={TabPublic} /> <Tab.Screen name='Tab-Public' component={TabPublic} />
<Tab.Screen <Tab.Screen
name='Tab-Compose' name='Tab-Compose'
listeners={{ listeners={({ navigation }) => ({
tabPress: e => { tabPress: e => {
e.preventDefault() e.preventDefault()
haptics('Light') haptics('Light')
navigation.navigate('Screen-Compose') navigation.navigate('Screen-Compose')
} }
}} })}
> >
{() => null} {() => null}
</Tab.Screen> </Tab.Screen>
@ -88,15 +88,13 @@ const ScreenTabs = ({ navigation }: RootStackScreenProps<'Screen-Tabs'>) => {
<Tab.Screen <Tab.Screen
name='Tab-Me' name='Tab-Me'
component={TabMe} component={TabMe}
listeners={{ listeners={({ navigation }) => ({
tabLongPress: () => { tabLongPress: () => {
haptics('Light') haptics('Light')
//@ts-ignore
navigation.navigate('Tab-Me', { screen: 'Tab-Me-Root' }) navigation.navigate('Tab-Me', { screen: 'Tab-Me-Root' })
//@ts-ignore
navigation.navigate('Tab-Me', { screen: 'Tab-Me-Switch' }) navigation.navigate('Tab-Me', { screen: 'Tab-Me-Switch' })
} }
}} })}
/> />
</Tab.Navigator> </Tab.Navigator>
) )

View File

@ -1,5 +1,7 @@
import axios from 'axios' import axios from 'axios'
import { GLOBAL } from '../../App'
import { ctx, handleError, PagedResponse, parseHeaderLinks, userAgent } from './helpers' import { ctx, handleError, PagedResponse, parseHeaderLinks, userAgent } from './helpers'
import { CONNECT_DOMAIN } from './helpers/connect'
export type Params = { export type Params = {
method: 'get' | 'post' | 'put' | 'delete' method: 'get' | 'post' | 'put' | 'delete'
@ -35,14 +37,15 @@ const apiGeneral = async <T = unknown>({
return axios({ return axios({
timeout: method === 'post' ? 1000 * 60 : 1000 * 15, timeout: method === 'post' ? 1000 * 60 : 1000 * 15,
method, method,
baseURL: `https://${domain}/`, baseURL: `https://${GLOBAL.connect ? CONNECT_DOMAIN() : domain}`,
url, url,
params, params,
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
...userAgent, ...userAgent,
...headers, ...headers,
...(body && body instanceof FormData && { 'Content-Type': 'multipart/form-data' }) ...(body && body instanceof FormData && { 'Content-Type': 'multipart/form-data' }),
...(GLOBAL.connect && { 'x-tooot-domain': domain })
}, },
data: body data: body
}) })

View File

@ -0,0 +1,145 @@
import { mapEnvironment } from '@utils/helpers/checkEnvironment'
import { setGlobalStorage } from '@utils/storage/actions'
import axios from 'axios'
import parse from 'url-parse'
import { userAgent } from '.'
import { GLOBAL } from '../../../App'
const list = [
'n61owz4leck',
'z9skyp2f0m',
'nc2dqtyxevj',
'tgl97fgudrf',
'eo2sj0ut2s',
'a75auwihvyi',
'vzkpud5y5b',
'3uivf7yyex',
'pxfoa1wbor',
'3cor5jempc',
'9o32znuepr',
'9ayt1l2dzpi',
'60iu4rz8js',
'dzoa1lbxbv',
'82rpiiqw21',
'fblij1c9gyl',
'wk2x048g8gl',
'9x91yrbtmn',
'dgu5p7eif6',
'uftwyhrkgrh',
'vv5hay15vjk',
'ooj9ihtyur',
'o8r7phzd58',
'pujwyg269s',
'l6yq5nr8lv',
'ocyrlfmdnl',
'rdtpeip5e2',
'ykzb5784js',
'm34z7j5us1i',
'tqsfr0orqa',
'8ncrt0mifa',
'ygce2fdmsm',
'22vk7csljz',
'7mmb6hrih1',
'grla5cpgau',
'0vygyvs4k7',
'1texbe32sf',
'ckwvauiiol',
'qkxryrbpxx',
'ptb19c0ks9g',
'3bpe76o6stg',
'd507ejce9g',
'jpul5v2mqej',
'6m5uxemc79',
'wxbtoo9t3p',
'8qco3d0idh',
'u00c2xiabvf',
'hutkqwrcy8',
't6vrkzhpzo',
'wy6e529mnb',
'kzzrlfa59pg',
'mmo4sv4a7s',
'u0dishl20k',
'8qyx25bq3u',
'd3mucdzlu1',
'y123m81vsjl',
'51opvzdo6k',
'r4z333th9u',
'q77hl0ggfr',
'bsk1f2wi52g',
'eubnxpv0pz',
'h11pk7qm8i',
'brhxw45vd5',
'vtnvlsrn1z',
'0q5w0hhzb5',
'vq2rz02ayf',
'hml3igfwkq',
'39qs7vhenl',
'5vcv775rug',
'kjom5gr7i3',
't2kmaoeb5x',
'ni6ow1z11b',
'yvgtoc3d88',
'iax04eatnz',
'esxyu9zujg',
'73xa28n278',
'5x63a8l24k',
'dy1trb0b3sj',
'd4c31j23m8',
'ho76046l0j',
'sw8lj5u2ef',
'z5cn21mew5',
'wxj73nmqwa',
'gdj00dlx98',
'0v76xag64i',
'j35104qduhj',
'l63r7h0ss6',
'e5xdv7t1q0h',
'4icoh8t4c8',
'nbk36jt4sq',
'zi0n0cv4tk',
'o7qkfp3rxu',
'xd2wefzd27',
'rg7e6tsacx',
'9lrq3s4vfm',
'srs9p21lxoh',
'n8xymau42t',
'q5cik283fg',
'68ye9feqs5',
'xjc5anubnv'
]
export const CONNECT_DOMAIN = () =>
mapEnvironment({
release: `${list[Math.floor(Math.random() * (100 - 0) + 0)]}.tooot.app`,
candidate: 'connect-candidate.tooot.app',
development: 'connect-development.tooot.app'
})
export const connectImage = ({
uri
}: {
uri?: string
}): { uri?: string; headers?: { 'x-tooot-domain': string } } => {
if (GLOBAL.connect) {
if (uri) {
const host = parse(uri).host
return { uri: uri.replace(host, CONNECT_DOMAIN()), headers: { 'x-tooot-domain': host } }
} else {
return { uri }
}
} else {
return { uri }
}
}
export const connectVerify = () =>
axios({
method: 'get',
baseURL: `https://${CONNECT_DOMAIN()}`,
url: 'verify',
headers: { ...userAgent }
}).catch(err => {
GLOBAL.connect = false
setGlobalStorage('app.connect', false)
return Promise.reject(err)
})

View File

@ -1,8 +1,10 @@
import * as Sentry from '@sentry/react-native' import * as Sentry from '@sentry/react-native'
import { setGlobalStorage } from '@utils/storage/actions'
import chalk from 'chalk' import chalk from 'chalk'
import Constants from 'expo-constants' import Constants from 'expo-constants'
import { Platform } from 'react-native' import { Platform } from 'react-native'
import parse from 'url-parse' import parse from 'url-parse'
import { GLOBAL } from '../../../App'
const userAgent = { const userAgent = {
'User-Agent': `tooot/${Constants.expoConfig?.version} ${Platform.OS}/${Platform.Version}` 'User-Agent': `tooot/${Constants.expoConfig?.version} ${Platform.OS}/${Platform.Version}`
@ -18,6 +20,12 @@ const handleError =
} | void } | void
) => ) =>
(error: any) => { (error: any) => {
if (GLOBAL.connect) {
if (error?.response?.status == 403 && error?.response?.data == 'connect_blocked') {
GLOBAL.connect = false
setGlobalStorage('app.connect', false)
}
}
const shouldReportToSentry = config && (config.captureRequest || config.captureResponse) const shouldReportToSentry = config && (config.captureRequest || config.captureResponse)
shouldReportToSentry && Sentry.setContext('Error object', error) shouldReportToSentry && Sentry.setContext('Error object', error)

View File

@ -1,7 +1,9 @@
import { getAccountDetails } from '@utils/storage/actions' import { getAccountDetails } from '@utils/storage/actions'
import { StorageGlobal } from '@utils/storage/global' import { StorageGlobal } from '@utils/storage/global'
import axios, { AxiosRequestConfig } from 'axios' import axios, { AxiosRequestConfig } from 'axios'
import { GLOBAL } from '../../App'
import { ctx, handleError, PagedResponse, parseHeaderLinks, userAgent } from './helpers' import { ctx, handleError, PagedResponse, parseHeaderLinks, userAgent } from './helpers'
import { CONNECT_DOMAIN } from './helpers/connect'
export type Params = { export type Params = {
account?: StorageGlobal['account.active'] account?: StorageGlobal['account.active']
@ -43,11 +45,13 @@ const apiInstance = async <T = unknown>({
method + ctx.blue(' -> ') + `/${url}` + (params ? ctx.blue(' -> ') : ''), method + ctx.blue(' -> ') + `/${url}` + (params ? ctx.blue(' -> ') : ''),
params ? params : '' params ? params : ''
) )
console.log('body', body)
return axios({ return axios({
timeout: method === 'post' ? 1000 * 60 : 1000 * 15, timeout: method === 'post' ? 1000 * 60 : 1000 * 15,
method, method,
baseURL: `https://${accountDetails['auth.domain']}/api/${version}/`, baseURL: `https://${
GLOBAL.connect ? CONNECT_DOMAIN() : accountDetails['auth.domain']
}/api/${version}`,
url, url,
params, params,
headers: { headers: {
@ -55,7 +59,8 @@ const apiInstance = async <T = unknown>({
...userAgent, ...userAgent,
...headers, ...headers,
Authorization: `Bearer ${accountDetails['auth.token']}`, Authorization: `Bearer ${accountDetails['auth.token']}`,
...(body && body instanceof FormData && { 'Content-Type': 'multipart/form-data' }) ...(body && body instanceof FormData && { 'Content-Type': 'multipart/form-data' }),
...(GLOBAL.connect && { 'x-tooot-domain': accountDetails['auth.domain'] })
}, },
data: body, data: body,
...extras ...extras

View File

@ -0,0 +1,23 @@
// Central place appending _remote internal prop
export const appendRemote = {
status: (status: Mastodon.Status) => ({
...status,
...(status.reblog && {
reblog: {
...status.reblog,
account: appendRemote.account(status.reblog.account),
mentions: appendRemote.mentions(status.reblog.mentions)
}
}),
account: appendRemote.account(status.account),
mentions: appendRemote.mentions(status.mentions),
_remote: true
}),
account: (account: Mastodon.Account) => ({
...account,
_remote: true
}),
mentions: (mentions: Mastodon.Mention[]) =>
mentions?.map(mention => ({ ...mention, _remote: true }))
}

View File

@ -1,6 +1,9 @@
import { getAccountStorage } from '@utils/storage/actions' import { getAccountStorage } from '@utils/storage/actions'
import parse from 'url-parse' import parse from 'url-parse'
// Would mess with the /@username format
const BLACK_LIST = ['matters.news', 'medium.com']
export const urlMatcher = ( export const urlMatcher = (
url: string url: string
): ):
@ -14,6 +17,10 @@ export const urlMatcher = (
if (!parsed.hostname.length || !parsed.pathname.length) return undefined if (!parsed.hostname.length || !parsed.pathname.length) return undefined
const domain = parsed.hostname const domain = parsed.hostname
if (BLACK_LIST.includes(domain)) {
return
}
const _remote = parsed.hostname !== getAccountStorage.string('auth.domain') const _remote = parsed.hostname !== getAccountStorage.string('auth.domain')
let statusId: string | undefined let statusId: string | undefined

View File

@ -1,6 +1,7 @@
import { QueryFunctionContext, useQuery, UseQueryOptions } from '@tanstack/react-query' import { QueryFunctionContext, useQuery, UseQueryOptions } from '@tanstack/react-query'
import apiGeneral from '@utils/api/general' import apiGeneral from '@utils/api/general'
import apiInstance from '@utils/api/instance' import apiInstance from '@utils/api/instance'
import { appendRemote } from '@utils/helpers/appendRemote'
import { urlMatcher } from '@utils/helpers/urlMatcher' import { urlMatcher } from '@utils/helpers/urlMatcher'
import { AxiosError } from 'axios' import { AxiosError } from 'axios'
import { searchLocalAccount } from './search' import { searchLocalAccount } from './search'
@ -34,14 +35,14 @@ const accountQueryFunction = async ({ queryKey }: QueryFunctionContext<QueryKeyA
method: 'get', method: 'get',
domain: domain, domain: domain,
url: `api/v1/accounts/${id}` url: `api/v1/accounts/${id}`
}).then(res => ({ ...res.body, _remote: true })) }).then(res => appendRemote.account(res.body))
} else if (acct) { } else if (acct) {
matchedAccount = await apiGeneral<Mastodon.Account>({ matchedAccount = await apiGeneral<Mastodon.Account>({
method: 'get', method: 'get',
domain: domain, domain: domain,
url: 'api/v1/accounts/lookup', url: 'api/v1/accounts/lookup',
params: { acct } params: { acct }
}).then(res => ({ ...res.body, _remote: true })) }).then(res => appendRemote.account(res.body))
} }
} catch {} } catch {}
} }

View File

@ -1,6 +1,7 @@
import { QueryFunctionContext, useQuery, UseQueryOptions } from '@tanstack/react-query' import { QueryFunctionContext, useQuery, UseQueryOptions } from '@tanstack/react-query'
import apiGeneral from '@utils/api/general' import apiGeneral from '@utils/api/general'
import apiInstance from '@utils/api/instance' import apiInstance from '@utils/api/instance'
import { appendRemote } from '@utils/helpers/appendRemote'
import { urlMatcher } from '@utils/helpers/urlMatcher' import { urlMatcher } from '@utils/helpers/urlMatcher'
import { AxiosError } from 'axios' import { AxiosError } from 'axios'
import { searchLocalStatus } from './search' import { searchLocalStatus } from './search'
@ -26,7 +27,7 @@ const queryFunction = async ({ queryKey }: QueryFunctionContext<QueryKeyStatus>)
method: 'get', method: 'get',
domain, domain,
url: `api/v1/statuses/${id}` url: `api/v1/statuses/${id}`
}).then(res => ({ ...res.body, _remote: true })) }).then(res => appendRemote.status(res.body))
} catch {} } catch {}
} }

View File

@ -6,6 +6,7 @@ import {
import apiGeneral from '@utils/api/general' import apiGeneral from '@utils/api/general'
import { PagedResponse } from '@utils/api/helpers' import { PagedResponse } from '@utils/api/helpers'
import apiInstance from '@utils/api/instance' import apiInstance from '@utils/api/instance'
import { appendRemote } from '@utils/helpers/appendRemote'
import { urlMatcher } from '@utils/helpers/urlMatcher' import { urlMatcher } from '@utils/helpers/urlMatcher'
import { TabSharedStackParamList } from '@utils/navigation/navigators' import { TabSharedStackParamList } from '@utils/navigation/navigators'
import { AxiosError } from 'axios' import { AxiosError } from 'axios'
@ -54,7 +55,11 @@ const queryFunction = async ({ queryKey, pageParam }: QueryFunctionContext<Query
url: `api/v1/accounts/${resLookup.body.id}/${page.type}`, url: `api/v1/accounts/${resLookup.body.id}/${page.type}`,
params params
}) })
return { ...res, remoteData: true } return {
...res,
body: res.body.map(account => appendRemote.account(account)),
remoteData: true
}
} else { } else {
throw new Error() throw new Error()
} }

View File

@ -323,27 +323,30 @@ export const removeAccount = async (account: string, warning: boolean = true) =>
} }
export type ReadableAccountType = { export type ReadableAccountType = {
avatar_static: string
acct: string acct: string
key: string key: string
active: boolean active: boolean
} }
export const getReadableAccounts = (withoutActive: boolean = false): ReadableAccountType[] => { export const getReadableAccounts = (): ReadableAccountType[] => {
const accountActive = !withoutActive && getGlobalStorage.string('account.active') const accountActive = getGlobalStorage.string('account.active')
const accounts = getGlobalStorage.object('accounts')?.sort((a, b) => a.localeCompare(b)) const accounts = getGlobalStorage.object('accounts')
!withoutActive &&
accounts?.splice(
accounts.findIndex(a => a === accountActive),
1
)
!withoutActive && accounts?.unshift(accountActive || '')
return ( return (
accounts?.map(account => { accounts?.map(account => {
const details = getAccountDetails( const details = getAccountDetails(
['auth.account.acct', 'auth.account.domain', 'auth.domain', 'auth.account.id'], [
'auth.account.avatar_static',
'auth.account.acct',
'auth.account.domain',
'auth.domain',
'auth.account.id'
],
account account
) )
if (details) { if (details) {
return { return {
avatar_static: details['auth.account.avatar_static'],
acct: `@${details['auth.account.acct']}@${details['auth.account.domain']}`, acct: `@${details['auth.account.acct']}@${details['auth.account.domain']}`,
key: generateAccountKey({ key: generateAccountKey({
domain: details['auth.domain'], domain: details['auth.domain'],
@ -352,7 +355,7 @@ export const getReadableAccounts = (withoutActive: boolean = false): ReadableAcc
active: account === accountActive active: account === accountActive
} }
} else { } else {
return { acct: '', key: '', active: false } return { avatar_static: '', acct: '', key: '', active: false }
} }
}) || [] }) || []
).filter(a => a.acct.length) ).filter(a => a.acct.length)

View File

@ -17,6 +17,7 @@ export type GlobalV0 = {
'version.account': number 'version.account': number
// boolean // boolean
'app.auto_play_gifv'?: boolean 'app.auto_play_gifv'?: boolean
'app.connect'?: boolean
//// account //// account
// string // string