// Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; import type { ReactElement } from 'react'; import classNames from 'classnames'; import emojiRegex from 'emoji-regex'; import { sortBy } from 'lodash'; import { linkify, SUPPORTED_PROTOCOLS } from './Linkify'; import type { BodyRangesForDisplayType, DisplayNode, HydratedBodyRangeMention, RangeNode, } from '../../types/BodyRange'; import { SPOILER_REPLACEMENT, BodyRange, insertRange, collapseRangeTree, groupContiguousSpoilers, } from '../../types/BodyRange'; import { AtMention } from './AtMention'; import { isLinkSneaky } from '../../types/LinkPreview'; import { Emojify } from './Emojify'; import { AddNewLines } from './AddNewLines'; import type { SizeClassType } from '../emoji/lib'; import type { LocalizerType } from '../../types/Util'; const EMOJI_REGEXP = emojiRegex(); export enum RenderLocation { ConversationList = 'ConversationList', Quote = 'Quote', MediaEditor = 'MediaEditor', SearchResult = 'SearchResult', StoryViewer = 'StoryViewer', Timeline = 'Timeline', } type Props = { bodyRanges: BodyRangesForDisplayType; direction: 'incoming' | 'outgoing' | undefined; disableLinks: boolean; emojiSizeClass: SizeClassType | undefined; i18n: LocalizerType; isSpoilerExpanded: Record; messageText: string; onExpandSpoiler?: (data: Record) => void; onMentionTrigger: (conversationId: string) => void; renderLocation: RenderLocation; // Sometimes we're passed a string with a suffix (like '...'); we won't process that textLength: number; }; export function MessageTextRenderer({ bodyRanges, direction, disableLinks, emojiSizeClass, i18n, isSpoilerExpanded, messageText, onExpandSpoiler, onMentionTrigger, renderLocation, textLength, }: Props): JSX.Element { const finalNodes = React.useMemo(() => { const links = disableLinks ? [] : extractLinks(messageText); // We need mentions to come last; they can't have children for proper rendering const sortedRanges = sortBy(bodyRanges, range => BodyRange.isMention(range) ? 1 : 0 ); // Create range tree, dropping bodyRanges that don't apply. Read More means truncated // strings. const tree = sortedRanges.reduce>( (acc, range) => { if (range.start < textLength) { return insertRange(range, acc); } return acc; }, links.map(b => ({ ...b, ranges: [] })) ); // Turn tree into flat list for proper spoiler rendering const nodes = collapseRangeTree({ tree, text: messageText }); // Group all contigusous spoilers to create one parent spoiler element in the DOM return groupContiguousSpoilers(nodes); }, [bodyRanges, disableLinks, messageText, textLength]); return ( <> {finalNodes.map(node => renderNode({ direction, disableLinks, emojiSizeClass, i18n, isInvisible: false, isSpoilerExpanded, node, renderLocation, onMentionTrigger, onExpandSpoiler, }) )} ); } function renderNode({ direction, disableLinks, emojiSizeClass, i18n, isInvisible, isSpoilerExpanded, node, onExpandSpoiler, onMentionTrigger, renderLocation, }: { direction: 'incoming' | 'outgoing' | undefined; disableLinks: boolean; emojiSizeClass: SizeClassType | undefined; i18n: LocalizerType; isInvisible: boolean; isSpoilerExpanded: Record; node: DisplayNode; onExpandSpoiler?: (data: Record) => void; onMentionTrigger: ((conversationId: string) => void) | undefined; renderLocation: RenderLocation; }): ReactElement { const key = node.start; if (node.isSpoiler && node.spoilerChildren?.length) { const isSpoilerHidden = Boolean( node.isSpoiler && !isSpoilerExpanded[node.spoilerIndex || 0] ); const content = node.spoilerChildren?.map(spoilerNode => renderNode({ direction, disableLinks, emojiSizeClass, i18n, isInvisible: isSpoilerHidden, isSpoilerExpanded, node: spoilerNode, renderLocation, onMentionTrigger, onExpandSpoiler, }) ); if (!isSpoilerHidden) { return ( {content} ); } return ( { if (onExpandSpoiler) { event.preventDefault(); event.stopPropagation(); onExpandSpoiler({ ...isSpoilerExpanded, [node.spoilerIndex || 0]: true, }); } } } onKeyDown={ disableLinks ? undefined : event => { if (event.key !== 'Enter' && event.key !== ' ') { return; } event.preventDefault(); event.stopPropagation(); onExpandSpoiler?.({ ...isSpoilerExpanded, [node.spoilerIndex || 0]: true, }); } } > {SPOILER_REPLACEMENT} {content} ); } let content = renderMentions({ direction, disableLinks, emojiSizeClass, isInvisible, mentions: node.mentions, onMentionTrigger, text: node.text, }); // We use separate elements for these because we want screenreaders to understand them if (node.isBold || node.isKeywordHighlight) { content = {content}; } if (node.isItalic) { content = {content}; } if (node.isStrikethrough) { content = {content}; } const formattingClasses = classNames( node.isMonospace ? 'MessageTextRenderer__formatting--monospace' : null, node.isKeywordHighlight ? 'MessageTextRenderer__formatting--keywordHighlight' : null, isInvisible ? 'MessageTextRenderer__formatting--invisible' : null ); if ( node.url && SUPPORTED_PROTOCOLS.test(node.url) && !isLinkSneaky(node.url) ) { return ( {content} ); } return ( {content} ); } function renderMentions({ direction, disableLinks, emojiSizeClass, isInvisible, mentions, onMentionTrigger, text, }: { emojiSizeClass: SizeClassType | undefined; isInvisible: boolean; mentions: ReadonlyArray; text: string; disableLinks: boolean; direction: 'incoming' | 'outgoing' | undefined; onMentionTrigger: ((conversationId: string) => void) | undefined; }): ReactElement { const result: Array = []; let offset = 0; for (const mention of mentions) { // collect any previous text if (mention.start > offset) { result.push( renderText({ isInvisible, key: result.length.toString(), emojiSizeClass, text: text.slice(offset, mention.start), }) ); } result.push( renderMention({ isInvisible, key: result.length.toString(), conversationId: mention.conversationID, disableLinks, direction, name: mention.replacementText, onMentionTrigger, }) ); offset = mention.start + mention.length; } // collect any text after result.push( renderText({ isInvisible, key: result.length.toString(), emojiSizeClass, text: text.slice(offset, text.length), }) ); return <>{result}; } function renderMention({ conversationId, name, isInvisible, key, disableLinks, direction, onMentionTrigger, }: { conversationId: string; name: string; isInvisible: boolean; key: string; disableLinks: boolean; direction: 'incoming' | 'outgoing' | undefined; onMentionTrigger: ((conversationId: string) => void) | undefined; }): ReactElement { if (disableLinks) { return ( @ ); } return ( { if (onMentionTrigger) { onMentionTrigger(conversationId); } }} onKeyUp={e => { if ( e.target === e.currentTarget && e.key === 'Enter' && onMentionTrigger ) { onMentionTrigger(conversationId); } }} /> ); } /** Render text that does not contain body ranges or is in between body ranges */ function renderText({ text, emojiSizeClass, isInvisible, key, }: { text: string; emojiSizeClass: SizeClassType | undefined; isInvisible: boolean; key: string; }) { return ( ( )} sizeClass={emojiSizeClass} text={text} /> ); } export function extractLinks( messageText: string ): ReadonlyArray> { // to support emojis immediately before links // we replace emojis with a space for each byte const matches = linkify.match( messageText.replace(EMOJI_REGEXP, s => ' '.repeat(s.length)) ); if (matches == null) { return []; } return matches.map(match => { return { start: match.index, length: match.lastIndex - match.index, url: match.url, }; }); }