mirror of
https://github.com/tooot-app/app
synced 2025-06-05 22:19:13 +02:00
@ -1,147 +1,23 @@
|
||||
import Icon from '@components/Icon'
|
||||
import openLink from '@components/openLink'
|
||||
import ParseEmojis from '@components/Parse/Emojis'
|
||||
import CustomText from '@components/Text'
|
||||
import { getHost } from '@helpers/urlMatcher'
|
||||
import { useNavigation, useRoute } from '@react-navigation/native'
|
||||
import { StackNavigationProp } from '@react-navigation/stack'
|
||||
import { TabLocalStackParamList } from '@utils/navigation/navigators'
|
||||
import { useFollowedTagsQuery } from '@utils/queryHooks/tags'
|
||||
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 { ChildNode } from 'domhandler'
|
||||
import { ElementType, parseDocument } from 'htmlparser2'
|
||||
import { isEqual } from 'lodash'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Pressable, TextStyleIOS, View } from 'react-native'
|
||||
import HTMLView from 'react-native-htmlview'
|
||||
import { Pressable, Text, TextStyleIOS, View } from 'react-native'
|
||||
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 host = getHost(href)
|
||||
// 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 : host}
|
||||
{!shouldBeTag ? '/...' : 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'
|
||||
@ -187,8 +63,8 @@ const ParseHTML = React.memo(
|
||||
)
|
||||
|
||||
const navigation = useNavigation<StackNavigationProp<TabLocalStackParamList>>()
|
||||
const route = useRoute()
|
||||
const { colors, theme } = useTheme()
|
||||
const { params } = useRoute()
|
||||
const { colors } = useTheme()
|
||||
const { t } = useTranslation('componentParse')
|
||||
if (!expandHint) {
|
||||
expandHint = t('HTML.defaultHint')
|
||||
@ -198,116 +74,195 @@ const ParseHTML = React.memo(
|
||||
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 followedTags = useFollowedTagsQuery()
|
||||
|
||||
const [totalLines, setTotalLines] = useState<number>()
|
||||
const [expanded, setExpanded] = useState(highlighted)
|
||||
|
||||
const document = parseDocument(content)
|
||||
const unwrapNode = (node: ChildNode): string => {
|
||||
switch (node.type) {
|
||||
case ElementType.Text:
|
||||
return node.data
|
||||
case ElementType.Tag:
|
||||
if (node.name === 'span') {
|
||||
if (node.attribs.class?.includes('invisible')) return ''
|
||||
if (node.attribs.class?.includes('ellipsis'))
|
||||
return node.children.map(child => unwrapNode(child)).join('') + '...'
|
||||
}
|
||||
return node.children.map(child => unwrapNode(child)).join('')
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}, [])
|
||||
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}
|
||||
}
|
||||
const renderNode = (node: ChildNode, index: number) => {
|
||||
switch (node.type) {
|
||||
case ElementType.Text:
|
||||
return (
|
||||
<ParseEmojis
|
||||
key={index}
|
||||
content={node.data}
|
||||
emojis={emojis}
|
||||
size={size}
|
||||
adaptiveSize={adaptiveSize}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
},
|
||||
[theme]
|
||||
)
|
||||
)
|
||||
case ElementType.Tag:
|
||||
switch (node.name) {
|
||||
case 'a':
|
||||
const classes = node.attribs.class
|
||||
const href = node.attribs.href
|
||||
if (classes) {
|
||||
if (classes.includes('hashtag')) {
|
||||
const tag = href.match(new RegExp(/\/tags?\/(.*)/, 'i'))?.[1]
|
||||
const paramsHashtag = (params as { hashtag: Mastodon.Tag['name'] } | undefined)
|
||||
?.hashtag
|
||||
const sameHashtag = paramsHashtag === tag
|
||||
const isFollowing = followedTags.data?.pages[0]?.body.find(t => t.name === tag)
|
||||
return (
|
||||
<Text
|
||||
key={index}
|
||||
style={[
|
||||
{ color: tag?.length ? colors.blue : colors.red },
|
||||
isFollowing
|
||||
? {
|
||||
textDecorationColor: tag?.length ? colors.blue : colors.red,
|
||||
textDecorationLine: 'underline',
|
||||
textDecorationStyle: 'dotted'
|
||||
}
|
||||
: null
|
||||
]}
|
||||
onPress={() =>
|
||||
tag?.length &&
|
||||
!disableDetails &&
|
||||
!sameHashtag &&
|
||||
navigation.push('Tab-Shared-Hashtag', { hashtag: tag })
|
||||
}
|
||||
children={node.children.map(unwrapNode).join('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (classes.includes('mention') && mentions?.length) {
|
||||
const mentionIndex = mentions.findIndex(mention => mention.url === href)
|
||||
const paramsAccount = (params as { account: Mastodon.Account } | undefined)
|
||||
?.account
|
||||
const sameAccount = paramsAccount?.id === mentions[mentionIndex]?.id
|
||||
return (
|
||||
<Text
|
||||
key={index}
|
||||
style={{ color: mentionIndex > -1 ? colors.blue : undefined }}
|
||||
onPress={() =>
|
||||
mentionIndex > -1 &&
|
||||
!disableDetails &&
|
||||
!sameAccount &&
|
||||
navigation.push('Tab-Shared-Account', { account: mentions[mentionIndex] })
|
||||
}
|
||||
children={node.children.map(unwrapNode).join('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const content = node.children.map(child => unwrapNode(child)).join('')
|
||||
const shouldBeTag = tags && tags.find(tag => `#${tag.name}` === content)
|
||||
return (
|
||||
<Text
|
||||
key={index}
|
||||
style={{ color: colors.blue }}
|
||||
onPress={async () => {
|
||||
if (!disableDetails) {
|
||||
if (shouldBeTag) {
|
||||
navigation.push('Tab-Shared-Hashtag', {
|
||||
hashtag: content.substring(1)
|
||||
})
|
||||
} else {
|
||||
await openLink(href, navigation)
|
||||
}
|
||||
}
|
||||
}}
|
||||
children={content !== href ? content : showFullLink ? href : content}
|
||||
/>
|
||||
)
|
||||
break
|
||||
case 'p':
|
||||
if (index < document.children.length - 1) {
|
||||
return (
|
||||
<Text key={index}>
|
||||
{node.children.map((c, i) => renderNode(c, i))}
|
||||
<Text style={{ lineHeight: adaptedLineheight / 2 }}>{'\n\n'}</Text>
|
||||
</Text>
|
||||
)
|
||||
} else {
|
||||
return <Text key={index} children={node.children.map((c, i) => renderNode(c, i))} />
|
||||
}
|
||||
default:
|
||||
return <Text key={index} children={node.children.map((c, i) => renderNode(c, i))} />
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<HTMLView
|
||||
value={content}
|
||||
TextComponent={textComponent}
|
||||
RootComponent={rootComponent}
|
||||
renderNode={renderNodeCallback}
|
||||
/>
|
||||
<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
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
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}
|
||||
<Text
|
||||
children={document.children.map(renderNode)}
|
||||
onTextLayout={({ nativeEvent }) => {
|
||||
if (numberOfLines === 1 || nativeEvent.lines.length >= numberOfLines + 5) {
|
||||
setTotalLines(nativeEvent.lines.length)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
fontSize: adaptedFontsize,
|
||||
lineHeight: adaptedLineheight,
|
||||
...textStyles,
|
||||
height: numberOfLines === 1 && !expanded ? 0 : undefined
|
||||
}}
|
||||
numberOfLines={
|
||||
typeof totalLines === 'number' ? (expanded ? 999 : numberOfLines) : undefined
|
||||
}
|
||||
selectable={selectable}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
},
|
||||
(prev, next) => prev.content === next.content && isEqual(prev.emojis, next.emojis)
|
||||
|
@ -5,7 +5,7 @@ import { StyleConstants } from '@utils/styles/constants'
|
||||
import { useTheme } from '@utils/styles/ThemeManager'
|
||||
import React, { useContext } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Platform } from 'react-native'
|
||||
import { Platform, View } from 'react-native'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { isRtlLang } from 'rtl-detect'
|
||||
import StatusContext from './Context'
|
||||
@ -24,7 +24,7 @@ const TimelineContent: React.FC<Props> = ({ notificationOwnToot = false, setSpoi
|
||||
const instanceAccount = useSelector(getInstanceAccount, () => true)
|
||||
|
||||
return (
|
||||
<>
|
||||
<View>
|
||||
{status.spoiler_text?.length ? (
|
||||
<>
|
||||
<ParseHTML
|
||||
@ -97,7 +97,7 @@ const TimelineContent: React.FC<Props> = ({ notificationOwnToot = false, setSpoi
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
|
Reference in New Issue
Block a user