This commit is contained in:
xmflsct 2023-02-12 14:50:31 +01:00
parent 441090ab28
commit 127978da79
13 changed files with 419 additions and 52 deletions

View File

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

View File

@ -1,9 +1,2 @@
toooting愉快此版本包括以下改进和修复
- 主动获取对话的远程内容
- 自动加载上次我的关注的阅读位置
- 用其它已登陆的账户关注用户
- 可添加举报细节
- 新增暂停自动播放gif动画选项
- 隐藏用户的转嘟
- 下划线高亮正在关注的话题标签
- 支持GoToSocial
- 新增关注远程实例功能

View File

@ -1,6 +1,6 @@
{
"name": "tooot",
"version": "4.8.9",
"version": "4.9.0",
"description": "tooot for Mastodon",
"author": "xmflsct <me@xmflsct.com>",
"license": "GPL-3.0-or-later",

2
src/@types/app.d.ts vendored
View File

@ -3,7 +3,7 @@ declare namespace App {
| 'Following'
| 'Local'
| 'LocalPublic'
| 'Trending'
| 'Explore'
| 'Notifications'
| 'Hashtag'
| 'List'

View File

@ -22,7 +22,7 @@ export interface Props {
switchDisabled?: boolean
switchOnValueChange?: () => void
iconBack?: 'chevron-right' | 'external-link' | 'check'
iconBack?: 'chevron-right' | 'chevron-down' | 'external-link' | 'check'
iconBackColor?: ColorDefinitions
loading?: boolean
@ -66,14 +66,7 @@ const MenuRow: React.FC<Props> = ({
}}
>
<View style={{ flex: 1 }}>
<View
style={{
flex: 1,
flexDirection: 'row',
justifyContent: 'space-between',
marginTop: StyleConstants.Spacing.S
}}
>
<View style={{ flex: 1, flexDirection: 'row', justifyContent: 'space-between' }}>
<View
style={{
flexShrink: 3,

View File

@ -8,7 +8,8 @@
"create": "Create",
"delete": "Delete",
"done": "Done",
"confirm": "Confirm"
"confirm": "Confirm",
"add": "Add"
},
"customEmoji": {
"accessibilityLabel": "Custom emoji {{emoji}}"

View File

@ -7,9 +7,7 @@
"button": "Login",
"information": {
"name": "Name",
"accounts": "Users",
"statuses": "Toots",
"domains": "Universes"
"description": "Description"
},
"disclaimer": {
"base": "Logging in process uses system browser that, your account information won't be visible to tooot app."

View File

@ -11,7 +11,13 @@
"segments": {
"federated": "Federated",
"local": "Local",
"trending": "Trending"
"explore": "Explore"
},
"exploring": {
"heading": "Exploring",
"trending": "Trending",
"followRemote": "Follow remote instance",
"noTitle": "No Title"
}
},
"notifications": {

View File

@ -39,7 +39,7 @@ const ComposePoll: React.FC = () => {
style={{
flex: 1,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: 6,
borderRadius: StyleConstants.Spacing.S,
margin: StyleConstants.Spacing.Global.PagePadding,
borderColor: colors.border
}}

View File

@ -1,26 +1,384 @@
import { HeaderRight } from '@components/Header'
import Button from '@components/Button'
import { HeaderLeft, HeaderRight } from '@components/Header'
import Icon from '@components/Icon'
import CustomText from '@components/Text'
import Timeline from '@components/Timeline'
import SegmentedControl from '@react-native-segmented-control/segmented-control'
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 } from '@utils/storage/actions'
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 { useTheme } from '@utils/styles/ThemeManager'
import { useEffect, useState } from 'react'
import { debounce } from 'lodash'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Dimensions } from 'react-native'
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 Route = ({ route: { key: page } }: { route: any }) => {
const Explore = ({ route: { key: page } }: { route: { key: 'Explore' } }) => {
const { t } = useTranslation(['common', 'componentInstance', 'screenTabs'])
const { colors, mode } = useTheme()
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,
onSuccess: () =>
apiGeneral<Mastodon.Status[]>({
method: 'get',
domain: domain,
url: 'api/v1/timelines/public',
params: { local: 'true', limit: 1 }
})
.then(({ body }) => {
if (Array.isArray(body)) {
setDomainValid(true)
} else {
setDomainValid(false)
}
})
.catch(() => setDomainValid(false))
}
})
const debounceFetch = useCallback(
debounce(() => {
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>
)
return (
<Timeline
flRef={flRef}
queryKey={queryKey}
disableRefresh={!remoteActive}
customProps={{
ListHeaderComponent: (
<View
style={{ backgroundColor: colors.backgroundDefault }}
children={
addingRemote ? (
<View
style={{
paddingHorizontal: StyleConstants.Spacing.Global.PagePadding,
marginTop: StyleConstants.Spacing.S,
borderWidth: 1,
borderRadius: StyleConstants.Spacing.S,
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={instanceQuery.isFetching}
disabled={
!!domain.length
? domainValid === false ||
accountActive === domain ||
!!remotes?.find(r => r.domain === domain)
: true
}
onPress={() => {
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: 2
})}
</Placeholder>
</View>
) : (
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Pressable
style={{
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: StyleConstants.Spacing.XS,
paddingTop: 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)
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' }} />
) : 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' }} />
</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} disableRefresh={page === 'Trending'} />
return <Timeline queryKey={queryKey} />
}
const renderScene = SceneMap({
Local: Route,
LocalPublic: Route,
Trending: Route
Explore
})
const Root: React.FC<NativeStackScreenProps<TabPublicStackParamList, 'Tab-Public-Root'>> = ({
@ -29,7 +387,7 @@ const Root: React.FC<NativeStackScreenProps<TabPublicStackParamList, 'Tab-Public
const { mode } = useTheme()
const { t } = useTranslation('screenTabs')
const segments: StorageGlobal['app.prev_public_segment'][] = ['Local', 'LocalPublic', 'Trending']
const segments: StorageGlobal['app.prev_public_segment'][] = ['Local', 'LocalPublic', 'Explore']
const [segment, setSegment] = useState<number>(
Math.max(
0,
@ -39,7 +397,7 @@ const Root: React.FC<NativeStackScreenProps<TabPublicStackParamList, 'Tab-Public
const [routes] = useState([
{ key: 'Local', title: t('tabs.public.segments.local') },
{ key: 'LocalPublic', title: t('tabs.public.segments.federated') },
{ key: 'Trending', title: t('tabs.public.segments.trending') }
{ key: 'Explore', title: t('tabs.public.segments.explore') }
])
useEffect(() => {
const page = segments[segment]

View File

@ -54,12 +54,16 @@ const useInstanceQuery = (
options?: UseQueryOptions<Mastodon.Instance<any>, AxiosError>
}
) => {
const queryKey: QueryKeyInstance = params?.domain ? ['Instance', params] : ['Instance']
const queryKey: QueryKeyInstance = params?.domain
? ['Instance', { domain: params.domain }]
: ['Instance']
return useQuery(queryKey, queryFunction, {
...params?.options,
staleTime: Infinity,
cacheTime: Infinity,
onSuccess: data => setAccountStorage([{ key: 'version', value: data.version }])
...(!params?.domain && {
onSuccess: data => setAccountStorage([{ key: 'version', value: data.version }])
})
})
}

View File

@ -6,8 +6,10 @@ import {
UseInfiniteQueryOptions,
useMutation
} from '@tanstack/react-query'
import apiGeneral from '@utils/api/general'
import { PagedResponse } from '@utils/api/helpers'
import apiInstance from '@utils/api/instance'
import { appendRemote } from '@utils/helpers/appendRemote'
import { featureCheck } from '@utils/helpers/featureCheck'
import { useNavState } from '@utils/navigation/navigators'
import { queryClient } from '@utils/queryHooks'
@ -24,7 +26,7 @@ export type QueryKeyTimeline = [
'Timeline',
(
| {
page: Exclude<App.Pages, 'Following' | 'Hashtag' | 'List' | 'Toot' | 'Account'>
page: Exclude<App.Pages, 'Following' | 'Hashtag' | 'List' | 'Toot' | 'Account' | 'Explore'>
}
| {
page: 'Following'
@ -50,6 +52,7 @@ export type QueryKeyTimeline = [
toot: Mastodon.Status['id']
remote: boolean
}
| { page: 'Explore'; domain?: string }
)
]
@ -117,12 +120,24 @@ export const queryFunctionTimeline = async ({
params
})
case 'Trending':
return apiInstance<Mastodon.Status[]>({
method: 'get',
url: 'trends/statuses',
params
})
case 'Explore':
if (page.domain) {
return apiGeneral<Mastodon.Status[]>({
method: 'get',
domain: page.domain,
url: 'api/v1/timelines/public',
params: {
...params,
local: 'true'
}
}).then(res => ({ ...res, body: res.body.map(status => appendRemote.status(status)) }))
} else {
return apiInstance<Mastodon.Status[]>({
method: 'get',
url: 'trends/statuses',
params
})
}
case 'Notifications':
const notificationsFilter = getAccountStorage.object('notifications')

View File

@ -5,7 +5,7 @@ export type GlobalV0 = {
// string
'app.expo_token'?: string
'app.prev_tab'?: keyof ScreenTabsStackParamList
'app.prev_public_segment'?: Extract<App.Pages, 'Local' | 'LocalPublic' | 'Trending'>
'app.prev_public_segment'?: Extract<App.Pages, 'Local' | 'LocalPublic' | 'Explore'>
'app.language'?: string
'app.theme'?: 'light' | 'dark' | 'auto'
'app.theme.dark'?: 'lighter' | 'darker'
@ -24,4 +24,10 @@ export type GlobalV0 = {
'account.active'?: string
// object
accounts?: string[]
//// remote
// string
'remote.active'?: string
// object
remotes?: { title: string; domain: string }[]
}