tooot/src/screens/Tabs/Public/Root.tsx

495 lines
19 KiB
TypeScript

import Button from '@components/Button'
import haptics from '@components/haptics'
import { HeaderLeft, HeaderRight } from '@components/Header'
import Icon from '@components/Icon'
import { displayMessage } from '@components/Message'
import CustomText from '@components/Text'
import Timeline from '@components/Timeline'
import SegmentedControl from '@react-native-segmented-control/segmented-control'
import { useScrollToTop } from '@react-navigation/native'
import { NativeStackScreenProps } from '@react-navigation/native-stack'
import apiGeneral from '@utils/api/general'
import { TabPublicStackParamList } from '@utils/navigation/navigators'
import { useInstanceQuery } from '@utils/queryHooks/instance'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { getGlobalStorage, setGlobalStorage, useGlobalStorage } from '@utils/storage/actions'
import { StorageGlobal } from '@utils/storage/global'
import { StyleConstants } from '@utils/styles/constants'
import layoutAnimation from '@utils/styles/layoutAnimation'
import { isLargeDevice } from '@utils/styles/scaling'
import { useTheme } from '@utils/styles/ThemeManager'
import { debounce } from 'lodash'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Dimensions, FlatList, Platform, Pressable, TextInput, View } from 'react-native'
import { SceneMap, TabView } from 'react-native-tab-view'
import { Placeholder, PlaceholderLine } from 'rn-placeholder'
import * as DropdownMenu from 'zeego/dropdown-menu'
const Explore = ({ route: { key: page } }: { route: { key: 'Explore' } }) => {
const { t } = useTranslation(['common', 'componentInstance', 'screenTabs'])
const { colors, mode } = useTheme()
const [loading, setLoading] = useState(false)
const [addingRemote, setAddingRemote] = useState(false)
const [domain, setDomain] = useState<string>('')
const [domainValid, setDomainValid] = useState<boolean>()
const instanceQuery = useInstanceQuery({
domain,
options: {
enabled: false,
retry: false,
keepPreviousData: false,
cacheTime: 1000 * 30,
onError: () => setLoading(false),
onSuccess: () => {
if (!!remotes?.find(r => r.domain === domain)) {
displayMessage({
type: 'warning',
message: t('screenTabs:tabs.public.exploring.errors.existed')
})
}
apiGeneral<Mastodon.Status[]>({
method: 'get',
domain: domain,
url: 'api/v1/timelines/public',
params: { local: 'true', limit: 1 }
})
.then(({ body }) => {
setLoading(false)
if (Array.isArray(body)) {
setDomainValid(true)
} else {
displayMessage({
type: 'danger',
message: t('screenTabs:tabs.public.exploring.errors.notAvailable')
})
setDomainValid(false)
}
})
.catch(() => {
displayMessage({
type: 'danger',
message: t('screenTabs:tabs.public.exploring.errors.notAvailable')
})
setLoading(false)
setDomainValid(false)
})
}
}
})
const debounceFetch = useCallback(
debounce(() => {
setLoading(true)
instanceQuery.refetch()
}, 1000),
[]
)
const [accountActive] = useGlobalStorage.string('account.active')
const [remoteActive, setRemoteActive] = useGlobalStorage.string('remote.active')
const [remotes, setRemotes] = useGlobalStorage.object('remotes')
const flRef = useRef<FlatList>(null)
const queryKey: QueryKeyTimeline = [
'Timeline',
{ page, ...(remoteActive && { domain: remoteActive }) }
]
const info = ({
heading,
content,
lines,
potentialWidth = 6
}: {
heading: string
content?: string
lines?: number
potentialWidth?: number
}) => (
<View style={{ flex: 1, marginTop: StyleConstants.Spacing.M }} accessible>
<CustomText
fontStyle='S'
style={{
marginBottom: StyleConstants.Spacing.XS,
color: colors.primaryDefault
}}
fontWeight='Bold'
children={heading}
/>
{content ? (
<CustomText
fontStyle='M'
style={{ color: colors.primaryDefault }}
children={content}
numberOfLines={lines}
/>
) : (
Array.from({ length: lines || 1 }).map((_, index) => (
<PlaceholderLine
key={index}
width={potentialWidth ? potentialWidth * StyleConstants.Font.Size.M : undefined}
height={StyleConstants.Font.LineHeight.M}
color={colors.shimmerDefault}
noMargin
style={{ borderRadius: 0 }}
/>
))
)}
</View>
)
useScrollToTop(flRef)
return (
<Timeline
flRef={flRef}
queryKey={queryKey}
disableRefresh={!remoteActive}
customProps={{
keyboardDismissMode: 'on-drag',
keyboardShouldPersistTaps: 'always',
stickyHeaderIndices: [0],
stickyHeaderHiddenOnScroll: true,
ListHeaderComponent: (
<View
style={{ backgroundColor: colors.backgroundDefault }}
children={
addingRemote ? (
<View
style={{
paddingHorizontal: StyleConstants.Spacing.Global.PagePadding,
marginTop: StyleConstants.Spacing.S,
borderWidth: 1,
borderRadius: StyleConstants.BorderRadius,
marginHorizontal: StyleConstants.Spacing.Global.PagePadding,
borderColor: colors.border
}}
>
<View
style={{
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: StyleConstants.Spacing.XS
}}
>
<HeaderLeft
onPress={() => {
setDomain('')
setAddingRemote(false)
layoutAnimation().then(() =>
flRef.current?.scrollToOffset({ animated: true, offset: 0 })
)
}}
/>
<CustomText
fontSize='M'
fontWeight='Bold'
style={{ color: colors.primaryDefault }}
children={t('screenTabs:tabs.public.exploring.followRemote')}
/>
<HeaderRight type='text' content='' onPress={() => {}} />
</View>
<View
style={{
flex: 1,
flexDirection: 'row',
alignItems: 'center'
}}
>
<TextInput
accessible={false}
accessibilityRole='none'
style={{
borderBottomWidth: 1,
...StyleConstants.FontStyle.M,
color: colors.primaryDefault,
borderBottomColor:
instanceQuery.isError ||
(!!domain.length && instanceQuery.isFetched && domainValid === false)
? colors.red
: colors.border,
paddingVertical: StyleConstants.Spacing.S,
...(Platform.OS === 'android' && { paddingRight: 0 })
}}
editable={false}
defaultValue='https://'
/>
<TextInput
style={{
flex: 1,
borderBottomWidth: 1,
marginRight: StyleConstants.Spacing.M,
...StyleConstants.FontStyle.M,
color: colors.primaryDefault,
borderBottomColor:
instanceQuery.isError ||
(!!domain.length && instanceQuery.isFetched && domainValid === false)
? colors.red
: colors.border,
paddingVertical: StyleConstants.Spacing.S,
...(Platform.OS === 'android' && { paddingLeft: 0 })
}}
value={domain}
onChangeText={text => {
setDomain(text.replace(/^http(s)?\:\/\//i, ''))
setDomainValid(undefined)
debounceFetch()
}}
autoCapitalize='none'
clearButtonMode='never'
keyboardType='url'
textContentType='URL'
onSubmitEditing={() => instanceQuery.refetch()}
placeholder={' ' + t('componentInstance:server.textInput.placeholder')}
placeholderTextColor={colors.secondary}
returnKeyType='go'
keyboardAppearance={mode}
autoCorrect={false}
spellCheck={false}
autoFocus
/>
<Button
type='text'
content={t('common:buttons.add')}
loading={loading}
disabled={
!!domain.length
? !domainValid ||
accountActive === domain ||
!!remotes?.find(r => r.domain === domain)
: true
}
onPress={() => {
haptics('Success')
setRemotes([
...(remotes || []),
{
title:
instanceQuery.data?.title ||
t('screenTabs:tabs.public.exploring.noTitle'),
domain
}
])
setRemoteActive(domain)
setAddingRemote(false)
layoutAnimation().then(() =>
flRef.current?.scrollToOffset({ animated: true, offset: 0 })
)
}}
/>
</View>
<Placeholder style={{ marginBottom: StyleConstants.Spacing.M }}>
{info({
heading: t('componentInstance:server.information.name'),
content: !!domain.length ? instanceQuery.data?.title : undefined,
potentialWidth: 2
})}
{info({
heading: t('componentInstance:server.information.description'),
content: !!domain.length
? (instanceQuery.data as Mastodon.Instance_V1)?.short_description ||
instanceQuery.data?.description
: undefined,
lines: isLargeDevice ? 1 : 2
})}
</Placeholder>
</View>
) : (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Pressable
style={{
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: StyleConstants.Spacing.XS,
paddingTop: StyleConstants.Spacing.S,
paddingBottom: StyleConstants.Spacing.M
}}
>
<CustomText
fontSize='S'
style={{ color: colors.secondary }}
children={t('screenTabs:tabs.public.exploring.heading')}
/>
<CustomText
fontSize='S'
style={{ color: colors.primaryDefault }}
children={
!remoteActive
? t('screenTabs:tabs.public.exploring.trending').toLocaleLowerCase()
: remotes?.find(r => r.domain === remoteActive)?.title
}
/>
<Icon
name='chevron-down'
color={colors.primaryDefault}
size={StyleConstants.Font.Size.M}
/>
</Pressable>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Group>
<DropdownMenu.CheckboxItem
key={`explore_trending`}
value={!remoteActive ? 'on' : 'off'}
onValueChange={next => {
if (next === 'on') {
setRemoteActive(undefined)
}
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle
children={t('screenTabs:tabs.public.exploring.trending')}
/>
</DropdownMenu.CheckboxItem>
</DropdownMenu.Group>
<DropdownMenu.Group>
{remotes?.map((item: NonNullable<StorageGlobal['remotes']>[0], index) => (
<DropdownMenu.CheckboxItem
key={`explore_${index}`}
value={
index === remotes?.findIndex(r => r.domain === remoteActive)
? 'on'
: 'off'
}
onValueChange={next => {
if (next === 'on') {
setRemoteActive(item.domain)
} else if (next === 'off') {
const nextRemotes = remotes?.filter(r => r.domain !== item.domain)
haptics('Light')
setRemotes(nextRemotes)
setRemoteActive(nextRemotes.at(-1)?.domain)
}
}}
>
<DropdownMenu.ItemIndicator />
<DropdownMenu.ItemTitle children={item.title} />
<DropdownMenu.ItemSubtitle children={item.domain} />
{index === remotes?.findIndex(r => r.domain === remoteActive) ? (
<DropdownMenu.ItemIcon
ios={{ name: 'trash' }}
androidIconName='ic_menu_delete'
/>
) : null}
</DropdownMenu.CheckboxItem>
))}
<DropdownMenu.Item
key='explore_add'
onSelect={() => {
setDomain('')
setAddingRemote(true)
layoutAnimation().then(() =>
flRef.current?.scrollToOffset({ animated: true, offset: 0 })
)
}}
>
<DropdownMenu.ItemTitle
children={t('screenTabs:tabs.public.exploring.followRemote')}
/>
<DropdownMenu.ItemIcon ios={{ name: 'plus' }} androidIconName='ic_menu_add' />
</DropdownMenu.Item>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
)
}
/>
)
}}
/>
)
}
const Route = ({ route: { key: page } }: { route: { key: any } }) => {
const queryKey: QueryKeyTimeline = ['Timeline', { page }]
return <Timeline queryKey={queryKey} />
}
const renderScene = SceneMap({
Local: Route,
LocalPublic: Route,
Explore
})
const Root: React.FC<NativeStackScreenProps<TabPublicStackParamList, 'Tab-Public-Root'>> = ({
navigation
}) => {
const { mode } = useTheme()
const { t } = useTranslation('screenTabs')
const segments: StorageGlobal['app.prev_public_segment'][] = ['Local', 'LocalPublic', 'Explore']
const [segment, setSegment] = useState<number>(
Math.max(
0,
segments.findIndex(segment => segment === getGlobalStorage.string('app.prev_public_segment'))
)
)
const [routes] = useState([
{ key: 'Local', title: t('tabs.public.segments.local') },
{ key: 'LocalPublic', title: t('tabs.public.segments.federated') },
{ key: 'Explore', title: t('tabs.public.segments.explore') }
])
const [remoteActive] = useGlobalStorage.string('remote.active')
useEffect(() => {
const page = segments[segment]
page &&
navigation.setParams({
queryKey: [
'Timeline',
{ page, ...(page === 'Explore' && remoteActive && { domain: remoteActive }) }
]
})
}, [segment, remoteActive])
useEffect(() => {
navigation.setOptions({
headerTitle: () => (
// @ts-ignore
<SegmentedControl
appearance={mode}
values={routes.map(({ title }) => title)}
selectedIndex={segment}
onChange={({ nativeEvent }: any) => {
setSegment(nativeEvent.selectedSegmentIndex)
setGlobalStorage('app.prev_public_segment', segments[nativeEvent.selectedSegmentIndex])
}}
style={{ flexBasis: '65%' }}
/>
),
headerRight: () => (
<HeaderRight
accessibilityLabel={t('common.search.accessibilityLabel')}
accessibilityHint={t('common.search.accessibilityHint')}
content='search'
onPress={() => navigation.navigate('Tab-Shared-Search')}
/>
)
})
}, [mode, segment])
return (
<TabView
lazy
swipeEnabled
renderScene={renderScene}
renderTabBar={() => null}
onIndexChange={index => {
setSegment(index)
setGlobalStorage('app.prev_public_segment', segments[index])
}}
navigationState={{ index: segment, routes }}
initialLayout={{ width: Dimensions.get('window').width }}
/>
)
}
export default Root