Merge branch 'main' into candidate

This commit is contained in:
xmflsct 2023-01-08 18:12:34 +01:00
commit c4e94c6f5a
56 changed files with 822 additions and 286 deletions

View File

@ -1,6 +1,7 @@
Enjoy toooting! This version includes following improvements and fixes: Enjoy toooting! This version includes following improvements and fixes:
- Auto fetch remote content in conversations! - Auto fetch remote content in conversations!
- Remember last read position in timeline! - Remember last read position in timeline!
- Follow a user with other logged in accounts
- Allowing adding more context of reports - Allowing adding more context of reports
- Option to disable autoplay gif - Option to disable autoplay gif
- Hide boosts from users - Hide boosts from users

View File

@ -1,6 +1,7 @@
toooting愉快此版本包括以下改进和修复 toooting愉快此版本包括以下改进和修复
- 主动获取对话的远程内容 - 主动获取对话的远程内容
- 自动加载上次我的关注的阅读位置 - 自动加载上次我的关注的阅读位置
- 用其它已登陆的账户关注用户
- 可添加举报细节 - 可添加举报细节
- 新增暂停自动播放gif动画选项 - 新增暂停自动播放gif动画选项
- 隐藏用户的转嘟 - 隐藏用户的转嘟

View File

@ -1,44 +1,30 @@
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { generateAccountKey, getAccountDetails, setAccount } from '@utils/storage/actions' import { ReadableAccountType, setAccount } from '@utils/storage/actions'
import { StorageGlobal } from '@utils/storage/global'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import React from 'react' import React from 'react'
import Button from './Button' import Button from './Button'
import haptics from './haptics' import haptics from './haptics'
interface Props { interface Props {
account: NonNullable<StorageGlobal['accounts']>[number] account: ReadableAccountType
selected?: boolean
additionalActions?: () => void additionalActions?: () => void
} }
const AccountButton: React.FC<Props> = ({ account, selected = false, additionalActions }) => { const AccountButton: React.FC<Props> = ({ account, additionalActions }) => {
const navigation = useNavigation() const navigation = useNavigation()
const accountDetails = getAccountDetails(
['auth.domain', 'auth.account.acct', 'auth.account.domain', 'auth.account.id'],
account
)
if (!accountDetails) return null
return ( return (
<Button <Button
type='text' type='text'
selected={selected} selected={account.active}
style={{ style={{
marginBottom: StyleConstants.Spacing.M, marginBottom: StyleConstants.Spacing.M,
marginRight: StyleConstants.Spacing.M marginRight: StyleConstants.Spacing.M
}} }}
content={`@${accountDetails['auth.account.acct']}@${accountDetails['auth.account.domain']}${ content={account.acct}
selected ? ' ✓' : ''
}`}
onPress={() => { onPress={() => {
haptics('Light') haptics('Light')
setAccount( setAccount(account.key)
generateAccountKey({
domain: accountDetails['auth.domain'],
id: accountDetails['auth.account.id']
})
)
navigation.goBack() navigation.goBack()
if (additionalActions) { if (additionalActions) {
additionalActions() additionalActions()

View File

@ -97,7 +97,7 @@ const Button: React.FC<Props> = ({
fontSize: StyleConstants.Font.Size[size] * (size === 'L' ? 1.25 : 1), fontSize: StyleConstants.Font.Size[size] * (size === 'L' ? 1.25 : 1),
opacity: loading ? 0 : 1 opacity: loading ? 0 : 1
}} }}
fontWeight={fontBold ? 'Bold' : 'Normal'} fontWeight={fontBold || selected ? 'Bold' : 'Normal'}
children={content} children={content}
testID='text' testID='text'
/> />
@ -125,7 +125,7 @@ const Button: React.FC<Props> = ({
borderRadius: 100, borderRadius: 100,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
borderWidth: overlay ? 0 : 1, borderWidth: overlay ? 0 : selected ? 1.5 : 1,
borderColor: mainColor(), borderColor: mainColor(),
backgroundColor: overlay ? colors.backgroundOverlayInvert : colors.backgroundDefault, backgroundColor: overlay ? colors.backgroundOverlayInvert : colors.backgroundDefault,
paddingVertical: StyleConstants.Spacing[spacing], paddingVertical: StyleConstants.Spacing[spacing],

View File

@ -13,6 +13,7 @@ import { StorageAccount } from '@utils/storage/account'
import { import {
generateAccountKey, generateAccountKey,
getGlobalStorage, getGlobalStorage,
setAccount,
setAccountStorage, setAccountStorage,
setGlobalStorage setGlobalStorage
} from '@utils/storage/actions' } from '@utils/storage/actions'
@ -95,7 +96,10 @@ const ComponentInstance: React.FC<Props> = ({
scopes: ['read', 'write', 'follow', 'push'], scopes: ['read', 'write', 'follow', 'push'],
redirectUri, redirectUri,
code: promptResult.params.code, code: promptResult.params.code,
extraParams: { grant_type: 'authorization_code' } extraParams: {
grant_type: 'authorization_code',
...(request.codeVerifier && { code_verifier: request.codeVerifier })
}
}, },
{ tokenEndpoint: `https://${variables.domain}/oauth/token` } { tokenEndpoint: `https://${variables.domain}/oauth/token` }
) )
@ -175,12 +179,11 @@ const ComponentInstance: React.FC<Props> = ({
})), })),
accountKey accountKey
) )
storage.account = new MMKV({ id: accountKey })
if (!account) { if (!account) {
setGlobalStorage('accounts', accounts?.concat([accountKey])) setGlobalStorage('accounts', accounts?.concat([accountKey]))
} }
setGlobalStorage('account.active', accountKey) setAccount(accountKey)
goBack && navigation.goBack() goBack && navigation.goBack()
} }

View File

@ -106,7 +106,7 @@ const Message = React.forwardRef<FlashMessage>((_, ref) => {
...StyleConstants.FontStyle.S ...StyleConstants.FontStyle.S
}} }}
// @ts-ignore // @ts-ignore
textProps={{ numberOfLines: 2 }} textProps={{ numberOfLines: 3 }}
/> />
) )
}) })

View File

