1
0
mirror of https://github.com/tooot-app/app synced 2025-06-05 22:19:13 +02:00

Rewrite timeline logic

This commit is contained in:
Zhiyuan Zheng
2021-02-27 16:33:54 +01:00
parent 45681fc1f5
commit f3fa6bc662
67 changed files with 1980 additions and 1395 deletions

View File

@ -57,13 +57,9 @@ const MenuRow: React.FC<Props> = ({
return (
<View style={styles.base}>
<TapGestureHandler
onHandlerStateChange={({ nativeEvent }) => {
if (nativeEvent.state === State.ACTIVE) {
if (!loading) {
onPress && onPress()
}
}
}}
onHandlerStateChange={({ nativeEvent }) =>
nativeEvent.state === State.ACTIVE && !loading && onPress && onPress()
}
>
<View style={styles.core}>
<View style={styles.front}>
@ -82,11 +78,6 @@ const MenuRow: React.FC<Props> = ({
>
{title}
</Text>
{description ? (
<Text style={[styles.description, { color: theme.secondary }]}>
{description}
</Text>
) : null}
</View>
</View>
@ -94,21 +85,18 @@ const MenuRow: React.FC<Props> = ({
<View style={styles.back}>
{content ? (
typeof content === 'string' ? (
<>
<Text
style={[
styles.content,
{
color: theme.secondary,
opacity: !iconBack && loading ? 0 : 1
}
]}
numberOfLines={1}
>
{content}
</Text>
{loading && !iconBack && loadingSpinkit}
</>
<Text
style={[
styles.content,
{
color: theme.secondary,
opacity: !iconBack && loading ? 0 : 1
}
]}
numberOfLines={1}
>
{content}
</Text>
) : (
content
)
@ -119,23 +107,27 @@ const MenuRow: React.FC<Props> = ({
onValueChange={switchOnValueChange}
disabled={switchDisabled}
trackColor={{ true: theme.blue, false: theme.disabled }}
style={{ opacity: loading ? 0 : 1 }}
/>
) : null}
{iconBack ? (
<>
<Icon
name={iconBack}
size={StyleConstants.Font.Size.L}
color={theme[iconBackColor]}
style={[styles.iconBack, { opacity: loading ? 0 : 1 }]}
/>
{loading && loadingSpinkit}
</>
<Icon
name={iconBack}
size={StyleConstants.Font.Size.L}
color={theme[iconBackColor]}
style={[styles.iconBack, { opacity: loading ? 0 : 1 }]}
/>
) : null}
{loading && loadingSpinkit}
</View>
) : null}
</View>
</TapGestureHandler>
{description ? (
<Text style={[styles.description, { color: theme.secondary }]}>
{description}
</Text>
) : null}
</View>
)
}
@ -147,9 +139,7 @@ const styles = StyleSheet.create({
core: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
paddingLeft: StyleConstants.Spacing.Global.PagePadding,
paddingRight: StyleConstants.Spacing.Global.PagePadding
paddingHorizontal: StyleConstants.Spacing.Global.PagePadding
},
front: {
flex: 2,
@ -174,7 +164,8 @@ const styles = StyleSheet.create({
},
description: {
...StyleConstants.FontStyle.S,
marginTop: StyleConstants.Spacing.XS
marginTop: StyleConstants.Spacing.XS,
paddingHorizontal: StyleConstants.Spacing.Global.PagePadding
},
content: {
...StyleConstants.FontStyle.M

View File

@ -1,93 +1,61 @@
import ComponentSeparator from '@components/Separator'
import { useNavigation, useScrollToTop } from '@react-navigation/native'
import { useScrollToTop } from '@react-navigation/native'
import { QueryKeyTimeline, useTimelineQuery } from '@utils/queryHooks/timeline'
import { getInstanceActive } from '@utils/slices/instancesSlice'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import { findIndex } from 'lodash'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import React, { RefObject, useCallback, useRef } from 'react'
import {
FlatList,
FlatListProps,
Platform,
RefreshControl,
StyleSheet
} from 'react-native'
import { FlatList } from 'react-native-gesture-handler'
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming
useAnimatedScrollHandler,
useSharedValue
} from 'react-native-reanimated'
import { InfiniteData, useQueryClient } from 'react-query'
import { useSelector } from 'react-redux'
import haptics from './haptics'
import TimelineConversation from './Timeline/Conversation'
import TimelineDefault from './Timeline/Default'
import TimelineEmpty from './Timeline/Empty'
import TimelineEnd from './Timeline/End'
import TimelineNotifications from './Timeline/Notifications'
import TimelineRefresh from './Timeline/Refresh'
import TimelineFooter from './Timeline/Footer'
import TimelineRefresh, {
SEPARATION_Y_1,
SEPARATION_Y_2
} from './Timeline/Refresh'
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList)
export interface Props {
page: App.Pages
hashtag?: Mastodon.Tag['name']
list?: Mastodon.List['id']
toot?: Mastodon.Status['id']
rootQueryKey?: QueryKeyTimeline
account?: Mastodon.Account['id']
flRef?: RefObject<FlatList<any>>
queryKey: QueryKeyTimeline
disableRefresh?: boolean
disableInfinity?: boolean
customProps?: Partial<FlatListProps<any>>
customProps: Partial<FlatListProps<any>> &
Pick<FlatListProps<any>, 'renderItem'>
}
const Timeline: React.FC<Props> = ({
page,
hashtag,
list,
toot,
rootQueryKey,
account,
flRef: customFLRef,
queryKey,
disableRefresh = false,
disableInfinity = false,
customProps
}) => {
const { theme } = useTheme()
// Update timeline when account switched
useSelector(getInstanceActive)
const queryKeyParams = {
page,
...(hashtag && { hashtag }),
...(list && { list }),
...(toot && { toot }),
...(account && { account })
}
const queryKey: QueryKeyTimeline = ['Timeline', queryKeyParams]
const {
status,
data,
refetch,
isSuccess,
isFetching,
isLoading,
hasPreviousPage,
fetchPreviousPage,
isFetchingPreviousPage,
hasNextPage,
fetchNextPage,
isFetchingNextPage
} = useTimelineQuery({
...queryKeyParams,
...queryKey[1],
options: {
getPreviousPageParam: firstPage =>
firstPage?.links?.prev && {
min_id: firstPage.links.prev,
// https://github.com/facebook/react-native/issues/25239#issuecomment-731100372
limit: '5'
},
notifyOnChangeProps: Platform.select({
ios: ['data', 'isFetching'],
android: ['data', 'isFetching', 'isLoading']
}),
getNextPageParam: lastPage =>
lastPage?.links?.next && { max_id: lastPage.links.next }
}
@ -95,243 +63,87 @@ const Timeline: React.FC<Props> = ({
const flattenData = data?.pages ? data.pages.flatMap(d => [...d.body]) : []
// Auto go back when toot page is empty
const navigation = useNavigation()
useEffect(() => {
if (toot && isSuccess && flattenData.length === 0) {
navigation.goBack()
}
}, [isSuccess, flattenData.length])
// Toot page auto scroll to selected toot
const flRef = useRef<FlatList<any>>(null)
const scrolled = useRef(false)
useEffect(() => {
if (toot && isSuccess && !scrolled.current) {
scrolled.current = true
const pointer = findIndex(flattenData, ['id', toot])
setTimeout(() => {
flRef.current?.scrollToIndex({
index: pointer,
viewOffset: 100
})
}, 500)
}
}, [isSuccess, flattenData.length, scrolled])
const onScrollToIndexFailed = useCallback(error => {
const offset = error.averageItemLength * error.index
flRef.current?.scrollToOffset({ offset })
setTimeout(
() =>
flRef.current?.scrollToIndex({ index: error.index, viewOffset: 100 }),
350
)
}, [])
const keyExtractor = useCallback(({ id }) => id, [])
const renderItem = useCallback(
({ item }) => {
switch (page) {
case 'Conversations':
return (
<TimelineConversation conversation={item} queryKey={queryKey} />
)
case 'Notifications':
return (
<TimelineNotifications notification={item} queryKey={queryKey} />
)
default:
return (
<TimelineDefault
item={item}
queryKey={queryKey}
{...(toot === item.id && { highlighted: true })}
{...(toot && { rootQueryKey })}
// @ts-ignore
{...(data?.pages[0].pinned && { pinned: data?.pages[0].pinned })}
/>
)
}
},
[data?.pages[0]]
)
const ItemSeparatorComponent = useCallback(
({ leadingItem }) => (
<ComponentSeparator
{...(toot === leadingItem.id
? { extraMarginLeft: 0 }
: {
extraMarginLeft:
StyleConstants.Avatar.M + StyleConstants.Spacing.S
})}
/>
),
({ leadingItem }) =>
queryKey[1].page === 'Toot' && queryKey[1].toot === leadingItem.id ? (
<ComponentSeparator extraMarginLeft={0} />
) : (
<ComponentSeparator
extraMarginLeft={StyleConstants.Avatar.M + StyleConstants.Spacing.S}
/>
),
[]
)
const flItemEmptyComponent = useMemo(
() => <TimelineEmpty status={status} refetch={refetch} />,
[status]
)
const onEndReached = useCallback(
() => !disableInfinity && !isFetchingNextPage && fetchNextPage(),
[isFetchingNextPage]
)
const ListFooterComponent = useMemo(
() => <TimelineEnd hasNextPage={!disableInfinity ? hasNextPage : false} />,
[hasNextPage]
)
useScrollToTop(flRef)
const queryClient = useQueryClient()
const flRef = useRef<FlatList>(null)
const scrollY = useSharedValue(0)
const [isFetchingLatest, setIsFetchingLatest] = useState(0)
useEffect(() => {
// https://github.com/facebook/react-native/issues/25239#issuecomment-731100372
if (isFetchingLatest !== 0) {
if (!isFetchingPreviousPage) {
fetchPreviousPage()
setIsFetchingLatest(isFetchingLatest + 1)
} else {
if (isFetchingLatest === 8) {
setIsFetchingLatest(0)
if (data?.pages[0].body.length === 0) {
queryClient.setQueryData<InfiniteData<any> | undefined>(
queryKey,
data => {
if (data?.pages[0].body.length === 0) {
return {
pages: data.pages.slice(1),
pageParams: data.pageParams.slice(1)
}
} else {
return data
}
}
)
}
} else {
if (data?.pages[0].body.length === 0) {
setIsFetchingLatest(0)
queryClient.setQueryData<InfiniteData<any> | undefined>(
queryKey,
data => {
if (data?.pages[0].body.length === 0) {
return {
pages: data.pages.slice(1),
pageParams: data.pageParams.slice(1)
}
} else {
return data
}
}
)
const fetchingType = useSharedValue<0 | 1 | 2>(0)
const onScroll = useAnimatedScrollHandler(
{
onScroll: ({ contentOffset: { y } }) => {
scrollY.value = y
},
onEndDrag: ({ contentOffset: { y } }) => {
if (!disableRefresh && !isFetching) {
if (y <= SEPARATION_Y_2) {
fetchingType.value = 2
} else if (y <= SEPARATION_Y_1) {
fetchingType.value = 1
}
}
}
}
}, [isFetchingPreviousPage, isFetchingLatest, data?.pages[0].body])
const onScroll = useCallback(({ nativeEvent }) => {
scrollY.value = nativeEvent.contentOffset.y
}, [])
const onResponderRelease = useCallback(() => {
if (!disableRefresh) {
const separation01 = -(
(StyleConstants.Spacing.M * 2.5) / 2 +
StyleConstants.Font.Size.S / 2
)
const separation02 = -(
StyleConstants.Spacing.M * 2.5 * 1.5 +
StyleConstants.Font.Size.S / 2
)
if (
scrollY.value <= separation02 &&
!isFetching &&
isFetchingLatest === 0
) {
haptics('Light')
queryClient.setQueryData<InfiniteData<any> | undefined>(
queryKey,
data => {
if (data?.pages[0].body.length === 0) {
return {
pages: data.pages.slice(1),
pageParams: data.pageParams.slice(1)
}
} else {
return data
}
}
)
refetch()
} else if (
scrollY.value <= separation01 &&
!isFetching &&
isFetchingLatest === 0
) {
haptics('Light')
setIsFetchingLatest(1)
flRef.current?.scrollToOffset({
animated: true,
offset: 1
})
}
}
}, [scrollY.value, isFetching, isFetchingLatest, disableRefresh])
const headerPadding = useAnimatedStyle(() => {
return {
paddingTop:
isFetchingLatest !== 0 || (isFetching && !isLoading)
? withTiming(StyleConstants.Spacing.M * 2.5)
: withTiming(0)
}
}, [isFetchingLatest, isFetching, isLoading])
const ListHeaderComponent = useMemo(
() => <Animated.View style={headerPadding} />,
[]
},
[isFetching]
)
const androidRefreshControl = useMemo(
() =>
Platform.OS === 'android' && {
refreshControl: (
<RefreshControl
enabled
colors={[theme.primary]}
progressBackgroundColor={theme.background}
refreshing={isFetching || isLoading}
onRefresh={() => refetch()}
/>
)
},
[isFetching, isLoading]
)
const androidRefreshControl = Platform.select({
android: {
refreshControl: (
<RefreshControl
enabled
colors={[theme.primary]}
progressBackgroundColor={theme.background}
refreshing={isFetching || isLoading}
onRefresh={() => refetch()}
/>
)
}
})
useScrollToTop(flRef)
return (
<>
<TimelineRefresh
queryKey={queryKey}
scrollY={scrollY}
isLoading={isLoading}
isFetching={isFetching}
disable={disableRefresh}
fetchingType={fetchingType}
disableRefresh={disableRefresh}
/>
<FlatList
<AnimatedFlatList
// @ts-ignore
ref={customFLRef || flRef}
scrollEventThrottle={16}
onScroll={onScroll}
onResponderRelease={onResponderRelease}
ref={flRef}
windowSize={8}
data={flattenData}
initialNumToRender={6}
maxToRenderPerBatch={3}
style={styles.flatList}
renderItem={renderItem}
onEndReached={onEndReached}
keyExtractor={keyExtractor}
onEndReachedThreshold={0.75}
ListHeaderComponent={ListHeaderComponent}
ListFooterComponent={ListFooterComponent}
ListEmptyComponent={flItemEmptyComponent}
ListFooterComponent={
<TimelineFooter
queryKey={queryKey}
disableInfinity={disableInfinity}
/>
}
ListEmptyComponent={<TimelineEmpty queryKey={queryKey} />}
ItemSeparatorComponent={ItemSeparatorComponent}
{...(toot && isSuccess && { onScrollToIndexFailed })}
maintainVisibleContentPosition={{
minIndexForVisible: 0
}}

View File

@ -41,10 +41,7 @@ const TimelineDefault: React.FC<Props> = ({
pinned
}) => {
const { theme } = useTheme()
const instanceAccount = useSelector(
getInstanceAccount,
(prev, next) => prev?.id === next?.id
)
const instanceAccount = useSelector(getInstanceAccount, (prev, next) => true)
const navigation = useNavigation<
StackNavigationProp<Nav.TabLocalStackParamList>
>()

View File

@ -1,72 +1,79 @@
import analytics from '@components/analytics'
import Button from '@components/Button'
import Icon from '@components/Icon'
import { QueryKeyTimeline, useTimelineQuery } from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, Text, View } from 'react-native'
import { Circle } from 'react-native-animated-spinkit'
import { QueryStatus } from 'react-query'
export interface Props {
status: QueryStatus
refetch: () => void
queryKey: QueryKeyTimeline
}
const TimelineEmpty: React.FC<Props> = ({ status, refetch }) => {
const { mode, theme } = useTheme()
const { t, i18n } = useTranslation('componentTimeline')
const TimelineEmpty = React.memo(
({ queryKey }: Props) => {
const { status, refetch } = useTimelineQuery({
...queryKey[1],
options: { notifyOnChangeProps: ['status'] }
})
const children = useMemo(() => {
switch (status) {
case 'loading':
return (
<Circle size={StyleConstants.Font.Size.L} color={theme.secondary} />
)
case 'error':
return (
<>
<Icon
name='Frown'
size={StyleConstants.Font.Size.L}
color={theme.primary}
/>
<Text style={[styles.error, { color: theme.primary }]}>
{t('empty.error.message')}
</Text>
<Button
type='text'
content={t('empty.error.button')}
onPress={() => {
analytics('timeline_error_press_refetch')
refetch()
}}
/>
</>
)
case 'success':
return (
<>
<Icon
name='Smartphone'
size={StyleConstants.Font.Size.L}
color={theme.primary}
/>
<Text style={[styles.error, { color: theme.primary }]}>
{t('empty.success.message')}
</Text>
</>
)
}
}, [mode, i18n.language, status])
return (
<View
style={[styles.base, { backgroundColor: theme.background }]}
children={children}
/>
)
}
const { mode, theme } = useTheme()
const { t, i18n } = useTranslation('componentTimeline')
const children = useMemo(() => {
switch (status) {
case 'loading':
return (
<Circle size={StyleConstants.Font.Size.L} color={theme.secondary} />
)
case 'error':
return (
<>
<Icon
name='Frown'
size={StyleConstants.Font.Size.L}
color={theme.primary}
/>
<Text style={[styles.error, { color: theme.primary }]}>
{t('empty.error.message')}
</Text>
<Button
type='text'
content={t('empty.error.button')}
onPress={() => {
analytics('timeline_error_press_refetch')
refetch()
}}
/>
</>
)
case 'success':
return (
<>
<Icon
name='Smartphone'
size={StyleConstants.Font.Size.L}
color={theme.primary}
/>
<Text style={[styles.error, { color: theme.primary }]}>
{t('empty.success.message')}
</Text>
</>
)
}
}, [mode, i18n.language, status])
return (
<View
style={[styles.base, { backgroundColor: theme.background }]}
children={children}
/>
)
},
() => true
)
const styles = StyleSheet.create({
base: {

View File

@ -1,50 +0,0 @@
import Icon from '@components/Icon'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react'
import { Trans } from 'react-i18next'
import { StyleSheet, Text, View } from 'react-native'
import { Circle } from 'react-native-animated-spinkit'
export interface Props {
hasNextPage?: boolean
}
const TimelineEnd: React.FC<Props> = ({ hasNextPage }) => {
const { theme } = useTheme()
return (
<View style={styles.base}>
{hasNextPage ? (
<Circle size={StyleConstants.Font.Size.L} color={theme.secondary} />
) : (
<Text style={[styles.text, { color: theme.secondary }]}>
<Trans
i18nKey='componentTimeline:end.message'
components={[
<Icon
name='Coffee'
size={StyleConstants.Font.Size.S}
color={theme.secondary}
/>
]}
/>
</Text>
)}
</View>
)
}
const styles = StyleSheet.create({
base: {
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
padding: StyleConstants.Spacing.M
},
text: {
...StyleConstants.FontStyle.S
}
})
export default TimelineEnd

View File

@ -0,0 +1,63 @@
import Icon from '@components/Icon'
import { QueryKeyTimeline, useTimelineQuery } from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react'
import { Trans } from 'react-i18next'
import { StyleSheet, Text, View } from 'react-native'
import { Circle } from 'react-native-animated-spinkit'
export interface Props {
queryKey: QueryKeyTimeline
disableInfinity: boolean
}
const TimelineFooter = React.memo(
({ queryKey, disableInfinity }: Props) => {
const { hasNextPage } = useTimelineQuery({
...queryKey[1],
options: {
enabled: !disableInfinity,
notifyOnChangeProps: ['hasNextPage']
}
})
const { theme } = useTheme()
return (
<View style={styles.base}>
{!disableInfinity && hasNextPage ? (
<Circle size={StyleConstants.Font.Size.L} color={theme.secondary} />
) : (
<Text style={[styles.text, { color: theme.secondary }]}>
<Trans
i18nKey='componentTimeline:end.message'
components={[
<Icon
name='Coffee'
size={StyleConstants.Font.Size.S}
color={theme.secondary}
/>
]}
/>
</Text>
)}
</View>
)
},
() => true
)
const styles = StyleSheet.create({
base: {
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
padding: StyleConstants.Spacing.M
},
text: {
...StyleConstants.FontStyle.S
}
})
export default TimelineFooter

View File

@ -1,8 +1,13 @@
import haptics from '@components/haptics'
import Icon from '@components/Icon'
import {
QueryKeyTimeline,
TimelineData,
useTimelineQuery
} from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager'
import React, { useCallback, useState } from 'react'
import React, { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { StyleSheet, Text, View } from 'react-native'
import { Circle } from 'react-native-animated-spinkit'
@ -15,104 +20,201 @@ import Animated, {
useSharedValue,
withTiming
} from 'react-native-reanimated'
import { InfiniteData, useQueryClient } from 'react-query'
export interface Props {
queryKey: QueryKeyTimeline
scrollY: Animated.SharedValue<number>
isLoading: boolean
isFetching: boolean
disable?: boolean
fetchingType: Animated.SharedValue<0 | 1 | 2>
disableRefresh?: boolean
}
const CONTAINER_HEIGHT = StyleConstants.Spacing.M * 2.5
export const SEPARATION_Y_1 = -(
CONTAINER_HEIGHT / 2 +
StyleConstants.Font.Size.S / 2
)
export const SEPARATION_Y_2 = -(
CONTAINER_HEIGHT * 1.5 +
StyleConstants.Font.Size.S / 2
)
const TimelineRefresh = React.memo(
({ scrollY, isLoading, isFetching, disable = false }: Props) => {
if (disable || isLoading) {
return null
const TimelineRefresh: React.FC<Props> = ({
queryKey,
scrollY,
fetchingType,
disableRefresh = false
}) => {
if (disableRefresh) {
return null
}
const fetchingLatestIndex = useRef(0)
const {
data,
refetch,
isFetching,
isLoading,
fetchPreviousPage,
hasPreviousPage,
isFetchingNextPage
} = useTimelineQuery({
...queryKey[1],
options: {
getPreviousPageParam: firstPage =>
firstPage?.links?.prev && {
min_id: firstPage.links.prev,
// https://github.com/facebook/react-native/issues/25239#issuecomment-731100372
limit: '5'
},
onSuccess: () => {
if (fetchingLatestIndex.current > 0) {
if (fetchingLatestIndex.current > 8) {
clearFirstPage()
fetchingLatestIndex.current = 0
} else {
if (hasPreviousPage) {
fetchPreviousPage()
fetchingLatestIndex.current++
} else {
clearFirstPage()
fetchingLatestIndex.current = 0
}
}
}
}
}
})
const { t } = useTranslation('componentTimeline')
const { theme } = useTheme()
const { t } = useTranslation('componentTimeline')
const { theme } = useTheme()
const separation01 = -(
CONTAINER_HEIGHT / 2 +
StyleConstants.Font.Size.S / 2
)
const separation02 = -(
CONTAINER_HEIGHT * 1.5 +
StyleConstants.Font.Size.S / 2
)
const [textRight, setTextRight] = useState(0)
const arrowY = useAnimatedStyle(() => ({
transform: [
{
translateY: interpolate(
scrollY.value,
[0, separation01],
[
-CONTAINER_HEIGHT / 2 - StyleConstants.Font.Size.M / 2,
CONTAINER_HEIGHT / 2 - StyleConstants.Font.Size.S / 2
],
Extrapolate.CLAMP
)
const queryClient = useQueryClient()
const clearFirstPage = useCallback(() => {
if (data?.pages[0].body.length === 0) {
queryClient.setQueryData<InfiniteData<TimelineData> | undefined>(
queryKey,
data => {
if (data?.pages[0].body.length === 0) {
return {
pages: data.pages.slice(1),
pageParams: data.pageParams.slice(1)
}
} else {
return data
}
}
]
}))
const arrowTop = useAnimatedStyle(() => ({
marginTop:
scrollY.value < separation02
? withTiming(CONTAINER_HEIGHT)
: withTiming(0)
}))
)
}
}, [data?.pages.length && data?.pages[0].body.length])
const arrowStage = useSharedValue(0)
const onLayout = useCallback(
({ nativeEvent }) => {
if (nativeEvent.layout.x + nativeEvent.layout.width > textRight) {
setTextRight(nativeEvent.layout.x + nativeEvent.layout.width)
}
},
[textRight]
)
useAnimatedReaction(
() => {
if (isFetching) {
const [textRight, setTextRight] = useState(0)
const arrowY = useAnimatedStyle(() => ({
transform: [
{
translateY: interpolate(
scrollY.value,
[0, SEPARATION_Y_1],
[
-CONTAINER_HEIGHT / 2 - StyleConstants.Font.Size.M / 2,
CONTAINER_HEIGHT / 2 - StyleConstants.Font.Size.S / 2
],
Extrapolate.CLAMP
)
}
]
}))
const arrowTop = useAnimatedStyle(() => ({
marginTop:
scrollY.value < SEPARATION_Y_2
? withTiming(CONTAINER_HEIGHT)
: withTiming(0)
}))
const arrowStage = useSharedValue(0)
const onLayout = useCallback(
({ nativeEvent }) => {
if (nativeEvent.layout.x + nativeEvent.layout.width > textRight) {
setTextRight(nativeEvent.layout.x + nativeEvent.layout.width)
}
},
[textRight]
)
useAnimatedReaction(
() => {
if (isFetching) {
return false
}
switch (arrowStage.value) {
case 0:
if (scrollY.value < SEPARATION_Y_1) {
arrowStage.value = 1
return true
}
return false
}
switch (arrowStage.value) {
case 0:
if (scrollY.value < separation01) {
arrowStage.value = 1
return true
}
case 1:
if (scrollY.value < SEPARATION_Y_2) {
arrowStage.value = 2
return true
}
if (scrollY.value > SEPARATION_Y_1) {
arrowStage.value = 0
return false
case 1:
if (scrollY.value < separation02) {
arrowStage.value = 2
return true
}
if (scrollY.value > separation01) {
arrowStage.value = 0
return false
}
}
return false
case 2:
if (scrollY.value > SEPARATION_Y_2) {
arrowStage.value = 1
return false
case 2:
if (scrollY.value > separation02) {
arrowStage.value = 1
return false
}
return false
}
},
data => {
if (data) {
runOnJS(haptics)('Light')
}
},
[isFetching]
)
}
return false
}
},
data => {
if (data) {
runOnJS(haptics)('Light')
}
},
[isFetching]
)
const wrapper = () => {
fetchingLatestIndex.current = 1
}
useAnimatedReaction(
() => {
return fetchingType.value
},
data => {
fetchingType.value = 0
switch (data) {
case 1:
runOnJS(wrapper)()
runOnJS(clearFirstPage)()
runOnJS(fetchPreviousPage)()
break
case 2:
runOnJS(clearFirstPage)()
runOnJS(refetch)()
break
}
},
[]
)
return (
const headerPadding = useAnimatedStyle(
() => ({
paddingTop:
fetchingLatestIndex.current !== 0 ||
(isFetching && !isLoading && !isFetchingNextPage)
? withTiming(StyleConstants.Spacing.M * 2.5)
: withTiming(0)
}),
[fetchingLatestIndex.current, isFetching, isFetchingNextPage, isLoading]
)
return (
<Animated.View style={headerPadding}>
<View style={styles.base}>
{isFetching ? (
<View style={styles.container2}>
@ -154,11 +256,9 @@ const TimelineRefresh = React.memo(
</>
)}
</View>
)
},
(prev, next) =>
prev.isLoading === next.isLoading && prev.isFetching === next.isFetching
)
</Animated.View>
)
}
const styles = StyleSheet.create({
base: {

View File

@ -15,140 +15,139 @@ export interface Props {
notification?: boolean
}
const TimelineActioned: React.FC<Props> = ({
account,
action,
notification = false
}) => {
const { t } = useTranslation('componentTimeline')
const { theme } = useTheme()
const navigation = useNavigation<
StackNavigationProp<Nav.TabLocalStackParamList>
>()
const name = account.display_name || account.username
const iconColor = theme.primary
const TimelineActioned = React.memo(
({ account, action, notification = false }: Props) => {
const { t } = useTranslation('componentTimeline')
const { theme } = useTheme()
const navigation = useNavigation<
StackNavigationProp<Nav.TabLocalStackParamList>
>()
const name = account.display_name || account.username
const iconColor = theme.primary
const content = (content: string) => (
<ParseEmojis content={content} emojis={account.emojis} size='S' />
)
const content = (content: string) => (
<ParseEmojis content={content} emojis={account.emojis} size='S' />
)
const onPress = useCallback(() => {
analytics('timeline_shared_actioned_press', { action })
navigation.push('Tab-Shared-Account', { account })
}, [])
const onPress = useCallback(() => {
analytics('timeline_shared_actioned_press', { action })
navigation.push('Tab-Shared-Account', { account })
}, [])
const children = useMemo(() => {
switch (action) {
case 'pinned':
return (
<>
<Icon
name='Anchor'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
{content(t('shared.actioned.pinned'))}
</>
)
break
case 'favourite':
return (
<>
<Icon
name='Heart'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
<Pressable onPress={onPress}>
{content(t('shared.actioned.favourite', { name }))}
</Pressable>
</>
)
break
case 'follow':
return (
<>
<Icon
name='UserPlus'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
<Pressable onPress={onPress}>
{content(t('shared.actioned.follow', { name }))}
</Pressable>
</>
)
break
case 'follow_request':
return (
<>
<Icon
name='UserPlus'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
<Pressable onPress={onPress}>
{content(t('shared.actioned.follow_request', { name }))}
</Pressable>
</>
)
break
case 'poll':
return (
<>
<Icon
name='BarChart2'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
{content(t('shared.actioned.poll'))}
</>
)
break
case 'reblog':
return (
<>
<Icon
name='Repeat'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
<Pressable onPress={onPress}>
{content(
notification
? t('shared.actioned.reblog.notification', { name })
: t('shared.actioned.reblog.default', { name })
)}
</Pressable>
</>
)
break
case 'status':
return (
<>
<Icon
name='Activity'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
<Pressable onPress={onPress}>
{content(t('shared.actioned.status', { name }))}
</Pressable>
</>
)
break
}
}, [])
const children = useMemo(() => {
switch (action) {
case 'pinned':
return (
<>
<Icon
name='Anchor'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
{content(t('shared.actioned.pinned'))}
</>
)
break
case 'favourite':
return (
<>
<Icon
name='Heart'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
<Pressable onPress={onPress}>
{content(t('shared.actioned.favourite', { name }))}
</Pressable>
</>
)
break
case 'follow':
return (
<>
<Icon
name='UserPlus'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
<Pressable onPress={onPress}>
{content(t('shared.actioned.follow', { name }))}
</Pressable>
</>
)
break
case 'follow_request':
return (
<>
<Icon
name='UserPlus'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
<Pressable onPress={onPress}>
{content(t('shared.actioned.follow_request', { name }))}
</Pressable>
</>
)
break
case 'poll':
return (
<>
<Icon
name='BarChart2'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
{content(t('shared.actioned.poll'))}
</>
)
break
case 'reblog':
return (
<>
<Icon
name='Repeat'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
<Pressable onPress={onPress}>
{content(
notification
? t('shared.actioned.reblog.notification', { name })
: t('shared.actioned.reblog.default', { name })
)}
</Pressable>
</>
)
break
case 'status':
return (
<>
<Icon
name='Activity'
size={StyleConstants.Font.Size.S}
color={iconColor}
style={styles.icon}
/>
<Pressable onPress={onPress}>
{content(t('shared.actioned.status', { name }))}
</Pressable>
</>
)
break
}
}, [])
return <View style={styles.actioned} children={children} />
}
return <View style={styles.actioned} children={children} />
},
() => true
)
const styles = StyleSheet.create({
actioned: {
@ -163,4 +162,4 @@ const styles = StyleSheet.create({
}
})
export default React.memo(TimelineActioned, () => true)
export default TimelineActioned

View File

@ -56,7 +56,10 @@ const TimelineActions: React.FC<Props> = ({
theParams.payload.currentValue === true)
) {
queryClient.invalidateQueries(queryKey)
} else if (theParams.payload.property === 'reblogged') {
} else if (
theParams.payload.property === 'reblogged' &&
queryKey[1].page !== 'Following'
) {
// When reblogged, update cache of following page
const tempQueryKey: QueryKeyTimeline = [
'Timeline',

View File

@ -16,129 +16,132 @@ export interface Props {
status: Pick<Mastodon.Status, 'media_attachments' | 'sensitive'>
}
const TimelineAttachment: React.FC<Props> = ({ status }) => {
const { t } = useTranslation('componentTimeline')
const TimelineAttachment = React.memo(
({ status }: Props) => {
const { t } = useTranslation('componentTimeline')
const [sensitiveShown, setSensitiveShown] = useState(status.sensitive)
const onPressBlurView = useCallback(() => {
analytics('timeline_shared_attachment_blurview_press_show')
layoutAnimation()
setSensitiveShown(false)
haptics('Light')
}, [])
const onPressShow = useCallback(() => {
analytics('timeline_shared_attachment_blurview_press_hide')
setSensitiveShown(true)
haptics('Light')
}, [])
const [sensitiveShown, setSensitiveShown] = useState(status.sensitive)
const onPressBlurView = useCallback(() => {
analytics('timeline_shared_attachment_blurview_press_show')
layoutAnimation()
setSensitiveShown(false)
haptics('Light')
}, [])
const onPressShow = useCallback(() => {
analytics('timeline_shared_attachment_blurview_press_hide')
setSensitiveShown(true)
haptics('Light')
}, [])
let imageUrls: Nav.RootStackParamList['Screen-ImagesViewer']['imageUrls'] = []
const navigation = useNavigation()
const navigateToImagesViewer = (imageIndex: number) =>
navigation.navigate('Screen-ImagesViewer', {
imageUrls,
imageIndex
})
const attachments = useMemo(
() =>
status.media_attachments.map((attachment, index) => {
switch (attachment.type) {
case 'image':
imageUrls.push({
url: attachment.url,
preview_url: attachment.preview_url,
remote_url: attachment.remote_url,
width: attachment.meta?.original?.width,
height: attachment.meta?.original?.height,
imageIndex: index
})
return (
<AttachmentImage
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
image={attachment}
navigateToImagesViewer={navigateToImagesViewer}
/>
)
case 'video':
return (
<AttachmentVideo
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
video={attachment}
/>
)
case 'gifv':
return (
<AttachmentVideo
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
video={attachment}
gifv
/>
)
case 'audio':
return (
<AttachmentAudio
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
audio={attachment}
/>
)
default:
return (
<AttachmentUnsupported
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
attachment={attachment}
/>
)
}
}),
[sensitiveShown]
)
let imageUrls: Nav.RootStackParamList['Screen-ImagesViewer']['imageUrls'] = []
const navigation = useNavigation()
const navigateToImagesViewer = (imageIndex: number) =>
navigation.navigate('Screen-ImagesViewer', {
imageUrls,
imageIndex
})
const attachments = useMemo(
() =>
status.media_attachments.map((attachment, index) => {
switch (attachment.type) {
case 'image':
imageUrls.push({
url: attachment.url,
preview_url: attachment.preview_url,
remote_url: attachment.remote_url,
width: attachment.meta?.original?.width,
height: attachment.meta?.original?.height,
imageIndex: index
})
return (
<AttachmentImage
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
image={attachment}
navigateToImagesViewer={navigateToImagesViewer}
/>
)
case 'video':
return (
<AttachmentVideo
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
video={attachment}
/>
)
case 'gifv':
return (
<AttachmentVideo
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
video={attachment}
gifv
/>
)
case 'audio':
return (
<AttachmentAudio
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
audio={attachment}
/>
)
default:
return (
<AttachmentUnsupported
key={index}
total={status.media_attachments.length}
index={index}
sensitiveShown={sensitiveShown}
attachment={attachment}
/>
)
}
}),
[sensitiveShown]
)
return (
<View>
<View style={styles.container} children={attachments} />
return (
<View>
<View style={styles.container} children={attachments} />
{status.sensitive &&
(sensitiveShown ? (
<Pressable style={styles.sensitiveBlur}>
{status.sensitive &&
(sensitiveShown ? (
<Pressable style={styles.sensitiveBlur}>
<Button
type='text'
content={t('shared.attachment.sensitive.button')}
overlay
onPress={onPressBlurView}
/>
</Pressable>
) : (
<Button
type='text'
content={t('shared.attachment.sensitive.button')}
type='icon'
content='EyeOff'
round
overlay
onPress={onPressBlurView}
onPress={onPressShow}
style={{
position: 'absolute',
top: StyleConstants.Spacing.S * 2,
left: StyleConstants.Spacing.S
}}
/>
</Pressable>
) : (
<Button
type='icon'
content='EyeOff'
round
overlay
onPress={onPressShow}
style={{
position: 'absolute',
top: StyleConstants.Spacing.S * 2,
left: StyleConstants.Spacing.S
}}
/>
))}
</View>
)
}
))}
</View>
)
},
() => true
)
const styles = StyleSheet.create({
container: {
@ -166,4 +169,4 @@ const styles = StyleSheet.create({
}
})
export default React.memo(TimelineAttachment, () => true)
export default TimelineAttachment

View File

@ -13,31 +13,28 @@ export interface Props {
navigateToImagesViewer: (imageIndex: number) => void
}
const AttachmentImage: React.FC<Props> = ({
total,
index,
sensitiveShown,
image,
navigateToImagesViewer
}) => {
const onPress = useCallback(() => {
analytics('timeline_shared_attachment_image_press', { id: image.id })
navigateToImagesViewer(index)
}, [])
const AttachmentImage = React.memo(
({ total, index, sensitiveShown, image, navigateToImagesViewer }: Props) => {
const onPress = useCallback(() => {
analytics('timeline_shared_attachment_image_press', { id: image.id })
navigateToImagesViewer(index)
}, [])
return (
<GracefullyImage
hidden={sensitiveShown}
uri={{ original: image.preview_url, remote: image.remote_url }}
blurhash={image.blurhash}
onPress={onPress}
style={[
styles.base,
{ aspectRatio: attachmentAspectRatio({ total, index }) }
]}
/>
)
}
return (
<GracefullyImage
hidden={sensitiveShown}
uri={{ original: image.preview_url, remote: image.remote_url }}
blurhash={image.blurhash}
onPress={onPress}
style={[
styles.base,
{ aspectRatio: attachmentAspectRatio({ total, index }) }
]}
/>
)
},
(prev, next) => prev.sensitiveShown === next.sensitiveShown
)
const styles = StyleSheet.create({
base: {
@ -47,7 +44,4 @@ const styles = StyleSheet.create({
}
})
export default React.memo(
AttachmentImage,
(prev, next) => prev.sensitiveShown === next.sensitiveShown
)
export default AttachmentImage

View File

@ -11,33 +11,36 @@ export interface Props {
account: Mastodon.Account
}
const TimelineAvatar: React.FC<Props> = ({ queryKey, account }) => {
const navigation = useNavigation<
StackNavigationProp<Nav.TabLocalStackParamList>
>()
// Need to fix go back root
const onPress = useCallback(() => {
analytics('timeline_shared_avatar_press', {
page: queryKey && queryKey[1].page
})
queryKey && navigation.push('Tab-Shared-Account', { account })
}, [])
const TimelineAvatar = React.memo(
({ queryKey, account }: Props) => {
const navigation = useNavigation<
StackNavigationProp<Nav.TabLocalStackParamList>
>()
// Need to fix go back root
const onPress = useCallback(() => {
analytics('timeline_shared_avatar_press', {
page: queryKey && queryKey[1].page
})
queryKey && navigation.push('Tab-Shared-Account', { account })
}, [])
return (
<GracefullyImage
onPress={onPress}
uri={{ original: account.avatar_static }}
dimension={{
width: StyleConstants.Avatar.M,
height: StyleConstants.Avatar.M
}}
style={{
borderRadius: 4,
overflow: 'hidden',
marginRight: StyleConstants.Spacing.S
}}
/>
)
}
return (
<GracefullyImage
onPress={onPress}
uri={{ original: account.avatar_static }}
dimension={{
width: StyleConstants.Avatar.M,
height: StyleConstants.Avatar.M
}}
style={{
borderRadius: 4,
overflow: 'hidden',
marginRight: StyleConstants.Spacing.S
}}
/>
)
},
() => true
)
export default React.memo(TimelineAvatar, () => true)
export default TimelineAvatar

View File

@ -10,53 +10,56 @@ export interface Props {
card: Mastodon.Card
}
const TimelineCard: React.FC<Props> = ({ card }) => {
const { theme } = useTheme()
const TimelineCard = React.memo(
({ card }: Props) => {
const { theme } = useTheme()
return (
<Pressable
style={[styles.card, { borderColor: theme.border }]}
onPress={async () => {
analytics('timeline_shared_card_press')
await openLink(card.url)
}}
testID='base'
>
{card.image && (
<GracefullyImage
uri={{ original: card.image }}
blurhash={card.blurhash}
style={styles.left}
imageStyle={styles.image}
/>
)}
<View style={styles.right}>
<Text
numberOfLines={2}
style={[styles.rightTitle, { color: theme.primary }]}
testID='title'
>
{card.title}
</Text>
{card.description ? (
return (
<Pressable
style={[styles.card, { borderColor: theme.border }]}
onPress={async () => {
analytics('timeline_shared_card_press')
await openLink(card.url)
}}
testID='base'
>
{card.image && (
<GracefullyImage
uri={{ original: card.image }}
blurhash={card.blurhash}
style={styles.left}
imageStyle={styles.image}
/>
)}
<View style={styles.right}>
<Text
numberOfLines={2}
style={[styles.rightTitle, { color: theme.primary }]}
testID='title'
>
{card.title}
</Text>
{card.description ? (
<Text
numberOfLines={1}
style={[styles.rightDescription, { color: theme.primary }]}
testID='description'
>
{card.description}
</Text>
) : null}
<Text
numberOfLines={1}
style={[styles.rightDescription, { color: theme.primary }]}
testID='description'
style={[styles.rightLink, { color: theme.secondary }]}
>
{card.description}
{card.url}
</Text>
) : null}
<Text
numberOfLines={1}
style={[styles.rightLink, { color: theme.secondary }]}
>
{card.url}
</Text>
</View>
</Pressable>
)
}
</View>
</Pressable>
)
},
() => true
)
const styles = StyleSheet.create({
card: {
@ -93,4 +96,4 @@ const styles = StyleSheet.create({
}
})
export default React.memo(TimelineCard, () => true)
export default TimelineCard

View File

@ -11,53 +11,56 @@ export interface Props {
disableDetails?: boolean
}
const TimelineContent: React.FC<Props> = ({
status,
numberOfLines,
highlighted = false,
disableDetails = false
}) => {
const { t } = useTranslation('componentTimeline')
const TimelineContent = React.memo(
({
status,
numberOfLines,
highlighted = false,
disableDetails = false
}: Props) => {
const { t } = useTranslation('componentTimeline')
return (
<>
{status.spoiler_text ? (
<>
<View style={{ marginBottom: StyleConstants.Font.Size.M }}>
return (
<>
{status.spoiler_text ? (
<>
<View style={{ marginBottom: StyleConstants.Font.Size.M }}>
<ParseHTML
content={status.spoiler_text}
size={highlighted ? 'L' : 'M'}
emojis={status.emojis}
mentions={status.mentions}
tags={status.tags}
numberOfLines={999}
disableDetails={disableDetails}
/>
</View>
<ParseHTML
content={status.spoiler_text}
content={status.content}
size={highlighted ? 'L' : 'M'}
emojis={status.emojis}
mentions={status.mentions}
tags={status.tags}
numberOfLines={999}
numberOfLines={0}
expandHint={t('shared.content.expandHint')}
disableDetails={disableDetails}
/>
</View>
</>
) : (
<ParseHTML
content={status.content}
size={highlighted ? 'L' : 'M'}
emojis={status.emojis}
mentions={status.mentions}
tags={status.tags}
numberOfLines={0}
expandHint={t('shared.content.expandHint')}
numberOfLines={highlighted ? 999 : numberOfLines}
disableDetails={disableDetails}
/>
</>
) : (
<ParseHTML
content={status.content}
size={highlighted ? 'L' : 'M'}
emojis={status.emojis}
mentions={status.mentions}
tags={status.tags}
numberOfLines={highlighted ? 999 : numberOfLines}
disableDetails={disableDetails}
/>
)}
</>
)
}
)}
</>
)
},
() => true
)
export default React.memo(TimelineContent, () => true)
export default TimelineContent

View File

@ -44,78 +44,81 @@ export interface Props {
conversation: Mastodon.Conversation
}
const HeaderConversation: React.FC<Props> = ({ queryKey, conversation }) => {
const { t } = useTranslation('componentTimeline')
const HeaderConversation = React.memo(
({ queryKey, conversation }: Props) => {
const { t } = useTranslation('componentTimeline')
const queryClient = useQueryClient()
const mutation = useTimelineMutation({
queryClient,
onMutate: true,
onError: (err: any, _, oldData) => {
haptics('Error')
toast({
type: 'error',
message: t('common:toastMessage.error.message', {
function: t(`shared.header.conversation.delete.function`)
}),
...(err.status &&
typeof err.status === 'number' &&
err.data &&
err.data.error &&
typeof err.data.error === 'string' && {
description: err.data.error
const queryClient = useQueryClient()
const mutation = useTimelineMutation({
queryClient,
onMutate: true,
onError: (err: any, _, oldData) => {
haptics('Error')
toast({
type: 'error',
message: t('common:toastMessage.error.message', {
function: t(`shared.header.conversation.delete.function`)
}),
autoHide: false
})
queryClient.setQueryData(queryKey, oldData)
}
})
const { theme } = useTheme()
const actionOnPress = useCallback(() => {
analytics('timeline_conversation_delete_press')
mutation.mutate({
type: 'deleteItem',
source: 'conversations',
queryKey,
id: conversation.id
...(err.status &&
typeof err.status === 'number' &&
err.data &&
err.data.error &&
typeof err.data.error === 'string' && {
description: err.data.error
}),
autoHide: false
})
queryClient.setQueryData(queryKey, oldData)
}
})
}, [])
const actionChildren = useMemo(
() => (
<Icon
name='Trash'
color={theme.secondary}
size={StyleConstants.Font.Size.L}
/>
),
[]
)
const { theme } = useTheme()
return (
<View style={styles.base}>
<View style={styles.nameAndMeta}>
<Names accounts={conversation.accounts} />
<View style={styles.meta}>
{conversation.last_status?.created_at ? (
<HeaderSharedCreated
created_at={conversation.last_status?.created_at}
/>
) : null}
<HeaderSharedMuted muted={conversation.last_status?.muted} />
const actionOnPress = useCallback(() => {
analytics('timeline_conversation_delete_press')
mutation.mutate({
type: 'deleteItem',
source: 'conversations',
queryKey,
id: conversation.id
})
}, [])
const actionChildren = useMemo(
() => (
<Icon
name='Trash'
color={theme.secondary}
size={StyleConstants.Font.Size.L}
/>
),
[]
)
return (
<View style={styles.base}>
<View style={styles.nameAndMeta}>
<Names accounts={conversation.accounts} />
<View style={styles.meta}>
{conversation.last_status?.created_at ? (
<HeaderSharedCreated
created_at={conversation.last_status?.created_at}
/>
) : null}
<HeaderSharedMuted muted={conversation.last_status?.muted} />
</View>
</View>
</View>
<Pressable
style={styles.action}
onPress={actionOnPress}
children={actionChildren}
/>
</View>
)
}
<Pressable
style={styles.action}
onPress={actionOnPress}
children={actionChildren}
/>
</View>
)
},
() => true
)
const styles = StyleSheet.create({
base: {

View File

@ -17,50 +17,49 @@ export interface Props {
status: Mastodon.Status
}
const TimelineHeaderDefault: React.FC<Props> = ({
queryKey,
rootQueryKey,
status
}) => {
const navigation = useNavigation()
const { theme } = useTheme()
const TimelineHeaderDefault = React.memo(
({ queryKey, rootQueryKey, status }: Props) => {
const navigation = useNavigation()
const { theme } = useTheme()
return (
<View style={styles.base}>
<View style={styles.accountAndMeta}>
<HeaderSharedAccount account={status.account} />
<View style={styles.meta}>
<HeaderSharedCreated created_at={status.created_at} />
<HeaderSharedVisibility visibility={status.visibility} />
<HeaderSharedMuted muted={status.muted} />
<HeaderSharedApplication application={status.application} />
return (
<View style={styles.base}>
<View style={styles.accountAndMeta}>
<HeaderSharedAccount account={status.account} />
<View style={styles.meta}>
<HeaderSharedCreated created_at={status.created_at} />
<HeaderSharedVisibility visibility={status.visibility} />
<HeaderSharedMuted muted={status.muted} />
<HeaderSharedApplication application={status.application} />
</View>
</View>
</View>
{queryKey ? (
<Pressable
style={styles.action}
onPress={() =>
navigation.navigate('Screen-Actions', {
queryKey,
rootQueryKey,
status,
url: status.url || status.uri,
type: 'status'
})
}
children={
<Icon
name='MoreHorizontal'
color={theme.secondary}
size={StyleConstants.Font.Size.L}
/>
}
/>
) : null}
</View>
)
}
{queryKey ? (
<Pressable
style={styles.action}
onPress={() =>
navigation.navigate('Screen-Actions', {
queryKey,
rootQueryKey,
status,
url: status.url || status.uri,
type: 'status'
})
}
children={
<Icon
name='MoreHorizontal'
color={theme.secondary}
size={StyleConstants.Font.Size.L}
/>
}
/>
) : null}
</View>
)
},
() => true
)
const styles = StyleSheet.create({
base: {
@ -87,7 +86,4 @@ const styles = StyleSheet.create({
}
})
export default React.memo(
TimelineHeaderDefault,
(prev, next) => prev.status.muted !== next.status.muted
)
export default TimelineHeaderDefault

View File

@ -20,98 +20,98 @@ export interface Props {
notification: Mastodon.Notification
}
const TimelineHeaderNotification: React.FC<Props> = ({
queryKey,
notification
}) => {
const navigation = useNavigation()
const { theme } = useTheme()
const TimelineHeaderNotification = React.memo(
({ queryKey, notification }: Props) => {
const navigation = useNavigation()
const { theme } = useTheme()
const actions = useMemo(() => {
switch (notification.type) {
case 'follow':
return <RelationshipOutgoing id={notification.account.id} />
case 'follow_request':
return <RelationshipIncoming id={notification.account.id} />
default:
if (notification.status) {
return (
<Pressable
style={{
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
paddingBottom: StyleConstants.Spacing.S
}}
onPress={() =>
navigation.navigate('Screen-Actions', {
queryKey,
status: notification.status,
url: notification.status?.url || notification.status?.uri,
type: 'status'
})
}
children={
<Icon
name='MoreHorizontal'
color={theme.secondary}
size={StyleConstants.Font.Size.L}
/>
}
const actions = useMemo(() => {
switch (notification.type) {
case 'follow':
return <RelationshipOutgoing id={notification.account.id} />
case 'follow_request':
return <RelationshipIncoming id={notification.account.id} />
default:
if (notification.status) {
return (
<Pressable
style={{
flex: 1,
flexDirection: 'row',
justifyContent: 'center',
paddingBottom: StyleConstants.Spacing.S
}}
onPress={() =>
navigation.navigate('Screen-Actions', {
queryKey,
status: notification.status,
url: notification.status?.url || notification.status?.uri,
type: 'status'
})
}
children={
<Icon
name='MoreHorizontal'
color={theme.secondary}
size={StyleConstants.Font.Size.L}
/>
}
/>
)
}
}
}, [notification.type])
return (
<View style={styles.base}>
<View
style={{
flex:
notification.type === 'follow' ||
notification.type === 'follow_request'
? 1
: 4
}}
>
<HeaderSharedAccount
account={
notification.status
? notification.status.account
: notification.account
}
{...((notification.type === 'follow' ||
notification.type === 'follow_request') && { withoutName: true })}
/>
<View style={styles.meta}>
<HeaderSharedCreated created_at={notification.created_at} />
{notification.status?.visibility ? (
<HeaderSharedVisibility
visibility={notification.status.visibility}
/>
) : null}
<HeaderSharedMuted muted={notification.status?.muted} />
<HeaderSharedApplication
application={notification.status?.application}
/>
)
}
}
}, [notification.type])
</View>
</View>
return (
<View style={styles.base}>
<View
style={{
flex:
<View
style={[
styles.relationship,
notification.type === 'follow' ||
notification.type === 'follow_request'
? 1
: 4
}}
>
<HeaderSharedAccount
account={
notification.status
? notification.status.account
: notification.account
}
{...((notification.type === 'follow' ||
notification.type === 'follow_request') && { withoutName: true })}
/>
<View style={styles.meta}>
<HeaderSharedCreated created_at={notification.created_at} />
{notification.status?.visibility ? (
<HeaderSharedVisibility
visibility={notification.status.visibility}
/>
) : null}
<HeaderSharedMuted muted={notification.status?.muted} />
<HeaderSharedApplication
application={notification.status?.application}
/>
? { flexShrink: 1 }
: { flex: 1 }
]}
>
{actions}
</View>
</View>
<View
style={[
styles.relationship,
notification.type === 'follow' ||
notification.type === 'follow_request'
? { flexShrink: 1 }
: { flex: 1 }
]}
>
{actions}
</View>
</View>
)
}
)
},
() => true
)
const styles = StyleSheet.create({
base: {
@ -129,4 +129,4 @@ const styles = StyleSheet.create({
}
})
export default React.memo(TimelineHeaderNotification, () => true)
export default TimelineHeaderNotification