diff --git a/app/components/emoji/emoji.tsx b/app/components/emoji/emoji.tsx new file mode 100644 index 0000000000..8334aea4d1 --- /dev/null +++ b/app/components/emoji/emoji.tsx @@ -0,0 +1,152 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; +import withObservables from '@nozbe/with-observables'; +import React from 'react'; +import { + Platform, + StyleSheet, + Text, +} from 'react-native'; +import FastImage from 'react-native-fast-image'; +import {of as of$} from 'rxjs'; +import {switchMap} from 'rxjs/operators'; + +import {fetchCustomEmojiInBatch} from '@actions/remote/custom_emoji'; +import {useServerUrl} from '@context/server'; +import NetworkManager from '@managers/network_manager'; +import {queryCustomEmojisByName} from '@queries/servers/custom_emoji'; +import {observeConfigBooleanValue} from '@queries/servers/system'; +import {EmojiIndicesByAlias, Emojis} from '@utils/emoji'; +import {isUnicodeEmoji} from '@utils/emoji/helpers'; + +import type {EmojiProps} from '@typings/components/emoji'; +import type {WithDatabaseArgs} from '@typings/database/database'; + +const assetImages = new Map([['mattermost.png', require('@assets/images/emojis/mattermost.png')]]); + +const Emoji = (props: EmojiProps) => { + const { + customEmojis, + customEmojiStyle, + displayTextOnly, + emojiName, + literal = '', + testID, + textStyle, + } = props; + const serverUrl = useServerUrl(); + let assetImage = ''; + let unicode; + let imageUrl = ''; + const name = emojiName.trim(); + if (EmojiIndicesByAlias.has(name)) { + const emoji = Emojis[EmojiIndicesByAlias.get(name)!]; + if (emoji.category === 'custom') { + assetImage = emoji.fileName; + } else { + unicode = emoji.image; + } + } else { + const custom = customEmojis.find((ce) => ce.name === name); + if (custom) { + try { + const client = NetworkManager.getClient(serverUrl); + imageUrl = client.getCustomEmojiImageUrl(custom.id); + } catch { + // do nothing + } + } else if (name && !isUnicodeEmoji(name)) { + fetchCustomEmojiInBatch(serverUrl, name); + } + } + + let size = props.size; + let fontSize = size; + if (!size && textStyle) { + const flatten = StyleSheet.flatten(textStyle); + fontSize = flatten.fontSize; + size = fontSize; + } + + if (displayTextOnly || (!imageUrl && !assetImage && !unicode)) { + return ( + + {literal} + ); + } + + const width = size; + const height = size; + + if (unicode && !imageUrl) { + const codeArray = unicode.split('-'); + const code = codeArray.reduce((acc: string, c: string) => { + return acc + String.fromCodePoint(parseInt(c, 16)); + }, ''); + + return ( + + {code} + + ); + } + + if (assetImage) { + const key = Platform.OS === 'android' ? (`${assetImage}-${height}-${width}`) : null; + + const image = assetImages.get(assetImage); + if (!image) { + return null; + } + return ( + + ); + } + + if (!imageUrl) { + return null; + } + + // Android can't change the size of an image after its first render, so + // force a new image to be rendered when the size changes + const key = Platform.OS === 'android' ? (`${imageUrl}-${height}-${width}`) : null; + + return ( + + ); +}; + +const withCustomEmojis = withObservables(['emojiName'], ({database, emojiName}: WithDatabaseArgs & {emojiName: string}) => { + const hasEmojiBuiltIn = EmojiIndicesByAlias.has(emojiName); + + const displayTextOnly = hasEmojiBuiltIn ? of$(false) : observeConfigBooleanValue(database, 'EnableCustomEmoji').pipe( + switchMap((value) => of$(!value)), + ); + + return { + displayTextOnly, + customEmojis: hasEmojiBuiltIn ? of$([]) : queryCustomEmojisByName(database, [emojiName]).observe(), + }; +}); + +export default withDatabase(withCustomEmojis(Emoji)); diff --git a/app/components/emoji/index.tsx b/app/components/emoji/index.tsx index 090fe3ad30..918f4bd23a 100644 --- a/app/components/emoji/index.tsx +++ b/app/components/emoji/index.tsx @@ -1,165 +1,21 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider'; -import withObservables from '@nozbe/with-observables'; -import React from 'react'; -import { - Platform, - StyleProp, - StyleSheet, - Text, - TextStyle, -} from 'react-native'; -import FastImage, {ImageStyle} from 'react-native-fast-image'; -import {of as of$} from 'rxjs'; -import {switchMap} from 'rxjs/operators'; +import React, {useMemo} from 'react'; -import {fetchCustomEmojiInBatch} from '@actions/remote/custom_emoji'; -import {useServerUrl} from '@context/server'; -import NetworkManager from '@managers/network_manager'; -import {queryCustomEmojisByName} from '@queries/servers/custom_emoji'; -import {observeConfigBooleanValue} from '@queries/servers/system'; -import {EmojiIndicesByAlias, Emojis} from '@utils/emoji'; -import {isUnicodeEmoji} from '@utils/emoji/helpers'; +import {EmojiComponent, EmojiProps} from '@typings/components/emoji'; -import type {WithDatabaseArgs} from '@typings/database/database'; -import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji'; +let emojiComponent: EmojiComponent; -const assetImages = new Map([['mattermost.png', require('@assets/images/emojis/mattermost.png')]]); - -type Props = { - emojiName: string; - displayTextOnly?: boolean; - literal?: string; - size?: number; - textStyle?: StyleProp; - customEmojiStyle?: StyleProp; - customEmojis: CustomEmojiModel[]; - testID?: string; -} - -const Emoji = (props: Props) => { - const { - customEmojis, - customEmojiStyle, - displayTextOnly, - emojiName, - literal = '', - testID, - textStyle, - } = props; - const serverUrl = useServerUrl(); - let assetImage = ''; - let unicode; - let imageUrl = ''; - const name = emojiName.trim(); - if (EmojiIndicesByAlias.has(name)) { - const emoji = Emojis[EmojiIndicesByAlias.get(name)!]; - if (emoji.category === 'custom') { - assetImage = emoji.fileName; - } else { - unicode = emoji.image; +const EmojiWrapper = (props: Omit) => { + const Emoji = useMemo(() => { + if (!emojiComponent) { + emojiComponent = require('./emoji').default; } - } else { - const custom = customEmojis.find((ce) => ce.name === name); - if (custom) { - try { - const client = NetworkManager.getClient(serverUrl); - imageUrl = client.getCustomEmojiImageUrl(custom.id); - } catch { - // do nothing - } - } else if (name && !isUnicodeEmoji(name)) { - fetchCustomEmojiInBatch(serverUrl, name); - } - } + return emojiComponent; + }, []); - let size = props.size; - let fontSize = size; - if (!size && textStyle) { - const flatten = StyleSheet.flatten(textStyle); - fontSize = flatten.fontSize; - size = fontSize; - } - - if (displayTextOnly || (!imageUrl && !assetImage && !unicode)) { - return ( - - {literal} - ); - } - - const width = size; - const height = size; - - if (unicode && !imageUrl) { - const codeArray = unicode.split('-'); - const code = codeArray.reduce((acc: string, c: string) => { - return acc + String.fromCodePoint(parseInt(c, 16)); - }, ''); - - return ( - - {code} - - ); - } - - if (assetImage) { - const key = Platform.OS === 'android' ? (`${assetImage}-${height}-${width}`) : null; - - const image = assetImages.get(assetImage); - if (!image) { - return null; - } - return ( - - ); - } - - if (!imageUrl) { - return null; - } - - // Android can't change the size of an image after its first render, so - // force a new image to be rendered when the size changes - const key = Platform.OS === 'android' ? (`${imageUrl}-${height}-${width}`) : null; - - return ( - - ); + return (); }; -const withCustomEmojis = withObservables(['emojiName'], ({database, emojiName}: WithDatabaseArgs & {emojiName: string}) => { - const hasEmojiBuiltIn = EmojiIndicesByAlias.has(emojiName); - - const displayTextOnly = hasEmojiBuiltIn ? of$(false) : observeConfigBooleanValue(database, 'EnableCustomEmoji').pipe( - switchMap((value) => of$(!value)), - ); - - return { - displayTextOnly, - customEmojis: hasEmojiBuiltIn ? of$([]) : queryCustomEmojisByName(database, [emojiName]).observe(), - }; -}); - -export default withDatabase(withCustomEmojis(Emoji)); +export default EmojiWrapper; diff --git a/app/components/markdown/markdown.tsx b/app/components/markdown/markdown.tsx index 4bdf7a6d5e..a7efb3a11b 100644 --- a/app/components/markdown/markdown.tsx +++ b/app/components/markdown/markdown.tsx @@ -9,13 +9,13 @@ import {Dimensions, GestureResponderEvent, Platform, StyleProp, Text, TextStyle, import CompassIcon from '@components/compass_icon'; import Emoji from '@components/emoji'; import FormattedText from '@components/formatted_text'; -import Hashtag from '@components/markdown/hashtag'; import {computeTextStyle} from '@utils/markdown'; import {blendColors, changeOpacity, concatStyles, makeStyleSheetFromTheme} from '@utils/theme'; import {getScheme} from '@utils/url'; import AtMention from './at_mention'; import ChannelMention, {ChannelMentions} from './channel_mention'; +import Hashtag from './hashtag'; import MarkdownBlockQuote from './markdown_block_quote'; import MarkdownCodeBlock from './markdown_code_block'; import MarkdownImage from './markdown_image'; diff --git a/app/components/markdown/markdown_code_block/index.tsx b/app/components/markdown/markdown_code_block/index.tsx index 8e0dad2ed2..20549d4270 100644 --- a/app/components/markdown/markdown_code_block/index.tsx +++ b/app/components/markdown/markdown_code_block/index.tsx @@ -3,14 +3,13 @@ import {useManagedConfig} from '@mattermost/react-native-emm'; import Clipboard from '@react-native-clipboard/clipboard'; -import React, {useCallback} from 'react'; +import React, {useCallback, useMemo} from 'react'; import {useIntl} from 'react-intl'; import {Keyboard, StyleSheet, Text, TextStyle, TouchableOpacity, View} from 'react-native'; import {useSafeAreaInsets} from 'react-native-safe-area-context'; import FormattedText from '@components/formatted_text'; import SlideUpPanelItem, {ITEM_HEIGHT} from '@components/slide_up_panel_item'; -import SyntaxHighlighter from '@components/syntax_highlight'; import {Screens} from '@constants'; import {useTheme} from '@context/theme'; import {bottomSheet, dismissBottomSheet, goToScreen} from '@screens/navigation'; @@ -19,6 +18,8 @@ import {getHighlightLanguageFromNameOrAlias, getHighlightLanguageName} from '@ut import {preventDoubleTap} from '@utils/tap'; import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; +import type {SyntaxHiglightProps} from '@typings/components/syntax_highlight'; + type MarkdownCodeBlockProps = { language: string; content: string; @@ -27,6 +28,8 @@ type MarkdownCodeBlockProps = { const MAX_LINES = 4; +let syntaxHighlighter: (props: SyntaxHiglightProps) => JSX.Element; + const getStyleSheet = makeStyleSheetFromTheme((theme) => { return { bottomSheet: { @@ -70,6 +73,13 @@ const MarkdownCodeBlock = ({language = '', content, textStyle}: MarkdownCodeBloc const theme = useTheme(); const insets = useSafeAreaInsets(); const style = getStyleSheet(theme); + const SyntaxHighlighter = useMemo(() => { + if (!syntaxHighlighter) { + syntaxHighlighter = require('@components/syntax_highlight').default; + } + + return syntaxHighlighter; + }, []); const handlePress = useCallback(preventDoubleTap(() => { const screen = Screens.CODE; diff --git a/app/components/syntax_highlight/index.tsx b/app/components/syntax_highlight/index.tsx index 963be47594..a83b2e3e01 100644 --- a/app/components/syntax_highlight/index.tsx +++ b/app/components/syntax_highlight/index.tsx @@ -2,7 +2,7 @@ // See LICENSE.txt for license information. import React, {useCallback, useMemo} from 'react'; -import {StyleSheet, TextStyle, View} from 'react-native'; +import {StyleSheet, View} from 'react-native'; import SyntaxHighlighter from 'react-syntax-highlighter'; import {github, monokai, solarizedDark, solarizedLight} from 'react-syntax-highlighter/dist/cjs/styles/hljs'; @@ -10,12 +10,7 @@ import {useTheme} from '@context/theme'; import CodeHighlightRenderer from './renderer'; -type Props = { - code: string; - language: string; - textStyle: TextStyle; - selectable?: boolean; -} +import type {SyntaxHiglightProps} from '@typings/components/syntax_highlight'; const codeTheme: Record = { github, @@ -34,7 +29,7 @@ const styles = StyleSheet.create({ }, }); -const Highlighter = ({code, language, textStyle, selectable = false}: Props) => { +const Highlighter = ({code, language, textStyle, selectable = false}: SyntaxHiglightProps) => { const theme = useTheme(); const style = codeTheme[theme.codeTheme] || github; const preTagStyle = useMemo(() => [ diff --git a/types/components/emoji.ts b/types/components/emoji.ts new file mode 100644 index 0000000000..8daf4074f7 --- /dev/null +++ b/types/components/emoji.ts @@ -0,0 +1,19 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type CustomEmojiModel from '@typings/database/models/servers/custom_emoji'; +import type {StyleProp, TextStyle} from 'react-native'; +import type {ImageStyle} from 'react-native-fast-image'; + +export type EmojiProps = { + emojiName: string; + displayTextOnly?: boolean; + literal?: string; + size?: number; + textStyle?: StyleProp; + customEmojiStyle?: StyleProp; + customEmojis: CustomEmojiModel[]; + testID?: string; +} + +export type EmojiComponent = (props: Omit) => JSX.Element; diff --git a/types/components/syntax_highlight.ts b/types/components/syntax_highlight.ts new file mode 100644 index 0000000000..d8f959e5f8 --- /dev/null +++ b/types/components/syntax_highlight.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {TextStyle} from 'react-native'; + +export type SyntaxHiglightProps = { + code: string; + language: string; + textStyle: TextStyle; + selectable?: boolean; +};