@ -192,16 +192,48 @@ const TimelineDefault: React.FC<Props> = ({
</ContextMenu.Trigger> </ContextMenu.Trigger>
<ContextMenu.Content> <ContextMenu.Content>
{[mShare, mStatus, mInstance].map((type, i) => ( {[mShare, mStatus, mInstance].map((menu, i) => (
<Fragment key={i}> <Fragment key={i}>
{type.map((mGroup, index) => ( {menu.map((group, index) => (
<ContextMenu.Group key={index}> <ContextMenu.Group key={index}>
{mGroup.map(menu => ( {group.map(item => {
<ContextMenu.Item key={menu.key} {...menu.item}> switch (item.type) {
<ContextMenu.ItemTitle children={menu.title} /> case 'item':
<ContextMenu.ItemIcon ios={{ name: menu.icon }} /> return (
</ContextMenu.Item> <ContextMenu.Item key={item.key} {...item.props}>
))} <ContextMenu.ItemTitle children={item.title} />
{item.icon ? (
<ContextMenu.ItemIcon ios={{ name: item.icon }} />
) : null}
</ContextMenu.Item>
)
case 'sub':
return (
// @ts-ignore
<ContextMenu.Sub key={item.key}>
<ContextMenu.SubTrigger
key={item.trigger.key}
{...item.trigger.props}
>
<ContextMenu.ItemTitle children={item.trigger.title} />
{item.trigger.icon ? (
<ContextMenu.ItemIcon ios={{ name: item.trigger.icon }} />
) : null}
</ContextMenu.SubTrigger>
<ContextMenu.SubContent>
{item.items.map(sub => (
<ContextMenu.Item key={sub.key} {...sub.props}>
<ContextMenu.ItemTitle children={sub.title} />
{sub.icon ? (
<ContextMenu.ItemIcon ios={{ name: sub.icon }} />
) : null}
</ContextMenu.Item>
))}
</ContextMenu.SubContent>
</ContextMenu.Sub>
)
}
})}
</ContextMenu.Group> </ContextMenu.Group>
))} ))}
</Fragment> </Fragment>

View File

@ -162,16 +162,43 @@ const TimelineNotifications: React.FC<Props> = ({ notification, queryKey }) => {
</ContextMenu.Trigger> </ContextMenu.Trigger>
<ContextMenu.Content> <ContextMenu.Content>
{[mShare, mStatus, mInstance].map((type, i) => ( {[mShare, mStatus, mInstance].map((menu, i) => (
<Fragment key={i}> <Fragment key={i}>
{type.map((mGroup, index) => ( {menu.map((group, index) => (
<ContextMenu.Group key={index}> <ContextMenu.Group key={index}>
{mGroup.map(menu => ( {group.map(item => {
<ContextMenu.Item key={menu.key} {...menu.item}> switch (item.type) {
<ContextMenu.ItemTitle children={menu.title} /> case 'item':
<ContextMenu.ItemIcon ios={{ name: menu.icon }} /> return (
</ContextMenu.Item> <ContextMenu.Item key={item.key} {...item.props}>
))} <ContextMenu.ItemTitle children={item.title} />
{item.icon ? <ContextMenu.ItemIcon ios={{ name: item.icon }} /> : null}
</ContextMenu.Item>
)
case 'sub':
return (
// @ts-ignore
<ContextMenu.Sub key={item.key}>
<ContextMenu.SubTrigger key={item.trigger.key} {...item.trigger.props}>
<ContextMenu.ItemTitle children={item.trigger.title} />
{item.trigger.icon ? (
<ContextMenu.ItemIcon ios={{ name: item.trigger.icon }} />
) : null}
</ContextMenu.SubTrigger>
<ContextMenu.SubContent>
{item.items.map(sub => (
<ContextMenu.Item key={sub.key} {...sub.props}>
<ContextMenu.ItemTitle children={sub.title} />
{sub.icon ? (
<ContextMenu.ItemIcon ios={{ name: sub.icon }} />
) : null}
</ContextMenu.Item>
))}
</ContextMenu.SubContent>
</ContextMenu.Sub>
)
}
})}
</ContextMenu.Group> </ContextMenu.Group>
))} ))}
</Fragment> </Fragment>

View File

@ -28,7 +28,7 @@ const TimelineActions: React.FC = () => {
const navigationState = useNavState() const navigationState = useNavState()
const navigation = useNavigation<StackNavigationProp<RootStackParamList>>() const navigation = useNavigation<StackNavigationProp<RootStackParamList>>()
const { t } = useTranslation(['common', 'componentTimeline']) const { t } = useTranslation(['common', 'componentTimeline'])
const { colors, theme } = useTheme() const { colors } = useTheme()
const iconColor = colors.secondary const iconColor = colors.secondary
const queryClient = useQueryClient() const queryClient = useQueryClient()
@ -56,7 +56,6 @@ const TimelineActions: React.FC = () => {
onError: (err: any, params) => { onError: (err: any, params) => {
const correctParam = params as MutationVarsTimelineUpdateStatusProperty const correctParam = params as MutationVarsTimelineUpdateStatusProperty
displayMessage({ displayMessage({
theme,
type: 'error', type: 'error',
message: t('common:message.error.message', { message: t('common:message.error.message', {
function: t( function: t(

View File

@ -10,8 +10,7 @@ import * as DropdownMenu from 'zeego/dropdown-menu'
import StatusContext from './Context' import StatusContext from './Context'
const TimelineHeaderAndroid: React.FC = () => { const TimelineHeaderAndroid: React.FC = () => {
const { queryKey, status, disableDetails, disableOnPress, rawContent } = const { queryKey, status, disableDetails, disableOnPress, rawContent } = useContext(StatusContext)
useContext(StatusContext)
if (Platform.OS !== 'android' || !status || disableDetails || disableOnPress) return null if (Platform.OS !== 'android' || !status || disableDetails || disableOnPress) return null
@ -52,16 +51,48 @@ const TimelineHeaderAndroid: React.FC = () => {
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content> <DropdownMenu.Content>
{[mShare, mAccount, mStatus].map((type, i) => ( {[mShare, mAccount, mStatus].map((menu, i) => (
<Fragment key={i}> <Fragment key={i}>
{type.map((mGroup, index) => ( {menu.map((group, index) => (
<DropdownMenu.Group key={index}> <DropdownMenu.Group key={index}>
{mGroup.map(menu => ( {group.map(item => {
<DropdownMenu.Item key={menu.key} {...menu.item}> switch (item.type) {
<DropdownMenu.ItemTitle children={menu.title} /> case 'item':
<DropdownMenu.ItemIcon ios={{ name: menu.icon }} /> return (
</DropdownMenu.Item> <DropdownMenu.Item key={item.key} {...item.props}>
))} <DropdownMenu.ItemTitle children={item.title} />
{item.icon ? (
<DropdownMenu.ItemIcon ios={{ name: item.icon }} />
) : null}
</DropdownMenu.Item>
)
case 'sub':
return (
// @ts-ignore
<DropdownMenu.Sub key={item.key}>
<DropdownMenu.SubTrigger
key={item.trigger.key}
{...item.trigger.props}
>
<DropdownMenu.ItemTitle children={item.trigger.title} />
{item.trigger.icon ? (
<DropdownMenu.ItemIcon ios={{ name: item.trigger.icon }} />
) : null}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
{item.items.map(sub => (
<DropdownMenu.Item key={sub.key} {...sub.props}>
<DropdownMenu.ItemTitle children={sub.title} />
{sub.icon ? (
<DropdownMenu.ItemIcon ios={{ name: sub.icon }} />
) : null}
</DropdownMenu.Item>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
)
}
})}
</DropdownMenu.Group> </DropdownMenu.Group>
))} ))}
</Fragment> </Fragment>

View File

@ -17,8 +17,7 @@ import HeaderSharedReplies from './HeaderShared/Replies'
import HeaderSharedVisibility from './HeaderShared/Visibility' import HeaderSharedVisibility from './HeaderShared/Visibility'
const TimelineHeaderDefault: React.FC = () => { const TimelineHeaderDefault: React.FC = () => {
const { queryKey, status, disableDetails, rawContent, isRemote } = const { queryKey, status, disableDetails, rawContent, isRemote } = useContext(StatusContext)
useContext(StatusContext)
if (!status) return null if (!status) return null
const { colors } = useTheme() const { colors } = useTheme()
@ -88,16 +87,48 @@ const TimelineHeaderDefault: React.FC = () => {
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content> <DropdownMenu.Content>
{[mShare, mAccount, mStatus].map((type, i) => ( {[mShare, mAccount, mStatus].map((menu, i) => (
<Fragment key={i}> <Fragment key={i}>
{type.map((mGroup, index) => ( {menu.map((group, index) => (
<DropdownMenu.Group key={index}> <DropdownMenu.Group key={index}>
{mGroup.map(menu => ( {group.map(item => {
<DropdownMenu.Item key={menu.key} {...menu.item}> switch (item.type) {
<DropdownMenu.ItemTitle children={menu.title} /> case 'item':
<DropdownMenu.ItemIcon ios={{ name: menu.icon }} /> return (
</DropdownMenu.Item> <DropdownMenu.Item key={item.key} {...item.props}>
))} <DropdownMenu.ItemTitle children={item.title} />
{item.icon ? (
<DropdownMenu.ItemIcon ios={{ name: item.icon }} />
) : null}
</DropdownMenu.Item>
)
case 'sub':
return (
// @ts-ignore
<DropdownMenu.Sub key={item}>
<DropdownMenu.SubTrigger
key={item.trigger.key}
{...item.trigger.props}
>
<DropdownMenu.ItemTitle children={item.trigger.title} />
{item.trigger.icon ? (
<DropdownMenu.ItemIcon ios={{ name: item.trigger.icon }} />
) : null}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
{item.items.map(sub => (
<DropdownMenu.Item key={sub.key} {...sub.props}>
<DropdownMenu.ItemTitle children={sub.title} />
{sub.icon ? (
<DropdownMenu.ItemIcon ios={{ name: sub.icon }} />
) : null}
</DropdownMenu.Item>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
)
}
})}
</DropdownMenu.Group> </DropdownMenu.Group>
))} ))}
</Fragment> </Fragment>

View File

@ -88,16 +88,50 @@ const TimelineHeaderNotification: React.FC<Props> = ({ notification }) => {
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content> <DropdownMenu.Content>
{[mShare, mStatus, mAccount, mInstance].map((type, i) => ( {[mShare, mStatus, mAccount, mInstance].map((menu, i) => (
<Fragment key={i}> <Fragment key={i}>
{type.map((mGroup, index) => ( {menu.map((group, index) => (
<DropdownMenu.Group key={index}> <DropdownMenu.Group key={index}>
{mGroup.map(menu => ( {group.map(item => {
<DropdownMenu.Item key={menu.key} {...menu.item}> switch (item.type) {
<DropdownMenu.ItemTitle children={menu.title} /> case 'item':
<DropdownMenu.ItemIcon ios={{ name: menu.icon }} /> return (
</DropdownMenu.Item> <DropdownMenu.Item key={item.key} {...item.props}>
))} <DropdownMenu.ItemTitle children={item.title} />
{item.icon ? (
<DropdownMenu.ItemIcon ios={{ name: item.icon }} />
) : null}
</DropdownMenu.Item>
)
case 'sub':
return (
// @ts-ignore
<DropdownMenu.Sub key={item.key}>
<DropdownMenu.SubTrigger
key={item.trigger.key}
{...item.trigger.props}
>
<DropdownMenu.ItemTitle children={item.trigger.title} />
{item.trigger.icon ? (
<DropdownMenu.ItemIcon
ios={{ name: item.trigger.icon }}
/>
) : null}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
{item.items.map(sub => (
<DropdownMenu.Item key={sub.key} {...sub.props}>
<DropdownMenu.ItemTitle children={sub.title} />
{sub.icon ? (
<DropdownMenu.ItemIcon ios={{ name: sub.icon }} />
) : null}
</DropdownMenu.Item>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
)
}
})}
</DropdownMenu.Group> </DropdownMenu.Group>
))} ))}
</Fragment> </Fragment>

View File

@ -3,6 +3,7 @@ import { displayMessage } from '@components/Message'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { NativeStackNavigationProp } from '@react-navigation/native-stack' import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import apiInstance from '@utils/api/instance'
import { TabSharedStackParamList, useNavState } from '@utils/navigation/navigators' import { TabSharedStackParamList, useNavState } from '@utils/navigation/navigators'
import { useAccountQuery } from '@utils/queryHooks/account' import { useAccountQuery } from '@utils/queryHooks/account'
import { import {
@ -14,7 +15,7 @@ import {
MutationVarsTimelineUpdateAccountProperty, MutationVarsTimelineUpdateAccountProperty,
useTimelineMutation useTimelineMutation
} from '@utils/queryHooks/timeline' } from '@utils/queryHooks/timeline'
import { useAccountStorage } from '@utils/storage/actions' import { getAccountStorage, getReadableAccounts, useAccountStorage } from '@utils/storage/actions'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Alert, Platform } from 'react-native' import { Alert, Platform } from 'react-native'
@ -29,13 +30,13 @@ const menuAccount = ({
openChange: boolean openChange: boolean
account?: Partial<Mastodon.Account> & Pick<Mastodon.Account, 'id' | 'username' | 'acct' | 'url'> account?: Partial<Mastodon.Account> & Pick<Mastodon.Account, 'id' | 'username' | 'acct' | 'url'>
status?: Mastodon.Status status?: Mastodon.Status
}): ContextMenu[][] => { }): ContextMenu => {
const navigation = const navigation =
useNavigation<NativeStackNavigationProp<TabSharedStackParamList, any, undefined>>() useNavigation<NativeStackNavigationProp<TabSharedStackParamList, any, undefined>>()
const navState = useNavState() const navState = useNavState()
const { t } = useTranslation(['common', 'componentContextMenu', 'componentRelationship']) const { t } = useTranslation(['common', 'componentContextMenu', 'componentRelationship'])
const menus: ContextMenu[][] = [[]] const menus: ContextMenu = [[]]
const [enabled, setEnabled] = useState(openChange) const [enabled, setEnabled] = useState(openChange)
useEffect(() => { useEffect(() => {
@ -135,8 +136,9 @@ const menuAccount = ({
if (!ownAccount && Platform.OS !== 'android' && type !== 'account') { if (!ownAccount && Platform.OS !== 'android' && type !== 'account') {
menus[0].push({ menus[0].push({
type: 'item',
key: 'account-following', key: 'account-following',
item: { props: {
onSelect: () => onSelect: () =>
data && data &&
actualAccount && actualAccount &&
@ -165,8 +167,9 @@ const menuAccount = ({
if (!ownAccount) { if (!ownAccount) {
menus[0].push({ menus[0].push({
type: 'item',
key: 'account-list', key: 'account-list',
item: { props: {
onSelect: () => navigation.navigate('Tab-Shared-Account-In-Lists', { account }), onSelect: () => navigation.navigate('Tab-Shared-Account-In-Lists', { account }),
disabled: Platform.OS !== 'android' ? !data || !isFetched : false, disabled: Platform.OS !== 'android' ? !data || !isFetched : false,
destructive: false, destructive: false,
@ -176,8 +179,9 @@ const menuAccount = ({
icon: 'checklist' icon: 'checklist'
}) })
menus[0].push({ menus[0].push({
type: 'item',
key: 'account-show-boosts', key: 'account-show-boosts',
item: { props: {
onSelect: () => onSelect: () =>
actualAccount && actualAccount &&
relationshipMutation.mutate({ relationshipMutation.mutate({
@ -196,8 +200,9 @@ const menuAccount = ({
icon: data?.showing_reblogs ? 'rectangle.on.rectangle.slash' : 'rectangle.on.rectangle' icon: data?.showing_reblogs ? 'rectangle.on.rectangle.slash' : 'rectangle.on.rectangle'
}) })
menus[0].push({ menus[0].push({
type: 'item',
key: 'account-mute', key: 'account-mute',
item: { props: {
onSelect: () => onSelect: () =>
actualAccount && actualAccount &&
timelineMutation.mutate({ timelineMutation.mutate({
@ -216,58 +221,139 @@ const menuAccount = ({
icon: data?.muting ? 'eye' : 'eye.slash' icon: data?.muting ? 'eye' : 'eye.slash'
}) })
const followAs = () => {
if (type !== 'account') return
const accounts = getReadableAccounts()
menus[0].push({
type: 'sub',
key: 'account-follow-as',
trigger: {
key: 'account-follow-as',
props: { destructive: false, disabled: false, hidden: !accounts.length },
title: t('componentContextMenu:account.followAs.trigger'),
icon: 'person.badge.plus'
},
items: accounts.map(a => ({
key: `account-${a.key}`,
props: {
onSelect: async () => {
const lookup = await apiInstance<Mastodon.Account>({
account: a.key,
method: 'get',
url: 'accounts/lookup',
params: {
acct:
account.acct === account.username
? `${account.acct}@${getAccountStorage.string('auth.account.domain')}`
: account.acct
}
}).then(res => res.body)
await apiInstance({
account: a.key,
method: 'post',
url: `accounts/${lookup.id}/follow`
})
.then(() =>
displayMessage({
type: 'success',
message: t('componentContextMenu:account.followAs.succeed', {
context: account.locked ? 'locked' : 'default',
defaultValue: 'default',
target: account.acct,
source: a.acct
})
})
)
.catch(err =>
displayMessage({
type: 'error',
message: t('common:message.error.message', {
function: t('componentContextMenu:account.followAs.failed')
}),
...(err.status &&
typeof err.status === 'number' &&
err.data &&
err.data.error &&
typeof err.data.error === 'string' && {
description: err.data.error
})
})
)
},
disabled: false,
destructive: false,
hidden: a.active
},
title: a.acct
}))
})
}
followAs()
menus.push([ menus.push([
{ {
key: 'account-block', type: 'sub',
item: { key: 'account-block-report',
onSelect: () => trigger: {
Alert.alert( key: 'account-block-report',
t('componentContextMenu:account.block.alert.title', { props: { destructive: true, disabled: false, hidden: false },
username: actualAccount?.username title: t('componentContextMenu:account.blockReport'),
}), icon: 'hand.raised'
undefined,
[
{
text: t('common:buttons.confirm'),
style: 'destructive',
onPress: () =>
actualAccount &&
timelineMutation.mutate({
type: 'updateAccountProperty',
id: actualAccount.id,
payload: { property: 'block', currentValue: data?.blocking }
})
},
{
text: t('common:buttons.cancel')
}
]
),
disabled: Platform.OS !== 'android' ? !data || !isFetched : false,
destructive: !data?.blocking,
hidden: false
}, },
title: t('componentContextMenu:account.block.action', { items: [
defaultValue: 'false', {
context: (data?.blocking || false).toString() key: 'account-block',
}), props: {
icon: data?.blocking ? 'checkmark.circle' : 'xmark.circle' onSelect: () =>
}, Alert.alert(
{ t('componentContextMenu:account.block.alert.title', {
key: 'account-reports', username: actualAccount?.username
item: { }),
onSelect: () => undefined,
actualAccount && [
navigation.navigate('Tab-Shared-Report', { {
account: actualAccount, text: t('common:buttons.confirm'),
status style: 'destructive',
onPress: () =>
actualAccount &&
timelineMutation.mutate({
type: 'updateAccountProperty',
id: actualAccount.id,
payload: { property: 'block', currentValue: data?.blocking }
})
},
{
text: t('common:buttons.cancel')
}
]
),
disabled: Platform.OS !== 'android' ? !data || !isFetched : false,
destructive: !data?.blocking,
hidden: false
},
title: t('componentContextMenu:account.block.action', {
defaultValue: 'false',
context: (data?.blocking || false).toString()
}), }),
disabled: Platform.OS !== 'android' ? !data || !isFetched : false, icon: data?.blocking ? 'checkmark.circle' : 'xmark.circle'
destructive: true, },
hidden: false {
}, key: 'account-reports',
title: t('componentContextMenu:account.reports.action'), props: {
icon: 'flag' onSelect: () =>
actualAccount &&
navigation.navigate('Tab-Shared-Report', {
account: actualAccount,
status
}),
disabled: Platform.OS !== 'android' ? !data || !isFetched : false,
destructive: true,
hidden: false
},
title: t('componentContextMenu:account.reports.action'),
icon: 'flag'
}
]
} }
]) ])
} }

View File

@ -3,51 +3,51 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'
import { RootStackParamList, useNavState } from '@utils/navigation/navigators' import { RootStackParamList, useNavState } from '@utils/navigation/navigators'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
const menuAt = ({ account }: { account: Mastodon.Account }): ContextMenu[][] => { const menuAt = ({ account }: { account: Mastodon.Account }): ContextMenu => {
const { t } = useTranslation('componentContextMenu') const { t } = useTranslation('componentContextMenu')
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>() const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>()
const navigationState = useNavState() const navigationState = useNavState()
const menus: ContextMenu[][] = [] return [
[
menus.push([ {
{ type: 'item',
key: 'at-direct', key: 'at-direct',
item: { props: {
onSelect: () => onSelect: () =>
navigation.navigate('Screen-Compose', { navigation.navigate('Screen-Compose', {
type: 'conversation', type: 'conversation',
accts: [account.acct], accts: [account.acct],
visibility: 'direct', visibility: 'direct',
navigationState navigationState
}), }),
disabled: false, disabled: false,
destructive: false, destructive: false,
hidden: false hidden: false
},
title: t('at.direct'),
icon: 'envelope'
}, },
title: t('at.direct'), {
icon: 'envelope' type: 'item',
}, key: 'at-public',
{ props: {
key: 'at-public', onSelect: () =>
item: { navigation.navigate('Screen-Compose', {
onSelect: () => type: 'conversation',
navigation.navigate('Screen-Compose', { accts: [account.acct],
type: 'conversation', visibility: 'public',
accts: [account.acct], navigationState
visibility: 'public', }),
navigationState disabled: false,
}), destructive: false,
disabled: false, hidden: false
destructive: false, },
hidden: false title: t('at.public'),
}, icon: 'at'
title: t('at.public'), }
icon: 'at' ]
} ]
])
return menus
} }
export default menuAt export default menuAt

View File

@ -1,6 +1,53 @@
type ContextMenu = { // type ContextMenu = (
// | {
// type: 'group'
// key: string
// items: ContextMenuItem[]
// }
// | {
// type: 'sub'
// key: string
// trigger: {
// key: string
// props: {
// disabled: boolean
// destructive: boolean
// hidden: boolean
// }
// title: string
// icon?: string
// }
// items: ContextMenuItem[]
// }
// )[]
type ContextMenu = (ContextMenuItem | ContextMenuSub)[][]
type ContextMenuItem = {
type: 'item'
key: string key: string
item: { onSelect: () => void; disabled: boolean; destructive: boolean; hidden: boolean } props: {
onSelect: () => void
disabled: boolean
destructive: boolean
hidden: boolean
}
title: string title: string
icon: string icon?: string
}
type ContextMenuSub = {
type: 'sub'
key: string
trigger: {
key: string
props: {
disabled: boolean
destructive: boolean
hidden: boolean
}
title: string
icon?: string
}
items: Omit<ContextMenuItem, 'type'>[]
} }

View File

@ -12,7 +12,7 @@ const menuInstance = ({
}: { }: {
status?: Mastodon.Status status?: Mastodon.Status
queryKey?: QueryKeyTimeline queryKey?: QueryKeyTimeline
}): ContextMenu[][] => { }): ContextMenu => {
if (!status || !queryKey) return [] if (!status || !queryKey) return []
const { t } = useTranslation(['common', 'componentContextMenu']) const { t } = useTranslation(['common', 'componentContextMenu'])
@ -30,15 +30,16 @@ const menuInstance = ({
} }
}) })
const menus: ContextMenu[][] = [] const menus: ContextMenu = []
const instance = parse(status.uri).hostname const instance = parse(status.uri).hostname
if (instance !== getAccountStorage.string('auth.domain')) { if (instance !== getAccountStorage.string('auth.domain')) {
menus.push([ menus.push([
{ {
type: 'item',
key: 'instance-block', key: 'instance-block',
item: { props: {
onSelect: () => onSelect: () =>
Alert.alert( Alert.alert(
t('componentContextMenu:instance.block.alert.title', { instance }), t('componentContextMenu:instance.block.alert.title', { instance }),

View File

@ -15,18 +15,19 @@ const menuShare = (
type: 'account' type: 'account'
url?: string url?: string
} }
): ContextMenu[][] => { ): ContextMenu => {
if (params.type === 'status' && params.visibility === 'direct') return [] if (params.type === 'status' && params.visibility === 'direct') return []
const { t } = useTranslation('componentContextMenu') const { t } = useTranslation('componentContextMenu')
const menus: ContextMenu[][] = [[]] const menus: ContextMenu = [[]]
if (params.url) { if (params.url) {
const url = params.url const url = params.url
menus[0].push({ menus[0].push({
type: 'item',
key: 'share', key: 'share',
item: { props: {
onSelect: () => { onSelect: () => {
switch (Platform.OS) { switch (Platform.OS) {
case 'ios': case 'ios':
@ -47,8 +48,9 @@ const menuShare = (
} }
if (params.type === 'status') if (params.type === 'status')
menus[0].push({ menus[0].push({
type: 'item',
key: 'copy', key: 'copy',
item: { props: {
onSelect: () => { onSelect: () => {
Clipboard.setString(params.rawContent?.current.join(`\n\n`) || '') Clipboard.setString(params.rawContent?.current.join(`\n\n`) || '')
displayMessage({ type: 'success', message: t(`copy.succeed`) }) displayMessage({ type: 'success', message: t(`copy.succeed`) })

View File

@ -21,7 +21,7 @@ const menuStatus = ({
}: { }: {
status?: Mastodon.Status status?: Mastodon.Status
queryKey?: QueryKeyTimeline queryKey?: QueryKeyTimeline
}): ContextMenu[][] => { }): ContextMenu => {
if (!status || !queryKey) return [] if (!status || !queryKey) return []
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Screen-Tabs'>>() const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList, 'Screen-Tabs'>>()
@ -55,7 +55,7 @@ const menuStatus = ({
} }
}) })
const menus: ContextMenu[][] = [] const menus: ContextMenu = []
const [accountId] = useAccountStorage.string('auth.account.id') const [accountId] = useAccountStorage.string('auth.account.id')
const ownAccount = accountId === status.account?.id const ownAccount = accountId === status.account?.id
@ -64,8 +64,9 @@ const menuStatus = ({
menus.push([ menus.push([
{ {
type: 'item',
key: 'status-edit', key: 'status-edit',
item: { props: {
onSelect: async () => { onSelect: async () => {
let replyToStatus: Mastodon.Status | undefined = undefined let replyToStatus: Mastodon.Status | undefined = undefined
if (status.in_reply_to_id) { if (status.in_reply_to_id) {
@ -102,8 +103,9 @@ const menuStatus = ({
icon: 'square.and.pencil' icon: 'square.and.pencil'
}, },
{ {
type: 'item',
key: 'status-delete-edit', key: 'status-delete-edit',
item: { props: {
onSelect: () => onSelect: () =>
Alert.alert( Alert.alert(
t('componentContextMenu:status.deleteEdit.alert.title'), t('componentContextMenu:status.deleteEdit.alert.title'),
@ -145,8 +147,9 @@ const menuStatus = ({
icon: 'pencil.and.outline' icon: 'pencil.and.outline'
}, },
{ {
type: 'item',
key: 'status-delete', key: 'status-delete',
item: { props: {
onSelect: () => onSelect: () =>
Alert.alert( Alert.alert(
t('componentContextMenu:status.delete.alert.title'), t('componentContextMenu:status.delete.alert.title'),
@ -176,8 +179,9 @@ const menuStatus = ({
menus.push([ menus.push([
{ {
type: 'item',
key: 'status-mute', key: 'status-mute',
item: { props: {
onSelect: () => onSelect: () =>
mutation.mutate({ mutation.mutate({
type: 'updateStatusProperty', type: 'updateStatusProperty',
@ -198,8 +202,9 @@ const menuStatus = ({
icon: status.muted ? 'speaker' : 'speaker.slash' icon: status.muted ? 'speaker' : 'speaker.slash'
}, },
{ {
type: 'item',
key: 'status-pin', key: 'status-pin',
item: { props: {
onSelect: () => onSelect: () =>
// Also note that reblogs cannot be pinned. // Also note that reblogs cannot be pinned.
mutation.mutate({ mutation.mutate({

View File

@ -15,6 +15,13 @@
"action_false": "Silencia l'usuari", "action_false": "Silencia l'usuari",
"action_true": "Deixa de silenciar l'usuari" "action_true": "Deixa de silenciar l'usuari"
}, },
"followAs": {
"trigger": "",
"succeed_default": "",
"succeed_locked": "",
"failed": ""
},
"blockReport": "",
"block": { "block": {
"action_false": "Bloqueja l'usuari", "action_false": "Bloqueja l'usuari",
"action_true": "Deixa de bloquejar l'usuari", "action_true": "Deixa de bloquejar l'usuari",

View File

@ -15,6 +15,13 @@
"action_false": "", "action_false": "",
"action_true": "" "action_true": ""
}, },
"followAs": {
"trigger": "",
"succeed_default": "",
"succeed_locked": "",
"failed": ""
},
"blockReport": "",
"block": { "block": {
"action_false": "", "action_false": "",
"action_true": "", "action_true": "",

View File

@ -15,6 +15,13 @@
"action_false": "Profil stummschalten", "action_false": "Profil stummschalten",
"action_true": "Stummschaltung des Nutzers aufheben" "action_true": "Stummschaltung des Nutzers aufheben"
}, },
"followAs": {
"trigger": "",
"succeed_default": "",
"succeed_locked": "",
"failed": ""
},
"blockReport": "",
"block": { "block": {
"action_false": "Nutzer blockieren", "action_false": "Nutzer blockieren",
"action_true": "User entblocken", "action_true": "User entblocken",

View File

@ -15,6 +15,13 @@
"action_false": "Σίγαση χρήστη", "action_false": "Σίγαση χρήστη",
"action_true": "Κατάργηση σίγασης χρήστη" "action_true": "Κατάργηση σίγασης χρήστη"
}, },
"followAs": {
"trigger": "",
"succeed_default": "",
"succeed_locked": "",
"failed": ""
},
"blockReport": "",
"block": { "block": {
"action_false": "Αποκλεισμός χρήστη", "action_false": "Αποκλεισμός χρήστη",
"action_true": "Κατάργηση αποκλεισμού χρήστη", "action_true": "Κατάργηση αποκλεισμού χρήστη",

View File

@ -15,6 +15,13 @@
"action_false": "Mute user", "action_false": "Mute user",
"action_true": "Unmute user" "action_true": "Unmute user"
}, },
"followAs": {
"trigger": "Follow as...",
"succeed_default": "Now following @{{target}} with @{{source}}",
"succeed_locked": "Sent follow request to @{{target}} with {{source}}, pending approval",
"failed": "Follow as"
},
"blockReport": "Block and report...",
"block": { "block": {
"action_false": "Block user", "action_false": "Block user",
"action_true": "Unblock user", "action_true": "Unblock user",

View File

@ -15,6 +15,13 @@
"action_false": "Silenciar usuario", "action_false": "Silenciar usuario",
"action_true": "Dejar de silenciar al usuario" "action_true": "Dejar de silenciar al usuario"
}, },
"followAs": {
"trigger": "",
"succeed_default": "",
"succeed_locked": "",
"failed": ""
},
"blockReport": "",
"block": { "block": {
"action_false": "Bloquear usuario", "action_false": "Bloquear usuario",
"action_true": "Desbloquear usuario", "action_true": "Desbloquear usuario",

View File

@ -15,6 +15,13 @@
"action_false": "Rendre muet l'utilisateur", "action_false": "Rendre muet l'utilisateur",
"action_true": "Rendre la parole" "action_true": "Rendre la parole"
}, },
"followAs": {
"trigger": "",
"succeed_default": "",
"succeed_locked": "",
"failed": ""
},
"blockReport": "",
"block": { "block": {
"action_false": "Bloquer l'utilisateur", "action_false": "Bloquer l'utilisateur",
"action_true": "Débloquer l'utilisateur", "action_true": "Débloquer l'utilisateur",

View File

@ -15,6 +15,13 @@
"action_false": "Muta utente", "action_false": "Muta utente",
"action_true": "Riattiva utente" "action_true": "Riattiva utente"
}, },
"followAs": {
"trigger": "",
"succeed_default": "",
"succeed_locked": "",
"failed": ""
},
"blockReport": "",
"block": { "block": {
"action_false": "Blocca utente", "action_false": "Blocca utente",
"action_true": "Sblocca utente", "action_true": "Sblocca utente",

View File

@ -15,6 +15,13 @@
"action_false": "ユーザーをミュート", "action_false": "ユーザーをミュート",
"action_true": "ユーザーのミュートを解除" "action_true": "ユーザーのミュートを解除"
}, },
"followAs": {
"trigger": "",
"succeed_default": "",
"succeed_locked": "",
"failed": ""
},
"blockReport": "",
"block": { "block": {
"action_false": "ユーザーをブロック", "action_false": "ユーザーをブロック",
"action_true": "ユーザーのブロックを解除", "action_true": "ユーザーのブロックを解除",

View File

@ -6,26 +6,33 @@
"action_false": "사용자 팔로우", "action_false": "사용자 팔로우",
"action_true": "사용자 팔로우 해제" "action_true": "사용자 팔로우 해제"
}, },
"inLists": "", "inLists": "사용자를 포함한 리스트",
"showBoosts": { "showBoosts": {
"action_false": "", "action_false": "사용자의 부스트 보이기",
"action_true": "" "action_true": "사용자의 부스트 숨기기"
}, },
"mute": { "mute": {
"action_false": "사용자 뮤트", "action_false": "사용자 뮤트",
"action_true": "사용자 뮤트 해제" "action_true": "사용자 뮤트 해제"
}, },
"followAs": {
"trigger": "",
"succeed_default": "",
"succeed_locked": "",
"failed": ""
},
"blockReport": "",
"block": { "block": {
"action_false": "사용자 차단", "action_false": "사용자 차단",
"action_true": "사용자 차단 해제", "action_true": "사용자 차단 해제",
"alert": { "alert": {
"title": "" "title": "정말 @{{username}} 사용자를 차단할까요?"
} }
}, },
"reports": { "reports": {
"action": "사용자 신고 및 차단", "action": "사용자 신고 및 차단",
"alert": { "alert": {
"title": "" "title": "정말 @{{username}} 사용자를 차단하고 신고할까요?"
} }
} }
}, },

View File

@ -15,8 +15,8 @@
"message": "마지막 읽음" "message": "마지막 읽음"
}, },
"refresh": { "refresh": {
"fetchPreviousPage": "여기부터 더 새로운", "fetchPreviousPage": "이 시점에 이어서 불러오기",
"refetch": "가장 최신으로" "refetch": "최신 내용 불러오기"
}, },
"shared": { "shared": {
"actioned": { "actioned": {
@ -83,7 +83,7 @@
"text": "불러오기 오류", "text": "불러오기 오류",
"button": "원격 링크 시도" "button": "원격 링크 시도"
}, },
"altText": "" "altText": "대체 텍스트"
}, },
"avatar": { "avatar": {
"accessibilityLabel": "{{name}}의 아바타", "accessibilityLabel": "{{name}}의 아바타",
@ -116,14 +116,14 @@
"accessibilityHint": "사용자 계정" "accessibilityHint": "사용자 계정"
} }
}, },
"application": "", "application": "{{application}}으로 툿",
"edited": { "edited": {
"accessibilityLabel": "툿 수정됨" "accessibilityLabel": "툿 수정됨"
}, },
"muted": { "muted": {
"accessibilityLabel": "툿 음소거됨" "accessibilityLabel": "툿 음소거됨"
}, },
"replies": "", "replies": "답장 <0 />",
"visibility": { "visibility": {
"direct": { "direct": {
"accessibilityLabel": "툿이 개인 메시지에요" "accessibilityLabel": "툿이 개인 메시지에요"

View File

@ -140,7 +140,7 @@
}, },
"editAttachment": { "editAttachment": {
"header": { "header": {
"title": "첨부파일 편집", "title": "첨부 파일 편집",
"right": { "right": {
"accessibilityLabel": "첨부 파일 편집 저장", "accessibilityLabel": "첨부 파일 편집 저장",
"failed": { "failed": {
@ -152,7 +152,7 @@
"content": { "content": {
"altText": { "altText": {
"heading": "시각장애인을 위한 미디어 설명", "heading": "시각장애인을 위한 미디어 설명",
"placeholder": "미디어에 alt-text라고도 하는 설명을 추가하여 시각 장애가 있는 사람들을 포함하여 더 많은 사람들이 액세스할 수 있도록 할 수 있어요. \n\n좋은 설명은 간결하지만 미디어에 있는 내용을 정확하게 제시하여 컨텍스트를 파악할 수 있는 것이에요." "placeholder": "미디어에 '대체 텍스트'라고도 하는 설명을 추가하여, 시각 장애가 있는 사람들을 포함해 더 많은 사람들이 접근하도록 할 수 있어요.\n\n좋은 설명은 간결하지만, 미디어의 내용을 정확하게 표현하여 문맥을 파악할 수 있는 것이에요."
}, },
"imageFocus": "포커스 원을 드래그하여 포커스 포인트를 업데이트할 수 있어요" "imageFocus": "포커스 원을 드래그하여 포커스 포인트를 업데이트할 수 있어요"
} }

View File

@ -266,7 +266,7 @@
"heading": "다크 테마", "heading": "다크 테마",
"options": { "options": {
"lighter": "기본값", "lighter": "기본값",
"darker": "" "darker": "완전히 검게"
} }
}, },
"browser": { "browser": {
@ -277,7 +277,7 @@
} }
}, },
"autoplayGifv": { "autoplayGifv": {
"heading": "" "heading": "타임라인의 GIF 파일 자동 재생"
}, },
"feedback": { "feedback": {
"heading": "기능 제안" "heading": "기능 제안"
@ -330,22 +330,22 @@
"name": "수정 이력" "name": "수정 이력"
}, },
"report": { "report": {
"name": "", "name": "@{{acct}} 신고",
"report": "", "report": "신고",
"forward": { "forward": {
"heading": "" "heading": "원격 인스턴스 {{instance}}에도 익명으로 전달"
}, },
"reasons": { "reasons": {
"heading": "", "heading": "이 계정에 어떤 문제가 있나요?",
"spam": "", "spam": "스팸입니다",
"other": "", "other": "다른 문제가 있습니다",
"violation": "" "violation": "서버 규칙을 위반합니다"
}, },
"comment": { "comment": {
"heading": "" "heading": "그 밖에 추가로 작성할 내용이 있나요?"
}, },
"violatedRules": { "violatedRules": {
"heading": "" "heading": "서버 규칙 위반 내용"
} }
}, },
"search": { "search": {
@ -378,7 +378,7 @@
"toot": { "toot": {
"name": "대화", "name": "대화",
"remoteFetch": { "remoteFetch": {
"title": "", "title": "원격 컨텐츠를 포함",
"message": "" "message": ""
} }
}, },

View File

@ -15,6 +15,13 @@
"action_false": "Gebruiker dempen", "action_false": "Gebruiker dempen",
"action_true": "Dempen opheffen voor gebruiker" "action_true": "Dempen opheffen voor gebruiker"
}, },
"followAs": {
"trigger": "",
"succeed_default": "",
"succeed_locked": "",
"failed": ""
},
"blockReport": "",
"block": { "block": {
"action_false": "Gebruiker blokkeren", "action_false": "Gebruiker blokkeren",
"action_true": "Gebruiker deblokkeren", "action_true": "Gebruiker deblokkeren",

View File

@ -15,6 +15,13 @@
"action_false": "Wycisz użytkownika", "action_false": "Wycisz użytkownika",
"action_true": "Wyłącz wyciszenie" "action_true": "Wyłącz wyciszenie"
}, },
"followAs": {
"trigger": "",
"succeed_default": "",
"succeed_locked": "",
"failed": ""
},
"blockReport": "",
"block": { "block": {
"action_false": "Zablokuj użytkownika", "action_false": "Zablokuj użytkownika",
"action_true": "Odblokuj użytkownika", "action_true": "Odblokuj użytkownika",

View File

@ -15,6 +15,13 @@
"action_false": "Silenciar usuário", "action_false": "Silenciar usuário",
"action_true": "Desativar o silêncio do usuário" "action_true": "Desativar o silêncio do usuário"
}, },
"followAs": {
"trigger": "",
"succeed_default": "",
"succeed_locked": "",
"failed": ""
},
"blockReport": "",
"block": { "block": {
"action_false": "Bloquear usuário", "action_false": "Bloquear usuário",
"action_true": "Desbloquear usuário", "action_true": "Desbloquear usuário",

View File

@ -15,6 +15,13 @@
"action_false": "", "action_false": "",
"action_true": "" "action_true": ""
}, },
"followAs": {
"trigger": "",
"succeed_default": "",
"succeed_locked": "",
"failed": ""
},
"blockReport": "",
"block": { "block": {
"action_false": "", "action_false": "",
"action_true": "", "action_true": "",

View File

@ -15,6 +15,13 @@
"action_false": "Tysta användare", "action_false": "Tysta användare",
"action_true": "Sluta tysta användare" "action_true": "Sluta tysta användare"
}, },
"followAs": {
"trigger": "",
"succeed_default": "",
"succeed_locked": "",
"failed": ""
},
"blockReport": "",
"block": { "block": {
"action_false": "Blockera användare", "action_false": "Blockera användare",
"action_true": "Avblockera användare", "action_true": "Avblockera användare",

View File

@ -15,6 +15,13 @@
"action_false": "Заглушити користувача", "action_false": "Заглушити користувача",
"action_true": "Зняти заглушення з користувача" "action_true": "Зняти заглушення з користувача"
}, },
"followAs": {
"trigger": "",
"succeed_default": "",
"succeed_locked": "",
"failed": ""
},
"blockReport": "",
"block": { "block": {
"action_false": "Заблокувати користувача", "action_false": "Заблокувати користувача",
"action_true": "Розблокувати користувача", "action_true": "Розблокувати користувача",

View File

@ -15,6 +15,13 @@
"action_false": "Ẩn người này", "action_false": "Ẩn người này",
"action_true": "Bỏ ẩn người dùng" "action_true": "Bỏ ẩn người dùng"
}, },
"followAs": {
"trigger": "",
"succeed_default": "",
"succeed_locked": "",
"failed": ""
},
"blockReport": "",
"block": { "block": {
"action_false": "Chặn người này", "action_false": "Chặn người này",
"action_true": "Bỏ chặn người dùng", "action_true": "Bỏ chặn người dùng",

View File

@ -15,6 +15,13 @@
"action_false": "静音用户", "action_false": "静音用户",
"action_true": "取消静音用户" "action_true": "取消静音用户"
}, },
"followAs": {
"trigger": "关注…",
"succeed_default": "{{source}} 正在关注 {{target}}",
"succeed_locked": "已从 {{source}} 发送关注请求至 {{target}},等待通过",
"failed": "用其它账户关注"
},
"blockReport": "屏蔽与举报…",
"block": { "block": {
"action_false": "屏蔽用户", "action_false": "屏蔽用户",
"action_true": "取消屏蔽用户", "action_true": "取消屏蔽用户",

View File

@ -15,6 +15,13 @@
"action_false": "靜音使用者", "action_false": "靜音使用者",
"action_true": "解除靜音使用者" "action_true": "解除靜音使用者"
}, },
"followAs": {
"trigger": "",
"succeed_default": "",
"succeed_locked": "",
"failed": ""
},
"blockReport": "",
"block": { "block": {
"action_false": "封鎖使用者", "action_false": "封鎖使用者",
"action_true": "解除封鎖使用者", "action_true": "解除封鎖使用者",

View File

@ -2,7 +2,7 @@ import AccountButton from '@components/AccountButton'
import CustomText from '@components/Text' import CustomText from '@components/Text'
import navigationRef from '@utils/navigation/navigationRef' import navigationRef from '@utils/navigation/navigationRef'
import { RootStackScreenProps } from '@utils/navigation/navigators' import { RootStackScreenProps } from '@utils/navigation/navigators'
import { getGlobalStorage } from '@utils/storage/actions' import { getReadableAccounts } 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 * as VideoThumbnails from 'expo-video-thumbnails' import * as VideoThumbnails from 'expo-video-thumbnails'
@ -92,7 +92,7 @@ const ScreenAccountSelection = ({
const { colors } = useTheme() const { colors } = useTheme()
const { t } = useTranslation('screenAccountSelection') const { t } = useTranslation('screenAccountSelection')
const accounts = getGlobalStorage.object('accounts') const accounts = getReadableAccounts()
return ( return (
<ScrollView <ScrollView
@ -125,24 +125,20 @@ const ScreenAccountSelection = ({
marginTop: StyleConstants.Spacing.M marginTop: StyleConstants.Spacing.M
}} }}
> >
{accounts && {accounts.map((account, index) => {
accounts return (
.slice() <AccountButton
.sort((a, b) => a.localeCompare(b)) key={index}
.map((account, index) => { account={account}
return ( additionalActions={() =>
<AccountButton navigationRef.navigate('Screen-Compose', {
key={index} type: 'share',
account={account} ...share
additionalActions={() => })
navigationRef.navigate('Screen-Compose', { }
type: 'share', />
...share )
}) })}
}
/>
)
})}
</View> </View>
</View> </View>
</ScrollView> </ScrollView>

View File

@ -1,7 +1,7 @@
import AccountButton from '@components/AccountButton' import AccountButton from '@components/AccountButton'
import ComponentInstance from '@components/Instance' import ComponentInstance from '@components/Instance'
import CustomText from '@components/Text' import CustomText from '@components/Text'
import { getGlobalStorage } from '@utils/storage/actions' import { getReadableAccounts } 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, { useEffect, useRef } from 'react' import React, { useEffect, useRef } from 'react'
@ -12,8 +12,7 @@ import { ScrollView } from 'react-native-gesture-handler'
const TabMeSwitch: React.FC = () => { const TabMeSwitch: React.FC = () => {
const { t } = useTranslation('screenTabs') const { t } = useTranslation('screenTabs')
const { colors } = useTheme() const { colors } = useTheme()
const accounts = getGlobalStorage.object('accounts') const accounts = getReadableAccounts()
const accountActive = getGlobalStorage.string('account.active')
const scrollViewRef = useRef<ScrollView>(null) const scrollViewRef = useRef<ScrollView>(null)
useEffect(() => { useEffect(() => {
@ -71,19 +70,9 @@ const TabMeSwitch: React.FC = () => {
marginTop: StyleConstants.Spacing.M marginTop: StyleConstants.Spacing.M
}} }}
> >
{accounts && {accounts.map((account, index) => {
accounts return <AccountButton key={index} account={account} />
.slice() })}
.sort((a, b) => a.localeCompare(b))
.map((account, index) => {
return (
<AccountButton
key={index}
account={account}
selected={account === accountActive}
/>
)
})}
</View> </View>
</View> </View>
</ScrollView> </ScrollView>

View File

@ -2,6 +2,7 @@ import { createContext } from 'react'
type AccountContextType = { type AccountContextType = {
account?: Mastodon.Account account?: Mastodon.Account
relationship?: Mastodon.Relationship
pageMe?: boolean pageMe?: boolean
} }
const AccountContext = createContext<AccountContextType>({} as AccountContextType) const AccountContext = createContext<AccountContextType>({} as AccountContextType)

View File

@ -1,4 +1,3 @@
import { useRoute } from '@react-navigation/native'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react' import React from 'react'
@ -11,16 +10,19 @@ import AccountInformationCreated from './Information/Created'
import AccountInformationFields from './Information/Fields' import AccountInformationFields from './Information/Fields'
import AccountInformationName from './Information/Name' import AccountInformationName from './Information/Name'
import AccountInformationNote from './Information/Note' import AccountInformationNote from './Information/Note'
import AccountInformationPrivateNote from './Information/PrivateNotes'
import AccountInformationStats from './Information/Stats' import AccountInformationStats from './Information/Stats'
const AccountInformation: React.FC = () => { const AccountInformation: React.FC = () => {
const { colors } = useTheme() const { colors } = useTheme()
const { name } = useRoute()
const myInfo = name !== 'Tab-Shared-Account'
return ( return (
<View style={styles.base}> <View
style={{
marginTop: -StyleConstants.Avatar.L / 2,
padding: StyleConstants.Spacing.Global.PagePadding
}}
>
<Placeholder <Placeholder
Animation={props => ( Animation={props => (
<Fade {...props} style={{ backgroundColor: colors.shimmerHighlight }} /> <Fade {...props} style={{ backgroundColor: colors.shimmerHighlight }} />
@ -35,6 +37,8 @@ const AccountInformation: React.FC = () => {
<AccountInformationAccount /> <AccountInformationAccount />
<AccountInformationPrivateNote />
<AccountInformationFields /> <AccountInformationFields />
<AccountInformationNote /> <AccountInformationNote />

View File

@ -1,6 +1,5 @@
import Icon from '@components/Icon' import Icon from '@components/Icon'
import CustomText from '@components/Text' import CustomText from '@components/Text'
import { useRelationshipQuery } from '@utils/queryHooks/relationship'
import { getAccountStorage, useAccountStorage } from '@utils/storage/actions' import { getAccountStorage, useAccountStorage } 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'
@ -11,7 +10,7 @@ import { PlaceholderLine } from 'rn-placeholder'
import AccountContext from '../Context' import AccountContext from '../Context'
const AccountInformationAccount: React.FC = () => { const AccountInformationAccount: React.FC = () => {
const { account, pageMe } = useContext(AccountContext) const { account, relationship, pageMe } = useContext(AccountContext)
const { t } = useTranslation('screenTabs') const { t } = useTranslation('screenTabs')
const { colors } = useTheme() const { colors } = useTheme()
@ -19,8 +18,6 @@ const AccountInformationAccount: React.FC = () => {
const [acct] = useAccountStorage.string('auth.account.acct') const [acct] = useAccountStorage.string('auth.account.acct')
const domain = getAccountStorage.string('auth.account.domain') const domain = getAccountStorage.string('auth.account.domain')
const { data: relationship } = useRelationshipQuery({ id: account?.id })
const localInstance = account?.acct.includes('@') ? account?.acct.includes(`@${domain}`) : true const localInstance = account?.acct.includes('@') ? account?.acct.includes(`@${domain}`) : true
if (account || pageMe) { if (account || pageMe) {

View File

@ -2,7 +2,6 @@ import Button from '@components/Button'
import menuAt from '@components/contextMenu/at' import menuAt from '@components/contextMenu/at'
import { RelationshipOutgoing } from '@components/Relationship' import { RelationshipOutgoing } from '@components/Relationship'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { useRelationshipQuery } from '@utils/queryHooks/relationship'
import { useAccountStorage } from '@utils/storage/actions' import { useAccountStorage } from '@utils/storage/actions'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import React, { useContext } from 'react' import React, { useContext } from 'react'
@ -12,7 +11,7 @@ import * as DropdownMenu from 'zeego/dropdown-menu'
import AccountContext from '../Context' import AccountContext from '../Context'
const AccountInformationActions: React.FC = () => { const AccountInformationActions: React.FC = () => {
const { account, pageMe } = useContext(AccountContext) const { account, relationship, pageMe } = useContext(AccountContext)
if (!account || account.suspended) { if (!account || account.suspended) {
return null return null
@ -50,13 +49,12 @@ const AccountInformationActions: React.FC = () => {
const [accountId] = useAccountStorage.string('auth.account.id') const [accountId] = useAccountStorage.string('auth.account.id')
const ownAccount = account?.id === accountId const ownAccount = account?.id === accountId
const query = useRelationshipQuery({ id: account.id })
const mAt = menuAt({ account }) const mAt = menuAt({ account })
if (!ownAccount && account) { if (!ownAccount && account) {
return ( return (
<View style={styles.base}> <View style={styles.base}>
{query.data && !query.data.blocked_by ? ( {relationship && !relationship.blocked_by ? (
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger> <DropdownMenu.Trigger>
<Button <Button
@ -69,14 +67,41 @@ const AccountInformationActions: React.FC = () => {
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content> <DropdownMenu.Content>
{mAt.map((mGroup, index) => ( {mAt.map((group, index) => (
<DropdownMenu.Group key={index}> <DropdownMenu.Group key={index}>
{mGroup.map(menu => ( {group.map(item => {
<DropdownMenu.Item key={menu.key} {...menu.item}> switch (item.type) {
<DropdownMenu.ItemTitle children={menu.title} /> case 'item':
<DropdownMenu.ItemIcon ios={{ name: menu.icon }} /> return (
</DropdownMenu.Item> <DropdownMenu.Item key={item.key} {...item.props}>
))} <DropdownMenu.ItemTitle children={item.title} />
{item.icon ? <DropdownMenu.ItemIcon ios={{ name: item.icon }} /> : null}
</DropdownMenu.Item>
)
case 'sub':
return (
// @ts-ignore
<DropdownMenu.Sub key={item.key}>
<DropdownMenu.SubTrigger key={item.trigger.key} {...item.trigger.props}>
<DropdownMenu.ItemTitle children={item.trigger.title} />
{item.trigger.icon ? (
<DropdownMenu.ItemIcon ios={{ name: item.trigger.icon }} />
) : null}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
{item.items.map(sub => (
<DropdownMenu.Item key={sub.key} {...sub.props}>
<DropdownMenu.ItemTitle children={sub.title} />
{sub.icon ? (
<DropdownMenu.ItemIcon ios={{ name: sub.icon }} />
) : null}
</DropdownMenu.Item>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
)
}
})}
</DropdownMenu.Group> </DropdownMenu.Group>
))} ))}
</DropdownMenu.Content> </DropdownMenu.Content>

View File

@ -0,0 +1,28 @@
import { ParseHTML } from '@components/Parse'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useContext } from 'react'
import { View } from 'react-native'
import AccountContext from '../Context'
const AccountInformationPrivateNote: React.FC = () => {
const { relationship, pageMe } = useContext(AccountContext)
if (pageMe) return null
const { colors } = useTheme()
return relationship?.note ? (
<View
style={{
marginBottom: StyleConstants.Spacing.L,
borderLeftColor: colors.border,
borderLeftWidth: StyleConstants.Spacing.XS,
paddingLeft: StyleConstants.Spacing.S
}}
>
<ParseHTML content={relationship.note} size={'S'} selectable numberOfLines={2} />
</View>
) : null
}
export default AccountInformationPrivateNote

View File

@ -7,6 +7,7 @@ import SegmentedControl from '@react-native-community/segmented-control'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { TabSharedStackScreenProps } from '@utils/navigation/navigators' import { TabSharedStackScreenProps } from '@utils/navigation/navigators'
import { useAccountQuery } from '@utils/queryHooks/account' import { useAccountQuery } from '@utils/queryHooks/account'
import { useRelationshipQuery } from '@utils/queryHooks/relationship'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
@ -51,6 +52,10 @@ const TabSharedAccount: React.FC<TabSharedStackScreenProps<'Tab-Shared-Account'>
onError: () => navigation.goBack() onError: () => navigation.goBack()
} }
}) })
const { data: dataRelationship } = useRelationshipQuery({
id: account._remote ? data?.id : account.id,
options: { enabled: account._remote ? !!data?.id : true }
})
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [queryKey, setQueryKey] = useState<QueryKeyTimeline>([ const [queryKey, setQueryKey] = useState<QueryKeyTimeline>([
@ -89,16 +94,48 @@ const TabSharedAccount: React.FC<TabSharedStackScreenProps<'Tab-Shared-Account'>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content> <DropdownMenu.Content>
{[mShare, mAccount].map((type, i) => ( {[mShare, mAccount].map((menu, i) => (
<Fragment key={i}> <Fragment key={i}>
{type.map((mGroup, index) => ( {menu.map((group, index) => (
<DropdownMenu.Group key={index}> <DropdownMenu.Group key={index}>
{mGroup.map(menu => ( {group.map(item => {
<DropdownMenu.Item key={menu.key} {...menu.item}> switch (item.type) {
<DropdownMenu.ItemTitle children={menu.title} /> case 'item':
<DropdownMenu.ItemIcon ios={{ name: menu.icon }} /> return (
</DropdownMenu.Item> <DropdownMenu.Item key={item.key} {...item.props}>
))} <DropdownMenu.ItemTitle children={item.title} />
{item.icon ? (
<DropdownMenu.ItemIcon ios={{ name: item.icon }} />
) : null}
</DropdownMenu.Item>
)
case 'sub':
return (
// @ts-ignore
<DropdownMenu.Sub key={item.key}>
<DropdownMenu.SubTrigger
key={item.trigger.key}
{...item.trigger.props}
>
<DropdownMenu.ItemTitle children={item.trigger.title} />
{item.trigger.icon ? (
<DropdownMenu.ItemIcon ios={{ name: item.trigger.icon }} />
) : null}
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
{item.items.map(sub => (
<DropdownMenu.Item key={sub.key} {...sub.props}>
<DropdownMenu.ItemTitle children={sub.title} />
{sub.icon ? (
<DropdownMenu.ItemIcon ios={{ name: sub.icon }} />
) : null}
</DropdownMenu.Item>
))}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
)
}
})}
</DropdownMenu.Group> </DropdownMenu.Group>
))} ))}
</Fragment> </Fragment>
@ -191,7 +228,7 @@ const TabSharedAccount: React.FC<TabSharedStackScreenProps<'Tab-Shared-Account'>
}, [segment, dataUpdatedAt, mode]) }, [segment, dataUpdatedAt, mode])
return ( return (
<AccountContext.Provider value={{ account: data }}> <AccountContext.Provider value={{ account: data, relationship: dataRelationship }}>
<AccountNav scrollY={scrollY} /> <AccountNav scrollY={scrollY} />
{data?.suspended ? ( {data?.suspended ? (

View File

@ -21,14 +21,14 @@ const apiGeneral = async <T = unknown>({
body body
}: Params): Promise<PagedResponse<T>> => { }: Params): Promise<PagedResponse<T>> => {
console.log( console.log(
ctx.bgGreen.bold(' API general ') + ctx.bgMagenta.bold(' General ') +
' ' + ' ' +
domain + domain +
' ' + ' ' +
method + method +
ctx.green(' -> ') + ctx.magenta(' -> ') +
`/${url}` + `/${url}` +
(params ? ctx.green(' -> ') : ''), (params ? ctx.magenta(' -> ') : ''),
params ? params : '' params ? params : ''
) )

View File

@ -27,9 +27,9 @@ const handleError =
if (error?.response) { if (error?.response) {
if (config?.captureResponse) { if (config?.captureResponse) {
Sentry.setTag('error_status', error.response.status)
Sentry.setContext('Error response', { Sentry.setContext('Error response', {
data: error.response.data, data: error.response.data,
status: error.response.status,
headers: error.response.headers headers: error.response.headers
}) })
} }

View File

@ -1,8 +1,10 @@
import { getAccountDetails } from '@utils/storage/actions' import { getAccountDetails } from '@utils/storage/actions'
import { StorageGlobal } from '@utils/storage/global'
import axios, { AxiosRequestConfig } from 'axios' import axios, { AxiosRequestConfig } from 'axios'
import { ctx, handleError, PagedResponse, parseHeaderLinks, userAgent } from './helpers' import { ctx, handleError, PagedResponse, parseHeaderLinks, userAgent } from './helpers'
export type Params = { export type Params = {
account?: StorageGlobal['account.active']
method: 'get' | 'post' | 'put' | 'delete' | 'patch' method: 'get' | 'post' | 'put' | 'delete' | 'patch'
version?: 'v1' | 'v2' version?: 'v1' | 'v2'
url: string url: string
@ -15,6 +17,7 @@ export type Params = {
} }
const apiInstance = async <T = unknown>({ const apiInstance = async <T = unknown>({
account,
method, method,
version = 'v1', version = 'v1',
url, url,
@ -23,7 +26,7 @@ const apiInstance = async <T = unknown>({
body, body,
extras extras
}: Params): Promise<PagedResponse<T>> => { }: Params): Promise<PagedResponse<T>> => {
const accountDetails = getAccountDetails(['auth.domain', 'auth.token']) const accountDetails = getAccountDetails(['auth.domain', 'auth.token'], account)
if (!accountDetails) { if (!accountDetails) {
console.warn(ctx.bgRed.white.bold(' API instance '), 'No account detail available') console.warn(ctx.bgRed.white.bold(' API instance '), 'No account detail available')
return Promise.reject() return Promise.reject()
@ -35,9 +38,9 @@ const apiInstance = async <T = unknown>({
} }
console.log( console.log(
ctx.bgGreen.bold(' API instance '), ctx.bgBlue.bold(' Instance '),
accountDetails['auth.domain'], accountDetails['auth.domain'],
method + ctx.green(' -> ') + `/${url}` + (params ? ctx.green(' -> ') : ''), method + ctx.blue(' -> ') + `/${url}` + (params ? ctx.blue(' -> ') : ''),
params ? params : '' params ? params : ''
) )

View File

@ -26,7 +26,7 @@ const apiTooot = async <T = unknown>({
body body
}: Params): Promise<{ body: T }> => { }: Params): Promise<{ body: T }> => {
console.log( console.log(
ctx.bgGreen.bold(' API tooot ') + ctx.bgGreen.bold(' tooot ') +
' ' + ' ' +
method + method +
ctx.green(' -> ') + ctx.green(' -> ') +

View File

@ -96,7 +96,7 @@ const pushUseConnect = () => {
) )
useEffect(() => { useEffect(() => {
Sentry.setContext('Push', { expoToken, pushEnabledCount }) Sentry.setTags({ expoToken, pushEnabledCount })
if (expoToken && pushEnabledCount) { if (expoToken && pushEnabledCount) {
connectQuery.refetch() connectQuery.refetch()

View File

@ -6,9 +6,9 @@ const log = (type: 'log' | 'warn' | 'error', func: string, message: string) => {
switch (type) { switch (type) {
case 'log': case 'log':
console.log( console.log(
ctx.bgBlue.white.bold(' Start up ') + ctx.bgGrey.white.bold(' Start up ') +
' ' + ' ' +
ctx.bgBlueBright.black(` ${func} `) + ctx.bgGrey.black(` ${func} `) +
' ' + ' ' +
message message
) )

View File

@ -4,8 +4,10 @@ import log from './log'
const timezone = () => { const timezone = () => {
log('log', 'Timezone', Localization.getCalendars()[0].timeZone || 'unknown') log('log', 'Timezone', Localization.getCalendars()[0].timeZone || 'unknown')
if ('__setDefaultTimeZone' in Intl.DateTimeFormat) { if ('__setDefaultTimeZone' in Intl.DateTimeFormat) {
// @ts-ignore try {
Intl.DateTimeFormat.__setDefaultTimeZone(Localization.getCalendars()[0].timeZone) // @ts-ignore
Intl.DateTimeFormat.__setDefaultTimeZone(Intl.DateTimeFormat.__setDefaultTimeZone('xxx'))
} catch {}
} }
} }

View File

@ -228,6 +228,41 @@ export const setAccount = async (account: string) => {
queryClient.clear() queryClient.clear()
} }
export type ReadableAccountType = {
acct: string
key: string
active: boolean
}
export const getReadableAccounts = (): ReadableAccountType[] => {
const accountActive = getGlobalStorage.string('account.active')
const accounts = getGlobalStorage.object('accounts')?.sort((a, b) => a.localeCompare(b))
accounts?.splice(
accounts.findIndex(a => a === accountActive),
1
)
accounts?.unshift(accountActive || '')
return (
accounts?.map(account => {
const details = getAccountDetails(
['auth.account.acct', 'auth.account.domain', 'auth.domain', 'auth.account.id'],
account
)
if (details) {
return {
acct: `@${details['auth.account.acct']}@${details['auth.account.domain']}`,
key: generateAccountKey({
domain: details['auth.domain'],
id: details['auth.account.id']
}),
active: account === accountActive
}
} else {
return { acct: '', key: '', active: false }
}
}) || []
).filter(a => a.acct.length)
}
export const removeAccount = async (account: string) => { export const removeAccount = async (account: string) => {
const currAccounts: NonNullable<StorageGlobal['accounts']> = JSON.parse( const currAccounts: NonNullable<StorageGlobal['accounts']> = JSON.parse(
storage.global.getString('accounts') || '[]' storage.global.getString('accounts') || '[]'