Support emoji in text with custom font

This commit is contained in:
Eric Bailey
2024-09-22 12:42:00 -05:00
parent 7e2456b906
commit fa704e3acc
5 changed files with 159 additions and 18 deletions
+1
View File
@@ -116,6 +116,7 @@
"deprecated-react-native-prop-types": "^5.0.0", "deprecated-react-native-prop-types": "^5.0.0",
"email-validator": "^2.0.4", "email-validator": "^2.0.4",
"emoji-mart": "^5.5.2", "emoji-mart": "^5.5.2",
"emoji-regex": "^10.4.0",
"eventemitter3": "^5.0.1", "eventemitter3": "^5.0.1",
"expo": "^51.0.8", "expo": "^51.0.8",
"expo-application": "^5.9.1", "expo-application": "^5.9.1",
+8 -1
View File
@@ -66,6 +66,7 @@ export function RichText({
(flattenedStyle.fontSize ?? a.text_sm.fontSize) * emojiMultiplier (flattenedStyle.fontSize ?? a.text_sm.fontSize) * emojiMultiplier
return ( return (
<Text <Text
emoji
selectable={selectable} selectable={selectable}
testID={testID} testID={testID}
style={[plainStyles, {fontSize}]} style={[plainStyles, {fontSize}]}
@@ -77,6 +78,7 @@ export function RichText({
} }
return ( return (
<Text <Text
emoji
selectable={selectable} selectable={selectable}
testID={testID} testID={testID}
style={plainStyles} style={plainStyles}
@@ -148,7 +150,11 @@ export function RichText({
/>, />,
) )
} else { } else {
els.push(segment.text) els.push(
<Text key={key} emoji style={plainStyles}>
{segment.text}
</Text>,
)
} }
key++ key++
} }
@@ -213,6 +219,7 @@ function RichTextTag({
<React.Fragment> <React.Fragment>
<TagMenu control={control} tag={tag} authorHandle={authorHandle}> <TagMenu control={control} tag={tag} authorHandle={authorHandle}>
<Text <Text
emoji
selectable={selectable} selectable={selectable}
{...native({ {...native({
accessibilityLabel: _(msg`Hashtag: #${tag}`), accessibilityLabel: _(msg`Hashtag: #${tag}`),
+106 -4
View File
@@ -1,15 +1,85 @@
import React from 'react' import React from 'react'
import {StyleProp, TextProps as RNTextProps, TextStyle} from 'react-native' import {StyleProp, TextProps as RNTextProps, TextStyle} from 'react-native'
import {UITextView} from 'react-native-uitextview' import {UITextView} from 'react-native-uitextview'
import createEmojiRegex from 'emoji-regex'
import {isNative} from '#/platform/detection' import {logger} from '#/logger'
import {isIOS, isNative} from '#/platform/detection'
import {Alf, applyFonts, atoms, flatten, useAlf, useTheme, web} from '#/alf' import {Alf, applyFonts, atoms, flatten, useAlf, useTheme, web} from '#/alf'
import {IS_DEV} from '#/env'
export type TextProps = RNTextProps & { export type StringChild = string | (string | null)[]
export type TextProps = Omit<RNTextProps, 'children'> & {
/** /**
* Lets the user select text, to use the native copy and paste functionality. * Lets the user select text, to use the native copy and paste functionality.
*/ */
selectable?: boolean selectable?: boolean
/**
* Provides `data-*` attributes to the underlying `UITextView` component on
* web only.
*/
dataSet?: Record<string, string | number | undefined>
/**
* Appears as a small tooltip on web hover.
*/
title?: string
} & (
| {
emoji: true
children: StringChild
}
| {
emoji?: false
children: RNTextProps['children']
}
)
const EMOJI = createEmojiRegex()
export function childHasEmoji(children: React.ReactNode) {
return (Array.isArray(children) ? children : [children]).some(
child => typeof child === 'string' && createEmojiRegex().test(child),
)
}
export function childIsString(
children: React.ReactNode,
): children is StringChild {
return (
typeof children === 'string' ||
(Array.isArray(children) &&
children.every(child => typeof child === 'string' || child === null))
)
}
export function renderChildrenWithEmoji(children: StringChild) {
const normalized = Array.isArray(children) ? children : [children]
return (
<UITextView>
{normalized.map(child => {
if (typeof child !== 'string') return child
const emojis = child.match(EMOJI)
if (emojis === null) {
return child
}
return child.split(EMOJI).map((stringPart, index) => (
<UITextView key={index}>
{stringPart}
{emojis[index] ? (
<UITextView style={{color: 'black', fontFamily: 'System'}}>
{emojis[index]}
</UITextView>
) : null}
</UITextView>
))
})}
</UITextView>
)
} }
/** /**
@@ -64,7 +134,15 @@ export function normalizeTextStyles(
/** /**
* Our main text component. Use this most of the time. * Our main text component. Use this most of the time.
*/ */
export function Text({style, selectable, ...rest}: TextProps) { export function Text({
children,
emoji,
style,
selectable,
title,
dataSet,
...rest
}: TextProps) {
const {fonts, flags} = useAlf() const {fonts, flags} = useAlf()
const t = useTheme() const t = useTheme()
const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)], { const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)], {
@@ -73,7 +151,31 @@ export function Text({style, selectable, ...rest}: TextProps) {
flags, flags,
}) })
return <UITextView selectable={selectable} uiTextView style={s} {...rest} /> if (IS_DEV) {
if (!emoji && childHasEmoji(children)) {
logger.warn(
`Text: emoji detected but emoji not enabled: "${children}"\n\nPlease add <Text emoji />'`,
)
}
if (emoji && !childIsString(children)) {
throw new Error(
'Text: when <Text emoji />, children can only be strings.',
)
}
}
return (
<UITextView
selectable={selectable}
uiTextView
style={s}
{...rest}
// @ts-ignore
dataSet={Object.assign({tooltip: title}, dataSet || {})}>
{isIOS && emoji ? renderChildrenWithEmoji(children) : children}
</UITextView>
)
} }
export function createHeadingElement({level}: {level: number}) { export function createHeadingElement({level}: {level: number}) {
+39 -13
View File
@@ -2,27 +2,40 @@ import React from 'react'
import {StyleSheet, Text as RNText, TextProps} from 'react-native' import {StyleSheet, Text as RNText, TextProps} from 'react-native'
import {UITextView} from 'react-native-uitextview' import {UITextView} from 'react-native-uitextview'
import {lh, s} from 'lib/styles' import {lh, s} from '#/lib/styles'
import {TypographyVariant, useTheme} from 'lib/ThemeContext' import {TypographyVariant, useTheme} from '#/lib/ThemeContext'
import {isIOS, isWeb} from 'platform/detection' import {logger} from '#/logger'
import {isIOS} from '#/platform/detection'
import {applyFonts, useAlf} from '#/alf' import {applyFonts, useAlf} from '#/alf'
import {
childHasEmoji,
childIsString,
renderChildrenWithEmoji,
StringChild,
} from '#/components/Typography'
import {IS_DEV} from '#/env'
export type CustomTextProps = TextProps & { export type CustomTextProps = Omit<TextProps, 'children'> & {
type?: TypographyVariant type?: TypographyVariant
lineHeight?: number lineHeight?: number
title?: string title?: string
dataSet?: Record<string, string | number> dataSet?: Record<string, string | number>
selectable?: boolean selectable?: boolean
} } & (
| {
const fontFamilyStyle = { emoji: true
fontFamily: children: StringChild
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Liberation Sans", Helvetica, Arial, sans-serif', }
} | {
emoji?: false
children: TextProps['children']
}
)
export function Text({ export function Text({
type = 'md', type = 'md',
children, children,
emoji,
lineHeight, lineHeight,
style, style,
title, title,
@@ -35,6 +48,20 @@ export function Text({
const lineHeightStyle = lineHeight ? lh(theme, type, lineHeight) : undefined const lineHeightStyle = lineHeight ? lh(theme, type, lineHeight) : undefined
const {fonts} = useAlf() const {fonts} = useAlf()
if (IS_DEV) {
if (!emoji && childHasEmoji(children)) {
logger.warn(
`Text: emoji detected but emoji not enabled: "${children}"\n\nPlease add <Text emoji />'`,
)
}
if (emoji && !childIsString(children)) {
throw new Error(
'Text: when <Text emoji />, children can only be strings.',
)
}
}
if (selectable && isIOS) { if (selectable && isIOS) {
const flattened = StyleSheet.flatten([ const flattened = StyleSheet.flatten([
s.black, s.black,
@@ -58,7 +85,7 @@ export function Text({
selectable={selectable} selectable={selectable}
uiTextView uiTextView
{...props}> {...props}>
{children} {isIOS && emoji ? renderChildrenWithEmoji(children) : children}
</UITextView> </UITextView>
) )
} }
@@ -66,7 +93,6 @@ export function Text({
const flattened = StyleSheet.flatten([ const flattened = StyleSheet.flatten([
s.black, s.black,
typography, typography,
isWeb && fontFamilyStyle,
lineHeightStyle, lineHeightStyle,
style, style,
]) ])
@@ -87,7 +113,7 @@ export function Text({
dataSet={Object.assign({tooltip: title}, dataSet || {})} dataSet={Object.assign({tooltip: title}, dataSet || {})}
selectable={selectable} selectable={selectable}
{...props}> {...props}>
{children} {isIOS && emoji ? renderChildrenWithEmoji(children) : children}
</RNText> </RNText>
) )
} }
+5
View File
@@ -11360,6 +11360,11 @@ emoji-mart@^5.5.2:
resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-5.5.2.tgz#3ddbaf053139cf4aa217650078bc1c50ca8381af" resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-5.5.2.tgz#3ddbaf053139cf4aa217650078bc1c50ca8381af"
integrity sha512-Sqc/nso4cjxhOwWJsp9xkVm8OF5c+mJLZJFoFfzRuKO+yWiN7K8c96xmtughYb0d/fZ8UC6cLIQ/p4BR6Pv3/A== integrity sha512-Sqc/nso4cjxhOwWJsp9xkVm8OF5c+mJLZJFoFfzRuKO+yWiN7K8c96xmtughYb0d/fZ8UC6cLIQ/p4BR6Pv3/A==
emoji-regex@^10.4.0:
version "10.4.0"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.4.0.tgz#03553afea80b3975749cfcb36f776ca268e413d4"
integrity sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==
emoji-regex@^8.0.0: emoji-regex@^8.0.0:
version "8.0.0" version "8.0.0"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"