Add trending tags in search landing page

This commit is contained in:
xmflsct 2022-12-03 23:49:14 +01:00
parent 8a7e78485d
commit dab09369cb
7 changed files with 135 additions and 79 deletions

View File

@ -7,30 +7,23 @@ const handleError = (error: any) => {
// The request was made and the server responded with a status code // The request was made and the server responded with a status code
// that falls out of the range of 2xx // that falls out of the range of 2xx
console.error( console.error(
ctx.bold(' API instance '), ctx.bold(' API '),
ctx.bold('response'), ctx.bold('response'),
error.response.status, error.response.status,
error?.response.data?.error || error?.response.message || 'Unknown error' error?.response.data?.error || error?.response.message || 'Unknown error'
) )
return Promise.reject({ return Promise.reject({
status: error?.response.status, status: error?.response.status,
message: message: error?.response.data?.error || error?.response.message || 'Unknown error'
error?.response.data?.error ||
error?.response.message ||
'Unknown error'
}) })
} else if (error?.request) { } else if (error?.request) {
// The request was made but no response was received // The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of // `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js // http.ClientRequest in node.js
console.error(ctx.bold(' API instance '), ctx.bold('request'), error) console.error(ctx.bold(' API '), ctx.bold('request'), error)
return Promise.reject() return Promise.reject()
} else { } else {
console.error( console.error(ctx.bold(' API '), ctx.bold('internal'), error?.message)
ctx.bold(' API instance '),
ctx.bold('internal'),
error?.message
)
return Promise.reject() return Promise.reject()
} }
} }

View File

