This commit is contained in:
Zhiyuan Zheng 2021-01-23 02:41:50 +01:00
parent aa467f6911
commit 86231fb7b7
No known key found for this signature in database
GPG Key ID: 078A93AB607D85E0
36 changed files with 614 additions and 481 deletions

View File

@ -1,7 +1,7 @@
import { ActionSheetProvider } from '@expo/react-native-action-sheet'
import Index from '@root/Index'
import dev from '@root/startup/dev'
// import sentry from '@root/startup/sentry'
import sentry from '@root/startup/sentry'
import log from '@root/startup/log'
import audio from '@root/startup/audio'
import onlineStatus from '@root/startup/onlineStatus'
@ -22,7 +22,7 @@ if (Platform.OS === 'android') {
dev()
// sentry()
sentry()
audio()
onlineStatus()

View File

@ -62,7 +62,6 @@
"react-native-reanimated": "2.0.0-rc.0",
"react-native-safe-area-context": "3.1.9",
"react-native-screens": "~2.15.0",
"react-native-shimmer-placeholder": "^2.0.6",
"react-native-svg": "12.1.0",
"react-native-tab-view": "^2.15.2",
"react-native-tab-view-viewpager-adapter": "^1.1.0",
@ -72,6 +71,7 @@
"react-redux": "^7.2.2",
"react-timeago": "^5.2.0",
"redux-persist": "^6.0.0",
"rn-placeholder": "^3.0.3",
"sentry-expo": "^3.0.4",
"tslib": "^2.0.3"
},

View File

