import Icon from '@components/Icon' import openLink from '@components/openLink' import ParseEmojis from '@components/Parse/Emojis' import StatusContext from '@components/Timeline/Shared/Context' import { useNavigation, useRoute } from '@react-navigation/native' import { StackNavigationProp } from '@react-navigation/stack' import { urlMatcher } from '@utils/helpers/urlMatcher' import { TabLocalStackParamList, TabSharedStackParamList } from '@utils/navigation/navigators' import { useAccountStorage, useGlobalStorage } from '@utils/storage/actions' 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 i18next from 'i18next' import React, { useContext, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { ColorValue, Platform, Pressable, Text, View } from 'react-native' export interface Props { content: string size?: 'S' | 'M' | 'L' color?: ColorValue adaptiveSize?: boolean showFullLink?: boolean numberOfLines?: number expandHint?: string selectable?: boolean setSpoilerExpanded?: React.Dispatch> emojis?: Mastodon.Emoji[] mentions?: Mastodon.Mention[] } const ParseHTML: React.FC = ({ content, size = 'M', color, adaptiveSize = false, showFullLink = false, numberOfLines = 10, expandHint, selectable = false, setSpoilerExpanded, emojis, mentions }) => { const { status, highlighted, disableDetails, excludeMentions } = useContext(StatusContext) const [adaptiveFontsize] = useGlobalStorage.number('app.font_size') const adaptedFontsize = adaptiveScale( StyleConstants.Font.Size[size], adaptiveSize ? adaptiveFontsize : 0 ) const adaptedLineheight = Platform.OS === 'ios' ? adaptiveScale(StyleConstants.Font.LineHeight[size], adaptiveSize ? adaptiveFontsize : 0) : undefined const navigation = useNavigation>() const { params } = useRoute() const { colors } = useTheme() const colorPrimary = color || colors.primaryDefault const { t } = useTranslation('componentParse') if (!expandHint) { expandHint = t('HTML.defaultHint') } if (disableDetails) { numberOfLines = 4 } const [followedTags] = useAccountStorage.object('followed_tags') const MAX_ALLOWED_LINES = 35 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') && !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('') default: return '' } } const prevMentionRemoved = useRef(false) const renderNode = (node: ChildNode, index: number) => { switch (node.type) { case ElementType.Text: let content: string = node.data if (prevMentionRemoved.current) { prevMentionRemoved.current = false // Removing empty spaces appeared between tags and mentions if (node.data.trim().length) { content = excludeMentions?.current.length ? node.data.replace(new RegExp(/^\s+/), '') : node.data } else { content = node.data.trim() } } 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 children = node.children.map(unwrapNode).join('') const tag = href.match(new RegExp(/\/tags?\/(.*)/, 'i'))?.[1]?.toLowerCase() || children.match(new RegExp(/#(\S+)/))?.[1]?.toLowerCase() const paramsHashtag = (params as { hashtag: Mastodon.Tag['name'] } | undefined) ?.hashtag const sameHashtag = paramsHashtag === tag const isFollowing = followedTags?.find(t => t.name === tag) return ( tag?.length && !disableDetails && !sameHashtag && navigation.push('Tab-Shared-Hashtag', { tag_name: tag }) } children={children} /> ) } if (classes.includes('mention') && (status?.mentions?.length || mentions?.length)) { let matchedMention: | TabSharedStackParamList['Tab-Shared-Account']['account'] | undefined = (status?.mentions || mentions || []).find( mention => mention.url === href ) if ( matchedMention && excludeMentions?.current.find(eM => eM.id === matchedMention?.id) ) { prevMentionRemoved.current = true return null } if (!matchedMention) { const match = urlMatcher(href) if (match?.account?.acct) { // @ts-ignore matchedMention = { ...match.account, url: href } } } const paramsAccount = (params as { account: Mastodon.Account } | undefined)?.account const sameAccount = paramsAccount ? paramsAccount.id === matchedMention?.id : false return ( matchedMention && !disableDetails && !sameAccount && navigation.push('Tab-Shared-Account', { account: matchedMention }) } children={node.children.map(unwrapNode).join('')} /> ) } } const content = node.children.map(child => unwrapNode(child)).join('') const shouldBeTag = status?.tags?.find(tag => `#${tag.name}` === content) return ( { if (!disableDetails) { if (shouldBeTag) { navigation.push('Tab-Shared-Hashtag', { tag_name: content.substring(1) }) } else { await openLink(href, navigation) } } }} children={content} /> ) break case 'br': return ( {'\n'} ) 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 === MAX_ALLOWED_LINES ? (`${totalLines - numberOfLines}+` as unknown as number) : totalLines - numberOfLines }) : '' })} /> ) : null} { if (numberOfLines === 1 || nativeEvent.lines.length >= numberOfLines + 5) { setTotalLines(nativeEvent.lines.length) } }} style={{ fontSize: adaptedFontsize, lineHeight: adaptedLineheight, ...(Platform.OS === 'ios' && status?.language && i18next.dir(status.language) === 'rtl' && ({ writingDirection: 'rtl' } as { writingDirection: 'rtl' })), height: numberOfLines === 1 && !expanded ? 0 : undefined }} numberOfLines={ typeof totalLines === 'number' ? expanded ? 999 : numberOfLines : Math.max(MAX_ALLOWED_LINES, numberOfLines) } selectable={selectable} /> ) } export default ParseHTML