@ -13,10 +13,7 @@ export type Params = {
} }
headers?: { [key: string]: string } headers?: { [key: string]: string }
body?: FormData body?: FormData
extras?: Omit< extras?: Omit<AxiosRequestConfig, 'method' | 'url' | 'params' | 'headers' | 'data'>
AxiosRequestConfig,
'method' | 'url' | 'params' | 'headers' | 'data'
>
} }
export type InstanceResponse<T = unknown> = { export type InstanceResponse<T = unknown> = {
@ -35,9 +32,7 @@ const apiInstance = async <T = unknown>({
}: Params): Promise<InstanceResponse<T>> => { }: Params): Promise<InstanceResponse<T>> => {
const { store } = require('@root/store') const { store } = require('@root/store')
const state = store.getState() as RootState const state = store.getState() as RootState
const instanceActive = state.instances.instances.findIndex( const instanceActive = state.instances.instances.findIndex(instance => instance.active)
instance => instance.active
)
let domain let domain
let token let token
@ -45,21 +40,19 @@ const apiInstance = async <T = unknown>({
domain = state.instances.instances[instanceActive].url domain = state.instances.instances[instanceActive].url
token = state.instances.instances[instanceActive].token token = state.instances.instances[instanceActive].token
} else { } else {
console.warn( console.warn(ctx.bgRed.white.bold(' API ') + ' ' + 'No instance domain is provided')
ctx.bgRed.white.bold(' API ') + ' ' + 'No instance domain is provided'
)
return Promise.reject() return Promise.reject()
} }
console.log( console.log(
ctx.bgGreen.bold(' API instance ') + ctx.bgGreen.bold(' API instance ') +
' ' + ' ' +
domain + domain +
' ' + ' ' +
method + method +
ctx.green(' -> ') + ctx.green(' -> ') +
`/${url}` + `/${url}` +
(params ? ctx.green(' -> ') : ''), (params ? ctx.green(' -> ') : ''),
params ? params : '' params ? params : ''
) )
@ -70,10 +63,7 @@ const apiInstance = async <T = unknown>({
url, url,
params, params,
headers: { headers: {
'Content-Type': 'Content-Type': body && body instanceof FormData ? 'multipart/form-data' : 'application/json',
body && body instanceof FormData
? 'multipart/form-data'
: 'application/json',
Accept: '*/*', Accept: '*/*',
...userAgent, ...userAgent,
...headers, ...headers,
@ -87,10 +77,10 @@ const apiInstance = async <T = unknown>({
.then(response => { .then(response => {
let prev let prev
let next let next
if (response.headers.link) { if (response.headers?.link) {
const headersLinks = li.parse(response.headers.link) const headersLinks = li.parse(response.headers?.link)
prev = headersLinks.prev?.match(/_id=([0-9]*)/)[1] prev = headersLinks.prev?.match(/_id=([0-9]*)/)?.[1]
next = headersLinks.next?.match(/_id=([0-9]*)/)[1] next = headersLinks.next?.match(/_id=([0-9]*)/)?.[1]
} }
return Promise.resolve({ return Promise.resolve({
body: response.data, body: response.data,

View File

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

View File

@ -24,6 +24,11 @@
"version": 3.5, "version": 3.5,
"reference": "https://github.com/mastodon/mastodon/releases/tag/v3.5.0" "reference": "https://github.com/mastodon/mastodon/releases/tag/v3.5.0"
}, },
{
"feature": "trends_new_path",
"version": 3.5,
"reference": "https://github.com/mastodon/mastodon/releases/tag/v3.5.0"
},
{ {
"feature": "follow_tags", "feature": "follow_tags",
"version": 4.0, "version": 4.0,

View File

@ -350,6 +350,9 @@
"statusLink": "$t(shared.search.header.prefix) $t(shared.search.sections.statuses)", "statusLink": "$t(shared.search.header.prefix) $t(shared.search.sections.statuses)",
"accountLink": "$t(shared.search.header.prefix) $t(shared.search.sections.accounts)" "accountLink": "$t(shared.search.header.prefix) $t(shared.search.sections.accounts)"
} }
},
"trending": {
"tags": "Trending tags"
} }
}, },
"sections": { "sections": {

View File

@ -6,10 +6,11 @@ import CustomText from '@components/Text'
import TimelineDefault from '@components/Timeline/Default' import TimelineDefault from '@components/Timeline/Default'
import { TabSharedStackScreenProps } from '@utils/navigation/navigators' import { TabSharedStackScreenProps } from '@utils/navigation/navigators'
import { useSearchQuery } from '@utils/queryHooks/search' import { useSearchQuery } from '@utils/queryHooks/search'
import { useTrendsQuery } from '@utils/queryHooks/trends'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import { debounce } from 'lodash' import { debounce } from 'lodash'
import React, { useCallback, useEffect, useMemo } from 'react' import React, { useEffect } from 'react'
import { Trans, useTranslation } from 'react-i18next' import { Trans, useTranslation } from 'react-i18next'
import { import {
KeyboardAvoidingView, KeyboardAvoidingView,
@ -38,9 +39,6 @@ const TabSharedSearch: React.FC<TabSharedStackScreenProps<'Tab-Shared-Search'>>
} }
: { headerLeft: () => null }), : { headerLeft: () => null }),
headerTitle: () => { headerTitle: () => {
const onChangeText = debounce((text: string) => navigation.setParams({ text }), 1000, {
trailing: true
})
return ( return (
<View <View
style={{ style={{
@ -67,7 +65,10 @@ const TabSharedSearch: React.FC<TabSharedStackScreenProps<'Tab-Shared-Search'>>
paddingLeft: StyleConstants.Spacing.XS paddingLeft: StyleConstants.Spacing.XS
}} }}
autoFocus autoFocus
onChangeText={onChangeText} value={text}
onChangeText={debounce((text: string) => navigation.setParams({ text }), 1000, {
trailing: true
})}
autoCapitalize='none' autoCapitalize='none'
autoCorrect={false} autoCorrect={false}
clearButtonMode='never' clearButtonMode='never'
@ -81,7 +82,9 @@ const TabSharedSearch: React.FC<TabSharedStackScreenProps<'Tab-Shared-Search'>>
) )
} }
}) })
}, [mode]) }, [text, mode])
const trendsTags = useTrendsQuery({ type: 'tags' })
const mapKeyToTranslations = { const mapKeyToTranslations = {
accounts: t('shared.search.sections.accounts'), accounts: t('shared.search.sections.accounts'),
@ -119,21 +122,16 @@ const TabSharedSearch: React.FC<TabSharedStackScreenProps<'Tab-Shared-Search'>>
} }
}) })
const listEmpty = useMemo(() => { const listEmpty = () => {
return ( return (
<View <View style={{ paddingVertical: StyleConstants.Spacing.Global.PagePadding }}>
style={{ {status === 'loading' ? (
marginVertical: StyleConstants.Spacing.Global.PagePadding, <View style={{ flex: 1, alignItems: 'center' }}>
alignItems: 'center' <Circle size={StyleConstants.Font.Size.M * 1.25} color={colors.secondary} />
}} </View>
> ) : (
<View> <>
{status === 'loading' ? ( <View style={{ paddingHorizontal: StyleConstants.Spacing.Global.PagePadding }}>
<View style={{ flex: 1, alignItems: 'center' }}>
<Circle size={StyleConstants.Font.Size.M * 1.25} color={colors.secondary} />
</View>
) : (
<>
<CustomText <CustomText
fontStyle='S' fontStyle='S'
style={{ style={{
@ -148,7 +146,10 @@ const TabSharedSearch: React.FC<TabSharedStackScreenProps<'Tab-Shared-Search'>>
}} }}
/> />
</CustomText> </CustomText>
<CustomText style={[styles.emptyAdvanced, { color: colors.primaryDefault }]}> <CustomText
style={[styles.emptyAdvanced, { color: colors.primaryDefault }]}
fontWeight='Bold'
>
{t('shared.search.empty.advanced.header')} {t('shared.search.empty.advanced.header')}
</CustomText> </CustomText>
<CustomText style={[styles.emptyAdvanced, { color: colors.primaryDefault }]}> <CustomText style={[styles.emptyAdvanced, { color: colors.primaryDefault }]}>
@ -171,25 +172,37 @@ const TabSharedSearch: React.FC<TabSharedStackScreenProps<'Tab-Shared-Search'>>
{' '} {' '}
{t('shared.search.empty.advanced.example.accountLink')} {t('shared.search.empty.advanced.example.accountLink')}
</CustomText> </CustomText>
</> </View>
)}
</View> <CustomText
style={{
color: colors.primaryDefault,
marginTop: StyleConstants.Spacing.M,
paddingHorizontal: StyleConstants.Spacing.Global.PagePadding
}}
fontWeight='Bold'
>
{t('shared.search.empty.trending.tags')}
</CustomText>
<View>
{trendsTags.data?.map((tag, index) => {
const hashtag = tag as Mastodon.Tag
return (
<React.Fragment key={index}>
{index !== 0 ? <ComponentSeparator /> : null}
<ComponentHashtag
hashtag={hashtag}
onPress={() => navigation.setParams({ text: `#${hashtag.name}` })}
/>
</React.Fragment>
)
})}
</View>
</>
)}
</View> </View>
) )
}, [status]) }
const listItem = useCallback(({ item, section }: { item: any; section: any }) => {
switch (section.title) {
case 'accounts':
return <ComponentAccount account={item} />
case 'hashtags':
return <ComponentHashtag hashtag={item} />
case 'statuses':
return <TimelineDefault item={item} disableDetails />
default:
return null
}
}, [])
return ( return (
<KeyboardAvoidingView <KeyboardAvoidingView
@ -198,10 +211,21 @@ const TabSharedSearch: React.FC<TabSharedStackScreenProps<'Tab-Shared-Search'>>
> >
<SectionList <SectionList
style={{ minHeight: '100%' }} style={{ minHeight: '100%' }}
renderItem={listItem} renderItem={({ item, section }: { item: any; section: any }) => {
switch (section.title) {
case 'accounts':
return <ComponentAccount account={item} />
case 'hashtags':
return <ComponentHashtag hashtag={item} />
case 'statuses':
return <TimelineDefault item={item} disableDetails />
default:
return null
}
}}
stickySectionHeadersEnabled stickySectionHeadersEnabled
sections={data || []} sections={data || []}
ListEmptyComponent={listEmpty} ListEmptyComponent={listEmpty()}
keyboardShouldPersistTaps='always' keyboardShouldPersistTaps='always'
renderSectionHeader={({ section: { translation } }) => ( renderSectionHeader={({ section: { translation } }) => (
<View <View

View File

@ -0,0 +1,42 @@
import apiInstance from '@api/instance'
import { store } from '@root/store'
import { checkInstanceFeature } from '@utils/slices/instancesSlice'
import { AxiosError } from 'axios'
import { QueryFunctionContext, useQuery, UseQueryOptions } from 'react-query'
export type QueryKeyTrends = ['Trends', { type: 'tags' | 'statuses' | 'links' }]
const queryFunction = ({ queryKey }: QueryFunctionContext<QueryKeyTrends>) => {
const trendsNewPath = checkInstanceFeature('trends_new_path')(store.getState())
if (!trendsNewPath && queryKey[1].type !== 'tags') {
return []
}
const { type } = queryKey[1]
switch (type) {
case 'tags':
return apiInstance<Mastodon.Tag[]>({
method: 'get',
url: trendsNewPath ? 'trends/tags' : 'trends'
}).then(res => res.body)
case 'statuses':
return apiInstance<Mastodon.Status[]>({
method: 'get',
url: 'trends/tags'
}).then(res => res.body)
}
}
const useTrendsQuery = ({
options,
...queryKeyParams
}: QueryKeyTrends[1] & {
options?: UseQueryOptions<Mastodon.Tag[] | Mastodon.Status[], AxiosError>
}) => {
const queryKey: QueryKeyTrends = ['Trends', { ...queryKeyParams }]
return useQuery(queryKey, queryFunction, options)
}
export { useTrendsQuery }