Remove most React memorization

Though added memo for timeline components making them (almost) pure
This commit is contained in:
xmflsct 2022-12-29 00:36:35 +01:00
parent 1ea6aff328
commit 4cddbb9bad
32 changed files with 1116 additions and 1210 deletions

View File

@ -32,7 +32,7 @@
"@react-native-community/blur": "^4.3.0",
"@react-native-community/netinfo": "9.3.7",
"@react-native-community/segmented-control": "^2.2.2",
"@react-native-menu/menu": "^0.7.2",
"@react-native-menu/menu": "^0.7.3",
"@react-navigation/bottom-tabs": "^6.5.2",
"@react-navigation/native": "^6.1.1",
"@react-navigation/native-stack": "^6.9.7",

View File

@ -17,10 +17,7 @@ import {
setAccount,
setGlobalStorage
} from '@utils/storage/actions'
import {
hasMigratedFromAsyncStorage,
migrateFromAsyncStorage
} from '@utils/storage/migrations/toMMKV'
import { migrateFromAsyncStorage, versionStorageGlobal } from '@utils/storage/migrations/toMMKV'
import ThemeManager from '@utils/styles/ThemeManager'
import * as Localization from 'expo-localization'
import * as SplashScreen from 'expo-splash-screen'
@ -51,17 +48,15 @@ const App: React.FC = () => {
const [appIsReady, setAppIsReady] = useState(false)
const [localCorrupt, setLocalCorrupt] = useState<string>()
const [hasMigrated, setHasMigrated] = useState(hasMigratedFromAsyncStorage)
const [hasMigrated, setHasMigrated] = useState<boolean>(versionStorageGlobal !== undefined)
useEffect(() => {
const prepare = async () => {
if (!hasMigrated && !hasMigratedFromAsyncStorage) {
if (!hasMigrated) {
try {
await migrateFromAsyncStorage()
setHasMigrated(true)
} catch (e) {
// TODO: fall back to AsyncStorage? Wipe storage clean and use MMKV? Crash app?
}
} catch {}
} else {
log('log', 'App', 'loading from MMKV')
const account = getGlobalStorage.string('account.active')

View File

@ -1,7 +1,7 @@
import Icon from '@components/Icon'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useMemo, useState } from 'react'
import React, { useState } from 'react'
import { AccessibilityProps, Pressable, StyleProp, View, ViewStyle } from 'react-native'
import { Flow } from 'react-native-animated-spinkit'
import CustomText from './Text'
@ -48,18 +48,16 @@ const Button: React.FC<Props> = ({
overlay = false,
onPress
}) => {
const { colors, theme } = useTheme()
const { colors } = useTheme()
const loadingSpinkit = useMemo(
() => (
const loadingSpinkit = () =>
loading ? (
<View style={{ position: 'absolute' }}>
<Flow size={StyleConstants.Font.Size[size]} color={colors.secondary} />
</View>
),
[theme]
)
) : null
const mainColor = useMemo(() => {
const mainColor = () => {
if (selected) {
return colors.blue
} else if (overlay) {
@ -73,29 +71,21 @@ const Button: React.FC<Props> = ({
return colors.primaryDefault
}
}
}, [theme, disabled, loading, selected])
}
const colorBackground = useMemo(() => {
if (overlay) {
return colors.backgroundOverlayInvert
} else {
return colors.backgroundDefault
}
}, [theme])
const children = useMemo(() => {
const children = () => {
switch (type) {
case 'icon':
return (
<>
<Icon
name={content}
color={mainColor}
color={mainColor()}
strokeWidth={strokeWidth}
style={{ opacity: loading ? 0 : 1 }}
size={StyleConstants.Font.Size[size] * (size === 'L' ? 1.25 : 1)}
/>
{loading ? loadingSpinkit : null}
{loadingSpinkit()}
</>
)
case 'text':
@ -103,7 +93,7 @@ const Button: React.FC<Props> = ({
<>
<CustomText
style={{
color: mainColor,
color: mainColor(),
fontSize: StyleConstants.Font.Size[size] * (size === 'L' ? 1.25 : 1),
opacity: loading ? 0 : 1
}}
@ -111,11 +101,11 @@ const Button: React.FC<Props> = ({
children={content}
testID='text'
/>
{loading ? loadingSpinkit : null}
{loadingSpinkit()}
</>
)
}
}, [theme, content, loading, disabled])
}
const [layoutHeight, setLayoutHeight] = useState<number | undefined>()
@ -136,8 +126,8 @@ const Button: React.FC<Props> = ({
justifyContent: 'center',
alignItems: 'center',
borderWidth: overlay ? 0 : 1,
borderColor: mainColor,
backgroundColor: colorBackground,
borderColor: mainColor(),
backgroundColor: overlay ? colors.backgroundOverlayInvert : colors.backgroundDefault,
paddingVertical: StyleConstants.Spacing[spacing],
paddingHorizontal: StyleConstants.Spacing[spacing] + StyleConstants.Spacing.XS,
width: round && layoutHeight ? layoutHeight : undefined
@ -149,7 +139,7 @@ const Button: React.FC<Props> = ({
})}
testID='base'
onPress={onPress}
children={children}
children={children()}
disabled={selected || disabled || loading}
/>
)

View File

@ -1,6 +1,6 @@
import { useAccessibility } from '@utils/accessibility/AccessibilityManager'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useMemo, useState } from 'react'
import React, { useState } from 'react'
import {
AccessibilityProps,
Image,
@ -65,7 +65,7 @@ const GracefullyImage = ({
}
}
const blurhashView = useMemo(() => {
const blurhashView = () => {
if (hidden || !imageLoaded) {
if (blurhash) {
return <Blurhash decodeAsync blurhash={blurhash} style={styles.placeholder} />
@ -75,7 +75,7 @@ const GracefullyImage = ({
} else {
return null
}
}, [hidden, imageLoaded])
}
return (
<Pressable
@ -98,7 +98,7 @@ const GracefullyImage = ({
style={[{ flex: 1 }, imageStyle]}
onLoad={onLoad}
/>
{blurhashView}
{blurhashView()}
</Pressable>
)
}

View File

@ -3,7 +3,7 @@ import { StackNavigationProp } from '@react-navigation/stack'
import { TabLocalStackParamList } from '@utils/navigation/navigators'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { PropsWithChildren, useCallback, useState } from 'react'
import React, { PropsWithChildren, useState } from 'react'
import { Dimensions, Pressable, View } from 'react-native'
import Sparkline from './Sparkline'
import CustomText from './Text'
@ -21,9 +21,9 @@ const ComponentHashtag: React.FC<PropsWithChildren & Props> = ({
const { colors } = useTheme()
const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
const onPress = useCallback(() => {
const onPress = () => {
navigation.push('Tab-Shared-Hashtag', { hashtag: hashtag.name })
}, [])
}
const padding = StyleConstants.Spacing.Global.PagePadding
const width = Dimensions.get('window').width / 4

View File

@ -2,7 +2,7 @@ import Icon from '@components/Icon'
import CustomText from '@components/Text'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useMemo } from 'react'
import React from 'react'
import { Pressable } from 'react-native'
export interface Props {
@ -21,9 +21,9 @@ const HeaderLeft: React.FC<Props> = ({
background = false,
onPress
}) => {
const { colors, theme } = useTheme()
const { colors } = useTheme()
const children = useMemo(() => {
const children = () => {
switch (type) {
case 'icon':
return (
@ -35,31 +35,23 @@ const HeaderLeft: React.FC<Props> = ({
)
case 'text':
return (
<CustomText
fontStyle='M'
style={{ color: colors.primaryDefault }}
children={content}
/>
<CustomText fontStyle='M' style={{ color: colors.primaryDefault }} children={content} />
)
}
}, [theme])
}
return (
<Pressable
onPress={onPress}
children={children}
children={children()}
style={{
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: background
? colors.backgroundOverlayDefault
: undefined,
backgroundColor: background ? colors.backgroundOverlayDefault : undefined,
minHeight: 44,
minWidth: 44,
marginLeft: native
? -StyleConstants.Spacing.S
: StyleConstants.Spacing.S,
marginLeft: native ? -StyleConstants.Spacing.S : StyleConstants.Spacing.S,
...(type === 'icon' && {
borderRadius: 100
}),

View File

@ -2,7 +2,7 @@ import Icon from '@components/Icon'
import CustomText from '@components/Text'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useMemo } from 'react'
import React from 'react'
import { AccessibilityProps, Pressable, View } from 'react-native'
import { Flow } from 'react-native-animated-spinkit'
@ -40,16 +40,14 @@ const HeaderRight: React.FC<Props> = ({
}) => {
const { colors, theme } = useTheme()
const loadingSpinkit = useMemo(
() => (
const loadingSpinkit = () =>
loading ? (
<View style={{ position: 'absolute' }}>
<Flow size={StyleConstants.Font.Size.M * 1.25} color={colors.secondary} />
</View>
),
[theme]
)
) : null
const children = useMemo(() => {
const children = () => {
switch (type) {
case 'icon':
return (
@ -60,7 +58,7 @@ const HeaderRight: React.FC<Props> = ({
size={StyleConstants.Spacing.M * 1.25}
color={disabled ? colors.secondary : destructive ? colors.red : colors.primaryDefault}
/>
{loading && loadingSpinkit}
{loadingSpinkit()}
</>
)
case 'text':
@ -79,11 +77,11 @@ const HeaderRight: React.FC<Props> = ({
}}
children={content}
/>
{loading && loadingSpinkit}
{loadingSpinkit()}
</>
)
}
}, [theme, loading, disabled])
}
return (
<Pressable
@ -92,7 +90,7 @@ const HeaderRight: React.FC<Props> = ({
accessibilityRole='button'
accessibilityState={accessibilityState}
onPress={onPress}
children={children}
children={children()}
disabled={disabled || loading}
style={{
flexDirection: 'row',

View File

@ -4,7 +4,7 @@ import { useAccessibility } from '@utils/accessibility/AccessibilityManager'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import { ColorDefinitions } from '@utils/styles/themes'
import React, { useMemo } from 'react'
import React from 'react'
import { View } from 'react-native'
import { Flow } from 'react-native-animated-spinkit'
import { State, Switch, TapGestureHandler } from 'react-native-gesture-handler'
@ -47,15 +47,6 @@ const MenuRow: React.FC<Props> = ({
const { colors, theme } = useTheme()
const { screenReaderEnabled } = useAccessibility()
const loadingSpinkit = useMemo(
() => (
<View style={{ position: 'absolute' }}>
<Flow size={StyleConstants.Font.Size.M * 1.25} color={colors.secondary} />
</View>
),
[theme]
)
return (
<View
style={{ minHeight: 50 }}
@ -157,7 +148,11 @@ const MenuRow: React.FC<Props> = ({
style={{ marginLeft: 8, opacity: loading ? 0 : 1 }}
/>
) : null}
{loading && loadingSpinkit}
{loading ? (
<View style={{ position: 'absolute' }}>
<Flow size={StyleConstants.Font.Size.M * 1.25} color={colors.secondary} />
</View>
) : null}
</View>
) : null}
</View>

View File

@ -20,81 +20,85 @@ export interface Props {
style?: TextStyle
}
const ParseEmojis = React.memo(
({ content, emojis, size = 'M', adaptiveSize = false, fontBold = false, style }: Props) => {
if (!content) return null
const ParseEmojis: React.FC<Props> = ({
content,
emojis,
size = 'M',
adaptiveSize = false,
fontBold = false,
style
}) => {
if (!content) return null
const { reduceMotionEnabled } = useAccessibility()
const { reduceMotionEnabled } = useAccessibility()
const [adaptiveFontsize] = useGlobalStorage.number('app.font_size')
const adaptedFontsize = adaptiveScale(
StyleConstants.Font.Size[size],
adaptiveSize ? adaptiveFontsize : 0
)
const adaptedLineheight = adaptiveScale(
StyleConstants.Font.LineHeight[size],
adaptiveSize ? adaptiveFontsize : 0
)
const [adaptiveFontsize] = useGlobalStorage.number('app.font_size')
const adaptedFontsize = adaptiveScale(
StyleConstants.Font.Size[size],
adaptiveSize ? adaptiveFontsize : 0
)
const adaptedLineheight = adaptiveScale(
StyleConstants.Font.LineHeight[size],
adaptiveSize ? adaptiveFontsize : 0
)
const { colors, theme } = useTheme()
const { colors, theme } = useTheme()
return (
<CustomText
style={[
{
color: colors.primaryDefault,
fontSize: adaptedFontsize,
lineHeight: adaptedLineheight
},
style
]}
fontWeight={fontBold ? 'Bold' : undefined}
>
{emojis ? (
content
.split(regexEmoji)
.filter(f => f)
.map((str, i) => {
if (str.match(regexEmoji)) {
const emojiShortcode = str.split(regexEmoji)[1]
const emojiIndex = emojis.findIndex(emoji => {
return emojiShortcode === `:${emoji.shortcode}:`
})
if (emojiIndex === -1) {
return <CustomText key={emojiShortcode + i}>{emojiShortcode}</CustomText>
} else {
const uri = reduceMotionEnabled
? emojis[emojiIndex].static_url
: emojis[emojiIndex].url
if (validUrl.isHttpsUri(uri)) {
return (
<CustomText key={emojiShortcode + i}>
{i === 0 ? ' ' : undefined}
<FastImage
source={{ uri }}
style={{
width: adaptedFontsize,
height: adaptedFontsize,
transform: [{ translateY: Platform.OS === 'ios' ? -1 : 2 }]
}}
/>
</CustomText>
)
} else {
return null
}
}
return (
<CustomText
style={[
{
color: colors.primaryDefault,
fontSize: adaptedFontsize,
lineHeight: adaptedLineheight
},
style
]}
fontWeight={fontBold ? 'Bold' : undefined}
>
{emojis ? (
content
.split(regexEmoji)
.filter(f => f)
.map((str, i) => {
if (str.match(regexEmoji)) {
const emojiShortcode = str.split(regexEmoji)[1]
const emojiIndex = emojis.findIndex(emoji => {
return emojiShortcode === `:${emoji.shortcode}:`
})
if (emojiIndex === -1) {
return <CustomText key={emojiShortcode + i}>{emojiShortcode}</CustomText>
} else {
return <CustomText key={i}>{str}</CustomText>
const uri = reduceMotionEnabled
? emojis[emojiIndex].static_url
: emojis[emojiIndex].url
if (validUrl.isHttpsUri(uri)) {
return (
<CustomText key={emojiShortcode + i}>
{i === 0 ? ' ' : undefined}
<FastImage
source={{ uri }}
style={{
width: adaptedFontsize,
height: adaptedFontsize,
transform: [{ translateY: Platform.OS === 'ios' ? -1 : 2 }]
}}
/>
</CustomText>
)
} else {
return null
}
}
})
) : (
<CustomText>{content}</CustomText>
)}
</CustomText>
)
},
(prev, next) => prev.content === next.content && prev.style?.color === next.style?.color
)
} else {
return <CustomText key={i}>{str}</CustomText>
}
})
) : (
<CustomText>{content}</CustomText>
)}
</CustomText>
)
}
export default ParseEmojis

View File

@ -34,237 +34,233 @@ export interface Props {
setSpoilerExpanded?: React.Dispatch<React.SetStateAction<boolean>>
}
const ParseHTML = React.memo(
({
content,
size = 'M',
textStyles,
adaptiveSize = false,
emojis,
mentions,
tags,
showFullLink = false,
numberOfLines = 10,
expandHint,
highlighted = false,
disableDetails = false,
selectable = false,
setSpoilerExpanded
}: Props) => {
const [adaptiveFontsize] = useGlobalStorage.number('app.font_size')
const adaptedFontsize = adaptiveScale(
StyleConstants.Font.Size[size],
adaptiveSize ? adaptiveFontsize : 0
)
const adaptedLineheight = adaptiveScale(
StyleConstants.Font.LineHeight[size],
adaptiveSize ? adaptiveFontsize : 0
)
const ParseHTML: React.FC<Props> = ({
content,
size = 'M',
textStyles,
adaptiveSize = false,
emojis,
mentions,
tags,
showFullLink = false,
numberOfLines = 10,
expandHint,
highlighted = false,
disableDetails = false,
selectable = false,
setSpoilerExpanded
}) => {
const [adaptiveFontsize] = useGlobalStorage.number('app.font_size')
const adaptedFontsize = adaptiveScale(
StyleConstants.Font.Size[size],
adaptiveSize ? adaptiveFontsize : 0
)
const adaptedLineheight = adaptiveScale(
StyleConstants.Font.LineHeight[size],
adaptiveSize ? adaptiveFontsize : 0
)
const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
const { params } = useRoute()
const { colors } = useTheme()
const { t } = useTranslation('componentParse')
if (!expandHint) {
expandHint = t('HTML.defaultHint')
const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
const { params } = useRoute()
const { colors } = useTheme()
const { t } = useTranslation('componentParse')
if (!expandHint) {
expandHint = t('HTML.defaultHint')
}
if (disableDetails) {
numberOfLines = 4
}
const followedTags = useFollowedTagsQuery()
const [totalLines, setTotalLines] = useState<number>()
const [expanded, setExpanded] = useState(highlighted)
const document = parseDocument(content)
const unwrapNode = (node: ChildNode): string => {
switch (node.type) {
case ElementType.Text:
return node.data
case ElementType.Tag:
if (node.name === 'span') {
if (node.attribs.class?.includes('invisible')) return ''
if (node.attribs.class?.includes('ellipsis'))
return node.children.map(child => unwrapNode(child)).join('') + '...'
}
return node.children.map(child => unwrapNode(child)).join('')
default:
return ''
}
if (disableDetails) {
numberOfLines = 4
}
const followedTags = useFollowedTagsQuery()
const [totalLines, setTotalLines] = useState<number>()
const [expanded, setExpanded] = useState(highlighted)
const document = parseDocument(content)
const unwrapNode = (node: ChildNode): string => {
switch (node.type) {
case ElementType.Text:
return node.data
case ElementType.Tag:
if (node.name === 'span') {
if (node.attribs.class?.includes('invisible')) return ''
if (node.attribs.class?.includes('ellipsis'))
return node.children.map(child => unwrapNode(child)).join('') + '...'
}
return node.children.map(child => unwrapNode(child)).join('')
default:
return ''
}
}
const renderNode = (node: ChildNode, index: number) => {
switch (node.type) {
case ElementType.Text:
return (
<ParseEmojis
key={index}
content={node.data}
emojis={emojis}
size={size}
adaptiveSize={adaptiveSize}
/>
)
case ElementType.Tag:
switch (node.name) {
case 'a':
const classes = node.attribs.class
const href = node.attribs.href
if (classes) {
if (classes.includes('hashtag')) {
const tag = href.match(new RegExp(/\/tags?\/(.*)/, 'i'))?.[1]
const paramsHashtag = (params as { hashtag: Mastodon.Tag['name'] } | undefined)
?.hashtag
const sameHashtag = paramsHashtag === tag
const isFollowing = followedTags.data?.pages[0]?.body.find(t => t.name === tag)
return (
<Text
key={index}
style={[
{ color: tag?.length ? colors.blue : colors.red },
isFollowing
? {
textDecorationColor: tag?.length ? colors.blue : colors.red,
textDecorationLine: 'underline',
textDecorationStyle: 'dotted'
}
: null
]}
onPress={() =>
tag?.length &&
!disableDetails &&
!sameHashtag &&
navigation.push('Tab-Shared-Hashtag', { hashtag: tag })
}
children={node.children.map(unwrapNode).join('')}
/>
)
}
if (classes.includes('mention') && mentions?.length) {
const mentionIndex = mentions.findIndex(mention => mention.url === href)
const paramsAccount = (params as { account: Mastodon.Account } | undefined)
?.account
const sameAccount = paramsAccount?.id === mentions[mentionIndex]?.id
return (
<Text
key={index}
style={{ color: mentionIndex > -1 ? colors.blue : undefined }}
onPress={() =>
mentionIndex > -1 &&
!disableDetails &&
!sameAccount &&
navigation.push('Tab-Shared-Account', { account: mentions[mentionIndex] })
}
children={node.children.map(unwrapNode).join('')}
/>
)
}
}
const content = node.children.map(child => unwrapNode(child)).join('')
const shouldBeTag = tags && tags.find(tag => `#${tag.name}` === content)
return (
<Text
key={index}
style={{ color: colors.blue }}
onPress={async () => {
if (!disableDetails) {
if (shouldBeTag) {
navigation.push('Tab-Shared-Hashtag', {
hashtag: content.substring(1)
})
} else {
await openLink(href, navigation)
}
}
}}
children={content !== href ? content : showFullLink ? href : content}
/>
)
break
case 'p':
if (index < document.children.length - 1) {
}
const renderNode = (node: ChildNode, index: number) => {
switch (node.type) {
case ElementType.Text:
return (
<ParseEmojis
key={index}
content={node.data}
emojis={emojis}
size={size}
adaptiveSize={adaptiveSize}
/>
)
case ElementType.Tag:
switch (node.name) {
case 'a':
const classes = node.attribs.class
const href = node.attribs.href
if (classes) {
if (classes.includes('hashtag')) {
const tag = href.match(new RegExp(/\/tags?\/(.*)/, 'i'))?.[1]
const paramsHashtag = (params as { hashtag: Mastodon.Tag['name'] } | undefined)
?.hashtag
const sameHashtag = paramsHashtag === tag
const isFollowing = followedTags.data?.pages[0]?.body.find(t => t.name === tag)
return (
<Text key={index}>
{node.children.map((c, i) => renderNode(c, i))}
<Text style={{ lineHeight: adaptedLineheight / 2 }}>{'\n\n'}</Text>
</Text>
<Text
key={index}
style={[
{ color: tag?.length ? colors.blue : colors.red },
isFollowing
? {
textDecorationColor: tag?.length ? colors.blue : colors.red,
textDecorationLine: 'underline',
textDecorationStyle: 'dotted'
}
: null
]}
onPress={() =>
tag?.length &&
!disableDetails &&
!sameHashtag &&
navigation.push('Tab-Shared-Hashtag', { hashtag: tag })
}
children={node.children.map(unwrapNode).join('')}
/>
)
} else {
return <Text key={index} children={node.children.map((c, i) => renderNode(c, i))} />
}
default:
if (classes.includes('mention') && mentions?.length) {
const mentionIndex = mentions.findIndex(mention => mention.url === href)
const paramsAccount = (params as { account: Mastodon.Account } | undefined)?.account
const sameAccount = paramsAccount?.id === mentions[mentionIndex]?.id
return (
<Text
key={index}
style={{ color: mentionIndex > -1 ? colors.blue : undefined }}
onPress={() =>
mentionIndex > -1 &&
!disableDetails &&
!sameAccount &&
navigation.push('Tab-Shared-Account', { account: mentions[mentionIndex] })
}
children={node.children.map(unwrapNode).join('')}
/>
)
}
}
const content = node.children.map(child => unwrapNode(child)).join('')
const shouldBeTag = tags && tags.find(tag => `#${tag.name}` === content)
return (
<Text
key={index}
style={{ color: colors.blue }}
onPress={async () => {
if (!disableDetails) {
if (shouldBeTag) {
navigation.push('Tab-Shared-Hashtag', {
hashtag: content.substring(1)
})
} else {
await openLink(href, navigation)
}
}
}}
children={content !== href ? content : showFullLink ? href : content}
/>
)
break
case 'p':
if (index < document.children.length - 1) {
return (
<Text key={index}>
{node.children.map((c, i) => renderNode(c, i))}
<Text style={{ lineHeight: adaptedLineheight / 2 }}>{'\n\n'}</Text>
</Text>
)
} else {
return <Text key={index} children={node.children.map((c, i) => renderNode(c, i))} />
}
}
return null
}
default:
return <Text key={index} children={node.children.map((c, i) => renderNode(c, i))} />
}
}
return (
<View style={{ overflow: 'hidden' }}>
{(!disableDetails && typeof totalLines === 'number') || numberOfLines === 1 ? (
<Pressable
accessibilityLabel={t('HTML.accessibilityHint')}
onPress={() => {
layoutAnimation()
setExpanded(!expanded)
if (setSpoilerExpanded) {
setSpoilerExpanded(!expanded)
}
}}
style={{
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
minHeight: 44,
backgroundColor: colors.backgroundDefault
}}
>
<Text
style={{
textAlign: 'center',
...StyleConstants.FontStyle.S,
color: colors.primaryDefault,
marginRight: StyleConstants.Spacing.S
}}
children={t('HTML.expanded', {
hint: expandHint,
moreLines:
numberOfLines > 1 && typeof totalLines === 'number'
? t('HTML.moreLines', { count: totalLines - numberOfLines })
: ''
})}
/>
<Icon
name={expanded ? 'Minimize2' : 'Maximize2'}
color={colors.primaryDefault}
strokeWidth={2}
size={StyleConstants.Font.Size[size]}
/>
</Pressable>
) : null}
<Text
children={document.children.map(renderNode)}
onTextLayout={({ nativeEvent }) => {
if (numberOfLines === 1 || nativeEvent.lines.length >= numberOfLines + 5) {
setTotalLines(nativeEvent.lines.length)
return null
}
return (
<View style={{ overflow: 'hidden' }}>
{(!disableDetails && typeof totalLines === 'number') || numberOfLines === 1 ? (
<Pressable
accessibilityLabel={t('HTML.accessibilityHint')}
onPress={() => {
layoutAnimation()
setExpanded(!expanded)
if (setSpoilerExpanded) {
setSpoilerExpanded(!expanded)
}
}}
style={{
fontSize: adaptedFontsize,
lineHeight: adaptedLineheight,
...textStyles,
height: numberOfLines === 1 && !expanded ? 0 : undefined
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
minHeight: 44,
backgroundColor: colors.backgroundDefault
}}
numberOfLines={
typeof totalLines === 'number' ? (expanded ? 999 : numberOfLines) : undefined
>
<Text
style={{
textAlign: 'center',
...StyleConstants.FontStyle.S,
color: colors.primaryDefault,
marginRight: StyleConstants.Spacing.S
}}
children={t('HTML.expanded', {
hint: expandHint,
moreLines:
numberOfLines > 1 && typeof totalLines === 'number'
? t('HTML.moreLines', { count: totalLines - numberOfLines })
: ''
})}
/>
<Icon
name={expanded ? 'Minimize2' : 'Maximize2'}
color={colors.primaryDefault}
strokeWidth={2}
size={StyleConstants.Font.Size[size]}
/>
</Pressable>
) : null}
<Text
children={document.children.map(renderNode)}
onTextLayout={({ nativeEvent }) => {
if (numberOfLines === 1 || nativeEvent.lines.length >= numberOfLines + 5) {
setTotalLines(nativeEvent.lines.length)
}
selectable={selectable}
/>
</View>
)
},
(prev, next) => prev.content === next.content && isEqual(prev.emojis, next.emojis)
)
}}
style={{
fontSize: adaptedFontsize,
lineHeight: adaptedLineheight,
...textStyles,
height: numberOfLines === 1 && !expanded ? 0 : undefined
}}
numberOfLines={
typeof totalLines === 'number' ? (expanded ? 999 : numberOfLines) : undefined
}
selectable={selectable}
/>
</View>
)
}
export default ParseHTML

View File

@ -115,4 +115,4 @@ const TimelineConversation: React.FC<Props> = ({ conversation, queryKey, highlig
)
}
export default TimelineConversation
export default React.memo(TimelineConversation, () => true)

View File

@ -221,4 +221,4 @@ const TimelineDefault: React.FC<Props> = ({
)
}
export default TimelineDefault
export default React.memo(TimelineDefault, () => true)

View File

@ -18,7 +18,7 @@ import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { useAccountStorage } from '@utils/storage/actions'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { Fragment, useCallback, useState } from 'react'
import React, { Fragment, useState } from 'react'
import { Pressable, View } from 'react-native'
import * as ContextMenu from 'zeego/context-menu'
import StatusContext from './Shared/Context'
@ -53,14 +53,6 @@ const TimelineNotifications: React.FC<Props> = ({ notification, queryKey }) => {
const { colors } = useTheme()
const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
const onPress = useCallback(() => {
notification.status &&
navigation.push('Tab-Shared-Toot', {
toot: notification.status,
rootQueryKey: queryKey
})
}, [])
const main = () => {
return (
<>
@ -159,7 +151,13 @@ const TimelineNotifications: React.FC<Props> = ({ notification, queryKey }) => {
backgroundColor: colors.backgroundDefault,
paddingBottom: notification.status ? 0 : StyleConstants.Spacing.Global.PagePadding
}}
onPress={onPress}
onPress={() =>
notification.status &&
navigation.push('Tab-Shared-Toot', {
toot: notification.status,
rootQueryKey: queryKey
})
}
onLongPress={() => {}}
children={main()}
/>
@ -187,4 +185,4 @@ const TimelineNotifications: React.FC<Props> = ({ notification, queryKey }) => {
)
}
export default TimelineNotifications
export default React.memo(TimelineNotifications, () => true)

View File

@ -16,7 +16,7 @@ import { useAccountStorage } from '@utils/storage/actions'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import { uniqBy } from 'lodash'
import React, { useCallback, useContext, useMemo } from 'react'
import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next'
import { Pressable, StyleSheet, View } from 'react-native'
import StatusContext from './Context'
@ -76,7 +76,7 @@ const TimelineActions: React.FC = () => {
})
const [accountId] = useAccountStorage.string('auth.account.id')
const onPressReply = useCallback(() => {
const onPressReply = () => {
const accts = uniqBy(
([status.account] as Mastodon.Account[] & Mastodon.Mention[])
.concat(status.mentions)
@ -89,9 +89,9 @@ const TimelineActions: React.FC = () => {
accts,
queryKey
})
}, [status.replies_count])
}
const { showActionSheetWithOptions } = useActionSheet()
const onPressReblog = useCallback(() => {
const onPressReblog = () => {
if (!status.reblogged) {
showActionSheetWithOptions(
{
@ -157,8 +157,8 @@ const TimelineActions: React.FC = () => {
}
})
}
}, [status.reblogged, status.reblogs_count])
const onPressFavourite = useCallback(() => {
}
const onPressFavourite = () => {
mutation.mutate({
type: 'updateStatusProperty',
queryKey,
@ -172,8 +172,8 @@ const TimelineActions: React.FC = () => {
countValue: status.favourites_count
}
})
}, [status.favourited, status.favourites_count])
const onPressBookmark = useCallback(() => {
}
const onPressBookmark = () => {
mutation.mutate({
type: 'updateStatusProperty',
queryKey,
@ -187,28 +187,25 @@ const TimelineActions: React.FC = () => {
countValue: undefined
}
})
}, [status.bookmarked])
}
const childrenReply = useMemo(
() => (
<>
<Icon name='MessageCircle' color={iconColor} size={StyleConstants.Font.Size.L} />
{status.replies_count > 0 ? (
<CustomText
style={{
color: colors.secondary,
fontSize: StyleConstants.Font.Size.M,
marginLeft: StyleConstants.Spacing.XS
}}
>
{status.replies_count}
</CustomText>
) : null}
</>
),
[status.replies_count]
const childrenReply = () => (
<>
<Icon name='MessageCircle' color={iconColor} size={StyleConstants.Font.Size.L} />
{status.replies_count > 0 ? (
<CustomText
style={{
color: colors.secondary,
fontSize: StyleConstants.Font.Size.M,
marginLeft: StyleConstants.Spacing.XS
}}
>
{status.replies_count}
</CustomText>
) : null}
</>
)
const childrenReblog = useMemo(() => {
const childrenReblog = () => {
const color = (state: boolean) => (state ? colors.green : colors.secondary)
const disabled =
status.visibility === 'direct' || (status.visibility === 'private' && !ownAccount)
@ -236,8 +233,8 @@ const TimelineActions: React.FC = () => {
) : null}
</>
)
}, [status.reblogged, status.reblogs_count])
const childrenFavourite = useMemo(() => {
}
const childrenFavourite = () => {
const color = (state: boolean) => (state ? colors.red : colors.secondary)
return (
<>
@ -256,13 +253,13 @@ const TimelineActions: React.FC = () => {
) : null}
</>
)
}, [status.favourited, status.favourites_count])
const childrenBookmark = useMemo(() => {
}
const childrenBookmark = () => {
const color = (state: boolean) => (state ? colors.yellow : colors.secondary)
return (
<Icon name='Bookmark' color={color(status.bookmarked)} size={StyleConstants.Font.Size.L} />
)
}, [status.bookmarked])
}
return (
<View style={{ flexDirection: 'row' }}>
@ -275,7 +272,7 @@ const TimelineActions: React.FC = () => {
: { accessibilityLabel: '' })}
style={styles.action}
onPress={onPressReply}
children={childrenReply}
children={childrenReply()}
/>
<Pressable
@ -289,7 +286,7 @@ const TimelineActions: React.FC = () => {
: { accessibilityLabel: '' })}
style={styles.action}
onPress={onPressReblog}
children={childrenReblog}
children={childrenReblog()}
disabled={
status.visibility === 'direct' || (status.visibility === 'private' && !ownAccount)
}
@ -306,7 +303,7 @@ const TimelineActions: React.FC = () => {
: { accessibilityLabel: '' })}
style={styles.action}
onPress={onPressFavourite}
children={childrenFavourite}
children={childrenFavourite()}
/>
<Pressable
@ -320,7 +317,7 @@ const TimelineActions: React.FC = () => {
: { accessibilityLabel: '' })}
style={styles.action}
onPress={onPressBookmark}
children={childrenBookmark}
children={childrenBookmark()}
/>
</View>
)

View File

@ -14,7 +14,7 @@ import updateStatusProperty from '@utils/queryHooks/timeline/updateStatusPropert
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import { maxBy } from 'lodash'
import React, { useCallback, useContext, useMemo, useState } from 'react'
import React, { useContext, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { Pressable, View } from 'react-native'
import StatusContext from './Context'
@ -73,7 +73,7 @@ const TimelinePoll: React.FC = () => {
}
})
const pollButton = useMemo(() => {
const pollButton = () => {
if (!poll.expired) {
if (!ownAccount && !poll.voted) {
return (
@ -127,17 +127,14 @@ const TimelinePoll: React.FC = () => {
)
}
}
}, [theme, poll.expired, poll.voted, allOptions, mutation.isLoading])
}
const isSelected = useCallback(
(index: number): string =>
allOptions[index]
? `Check${poll.multiple ? 'Square' : 'Circle'}`
: `${poll.multiple ? 'Square' : 'Circle'}`,
[allOptions]
)
const isSelected = (index: number): string =>
allOptions[index]
? `Check${poll.multiple ? 'Square' : 'Circle'}`
: `${poll.multiple ? 'Square' : 'Circle'}`
const pollBodyDisallow = useMemo(() => {
const pollBodyDisallow = () => {
const maxValue = maxBy(poll.options, option => option.votes_count)?.votes_count
return poll.options.map((option, index) => (
<View key={index} style={{ flex: 1, paddingVertical: StyleConstants.Spacing.S }}>
@ -191,8 +188,8 @@ const TimelinePoll: React.FC = () => {
/>
</View>
))
}, [theme, poll.options])
const pollBodyAllow = useMemo(() => {
}
const pollBodyAllow = () => {
return poll.options.map((option, index) => (
<Pressable
key={index}
@ -229,7 +226,7 @@ const TimelinePoll: React.FC = () => {
</View>
</Pressable>
))
}, [theme, allOptions])
}
const pollVoteCounts = () => {
if (poll.voters_count !== null) {
@ -263,7 +260,7 @@ const TimelinePoll: React.FC = () => {
return (
<View style={{ marginTop: StyleConstants.Spacing.M }}>
{poll.expired || poll.voted ? pollBodyDisallow : pollBodyAllow}
{poll.expired || poll.voted ? pollBodyDisallow() : pollBodyAllow()}
<View
style={{
flex: 1,
@ -272,7 +269,7 @@ const TimelinePoll: React.FC = () => {
marginTop: StyleConstants.Spacing.XS
}}
>
{pollButton}
{pollButton()}
<CustomText fontStyle='S' style={{ flexShrink: 1, color: colors.secondary }}>
{pollVoteCounts()}
{pollExpiration()}

View File

@ -5,7 +5,7 @@ import { QueryKeyTimeline, useTimelineQuery } from '@utils/queryHooks/timeline'
import { useGlobalStorageListener } from '@utils/storage/actions'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { RefObject, useCallback, useRef } from 'react'
import React, { RefObject, useRef } from 'react'
import { FlatList, FlatListProps, Platform, RefreshControl } from 'react-native'
import Animated, { useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated'
import TimelineEmpty from './Empty'
@ -56,11 +56,6 @@ const Timeline: React.FC<Props> = ({
const flattenData = data?.pages ? data.pages?.flatMap(page => [...page.body]) : []
const onEndReached = useCallback(
() => !disableInfinity && !isFetchingNextPage && fetchNextPage(),
[isFetchingNextPage]
)
const flRef = useRef<FlatList>(null)
const scrollY = useSharedValue(0)
@ -120,7 +115,7 @@ const Timeline: React.FC<Props> = ({
data={flattenData}
initialNumToRender={6}
maxToRenderPerBatch={3}
onEndReached={onEndReached}
onEndReached={() => !disableInfinity && !isFetchingNextPage && fetchNextPage()}
onEndReachedThreshold={0.75}
ListFooterComponent={
<TimelineFooter queryKey={queryKey} disableInfinity={disableInfinity} />

View File

@ -1,7 +1,7 @@
import { RootStackScreenProps } from '@utils/navigation/navigators'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useEffect } from 'react'
import React, { useEffect } from 'react'
import { Dimensions, StyleSheet, View } from 'react-native'
import { PanGestureHandler, State, TapGestureHandler } from 'react-native-gesture-handler'
import Animated, {
@ -34,9 +34,8 @@ const ScreenActions = ({
bottom: interpolate(panY.value, [0, screenHeight], [0, -screenHeight], Extrapolate.CLAMP)
}
})
const dismiss = useCallback(() => {
navigation.goBack()
}, [])
const dismiss = () => navigation.goBack()
const onGestureEvent = useAnimatedGestureHandler({
onActive: ({ translationY }) => {
panY.value = translationY

View File

@ -9,7 +9,7 @@ import { RootStackScreenProps } from '@utils/navigation/navigators'
import { useAnnouncementMutation, useAnnouncementQuery } from '@utils/queryHooks/announcement'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useEffect, useState } from 'react'
import React, { useEffect, useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import {
Dimensions,
@ -56,148 +56,140 @@ const ScreenAnnouncements: React.FC<RootStackScreenProps<'Screen-Announcements'>
}
}, [query.data])
const renderItem = useCallback(
({ item, index }: { item: Mastodon.Announcement; index: number }) => (
const renderItem = ({ item, index }: { item: Mastodon.Announcement; index: number }) => (
<View
key={index}
style={{
width: Dimensions.get('window').width,
padding: StyleConstants.Spacing.Global.PagePadding,
marginVertical: StyleConstants.Spacing.Global.PagePadding,
justifyContent: 'center'
}}
>
<Pressable style={StyleSheet.absoluteFillObject} onPress={() => navigation.goBack()} />
<View
key={index}
style={{
width: Dimensions.get('window').width,
flexShrink: 1,
padding: StyleConstants.Spacing.Global.PagePadding,
marginVertical: StyleConstants.Spacing.Global.PagePadding,
justifyContent: 'center'
marginTop: StyleConstants.Spacing.Global.PagePadding,
borderWidth: 1,
borderRadius: 6,
borderColor: colors.primaryDefault,
backgroundColor: colors.backgroundDefault
}}
>
<Pressable style={StyleSheet.absoluteFillObject} onPress={() => navigation.goBack()} />
<View
<CustomText
fontStyle='S'
style={{
flexShrink: 1,
padding: StyleConstants.Spacing.Global.PagePadding,
marginTop: StyleConstants.Spacing.Global.PagePadding,
borderWidth: 1,
borderRadius: 6,
borderColor: colors.primaryDefault,
backgroundColor: colors.backgroundDefault
marginBottom: StyleConstants.Spacing.S,
color: colors.secondary
}}
>
<CustomText
fontStyle='S'
style={{
marginBottom: StyleConstants.Spacing.S,
color: colors.secondary
}}
>
<Trans
ns='screenAnnouncements'
i18nKey='content.published'
components={[<RelativeTime time={item.published_at} />]}
/>
</CustomText>
<ScrollView
<Trans
ns='screenAnnouncements'
i18nKey='content.published'
components={[<RelativeTime time={item.published_at} />]}
/>
</CustomText>
<ScrollView
style={{
marginBottom: StyleConstants.Spacing.Global.PagePadding / 2
}}
showsVerticalScrollIndicator
>
<ParseHTML
content={item.content}
size='M'
emojis={item.emojis}
mentions={item.mentions}
numberOfLines={999}
selectable
/>
</ScrollView>
{item.reactions?.length ? (
<View
style={{
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: StyleConstants.Spacing.Global.PagePadding / 2
}}
showsVerticalScrollIndicator
>
<ParseHTML
content={item.content}
size='M'
emojis={item.emojis}
mentions={item.mentions}
numberOfLines={999}
selectable
/>
</ScrollView>
{item.reactions?.length ? (
<View
style={{
flexDirection: 'row',
flexWrap: 'wrap',
marginBottom: StyleConstants.Spacing.Global.PagePadding / 2
}}
>
{item.reactions?.map(reaction => (
<Pressable
key={reaction.name}
style={{
borderWidth: 1,
padding: StyleConstants.Spacing.Global.PagePadding / 2,
marginTop: StyleConstants.Spacing.Global.PagePadding / 2,
marginBottom: StyleConstants.Spacing.Global.PagePadding / 2,
marginRight: StyleConstants.Spacing.M,
borderRadius: 6,
flexDirection: 'row',
borderColor: reaction.me ? colors.disabled : colors.primaryDefault,
backgroundColor: reaction.me ? colors.disabled : colors.backgroundDefault
}}
onPress={() =>
mutation.mutate({
id: item.id,
type: 'reaction',
name: reaction.name,
me: reaction.me
})
}
>
{reaction.url ? (
<FastImage
source={{
uri: reduceMotionEnabled ? reaction.static_url : reaction.url
}}
style={{
width: StyleConstants.Font.LineHeight.M + 3,
height: StyleConstants.Font.LineHeight.M
}}
/>
) : (
<CustomText fontStyle='M'>{reaction.name}</CustomText>
)}
{reaction.count ? (
<CustomText
fontStyle='S'
style={{
marginLeft: StyleConstants.Spacing.S,
color: colors.primaryDefault
}}
>
{reaction.count}
</CustomText>
) : null}
</Pressable>
))}
</View>
) : null}
<Button
type='text'
content={item.read ? t('content.button.read') : t('content.button.unread')}
loading={mutation.isLoading}
disabled={item.read}
onPress={() => {
!item.read &&
mutation.mutate({
id: item.id,
type: 'dismiss'
})
}}
/>
</View>
{item.reactions?.map(reaction => (
<Pressable
key={reaction.name}
style={{
borderWidth: 1,
padding: StyleConstants.Spacing.Global.PagePadding / 2,
marginTop: StyleConstants.Spacing.Global.PagePadding / 2,
marginBottom: StyleConstants.Spacing.Global.PagePadding / 2,
marginRight: StyleConstants.Spacing.M,
borderRadius: 6,
flexDirection: 'row',
borderColor: reaction.me ? colors.disabled : colors.primaryDefault,
backgroundColor: reaction.me ? colors.disabled : colors.backgroundDefault
}}
onPress={() =>
mutation.mutate({
id: item.id,
type: 'reaction',
name: reaction.name,
me: reaction.me
})
}
>
{reaction.url ? (
<FastImage
source={{
uri: reduceMotionEnabled ? reaction.static_url : reaction.url
}}
style={{
width: StyleConstants.Font.LineHeight.M + 3,
height: StyleConstants.Font.LineHeight.M
}}
/>
) : (
<CustomText fontStyle='M'>{reaction.name}</CustomText>
)}
{reaction.count ? (
<CustomText
fontStyle='S'
style={{
marginLeft: StyleConstants.Spacing.S,
color: colors.primaryDefault
}}
>
{reaction.count}
</CustomText>
) : null}
</Pressable>
))}
</View>
) : null}
<Button
type='text'
content={item.read ? t('content.button.read') : t('content.button.unread')}
loading={mutation.isLoading}
disabled={item.read}
onPress={() => {
!item.read &&
mutation.mutate({
id: item.id,
type: 'dismiss'
})
}}
/>
</View>
),
[mode]
</View>
)
const onMomentumScrollEnd = useCallback(
({
nativeEvent: {
contentOffset: { x },
layoutMeasurement: { width }
}
}: NativeSyntheticEvent<NativeScrollEvent>) => {
setIndex(Math.floor(x / width))
},
[]
)
const onMomentumScrollEnd = ({
nativeEvent: {
contentOffset: { x },
layoutMeasurement: { width }
}
}: NativeSyntheticEvent<NativeScrollEvent>) => setIndex(Math.floor(x / width))
const ListEmptyComponent = useCallback(() => {
const ListEmptyComponent = () => {
return (
<View
style={{
@ -209,7 +201,7 @@ const ScreenAnnouncements: React.FC<RootStackScreenProps<'Screen-Announcements'>
<Circle size={StyleConstants.Font.Size.L} color={colors.secondary} />
</View>
)
}, [])
}
return Platform.OS === 'ios' ? (
<BlurView

View File

@ -6,7 +6,7 @@ import { useActionSheet } from '@expo/react-native-action-sheet'
import { androidActionSheetStyles } from '@utils/helpers/androidActionSheetStyles'
import layoutAnimation from '@utils/styles/layoutAnimation'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useContext, useMemo } from 'react'
import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next'
import { Keyboard, Pressable, StyleSheet, View } from 'react-native'
import ComposeContext from '../utils/createContext'
@ -18,7 +18,7 @@ const ComposeActions: React.FC = () => {
const { t } = useTranslation(['common', 'screenCompose'])
const { colors } = useTheme()
const attachmentColor = useMemo(() => {
const attachmentColor = () => {
if (composeState.poll.active) return colors.disabled
if (composeState.attachments.uploads.length) {
@ -26,7 +26,7 @@ const ComposeActions: React.FC = () => {
} else {
return colors.secondary
}
}, [composeState.poll.active, composeState.attachments.uploads])
}
const attachmentOnPress = () => {
if (composeState.poll.active) return
@ -35,7 +35,7 @@ const ComposeActions: React.FC = () => {
}
}
const pollColor = useMemo(() => {
const pollColor = () => {
if (composeState.attachments.uploads.length) return colors.disabled
if (composeState.poll.active) {
@ -43,7 +43,7 @@ const ComposeActions: React.FC = () => {
} else {
return colors.secondary
}
}, [composeState.poll.active, composeState.attachments.uploads])
}
const pollOnPress = () => {
if (!composeState.attachments.uploads.length) {
layoutAnimation()
@ -57,7 +57,7 @@ const ComposeActions: React.FC = () => {
}
}
const visibilityIcon = useMemo(() => {
const visibilityIcon = () => {
switch (composeState.visibility) {
case 'public':
return 'Globe'
@ -68,7 +68,7 @@ const ComposeActions: React.FC = () => {
case 'direct':
return 'Mail'
}
}, [composeState.visibility])
}
const visibilityOnPress = () => {
if (!composeState.visibilityLock) {
showActionSheetWithOptions(
@ -116,7 +116,7 @@ const ComposeActions: React.FC = () => {
}
const { emojisState, emojisDispatch } = useContext(EmojisContext)
const emojiColor = useMemo(() => {
const emojiColor = () => {
if (!emojis.current?.length) return colors.disabled
if (emojisState.targetIndex !== -1) {
@ -124,7 +124,7 @@ const ComposeActions: React.FC = () => {
} else {
return colors.secondary
}
}, [emojis.current?.length, emojisState.targetIndex])
}
const emojiOnPress = () => {
if (emojisState.targetIndex === -1) {
Keyboard.dismiss()
@ -159,7 +159,7 @@ const ComposeActions: React.FC = () => {
}}
style={styles.button}
onPress={attachmentOnPress}
children={<Icon name='Camera' size={24} color={attachmentColor} />}
children={<Icon name='Camera' size={24} color={attachmentColor()} />}
/>
<Pressable
accessibilityRole='button'
@ -171,7 +171,7 @@ const ComposeActions: React.FC = () => {
}}
style={styles.button}
onPress={pollOnPress}
children={<Icon name='BarChart2' size={24} color={pollColor} />}
children={<Icon name='BarChart2' size={24} color={pollColor()} />}
/>
<Pressable
accessibilityRole='button'
@ -183,7 +183,7 @@ const ComposeActions: React.FC = () => {
onPress={visibilityOnPress}
children={
<Icon
name={visibilityIcon}
name={visibilityIcon()}
size={24}
color={composeState.visibilityLock ? colors.disabled : colors.secondary}
/>
@ -213,7 +213,7 @@ const ComposeActions: React.FC = () => {
}}
style={styles.button}
onPress={emojiOnPress}
children={<Icon name='Smile' size={24} color={emojiColor} />}
children={<Icon name='Smile' size={24} color={emojiColor()} />}
/>
</View>
)

View File

@ -8,7 +8,7 @@ import { useNavigation } from '@react-navigation/native'
import { StyleConstants } from '@utils/styles/constants'
import layoutAnimation from '@utils/styles/layoutAnimation'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { RefObject, useCallback, useContext, useEffect, useMemo, useRef } from 'react'
import React, { RefObject, useContext, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { FlatList, Pressable, StyleSheet, View } from 'react-native'
import { Circle } from 'react-native-animated-spinkit'
@ -32,16 +32,13 @@ const ComposeAttachments: React.FC<Props> = ({ accessibleRefAttachments }) => {
const flatListRef = useRef<FlatList>(null)
const sensitiveOnPress = useCallback(
() =>
composeDispatch({
type: 'attachments/sensitive',
payload: { sensitive: !composeState.attachments.sensitive }
}),
[composeState.attachments.sensitive]
)
const sensitiveOnPress = () =>
composeDispatch({
type: 'attachments/sensitive',
payload: { sensitive: !composeState.attachments.sensitive }
})
const calculateWidth = useCallback((item: ExtendedAttachment) => {
const calculateWidth = (item: ExtendedAttachment) => {
if (item.local) {
return ((item.local.width || 100) / (item.local.height || 100)) * DEFAULT_HEIGHT
} else {
@ -59,9 +56,9 @@ const ComposeAttachments: React.FC<Props> = ({ accessibleRefAttachments }) => {
return DEFAULT_HEIGHT
}
}
}, [])
}
const snapToOffsets = useMemo(() => {
const snapToOffsets = () => {
const attachmentsOffsets = composeState.attachments.uploads.map((_, index) => {
let currentOffset = 0
Array.from(Array(index).keys()).map(
@ -81,160 +78,116 @@ const ComposeAttachments: React.FC<Props> = ({ accessibleRefAttachments }) => {
StyleConstants.Spacing.Global.PagePadding
]
: attachmentsOffsets
}, [composeState.attachments.uploads.length])
}
let prevOffsets = useRef<number[]>()
useEffect(() => {
if (snapToOffsets.length > (prevOffsets.current ? prevOffsets.current.length : 0)) {
const snap = snapToOffsets()
if (snap.length > (prevOffsets.current ? prevOffsets.current.length : 0)) {
flatListRef.current?.scrollToOffset({
offset: snapToOffsets[snapToOffsets.length - 2] + snapToOffsets[snapToOffsets.length - 1]
offset: snap[snapToOffsets.length - 2] + snap[snapToOffsets.length - 1]
})
}
prevOffsets.current = snapToOffsets
prevOffsets.current = snap
}, [snapToOffsets, prevOffsets.current])
const renderAttachment = useCallback(
({ item, index }: { item: ExtendedAttachment; index: number }) => {
return (
<View
key={index}
style={{
height: DEFAULT_HEIGHT,
marginLeft: StyleConstants.Spacing.Global.PagePadding,
marginTop: StyleConstants.Spacing.Global.PagePadding,
marginBottom: StyleConstants.Spacing.Global.PagePadding,
width: calculateWidth(item)
}}
>
<FastImage
style={{ width: '100%', height: '100%' }}
source={{
uri: item.local?.thumbnail || item.remote?.preview_url
}}
/>
{item.remote?.meta?.original?.duration ? (
<CustomText
fontStyle='S'
style={{
position: 'absolute',
bottom: StyleConstants.Spacing.S,
left: StyleConstants.Spacing.S,
paddingLeft: StyleConstants.Spacing.S,
paddingRight: StyleConstants.Spacing.S,
paddingTop: StyleConstants.Spacing.XS,
paddingBottom: StyleConstants.Spacing.XS,
color: colors.backgroundDefault,
backgroundColor: colors.backgroundOverlayInvert
}}
>
{item.remote.meta.original.duration}
</CustomText>
) : null}
{item.uploading ? (
<View
style={{
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.backgroundOverlayInvert
}}
>
<Circle size={StyleConstants.Font.Size.L} color={colors.primaryOverlay} />
</View>
) : (
<View
style={{
...StyleSheet.absoluteFillObject,
justifyContent: 'space-between',
alignContent: 'flex-end',
alignItems: 'flex-end',
padding: StyleConstants.Spacing.S
}}
>
<Button
accessibilityLabel={t('content.root.footer.attachments.remove.accessibilityLabel', {
attachment: index + 1
})}
type='icon'
content='X'
spacing='M'
round
overlay
onPress={() => {
layoutAnimation()
composeDispatch({
type: 'attachment/delete',
payload: item.remote!.id
})
haptics('Success')
}}
/>
{!composeState.attachments.disallowEditing ? (
<Button
accessibilityLabel={t('content.root.footer.attachments.edit.accessibilityLabel', {
attachment: index + 1
})}
type='icon'
content='Edit'
spacing='M'
round
overlay
onPress={() => {
navigation.navigate('Screen-Compose-EditAttachment', {
index
})
}}
/>
) : null}
</View>
)}
</View>
)
},
[]
)
const listFooter = useMemo(
() => (
<Pressable
accessible
accessibilityLabel={t('content.root.footer.attachments.upload.accessibilityLabel')}
const renderAttachment = ({ item, index }: { item: ExtendedAttachment; index: number }) => {
return (
<View
key={index}
style={{
height: DEFAULT_HEIGHT,
marginLeft: StyleConstants.Spacing.Global.PagePadding,
marginTop: StyleConstants.Spacing.Global.PagePadding,
marginBottom: StyleConstants.Spacing.Global.PagePadding,
width: DEFAULT_HEIGHT,
backgroundColor: colors.backgroundOverlayInvert
}}
onPress={async () => {
await chooseAndUploadAttachment({
composeDispatch,
showActionSheetWithOptions
})
width: calculateWidth(item)
}}
>
<Button
type='icon'
content='UploadCloud'
spacing='M'
round
overlay
onPress={async () => {
await chooseAndUploadAttachment({
composeDispatch,
showActionSheetWithOptions
})
}}
style={{
position: 'absolute',
top: (DEFAULT_HEIGHT - StyleConstants.Spacing.M * 2 - StyleConstants.Font.Size.M) / 2,
left: (DEFAULT_HEIGHT - StyleConstants.Spacing.M * 2 - StyleConstants.Font.Size.M) / 2
<FastImage
style={{ width: '100%', height: '100%' }}
source={{
uri: item.local?.thumbnail || item.remote?.preview_url
}}
/>
</Pressable>
),
[]
)
{item.remote?.meta?.original?.duration ? (
<CustomText
fontStyle='S'
style={{
position: 'absolute',
bottom: StyleConstants.Spacing.S,
left: StyleConstants.Spacing.S,
paddingLeft: StyleConstants.Spacing.S,
paddingRight: StyleConstants.Spacing.S,
paddingTop: StyleConstants.Spacing.XS,
paddingBottom: StyleConstants.Spacing.XS,
color: colors.backgroundDefault,
backgroundColor: colors.backgroundOverlayInvert
}}
>
{item.remote.meta.original.duration}
</CustomText>
) : null}
{item.uploading ? (
<View
style={{
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.backgroundOverlayInvert
}}
>
<Circle size={StyleConstants.Font.Size.L} color={colors.primaryOverlay} />
</View>
) : (
<View
style={{
...StyleSheet.absoluteFillObject,
justifyContent: 'space-between',
alignContent: 'flex-end',
alignItems: 'flex-end',
padding: StyleConstants.Spacing.S
}}
>
<Button
accessibilityLabel={t('content.root.footer.attachments.remove.accessibilityLabel', {
attachment: index + 1
})}
type='icon'
content='X'
spacing='M'
round
overlay
onPress={() => {
layoutAnimation()
composeDispatch({
type: 'attachment/delete',
payload: item.remote!.id
})
haptics('Success')
}}
/>
{!composeState.attachments.disallowEditing ? (
<Button
accessibilityLabel={t('content.root.footer.attachments.edit.accessibilityLabel', {
attachment: index + 1
})}
type='icon'
content='Edit'
spacing='M'
round
overlay
onPress={() => {
navigation.navigate('Screen-Compose-EditAttachment', {
index
})
}}
/>
) : null}
</View>
)}
</View>
)
}
return (
<View
style={{
@ -276,13 +229,54 @@ const ComposeAttachments: React.FC<Props> = ({ accessibleRefAttachments }) => {
pagingEnabled={false}
snapToAlignment='center'
renderItem={renderAttachment}
snapToOffsets={snapToOffsets}
snapToOffsets={snapToOffsets()}
keyboardShouldPersistTaps='always'
showsHorizontalScrollIndicator={false}
data={composeState.attachments.uploads}
keyExtractor={item => item.local?.uri || item.remote?.url || Math.random().toString()}
ListFooterComponent={
composeState.attachments.uploads.length < MAX_MEDIA_ATTACHMENTS ? listFooter : null
composeState.attachments.uploads.length < MAX_MEDIA_ATTACHMENTS ? (
<Pressable
accessible
accessibilityLabel={t('content.root.footer.attachments.upload.accessibilityLabel')}
style={{
height: DEFAULT_HEIGHT,
marginLeft: StyleConstants.Spacing.Global.PagePadding,
marginTop: StyleConstants.Spacing.Global.PagePadding,
marginBottom: StyleConstants.Spacing.Global.PagePadding,
width: DEFAULT_HEIGHT,
backgroundColor: colors.backgroundOverlayInvert
}}
onPress={async () => {
await chooseAndUploadAttachment({
composeDispatch,
showActionSheetWithOptions
})
}}
>
<Button
type='icon'
content='UploadCloud'
spacing='M'
round
overlay
onPress={async () => {
await chooseAndUploadAttachment({
composeDispatch,
showActionSheetWithOptions
})
}}
style={{
position: 'absolute',
top:
(DEFAULT_HEIGHT - StyleConstants.Spacing.M * 2 - StyleConstants.Font.Size.M) /
2,
left:
(DEFAULT_HEIGHT - StyleConstants.Spacing.M * 2 - StyleConstants.Font.Size.M) / 2
}}
/>
</Pressable>
) : null
}
/>
</View>

View File

@ -2,7 +2,7 @@ import ComponentSeparator from '@components/Separator'
import { useSearchQuery } from '@utils/queryHooks/search'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useContext, useEffect, useMemo, useRef } from 'react'
import React, { useContext, useEffect, useRef } from 'react'
import { AccessibilityInfo, findNodeHandle, FlatList, View } from 'react-native'
import { Circle } from 'react-native-animated-spinkit'
import ComposePosting from '../Posting'
@ -53,29 +53,22 @@ const ComposeRoot = () => {
}
}, [composeState.tag])
const listEmpty = useMemo(() => {
if (isFetching) {
return (
<View key='listEmpty' style={{ flex: 1, alignItems: 'center' }}>
<Circle size={StyleConstants.Font.Size.M * 1.25} color={colors.secondary} />
</View>
)
}
}, [isFetching])
const Footer = useMemo(
() => <ComposeRootFooter accessibleRefAttachments={accessibleRefAttachments} />,
[accessibleRefAttachments.current]
)
return (
<View style={{ flex: 1 }}>
<FlatList
renderItem={({ item }) => <ComposeRootSuggestion item={item} />}
ListEmptyComponent={listEmpty}
ListEmptyComponent={
isFetching ? (
<View key='listEmpty' style={{ flex: 1, alignItems: 'center' }}>
<Circle size={StyleConstants.Font.Size.M * 1.25} color={colors.secondary} />
</View>
) : null
}
keyboardShouldPersistTaps='always'
ListHeaderComponent={ComposeRootHeader}
ListFooterComponent={Footer}
ListFooterComponent={
<ComposeRootFooter accessibleRefAttachments={accessibleRefAttachments} />
}
ItemSeparatorComponent={ComponentSeparator}
// @ts-ignore
data={data ? data[mapSchemaToType()] : undefined}

View File

@ -21,7 +21,7 @@ import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import * as StoreReview from 'expo-store-review'
import { filter } from 'lodash'
import React, { useCallback, useEffect, useMemo, useReducer, useState } from 'react'
import React, { useEffect, useMemo, useReducer, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Alert, Keyboard, Platform } from 'react-native'
import ComposeDraftsList, { removeDraft } from './DraftsList'
@ -202,44 +202,7 @@ const ScreenCompose: React.FC<RootStackScreenProps<'Screen-Compose'>> = ({
return () => (autoSave ? clearInterval(autoSave) : undefined)
}, [composeState])
const headerLeft = useCallback(
() => (
<HeaderLeft
type='text'
content={t('common:buttons.cancel')}
onPress={() => {
if (!composeState.dirty) {
navigation.goBack()
return
} else {
Alert.alert(t('screenCompose:heading.left.alert.title'), undefined, [
{
text: t('screenCompose:heading.left.alert.buttons.delete'),
style: 'destructive',
onPress: () => {
removeDraft(composeState.timestamp)
navigation.goBack()
}
},
{
text: t('screenCompose:heading.left.alert.buttons.save'),
onPress: () => {
saveDraft()
navigation.goBack()
}
},
{
text: t('common:buttons.cancel'),
style: 'cancel'
}
])
}
}}
/>
),
[composeState]
)
const headerRightDisabled = useMemo(() => {
const headerRightDisabled = () => {
if (totalTextCount > maxTootChars) {
return true
}
@ -250,104 +213,8 @@ const ScreenCompose: React.FC<RootStackScreenProps<'Screen-Compose'>> = ({
return true
}
return false
}, [totalTextCount, composeState.attachments.uploads, composeState.text.raw])
}
const mutateTimeline = useTimelineMutation({ onMutate: true })
const headerRight = useCallback(
() => (
<HeaderRight
type='text'
content={t(
`screenCompose:heading.right.button.${
(params?.type &&
(params.type === 'conversation'
? params.visibility === 'direct'
? params.type
: 'default'
: params.type)) ||
'default'
}`
)}
onPress={() => {
composeDispatch({ type: 'posting', payload: true })
composePost(params, composeState)
.then(res => {
haptics('Success')
if (Platform.OS === 'ios' && Platform.constants.osVersion === '13.3') {
// https://github.com/tooot-app/app/issues/59
} else {
const currentCount = getGlobalStorage.number('app.count_till_store_review')
if (currentCount === 10) {
StoreReview?.isAvailableAsync()
.then(() => StoreReview.requestReview())
.catch(() => {})
} else {
setGlobalStorage('app.count_till_store_review', (currentCount || 0) + 1)
}
}
switch (params?.type) {
case 'edit':
mutateTimeline.mutate({
type: 'editItem',
queryKey: params.queryKey,
rootQueryKey: params.rootQueryKey,
status: res
})
break
case 'deleteEdit':
case 'reply':
if (params?.queryKey && params.queryKey[1].page === 'Toot') {
queryClient.invalidateQueries(params.queryKey)
}
break
}
removeDraft(composeState.timestamp)
navigation.goBack()
})
.catch(error => {
if (error?.removeReply) {
Alert.alert(
t('screenCompose:heading.right.alert.removeReply.title'),
t('screenCompose:heading.right.alert.removeReply.description'),
[
{
text: t('common:buttons.cancel'),
onPress: () => {
composeDispatch({ type: 'posting', payload: false })
},
style: 'destructive'
},
{
text: t('screenCompose:heading.right.alert.removeReply.confirm'),
onPress: () => {
composeDispatch({ type: 'removeReply' })
composeDispatch({ type: 'posting', payload: false })
},
style: 'default'
}
]
)
} else {
haptics('Error')
handleError({ message: 'Posting error', captureResponse: true })
composeDispatch({ type: 'posting', payload: false })
Alert.alert(t('screenCompose:heading.right.alert.default.title'), undefined, [
{ text: t('screenCompose:heading.right.alert.default.button') }
])
}
})
}}
loading={composeState.posting}
disabled={headerRightDisabled}
/>
),
[totalTextCount, composeState]
)
const headerContent = useMemo(() => {
return `${totalTextCount} / ${maxTootChars}`
}, [totalTextCount, maxTootChars, composeState.dirty])
const inputProps: EmojisState['inputProps'] = [
{
@ -393,7 +260,7 @@ const ScreenCompose: React.FC<RootStackScreenProps<'Screen-Compose'>> = ({
name='Screen-Compose-Root'
component={ComposeRoot}
options={{
title: headerContent,
title: `${totalTextCount} / ${maxTootChars}`,
headerTitleStyle: {
fontWeight:
totalTextCount > maxTootChars
@ -402,8 +269,133 @@ const ScreenCompose: React.FC<RootStackScreenProps<'Screen-Compose'>> = ({
fontSize: StyleConstants.Font.Size.M
},
headerTintColor: totalTextCount > maxTootChars ? colors.red : colors.secondary,
headerLeft,
headerRight
headerLeft: () => (
<HeaderLeft
type='text'
content={t('common:buttons.cancel')}
onPress={() => {
if (!composeState.dirty) {
navigation.goBack()
return
} else {
Alert.alert(t('screenCompose:heading.left.alert.title'), undefined, [
{
text: t('screenCompose:heading.left.alert.buttons.delete'),
style: 'destructive',
onPress: () => {
removeDraft(composeState.timestamp)
navigation.goBack()
}
},
{
text: t('screenCompose:heading.left.alert.buttons.save'),
onPress: () => {
saveDraft()
navigation.goBack()
}
},
{
text: t('common:buttons.cancel'),
style: 'cancel'
}
])
}
}}
/>
),
headerRight: () => (
<HeaderRight
type='text'
content={t(
`screenCompose:heading.right.button.${
(params?.type &&
(params.type === 'conversation'
? params.visibility === 'direct'
? params.type
: 'default'
: params.type)) ||
'default'
}`
)}
onPress={() => {
composeDispatch({ type: 'posting', payload: true })
composePost(params, composeState)
.then(res => {
haptics('Success')
if (Platform.OS === 'ios' && Platform.constants.osVersion === '13.3') {
// https://github.com/tooot-app/app/issues/59
} else {
const currentCount = getGlobalStorage.number(
'app.count_till_store_review'
)
if (currentCount === 10) {
StoreReview?.isAvailableAsync()
.then(() => StoreReview.requestReview())
.catch(() => {})
} else {
setGlobalStorage('app.count_till_store_review', (currentCount || 0) + 1)
}
}
switch (params?.type) {
case 'edit':
mutateTimeline.mutate({
type: 'editItem',
queryKey: params.queryKey,
rootQueryKey: params.rootQueryKey,
status: res
})
break
case 'deleteEdit':
case 'reply':
if (params?.queryKey && params.queryKey[1].page === 'Toot') {
queryClient.invalidateQueries(params.queryKey)
}
break
}
removeDraft(composeState.timestamp)
navigation.goBack()
})
.catch(error => {
if (error?.removeReply) {
Alert.alert(
t('screenCompose:heading.right.alert.removeReply.title'),
t('screenCompose:heading.right.alert.removeReply.description'),
[
{
text: t('common:buttons.cancel'),
onPress: () => {
composeDispatch({ type: 'posting', payload: false })
},
style: 'destructive'
},
{
text: t('screenCompose:heading.right.alert.removeReply.confirm'),
onPress: () => {
composeDispatch({ type: 'removeReply' })
composeDispatch({ type: 'posting', payload: false })
},
style: 'default'
}
]
)
} else {
haptics('Error')
handleError({ message: 'Posting error', captureResponse: true })
composeDispatch({ type: 'posting', payload: false })
Alert.alert(
t('screenCompose:heading.right.alert.default.title'),
undefined,
[{ text: t('screenCompose:heading.right.alert.default.button') }]
)
}
})
}}
loading={composeState.posting}
disabled={headerRightDisabled()}
/>
)
}}
/>
<Stack.Screen

View File

@ -4,7 +4,7 @@ import { useActionSheet } from '@expo/react-native-action-sheet'
import { androidActionSheetStyles } from '@utils/helpers/androidActionSheetStyles'
import { RootStackScreenProps } from '@utils/navigation/navigators'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useState } from 'react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
Dimensions,
@ -40,111 +40,16 @@ const ScreenImagesViewer = ({
const insets = useSafeAreaInsets()
const { mode, colors } = useTheme()
const { colors } = useTheme()
const { t } = useTranslation(['common', 'screenImageViewer'])
const initialIndex = imageUrls.findIndex(image => image.id === id)
const [currentIndex, setCurrentIndex] = useState(initialIndex)
const { showActionSheetWithOptions } = useActionSheet()
const onPress = useCallback(() => {
showActionSheetWithOptions(
{
options: [
t('screenImageViewer:content.options.save'),
t('screenImageViewer:content.options.share'),
t('common:buttons.cancel')
],
cancelButtonIndex: 2,
...androidActionSheetStyles(colors)
},
async buttonIndex => {
switch (buttonIndex) {
case 0:
saveImage({ image: imageUrls[currentIndex] })
break
case 1:
switch (Platform.OS) {
case 'ios':
await Share.share({ url: imageUrls[currentIndex].url })
break
case 'android':
await Share.share({ message: imageUrls[currentIndex].url })
break
}
break
}
}
)
}, [currentIndex])
const isZoomed = useSharedValue(false)
const renderItem = React.useCallback(
({
item
}: {
item: RootStackScreenProps<'Screen-ImagesViewer'>['route']['params']['imageUrls'][0]
}) => {
const screenRatio = WINDOW_WIDTH / WINDOW_HEIGHT
const imageRatio = item.width && item.height ? item.width / item.height : 1
const imageWidth = item.width || 100
const imageHeight = item.height || 100
const maxWidthScale = item.width ? (item.width / WINDOW_WIDTH / PixelRatio.get()) * 4 : 0
const maxHeightScale = item.height ? (item.height / WINDOW_WIDTH / PixelRatio.get()) * 4 : 0
const max = Math.max.apply(Math, [maxWidthScale, maxHeightScale, 4])
return (
<Zoom
onZoomBegin={() => (isZoomed.value = true)}
onZoomEnd={() => (isZoomed.value = false)}
maximumZoomScale={max > 8 ? 8 : max}
simultaneousGesture={Gesture.Fling()
.direction(Directions.DOWN)
.onStart(() => {
if (isZoomed.value === false) {
runOnJS(navigation.goBack)()
}
})}
children={
<View
style={{
width: WINDOW_WIDTH,
height: WINDOW_HEIGHT,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center'
}}
>
<GracefullyImage
uri={{ preview: item.preview_url, remote: item.remote_url, original: item.url }}
dimension={{
width:
screenRatio > imageRatio
? (WINDOW_HEIGHT / imageHeight) * imageWidth
: WINDOW_WIDTH,
height:
screenRatio > imageRatio
? WINDOW_HEIGHT
: (WINDOW_WIDTH / imageWidth) * imageHeight
}}
/>
</View>
}
/>
)
},
[isZoomed.value]
)
const onViewableItemsChanged = useCallback(
({ viewableItems }: { viewableItems: ViewToken[] }) => {
setCurrentIndex(viewableItems[0]?.index || 0)
},
[]
)
return (
<View style={{ backgroundColor: 'black' }}>
<StatusBar hidden />
@ -169,7 +74,36 @@ const ScreenImagesViewer = ({
content='MoreHorizontal'
native={false}
background
onPress={onPress}
onPress={() =>
showActionSheetWithOptions(
{
options: [
t('screenImageViewer:content.options.save'),
t('screenImageViewer:content.options.share'),
t('common:buttons.cancel')
],
cancelButtonIndex: 2,
...androidActionSheetStyles(colors)
},
async buttonIndex => {
switch (buttonIndex) {
case 0:
saveImage({ image: imageUrls[currentIndex] })
break
case 1:
switch (Platform.OS) {
case 'ios':
await Share.share({ url: imageUrls[currentIndex].url })
break
case 'android':
await Share.share({ message: imageUrls[currentIndex].url })
break
}
break
}
}
)
}
/>
</View>
<LongPressGestureHandler
@ -211,8 +145,71 @@ const ScreenImagesViewer = ({
pagingEnabled
horizontal
keyExtractor={item => item.id}
renderItem={renderItem}
onViewableItemsChanged={onViewableItemsChanged}
renderItem={({
item
}: {
item: RootStackScreenProps<'Screen-ImagesViewer'>['route']['params']['imageUrls'][0]
}) => {
const screenRatio = WINDOW_WIDTH / WINDOW_HEIGHT
const imageRatio = item.width && item.height ? item.width / item.height : 1
const imageWidth = item.width || 100
const imageHeight = item.height || 100
const maxWidthScale = item.width
? (item.width / WINDOW_WIDTH / PixelRatio.get()) * 4
: 0
const maxHeightScale = item.height
? (item.height / WINDOW_WIDTH / PixelRatio.get()) * 4
: 0
const max = Math.max.apply(Math, [maxWidthScale, maxHeightScale, 4])
return (
<Zoom
onZoomBegin={() => (isZoomed.value = true)}
onZoomEnd={() => (isZoomed.value = false)}
maximumZoomScale={max > 8 ? 8 : max}
simultaneousGesture={Gesture.Fling()
.direction(Directions.DOWN)
.onStart(() => {
if (isZoomed.value === false) {
runOnJS(navigation.goBack)()
}
})}
children={
<View
style={{
width: WINDOW_WIDTH,
height: WINDOW_HEIGHT,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center'
}}
>
<GracefullyImage
uri={{
preview: item.preview_url,
remote: item.remote_url,
original: item.url
}}
dimension={{
width:
screenRatio > imageRatio
? (WINDOW_HEIGHT / imageHeight) * imageWidth
: WINDOW_WIDTH,
height:
screenRatio > imageRatio
? WINDOW_HEIGHT
: (WINDOW_WIDTH / imageWidth) * imageHeight
}}
/>
</View>
}
/>
)
}}
onViewableItemsChanged={({ viewableItems }: { viewableItems: ViewToken[] }) => {
setCurrentIndex(viewableItems[0]?.index || 0)
}}
viewabilityConfig={{
itemVisiblePercentThreshold: 50
}}

View File

@ -6,8 +6,8 @@ import { TabLocalStackParamList } from '@utils/navigation/navigators'
import { useTimelineQuery } from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback } from 'react'
import { Dimensions, ListRenderItem, Pressable, View } from 'react-native'
import React from 'react'
import { Dimensions, Pressable, View } from 'react-native'
import { FlatList } from 'react-native-gesture-handler'
import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'
@ -39,55 +39,6 @@ const AccountAttachments: React.FC<Props> = ({ account }) => {
.splice(0, DISPLAY_AMOUNT)
: []
const renderItem = useCallback<ListRenderItem<Mastodon.Status>>(
({ item, index }) => {
if (index === DISPLAY_AMOUNT - 1) {
return (
<Pressable
onPress={() => {
account && navigation.push('Tab-Shared-Attachments', { account })
}}
children={
<View
style={{
marginHorizontal: StyleConstants.Spacing.Global.PagePadding,
backgroundColor: colors.backgroundOverlayInvert,
width: width,
height: width,
justifyContent: 'center',
alignItems: 'center'
}}
children={
<Icon
name='MoreHorizontal'
color={colors.primaryOverlay}
size={StyleConstants.Font.Size.L * 1.5}
/>
}
/>
}
/>
)
} else {
return (
<GracefullyImage
uri={{
original: item.media_attachments[0]?.preview_url || item.media_attachments[0]?.url,
remote: item.media_attachments[0]?.remote_url
}}
blurhash={
item.media_attachments[0] && (item.media_attachments[0].blurhash || undefined)
}
dimension={{ width: width, height: width }}
style={{ marginLeft: StyleConstants.Spacing.Global.PagePadding }}
onPress={() => navigation.push('Tab-Shared-Toot', { toot: item })}
/>
)
}
},
[account]
)
const styleContainer = useAnimatedStyle(() => {
if (flattenData.length) {
return {
@ -106,7 +57,52 @@ const AccountAttachments: React.FC<Props> = ({ account }) => {
<FlatList
horizontal
data={flattenData}
renderItem={renderItem}
renderItem={({ item, index }) => {
if (index === DISPLAY_AMOUNT - 1) {
return (
<Pressable
onPress={() => {
account && navigation.push('Tab-Shared-Attachments', { account })
}}
children={
<View
style={{
marginHorizontal: StyleConstants.Spacing.Global.PagePadding,
backgroundColor: colors.backgroundOverlayInvert,
width: width,
height: width,
justifyContent: 'center',
alignItems: 'center'
}}
children={
<Icon
name='MoreHorizontal'
color={colors.primaryOverlay}
size={StyleConstants.Font.Size.L * 1.5}
/>
}
/>
}
/>
)
} else {
return (
<GracefullyImage
uri={{
original:
item.media_attachments[0]?.preview_url || item.media_attachments[0]?.url,
remote: item.media_attachments[0]?.remote_url
}}
blurhash={
item.media_attachments[0] && (item.media_attachments[0].blurhash || undefined)
}
dimension={{ width: width, height: width }}
style={{ marginLeft: StyleConstants.Spacing.Global.PagePadding }}
onPress={() => navigation.push('Tab-Shared-Toot', { toot: item })}
/>
)
}
}}
showsHorizontalScrollIndicator={false}
/>
</Animated.View>

View File

@ -2,7 +2,7 @@ import { ParseEmojis } from '@components/Parse'
import CustomText from '@components/Text'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useMemo } from 'react'
import React from 'react'
import { View } from 'react-native'
import { PlaceholderLine } from 'rn-placeholder'
@ -13,21 +13,6 @@ export interface Props {
const AccountInformationName: React.FC<Props> = ({ account }) => {
const { colors } = useTheme()
const movedContent = useMemo(() => {
if (account?.moved) {
return (
<View style={{ marginLeft: StyleConstants.Spacing.S }}>
<ParseEmojis
content={account.moved.display_name || account.moved.username}
emojis={account.moved.emojis}
size='L'
fontBold
/>
</View>
)
}
}, [account?.moved])
return (
<View
style={{
@ -51,7 +36,16 @@ const AccountInformationName: React.FC<Props> = ({ account }) => {
fontBold
/>
</CustomText>
{movedContent}
{account.moved ? (
<View style={{ marginLeft: StyleConstants.Spacing.S }}>
<ParseEmojis
content={account.moved.display_name || account.moved.username}
emojis={account.moved.emojis}
size='L'
fontBold
/>
</View>
) : null}
</>
) : (
<PlaceholderLine

View File

@ -9,7 +9,7 @@ import { SearchResult } from '@utils/queryHooks/search'
import { QueryKeyUsers, useUsersQuery } from '@utils/queryHooks/users'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useEffect, useState } from 'react'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { View } from 'react-native'
import { Circle, Flow } from 'react-native-animated-spinkit'
@ -41,11 +41,6 @@ const TabSharedUsers: React.FC<TabSharedStackScreenProps<'Tab-Shared-Users'>> =
})
const flattenData = data?.pages ? data.pages.flatMap(page => [...page.body]) : []
const onEndReached = useCallback(
() => hasNextPage && !isFetchingNextPage && fetchNextPage(),
[hasNextPage, isFetchingNextPage]
)
const [isSearching, setIsSearching] = useState(false)
return (
@ -90,7 +85,7 @@ const TabSharedUsers: React.FC<TabSharedStackScreenProps<'Tab-Shared-Users'>> =
children={<Flow size={StyleConstants.Font.Size.L} color={colors.secondary} />}
/>
)}
onEndReached={onEndReached}
onEndReached={() => hasNextPage && !isFetchingNextPage && fetchNextPage()}
onEndReachedThreshold={0.75}
ItemSeparatorComponent={ComponentSeparator}
ListEmptyComponent={

View File

@ -5,7 +5,7 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'
import { RootStackScreenProps, ScreenTabsStackParamList } from '@utils/navigation/navigators'
import { getGlobalStorage, useAccountStorage, useGlobalStorage } from '@utils/storage/actions'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useMemo } from 'react'
import React from 'react'
import { Platform } from 'react-native'
import TabLocal from './Local'
import TabMe from './Me'
@ -20,31 +20,6 @@ const ScreenTabs = ({ navigation }: RootStackScreenProps<'Screen-Tabs'>) => {
const [accountActive] = useGlobalStorage.string('account.active')
const [avatarStatic] = useAccountStorage.string('auth.account.avatar_static')
const composeListeners = useMemo(
() => ({
tabPress: (e: any) => {
e.preventDefault()
haptics('Light')
navigation.navigate('Screen-Compose')
}
}),
[]
)
const composeComponent = useCallback(() => null, [])
const meListeners = useMemo(
() => ({
tabLongPress: () => {
haptics('Light')
//@ts-ignore
navigation.navigate('Tab-Me', { screen: 'Tab-Me-Root' })
//@ts-ignore
navigation.navigate('Tab-Me', { screen: 'Tab-Me-Switch' })
}
}),
[]
)
return (
<Tab.Navigator
initialRouteName={accountActive ? getGlobalStorage.string('app.prev_tab') : 'Tab-Me'}
@ -97,9 +72,32 @@ const ScreenTabs = ({ navigation }: RootStackScreenProps<'Screen-Tabs'>) => {
>
<Tab.Screen name='Tab-Local' component={TabLocal} />
<Tab.Screen name='Tab-Public' component={TabPublic} />
<Tab.Screen name='Tab-Compose' component={composeComponent} listeners={composeListeners} />
<Tab.Screen
name='Tab-Compose'
listeners={{
tabPress: e => {
e.preventDefault()
haptics('Light')
navigation.navigate('Screen-Compose')
}
}}
>
{() => null}
</Tab.Screen>
<Tab.Screen name='Tab-Notifications' component={TabNotifications} />
<Tab.Screen name='Tab-Me' component={TabMe} listeners={meListeners} />
<Tab.Screen
name='Tab-Me'
component={TabMe}
listeners={{
tabLongPress: () => {
haptics('Light')
//@ts-ignore
navigation.navigate('Tab-Me', { screen: 'Tab-Me-Root' })
//@ts-ignore
navigation.navigate('Tab-Me', { screen: 'Tab-Me-Switch' })
}
}}
/>
</Tab.Navigator>
)
}

View File

@ -23,7 +23,7 @@ import { useTheme } from '@utils/styles/ThemeManager'
import { themes } from '@utils/styles/themes'
import * as Linking from 'expo-linking'
import { addScreenshotListener } from 'expo-screen-capture'
import React, { useCallback, useEffect, useState } from 'react'
import React, { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { IntlProvider } from 'react-intl'
import { Alert, Platform, StatusBar } from 'react-native'
@ -90,7 +90,7 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
useEmojisQuery({ options: { enabled: !!accountActive } })
// Callbacks
const navigationContainerOnStateChange = useCallback(() => {
const navigationContainerOnStateChange = () => {
const currentRoute = navigationRef.getCurrentRoute()
const matchTabName = currentRoute?.name?.match(/(Tab-.*)-Root/)
@ -98,7 +98,7 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
// @ts-ignore
setGlobalStorage('app.prev_tab', matchTabName[1])
}
}, [])
}
// Deep linking for compose
const [deeplinked, setDeeplinked] = useState(false)
@ -128,109 +128,106 @@ const Screens: React.FC<Props> = ({ localCorrupt }) => {
}, [accounts, accountActive, deeplinked])
// Share Extension
const handleShare = useCallback(
(
item?:
| {
data: { mimeType: string; data: string }[]
mimeType: undefined
}
| { data: string | string[]; mimeType: string }
) => {
if (!accountActive) {
return
}
if (!item || !item.data) {
return
}
let text: string | undefined = undefined
let media: { uri: string; mime: string }[] = []
const typesImage = ['png', 'jpg', 'jpeg', 'gif']
const typesVideo = ['mp4', 'm4v', 'mov', 'webm', 'mpeg']
const filterMedia = ({ uri, mime }: { uri: string; mime: string }) => {
if (mime.startsWith('image/')) {
if (!typesImage.includes(mime.split('/')[1])) {
console.warn('Image type not supported:', mime.split('/')[1])
displayMessage({
message: t('screens:shareError.imageNotSupported', {
type: mime.split('/')[1]
}),
type: 'danger'
})
return
}
media.push({ uri, mime })
} else if (mime.startsWith('video/')) {
if (!typesVideo.includes(mime.split('/')[1])) {
console.warn('Video type not supported:', mime.split('/')[1])
displayMessage({
message: t('screens:shareError.videoNotSupported', {
type: mime.split('/')[1]
}),
type: 'danger'
})
return
}
media.push({ uri, mime })
} else {
if (typesImage.includes(uri.split('.').pop() || '')) {
media.push({ uri, mime: 'image/jpg' })
return
}
if (typesVideo.includes(uri.split('.').pop() || '')) {
media.push({ uri, mime: 'video/mp4' })
return
}
text = !text ? uri : text.concat(text, `\n${uri}`)
const handleShare = (
item?:
| {
data: { mimeType: string; data: string }[]
mimeType: undefined
}
}
| { data: string | string[]; mimeType: string }
) => {
if (!accountActive) {
return
}
if (!item || !item.data) {
return
}
switch (Platform.OS) {
case 'ios':
if (!Array.isArray(item.data) || !item.data) {
return
}
let text: string | undefined = undefined
let media: { uri: string; mime: string }[] = []
for (const d of item.data) {
if (typeof d !== 'string') {
filterMedia({ uri: d.data, mime: d.mimeType })
}
}
break
case 'android':
if (!item.mimeType) {
return
}
if (Array.isArray(item.data)) {
for (const d of item.data) {
filterMedia({ uri: d, mime: item.mimeType })
}
} else {
filterMedia({ uri: item.data, mime: item.mimeType })
}
break
}
if (!text && !media.length) {
return
const typesImage = ['png', 'jpg', 'jpeg', 'gif']
const typesVideo = ['mp4', 'm4v', 'mov', 'webm', 'mpeg']
const filterMedia = ({ uri, mime }: { uri: string; mime: string }) => {
if (mime.startsWith('image/')) {
if (!typesImage.includes(mime.split('/')[1])) {
console.warn('Image type not supported:', mime.split('/')[1])
displayMessage({
message: t('screens:shareError.imageNotSupported', {
type: mime.split('/')[1]
}),
type: 'danger'
})
return
}
media.push({ uri, mime })
} else if (mime.startsWith('video/')) {
if (!typesVideo.includes(mime.split('/')[1])) {
console.warn('Video type not supported:', mime.split('/')[1])
displayMessage({
message: t('screens:shareError.videoNotSupported', {
type: mime.split('/')[1]
}),
type: 'danger'
})
return
}
media.push({ uri, mime })
} else {
if (accounts?.length) {
navigationRef.navigate('Screen-AccountSelection', {
share: { text, media }
})
} else {
navigationRef.navigate('Screen-Compose', {
type: 'share',
text,
media
})
if (typesImage.includes(uri.split('.').pop() || '')) {
media.push({ uri, mime: 'image/jpg' })
return
}
if (typesVideo.includes(uri.split('.').pop() || '')) {
media.push({ uri, mime: 'video/mp4' })
return
}
text = !text ? uri : text.concat(text, `\n${uri}`)
}
},
[]
)
}
switch (Platform.OS) {
case 'ios':
if (!Array.isArray(item.data) || !item.data) {
return
}
for (const d of item.data) {
if (typeof d !== 'string') {
filterMedia({ uri: d.data, mime: d.mimeType })
}
}
break
case 'android':
if (!item.mimeType) {
return
}
if (Array.isArray(item.data)) {
for (const d of item.data) {
filterMedia({ uri: d, mime: item.mimeType })
}
} else {
filterMedia({ uri: item.data, mime: item.mimeType })
}
break
}
if (!text && !media.length) {
return
} else {
if (accounts?.length) {
navigationRef.navigate('Screen-AccountSelection', {
share: { text, media }
})
} else {
navigationRef.navigate('Screen-Compose', {
type: 'share',
text,
media
})
}
}
}
useEffect(() => {
ShareMenu.getInitialShare(handleShare)
}, [])

View File

@ -13,6 +13,8 @@ export type GlobalV0 = {
// number
'app.count_till_store_review'?: number
'app.font_size'?: -1 | 0 | 1 | 2 | 3
'version.global': number
'version.account': number
// boolean
'app.auto_play_gifv'?: boolean

View File

@ -3,7 +3,7 @@ import log from '@utils/startup/log'
import { secureStorage, storage } from '@utils/storage'
import { MMKV } from 'react-native-mmkv'
export const hasMigratedFromAsyncStorage = storage.global.getBoolean('hasMigratedFromAsyncStorage')
export const versionStorageGlobal = storage.global.getNumber('version.global')
export async function migrateFromAsyncStorage(): Promise<void> {
log('log', 'Migration', 'Migrating...')
@ -107,7 +107,7 @@ export async function migrateFromAsyncStorage(): Promise<void> {
throw error
}
storage.global.set('hasMigratedFromAsyncStorage', true)
storage.global.set('version.global', 0)
const end = global.performance.now()
log('log', 'Migration', `Migrated in ${end - start}ms`)

View File

@ -19,7 +19,7 @@ const ManageThemeContext = createContext<ContextType>({
export const useTheme = () => useContext(ManageThemeContext)
const useColorSchemeDelay = (delay = 500) => {
const useColorSchemeDelay = (delay = 50) => {
const [colorScheme, setColorScheme] = React.useState(Appearance.getColorScheme())
const onColorSchemeChange = React.useCallback(
throttle(

View File

@ -2922,13 +2922,13 @@ __metadata:
languageName: node
linkType: hard
"@react-native-menu/menu@npm:^0.7.2":
version: 0.7.2
resolution: "@react-native-menu/menu@npm:0.7.2"
"@react-native-menu/menu@npm:^0.7.3":
version: 0.7.3
resolution: "@react-native-menu/menu@npm:0.7.3"
peerDependencies:
react: "*"
react-native: "*"
checksum: b82402d183a58427cf0cc0dd617a81019e559d828b370f37d2df2690254dfa1e53f4da228e8c63e53d7c29423fd939f9a4b0c2b2930dc7d6cc5fdc5858b523f6
checksum: 419b2e500c49248b3cc2202ceda7cdb35008dfc3a4d67becb5a1276a9d306c654eccfa041a1d040b97ebb188478e565c658fa5dee1d7db834a786d67e32461e1
languageName: node
linkType: hard
@ -11232,7 +11232,7 @@ __metadata:
"@react-native-community/blur": ^4.3.0
"@react-native-community/netinfo": 9.3.7
"@react-native-community/segmented-control": ^2.2.2
"@react-native-menu/menu": ^0.7.2
"@react-native-menu/menu": ^0.7.3
"@react-navigation/bottom-tabs": ^6.5.2
"@react-navigation/native": ^6.1.1
"@react-navigation/native-stack": ^6.9.7