[Neue] Handle emoji within custom font (#5449)

* Support emoji in text with custom font

* Add emoji support to elements that need it

* Remove unused file causing lint failure

* Fix a few more emoji locations

* Couple more

* No throw
This commit is contained in:
Eric Bailey
2024-09-23 10:40:37 -05:00
committed by GitHub
parent 443f3a6406
commit 5eb294488f
35 changed files with 424 additions and 290 deletions
+8 -5
View File
@@ -11,17 +11,17 @@ import {msg, plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {sanitizeHandle} from '#/lib/strings/handles'
import {logger} from '#/logger'
import {precacheFeedFromGeneratorView} from '#/state/queries/feed'
import {
useAddSavedFeedsMutation,
usePreferencesQuery,
useRemoveFeedMutation,
} from '#/state/queries/preferences'
import {sanitizeHandle} from 'lib/strings/handles'
import {precacheFeedFromGeneratorView} from 'state/queries/feed'
import {useSession} from 'state/session'
import {useSession} from '#/state/session'
import * as Toast from '#/view/com/util/Toast'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import * as Toast from 'view/com/util/Toast'
import {useTheme} from '#/alf'
import {atoms as a} from '#/alf'
import {Button, ButtonIcon} from '#/components/Button'
@@ -121,7 +121,10 @@ export function TitleAndByline({
return (
<View style={[a.flex_1]}>
<Text style={[a.text_md, a.font_bold, a.leading_snug]} numberOfLines={1}>
<Text
emoji
style={[a.text_md, a.font_bold, a.leading_snug]}
numberOfLines={1}>
{title}
</Text>
{creator && (
+7 -7
View File
@@ -5,7 +5,7 @@ import {msg, Plural, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Link, LinkProps} from '#/components/Link'
@@ -185,11 +185,11 @@ function KnownFollowersInner({
serverCount > 2 ? (
<Trans>
Followed by{' '}
<Text key={slice[0].profile.did} style={textStyle}>
<Text emoji key={slice[0].profile.did} style={textStyle}>
{slice[0].profile.displayName}
</Text>
,{' '}
<Text key={slice[1].profile.did} style={textStyle}>
<Text emoji key={slice[1].profile.did} style={textStyle}>
{slice[1].profile.displayName}
</Text>
, and{' '}
@@ -203,11 +203,11 @@ function KnownFollowersInner({
// only 2
<Trans>
Followed by{' '}
<Text key={slice[0].profile.did} style={textStyle}>
<Text emoji key={slice[0].profile.did} style={textStyle}>
{slice[0].profile.displayName}
</Text>{' '}
and{' '}
<Text key={slice[1].profile.did} style={textStyle}>
<Text emoji key={slice[1].profile.did} style={textStyle}>
{slice[1].profile.displayName}
</Text>
</Trans>
@@ -216,7 +216,7 @@ function KnownFollowersInner({
// 1-n followers, including blocks
<Trans>
Followed by{' '}
<Text key={slice[0].profile.did} style={textStyle}>
<Text emoji key={slice[0].profile.did} style={textStyle}>
{slice[0].profile.displayName}
</Text>{' '}
and{' '}
@@ -230,7 +230,7 @@ function KnownFollowersInner({
// only 1
<Trans>
Followed by{' '}
<Text key={slice[0].profile.did} style={textStyle}>
<Text emoji key={slice[0].profile.did} style={textStyle}>
{slice[0].profile.displayName}
</Text>
</Trans>
+8 -3
View File
@@ -44,17 +44,22 @@ export function Avatar({avatar}: {avatar?: string}) {
}
export function Title({value}: {value: string}) {
return <Text style={[a.text_md, a.font_bold, a.leading_tight]}>{value}</Text>
return (
<Text emoji style={[a.text_md, a.font_bold, a.leading_tight]}>
{value}
</Text>
)
}
export function Description({value, handle}: {value?: string; handle: string}) {
const {_} = useLingui()
return value ? (
<Text numberOfLines={2}>
<RichText value={value} style={[a.leading_snug]} />
</Text>
) : (
<Text style={[a.leading_snug]}>
<Trans>By {sanitizeHandle(handle, '@')}</Trans>
<Text emoji style={[a.leading_snug]}>
{_(msg`By ${sanitizeHandle(handle, '@')}`)}
</Text>
)
}
+12 -12
View File
@@ -7,13 +7,14 @@ import {
moderateUserList,
ModerationUI,
} from '@atproto/api'
import {Trans} from '@lingui/macro'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {sanitizeHandle} from 'lib/strings/handles'
import {useModerationOpts} from 'state/preferences/moderation-opts'
import {precacheList} from 'state/queries/feed'
import {useSession} from 'state/session'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {precacheList} from '#/state/queries/feed'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {
Avatar,
@@ -111,6 +112,7 @@ export function TitleAndByline({
modUi?: ModerationUI
}) {
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
return (
@@ -130,6 +132,7 @@ export function TitleAndByline({
</Hider.Mask>
<Hider.Content>
<Text
emoji
style={[a.text_md, a.font_bold, a.leading_snug]}
numberOfLines={1}>
{title}
@@ -139,15 +142,12 @@ export function TitleAndByline({
{creator && (
<Text
emoji
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{purpose === MODLIST ? (
<Trans>
Moderation list by {sanitizeHandle(creator.handle, '@')}
</Trans>
) : (
<Trans>List by {sanitizeHandle(creator.handle, '@')}</Trans>
)}
{purpose === MODLIST
? _(msg`Moderation list by ${sanitizeHandle(creator.handle, '@')}`)
: _(msg`List by ${sanitizeHandle(creator.handle, '@')}`)}
</Text>
)}
</View>
+1
View File
@@ -130,6 +130,7 @@ export function Label({
)}
<Text
emoji
style={[
text,
a.font_bold,
+7 -5
View File
@@ -11,13 +11,13 @@ import {useLingui} from '@lingui/react'
import {LogEvents} from '#/lib/statsig/statsig'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {useProfileFollowMutationQueue} from '#/state/queries/profile'
import {sanitizeHandle} from 'lib/strings/handles'
import {useProfileShadow} from 'state/cache/profile-shadow'
import {useSession} from 'state/session'
import {useSession} from '#/state/session'
import {ProfileCardPills} from '#/view/com/profile/ProfileCard'
import * as Toast from '#/view/com/util/Toast'
import {ProfileCardPills} from 'view/com/profile/ProfileCard'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonProps, ButtonText} from '#/components/Button'
import {Check_Stroke2_Corner0_Rounded as Check} from '#/components/icons/Check'
@@ -175,11 +175,13 @@ export function NameAndHandle({
return (
<View style={[a.flex_1]}>
<Text
emoji
style={[a.text_md, a.font_bold, a.leading_snug, a.self_start]}
numberOfLines={1}>
{name}
</Text>
<Text
emoji
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={1}>
{handle}
@@ -256,6 +256,7 @@ function LabelerToggle({title}: {title: string}) {
a.z_10,
]}>
<Text
emoji
style={[
native({marginTop: 2}),
t.atoms.text_contrast_medium,
+8 -1
View File
@@ -66,6 +66,7 @@ export function RichText({
(flattenedStyle.fontSize ?? a.text_sm.fontSize) * emojiMultiplier
return (
<Text
emoji
selectable={selectable}
testID={testID}
style={[plainStyles, {fontSize}]}
@@ -77,6 +78,7 @@ export function RichText({
}
return (
<Text
emoji
selectable={selectable}
testID={testID}
style={plainStyles}
@@ -148,7 +150,11 @@ export function RichText({
/>,
)
} else {
els.push(segment.text)
els.push(
<Text key={key} emoji style={plainStyles}>
{segment.text}
</Text>,
)
}
key++
}
@@ -213,6 +219,7 @@ function RichTextTag({
<React.Fragment>
<TagMenu control={control} tag={tag} authorHandle={authorHandle}>
<Text
emoji
selectable={selectable}
{...native({
accessibilityLabel: _(msg`Hashtag: #${tag}`),
+12 -15
View File
@@ -2,15 +2,15 @@ import React from 'react'
import {View} from 'react-native'
import {Image} from 'expo-image'
import {AppBskyGraphDefs, AppBskyGraphStarterpack, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useQueryClient} from '@tanstack/react-query'
import {sanitizeHandle} from 'lib/strings/handles'
import {getStarterPackOgCard} from 'lib/strings/starter-pack'
import {precacheResolvedUri} from 'state/queries/resolve-uri'
import {precacheStarterPack} from 'state/queries/starter-packs'
import {useSession} from 'state/session'
import {sanitizeHandle} from '#/lib/strings/handles'
import {getStarterPackOgCard} from '#/lib/strings/starter-pack'
import {precacheResolvedUri} from '#/state/queries/resolve-uri'
import {precacheStarterPack} from '#/state/queries/starter-packs'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import {StarterPack} from '#/components/icons/StarterPack'
import {BaseLink} from '#/components/Link'
@@ -66,21 +66,18 @@ export function Card({
<View style={[a.flex_row, a.gap_sm]}>
{!noIcon ? <StarterPack width={40} gradient="sky" /> : null}
<View>
<Text style={[a.text_md, a.font_bold, a.leading_snug]}>
<Text emoji style={[a.text_md, a.font_bold, a.leading_snug]}>
{record.name}
</Text>
<Text style={[a.leading_snug, t.atoms.text_contrast_medium]}>
<Trans>
Starter pack by{' '}
{creator?.did === currentAccount?.did
? _(msg`you`)
: `@${sanitizeHandle(creator.handle)}`}
</Trans>
<Text emoji style={[a.leading_snug, t.atoms.text_contrast_medium]}>
{creator?.did === currentAccount?.did
? _(msg`Starter pack by you`)
: _(msg`Starter pack by ${sanitizeHandle(creator.handle, '@')}`)}
</Text>
</View>
</View>
{!noDescription && record.description ? (
<Text numberOfLines={3} style={[a.leading_snug]}>
<Text emoji numberOfLines={3} style={[a.leading_snug]}>
{record.description}
</Text>
) : null}
@@ -12,11 +12,11 @@ import {GeneratorView} from '@atproto/api/dist/client/types/app/bsky/feed/defs'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {DISCOVER_FEED_URI, STARTER_PACK_MAX_SIZE} from 'lib/constants'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {sanitizeHandle} from 'lib/strings/handles'
import {useSession} from 'state/session'
import {UserAvatar} from 'view/com/util/UserAvatar'
import {DISCOVER_FEED_URI, STARTER_PACK_MAX_SIZE} from '#/lib/constants'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {WizardAction, WizardState} from '#/screens/StarterPack/Wizard/State'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
@@ -78,6 +78,7 @@ function WizardListCard({
/>
<View style={[a.flex_1, a.gap_2xs]}>
<Text
emoji
style={[
a.flex_1,
a.font_bold,
+104 -4
View File
@@ -1,15 +1,85 @@
import React from 'react'
import {StyleProp, TextProps as RNTextProps, TextStyle} from 'react-native'
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 {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.
*/
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.
*/
export function Text({style, selectable, ...rest}: TextProps) {
export function Text({
children,
emoji,
style,
selectable,
title,
dataSet,
...rest
}: TextProps) {
const {fonts, flags} = useAlf()
const t = useTheme()
const s = normalizeTextStyles([atoms.text_sm, t.atoms.text, flatten(style)], {
@@ -73,7 +151,29 @@ export function Text({style, selectable, ...rest}: TextProps) {
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)) {
logger.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}) {
+9 -8
View File
@@ -10,14 +10,14 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {BACK_HITSLOP} from 'lib/constants'
import {makeProfileLink} from 'lib/routes/links'
import {NavigationProp} from 'lib/routes/types'
import {sanitizeDisplayName} from 'lib/strings/display-names'
import {isWeb} from 'platform/detection'
import {useProfileShadow} from 'state/cache/profile-shadow'
import {isConvoActive, useConvo} from 'state/messages/convo'
import {PreviewableUserAvatar} from 'view/com/util/UserAvatar'
import {BACK_HITSLOP} from '#/lib/constants'
import {makeProfileLink} from '#/lib/routes/links'
import {NavigationProp} from '#/lib/routes/types'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {isWeb} from '#/platform/detection'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {isConvoActive, useConvo} from '#/state/messages/convo'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
@@ -170,6 +170,7 @@ function HeaderReady({
</View>
<View style={a.flex_1}>
<Text
emoji
style={[
a.text_md,
a.font_bold,
@@ -132,8 +132,10 @@ function Label({
]}>
<View style={[a.p_md, a.gap_sm, a.flex_row]}>
<View style={[a.flex_1, a.gap_xs]}>
<Text style={[a.font_bold, a.text_md]}>{strings.name}</Text>
<Text style={[t.atoms.text_contrast_medium, a.leading_snug]}>
<Text emoji style={[a.font_bold, a.text_md]}>
{strings.name}
</Text>
<Text emoji style={[t.atoms.text_contrast_medium, a.leading_snug]}>
{strings.description}
</Text>
</View>
@@ -118,7 +118,11 @@ function ModerationDetailsDialogInner({
: _(msg`The author of this thread has hidden this reply.`)
} else if (modcause.type === 'label') {
name = desc.name
description = desc.description
description = (
<Text emoji style={[t.atoms.text, a.text_md, a.leading_snug]}>
{desc.description}
</Text>
)
} else {
// should never happen
name = ''
@@ -127,7 +131,7 @@ function ModerationDetailsDialogInner({
return (
<Dialog.ScrollableInner label={_(msg`Moderation details`)}>
<Text style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}>
<Text emoji style={[t.atoms.text, a.text_2xl, a.font_bold, a.mb_sm]}>
{name}
</Text>
<Text style={[t.atoms.text, a.text_md, a.leading_snug]}>