import Icon from '@components/Icon' import openLink from '@components/openLink' import ParseEmojis from '@components/Parse/Emojis' 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, { useState } from 'react' import { useTranslation } from 'react-i18next' import { Pressable, Text, TextStyleIOS, View } from 'react-native' import { useSelector } from 'react-redux' 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> } 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>() const { params } = useRoute() const { colors } = useTheme() const { t } = useTranslation('componentParse') if (!expandHint) { expandHint = t('HTML.defaultHint') } if (disableDetails) { numberOfLines = 4 } const followedTags = useFollowedTagsQuery() const [totalLines, setTotalLines] = useState() 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 renderNode = (node: ChildNode, index: number) => { switch (node.type) { case ElementType.Text: return ( ) 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 ( 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 ( -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 ( { 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 ( {node.children.map((c, i) => renderNode(c, i))} {'\n\n'} ) } else { return renderNode(c, i))} /> } default: return renderNode(c, i))} /> } } return null } return ( {(!disableDetails && typeof totalLines === 'number') || numberOfLines === 1 ? ( { layoutAnimation() setExpanded(!expanded) if (setSpoilerExpanded) { setSpoilerExpanded(!expanded) } }} style={{ flexDirection: 'row', justifyContent: 'center', alignItems: 'center', minHeight: 44, backgroundColor: colors.backgroundDefault }} > 1 && typeof totalLines === 'number' ? t('HTML.moreLines', { count: totalLines - numberOfLines }) : '' })} /> ) : null} { 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} /> ) }, (prev, next) => prev.content === next.content && isEqual(prev.emojis, next.emojis) ) export default ParseHTML