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,
};