@ -25,7 +25,7 @@ declare namespace Mastodon {
following_count: number
// Others
moved?: Status
moved?: Account
fields: Field[]
bot: boolean
source: Source

View File

@ -33,7 +33,13 @@ declare namespace Nav {
hashtag: Mastodon.Tag['name']
}
'Screen-Shared-ImagesViewer': {
imageUrls: (IImageInfo & {
imageUrls: ({
url: string
width?: number
height?: number
originUrl?: string
props?: any
} & {
preview_url: Mastodon.AttachmentImage['preview_url']
remote_url: Mastodon.AttachmentImage['remote_url']
imageIndex: number

View File

@ -17,6 +17,7 @@ import ScreenNotifications from '@screens/Notifications'
import ScreenPublic from '@screens/Public'
import { useTimelineQuery } from '@utils/queryHooks/timeline'
import {
getLocalAccount,
getLocalActiveIndex,
getLocalNotification,
localUpdateAccountPreferences,
@ -32,7 +33,7 @@ import React, {
useMemo,
useRef
} from 'react'
import { Platform, StatusBar } from 'react-native'
import { Image, Platform, StatusBar } from 'react-native'
import Toast from 'react-native-toast-message'
import { useDispatch, useSelector } from 'react-redux'
@ -120,7 +121,7 @@ const Index: React.FC<Props> = ({ localCorrupt }) => {
if (!prevNotification || !prevNotification.latestTime) {
dispatch(
localUpdateNotification({
unread: true
unread: false
})
)
} else if (
@ -160,6 +161,7 @@ const Index: React.FC<Props> = ({ localCorrupt }) => {
routeNameRef.current = currentRouteName
}, [])
const localAccount = useSelector(getLocalAccount)
const tabNavigatorScreenOptions = useCallback(
({ route }): BottomTabNavigationOptions => ({
tabBarIcon: ({
@ -171,32 +173,43 @@ const Index: React.FC<Props> = ({ localCorrupt }) => {
color: string
size: number
}) => {
let name: any
let updateColor: string = color
console.log()
switch (route.name) {
case 'Screen-Local':
name = 'Home'
break
return <Icon name='Home' size={size} color={color} />
case 'Screen-Public':
name = 'Globe'
!focused && (updateColor = theme.secondary)
break
return (
<Icon
name='Globe'
size={size}
color={!focused ? theme.secondary : color}
/>
)
case 'Screen-Post':
name = 'Plus'
break
return <Icon name='Plus' size={size} color={color} />
case 'Screen-Notifications':
name = 'Bell'
break
return <Icon name='Bell' size={size} color={color} />
case 'Screen-Me':
name = focused ? 'Meh' : 'Smile'
!focused && (updateColor = theme.secondary)
break
return localActiveIndex !== null ? (
<Image
source={{ uri: localAccount?.avatarStatic }}
style={{
width: size + 2,
height: size + 2,
borderRadius: size,
borderWidth: focused ? 2 : 0,
borderColor: focused ? theme.secondary : color
}}
/>
) : (
<Icon
name={focused ? 'Meh' : 'Smile'}
size={size}
color={!focused ? theme.secondary : color}
/>
)
default:
name = 'AlertOctagon'
break
return <Icon name='AlertOctagon' size={size} color={color} />
}
return <Icon name={name} size={size} color={updateColor} />
},
...(Platform.OS === 'android' && {
tabBarVisible:
@ -208,7 +221,7 @@ const Index: React.FC<Props> = ({ localCorrupt }) => {
getFocusedRouteNameFromRoute(route) !== 'Screen-Me-Switch'
})
}),
[]
[localActiveIndex, localAccount]
)
const tabNavigatorTabBarOptions = useMemo(
() => ({

View File

@ -4,6 +4,7 @@ import { Blurhash } from 'gl-react-blurhash'
import React, { useCallback, useEffect, useState } from 'react'
import {
Image,
ImageStyle,
Pressable,
StyleProp,
StyleSheet,
@ -78,6 +79,7 @@ export interface Props {
dimension?: { width: number; height: number }
onPress?: () => void
style?: StyleProp<ViewStyle>
imageStyle?: StyleProp<ImageStyle>
}
const GracefullyImage: React.FC<Props> = ({
@ -87,7 +89,8 @@ const GracefullyImage: React.FC<Props> = ({
blurhash,
dimension,
onPress,
style
style,
imageStyle
}) => {
const { mode, theme } = useTheme()
@ -125,9 +128,16 @@ const GracefullyImage: React.FC<Props> = ({
const children = useCallback(() => {
if (imageVisible && !hidden) {
if (cache) {
return <ImageCache uri={imageVisible} style={styles.image} />
return (
<ImageCache uri={imageVisible} style={[styles.image, imageStyle]} />
)
} else {
return <Image source={{ uri: imageVisible }} style={styles.image} />
return (
<Image
source={{ uri: imageVisible }}
style={[styles.image, imageStyle]}
/>
)
}
} else if (blurhash) {
return (
@ -143,19 +153,17 @@ const GracefullyImage: React.FC<Props> = ({
<Blurhash hash={blurhash} />
</Surface>
)
} else {
return (
<View
style={[styles.image, { backgroundColor: theme.shimmerDefault }]}
/>
)
}
}, [hidden, mode, imageVisible])
return (
<Pressable
children={children}
style={[style, dimension && { ...dimension }]}
style={[
style,
{ backgroundColor: theme.shimmerDefault },
dimension && { ...dimension }
]}
{...(onPress
? hidden
? { disabled: true }

View File

@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next'
import { Alert, Image, StyleSheet, Text, TextInput, View } from 'react-native'
import { useQueryClient } from 'react-query'
import { useDispatch, useSelector } from 'react-redux'
import { Placeholder, Fade } from 'rn-placeholder'
import InstanceAuth from './Instance/Auth'
import InstanceInfo from './Instance/Info'
import { toast } from './toast'
@ -209,47 +210,60 @@ const ComponentInstance: React.FC<Props> = ({
</Text>
) : null}
<View>
<InstanceInfo
visible={instanceQuery.data?.title !== undefined}
header={t('server.information.name')}
content={instanceQuery.data?.title || undefined}
potentialWidth={10}
/>
<InstanceInfo
visible={instanceQuery.data?.short_description !== undefined}
header={t('server.information.description.heading')}
content={instanceQuery.data?.short_description || undefined}
potentialLines={5}
/>
<View style={styles.instanceStats}>
<Placeholder
{...(instanceQuery.isFetching && {
Animation: props => (
<Fade
{...props}
style={{ backgroundColor: theme.shimmerHighlight }}
/>
)
})}
>
<InstanceInfo
style={styles.stat1}
visible={instanceQuery.data?.stats?.user_count !== undefined}
header={t('server.information.accounts')}
content={
instanceQuery.data?.stats?.user_count?.toString() || undefined
}
potentialWidth={4}
visible={instanceQuery.data?.title !== undefined}
header={t('server.information.name')}
content={instanceQuery.data?.title || undefined}
potentialWidth={2}
/>
<InstanceInfo
style={styles.stat2}
visible={instanceQuery.data?.stats?.status_count !== undefined}
header={t('server.information.statuses')}
content={
instanceQuery.data?.stats?.status_count?.toString() || undefined
}
potentialWidth={4}
visible={instanceQuery.data?.short_description !== undefined}
header={t('server.information.description.heading')}
content={instanceQuery.data?.short_description || undefined}
potentialLines={5}
/>
<InstanceInfo
style={styles.stat3}
visible={instanceQuery.data?.stats?.domain_count !== undefined}
header={t('server.information.domains')}
content={
instanceQuery.data?.stats?.domain_count?.toString() || undefined
}
potentialWidth={4}
/>
</View>
<View style={styles.instanceStats}>
<InstanceInfo
style={styles.stat1}
visible={instanceQuery.data?.stats?.user_count !== undefined}
header={t('server.information.accounts')}
content={
instanceQuery.data?.stats?.user_count?.toString() || undefined
}
potentialWidth={4}
/>
<InstanceInfo
style={styles.stat2}
visible={instanceQuery.data?.stats?.status_count !== undefined}
header={t('server.information.statuses')}
content={
instanceQuery.data?.stats?.status_count?.toString() ||
undefined
}
potentialWidth={4}
/>
<InstanceInfo
style={styles.stat3}
visible={instanceQuery.data?.stats?.domain_count !== undefined}
header={t('server.information.domains')}
content={
instanceQuery.data?.stats?.domain_count?.toString() ||
undefined
}
potentialWidth={4}
/>
</View>
</Placeholder>
{type === 'local' ? (
<View style={styles.disclaimer}>
<Icon
@ -258,12 +272,12 @@ const ComponentInstance: React.FC<Props> = ({
color={theme.secondary}
style={styles.disclaimerIcon}
/>
<Text
style={[styles.disclaimerText, { color: theme.secondary }]}
onPress={() => Linking.openURL('https://tooot.app/privacy')}
>
<Text style={[styles.disclaimerText, { color: theme.secondary }]}>
{t('server.disclaimer')}
<Text style={{ color: theme.blue }}>
<Text
style={{ color: theme.blue }}
onPress={() => Linking.openURL('https://tooot.app/privacy')}
>
https://tooot.app/privacy
</Text>
</Text>

View File

@ -1,11 +1,10 @@
import { ParseHTML } from '@components/Parse'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import { LinearGradient } from 'expo-linear-gradient'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { Dimensions, StyleSheet, Text, View, ViewStyle } from 'react-native'
import { createShimmerPlaceholder } from 'react-native-shimmer-placeholder'
import { StyleSheet, Text, View, ViewStyle } from 'react-native'
import { PlaceholderLine } from 'rn-placeholder'
export interface Props {
style?: ViewStyle
@ -17,46 +16,36 @@ export interface Props {
}
const InstanceInfo = React.memo(
({
style,
visible,
header,
content,
potentialWidth,
potentialLines = 1
}: Props) => {
({ style, header, content, potentialWidth, potentialLines = 1 }: Props) => {
const { t } = useTranslation('componentInstance')
const { theme } = useTheme()
const ShimmerPlaceholder = createShimmerPlaceholder(LinearGradient)
return (
<View style={[styles.base, style]}>
<Text style={[styles.header, { color: theme.primary }]}>{header}</Text>
<ShimmerPlaceholder
visible={visible}
stopAutoRun
width={
potentialWidth
? potentialWidth * StyleConstants.Font.Size.M
: Dimensions.get('screen').width -
StyleConstants.Spacing.Global.PagePadding * 4
}
height={StyleConstants.Font.LineHeight.M * potentialLines}
shimmerColors={[
theme.shimmerDefault,
theme.shimmerHighlight,
theme.shimmerDefault
]}
>
{content ? (
<ParseHTML
content={content}
size={'M'}
numberOfLines={5}
expandHint={t('server.information.description.expandHint')}
{content ? (
<ParseHTML
content={content}
size={'M'}
numberOfLines={5}
expandHint={t('server.information.description.expandHint')}
/>
) : (
Array.from(Array(potentialLines)).map((_, i) => (
<PlaceholderLine
key={i}
width={
potentialWidth
? potentialWidth * StyleConstants.Font.Size.M
: undefined
}
height={StyleConstants.Font.LineHeight.M}
color={theme.shimmerDefault}
noMargin
style={{ borderRadius: 0 }}
/>
) : null}
</ShimmerPlaceholder>
))
)}
</View>
)
}

View File

@ -27,15 +27,10 @@ const ParseEmojis: React.FC<Props> = ({
...StyleConstants.FontStyle[size],
...(fontBold && { fontWeight: StyleConstants.Font.Weight.Bold })
},
imageContainer: {
paddingVertical:
(StyleConstants.Font.LineHeight[size] -
StyleConstants.Font.Size[size]) /
3
},
image: {
width: StyleConstants.Font.Size[size],
height: StyleConstants.Font.Size[size]
height: StyleConstants.Font.Size[size],
transform: [{ translateY: size === 'L' ? -3 : -1 }]
}
})
}, [mode])
@ -58,13 +53,11 @@ const ParseEmojis: React.FC<Props> = ({
<Text key={i}>
{/* When emoji starts a paragraph, lineHeight will break */}
{i === 0 ? <Text> </Text> : null}
<View style={styles.imageContainer}>
<Image
transitionDuration={0}
uri={emojis[emojiIndex].url}
style={[styles.image]}
/>
</View>
<Image
transitionDuration={0}
uri={emojis[emojiIndex].url}
style={[styles.image]}
/>
</Text>
)
} else {

View File

@ -104,7 +104,8 @@ const renderNode = ({
key={index}
style={{
color: theme.blue,
...StyleConstants.FontStyle[size]
...StyleConstants.FontStyle[size],
alignItems: 'center'
}}
onPress={async () =>
!disableDetails && !shouldBeTag
@ -114,14 +115,17 @@ const renderNode = ({
})
}
>
{content || (showFullLink ? href : domain[1])}
{!shouldBeTag ? (
<Icon
color={theme.blue}
name='ExternalLink'
size={StyleConstants.Font.Size[size]}
style={{
transform: [{ translateY: size === 'L' ? -3 : -1 }]
}}
/>
) : null}
{content || (showFullLink ? href : domain[1])}
</Text>
)
}

View File

@ -125,7 +125,8 @@ const styles = StyleSheet.create({
base: {
flex: 1,
flexDirection: 'column',
padding: StyleConstants.Spacing.Global.PagePadding
padding: StyleConstants.Spacing.Global.PagePadding,
paddingBottom: 0
},
header: {
flex: 1,

View File

@ -118,7 +118,7 @@ const TimelineDefault: React.FC<Props> = ({
const styles = StyleSheet.create({
statusView: {
padding: StyleConstants.Spacing.Global.PagePadding,
paddingBottom: StyleConstants.Spacing.S
paddingBottom: 0
},
header: {
flex: 1,

View File

@ -123,7 +123,7 @@ const TimelineNotifications: React.FC<Props> = ({
const styles = StyleSheet.create({
notificationView: {
padding: StyleConstants.Spacing.Global.PagePadding,
paddingBottom: StyleConstants.Spacing.M
paddingBottom: 0
},
header: {
flex: 1,

View File

@ -110,7 +110,9 @@ const TimelineActions: React.FC<Props> = ({ queryKey, status, reblog }) => {
reblog,
payload: {
property: 'reblogged',
currentValue: status.reblogged
currentValue: status.reblogged,
propertyCount: 'reblogs_count',
countValue: status.reblogs_count
}
}),
[status.reblogged]
@ -124,7 +126,9 @@ const TimelineActions: React.FC<Props> = ({ queryKey, status, reblog }) => {
reblog,
payload: {
property: 'favourited',
currentValue: status.favourited
currentValue: status.favourited,
propertyCount: 'favourites_count',
countValue: status.favourites_count
}
}),
[status.favourited]
@ -138,7 +142,9 @@ const TimelineActions: React.FC<Props> = ({ queryKey, status, reblog }) => {
reblog,
payload: {
property: 'bookmarked',
currentValue: status.bookmarked
currentValue: status.bookmarked,
propertyCount: undefined,
countValue: undefined
}
}),
[status.bookmarked]
@ -156,7 +162,7 @@ const TimelineActions: React.FC<Props> = ({ queryKey, status, reblog }) => {
<Text
style={{
color: theme.secondary,
...StyleConstants.FontStyle.M,
fontSize: StyleConstants.Font.Size.M,
marginLeft: StyleConstants.Spacing.XS
}}
>
@ -182,8 +188,8 @@ const TimelineActions: React.FC<Props> = ({ queryKey, status, reblog }) => {
{status.reblogs_count > 0 && (
<Text
style={{
color: theme.secondary,
...StyleConstants.FontStyle.M,
color: iconColorAction(status.reblogged),
fontSize: StyleConstants.Font.Size.M,
marginLeft: StyleConstants.Spacing.XS
}}
>
@ -205,9 +211,10 @@ const TimelineActions: React.FC<Props> = ({ queryKey, status, reblog }) => {
{status.favourites_count > 0 && (
<Text
style={{
color: theme.secondary,
...StyleConstants.FontStyle.M,
marginLeft: StyleConstants.Spacing.XS
color: iconColorAction(status.favourited),
fontSize: StyleConstants.Font.Size.M,
marginLeft: StyleConstants.Spacing.XS,
marginTop: 0
}}
>
{status.favourites_count}
@ -264,15 +271,14 @@ const TimelineActions: React.FC<Props> = ({ queryKey, status, reblog }) => {
const styles = StyleSheet.create({
actions: {
flexDirection: 'row',
marginTop: StyleConstants.Spacing.S
flexDirection: 'row'
},
action: {
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
paddingVertical: StyleConstants.Spacing.S
minHeight: StyleConstants.Font.Size.L + StyleConstants.Spacing.S * 4
}
})

View File

@ -54,9 +54,11 @@ const TimelineHeaderNotification: React.FC<Props> = ({
/>
<View style={styles.meta}>
<HeaderSharedCreated created_at={notification.created_at} />
<HeaderSharedVisibility
visibility={notification.status?.visibility}
/>
{notification.status?.visibility ? (
<HeaderSharedVisibility
visibility={notification.status.visibility}
/>
) : null}
<HeaderSharedMuted muted={notification.status?.muted} />
<HeaderSharedApplication
application={notification.status?.application}

View File

@ -5,20 +5,34 @@ import React from 'react'
import { StyleSheet } from 'react-native'
export interface Props {
visibility?: Mastodon.Status['visibility']
visibility: Mastodon.Status['visibility']
}
const HeaderSharedVisibility: React.FC<Props> = ({ visibility }) => {
const { theme } = useTheme()
return visibility && visibility === 'private' ? (
<Icon
name='Lock'
size={StyleConstants.Font.Size.S}
color={theme.secondary}
style={styles.visibility}
/>
) : null
switch (visibility) {
case 'private':
return (
<Icon
name='Lock'
size={StyleConstants.Font.Size.S}
color={theme.secondary}
style={styles.visibility}
/>
)
case 'direct':
return (
<Icon
name='Mail'
size={StyleConstants.Font.Size.S}
color={theme.secondary}
style={styles.visibility}
/>
)
default:
return null
}
}
const styles = StyleSheet.create({

View File

@ -1,5 +1,6 @@
export default {
content: {
moved: 'User moved',
created_at: 'Registered: {{date}}',
summary: {
statuses_count: '{{count}} toots',

View File

@ -1,5 +1,6 @@
export default {
content: {
moved: '账户已迁移',
created_at: '注册时间:{{date}}',
summary: {
statuses_count: '{{count}} 条嘟文',

View File

@ -1,7 +1,6 @@
import Button from '@components/Button'
import ComponentInstance from '@components/Instance'
import { useNavigation } from '@react-navigation/native'
import { useAccountCheckQuery } from '@utils/queryHooks/accountCheck'
import {
getLocalActiveIndex,
getLocalInstances,
@ -38,19 +37,15 @@ const AccountButton: React.FC<Props> = ({
const queryClient = useQueryClient()
const navigation = useNavigation()
const dispatch = useDispatch()
const { isLoading, data } = useAccountCheckQuery({
id: instance.account.id,
index,
options: { retry: false }
})
return (
<Button
type='text'
disabled={disabled}
loading={isLoading}
style={styles.button}
content={`@${data?.acct || '...'}@${instance.uri}${disabled ? ' ✓' : ''}`}
content={`@${instance.account.acct}@${instance.uri}${
disabled ? ' ✓' : ''
}`}
onPress={() => {
dispatch(localUpdateActiveIndex(index))
queryClient.clear()

View File

@ -25,8 +25,6 @@ import accountInitialState from './Account/utils/initialState'
import accountReducer from './Account/utils/reducer'
import { SharedAccountProp } from './sharedScreens'
// Moved account example: https://m.cmx.im/web/accounts/27812
const ScreenSharedAccount: React.FC<SharedAccountProp> = ({
route: {
params: { account }

View File

@ -1,6 +1,8 @@
import { StyleConstants } from '@utils/styles/constants'
import React, { createRef, useEffect } from 'react'
import { Animated, StyleSheet, View } from 'react-native'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback } from 'react'
import { StyleSheet, View } from 'react-native'
import { Placeholder, Fade } from 'rn-placeholder'
import AccountInformationAccount from './Information/Account'
import AccountInformationActions from './Information/Actions'
import AccountInformationAvatar from './Information/Avatar'
@ -20,65 +22,50 @@ const AccountInformation: React.FC<Props> = ({
account,
ownAccount = false
}) => {
const shimmerNameRef = createRef<any>()
const shimmerAccountRef = createRef<any>()
const shimmerCreatedRef = createRef<any>()
const shimmerStatsRef = createRef<any>()
useEffect(() => {
const informationAnimated = Animated.stagger(400, [
Animated.parallel([
shimmerNameRef.current?.getAnimated(),
shimmerAccountRef.current?.getAnimated(),
shimmerCreatedRef.current?.getAnimated(),
shimmerStatsRef.current?.ref1.getAnimated(),
shimmerStatsRef.current?.ref2.getAnimated(),
shimmerStatsRef.current?.ref3.getAnimated()
])
])
Animated.loop(informationAnimated).start()
}, [])
const { mode, theme } = useTheme()
const animation = useCallback(
props => (
<Fade {...props} style={{ backgroundColor: theme.shimmerHighlight }} />
),
[mode]
)
return (
<View style={styles.base}>
{/* <Text>Moved or not: {account.moved}</Text> */}
<View style={styles.avatarAndActions}>
<AccountInformationAvatar account={account} />
<View style={styles.actions}>
{ownAccount ? (
<AccountInformationSwitch />
) : (
<AccountInformationActions account={account} />
)}
<Placeholder Animation={animation}>
<View style={styles.avatarAndActions}>
<AccountInformationAvatar account={account} />
<View style={styles.actions}>
{ownAccount ? (
<AccountInformationSwitch />
) : (
<AccountInformationActions account={account} />
)}
</View>
</View>
</View>
<AccountInformationName ref={shimmerNameRef} account={account} />
<AccountInformationName account={account} />
<AccountInformationAccount
ref={shimmerAccountRef}
account={account}
ownAccount={ownAccount}
/>
<AccountInformationAccount account={account} ownAccount={ownAccount} />
{!ownAccount ? (
<>
{account?.fields && account.fields.length > 0 ? (
<AccountInformationFields account={account} />
) : null}
{account?.note &&
account.note.length > 0 &&
account.note !== '<p></p>' ? (
// Empty notes might generate empty p tag
<AccountInformationNotes account={account} />
) : null}
<AccountInformationCreated
ref={shimmerCreatedRef}
account={account}
/>
</>
) : null}
{!ownAccount ? (
<>
{account?.fields && account.fields.length > 0 ? (
<AccountInformationFields account={account} />
) : null}
{account?.note &&
account.note.length > 0 &&
account.note !== '<p></p>' ? (
// Empty notes might generate empty p tag
<AccountInformationNotes account={account} />
) : null}
<AccountInformationCreated account={account} />
</>
) : null}
<AccountInformationStats ref={shimmerStatsRef} account={account} />
<AccountInformationStats account={account} ownAccount={ownAccount} />
</Placeholder>
</View>
)
}

View File

@ -1,79 +1,109 @@
import Icon from '@components/Icon'
import { getLocalUri } from '@utils/slices/instancesSlice'
import { getLocalAccount, getLocalUri } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import { LinearGradient } from 'expo-linear-gradient'
import React, { forwardRef } from 'react'
import React, { useMemo } from 'react'
import { StyleSheet, Text, View } from 'react-native'
import ShimmerPlaceholder, {
createShimmerPlaceholder
} from 'react-native-shimmer-placeholder'
import { useSelector } from 'react-redux'
import { PlaceholderLine } from 'rn-placeholder'
export interface Props {
account: Mastodon.Account | undefined
ownAccount?: boolean
}
const AccountInformationAccount = forwardRef<ShimmerPlaceholder, Props>(
({ account, ownAccount }, ref) => {
const { theme } = useTheme()
const localUri = useSelector(getLocalUri)
const AccountInformationAccount: React.FC<Props> = ({
account,
ownAccount
}) => {
const { theme } = useTheme()
const localAccount = useSelector(getLocalAccount)
const localUri = useSelector(getLocalUri)
const ShimmerPlaceholder = createShimmerPlaceholder(LinearGradient)
const movedStyle = useMemo(
() =>
StyleSheet.create({
base: {
textDecorationLine: account?.moved ? 'line-through' : undefined
}
}),
[account?.moved]
)
const movedContent = useMemo(() => {
if (account?.moved) {
return (
<Text
style={[
styles.moved,
{ color: theme.secondary, ...StyleConstants.FontStyle.M }
]}
selectable
>
@{account.moved.acct}
</Text>
)
}
}, [account?.moved])
if (account || (ownAccount && localAccount !== undefined)) {
return (
<ShimmerPlaceholder
ref={ref}
visible={account?.acct !== undefined}
width={StyleConstants.Font.Size.M * 8}
height={StyleConstants.Font.LineHeight.M}
style={{ marginBottom: StyleConstants.Spacing.L }}
shimmerColors={[
theme.shimmerDefault,
theme.shimmerHighlight,
theme.shimmerDefault
]}
<View
style={[styles.base, { flexDirection: 'row', alignItems: 'center' }]}
>
<View style={styles.account}>
<Text
style={{
<Text
style={[
movedStyle.base,
{
color: theme.secondary,
...StyleConstants.FontStyle.M
}}
selectable
>
@{account?.acct}
{ownAccount ? `@${localUri}` : null}
</Text>
{account?.locked ? (
<Icon
name='Lock'
style={styles.type}
color={theme.secondary}
size={StyleConstants.Font.Size.M}
/>
) : null}
{account?.bot ? (
<Icon
name='HardDrive'
style={styles.type}
color={theme.secondary}
size={StyleConstants.Font.Size.M}
/>
) : null}
</View>
</ShimmerPlaceholder>
}
]}
selectable
>
@{ownAccount ? localAccount?.acct : account?.acct}
{ownAccount ? `@${localUri}` : null}
</Text>
{movedContent}
{account?.locked ? (
<Icon
name='Lock'
style={styles.type}
color={theme.secondary}
size={StyleConstants.Font.Size.M}
/>
) : null}
{account?.bot ? (
<Icon
name='HardDrive'
style={styles.type}
color={theme.secondary}
size={StyleConstants.Font.Size.M}
/>
) : null}
</View>
)
} else {
return (
<PlaceholderLine
width={StyleConstants.Font.Size.M * 2}
height={StyleConstants.Font.LineHeight.M}
color={theme.shimmerDefault}
noMargin
style={styles.base}
/>
)
}
)
}
const styles = StyleSheet.create({
account: {
flexDirection: 'row',
alignItems: 'center'
base: {
borderRadius: 0,
marginBottom: StyleConstants.Spacing.L
},
type: { marginLeft: StyleConstants.Spacing.S }
type: { marginLeft: StyleConstants.Spacing.S },
moved: {
marginLeft: StyleConstants.Spacing.S
}
})
export default React.memo(

View File

@ -4,12 +4,29 @@ import { useNavigation } from '@react-navigation/native'
import { useRelationshipQuery } from '@utils/queryHooks/relationship'
import { StyleConstants } from '@utils/styles/constants'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native'
export interface Props {
account: Mastodon.Account | undefined
}
const GoToMoved = ({ account }: { account: Mastodon.Account }) => {
const { t } = useTranslation('sharedAccount')
const navigation = useNavigation()
const query = useRelationshipQuery({ id: account.id })
return query.data && !query.data.blocked_by ? (
<Button
type='text'
content={t('content.moved')}
onPress={() =>
navigation.push('Screen-Shared-Account', { account: account.moved })
}
/>
) : null
}
const Conversation = ({ account }: { account: Mastodon.Account }) => {
const navigation = useNavigation()
const query = useRelationshipQuery({ id: account.id })
@ -32,10 +49,14 @@ const Conversation = ({ account }: { account: Mastodon.Account }) => {
const AccountInformationActions: React.FC<Props> = ({ account }) => {
return account && account.id ? (
<>
<Conversation account={account} />
<RelationshipOutgoing id={account.id} />
</>
account.moved ? (
<GoToMoved account={account} />
) : (
<>
<Conversation account={account} />
<RelationshipOutgoing id={account.id} />
</>
)
) : null
}

View File

@ -1,27 +1,29 @@
import GracefullyImage from '@components/GracefullyImage'
import { StyleConstants } from '@utils/styles/constants'
import React from 'react'
import React, { useMemo } from 'react'
import { StyleSheet } from 'react-native'
export interface Props {
account: Mastodon.Account | undefined
}
const AccountInformationAvatar = React.memo(
({ account }: Props) => {
return (
<GracefullyImage
style={styles.base}
uri={{ original: account?.avatar }}
dimension={{
width: StyleConstants.Avatar.L,
height: StyleConstants.Avatar.L
}}
/>
)
},
(_, next) => next.account === undefined
)
const AccountInformationAvatar: React.FC<Props> = ({ account }) => {
const dimension = useMemo(
() => ({
width: StyleConstants.Avatar.L,
height: StyleConstants.Avatar.L
}),
[]
)
return (
<GracefullyImage
style={styles.base}
uri={{ original: account?.avatar }}
dimension={dimension}
/>
)
}
const styles = StyleSheet.create({
base: { borderRadius: 8, overflow: 'hidden' }

View File

@ -1,76 +1,74 @@
import Icon from '@components/Icon'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import { LinearGradient } from 'expo-linear-gradient'
import React, { forwardRef } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, Text, View } from 'react-native'
import ShimmerPlaceholder, {
createShimmerPlaceholder
} from 'react-native-shimmer-placeholder'
import { PlaceholderLine } from 'rn-placeholder'
export interface Props {
account: Mastodon.Account | undefined
}
const AccountInformationCreated = forwardRef<ShimmerPlaceholder, Props>(
({ account }, ref) => {
const { i18n } = useTranslation()
const { theme } = useTheme()
const { t } = useTranslation('sharedAccount')
const ShimmerPlaceholder = createShimmerPlaceholder(LinearGradient)
const AccountInformationCreated: React.FC<Props> = ({ account }) => {
const { i18n } = useTranslation()
const { theme } = useTheme()
const { t } = useTranslation('sharedAccount')
if (account) {
return (
<ShimmerPlaceholder
ref={ref}
visible={account?.created_at !== undefined}
width={StyleConstants.Font.Size.S * 8}
height={StyleConstants.Font.LineHeight.S}
style={{ marginBottom: StyleConstants.Spacing.M }}
shimmerColors={[
theme.shimmerDefault,
theme.shimmerHighlight,
theme.shimmerDefault
]}
<View
style={[styles.base, { flexDirection: 'row', alignItems: 'center' }]}
>
<View style={styles.created}>
<Icon
name='Calendar'
size={StyleConstants.Font.Size.S}
color={theme.secondary}
style={styles.icon}
/>
<Text
style={{
color: theme.secondary,
...StyleConstants.FontStyle.S
}}
>
{t('content.created_at', {
date: new Date(account?.created_at || '').toLocaleDateString(
i18n.language,
{
year: 'numeric',
month: 'long',
day: 'numeric'
}
)
})}
</Text>
</View>
</ShimmerPlaceholder>
<Icon
name='Calendar'
size={StyleConstants.Font.Size.S}
color={theme.secondary}
style={styles.icon}
/>
<Text
style={{
color: theme.secondary,
...StyleConstants.FontStyle.S
}}
>
{t('content.created_at', {
date: new Date(account?.created_at || '').toLocaleDateString(
i18n.language,
{
year: 'numeric',
month: 'long',
day: 'numeric'
}
)
})}
</Text>
</View>
)
} else {
return (
<PlaceholderLine
width={StyleConstants.Font.Size.S * 3}
height={StyleConstants.Font.LineHeight.S}
color={theme.shimmerDefault}
noMargin
style={styles.base}
/>
)
}
)
}
const styles = StyleSheet.create({
created: {
flexDirection: 'row',
alignItems: 'center'
base: {
borderRadius: 0,
marginBottom: StyleConstants.Spacing.M
},
icon: {
marginRight: StyleConstants.Spacing.XS
}
})
export default AccountInformationCreated
export default React.memo(
AccountInformationCreated,
(_, next) => next.account === undefined
)

View File

@ -1,52 +1,80 @@
import { ParseEmojis } from '@components/Parse'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import { LinearGradient } from 'expo-linear-gradient'
import React, { forwardRef } from 'react'
import { StyleSheet } from 'react-native'
import ShimmerPlaceholder, {
createShimmerPlaceholder
} from 'react-native-shimmer-placeholder'
import React, { useMemo } from 'react'
import { StyleSheet, Text, View } from 'react-native'
import { PlaceholderLine } from 'rn-placeholder'
export interface Props {
account: Mastodon.Account | undefined
}
const AccountInformationName = forwardRef<ShimmerPlaceholder, Props>(
({ account }, ref) => {
const { theme } = useTheme()
const ShimmerPlaceholder = createShimmerPlaceholder(LinearGradient)
const AccountInformationName: React.FC<Props> = ({ account }) => {
const { theme } = useTheme()
return (
<ShimmerPlaceholder
ref={ref}
visible={
account?.display_name !== undefined || account?.username !== undefined
const movedStyle = useMemo(
() =>
StyleSheet.create({
base: {
textDecorationLine: account?.moved ? 'line-through' : undefined
}
width={StyleConstants.Font.Size.L * 8}
height={StyleConstants.Font.LineHeight.L}
style={styles.name}
shimmerColors={[theme.shimmerDefault, theme.shimmerHighlight, theme.shimmerDefault]}
>
{account ? (
}),
[account?.moved]
)
const movedContent = useMemo(() => {
if (account?.moved) {
return (
<View style={styles.moved}>
<ParseEmojis
content={account.moved.display_name || account.moved.username}
emojis={account.moved.emojis}
size='L'
fontBold
/>
</View>
)
}
}, [account?.moved])
if (account) {
return (
<View style={[styles.base, { flexDirection: 'row' }]}>
<Text style={movedStyle.base}>
<ParseEmojis
content={account.display_name || account.username}
emojis={account.emojis}
size='L'
fontBold
/>
) : null}
</ShimmerPlaceholder>
</Text>
{movedContent}
</View>
)
} else {
return (
<PlaceholderLine
width={StyleConstants.Font.Size.L * 2}
height={StyleConstants.Font.LineHeight.L}
color={theme.shimmerDefault}
noMargin
style={styles.base}
/>
)
}
)
}
const styles = StyleSheet.create({
name: {
flexDirection: 'row',
base: {
borderRadius: 0,
marginTop: StyleConstants.Spacing.M,
marginBottom: StyleConstants.Spacing.XS
},
moved: {
marginLeft: StyleConstants.Spacing.S
}
})
export default AccountInformationName
export default React.memo(
AccountInformationName,
(_, next) => next.account === undefined
)

View File

@ -1,106 +1,93 @@
import { useNavigation } from '@react-navigation/native'
import { StyleConstants } from '@root/utils/styles/constants'
import { useTheme } from '@root/utils/styles/ThemeManager'
import { LinearGradient } from 'expo-linear-gradient'
import React, { createRef, forwardRef, useImperativeHandle } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, Text, View } from 'react-native'
import ShimmerPlaceholder, {
createShimmerPlaceholder
} from 'react-native-shimmer-placeholder'
import { PlaceholderLine } from 'rn-placeholder'
export interface Props {
account: Mastodon.Account | undefined
ownAccount?: boolean
}
const AccountInformationStats = forwardRef<any, Props>(({ account }, ref) => {
const AccountInformationStats: React.FC<Props> = ({ account, ownAccount }) => {
const navigation = useNavigation()
const { theme } = useTheme()
const { t } = useTranslation('sharedAccount')
const ShimmerPlaceholder = createShimmerPlaceholder(LinearGradient)
const ref1 = createRef<ShimmerPlaceholder>()
const ref2 = createRef<ShimmerPlaceholder>()
const ref3 = createRef<ShimmerPlaceholder>()
useImperativeHandle(ref, () => ({
get ref1 () {
return ref1.current
},
get ref2 () {
return ref2.current
},
get ref3 () {
return ref3.current
}
}))
return (
<View style={styles.stats}>
<ShimmerPlaceholder
ref={ref1}
visible={account !== undefined}
width={StyleConstants.Font.Size.S * 5}
height={StyleConstants.Font.LineHeight.S}
shimmerColors={[theme.shimmerDefault, theme.shimmerHighlight, theme.shimmerDefault]}
>
<Text style={[styles.stat, { color: theme.primary }]}>
{t('content.summary.statuses_count', {
<View style={[styles.stats, { flexDirection: 'row' }]}>
{account ? (
<Text
style={[styles.stat, { color: theme.primary }]}
children={t('content.summary.statuses_count', {
count: account?.statuses_count || 0
})}
</Text>
</ShimmerPlaceholder>
<ShimmerPlaceholder
ref={ref2}
visible={account !== undefined}
width={StyleConstants.Font.Size.S * 5}
height={StyleConstants.Font.LineHeight.S}
shimmerColors={[theme.shimmerDefault, theme.shimmerHighlight, theme.shimmerDefault]}
>
onPress={() =>
ownAccount && navigation.push('Screen-Shared-Account', { account })
}
/>
) : (
<PlaceholderLine
width={StyleConstants.Font.Size.S * 1.25}
height={StyleConstants.Font.LineHeight.S}
color={theme.shimmerDefault}
noMargin
style={{ borderRadius: 0 }}
/>
)}
{account ? (
<Text
style={[styles.stat, { color: theme.primary, textAlign: 'right' }]}
children={t('content.summary.following_count', {
count: account?.following_count || 0
})}
onPress={() =>
account &&
navigation.push('Screen-Shared-Relationships', {
account,
initialType: 'following'
})
}
>
{t('content.summary.following_count', {
count: account?.following_count || 0
})}
</Text>
</ShimmerPlaceholder>
<ShimmerPlaceholder
ref={ref3}
visible={account !== undefined}
width={StyleConstants.Font.Size.S * 5}
height={StyleConstants.Font.LineHeight.S}
shimmerColors={[theme.shimmerDefault, theme.shimmerHighlight, theme.shimmerDefault]}
>
/>
) : (
<PlaceholderLine
width={StyleConstants.Font.Size.S * 1.25}
height={StyleConstants.Font.LineHeight.S}
color={theme.shimmerDefault}
noMargin
style={{ borderRadius: 0 }}
/>
)}
{account ? (
<Text
style={[styles.stat, { color: theme.primary, textAlign: 'center' }]}
children={t('content.summary.followers_count', {
count: account?.followers_count || 0
})}
onPress={() =>
account &&
navigation.push('Screen-Shared-Relationships', {
account,
initialType: 'followers'
})
}
>
{t('content.summary.followers_count', {
count: account?.followers_count || 0
})}
</Text>
</ShimmerPlaceholder>
/>
) : (
<PlaceholderLine
width={StyleConstants.Font.Size.S * 1.25}
height={StyleConstants.Font.LineHeight.S}
color={theme.shimmerDefault}
noMargin
style={{ borderRadius: 0 }}
/>
)}
</View>
)
})
}
const styles = StyleSheet.create({
stats: {
flex: 1,
flexDirection: 'row',
justifyContent: 'space-between'
},
stat: {

View File

@ -2,49 +2,14 @@ import { HeaderLeft, HeaderRight } from '@components/Header'
import { StyleConstants } from '@utils/styles/constants'
import { findIndex } from 'lodash'
import React, { useCallback, useState } from 'react'
import {
Image,
Platform,
Share,
StatusBar,
StyleSheet,
Text
} from 'react-native'
import { Image, Platform, Share, StatusBar, StyleSheet, Text } from 'react-native'
import ImageViewer from 'react-native-image-zoom-viewer'
import { IImageInfo } from 'react-native-image-zoom-viewer/built/image-viewer.type'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { createNativeStackNavigator } from 'react-native-screens/native-stack'
import { SharedImagesViewerProp } from './sharedScreens'
const Stack = createNativeStackNavigator()
const TheImage = ({
style,
source,
imageUrls
}: {
style: any
source: { uri: string }
imageUrls: (IImageInfo & {
preview_url: Mastodon.AttachmentImage['preview_url']
remote_url: Mastodon.AttachmentImage['remote_url']
imageIndex: number
})[]
}) => {
const [imageVisible, setImageVisible] = useState(false)
Image.getSize(source.uri, () => setImageVisible(true))
return (
<Image
style={style}
source={{
uri: imageVisible
? source.uri
: imageUrls[findIndex(imageUrls, ['url', source.uri])].preview_url
}}
/>
)
}
const ScreenSharedImagesViewer: React.FC<SharedImagesViewerProp> = ({
route: {
params: { imageUrls, imageIndex }
@ -64,15 +29,17 @@ const ScreenSharedImagesViewer: React.FC<SharedImagesViewerProp> = ({
index={initialIndex}
imageUrls={imageUrls}
pageAnimateTime={250}
enableSwipeDown={true}
useNativeDriver={true}
enableSwipeDown
useNativeDriver
swipeDownThreshold={100}
renderIndicator={() => <></>}
saveToLocalByLongPress={false}
onSwipeDown={() => navigation.goBack()}
style={{ flex: 1, marginBottom: 44 + safeAreaInsets.bottom }}
onChange={index => index !== undefined && setCurrentIndex(index)}
renderImage={props => <TheImage {...props} imageUrls={imageUrls} />}
renderImage={prop => {
return <Image {...prop} resizeMode={'contain'} />
}}
/>
</>
),

View File

@ -1,7 +1,10 @@
import client from '@api/client'
import NetInfo from '@react-native-community/netinfo'
import { store } from '@root/store'
import { localRemoveInstance } from '@utils/slices/instancesSlice'
import {
localRemoveInstance,
localUpdateAccount
} from '@utils/slices/instancesSlice'
import log from './log'
const netInfo = async (): Promise<{
@ -31,6 +34,12 @@ const netInfo = async (): Promise<{
store.dispatch(localRemoveInstance(activeIndex))
return Promise.resolve({ connected: true, corruputed: '' })
} else {
store.dispatch(
localUpdateAccount({
acct: res.acct,
avatarStatic: res.avatar_static
})
)
return Promise.resolve({ connected: true })
}
})

View File

@ -4,7 +4,7 @@ import log from './log'
const sentry = () => {
log('log', 'Sentry', 'initializing')
return Sentry.init({
Sentry.init({
dsn: Constants.manifest.extra.sentryDSN,
enableInExpoDevelopment: false,
debug: __DEV__

View File

@ -242,8 +242,16 @@ export type MutationVarsTimelineUpdateStatusProperty = {
reblog?: boolean
payload:
| {
property: 'bookmarked' | 'favourited' | 'muted' | 'pinned' | 'reblogged'
property: 'bookmarked' | 'muted' | 'pinned'
currentValue: boolean
propertyCount: undefined
countValue: undefined
}
| {
property: 'favourited' | 'reblogged'
currentValue: boolean
propertyCount: 'favourites_count' | 'reblogs_count'
countValue: number
}
| {
property: 'poll'

View File

@ -19,6 +19,13 @@ const updateConversation = ({
typeof payload.currentValue === 'boolean'
? !payload.currentValue
: true
if (payload.propertyCount) {
if (typeof payload.currentValue === 'boolean' && payload.currentValue) {
item.last_status[payload.propertyCount] = payload.countValue - 1
} else {
item.last_status[payload.propertyCount] = payload.countValue + 1
}
}
}
return item
}

View File

@ -16,6 +16,13 @@ const updateNotification = ({
typeof payload.currentValue === 'boolean'
? !payload.currentValue
: true
if (payload.propertyCount) {
if (typeof payload.currentValue === 'boolean' && payload.currentValue) {
item.status[payload.propertyCount] = payload.countValue - 1
} else {
item.status[payload.propertyCount] = payload.countValue + 1
}
}
}
return item
}

View File

@ -23,11 +23,25 @@ const updateStatus = ({
typeof payload.currentValue === 'boolean'
? !payload.currentValue
: true
if (payload.propertyCount) {
if (typeof payload.currentValue === 'boolean' && payload.currentValue) {
item.reblog![payload.propertyCount] = payload.countValue - 1
} else {
item.reblog![payload.propertyCount] = payload.countValue + 1
}
}
} else {
item[payload.property] =
typeof payload.currentValue === 'boolean'
? !payload.currentValue
: true
if (payload.propertyCount) {
if (typeof payload.currentValue === 'boolean' && payload.currentValue) {
item[payload.propertyCount] = payload.countValue - 1
} else {
item[payload.propertyCount] = payload.countValue + 1
}
}
}
return item
}

View File

@ -15,6 +15,8 @@ export type InstanceLocal = {
uri: Mastodon.Instance['uri']
account: {
id: Mastodon.Account['id']
acct: Mastodon.Account['acct']
avatarStatic: Mastodon.Account['avatar_static']
preferences: Mastodon.Preferences
}
notification: {
@ -64,7 +66,7 @@ export const localAddInstance = createAsyncThunk(
const instanceLocal: InstancesState['local'] = store.getState().instances
.local
const { id } = await client<Mastodon.Account>({
const { id, acct, avatar_static } = await client<Mastodon.Account>({
method: 'get',
instance: 'remote',
instanceDomain: url,
@ -108,6 +110,8 @@ export const localAddInstance = createAsyncThunk(
uri,
account: {
id,
acct,
avatarStatic: avatar_static,
preferences
},
notification: {
@ -182,6 +186,19 @@ const instancesSlice = createSlice({
throw new Error('Set index cannot be found')
}
},
localUpdateAccount: (
state,
action: PayloadAction<
Pick<InstanceLocal['account'], 'acct' & 'avatarStatic'>
>
) => {
if (state.local.activeIndex !== null) {
state.local.instances[state.local.activeIndex].account = {
...state.local.instances[state.local.activeIndex].account,
...action.payload
}
}
},
localUpdateNotification: (
state,
action: PayloadAction<Partial<InstanceLocal['notification']>>
@ -273,6 +290,7 @@ export const getRemoteUrl = ({ instances: { remote } }: RootState) => remote.url
export const {
localUpdateActiveIndex,
localUpdateAccount,
localUpdateNotification,
remoteUpdate
} = instancesSlice.actions

View File

@ -9212,6 +9212,11 @@ rimraf@~2.2.6:
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.2.8.tgz#e439be2aaee327321952730f99a8929e4fc50582"
integrity sha1-5Dm+Kq7jJzIZUnMPmaiSnk/FBYI=
rn-placeholder@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/rn-placeholder/-/rn-placeholder-3.0.3.tgz#98f635b263ee003af2a984eed32d86ade308df35"
integrity sha512-EmVeLT8zDcTPilQZ2OHO/IiYUy2gApKGgbshDZBX0C4qxsn0cFATwgwOwyz8O7Vwg1Hul97Ci95hu7d6Js6XMQ==
rsvp@^4.8.4:
version "4.8.5"
resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734"