mirror of
https://github.com/tooot-app/app
synced 2025-06-05 22:19:13 +02:00
Unfortunately iOS does not display RTL languages automatically like Android does, so some simple JS library is needed
326 lines
9.9 KiB
TypeScript
326 lines
9.9 KiB
TypeScript
import Icon from '@components/Icon'
|
|
import openLink from '@components/openLink'
|
|
import ParseEmojis from '@components/Parse/Emojis'
|
|
import CustomText from '@components/Text'
|
|
import { useNavigation, useRoute } from '@react-navigation/native'
|
|
import { StackNavigationProp } from '@react-navigation/stack'
|
|
import { TabLocalStackParamList } from '@utils/navigation/navigators'
|
|
import { getSettingsFontsize } from '@utils/slices/settingsSlice'
|
|
import { StyleConstants } from '@utils/styles/constants'
|
|
import layoutAnimation from '@utils/styles/layoutAnimation'
|
|
import { adaptiveScale } from '@utils/styles/scaling'
|
|
import { useTheme } from '@utils/styles/ThemeManager'
|
|
import { isEqual } from 'lodash'
|
|
import React, { useCallback, useState } from 'react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { Platform, Pressable, TextStyleIOS, View } from 'react-native'
|
|
import HTMLView from 'react-native-htmlview'
|
|
import { useSelector } from 'react-redux'
|
|
|
|
// Prevent going to the same hashtag multiple times
|
|
const renderNode = ({
|
|
routeParams,
|
|
colors,
|
|
node,
|
|
index,
|
|
adaptedFontsize,
|
|
adaptedLineheight,
|
|
navigation,
|
|
mentions,
|
|
tags,
|
|
showFullLink,
|
|
disableDetails
|
|
}: {
|
|
routeParams?: any
|
|
colors: any
|
|
node: any
|
|
index: number
|
|
adaptedFontsize: number
|
|
adaptedLineheight: number
|
|
navigation: StackNavigationProp<TabLocalStackParamList>
|
|
mentions?: Mastodon.Mention[]
|
|
tags?: Mastodon.Tag[]
|
|
showFullLink: boolean
|
|
disableDetails: boolean
|
|
}) => {
|
|
switch (node.name) {
|
|
case 'a':
|
|
const classes = node.attribs.class
|
|
const href = node.attribs.href
|
|
if (classes) {
|
|
if (classes.includes('hashtag')) {
|
|
const tag = href?.split(new RegExp(/\/tag\/(.*)|\/tags\/(.*)/))
|
|
const differentTag = routeParams?.hashtag
|
|
? routeParams.hashtag !== tag[1] && routeParams.hashtag !== tag[2]
|
|
: true
|
|
return (
|
|
<CustomText
|
|
accessible
|
|
key={index}
|
|
style={{
|
|
color: colors.blue,
|
|
fontSize: adaptedFontsize,
|
|
lineHeight: adaptedLineheight
|
|
}}
|
|
onPress={() => {
|
|
!disableDetails &&
|
|
differentTag &&
|
|
navigation.push('Tab-Shared-Hashtag', {
|
|
hashtag: tag[1] || tag[2]
|
|
})
|
|
}}
|
|
>
|
|
{node.children[0].data}
|
|
{node.children[1]?.children[0].data}
|
|
</CustomText>
|
|
)
|
|
} else if (classes.includes('mention') && mentions) {
|
|
const accountIndex = mentions.findIndex(mention => mention.url === href)
|
|
const differentAccount = routeParams?.account
|
|
? routeParams.account.id !== mentions[accountIndex]?.id
|
|
: true
|
|
return (
|
|
<CustomText
|
|
key={index}
|
|
style={{
|
|
color: accountIndex !== -1 ? colors.blue : colors.primaryDefault,
|
|
fontSize: adaptedFontsize,
|
|
lineHeight: adaptedLineheight
|
|
}}
|
|
onPress={() => {
|
|
accountIndex !== -1 &&
|
|
!disableDetails &&
|
|
differentAccount &&
|
|
navigation.push('Tab-Shared-Account', {
|
|
account: mentions[accountIndex]
|
|
})
|
|
}}
|
|
>
|
|
{node.children[0].data}
|
|
{node.children[1]?.children[0].data}
|
|
</CustomText>
|
|
)
|
|
}
|
|
} else {
|
|
const domain = href?.split(new RegExp(/:\/\/(.[^\/]+)/))
|
|
// Need example here
|
|
const content = node.children && node.children[0] && node.children[0].data
|
|
const shouldBeTag = tags && tags.filter(tag => `#${tag.name}` === content).length > 0
|
|
return (
|
|
<CustomText
|
|
key={index}
|
|
style={{
|
|
color: colors.blue,
|
|
alignItems: 'center',
|
|
fontSize: adaptedFontsize,
|
|
lineHeight: adaptedLineheight
|
|
}}
|
|
onPress={async () => {
|
|
if (!disableDetails) {
|
|
if (shouldBeTag) {
|
|
navigation.push('Tab-Shared-Hashtag', {
|
|
hashtag: content.substring(1)
|
|
})
|
|
} else {
|
|
await openLink(href, navigation)
|
|
}
|
|
}
|
|
}}
|
|
>
|
|
{content && content !== href ? content : showFullLink ? href : domain?.[1]}
|
|
{!shouldBeTag ? (
|
|
<Icon
|
|
color={colors.blue}
|
|
name='ExternalLink'
|
|
size={adaptedFontsize}
|
|
style={{
|
|
marginLeft: StyleConstants.Spacing.XS,
|
|
...(Platform.OS === 'android' && { transform: [{ translateY: 2 }] })
|
|
}}
|
|
/>
|
|
) : null}
|
|
</CustomText>
|
|
)
|
|
}
|
|
break
|
|
case 'p':
|
|
if (!node.children.length) {
|
|
return <View key={index} /> // bug when the tag is empty
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
export interface Props {
|
|
content: string
|
|
size?: 'S' | 'M' | 'L'
|
|
textStyles?: TextStyleIOS
|
|
adaptiveSize?: boolean
|
|
emojis?: Mastodon.Emoji[]
|
|
mentions?: Mastodon.Mention[]
|
|
tags?: Mastodon.Tag[]
|
|
showFullLink?: boolean
|
|
numberOfLines?: number
|
|
expandHint?: string
|
|
highlighted?: boolean
|
|
disableDetails?: boolean
|
|
selectable?: boolean
|
|
setSpoilerExpanded?: React.Dispatch<React.SetStateAction<boolean>>
|
|
}
|
|
|
|
const ParseHTML = React.memo(
|
|
({
|
|
content,
|
|
size = 'M',
|
|
textStyles,
|
|
adaptiveSize = false,
|
|
emojis,
|
|
mentions,
|
|
tags,
|
|
showFullLink = false,
|
|
numberOfLines = 10,
|
|
expandHint,
|
|
highlighted = false,
|
|
disableDetails = false,
|
|
selectable = false,
|
|
setSpoilerExpanded
|
|
}: Props) => {
|
|
const adaptiveFontsize = useSelector(getSettingsFontsize)
|
|
const adaptedFontsize = adaptiveScale(
|
|
StyleConstants.Font.Size[size],
|
|
adaptiveSize ? adaptiveFontsize : 0
|
|
)
|
|
const adaptedLineheight = adaptiveScale(
|
|
StyleConstants.Font.LineHeight[size],
|
|
adaptiveSize ? adaptiveFontsize : 0
|
|
)
|
|
|
|
const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
|
|
const route = useRoute()
|
|
const { colors, theme } = useTheme()
|
|
const { t } = useTranslation('componentParse')
|
|
if (!expandHint) {
|
|
expandHint = t('HTML.defaultHint')
|
|
}
|
|
|
|
if (disableDetails) {
|
|
numberOfLines = 4
|
|
}
|
|
|
|
const renderNodeCallback = useCallback(
|
|
(node: any, index: any) =>
|
|
renderNode({
|
|
routeParams: route.params,
|
|
colors,
|
|
node,
|
|
index,
|
|
adaptedFontsize,
|
|
adaptedLineheight,
|
|
navigation,
|
|
mentions,
|
|
tags,
|
|
showFullLink,
|
|
disableDetails
|
|
}),
|
|
[]
|
|
)
|
|
const textComponent = useCallback(({ children }: any) => {
|
|
if (children) {
|
|
return (
|
|
<ParseEmojis
|
|
content={children.toString()}
|
|
emojis={emojis}
|
|
size={size}
|
|
adaptiveSize={adaptiveSize}
|
|
/>
|
|
)
|
|
} else {
|
|
return null
|
|
}
|
|
}, [])
|
|
const rootComponent = useCallback(
|
|
({ children }: any) => {
|
|
const { t } = useTranslation('componentParse')
|
|
|
|
const [totalLines, setTotalLines] = useState<number>()
|
|
const [expanded, setExpanded] = useState(highlighted)
|
|
|
|
return (
|
|
<View style={{ overflow: 'hidden' }}>
|
|
{(!disableDetails && typeof totalLines === 'number') || numberOfLines === 1 ? (
|
|
<Pressable
|
|
accessibilityLabel={t('HTML.accessibilityHint')}
|
|
onPress={() => {
|
|
layoutAnimation()
|
|
setExpanded(!expanded)
|
|
if (setSpoilerExpanded) {
|
|
setSpoilerExpanded(!expanded)
|
|
}
|
|
}}
|
|
style={{
|
|
flexDirection: 'row',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
minHeight: 44,
|
|
backgroundColor: colors.backgroundDefault
|
|
}}
|
|
>
|
|
<CustomText
|
|
style={{
|
|
textAlign: 'center',
|
|
...StyleConstants.FontStyle.S,
|
|
color: colors.primaryDefault,
|
|
marginRight: StyleConstants.Spacing.S
|
|
}}
|
|
children={t('HTML.expanded', {
|
|
hint: expandHint,
|
|
moreLines:
|
|
numberOfLines > 1 && typeof totalLines === 'number'
|
|
? t('HTML.moreLines', { count: totalLines - numberOfLines })
|
|
: ''
|
|
})}
|
|
/>
|
|
<Icon
|
|
name={expanded ? 'Minimize2' : 'Maximize2'}
|
|
color={colors.primaryDefault}
|
|
strokeWidth={2}
|
|
size={StyleConstants.Font.Size[size]}
|
|
/>
|
|
</Pressable>
|
|
) : null}
|
|
<CustomText
|
|
children={children}
|
|
onTextLayout={({ nativeEvent }) => {
|
|
if (numberOfLines === 1 || nativeEvent.lines.length >= numberOfLines + 5) {
|
|
setTotalLines(nativeEvent.lines.length)
|
|
}
|
|
}}
|
|
style={{
|
|
...textStyles,
|
|
height: numberOfLines === 1 && !expanded ? 0 : undefined
|
|
}}
|
|
numberOfLines={
|
|
typeof totalLines === 'number' ? (expanded ? 999 : numberOfLines) : undefined
|
|
}
|
|
selectable={selectable}
|
|
/>
|
|
</View>
|
|
)
|
|
},
|
|
[theme]
|
|
)
|
|
|
|
return (
|
|
<HTMLView
|
|
value={content}
|
|
TextComponent={textComponent}
|
|
RootComponent={rootComponent}
|
|
renderNode={renderNodeCallback}
|
|
/>
|
|
)
|
|
},
|
|
(prev, next) => prev.content === next.content && isEqual(prev.emojis, next.emojis)
|
|
)
|
|
|
|
export default ParseHTML
|