diff --git a/src/components/Parse/HTML.tsx b/src/components/Parse/HTML.tsx index 4ff3436f..246f839a 100644 --- a/src/components/Parse/HTML.tsx +++ b/src/components/Parse/HTML.tsx @@ -15,7 +15,7 @@ import { ElementType, parseDocument } from 'htmlparser2' import i18next from 'i18next' import React, { useContext, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { Platform, Pressable, Text, TextStyleIOS, View } from 'react-native' +import { Platform, Pressable, Text, View } from 'react-native' export interface Props { content: string @@ -78,8 +78,8 @@ const ParseHTML: React.FC = ({ return node.data case ElementType.Tag: if (node.name === 'span') { - if (node.attribs.class?.includes('invisible')) return '' - if (node.attribs.class?.includes('ellipsis')) + if (node.attribs.class?.includes('invisible') && !showFullLink) return '' + if (node.attribs.class?.includes('ellipsis') && !showFullLink) return node.children.map(child => unwrapNode(child)).join('') + '...' } return node.children.map(child => unwrapNode(child)).join('') @@ -87,15 +87,26 @@ const ParseHTML: React.FC = ({ return '' } } - const startingOfText = useRef(false) + const openingMentions = useRef(true) const renderNode = (node: ChildNode, index: number) => { switch (node.type) { case ElementType.Text: - node.data.trim().length && (startingOfText.current = true) // Removing empty spaces appeared between tags and mentions + let content: string = node.data + if (openingMentions.current) { + if (node.data.trim().length) { + openingMentions.current = false // Removing empty spaces appeared between tags and mentions + content = excludeMentions?.current.length + ? node.data.replace(new RegExp(/^\s+/), '') + : node.data + } else { + content = node.data.trim() + } + } + return ( = ({ const href = node.attribs.href if (classes) { if (classes.includes('hashtag')) { + openingMentions.current = false const tag = href.match(new RegExp(/\/tags?\/(.*)/, 'i'))?.[1].toLowerCase() const paramsHashtag = (params as { hashtag: Mastodon.Tag['name'] } | undefined) ?.hashtag @@ -142,7 +154,6 @@ const ParseHTML: React.FC = ({ ) if ( matchedMention && - !startingOfText.current && excludeMentions?.current.find(eM => eM.id === matchedMention.id) ) { return null @@ -165,6 +176,7 @@ const ParseHTML: React.FC = ({ } } + openingMentions.current = false const content = node.children.map(child => unwrapNode(child)).join('') const shouldBeTag = status?.tags?.find(tag => `#${tag.name}` === content) return ( @@ -182,7 +194,7 @@ const ParseHTML: React.FC = ({ } } }} - children={content !== href ? content : showFullLink ? href : content} + children={content} /> ) break diff --git a/src/components/Timeline/Default.tsx b/src/components/Timeline/Default.tsx index 26c8da51..ae264a35 100644 --- a/src/components/Timeline/Default.tsx +++ b/src/components/Timeline/Default.tsx @@ -37,6 +37,7 @@ export interface Props { disableDetails?: boolean disableOnPress?: boolean isConversation?: boolean + isRemote?: boolean } // When the poll is long @@ -47,7 +48,8 @@ const TimelineDefault: React.FC = ({ highlighted = false, disableDetails = false, disableOnPress = false, - isConversation = false + isConversation = false, + isRemote = false }) => { const status = item.reblog ? item.reblog : item const rawContent = useRef([]) @@ -175,7 +177,8 @@ const TimelineDefault: React.FC = ({ inThread: queryKey?.[1].page === 'Toot', disableDetails, disableOnPress, - isConversation + isConversation, + isRemote }} > {disableOnPress ? ( diff --git a/src/components/Timeline/Shared/Context.tsx b/src/components/Timeline/Shared/Context.tsx index 80f1079e..ea689fc2 100644 --- a/src/components/Timeline/Shared/Context.tsx +++ b/src/components/Timeline/Shared/Context.tsx @@ -21,6 +21,7 @@ type StatusContextType = { disableDetails?: boolean disableOnPress?: boolean isConversation?: boolean + isRemote?: boolean } const StatusContext = createContext({} as StatusContextType) diff --git a/src/components/Timeline/Shared/HeaderDefault.tsx b/src/components/Timeline/Shared/HeaderDefault.tsx index c96bf9ef..e5795207 100644 --- a/src/components/Timeline/Shared/HeaderDefault.tsx +++ b/src/components/Timeline/Shared/HeaderDefault.tsx @@ -17,7 +17,7 @@ import HeaderSharedReplies from './HeaderShared/Replies' import HeaderSharedVisibility from './HeaderShared/Visibility' const TimelineHeaderDefault: React.FC = () => { - const { queryKey, rootQueryKey, status, highlighted, disableDetails, rawContent } = + const { queryKey, rootQueryKey, status, highlighted, disableDetails, rawContent, isRemote } = useContext(StatusContext) if (!status) return null @@ -58,6 +58,14 @@ const TimelineHeaderDefault: React.FC = () => { : { marginTop: StyleConstants.Spacing.XS, marginBottom: StyleConstants.Spacing.S }) }} > + {isRemote ? ( + + ) : null} { @@ -11,7 +11,7 @@ const HeaderSharedReplies: React.FC = () => { if (!isConversation) return null const navigation = useNavigation() - const { t } = useTranslation('componentTimeline') + const { t } = useTranslation(['common', 'componentTimeline']) const { colors } = useTheme() const mentionsBeginning = rawContent?.current?.[0] @@ -26,25 +26,27 @@ const HeaderSharedReplies: React.FC = () => { return excludeMentions?.current.length ? ( - <> - {t('shared.header.shared.replies')} - {excludeMentions.current.map((mention, index) => ( - - {' '} - navigation.push('Tab-Shared-Account', { account: mention })} - /> - - ))} - + + {excludeMentions.current.map((mention, index) => ( + + {index > 0 ? t('common:separator') : null} + navigation.push('Tab-Shared-Account', { account: mention })} + /> + + ))} + + ]} + /> ) : null } diff --git a/src/i18n/en/components/contextMenu.json b/src/i18n/en/components/contextMenu.json index f4feb500..457bba98 100644 --- a/src/i18n/en/components/contextMenu.json +++ b/src/i18n/en/components/contextMenu.json @@ -6,7 +6,7 @@ "action_false": "Follow user", "action_true": "Unfollow user" }, - "inLists": "Manage user of lists", + "inLists": "Lists containing user", "showBoosts": { "action_false": "Show user's boosts", "action_true": "Hide users's boosts" diff --git a/src/i18n/en/components/timeline.json b/src/i18n/en/components/timeline.json index 362cbe3e..77492cad 100644 --- a/src/i18n/en/components/timeline.json +++ b/src/i18n/en/components/timeline.json @@ -122,7 +122,7 @@ "muted": { "accessibilityLabel": "Toot muted" }, - "replies": "Replies", + "replies": "Replies <0 />", "visibility": { "direct": { "accessibilityLabel": "Toot is a direct message" diff --git a/src/screens/ImageViewer/index.tsx b/src/screens/ImageViewer/index.tsx index 140df005..28d3e9f6 100644 --- a/src/screens/ImageViewer/index.tsx +++ b/src/screens/ImageViewer/index.tsx @@ -4,7 +4,7 @@ import { useActionSheet } from '@expo/react-native-action-sheet' import { androidActionSheetStyles } from '@utils/helpers/androidActionSheetStyles' import { RootStackScreenProps } from '@utils/navigation/navigators' import { useTheme } from '@utils/styles/ThemeManager' -import React, { useState } from 'react' +import React, { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { Dimensions, @@ -50,6 +50,13 @@ const ScreenImagesViewer = ({ const isZoomed = useSharedValue(false) + const onViewableItemsChanged = useCallback( + ({ viewableItems }: { viewableItems: ViewToken[] }) => { + setCurrentIndex(viewableItems[0]?.index || 0) + }, + [] + ) + return ( { + onActivated={() => { showActionSheetWithOptions( { options: [ @@ -207,9 +214,7 @@ const ScreenImagesViewer = ({ /> ) }} - onViewableItemsChanged={({ viewableItems }: { viewableItems: ViewToken[] }) => { - setCurrentIndex(viewableItems[0]?.index || 0) - }} + onViewableItemsChanged={onViewableItemsChanged} viewabilityConfig={{ itemVisiblePercentThreshold: 50 }} diff --git a/src/screens/Tabs/Public/Root.tsx b/src/screens/Tabs/Public/Root.tsx index ac7c11e0..613e818b 100644 --- a/src/screens/Tabs/Public/Root.tsx +++ b/src/screens/Tabs/Public/Root.tsx @@ -83,7 +83,10 @@ const Root: React.FC null} - onIndexChange={index => setSegment(index)} + onIndexChange={index => { + setSegment(index) + setGlobalStorage('app.prev_public_segment', segments[index]) + }} navigationState={{ index: segment, routes }} initialLayout={{ width: Dimensions.get('window').width }} /> diff --git a/src/screens/Tabs/Shared/Account/Information/Fields.tsx b/src/screens/Tabs/Shared/Account/Information/Fields.tsx index f5c083df..824739a5 100644 --- a/src/screens/Tabs/Shared/Account/Information/Fields.tsx +++ b/src/screens/Tabs/Shared/Account/Information/Fields.tsx @@ -1,4 +1,3 @@ -import Icon from '@components/Icon' import { ParseHTML } from '@components/Parse' import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' @@ -18,10 +17,39 @@ const AccountInformationFields: React.FC = ({ account, myInfo }) => { const { colors } = useTheme() return ( - + {account.fields.map((field, index) => ( - - + + = ({ account, myInfo }) => { numberOfLines={5} selectable /> - {field.verified_at ? ( - - ) : null} - + = ({ account, myInfo }) => { ) } -const styles = StyleSheet.create({ - fields: { - borderTopWidth: StyleSheet.hairlineWidth, - marginBottom: StyleConstants.Spacing.M - }, - field: { - flex: 1, - flexDirection: 'row', - borderBottomWidth: StyleSheet.hairlineWidth, - paddingTop: StyleConstants.Spacing.S, - paddingBottom: StyleConstants.Spacing.S - }, - fieldLeft: { - flex: 1, - flexDirection: 'row', - alignItems: 'center', - borderRightWidth: 1, - paddingLeft: StyleConstants.Spacing.S, - paddingRight: StyleConstants.Spacing.S - }, - fieldCheck: { marginLeft: StyleConstants.Spacing.XS }, - fieldRight: { - flex: 2, - justifyContent: 'center', - paddingLeft: StyleConstants.Spacing.S, - paddingRight: StyleConstants.Spacing.S - } -}) - export default AccountInformationFields diff --git a/src/screens/Tabs/Shared/Toot.tsx b/src/screens/Tabs/Shared/Toot.tsx index 8b0149cf..fef8f609 100644 --- a/src/screens/Tabs/Shared/Toot.tsx +++ b/src/screens/Tabs/Shared/Toot.tsx @@ -1,14 +1,20 @@ +import Button from '@components/Button' import { HeaderLeft } from '@components/Header' +import Icon from '@components/Icon' import ComponentSeparator from '@components/Separator' import CustomText from '@components/Text' import TimelineDefault from '@components/Timeline/Default' +import { useQuery } from '@tanstack/react-query' +import apiGeneral from '@utils/api/general' +import apiInstance from '@utils/api/instance' +import { getHost } from '@utils/helpers/urlMatcher' import { TabSharedStackScreenProps } from '@utils/navigation/navigators' -import { QueryKeyTimeline, useTootQuery } from '@utils/queryHooks/timeline' import { StyleConstants } from '@utils/styles/constants' import { useTheme } from '@utils/styles/ThemeManager' -import React, { useEffect, useRef } from 'react' +import React, { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { FlatList, View } from 'react-native' +import { Circle } from 'react-native-animated-spinkit' import { Path, Svg } from 'react-native-svg' const TabSharedToot: React.FC> = ({ @@ -18,74 +24,271 @@ const TabSharedToot: React.FC> = ({ } }) => { const { colors } = useTheme() - const { t } = useTranslation('screenTabs') + const { t } = useTranslation(['componentTimeline', 'screenTabs']) + + const [hasRemoteContent, setHasRemoteContent] = useState(false) useEffect(() => { navigation.setOptions({ - title: t('shared.toot.name'), + headerTitle: () => ( + + {hasRemoteContent ? ( + + ) : null} + + + ), headerLeft: () => navigation.goBack()} /> }) - }, []) + }, [hasRemoteContent]) const flRef = useRef(null) const scrolled = useRef(false) - const queryKey: QueryKeyTimeline = ['Timeline', { page: 'Toot', toot: toot.id }] - const { data } = useTootQuery({ - ...queryKey[1], - options: { - meta: { toot }, + const finalData = useRef<(Mastodon.Status & { _level?: number; _remote?: boolean })[]>([ + { ...toot, _level: 0, _remote: false } + ]) + const highlightIndex = useRef(0) + const queryLocal = useQuery( + ['Timeline', { page: 'Toot', toot: toot.id, remote: false }], + async () => { + const context = await apiInstance<{ + ancestors: Mastodon.Status[] + descendants: Mastodon.Status[] + }>({ + method: 'get', + url: `statuses/${toot.id}/context` + }) + + const statuses: (Mastodon.Status & { _level?: number })[] = [ + ...context.body.ancestors, + toot, + ...context.body.descendants + ] + + const highlight = context.body.ancestors.length + highlightIndex.current = highlight + + for (const [index, status] of statuses.entries()) { + if (index < highlight || status.id === toot.id) { + statuses[index]._level = 0 + continue + } + + const repliedLevel = statuses.find(s => s.id === status.in_reply_to_id)?._level + statuses[index]._level = (repliedLevel || 0) + 1 + } + + return { pages: [{ body: statuses }] } + }, + { + staleTime: 0, + refetchOnMount: true, onSuccess: data => { - if (data.body.length < 1) { + if (data.pages[0].body.length < 1) { navigation.goBack() return } - if (!scrolled.current) { - scrolled.current = true - const pointer = data.body.findIndex(({ id }) => id === toot.id) - if (pointer < 1) return - const length = flRef.current?.props.data?.length - if (!length) return - try { - setTimeout(() => { - try { - flRef.current?.scrollToIndex({ - index: pointer, - viewOffset: 100 - }) - } catch {} - }, 500) - } catch (error) { - return + if (finalData.current.length < data.pages[0].body.length) { + // if the remote has been loaded first + finalData.current = data.pages[0].body + + if (!scrolled.current) { + scrolled.current = true + const pointer = data.pages[0].body.findIndex(({ id }) => id === toot.id) + if (pointer < 1) return + const length = flRef.current?.props.data?.length + if (!length) return + try { + setTimeout(() => { + try { + flRef.current?.scrollToIndex({ + index: pointer, + viewOffset: 100 + }) + } catch {} + }, 500) + } catch (error) { + return + } } } } } - }) + ) + useQuery( + ['Timeline', { page: 'Toot', toot: toot.id, remote: true }], + async () => { + let context: + | { + ancestors: Mastodon.Status[] + descendants: Mastodon.Status[] + } + | undefined + + try { + const domain = getHost(toot.url || toot.uri) + if (!domain?.length) { + throw new Error() + } + const id = (toot.url || toot.uri).match(new RegExp(/\/([0-9]+)$/))?.[1] + if (!id?.length) { + throw new Error() + } + + context = await apiGeneral<{ + ancestors: Mastodon.Status[] + descendants: Mastodon.Status[] + }>({ + method: 'get', + domain, + url: `api/v1/statuses/${id}/context` + }).then(res => res.body) + } catch {} + + if (!context) { + throw new Error() + } + + const statuses: (Mastodon.Status & { _level?: number })[] = [ + ...context.ancestors, + toot, + ...context.descendants + ] + + const highlight = context.ancestors.length + highlightIndex.current = highlight + + for (const [index, status] of statuses.entries()) { + if (index < highlight || status.id === toot.id) { + statuses[index]._level = 0 + continue + } + + const repliedLevel = statuses.find(s => s.id === status.in_reply_to_id)?._level + statuses[index]._level = (repliedLevel || 0) + 1 + } + + return { pages: [{ body: statuses }] } + }, + { + enabled: toot.account.acct !== toot.account.username, // When on the same instance, these two values are the same + staleTime: 0, + refetchOnMount: false, + onSuccess: data => { + if (finalData.current.length < 1 && data.pages[0].body.length < 1) { + navigation.goBack() + return + } + + if (finalData.current?.length < data.pages[0].body.length) { + finalData.current = data.pages[0].body.map(remote => { + const localMatch = finalData.current?.find(local => local.uri === remote.uri) + return localMatch ? { ...localMatch, _remote: false } : { ...remote, _remote: true } + }) + setHasRemoteContent(true) + } + + scrolled.current = true + const pointer = data.pages[0].body.findIndex(({ id }) => id === toot.id) + if (pointer < 1) return + const length = flRef.current?.props.data?.length + if (!length) return + try { + setTimeout(() => { + try { + flRef.current?.scrollToIndex({ + index: pointer, + viewOffset: 100 + }) + } catch {} + }, 500) + } catch (error) { + return + } + } + } + ) + + const empty = () => { + switch (queryLocal.status) { + case 'error': + return ( + <> + + + {t('componentTimeline:empty.error.message')} + +