diff --git a/native/markdown/markdown-context-provider.react.js b/native/markdown/markdown-context-provider.react.js index 3ee8fe21c..4dcc59dce 100644 --- a/native/markdown/markdown-context-provider.react.js +++ b/native/markdown/markdown-context-provider.react.js @@ -1,36 +1,51 @@ // @flow import * as React from 'react'; import { MarkdownContext } from './markdown-context.js'; type Props = { +children: React.Node, }; function MarkdownContextProvider(props: Props): React.Node { const [linkModalActive, setLinkModalActive] = React.useState<{ [key: string]: boolean, }>({}); + const [spoilerRevealed, setSpoilerRevealed] = React.useState<{ + [key: string]: { + [key: number]: boolean, + }, + }>({}); + const clearMarkdownContextData = React.useCallback(() => { setLinkModalActive({}); + setSpoilerRevealed({}); }, []); const contextValue = React.useMemo( () => ({ setLinkModalActive, linkModalActive, + setSpoilerRevealed, + spoilerRevealed, clearMarkdownContextData, }), - [setLinkModalActive, linkModalActive, clearMarkdownContextData], + [ + setLinkModalActive, + linkModalActive, + setSpoilerRevealed, + spoilerRevealed, + clearMarkdownContextData, + ], ); return ( {props.children} ); } export default MarkdownContextProvider; diff --git a/native/markdown/markdown-context.js b/native/markdown/markdown-context.js index 93c62b1b9..5be28c8df 100644 --- a/native/markdown/markdown-context.js +++ b/native/markdown/markdown-context.js @@ -1,17 +1,19 @@ // @flow import * as React from 'react'; import type { SetState } from 'lib/types/hook-types'; export type MarkdownContextType = { +setLinkModalActive: SetState<{ [key: string]: boolean }>, +linkModalActive: { [key: string]: boolean }, + +setSpoilerRevealed: SetState<{ [key: string]: { [key: number]: boolean } }>, + +spoilerRevealed: { [key: string]: { [key: number]: boolean } }, +clearMarkdownContextData: () => void, }; const MarkdownContext: React.Context = React.createContext( null, ); export { MarkdownContext }; diff --git a/native/markdown/markdown-spoiler.react.js b/native/markdown/markdown-spoiler.react.js index 1fdf3d0c7..ec096983d 100644 --- a/native/markdown/markdown-spoiler.react.js +++ b/native/markdown/markdown-spoiler.react.js @@ -1,45 +1,88 @@ // @flow +import invariant from 'invariant'; import * as React from 'react'; import { Text } from 'react-native'; import type { ReactElement } from 'lib/shared/markdown'; +import { TextMessageMarkdownContext } from '../chat/text-message-markdown-context'; import { useStyles } from '../themes/colors'; +import { MarkdownContext } from './markdown-context'; type MarkdownSpoilerProps = { + +spoilerIdentifier: string | number | void, +text: ReactElement, +children?: React.Node, }; function MarkdownSpoiler(props: MarkdownSpoilerProps): React.Node { - const [isRevealed, setIsRevealed] = React.useState(false); + const markdownContext = React.useContext(MarkdownContext); + invariant(markdownContext, 'MarkdownContext should be set'); + + const textMessageMarkdownContext = React.useContext( + TextMessageMarkdownContext, + ); + const styles = useStyles(unboundStyles); - const { text } = props; + + const { text, spoilerIdentifier } = props; + const { spoilerRevealed, setSpoilerRevealed } = markdownContext; + const messageKey = textMessageMarkdownContext?.messageKey; + + const parsedSpoilerIdentifier = spoilerIdentifier + ? parseInt(spoilerIdentifier) + : -1; + + const isRevealed = + (messageKey && spoilerRevealed[messageKey]?.[parsedSpoilerIdentifier]) ?? + false; + + const styleBasedOnSpoilerState = React.useMemo(() => { + if (isRevealed) { + return null; + } + return styles.spoilerHidden; + }, [isRevealed, styles.spoilerHidden]); const onSpoilerClick = React.useCallback(() => { - setIsRevealed(true); - }, []); + if (isRevealed) { + return; + } + + if (messageKey && parsedSpoilerIdentifier !== -1) { + setSpoilerRevealed({ + ...spoilerRevealed, + [messageKey]: { + ...spoilerRevealed[messageKey], + [parsedSpoilerIdentifier]: true, + }, + }); + } + }, [ + isRevealed, + spoilerRevealed, + setSpoilerRevealed, + messageKey, + parsedSpoilerIdentifier, + ]); const memoizedSpoiler = React.useMemo(() => { return ( - + {text} ); - }, [onSpoilerClick, isRevealed, styles.spoilerHidden, text]); + }, [onSpoilerClick, styleBasedOnSpoilerState, text]); return memoizedSpoiler; } const unboundStyles = { spoilerHidden: { color: 'spoiler', backgroundColor: 'spoiler', }, }; export default MarkdownSpoiler; diff --git a/native/markdown/rules.react.js b/native/markdown/rules.react.js index 2ae094610..b2ac7d97d 100644 --- a/native/markdown/rules.react.js +++ b/native/markdown/rules.react.js @@ -1,415 +1,419 @@ // @flow import _memoize from 'lodash/memoize'; import * as React from 'react'; import { Text, View } from 'react-native'; import { createSelector } from 'reselect'; import * as SimpleMarkdown from 'simple-markdown'; import { relativeMemberInfoSelectorForMembersOfThread } from 'lib/selectors/user-selectors'; import * as SharedMarkdown from 'lib/shared/markdown'; import type { RelativeMemberInfo } from 'lib/types/thread-types'; import { useSelector } from '../redux/redux-utils'; import MarkdownLink from './markdown-link.react'; import MarkdownParagraph from './markdown-paragraph.react'; import MarkdownSpoiler from './markdown-spoiler.react'; import { getMarkdownStyles } from './styles'; export type MarkdownRules = { +simpleMarkdownRules: SharedMarkdown.ParserRules, +emojiOnlyFactor: ?number, // We need to use a Text container for Entry because it needs to match up // exactly with TextInput. However, if we use a Text container, we can't // support styles for things like blockQuote, which rely on rendering as a // View, and Views can't be nested inside Texts without explicit height and // width +container: 'View' | 'Text', }; // Entry requires a seamless transition between Markdown and TextInput // components, so we can't do anything that would change the position of text const inlineMarkdownRules: boolean => MarkdownRules = _memoize(useDarkStyle => { const styles = getMarkdownStyles(useDarkStyle ? 'dark' : 'light'); const simpleMarkdownRules = { // Matches 'https://google.com' during parse phase and returns a 'link' node url: { ...SimpleMarkdown.defaultRules.url, // simple-markdown is case-sensitive, but we don't want to be match: SimpleMarkdown.inlineRegex(SharedMarkdown.urlRegex), }, // Matches '[Google](https://google.com)' during parse phase and handles // rendering all 'link' nodes, including for 'autolink' and 'url' link: { ...SimpleMarkdown.defaultRules.link, match: () => null, react( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) { return ( {output(node.content, state)} ); }, }, // Each line gets parsed into a 'paragraph' node. The AST returned by the // parser will be an array of one or more 'paragraph' nodes paragraph: { ...SimpleMarkdown.defaultRules.paragraph, // simple-markdown's default RegEx collapses multiple newlines into one. // We want to keep the newlines, but when rendering within a View, we // strip just one trailing newline off, since the View adds vertical // spacing between its children match: (source: string, state: SharedMarkdown.State) => { if (state.inline) { return null; } else if (state.container === 'View') { return SharedMarkdown.paragraphStripTrailingNewlineRegex.exec(source); } else { return SharedMarkdown.paragraphRegex.exec(source); } }, parse( capture: SharedMarkdown.Capture, parse: SharedMarkdown.Parser, state: SharedMarkdown.State, ) { let content = capture[1]; if (state.container === 'View') { // React Native renders empty lines with less height. We want to // preserve the newline characters, so we replace empty lines with a // single space content = content.replace(/^$/m, ' '); } return { content: SimpleMarkdown.parseInline(parse, content, state), }; }, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, // This is the leaf node in the AST returned by the parse phase text: SimpleMarkdown.defaultRules.text, }; return { simpleMarkdownRules, emojiOnlyFactor: null, container: 'Text', }; }); // We allow the most markdown features for TextMessage, which doesn't have the // same requirements as Entry const fullMarkdownRules: boolean => MarkdownRules = _memoize(useDarkStyle => { const styles = getMarkdownStyles(useDarkStyle ? 'dark' : 'light'); const inlineRules = inlineMarkdownRules(useDarkStyle); const simpleMarkdownRules = { ...inlineRules.simpleMarkdownRules, // Matches '' during parse phase and returns a 'link' // node autolink: SimpleMarkdown.defaultRules.autolink, // Matches '[Google](https://google.com)' during parse phase and handles // rendering all 'link' nodes, including for 'autolink' and 'url' link: { ...inlineRules.simpleMarkdownRules.link, match: SimpleMarkdown.defaultRules.link.match, }, mailto: SimpleMarkdown.defaultRules.mailto, em: { ...SimpleMarkdown.defaultRules.em, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, strong: { ...SimpleMarkdown.defaultRules.strong, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, u: { ...SimpleMarkdown.defaultRules.u, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, del: { ...SimpleMarkdown.defaultRules.del, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {output(node.content, state)} ), }, spoiler: { order: SimpleMarkdown.defaultRules.paragraph.order - 1, match: SimpleMarkdown.inlineRegex(SharedMarkdown.spoilerRegex), parse( capture: SharedMarkdown.Capture, parse: SharedMarkdown.Parser, state: SharedMarkdown.State, ) { const content = capture[1]; return { content: SimpleMarkdown.parseInline(parse, content, state), }; }, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( - + ), }, inlineCode: { ...SimpleMarkdown.defaultRules.inlineCode, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {node.content} ), }, heading: { ...SimpleMarkdown.defaultRules.heading, match: SimpleMarkdown.blockRegex( SharedMarkdown.headingStripFollowingNewlineRegex, ), // eslint-disable-next-line react/display-name react( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) { const headingStyle = styles['h' + node.level]; return ( {output(node.content, state)} ); }, }, blockQuote: { ...SimpleMarkdown.defaultRules.blockQuote, // match end of blockQuote by either \n\n or end of string match: SharedMarkdown.matchBlockQuote( SharedMarkdown.blockQuoteStripFollowingNewlineRegex, ), parse: SharedMarkdown.parseBlockQuote, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => { const { isNestedQuote } = state; const backgroundColor = isNestedQuote ? '#00000000' : '#00000066'; return ( {output(node.content, { ...state, isNestedQuote: true })} ); }, }, codeBlock: { ...SimpleMarkdown.defaultRules.codeBlock, match: SimpleMarkdown.blockRegex( SharedMarkdown.codeBlockStripTrailingNewlineRegex, ), parse(capture: SharedMarkdown.Capture) { return { content: capture[1].replace(/^ {4}/gm, ''), }; }, // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {node.content} ), }, fence: { ...SimpleMarkdown.defaultRules.fence, match: SimpleMarkdown.blockRegex( SharedMarkdown.fenceStripTrailingNewlineRegex, ), parse: (capture: SharedMarkdown.Capture) => ({ type: 'codeBlock', content: capture[2], }), }, json: { order: SimpleMarkdown.defaultRules.paragraph.order - 1, match: (source: string, state: SharedMarkdown.State) => { if (state.inline) { return null; } return SharedMarkdown.jsonMatch(source); }, parse: (capture: SharedMarkdown.Capture) => { const jsonCapture: SharedMarkdown.JSONCapture = (capture: any); return { type: 'codeBlock', content: SharedMarkdown.jsonPrint(jsonCapture), }; }, }, list: { ...SimpleMarkdown.defaultRules.list, match: SharedMarkdown.matchList, parse: SharedMarkdown.parseList, react( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) { const children = node.items.map((item, i) => { const content = output(item, state); const bulletValue = node.ordered ? node.start + i + '. ' : '\u2022 '; return ( {bulletValue} {content} ); }); return {children}; }, }, escape: SimpleMarkdown.defaultRules.escape, }; return { ...inlineRules, simpleMarkdownRules, emojiOnlyFactor: 2, container: 'View', }; }); function useTextMessageRulesFunc( threadID: ?string, ): (useDarkStyle: boolean) => MarkdownRules { return useSelector(getTextMessageRulesFunction(threadID)); } const getTextMessageRulesFunction = _memoize((threadID: ?string) => createSelector( relativeMemberInfoSelectorForMembersOfThread(threadID), ( threadMembers: $ReadOnlyArray, ): (boolean => MarkdownRules) => { if (!threadID) { return fullMarkdownRules; } return _memoize<[boolean], MarkdownRules>((useDarkStyle: boolean) => textMessageRules(threadMembers, useDarkStyle), ); }, ), ); function textMessageRules( members: $ReadOnlyArray, useDarkStyle: boolean, ): MarkdownRules { const styles = getMarkdownStyles(useDarkStyle ? 'dark' : 'light'); const baseRules = fullMarkdownRules(useDarkStyle); return { ...baseRules, simpleMarkdownRules: { ...baseRules.simpleMarkdownRules, mention: { ...SimpleMarkdown.defaultRules.strong, match: SharedMarkdown.matchMentions(members), parse: (capture: SharedMarkdown.Capture) => ({ content: capture[0], }), // eslint-disable-next-line react/display-name react: ( node: SharedMarkdown.SingleASTNode, output: SharedMarkdown.Output, state: SharedMarkdown.State, ) => ( {node.content} ), }, }, }; } let defaultTextMessageRules = null; function getDefaultTextMessageRules(): MarkdownRules { if (!defaultTextMessageRules) { defaultTextMessageRules = textMessageRules([], false); } return defaultTextMessageRules; } export { inlineMarkdownRules, useTextMessageRulesFunc, getDefaultTextMessageRules, };