diff --git a/native/markdown/rules.react.js b/native/markdown/rules.react.js index 045cf65df..f926d1cfb 100644 --- a/native/markdown/rules.react.js +++ b/native/markdown/rules.react.js @@ -1,412 +1,416 @@ // @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 { 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)}, + ) => ( + + {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, }; diff --git a/native/markdown/styles.js b/native/markdown/styles.js index a52cd0e60..b486145cd 100644 --- a/native/markdown/styles.js +++ b/native/markdown/styles.js @@ -1,105 +1,109 @@ // @flow import _memoize from 'lodash/memoize'; import { Platform } from 'react-native'; import { getStylesForTheme } from '../themes/colors'; import type { GlobalTheme } from '../types/themes'; const unboundStyles = { link: { color: 'markdownLink', textDecorationLine: 'underline', }, italics: { fontStyle: 'italic', }, + spoiler: { + color: 'spoiler', + backgroundColor: 'spoiler', + }, bold: { fontWeight: 'bold', }, underline: { textDecorationLine: 'underline', }, strikethrough: { textDecorationLine: 'line-through', textDecorationStyle: 'solid', }, inlineCode: { backgroundColor: 'codeBackground', fontFamily: (Platform.select({ ios: 'Menlo', default: 'monospace', }): string), fontSize: (Platform.select({ ios: 17, default: 18, }): number), }, h1: { fontSize: 32, fontWeight: 'bold', }, h2: { fontSize: 24, fontWeight: 'bold', }, h3: { fontSize: 18, fontWeight: 'bold', }, h4: { fontSize: 16, fontWeight: 'bold', }, h5: { fontSize: 13, fontWeight: 'bold', }, h6: { fontSize: 11, fontWeight: 'bold', }, blockQuote: { borderLeftColor: '#00000066', borderLeftWidth: 8, borderRadius: 8, padding: 10, marginBottom: 6, marginVertical: 6, }, codeBlock: { backgroundColor: 'codeBackground', padding: 10, borderRadius: 5, marginVertical: 6, }, codeBlockText: { fontFamily: (Platform.select({ ios: 'Menlo', default: 'monospace', }): string), fontSize: (Platform.select({ ios: 17, default: 18, }): number), }, listBulletStyle: { fontWeight: 'bold', }, listRow: { flexDirection: 'row', }, insideListView: { flexShrink: 1, }, }; export type MarkdownStyles = typeof unboundStyles; const getMarkdownStyles: GlobalTheme => MarkdownStyles = _memoize( (theme: GlobalTheme) => { return getStylesForTheme(unboundStyles, theme); }, ); export { getMarkdownStyles }; diff --git a/native/themes/colors.js b/native/themes/colors.js index 3635d314f..eac2e45a3 100644 --- a/native/themes/colors.js +++ b/native/themes/colors.js @@ -1,294 +1,296 @@ // @flow import * as React from 'react'; import { StyleSheet } from 'react-native'; import { createSelector } from 'reselect'; import { selectBackgroundIsDark } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import { useSelector } from '../redux/redux-utils'; import type { AppState } from '../redux/state-types'; import type { GlobalTheme } from '../types/themes'; const light = Object.freeze({ blockQuoteBackground: '#D3D3D3', blockQuoteBorder: '#C0C0C0', codeBackground: '#DCDCDC', disabledButton: '#D3D3D3', disconnectedBarBackground: '#F5F5F5', editButton: '#A4A4A2', floatingButtonBackground: '#999999', floatingButtonLabel: '#EEEEEE', greenButton: '#6EC472', greenText: 'green', headerChevron: '#0A0A0A', inlineSidebarBackground: '#E0E0E0', inlineSidebarLabel: '#000000', link: '#036AFF', listBackground: 'white', listBackgroundLabel: 'black', listBackgroundSecondaryLabel: '#444444', listBackgroundTernaryLabel: '#999999', listChatBubble: '#F1F0F5', listForegroundLabel: 'black', listForegroundQuaternaryLabel: '#AAAAAA', listForegroundSecondaryLabel: '#333333', listForegroundTertiaryLabel: '#666666', listInputBackground: '#F5F5F5', listInputBar: '#E2E2E2', listInputBorder: '#AAAAAAAA', listInputButton: '#8E8D92', listIosHighlightUnderlay: '#DDDDDDDD', listSearchBackground: '#F5F5F5', listSearchIcon: '#8E8D92', listSeparator: '#EEEEEE', listSeparatorLabel: '#555555', markdownLink: '#000000', mintButton: '#44CC99', modalBackground: '#EEEEEE', modalBackgroundLabel: '#333333', modalBackgroundSecondaryLabel: '#AAAAAA', modalButton: '#BBBBBB', modalButtonLabel: 'black', modalContrastBackground: 'black', modalContrastForegroundLabel: 'white', modalContrastOpacity: 0.7, modalForeground: 'white', modalForegroundBorder: '#CCCCCC', modalForegroundLabel: 'black', modalForegroundSecondaryLabel: '#888888', modalForegroundTertiaryLabel: '#AAAAAA', modalIosHighlightUnderlay: '#CCCCCCDD', modalSubtext: '#CCCCCC', modalSubtextLabel: '#555555', navigationCard: '#FFFFFF', navigationChevron: '#BAB9BE', panelBackground: '#F5F5F5', panelBackgroundLabel: '#888888', panelForeground: 'white', panelForegroundBorder: '#CCCCCC', panelForegroundLabel: 'black', panelForegroundSecondaryLabel: '#333333', panelForegroundTertiaryLabel: '#888888', panelIosHighlightUnderlay: '#EEEEEEDD', panelSecondaryForeground: '#F5F5F5', panelSecondaryForegroundBorder: '#D1D1D6', redButton: '#BB8888', redText: '#FF4444', + spoiler: '#33332C', tabBarAccent: '#7E57C2', tabBarBackground: '#F5F5F5', tabBarActiveTintColor: '#7E57C2', vibrantGreenButton: '#00C853', vibrantRedButton: '#F53100', tooltipBackground: '#E0E0E0', }); export type Colors = $Exact; const dark: Colors = Object.freeze({ blockQuoteBackground: '#A9A9A9', blockQuoteBorder: '#808080', codeBackground: '#0A0A0A', disabledButton: '#444444', disconnectedBarBackground: '#1D1D1D', editButton: '#5B5B5D', floatingButtonBackground: '#666666', floatingButtonLabel: 'white', greenButton: '#43A047', greenText: '#44FF44', headerChevron: '#FFFFFF', inlineSidebarBackground: '#666666', inlineSidebarLabel: '#FFFFFF', link: '#129AFF', listBackground: '#0A0A0A', listBackgroundLabel: '#C7C7CC', listBackgroundSecondaryLabel: '#BBBBBB', listBackgroundTernaryLabel: '#888888', listChatBubble: '#26252A', listForegroundLabel: 'white', listForegroundQuaternaryLabel: '#555555', listForegroundSecondaryLabel: '#CCCCCC', listForegroundTertiaryLabel: '#999999', listInputBackground: '#1D1D1D', listInputBar: '#555555', listInputBorder: '#333333', listInputButton: '#AAAAAA', listIosHighlightUnderlay: '#BBBBBB88', listSearchBackground: '#1D1D1D', listSearchIcon: '#AAAAAA', listSeparator: '#3A3A3C', listSeparatorLabel: '#EEEEEE', markdownLink: '#FFFFFF', mintButton: '#44CC99', modalBackground: '#0A0A0A', modalBackgroundLabel: '#CCCCCC', modalBackgroundSecondaryLabel: '#555555', modalButton: '#666666', modalButtonLabel: 'white', modalContrastBackground: 'white', modalContrastForegroundLabel: 'black', modalContrastOpacity: 0.85, modalForeground: '#1C1C1E', modalForegroundBorder: '#1C1C1E', modalForegroundLabel: 'white', modalForegroundSecondaryLabel: '#AAAAAA', modalForegroundTertiaryLabel: '#666666', modalIosHighlightUnderlay: '#AAAAAA88', modalSubtext: '#444444', modalSubtextLabel: '#AAAAAA', navigationCard: '#2A2A2A', navigationChevron: '#5B5B5D', panelBackground: '#0A0A0A', panelBackgroundLabel: '#C7C7CC', panelForeground: '#1D1D1D', panelForegroundBorder: '#2C2C2E', panelForegroundLabel: 'white', panelForegroundSecondaryLabel: '#CCCCCC', panelForegroundTertiaryLabel: '#AAAAAA', panelIosHighlightUnderlay: '#313035', panelSecondaryForeground: '#333333', panelSecondaryForegroundBorder: '#666666', redButton: '#FF4444', redText: '#FF4444', + spoiler: '#33332C', tabBarAccent: '#AE94DB', tabBarBackground: '#0A0A0A', tabBarActiveTintColor: '#AE94DB', vibrantGreenButton: '#00C853', vibrantRedButton: '#F53100', tooltipBackground: '#1F1F1F', }); const colors = { light, dark }; const colorsSelector: (state: AppState) => Colors = createSelector( (state: AppState) => state.globalThemeInfo.activeTheme, (theme: ?GlobalTheme) => { const explicitTheme = theme ? theme : 'light'; return colors[explicitTheme]; }, ); const magicStrings = new Set(); for (const theme in colors) { for (const magicString in colors[theme]) { magicStrings.add(magicString); } } type Styles = { [name: string]: { [field: string]: mixed } }; type ReplaceField = (input: any) => any; export type StyleSheetOf = $ObjMap; function stylesFromColors( obj: IS, themeColors: Colors, ): StyleSheetOf { const result = {}; for (const key in obj) { const style = obj[key]; const filledInStyle = { ...style }; for (const styleKey in style) { const styleValue = style[styleKey]; if (typeof styleValue !== 'string') { continue; } if (magicStrings.has(styleValue)) { const mapped = themeColors[styleValue]; if (mapped) { filledInStyle[styleKey] = mapped; } } } result[key] = filledInStyle; } return StyleSheet.create(result); } function styleSelector( obj: IS, ): (state: AppState) => StyleSheetOf { return createSelector(colorsSelector, (themeColors: Colors) => stylesFromColors(obj, themeColors), ); } function useStyles(obj: IS): StyleSheetOf { const ourColors = useColors(); return React.useMemo(() => stylesFromColors(obj, ourColors), [ obj, ourColors, ]); } function useOverlayStyles(obj: IS): StyleSheetOf { const navContext = React.useContext(NavContext); const navigationState = navContext && navContext.state; const theme = useSelector( (state: AppState) => state.globalThemeInfo.activeTheme, ); const backgroundIsDark = React.useMemo( () => selectBackgroundIsDark(navigationState, theme), [navigationState, theme], ); const syntheticTheme = backgroundIsDark ? 'dark' : 'light'; return React.useMemo(() => stylesFromColors(obj, colors[syntheticTheme]), [ obj, syntheticTheme, ]); } function useColors(): Colors { return useSelector(colorsSelector); } function getStylesForTheme( obj: IS, theme: GlobalTheme, ): StyleSheetOf { return stylesFromColors(obj, colors[theme]); } export type IndicatorStyle = 'white' | 'black'; function useIndicatorStyle(): IndicatorStyle { const theme = useSelector( (state: AppState) => state.globalThemeInfo.activeTheme, ); return theme && theme === 'dark' ? 'white' : 'black'; } const indicatorStyleSelector: ( state: AppState, ) => IndicatorStyle = createSelector( (state: AppState) => state.globalThemeInfo.activeTheme, (theme: ?GlobalTheme) => { return theme && theme === 'dark' ? 'white' : 'black'; }, ); export type KeyboardAppearance = 'default' | 'light' | 'dark'; const keyboardAppearanceSelector: ( state: AppState, ) => KeyboardAppearance = createSelector( (state: AppState) => state.globalThemeInfo.activeTheme, (theme: ?GlobalTheme) => { return theme && theme === 'dark' ? 'dark' : 'light'; }, ); function useKeyboardAppearance(): KeyboardAppearance { return useSelector(keyboardAppearanceSelector); } export { colors, colorsSelector, styleSelector, useStyles, useOverlayStyles, useColors, getStylesForTheme, useIndicatorStyle, indicatorStyleSelector, useKeyboardAppearance, };