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

Continue refine remote logic #638

This commit is contained in:
xmflsct 2023-01-03 23:57:23 +01:00
parent b067b9bdb1
commit 0bcd0c1725
46 changed files with 548 additions and 531 deletions

View File

@ -89,7 +89,7 @@
"react-native-tab-view": "^3.3.4", "react-native-tab-view": "^3.3.4",
"react-redux": "^8.0.5", "react-redux": "^8.0.5",
"rn-placeholder": "^3.0.3", "rn-placeholder": "^3.0.3",
"valid-url": "^1.0.9", "url-parse": "^1.5.10",
"zeego": "^1.0.2" "zeego": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
@ -104,7 +104,7 @@
"@types/react-dom": "^18.0.10", "@types/react-dom": "^18.0.10",
"@types/react-native": "^0.70.8", "@types/react-native": "^0.70.8",
"@types/react-native-share-menu": "^5.0.2", "@types/react-native-share-menu": "^5.0.2",
"@types/valid-url": "^1.0.3", "@types/url-parse": "^1",
"babel-plugin-module-resolver": "^4.1.0", "babel-plugin-module-resolver": "^4.1.0",
"babel-plugin-transform-remove-console": "^6.9.4", "babel-plugin-transform-remove-console": "^6.9.4",
"chalk": "^4.1.2", "chalk": "^4.1.2",

View File

@ -3,8 +3,9 @@ import * as Sentry from '@sentry/react-native'
import { QueryClientProvider } from '@tanstack/react-query' import { QueryClientProvider } from '@tanstack/react-query'
import AccessibilityManager from '@utils/accessibility/AccessibilityManager' import AccessibilityManager from '@utils/accessibility/AccessibilityManager'
import getLanguage from '@utils/helpers/getLanguage' import getLanguage from '@utils/helpers/getLanguage'
import queryClient from '@utils/queryHooks' import { queryClient } from '@utils/queryHooks'
import audio from '@utils/startup/audio' import audio from '@utils/startup/audio'
import { dev } from '@utils/startup/dev'
import log from '@utils/startup/log' import log from '@utils/startup/log'
import netInfo from '@utils/startup/netInfo' import netInfo from '@utils/startup/netInfo'
import push from '@utils/startup/push' import push from '@utils/startup/push'
@ -34,6 +35,7 @@ Platform.select({
android: LogBox.ignoreLogs(['Setting a timer for a long period of time']) android: LogBox.ignoreLogs(['Setting a timer for a long period of time'])
}) })
dev()
sentry() sentry()
audio() audio()
push() push()

View File

