diff --git a/native/markdown/markdown-spoiler.react.js b/native/markdown/markdown-spoiler.react.js
new file mode 100644
index 000000000..1fdf3d0c7
--- /dev/null
+++ b/native/markdown/markdown-spoiler.react.js
@@ -0,0 +1,45 @@
+// @flow
+
+import * as React from 'react';
+import { Text } from 'react-native';
+
+import type { ReactElement } from 'lib/shared/markdown';
+
+import { useStyles } from '../themes/colors';
+
+type MarkdownSpoilerProps = {
+ +text: ReactElement,
+ +children?: React.Node,
+};
+
+function MarkdownSpoiler(props: MarkdownSpoilerProps): React.Node {
+ const [isRevealed, setIsRevealed] = React.useState(false);
+ const styles = useStyles(unboundStyles);
+ const { text } = props;
+
+ const onSpoilerClick = React.useCallback(() => {
+ setIsRevealed(true);
+ }, []);
+
+ const memoizedSpoiler = React.useMemo(() => {
+ return (
+
+ {text}
+
+ );
+ }, [onSpoilerClick, isRevealed, styles.spoilerHidden, 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 f926d1cfb..2ae094610 100644
--- a/native/markdown/rules.react.js
+++ b/native/markdown/rules.react.js
@@ -1,416 +1,415 @@
// @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,
) => (
-
- {output(node.content, 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,
};