@ -19,7 +19,6 @@ import {
View View
} from 'react-native' } from 'react-native'
import FastImage from 'react-native-fast-image' import FastImage from 'react-native-fast-image'
import validUrl from 'valid-url'
import EmojisContext from './Context' import EmojisContext from './Context'
const EmojisList = () => { const EmojisList = () => {
@ -68,7 +67,6 @@ const EmojisList = () => {
> >
{item.map(emoji => { {item.map(emoji => {
const uri = reduceMotionEnabled ? emoji.static_url : emoji.url const uri = reduceMotionEnabled ? emoji.static_url : emoji.url
if (validUrl.isHttpsUri(uri)) {
return ( return (
<Pressable <Pressable
key={emoji.shortcode} key={emoji.shortcode}
@ -134,17 +132,12 @@ const EmojisList = () => {
accessibilityLabel={t('common:customEmoji.accessibilityLabel', { accessibilityLabel={t('common:customEmoji.accessibilityLabel', {
emoji: emoji.shortcode emoji: emoji.shortcode
})} })}
accessibilityHint={t( accessibilityHint={t('screenCompose:content.root.footer.emojis.accessibilityHint')}
'screenCompose:content.root.footer.emojis.accessibilityHint'
)}
source={{ uri }} source={{ uri }}
style={{ width: 32, height: 32 }} style={{ width: 32, height: 32 }}
/> />
</Pressable> </Pressable>
) )
} else {
return null
}
})} })}
</View> </View>
) )

View File

@ -5,7 +5,7 @@ import apiGeneral from '@utils/api/general'
import browserPackage from '@utils/helpers/browserPackage' import browserPackage from '@utils/helpers/browserPackage'
import { featureCheck } from '@utils/helpers/featureCheck' import { featureCheck } from '@utils/helpers/featureCheck'
import { TabMeStackNavigationProp } from '@utils/navigation/navigators' import { TabMeStackNavigationProp } from '@utils/navigation/navigators'
import queryClient from '@utils/queryHooks' import { queryClient } from '@utils/queryHooks'
import { redirectUri, useAppsMutation } from '@utils/queryHooks/apps' import { redirectUri, useAppsMutation } from '@utils/queryHooks/apps'
import { useInstanceQuery } from '@utils/queryHooks/instance' import { useInstanceQuery } from '@utils/queryHooks/instance'
import { storage } from '@utils/storage' import { storage } from '@utils/storage'
@ -19,7 +19,6 @@ import {
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 * as AuthSession from 'expo-auth-session' import * as AuthSession from 'expo-auth-session'
import * as Random from 'expo-random'
import * as WebBrowser from 'expo-web-browser' import * as WebBrowser from 'expo-web-browser'
import { debounce } from 'lodash' import { debounce } from 'lodash'
import React, { RefObject, useCallback, useState } from 'react' import React, { RefObject, useCallback, useState } from 'react'
@ -27,7 +26,7 @@ import { Trans, useTranslation } from 'react-i18next'
import { Alert, Image, KeyboardAvoidingView, Platform, TextInput, View } from 'react-native' import { Alert, Image, KeyboardAvoidingView, Platform, TextInput, View } from 'react-native'
import { ScrollView } from 'react-native-gesture-handler' import { ScrollView } from 'react-native-gesture-handler'
import { MMKV } from 'react-native-mmkv' import { MMKV } from 'react-native-mmkv'
import validUrl from 'valid-url' import parse from 'url-parse'
import CustomText from '../Text' import CustomText from '../Text'
export interface Props { export interface Props {
@ -50,7 +49,7 @@ const ComponentInstance: React.FC<Props> = ({
const whitelisted: boolean = const whitelisted: boolean =
!!domain.length && !!domain.length &&
!!errorCode && !!errorCode &&
!!validUrl.isHttpsUri(`https://${domain}`) && !!(parse(`https://${domain}/`).hostname === domain) &&
errorCode === 401 errorCode === 401
const instanceQuery = useInstanceQuery({ const instanceQuery = useInstanceQuery({

View File

@ -7,7 +7,6 @@ import { useTheme } from '@utils/styles/ThemeManager'
import React from 'react' import React from 'react'
import { Platform, TextStyle } from 'react-native' import { Platform, TextStyle } from 'react-native'
import FastImage from 'react-native-fast-image' import FastImage from 'react-native-fast-image'
import validUrl from 'valid-url'
const regexEmoji = new RegExp(/(:[A-Za-z0-9_]+:)/) const regexEmoji = new RegExp(/(:[A-Za-z0-9_]+:)/)
@ -72,12 +71,11 @@ const ParseEmojis: React.FC<Props> = ({
const uri = reduceMotionEnabled const uri = reduceMotionEnabled
? emojis[emojiIndex].static_url ? emojis[emojiIndex].static_url
: emojis[emojiIndex].url : emojis[emojiIndex].url
if (validUrl.isHttpsUri(uri)) {
return ( return (
<CustomText key={emojiShortcode + i}> <CustomText key={emojiShortcode + i}>
{i === 0 ? ' ' : undefined} {i === 0 ? ' ' : undefined}
<FastImage <FastImage
source={{ uri }} source={{ uri: uri.trim() }}
style={{ style={{
width: adaptedFontsize, width: adaptedFontsize,
height: adaptedFontsize, height: adaptedFontsize,
@ -86,9 +84,6 @@ const ParseEmojis: React.FC<Props> = ({
/> />
</CustomText> </CustomText>
) )
} else {
return null
}
} }
} else { } else {
return <CustomText key={i}>{str}</CustomText> return <CustomText key={i}>{str}</CustomText>

View File

@ -3,9 +3,10 @@ import GracefullyImage from '@components/GracefullyImage'
import openLink from '@components/openLink' import openLink from '@components/openLink'
import CustomText from '@components/Text' import CustomText from '@components/Text'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { matchAccount, matchStatus } from '@utils/helpers/urlMatcher' import { StackNavigationProp } from '@react-navigation/stack'
import { urlMatcher } from '@utils/helpers/urlMatcher'
import { TabLocalStackParamList } from '@utils/navigation/navigators'
import { useAccountQuery } from '@utils/queryHooks/account' import { useAccountQuery } from '@utils/queryHooks/account'
import { useSearchQuery } from '@utils/queryHooks/search'
import { useStatusQuery } from '@utils/queryHooks/status' import { useStatusQuery } from '@utils/queryHooks/status'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
@ -20,51 +21,20 @@ const TimelineCard: React.FC = () => {
if (!status || !status.card) return null if (!status || !status.card) return null
const { colors } = useTheme() const { colors } = useTheme()
const navigation = useNavigation() const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const isStatus = matchStatus(status.card.url) const match = urlMatcher(status.card.url)
const [foundStatus, setFoundStatus] = useState<Mastodon.Status>() const [foundStatus, setFoundStatus] = useState<Mastodon.Status>()
const isAccount = matchAccount(status.card.url)
const [foundAccount, setFoundAccount] = useState<Mastodon.Account>() const [foundAccount, setFoundAccount] = useState<Mastodon.Account>()
const searchQuery = useSearchQuery({
type: (() => {
if (isStatus) return 'statuses'
if (isAccount) return 'accounts'
})(),
term: (() => {
if (isStatus) {
if (isStatus.sameInstance) {
return
} else {
return status.card.url
}
}
if (isAccount) {
if (isAccount.sameInstance) {
if (isAccount.style === 'default') {
return
} else {
return isAccount.username
}
} else {
return status.card.url
}
}
})(),
limit: 1,
options: { enabled: false }
})
const statusQuery = useStatusQuery({ const statusQuery = useStatusQuery({
id: isStatus?.id || '', status: match?.status ? { ...match.status, uri: status.card.url } : undefined,
options: { enabled: false } options: { enabled: false }
}) })
useEffect(() => { useEffect(() => {
if (isStatus) { if (match?.status) {
setLoading(true) setLoading(true)
if (isStatus.sameInstance) {
statusQuery statusQuery
.refetch() .refetch()
.then(res => { .then(res => {
@ -72,28 +42,16 @@ const TimelineCard: React.FC = () => {
setLoading(false) setLoading(false)
}) })
.catch(() => setLoading(false)) .catch(() => setLoading(false))
} else {
searchQuery
.refetch()
.then(res => {
const status = (res.data as any)?.statuses?.[0]
status && setFoundStatus(status)
setLoading(false)
})
.catch(() => setLoading(false))
}
} }
}, []) }, [])
const accountQuery = useAccountQuery({ const accountQuery = useAccountQuery({
account: account: match?.account ? { ...match?.account, url: status.card.url } : undefined,
isAccount?.style === 'default' ? { id: isAccount.id, url: status.card.url } : undefined,
options: { enabled: false } options: { enabled: false }
}) })
useEffect(() => { useEffect(() => {
if (isAccount) { if (match?.account) {
setLoading(true) setLoading(true)
if (isAccount.sameInstance && isAccount.style === 'default') {
accountQuery accountQuery
.refetch() .refetch()
.then(res => { .then(res => {
@ -101,16 +59,6 @@ const TimelineCard: React.FC = () => {
setLoading(false) setLoading(false)
}) })
.catch(() => setLoading(false)) .catch(() => setLoading(false))
} else {
searchQuery
.refetch()
.then(res => {
const account = (res.data as any)?.accounts?.[0]
account && setFoundAccount(account)
setLoading(false)
})
.catch(() => setLoading(false))
}
} }
}, []) }, [])
@ -129,10 +77,10 @@ const TimelineCard: React.FC = () => {
</View> </View>
) )
} }
if (isStatus && foundStatus) { if (match?.status && foundStatus) {
return <TimelineDefault item={foundStatus} disableDetails disableOnPress /> return <TimelineDefault item={foundStatus} disableDetails disableOnPress />
} }
if (isAccount && foundAccount) { if (match?.account && foundAccount) {
return <ComponentAccount account={foundAccount} /> return <ComponentAccount account={foundAccount} />
} }
return ( return (
@ -198,7 +146,18 @@ const TimelineCard: React.FC = () => {
overflow: 'hidden', overflow: 'hidden',
borderColor: colors.border borderColor: colors.border
}} }}
onPress={async () => status.card && (await openLink(status.card.url, navigation))} onPress={async () => {
if (match?.status && foundStatus) {
navigation.push('Tab-Shared-Toot', { toot: foundStatus })
return
}
if (match?.account && foundAccount) {
navigation.push('Tab-Shared-Account', { account: foundAccount })
return
}
status.card?.url && (await openLink(status.card.url, navigation))
}}
children={cardContent()} children={cardContent()}
/> />
) )

View File

@ -19,7 +19,7 @@ const TimelineFeedback = () => {
const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>() const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
const { data } = useStatusHistory({ const { data } = useStatusHistory({
id: status.id, status,
options: { enabled: status.edited_at !== undefined } options: { enabled: status.edited_at !== undefined }
}) })
@ -82,7 +82,7 @@ const TimelineFeedback = () => {
style={[styles.text, { marginRight: 0, color: colors.blue }]} style={[styles.text, { marginRight: 0, color: colors.blue }]}
onPress={() => onPress={() =>
navigation.push('Tab-Shared-History', { navigation.push('Tab-Shared-History', {
id: status.id, status,
detectedLanguage: detectedLanguage?.current || status.language || '' detectedLanguage: detectedLanguage?.current || status.language || ''
}) })
} }

View File

@ -1,6 +1,6 @@
import CustomText from '@components/Text' import CustomText from '@components/Text'
import queryClient from '@utils/queryHooks'
import removeHTML from '@utils/helpers/removeHTML' import removeHTML from '@utils/helpers/removeHTML'
import { queryClient } from '@utils/queryHooks'
import { QueryKeyFilters } from '@utils/queryHooks/filters' import { QueryKeyFilters } from '@utils/queryHooks/filters'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'

View File

@ -73,13 +73,8 @@ const HeaderConversation = ({ conversation }: Props) => {
marginBottom: StyleConstants.Spacing.S marginBottom: StyleConstants.Spacing.S
}} }}
> >
{conversation.last_status?.created_at ? ( {conversation.last_status?.created_at ? <HeaderSharedCreated /> : null}
<HeaderSharedCreated <HeaderSharedMuted />
created_at={conversation.last_status?.created_at}
edited_at={conversation.last_status?.edited_at}
/>
) : null}
<HeaderSharedMuted muted={conversation.last_status?.muted} />
</View> </View>
</View> </View>

View File

@ -17,7 +17,7 @@ import HeaderSharedReplies from './HeaderShared/Replies'
import HeaderSharedVisibility from './HeaderShared/Visibility' import HeaderSharedVisibility from './HeaderShared/Visibility'
const TimelineHeaderDefault: React.FC = () => { const TimelineHeaderDefault: React.FC = () => {
const { queryKey, rootQueryKey, status, highlighted, disableDetails, rawContent, isRemote } = const { queryKey, rootQueryKey, status, disableDetails, rawContent, isRemote } =
useContext(StatusContext) useContext(StatusContext)
if (!status) return null if (!status) return null
@ -66,15 +66,11 @@ const TimelineHeaderDefault: React.FC = () => {
style={{ marginRight: StyleConstants.Spacing.S }} style={{ marginRight: StyleConstants.Spacing.S }}
/> />
) : null} ) : null}
<HeaderSharedCreated <HeaderSharedCreated />
created_at={status.created_at} <HeaderSharedVisibility />
edited_at={status.edited_at} <HeaderSharedMuted />
highlighted={highlighted}
/>
<HeaderSharedVisibility visibility={status.visibility} />
<HeaderSharedMuted muted={status.muted} />
<HeaderSharedReplies /> <HeaderSharedReplies />
<HeaderSharedApplication application={status.application} /> <HeaderSharedApplication />
</View> </View>
</View> </View>

View File

@ -146,15 +146,10 @@ const TimelineHeaderNotification: React.FC<Props> = ({ notification }) => {
marginBottom: StyleConstants.Spacing.S marginBottom: StyleConstants.Spacing.S
}} }}
> >
<HeaderSharedCreated <HeaderSharedCreated />
created_at={notification.status?.created_at || notification.created_at} {notification.status?.visibility ? <HeaderSharedVisibility /> : null}
edited_at={notification.status?.edited_at} <HeaderSharedMuted />
/> <HeaderSharedApplication />
{notification.status?.visibility ? (
<HeaderSharedVisibility visibility={notification.status.visibility} />
) : null}
<HeaderSharedMuted muted={notification.status?.muted} />
<HeaderSharedApplication application={notification.status?.application} />
</View> </View>
</View> </View>

View File

@ -2,32 +2,31 @@ import openLink from '@components/openLink'
import CustomText from '@components/Text' import CustomText from '@components/Text'
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 React from 'react' import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import StatusContext from '../Context'
export interface Props { const HeaderSharedApplication: React.FC = () => {
application?: Mastodon.Application const { status } = useContext(StatusContext)
}
const HeaderSharedApplication: React.FC<Props> = ({ application }) => {
const { colors } = useTheme() const { colors } = useTheme()
const { t } = useTranslation('componentTimeline') const { t } = useTranslation('componentTimeline')
return application && application.name !== 'Web' ? ( return status?.application?.name && status.application.name !== 'Web' ? (
<CustomText <CustomText
fontStyle='S' fontStyle='S'
accessibilityRole='link' accessibilityRole='link'
onPress={async () => { onPress={async () => {
application.website && (await openLink(application.website)) status.application?.website && (await openLink(status.application.website))
}} }}
style={{ style={{
flex: 1,
marginLeft: StyleConstants.Spacing.S, marginLeft: StyleConstants.Spacing.S,
color: colors.secondary color: colors.secondary
}} }}
numberOfLines={1} numberOfLines={1}
> >
{t('shared.header.shared.application', { {t('shared.header.shared.application', {
application: application.name application: status.application.name
})} })}
</CustomText> </CustomText>
) : null ) : null

View File

@ -3,21 +3,23 @@ import RelativeTime from '@components/RelativeTime'
import CustomText from '@components/Text' import CustomText from '@components/Text'
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 React from 'react' import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { FormattedDate } from 'react-intl' import { FormattedDate } from 'react-intl'
import StatusContext from '../Context'
export interface Props { export interface Props {
created_at: Mastodon.Status['created_at'] | number created_at?: Mastodon.Status['created_at'] | number
edited_at?: Mastodon.Status['edited_at']
highlighted?: boolean
} }
const HeaderSharedCreated: React.FC<Props> = ({ created_at, edited_at, highlighted = false }) => { const HeaderSharedCreated: React.FC<Props> = ({ created_at }) => {
const { status, highlighted } = useContext(StatusContext)
const { t } = useTranslation('componentTimeline') const { t } = useTranslation('componentTimeline')
const { colors } = useTheme() const { colors } = useTheme()
const actualTime = edited_at || created_at if (!status) return null
const actualTime = created_at || status.edited_at || status.created_at
return ( return (
<> <>
@ -30,7 +32,7 @@ const HeaderSharedCreated: React.FC<Props> = ({ created_at, edited_at, highlight
<RelativeTime time={actualTime} /> <RelativeTime time={actualTime} />
)} )}
</CustomText> </CustomText>
{edited_at ? ( {status.edited_at && !highlighted ? (
<Icon <Icon
accessibilityLabel={t('shared.header.shared.edited.accessibilityLabel')} accessibilityLabel={t('shared.header.shared.edited.accessibilityLabel')}
name='Edit' name='Edit'

View File

@ -1,18 +1,16 @@
import Icon from '@components/Icon' import Icon from '@components/Icon'
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 React from 'react' import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import StatusContext from '../Context'
export interface Props { const HeaderSharedMuted: React.FC = () => {
muted?: Mastodon.Status['muted'] const { status } = useContext(StatusContext)
}
const HeaderSharedMuted: React.FC<Props> = ({ muted }) => {
const { t } = useTranslation('componentTimeline') const { t } = useTranslation('componentTimeline')
const { colors } = useTheme() const { colors } = useTheme()
return muted ? ( return status?.muted ? (
<Icon <Icon
accessibilityLabel={t('shared.header.shared.muted.accessibilityLabel')} accessibilityLabel={t('shared.header.shared.muted.accessibilityLabel')}
name='VolumeX' name='VolumeX'

View File

@ -1,19 +1,17 @@
import Icon from '@components/Icon' import Icon from '@components/Icon'
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 React from 'react' import React, { useContext } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { StyleSheet } from 'react-native' import { StyleSheet } from 'react-native'
import StatusContext from '../Context'
export interface Props { const HeaderSharedVisibility: React.FC = () => {
visibility: Mastodon.Status['visibility'] const { status } = useContext(StatusContext)
}
const HeaderSharedVisibility: React.FC<Props> = ({ visibility }) => {
const { t } = useTranslation('componentTimeline') const { t } = useTranslation('componentTimeline')
const { colors } = useTheme() const { colors } = useTheme()
switch (visibility) { switch (status?.visibility) {
case 'unlisted': case 'unlisted':
return ( return (
<Icon <Icon

View File

@ -47,7 +47,7 @@ const menuAccount = ({
setEnabled(true) setEnabled(true)
} }
}, [openChange, enabled]) }, [openChange, enabled])
const { data: fetchedAccount } = useAccountQuery({ account, options: { enabled } }) const { data: fetchedAccount } = useAccountQuery({ account, _local: true, options: { enabled } })
const actualAccount = status?._remote ? fetchedAccount : account const actualAccount = status?._remote ? fetchedAccount : account
const { data, isFetched } = useRelationshipQuery({ const { data, isFetched } = useRelationshipQuery({
id: actualAccount?.id, id: actualAccount?.id,

View File

@ -1,10 +1,10 @@
import { displayMessage } from '@components/Message' import { displayMessage } from '@components/Message'
import { useQueryClient } from '@tanstack/react-query' import { useQueryClient } from '@tanstack/react-query'
import { getHost } from '@utils/helpers/urlMatcher'
import { QueryKeyTimeline, useTimelineMutation } from '@utils/queryHooks/timeline' import { QueryKeyTimeline, useTimelineMutation } from '@utils/queryHooks/timeline'
import { getAccountStorage } from '@utils/storage/actions' import { getAccountStorage } from '@utils/storage/actions'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { Alert } from 'react-native' import { Alert } from 'react-native'
import parse from 'url-parse'
const menuInstance = ({ const menuInstance = ({
status, status,
@ -35,9 +35,9 @@ const menuInstance = ({
const menus: ContextMenu[][] = [] const menus: ContextMenu[][] = []
const instance = getHost(status.uri) const instance = parse(status.uri).hostname
if (instance === getAccountStorage.string('auth.domain')) { if (instance !== getAccountStorage.string('auth.domain')) {
menus.push([ menus.push([
{ {
key: 'instance-block', key: 'instance-block',

View File

@ -1,5 +1,5 @@
import { ActionSheetOptions } from '@expo/react-native-action-sheet' import { ActionSheetOptions } from '@expo/react-native-action-sheet'
import queryClient from '@utils/queryHooks' import { queryClient } from '@utils/queryHooks'
import { QueryKeyInstance } from '@utils/queryHooks/instance' import { QueryKeyInstance } from '@utils/queryHooks/instance'
import i18next from 'i18next' import i18next from 'i18next'
import { Asset, launchImageLibrary } from 'react-native-image-picker' import { Asset, launchImageLibrary } from 'react-native-image-picker'

View File

@ -1,12 +1,13 @@
import apiInstance from '@utils/api/instance'
import browserPackage from '@utils/helpers/browserPackage' import browserPackage from '@utils/helpers/browserPackage'
import { matchAccount, matchStatus } from '@utils/helpers/urlMatcher' import { urlMatcher } from '@utils/helpers/urlMatcher'
import navigationRef from '@utils/navigation/navigationRef' import navigationRef from '@utils/navigation/navigationRef'
import { SearchResult } from '@utils/queryHooks/search' import { queryClient } from '@utils/queryHooks'
import { QueryKeyAccount } from '@utils/queryHooks/account'
import { searchLocalAccount, searchLocalStatus } from '@utils/queryHooks/search'
import { QueryKeyStatus } from '@utils/queryHooks/status'
import { getGlobalStorage } from '@utils/storage/actions' import { getGlobalStorage } from '@utils/storage/actions'
import * as Linking from 'expo-linking' import * as Linking from 'expo-linking'
import * as WebBrowser from 'expo-web-browser' import * as WebBrowser from 'expo-web-browser'
import validUrl from 'valid-url'
export let loadingLink = false export let loadingLink = false
@ -15,7 +16,7 @@ const openLink = async (url: string, navigation?: any) => {
return return
} }
const handleNavigation = (page: 'Tab-Shared-Toot' | 'Tab-Shared-Account', options: {}) => { const handleNavigation = (page: 'Tab-Shared-Toot' | 'Tab-Shared-Account', options: any) => {
if (navigation) { if (navigation) {
navigation.push(page, options) navigation.push(page, options)
} else { } else {
@ -24,84 +25,80 @@ const openLink = async (url: string, navigation?: any) => {
} }
} }
const match = urlMatcher(url)
// If a tooot can be found // If a tooot can be found
const isStatus = matchStatus(url) if (match?.status?.id) {
if (isStatus) {
if (isStatus.sameInstance) {
handleNavigation('Tab-Shared-Toot', { toot: { id: isStatus.id } })
return
}
loadingLink = true loadingLink = true
let response let response: Mastodon.Status | undefined = undefined
try {
response = await apiInstance<SearchResult>({ const queryKey: QueryKeyStatus = [
version: 'v2', 'Status',
method: 'get', { id: match.status.id, uri: url, _remote: match.status._remote }
url: 'search', ]
params: { type: 'statuses', q: url, limit: 1, resolve: true } const cache = queryClient.getQueryData<Mastodon.Status>(queryKey)
})
} catch {} if (cache) {
if (response && response.body && response.body.statuses.length) { handleNavigation('Tab-Shared-Toot', { toot: cache })
handleNavigation('Tab-Shared-Toot', {
toot: response.body.statuses[0]
})
loadingLink = false loadingLink = false
return return
} else {
try {
response = await searchLocalStatus(url)
} catch {}
if (response) {
handleNavigation('Tab-Shared-Toot', { toot: response })
loadingLink = false
return
}
} }
} }
// If an account can be found // If an account can be found
const isAccount = matchAccount(url) if (match?.account) {
if (isAccount) { if (!match.account._remote && match.account.id) {
if (isAccount.sameInstance) { handleNavigation('Tab-Shared-Account', { account: match.account.id })
if (isAccount.style === 'default' && isAccount.id) {
handleNavigation('Tab-Shared-Account', { account: isAccount })
return return
} }
}
loadingLink = true loadingLink = true
let response let response: Mastodon.Account | undefined = undefined
const queryKey: QueryKeyAccount = [
'Account',
{ id: match.account.id, url: url, _remote: match.account._remote }
]
const cache = queryClient.getQueryData<Mastodon.Status>(queryKey)
if (cache) {
handleNavigation('Tab-Shared-Account', { account: cache })
loadingLink = false
return
} else {
try { try {
response = await apiInstance<SearchResult>({ response = await searchLocalAccount(url)
version: 'v2',
method: 'get',
url: 'search',
params: {
type: 'accounts',
q: isAccount.sameInstance && isAccount.style === 'pretty' ? isAccount.username : url,
limit: 1,
resolve: true
}
})
} catch {} } catch {}
if (response && response.body && response.body.accounts.length) { if (response) {
handleNavigation('Tab-Shared-Account', { handleNavigation('Tab-Shared-Account', { account: response })
account: response.body.accounts[0]
})
loadingLink = false loadingLink = false
return return
} }
} }
}
loadingLink = false loadingLink = false
const validatedUrl = validUrl.isWebUri(url)
if (validatedUrl) {
switch (getGlobalStorage.string('app.browser')) { switch (getGlobalStorage.string('app.browser')) {
// Some links might end with an empty space at the end that triggers an error // Some links might end with an empty space at the end that triggers an error
case 'internal': case 'internal':
await WebBrowser.openBrowserAsync(validatedUrl, { await WebBrowser.openBrowserAsync(url.trim(), {
dismissButtonStyle: 'close', dismissButtonStyle: 'close',
enableBarCollapsing: true, enableBarCollapsing: true,
...(await browserPackage()) ...(await browserPackage())
}) })
break break
case 'external': case 'external':
await Linking.openURL(validatedUrl) await Linking.openURL(url.trim())
break break
} }
}
} }
export default openLink export default openLink

View File

@ -10,7 +10,7 @@ import { handleError } from '@utils/api/helpers'
import { RootStackScreenProps } from '@utils/navigation/navigators' import { RootStackScreenProps } from '@utils/navigation/navigators'
import { useInstanceQuery } from '@utils/queryHooks/instance' import { useInstanceQuery } from '@utils/queryHooks/instance'
import { usePreferencesQuery } from '@utils/queryHooks/preferences' import { usePreferencesQuery } from '@utils/queryHooks/preferences'
import { searchFetchToot, SearchResult } from '@utils/queryHooks/search' import { searchLocalStatus } from '@utils/queryHooks/search'
import { useTimelineMutation } from '@utils/queryHooks/timeline' import { useTimelineMutation } from '@utils/queryHooks/timeline'
import { import {
getAccountStorage, getAccountStorage,
@ -156,7 +156,7 @@ const ScreenCompose: React.FC<RootStackScreenProps<'Screen-Compose'>> = ({
content: params.accts.map(acct => `@${acct}`).join(' ') + ' ', content: params.accts.map(acct => `@${acct}`).join(' ') + ' ',
disableDebounce: true disableDebounce: true
}) })
searchFetchToot(params.incomingStatus.uri).then(status => { searchLocalStatus(params.incomingStatus.uri).then(status => {
if (status?.uri === params.incomingStatus.uri) { if (status?.uri === params.incomingStatus.uri) {
composeDispatch({ type: 'updateReply', payload: status }) composeDispatch({ type: 'updateReply', payload: status })
} }

View File

@ -1,6 +1,6 @@
import { emojis } from '@components/Emojis' import { emojis } from '@components/Emojis'
import CustomText from '@components/Text' import CustomText from '@components/Text'
import queryClient from '@utils/queryHooks' import { queryClient } from '@utils/queryHooks'
import { QueryKeyInstance } from '@utils/queryHooks/instance' import { QueryKeyInstance } from '@utils/queryHooks/instance'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
import LinkifyIt from 'linkify-it' import LinkifyIt from 'linkify-it'

View File

@ -1,8 +1,8 @@
import { MenuContainer, MenuRow } from '@components/Menu' import { MenuContainer, MenuRow } from '@components/Menu'
import { useActionSheet } from '@expo/react-native-action-sheet' import { useActionSheet } from '@expo/react-native-action-sheet'
import { androidActionSheetStyles } from '@utils/helpers/androidActionSheetStyles' import { androidActionSheetStyles } from '@utils/helpers/androidActionSheetStyles'
import queryClient from '@utils/queryHooks'
import { TabMeProfileStackScreenProps } from '@utils/navigation/navigators' import { TabMeProfileStackScreenProps } from '@utils/navigation/navigators'
import { queryClient } from '@utils/queryHooks'
import { QueryKeyPreferences } from '@utils/queryHooks/preferences' import { QueryKeyPreferences } from '@utils/queryHooks/preferences'
import { useProfileMutation, useProfileQuery } from '@utils/queryHooks/profile' import { useProfileMutation, useProfileQuery } from '@utils/queryHooks/profile'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'

View File

@ -4,11 +4,13 @@ import { displayMessage } from '@components/Message'
import { useActionSheet } from '@expo/react-native-action-sheet' import { useActionSheet } from '@expo/react-native-action-sheet'
import { useNavigation } from '@react-navigation/native' import { useNavigation } from '@react-navigation/native'
import { androidActionSheetStyles } from '@utils/helpers/androidActionSheetStyles' import { androidActionSheetStyles } from '@utils/helpers/androidActionSheetStyles'
import { urlMatcher } from '@utils/helpers/urlMatcher'
import { storage } from '@utils/storage' import { storage } from '@utils/storage'
import { getGlobalStorage, useGlobalStorage } from '@utils/storage/actions' import { getGlobalStorage, useGlobalStorage } from '@utils/storage/actions'
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 React from 'react' import React from 'react'
import { Alert } from 'react-native'
import { MMKV } from 'react-native-mmkv' import { MMKV } from 'react-native-mmkv'
const SettingsDev: React.FC = () => { const SettingsDev: React.FC = () => {
@ -37,6 +39,17 @@ const SettingsDev: React.FC = () => {
) )
} }
/> />
<Button
type='text'
content={'Test link matcher'}
style={{
marginHorizontal: StyleConstants.Spacing.Global.PagePadding * 2,
marginBottom: StyleConstants.Spacing.Global.PagePadding
}}
onPress={() =>
Alert.prompt('URL', undefined, text => console.log(urlMatcher(text)), 'plain-text')
}
/>
<Button <Button
type='text' type='text'
content={'Test flash message'} content={'Test flash message'}

View File

@ -32,6 +32,7 @@ const TabSharedAccount: React.FC<TabSharedStackScreenProps<'Tab-Shared-Account'>
const { data, dataUpdatedAt } = useAccountQuery({ const { data, dataUpdatedAt } = useAccountQuery({
account, account,
_local: true,
options: { options: {
onSuccess: a => { onSuccess: a => {
if (account._remote) { if (account._remote) {

View File

@ -111,11 +111,11 @@ const ContentView: React.FC<{
const TabSharedHistory: React.FC<TabSharedStackScreenProps<'Tab-Shared-History'>> = ({ const TabSharedHistory: React.FC<TabSharedStackScreenProps<'Tab-Shared-History'>> = ({
navigation, navigation,
route: { route: {
params: { id, detectedLanguage } params: { status, detectedLanguage }
} }
}) => { }) => {
const { t } = useTranslation('screenTabs') const { t } = useTranslation('screenTabs')
const { data } = useStatusHistory({ id }) const { data } = useStatusHistory({ status })
useEffect(() => { useEffect(() => {
navigation.setOptions({ navigation.setOptions({

View File

@ -5,7 +5,7 @@ import CustomText from '@components/Text'
import apiInstance from '@utils/api/instance' import apiInstance from '@utils/api/instance'
import { TabSharedStackScreenProps } from '@utils/navigation/navigators' import { TabSharedStackScreenProps } from '@utils/navigation/navigators'
import { useRulesQuery } from '@utils/queryHooks/reports' import { useRulesQuery } from '@utils/queryHooks/reports'
import { searchFetchToot } from '@utils/queryHooks/search' import { searchLocalStatus } from '@utils/queryHooks/search'
import { getAccountStorage } from '@utils/storage/actions' import { getAccountStorage } from '@utils/storage/actions'
import { StyleConstants } from '@utils/styles/constants' import { StyleConstants } from '@utils/styles/constants'
import { useTheme } from '@utils/styles/ThemeManager' import { useTheme } from '@utils/styles/ThemeManager'
@ -55,7 +55,7 @@ const TabSharedReport: React.FC<TabSharedStackScreenProps<'Tab-Shared-Report'>>
const body = new FormData() const body = new FormData()
if (status) { if (status) {
if (status._remote) { if (status._remote) {
const fetchedStatus = await searchFetchToot(status.uri) const fetchedStatus = await searchLocalStatus(status.uri)
if (fetchedStatus) { if (fetchedStatus) {
body.append('status_ids[]', fetchedStatus.id) body.append('status_ids[]', fetchedStatus.id)
} }

View File

@ -6,7 +6,7 @@ import TimelineDefault from '@components/Timeline/Default'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import apiGeneral from '@utils/api/general' import apiGeneral from '@utils/api/general'
import apiInstance from '@utils/api/instance' import apiInstance from '@utils/api/instance'
import { getHost } from '@utils/helpers/urlMatcher' import { urlMatcher } from '@utils/helpers/urlMatcher'
import { TabSharedStackScreenProps } from '@utils/navigation/navigators' import { TabSharedStackScreenProps } from '@utils/navigation/navigators'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { getAccountStorage } from '@utils/storage/actions' import { getAccountStorage } from '@utils/storage/actions'
@ -66,6 +66,7 @@ const TabSharedToot: React.FC<TabSharedStackScreenProps<'Tab-Shared-Toot'>> = ({
const flRef = useRef<FlatList>(null) const flRef = useRef<FlatList>(null)
const scrolled = useRef(false) const scrolled = useRef(false)
const match = urlMatcher(toot.url || toot.uri)
const finalData = useRef<(Mastodon.Status & { key?: string })[]>([ const finalData = useRef<(Mastodon.Status & { key?: string })[]>([
{ ...toot, _level: 0, key: 'cached' } { ...toot, _level: 0, key: 'cached' }
]) ])
@ -145,11 +146,11 @@ const TabSharedToot: React.FC<TabSharedStackScreenProps<'Tab-Shared-Toot'>> = ({
useQuery( useQuery(
queryKey.remote, queryKey.remote,
async () => { async () => {
const domain = getHost(toot.url || toot.uri) const domain = match?.domain
if (!domain?.length) { if (!domain?.length) {
return Promise.reject('Cannot parse remote doamin') return Promise.reject('Cannot parse remote doamin')
} }
const id = (toot.url || toot.uri).match(new RegExp(/\/([0-9]+)$/))?.[1] const id = match?.status?.id
if (!id?.length) { if (!id?.length) {
return Promise.reject('Cannot parse remote toot id') return Promise.reject('Cannot parse remote toot id')
} }
@ -191,7 +192,7 @@ const TabSharedToot: React.FC<TabSharedStackScreenProps<'Tab-Shared-Toot'>> = ({
{ {
enabled: enabled:
['public', 'unlisted'].includes(toot.visibility) && ['public', 'unlisted'].includes(toot.visibility) &&
getHost(toot.uri) !== getAccountStorage.string('auth.domain'), match?.domain !== getAccountStorage.string('auth.domain'),
staleTime: 0, staleTime: 0,
refetchOnMount: true, refetchOnMount: true,
onSuccess: data => { onSuccess: data => {

View File

@ -1,62 +1,69 @@
import { getAccountStorage } from '@utils/storage/actions' import { getAccountStorage } from '@utils/storage/actions'
import parse from 'url-parse'
const getHost = (url: unknown): string | undefined | null => { export const urlMatcher = (
if (typeof url !== 'string') return undefined
const matches = url.match(/^(?:https?:\/\/)?(?:[^@\/\n]+@)?(?:www\.)?([^:\/\n]+)/i)
return matches?.[1]
}
const matchStatus = (
url: string
): { id: string; style: 'default' | 'pretty'; sameInstance: boolean } | null => {
// https://social.xmflsct.com/web/statuses/105590085754428765 <- default
// https://social.xmflsct.com/@tooot/105590085754428765 <- pretty
const matcherStatus = new RegExp(/(https?:\/\/)?([^\/]+)\/(web\/statuses|@.+)\/([0-9]+)/)
const matched = url.match(matcherStatus)
if (matched) {
const hostname = matched[2]
const style = matched[3] === 'web/statuses' ? 'default' : 'pretty'
const id = matched[4]
const sameInstance = hostname === getAccountStorage.string('auth.domain')
return { id, style, sameInstance }
}
return null
}
const matchAccount = (
url: string url: string
): ):
| { id: string; style: 'default'; sameInstance: boolean } | {
| { username: string; style: 'pretty'; sameInstance: boolean } domain: string
| null => { account?: Partial<Pick<Mastodon.Account, 'id' | 'acct' | '_remote'>>
// https://social.xmflsct.com/web/accounts/14195 <- default status?: Partial<Pick<Mastodon.Status, 'id' | '_remote'>>
// https://social.xmflsct.com/web/@tooot <- pretty ! cannot be searched on the same instance }
// https://social.xmflsct.com/@tooot <- pretty | undefined => {
const matcherAccount = new RegExp( const parsed = parse(url)
/(https?:\/\/)?([^\/]+)(\/web\/accounts\/([0-9]+)|\/web\/(@.+)|\/(@.+))/ if (!parsed.hostname.length || !parsed.pathname.length) return undefined
)
const matched = url.match(matcherAccount) const domain = parsed.hostname
if (matched) { const _remote = parsed.hostname !== getAccountStorage.string('auth.domain')
const hostname = matched[2]
const account = matched.filter(i => i).reverse()?.[0]
if (account) {
const style = account.startsWith('@') ? 'pretty' : 'default'
const sameInstance = hostname === getAccountStorage.string('auth.domain') let statusId: string | undefined
return style === 'default' let accountId: string | undefined
? { id: account, style, sameInstance } let accountAcct: string | undefined
: { username: account, style, sameInstance }
} else { const segments = parsed.pathname.split('/')
return null const last = segments[segments.length - 1]
const length = segments.length // there is a starting slash
switch (last?.startsWith('@')) {
case true:
if (length === 2 || (length === 3 && segments[length - 2] === 'web')) {
// https://social.xmflsct.com/@tooot <- Mastodon v4.0 and above
// https://social.xmflsct.com/web/@tooot <- Mastodon v3.5 and below ! cannot be searched on the same instance
accountAcct = `${last}@${domain}`
}
break
case false:
const nextToLast = segments[length - 2]
if (nextToLast) {
if (nextToLast === 'statuses') {
if (length === 4 && segments[length - 3] === 'web') {
// https://social.xmflsct.com/web/statuses/105590085754428765 <- old
statusId = last
} else if (
length === 5 &&
segments[length - 2] === 'statuses' &&
segments[length - 4] === 'users'
) {
// https://social.xmflsct.com/users/tooot/statuses/105590085754428765 <- default Mastodon
statusId = last
// accountAcct = `@${segments[length - 3]}@${domain}`
}
} else if (
nextToLast.startsWith('@') &&
(length === 3 || (length === 4 && segments[length - 3] === 'web'))
) {
// https://social.xmflsct.com/web/@tooot/105590085754428765 <- pretty Mastodon v3.5 and below
// https://social.xmflsct.com/@tooot/105590085754428765 <- pretty Mastodon v4.0 and above
statusId = last
// accountAcct = `${nextToLast}@${domain}`
} }
} }
break
}
return null return {
domain,
...((accountId || accountAcct) && { account: { id: accountId, acct: accountAcct, _remote } }),
...(statusId && { status: { id: statusId, _remote } })
}
} }
export { getHost, matchStatus, matchAccount }

View File

@ -94,7 +94,7 @@ export type TabSharedStackParamList = {
hashtag: Mastodon.Tag['name'] hashtag: Mastodon.Tag['name']
} }
'Tab-Shared-History': { 'Tab-Shared-History': {
id: Mastodon.Status['id'] status: Mastodon.Status
detectedLanguage: string detectedLanguage: string
} }
'Tab-Shared-Report': { 'Tab-Shared-Report': {

View File

@ -5,7 +5,7 @@ import {
PERMISSION_MANAGE_REPORTS, PERMISSION_MANAGE_REPORTS,
PERMISSION_MANAGE_USERS PERMISSION_MANAGE_USERS
} from '@utils/helpers/permissions' } from '@utils/helpers/permissions'
import queryClient from '@utils/queryHooks' import { queryClient } from '@utils/queryHooks'
import { QueryKeyProfile } from '@utils/queryHooks/profile' import { QueryKeyProfile } from '@utils/queryHooks/profile'
import { getAccountDetails, getGlobalStorage } from '@utils/storage/actions' import { getAccountDetails, getGlobalStorage } from '@utils/storage/actions'
import * as Notifications from 'expo-notifications' import * as Notifications from 'expo-notifications'

View File

@ -1,5 +1,5 @@
import { displayMessage } from '@components/Message' import { displayMessage } from '@components/Message'
import queryClient from '@utils/queryHooks' import { queryClient } from '@utils/queryHooks'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { generateAccountKey, setAccount, useGlobalStorage } from '@utils/storage/actions' import { generateAccountKey, setAccount, useGlobalStorage } from '@utils/storage/actions'
import * as Notifications from 'expo-notifications' import * as Notifications from 'expo-notifications'

View File

@ -1,4 +1,4 @@
import queryClient from '@utils/queryHooks' import { queryClient } from '@utils/queryHooks'
import { QueryKeyTimeline } from '@utils/queryHooks/timeline' import { QueryKeyTimeline } from '@utils/queryHooks/timeline'
import { generateAccountKey, setAccount, useGlobalStorage } from '@utils/storage/actions' import { generateAccountKey, setAccount, useGlobalStorage } from '@utils/storage/actions'
import * as Notifications from 'expo-notifications' import * as Notifications from 'expo-notifications'

View File

@ -1,68 +1,89 @@
import { QueryFunctionContext, useQuery, UseQueryOptions } from '@tanstack/react-query' import { QueryFunctionContext, useQuery, UseQueryOptions } from '@tanstack/react-query'
import apiGeneral from '@utils/api/general'
import apiInstance from '@utils/api/instance' import apiInstance from '@utils/api/instance'
import { urlMatcher } from '@utils/helpers/urlMatcher'
import { AxiosError } from 'axios' import { AxiosError } from 'axios'
import { SearchResult } from './search' import { searchLocalAccount } from './search'
export type QueryKeyAccount = [ export type QueryKeyAccount = [
'Account', 'Account',
Pick<Mastodon.Account, 'id' | 'url' | '_remote'> | undefined (
| (Partial<Pick<Mastodon.Account, 'id' | 'acct' | 'username' | '_remote'>> &
Pick<Mastodon.Account, 'url'> & { _local?: boolean })
| undefined
)
] ]
const accountQueryFunction = async ({ queryKey }: QueryFunctionContext<QueryKeyAccount>) => { const accountQueryFunction = async ({ queryKey }: QueryFunctionContext<QueryKeyAccount>) => {
const key = queryKey[1] const key = queryKey[1]
if (!key) return Promise.reject() if (!key) return Promise.reject()
let matchedId = key.id let matchedAccount: Mastodon.Account | undefined = undefined
if (key._remote) { if (key._remote) {
await apiInstance<SearchResult>({ const match = urlMatcher(key.url)
version: 'v2',
const domain = match?.domain
const id = key.id || match?.account?.id
const acct = key.acct || key.username || match?.account?.acct
if (!key._local && domain) {
try {
if (id) {
matchedAccount = await apiGeneral<Mastodon.Account>({
method: 'get', method: 'get',
url: 'search', domain: domain,
params: { url: `api/v1/accounts/${id}`
q: key.url, }).then(res => ({ ...res.body, _remote: true }))
type: 'accounts', } else if (acct) {
limit: 1, matchedAccount = await apiGeneral<Mastodon.Account>({
resolve: true method: 'get',
domain: domain,
url: 'api/v1/accounts/lookup',
params: { acct }
}).then(res => ({ ...res.body, _remote: true }))
} }
}) } catch {}
.then(res => {
const account = res.body.accounts[0]
if (account.url !== key.url) {
return Promise.reject()
} else {
matchedId = account.id
}
})
.catch(() => Promise.reject())
} }
const res = await apiInstance<Mastodon.Account>({ if (!matchedAccount) {
matchedAccount = await searchLocalAccount(key.url)
}
} else {
if (!matchedAccount) {
matchedAccount = await apiInstance<Mastodon.Account>({
method: 'get', method: 'get',
url: `accounts/${matchedId}` url: `accounts/${key.id}`
}) }).then(res => res.body)
return res.body }
}
return matchedAccount
} }
const useAccountQuery = ({ const useAccountQuery = ({
options, account,
...queryKeyParams _local,
}: { account?: QueryKeyAccount[1] } & { options
}: {
account?: QueryKeyAccount[1]
_local?: boolean
options?: UseQueryOptions<Mastodon.Account, AxiosError> options?: UseQueryOptions<Mastodon.Account, AxiosError>
}) => { }) => {
const queryKey: QueryKeyAccount = [ const queryKey: QueryKeyAccount = [
'Account', 'Account',
queryKeyParams.account account
? { ? {
id: queryKeyParams.account.id, id: account.id,
url: queryKeyParams.account.url, username: account.username,
_remote: queryKeyParams.account._remote url: account.url,
_remote: account._remote,
...(_local && { _local })
} }
: undefined : undefined
] ]
return useQuery(queryKey, accountQueryFunction, { return useQuery(queryKey, accountQueryFunction, {
...options, ...options,
enabled: (queryKeyParams.account?._remote ? !!queryKeyParams.account : true) && options?.enabled enabled: (account?._remote ? !!account : true) && options?.enabled
}) })
} }

View File

@ -1,6 +1,6 @@
import { QueryClient } from '@tanstack/react-query' import { QueryClient } from '@tanstack/react-query'
const queryClient = new QueryClient({ export const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
staleTime: 1000 * 60 * 5, staleTime: 1000 * 60 * 5,
@ -15,12 +15,10 @@ const queryClient = new QueryClient({
} }
} }
} }
},
logger: {
log: log => console.log(log),
warn: () => {},
error: () => {}
} }
}) })
// @ts-ignore
import('react-query-native-devtools').then(({ addPlugin }) => {
addPlugin({ queryClient })
})
export default queryClient

View File

@ -2,7 +2,7 @@ import haptics from '@components/haptics'
import { displayMessage } from '@components/Message' import { displayMessage } from '@components/Message'
import { useMutation, useQuery, UseQueryOptions } from '@tanstack/react-query' import { useMutation, useQuery, UseQueryOptions } from '@tanstack/react-query'
import apiInstance from '@utils/api/instance' import apiInstance from '@utils/api/instance'
import queryClient from '@utils/queryHooks' import { queryClient } from '@utils/queryHooks'
import { AxiosError } from 'axios' import { AxiosError } from 'axios'
import i18next from 'i18next' import i18next from 'i18next'
import { RefObject } from 'react' import { RefObject } from 'react'

View File

@ -1,5 +1,6 @@
import { QueryFunctionContext, useQuery, UseQueryOptions } from '@tanstack/react-query' import { QueryFunctionContext, useQuery, UseQueryOptions } from '@tanstack/react-query'
import apiInstance from '@utils/api/instance' import apiInstance from '@utils/api/instance'
import { queryClient } from '@utils/queryHooks'
import { AxiosError } from 'axios' import { AxiosError } from 'axios'
export type QueryKeySearch = [ export type QueryKeySearch = [
@ -43,22 +44,27 @@ const useSearchQuery = <T = SearchResult>({
options?: UseQueryOptions<SearchResult, AxiosError, T> options?: UseQueryOptions<SearchResult, AxiosError, T>
}) => { }) => {
const queryKey: QueryKeySearch = ['Search', { ...queryKeyParams }] const queryKey: QueryKeySearch = ['Search', { ...queryKeyParams }]
return useQuery(queryKey, queryFunction, options) return useQuery(queryKey, queryFunction, { ...options, staleTime: 3600, cacheTime: 3600 })
} }
export const searchFetchToot = (uri: Mastodon.Status['uri']): Promise<Mastodon.Status | void> => export const searchLocalStatus = async (uri: Mastodon.Status['uri']): Promise<Mastodon.Status> => {
apiInstance<SearchResult>({ const queryKey: QueryKeySearch = ['Search', { type: 'statuses', term: uri, limit: 1 }]
version: 'v2', return await queryClient
method: 'get', .fetchQuery(queryKey, queryFunction, { staleTime: 3600, cacheTime: 3600 })
url: 'search', .then(res =>
params: { res.statuses[0].uri === uri || res.statuses[0].url === uri
q: uri, ? res.statuses[0]
type: 'statuses', : Promise.reject()
limit: 1, )
resolve: true }
}
}) export const searchLocalAccount = async (
.then(res => res.body.statuses[0]) url: Mastodon.Account['url']
.catch(err => console.warn(err)) ): Promise<Mastodon.Account> => {
const queryKey: QueryKeySearch = ['Search', { type: 'accounts', term: url, limit: 1 }]
return await queryClient
.fetchQuery(queryKey, queryFunction, { staleTime: 3600, cacheTime: 3600 })
.then(res => (res.accounts[0].url === url ? res.accounts[0] : Promise.reject()))
}
export { useSearchQuery } export { useSearchQuery }

View File

@ -1,26 +1,59 @@
import { QueryFunctionContext, useQuery, UseQueryOptions } from '@tanstack/react-query' import { QueryFunctionContext, useQuery, UseQueryOptions } from '@tanstack/react-query'
import apiGeneral from '@utils/api/general'
import apiInstance from '@utils/api/instance' import apiInstance from '@utils/api/instance'
import { urlMatcher } from '@utils/helpers/urlMatcher'
import { AxiosError } from 'axios' import { AxiosError } from 'axios'
import { searchLocalStatus } from './search'
export type QueryKeyStatus = ['Status', { id: Mastodon.Status['id'] }] export type QueryKeyStatus = [
'Status',
(Pick<Mastodon.Status, 'uri'> & Partial<Pick<Mastodon.Status, 'id' | '_remote'>>) | undefined
]
const queryFunction = async ({ queryKey }: QueryFunctionContext<QueryKeyStatus>) => { const queryFunction = async ({ queryKey }: QueryFunctionContext<QueryKeyStatus>) => {
const { id } = queryKey[1] const key = queryKey[1]
if (!key) return Promise.reject()
const res = await apiInstance<Mastodon.Status>({ let matchedStatus: Mastodon.Status | undefined = undefined
const match = urlMatcher(key.uri)
const domain = match?.domain
const id = key.id || match?.status?.id
if (key._remote && domain && id) {
try {
matchedStatus = await apiGeneral<Mastodon.Status>({
method: 'get',
domain,
url: `api/v1/statuses/${id}`
}).then(res => ({ ...res.body, _remote: true }))
} catch {}
}
if (!matchedStatus && !key._remote && id) {
matchedStatus = await apiInstance<Mastodon.Status>({
method: 'get', method: 'get',
url: `statuses/${id}` url: `statuses/${id}`
}) }).then(res => res.body)
return res.body }
if (!matchedStatus) {
matchedStatus = await searchLocalStatus(key.uri)
}
return matchedStatus
} }
const useStatusQuery = ({ const useStatusQuery = ({
options, options,
...queryKeyParams status
}: QueryKeyStatus[1] & { }: { status?: QueryKeyStatus[1] } & {
options?: UseQueryOptions<Mastodon.Status, AxiosError> options?: UseQueryOptions<Mastodon.Status, AxiosError>
}) => { }) => {
const queryKey: QueryKeyStatus = ['Status', { ...queryKeyParams }] const queryKey: QueryKeyStatus = [
'Status',
status ? { id: status.id, uri: status.uri, _remote: status._remote } : undefined
]
return useQuery(queryKey, queryFunction, options) return useQuery(queryKey, queryFunction, options)
} }

View File

@ -1,34 +1,53 @@
import { QueryFunctionContext, useQuery, UseQueryOptions } from '@tanstack/react-query' import { QueryFunctionContext, useQuery, UseQueryOptions } from '@tanstack/react-query'
import apiGeneral from '@utils/api/general'
import apiInstance from '@utils/api/instance' import apiInstance from '@utils/api/instance'
import { urlMatcher } from '@utils/helpers/urlMatcher'
import { AxiosError } from 'axios' import { AxiosError } from 'axios'
export type QueryKeyStatusesHistory = [ export type QueryKeyStatusesHistory = [
'StatusesHistory', 'StatusesHistory',
{ id: Mastodon.Status['id'] } Pick<Mastodon.Status, 'id' | 'uri' | 'edited_at' | '_remote'> &
Partial<Pick<Mastodon.Status, 'edited_at'>>
] ]
const queryFunction = async ({ const queryFunction = async ({ queryKey }: QueryFunctionContext<QueryKeyStatusesHistory>) => {
queryKey const { id, uri, _remote } = queryKey[1]
}: QueryFunctionContext<QueryKeyStatusesHistory>) => {
const { id } = queryKey[1] if (_remote) {
const res = await apiInstance<Mastodon.StatusHistory[]>({ const match = urlMatcher(uri)
const domain = match?.domain
if (!domain) {
return Promise.reject('Cannot find remote domain to retrieve status histories')
}
return await apiGeneral<Mastodon.StatusHistory[]>({
method: 'get',
domain,
url: `api/v1/statuses/${id}/history`
}).then(res => res.body)
}
return await apiInstance<Mastodon.StatusHistory[]>({
method: 'get', method: 'get',
url: `statuses/${id}/history` url: `statuses/${id}/history`
}) }).then(res => res.body)
return res.body
} }
const useStatusHistory = ({ const useStatusHistory = ({
options, options,
...queryKeyParams status
}: QueryKeyStatusesHistory[1] & { }: { status: QueryKeyStatusesHistory[1] } & {
options?: UseQueryOptions<Mastodon.StatusHistory[], AxiosError> options?: UseQueryOptions<Mastodon.StatusHistory[], AxiosError>
}) => { }) => {
const queryKey: QueryKeyStatusesHistory = [ const queryKey: QueryKeyStatusesHistory = [
'StatusesHistory', 'StatusesHistory',
{ ...queryKeyParams } { id: status.id, uri: status.uri, edited_at: status.edited_at, _remote: status._remote }
] ]
return useQuery(queryKey, queryFunction, options) return useQuery(queryKey, queryFunction, {
...options,
enabled: !!status.edited_at,
staleTime: 3600,
cacheTime: 3600
})
} }
export { useStatusHistory } export { useStatusHistory }

View File

@ -9,11 +9,11 @@ import {
import { PagedResponse } from '@utils/api/helpers' import { PagedResponse } from '@utils/api/helpers'
import apiInstance from '@utils/api/instance' import apiInstance from '@utils/api/instance'
import { featureCheck } from '@utils/helpers/featureCheck' import { featureCheck } from '@utils/helpers/featureCheck'
import queryClient from '@utils/queryHooks' import { queryClient } from '@utils/queryHooks'
import { getAccountStorage } from '@utils/storage/actions' import { getAccountStorage } from '@utils/storage/actions'
import { AxiosError } from 'axios' import { AxiosError } from 'axios'
import { uniqBy } from 'lodash' import { uniqBy } from 'lodash'
import { searchFetchToot } from './search' import { searchLocalStatus } from './search'
import deleteItem from './timeline/deleteItem' import deleteItem from './timeline/deleteItem'
import editItem from './timeline/editItem' import editItem from './timeline/editItem'
import updateStatusProperty from './timeline/updateStatusProperty' import updateStatusProperty from './timeline/updateStatusProperty'
@ -342,7 +342,7 @@ const mutationFunction = async (params: MutationVarsTimeline) => {
default: default:
let tootId = params.status.id let tootId = params.status.id
if (params.status._remote) { if (params.status._remote) {
const fetched = await searchFetchToot(params.status.uri) const fetched = await searchLocalStatus(params.status.uri)
if (fetched) { if (fetched) {
tootId = fetched.id tootId = fetched.id
} else { } else {

View File

@ -1,19 +1,13 @@
import { InfiniteData } from '@tanstack/react-query' import { InfiniteData } from '@tanstack/react-query'
import queryClient from '@utils/queryHooks' import { queryClient } from '@utils/queryHooks'
import { MutationVarsTimelineDeleteItem } from '../timeline' import { MutationVarsTimelineDeleteItem } from '../timeline'
const deleteItem = ({ const deleteItem = ({ queryKey, rootQueryKey, id }: MutationVarsTimelineDeleteItem) => {
queryKey,
rootQueryKey,
id
}: MutationVarsTimelineDeleteItem) => {
queryKey && queryKey &&
queryClient.setQueryData<InfiniteData<any> | undefined>(queryKey, old => { queryClient.setQueryData<InfiniteData<any> | undefined>(queryKey, old => {
if (old) { if (old) {
old.pages = old.pages.map(page => { old.pages = old.pages.map(page => {
page.body = page.body.filter( page.body = page.body.filter((item: Mastodon.Status) => item.id !== id)
(item: Mastodon.Status) => item.id !== id
)
return page return page
}) })
return old return old
@ -21,20 +15,15 @@ const deleteItem = ({
}) })
rootQueryKey && rootQueryKey &&
queryClient.setQueryData<InfiniteData<any> | undefined>( queryClient.setQueryData<InfiniteData<any> | undefined>(rootQueryKey, old => {
rootQueryKey,
old => {
if (old) { if (old) {
old.pages = old.pages.map(page => { old.pages = old.pages.map(page => {
page.body = page.body.filter( page.body = page.body.filter((item: Mastodon.Status) => item.id !== id)
(item: Mastodon.Status) => item.id !== id
)
return page return page
}) })
return old return old
} }
} })
)
} }
export default deleteItem export default deleteItem

View File

@ -1,12 +1,8 @@
import { InfiniteData } from '@tanstack/react-query' import { InfiniteData } from '@tanstack/react-query'
import queryClient from '@utils/queryHooks' import { queryClient } from '@utils/queryHooks'
import { MutationVarsTimelineEditItem } from '../timeline' import { MutationVarsTimelineEditItem } from '../timeline'
const editItem = ({ const editItem = ({ queryKey, rootQueryKey, status }: MutationVarsTimelineEditItem) => {
queryKey,
rootQueryKey,
status
}: MutationVarsTimelineEditItem) => {
queryKey && queryKey &&
queryClient.setQueryData<InfiniteData<any> | undefined>(queryKey, old => { queryClient.setQueryData<InfiniteData<any> | undefined>(queryKey, old => {
if (old) { if (old) {
@ -24,9 +20,7 @@ const editItem = ({
}) })
rootQueryKey && rootQueryKey &&
queryClient.setQueryData<InfiniteData<any> | undefined>( queryClient.setQueryData<InfiniteData<any> | undefined>(rootQueryKey, old => {
rootQueryKey,
old => {
if (old) { if (old) {
old.pages = old.pages.map(page => { old.pages = old.pages.map(page => {
page.body = page.body.map((item: Mastodon.Status) => { page.body = page.body.map((item: Mastodon.Status) => {
@ -39,8 +33,7 @@ const editItem = ({
}) })
return old return old
} }
} })
)
} }
export default editItem export default editItem

View File

@ -1,5 +1,5 @@
import { InfiniteData } from '@tanstack/react-query' import { InfiniteData } from '@tanstack/react-query'
import queryClient from '@utils/queryHooks' import { queryClient } from '@utils/queryHooks'
import { MutationVarsTimelineUpdateStatusProperty, TimelineData } from '../timeline' import { MutationVarsTimelineUpdateStatusProperty, TimelineData } from '../timeline'
const updateStatusProperty = ({ const updateStatusProperty = ({
@ -9,7 +9,7 @@ const updateStatusProperty = ({
payload, payload,
poll poll
}: MutationVarsTimelineUpdateStatusProperty & { poll?: Mastodon.Poll }) => { }: MutationVarsTimelineUpdateStatusProperty & { poll?: Mastodon.Poll }) => {
for (const key of [queryKey]) { for (const key of [queryKey, rootQueryKey]) {
if (!key) continue if (!key) continue
queryClient.setQueryData<InfiniteData<TimelineData> | undefined>(key, old => { queryClient.setQueryData<InfiniteData<TimelineData> | undefined>(key, old => {

View File

@ -6,17 +6,13 @@ import {
import apiGeneral from '@utils/api/general' import apiGeneral from '@utils/api/general'
import { PagedResponse } from '@utils/api/helpers' import { PagedResponse } from '@utils/api/helpers'
import apiInstance from '@utils/api/instance' import apiInstance from '@utils/api/instance'
import { getHost } from '@utils/helpers/urlMatcher' import { urlMatcher } from '@utils/helpers/urlMatcher'
import { TabSharedStackParamList } from '@utils/navigation/navigators' import { TabSharedStackParamList } from '@utils/navigation/navigators'
import { AxiosError } from 'axios' import { AxiosError } from 'axios'
export type QueryKeyUsers = ['Users', TabSharedStackParamList['Tab-Shared-Users']] export type QueryKeyUsers = ['Users', TabSharedStackParamList['Tab-Shared-Users']]
const queryFunction = async ({ const queryFunction = async ({ queryKey, pageParam }: QueryFunctionContext<QueryKeyUsers>) => {
queryKey,
pageParam,
meta
}: QueryFunctionContext<QueryKeyUsers>) => {
const page = queryKey[1] const page = queryKey[1]
let params: { [key: string]: string } = { ...pageParam } let params: { [key: string]: string } = { ...pageParam }
@ -24,7 +20,7 @@ const queryFunction = async ({
case 'statuses': case 'statuses':
return apiInstance<Mastodon.Account[]>({ return apiInstance<Mastodon.Account[]>({
method: 'get', method: 'get',
url: `${page.reference}/${page.status.id}/${page.type}`, url: `statuses/${page.status.id}/${page.type}`,
params params
}) })
case 'accounts': case 'accounts':
@ -32,14 +28,14 @@ const queryFunction = async ({
if (localInstance) { if (localInstance) {
return apiInstance<Mastodon.Account[]>({ return apiInstance<Mastodon.Account[]>({
method: 'get', method: 'get',
url: `${page.reference}/${page.account.id}/${page.type}`, url: `accounts/${page.account.id}/${page.type}`,
params params
}) })
} else { } else {
let res: PagedResponse<Mastodon.Account[]> let res: PagedResponse<Mastodon.Account[]>
try { try {
const domain = getHost(page.account.url) const domain = urlMatcher(page.account.url)?.domain
if (!domain?.length) { if (!domain?.length) {
throw new Error() throw new Error()
} }
@ -54,7 +50,7 @@ const queryFunction = async ({
res = await apiGeneral<Mastodon.Account[]>({ res = await apiGeneral<Mastodon.Account[]>({
method: 'get', method: 'get',
domain, domain,
url: `api/v1/${page.reference}/${resLookup.body.id}/${page.type}`, url: `api/v1/accounts/${resLookup.body.id}/${page.type}`,
params params
}) })
return { ...res, remoteData: true } return { ...res, remoteData: true }

12
src/utils/startup/dev.ts Normal file
View File

@ -0,0 +1,12 @@
import { queryClient } from '@utils/queryHooks'
import log from './log'
export const dev = () => {
if (__DEV__) {
log('log', 'dev', 'loading tools')
// @ts-ignore
import('react-query-native-devtools').then(({ addPlugin }) => {
addPlugin({ queryClient })
})
}
}

View File

@ -1,4 +1,4 @@
import queryClient from '@utils/queryHooks' import { queryClient } from '@utils/queryHooks'
import { storage } from '@utils/storage' import { storage } from '@utils/storage'
import { import {
MMKV, MMKV,

View File

@ -3449,6 +3449,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/url-parse@npm:^1":
version: 1.4.8
resolution: "@types/url-parse@npm:1.4.8"
checksum: 44a5e96ed4b579c43750f3578bfa9165f97a359c3b2a85ee126e9c16db964f6ea105e152afd3d1adbd15850a8b812043215f3820112177bb4255a60b432dbd85
languageName: node
linkType: hard
"@types/use-sync-external-store@npm:^0.0.3": "@types/use-sync-external-store@npm:^0.0.3":
version: 0.0.3 version: 0.0.3
resolution: "@types/use-sync-external-store@npm:0.0.3" resolution: "@types/use-sync-external-store@npm:0.0.3"
@ -3456,13 +3463,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/valid-url@npm:^1.0.3":
version: 1.0.3
resolution: "@types/valid-url@npm:1.0.3"
checksum: bd527221b4f839c440600520f9bfbdb340f473543ea5ee25215f35155ae52fd2cc5c39086cab2ffa67a54a7c303de937fcc8a58edf9bc5b7000169d7a7aa8d42
languageName: node
linkType: hard
"@types/yargs-parser@npm:*": "@types/yargs-parser@npm:*":
version: 21.0.0 version: 21.0.0
resolution: "@types/yargs-parser@npm:21.0.0" resolution: "@types/yargs-parser@npm:21.0.0"
@ -11262,7 +11262,7 @@ __metadata:
"@types/react-dom": ^18.0.10 "@types/react-dom": ^18.0.10
"@types/react-native": ^0.70.8 "@types/react-native": ^0.70.8
"@types/react-native-share-menu": ^5.0.2 "@types/react-native-share-menu": ^5.0.2
"@types/valid-url": ^1.0.3 "@types/url-parse": ^1
axios: ^1.2.1 axios: ^1.2.1
babel-plugin-module-resolver: ^4.1.0 babel-plugin-module-resolver: ^4.1.0
babel-plugin-transform-remove-console: ^6.9.4 babel-plugin-transform-remove-console: ^6.9.4
@ -11320,7 +11320,7 @@ __metadata:
react-redux: ^8.0.5 react-redux: ^8.0.5
rn-placeholder: ^3.0.3 rn-placeholder: ^3.0.3
typescript: ^4.9.4 typescript: ^4.9.4
valid-url: ^1.0.9 url-parse: ^1.5.10
zeego: ^1.0.2 zeego: ^1.0.2
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@ -11623,7 +11623,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"url-parse@npm:^1.5.9": "url-parse@npm:^1.5.10, url-parse@npm:^1.5.9":
version: 1.5.10 version: 1.5.10
resolution: "url-parse@npm:1.5.10" resolution: "url-parse@npm:1.5.10"
dependencies: dependencies:
@ -11753,7 +11753,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"valid-url@npm:^1.0.9, valid-url@npm:~1.0.9": "valid-url@npm:~1.0.9":
version: 1.0.9 version: 1.0.9
resolution: "valid-url@npm:1.0.9" resolution: "valid-url@npm:1.0.9"
checksum: 3ecb030559404441c2cf104cbabab8770efb0f36d117db03d1081052ef133015a68806148ce954bb4dd0b5c42c14b709a88783c93d66b0916cb67ba771c98702 checksum: 3ecb030559404441c2cf104cbabab8770efb0f36d117db03d1081052ef133015a68806148ce954bb4dd0b5c42c14b709a88783c93d66b0916cb67ba771c98702