[🐴] Rich text in messages (#3926)

* add facets to message

* richtext messages

* undo richtexttag changes

* whoops, don't redetect facets

* dont set color directly

* shorten links and filter invalid facets

* fix link shortening

* pass in underline style
zio/stable
Samuel Newman 2024-05-09 21:08:56 +01:00 committed by GitHub
parent 03b2796976
commit becc708c61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 75 additions and 20 deletions

View File

@ -840,4 +840,22 @@ export const atoms = {
mr_auto: { mr_auto: {
marginRight: 'auto', marginRight: 'auto',
}, },
/*
* Pointer events
*/
pointer_events_none: {
pointerEvents: 'none',
},
pointer_events_auto: {
pointerEvents: 'auto',
},
/*
* Text decoration
*/
underline: {
textDecorationLine: 'underline',
},
strike_through: {
textDecorationLine: 'line-through',
},
} as const } as const

View File

@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import {TextStyle} from 'react-native'
import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api' import {AppBskyRichtextFacet, RichText as RichTextAPI} from '@atproto/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@ -26,6 +27,7 @@ export function RichText({
enableTags = false, enableTags = false,
authorHandle, authorHandle,
onLinkPress, onLinkPress,
interactiveStyle,
}: TextStyleProp & }: TextStyleProp &
Pick<TextProps, 'selectable'> & { Pick<TextProps, 'selectable'> & {
value: RichTextAPI | string value: RichTextAPI | string
@ -35,13 +37,22 @@ export function RichText({
enableTags?: boolean enableTags?: boolean
authorHandle?: string authorHandle?: string
onLinkPress?: LinkProps['onPress'] onLinkPress?: LinkProps['onPress']
interactiveStyle?: TextStyle
}) { }) {
const richText = React.useMemo( const richText = React.useMemo(
() => () =>
value instanceof RichTextAPI ? value : new RichTextAPI({text: value}), value instanceof RichTextAPI ? value : new RichTextAPI({text: value}),
[value], [value],
) )
const styles = [a.leading_snug, flatten(style)]
const flattenedStyle = flatten(style)
const plainStyles = [a.leading_snug, flattenedStyle]
const interactiveStyles = [
a.leading_snug,
a.pointer_events_auto,
flatten(interactiveStyle),
flattenedStyle,
]
const {text, facets} = richText const {text, facets} = richText
@ -67,7 +78,7 @@ export function RichText({
<Text <Text
selectable={selectable} selectable={selectable}
testID={testID} testID={testID}
style={styles} style={plainStyles}
numberOfLines={numberOfLines} numberOfLines={numberOfLines}
// @ts-ignore web only -prf // @ts-ignore web only -prf
dataSet={WORD_WRAP}> dataSet={WORD_WRAP}>
@ -93,7 +104,7 @@ export function RichText({
<InlineLinkText <InlineLinkText
selectable={selectable} selectable={selectable}
to={`/profile/${mention.did}`} to={`/profile/${mention.did}`}
style={[...styles, {pointerEvents: 'auto'}]} style={interactiveStyles}
// @ts-ignore TODO // @ts-ignore TODO
dataSet={WORD_WRAP} dataSet={WORD_WRAP}
onPress={onLinkPress}> onPress={onLinkPress}>
@ -110,7 +121,7 @@ export function RichText({
selectable={selectable} selectable={selectable}
key={key} key={key}
to={link.uri} to={link.uri}
style={[...styles, {pointerEvents: 'auto'}]} style={interactiveStyles}
// @ts-ignore TODO // @ts-ignore TODO
dataSet={WORD_WRAP} dataSet={WORD_WRAP}
shareOnLongPress shareOnLongPress
@ -130,7 +141,7 @@ export function RichText({
key={key} key={key}
text={segment.text} text={segment.text}
tag={tag.tag} tag={tag.tag}
style={styles} style={interactiveStyles}
selectable={selectable} selectable={selectable}
authorHandle={authorHandle} authorHandle={authorHandle}
/>, />,
@ -145,7 +156,7 @@ export function RichText({
<Text <Text
selectable={selectable} selectable={selectable}
testID={testID} testID={testID}
style={styles} style={plainStyles}
numberOfLines={numberOfLines} numberOfLines={numberOfLines}
// @ts-ignore web only -prf // @ts-ignore web only -prf
dataSet={WORD_WRAP}> dataSet={WORD_WRAP}>
@ -219,19 +230,16 @@ function RichTextTag({
onFocus={onFocus} onFocus={onFocus}
onBlur={onBlur} onBlur={onBlur}
style={[ style={[
style,
{
pointerEvents: 'auto',
color: t.palette.primary_500,
},
web({ web({
cursor: 'pointer', cursor: 'pointer',
}), }),
{color: t.palette.primary_500},
(hovered || focused || pressed) && { (hovered || focused || pressed) && {
...web({outline: 0}), ...web({outline: 0}),
textDecorationLine: 'underline', textDecorationLine: 'underline',
textDecorationColor: t.palette.primary_500, textDecorationColor: t.palette.primary_500,
}, },
style,
]}> ]}>
{text} {text}
</Text> </Text>

View File

@ -1,5 +1,6 @@
import React, {useCallback, useMemo, useRef} from 'react' import React, {useCallback, useMemo, useRef} from 'react'
import {LayoutAnimation, StyleProp, TextStyle, View} from 'react-native' import {LayoutAnimation, StyleProp, TextStyle, View} from 'react-native'
import {RichText as RichTextAPI} from '@atproto/api'
import {ChatBskyConvoDefs} from '@atproto-labs/api' import {ChatBskyConvoDefs} from '@atproto-labs/api'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
@ -9,8 +10,9 @@ import {TimeElapsed} from 'view/com/util/TimeElapsed'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {ActionsWrapper} from '#/components/dms/ActionsWrapper' import {ActionsWrapper} from '#/components/dms/ActionsWrapper'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {RichText} from '../RichText'
export let MessageItem = ({ let MessageItem = ({
item, item,
next, next,
pending, pending,
@ -65,6 +67,10 @@ export let MessageItem = ({
const pendingColor = const pendingColor =
t.name === 'light' ? t.palette.primary_200 : t.palette.primary_800 t.name === 'light' ? t.palette.primary_200 : t.palette.primary_800
const rt = useMemo(() => {
return new RichTextAPI({text: item.text, facets: item.facets})
}, [item.text, item.facets])
return ( return (
<View> <View>
<ActionsWrapper isFromSelf={isFromSelf} message={item}> <ActionsWrapper isFromSelf={isFromSelf} message={item}>
@ -87,15 +93,17 @@ export let MessageItem = ({
? {borderBottomRightRadius: isLastInGroup ? 2 : 17} ? {borderBottomRightRadius: isLastInGroup ? 2 : 17}
: {borderBottomLeftRadius: isLastInGroup ? 2 : 17}, : {borderBottomLeftRadius: isLastInGroup ? 2 : 17},
]}> ]}>
<Text <RichText
value={rt}
style={[ style={[
a.text_md, a.text_md,
a.leading_snug, a.leading_snug,
isFromSelf && {color: t.palette.white}, isFromSelf && {color: t.palette.white},
pending && t.name !== 'light' && {color: t.palette.primary_300}, pending && t.name !== 'light' && {color: t.palette.primary_300},
]}> ]}
{item.text} interactiveStyle={a.underline}
</Text> enableTags
/>
</View> </View>
</ActionsWrapper> </ActionsWrapper>
<MessageItemMetadata <MessageItemMetadata
@ -106,8 +114,8 @@ export let MessageItem = ({
</View> </View>
) )
} }
MessageItem = React.memo(MessageItem) MessageItem = React.memo(MessageItem)
export {MessageItem}
let MessageItemMetadata = ({ let MessageItemMetadata = ({
message, message,

View File

@ -1,4 +1,5 @@
import {RichText, UnicodeString} from '@atproto/api' import {RichText, UnicodeString} from '@atproto/api'
import {toShortUrl} from './url-helpers' import {toShortUrl} from './url-helpers'
export function shortenLinks(rt: RichText): RichText { export function shortenLinks(rt: RichText): RichText {

View File

@ -7,12 +7,15 @@ import {
import {runOnJS, useSharedValue} from 'react-native-reanimated' import {runOnJS, useSharedValue} from 'react-native-reanimated'
import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes' import {ReanimatedScrollEvent} from 'react-native-reanimated/lib/typescript/reanimated2/hook/commonTypes'
import {useSafeAreaInsets} from 'react-native-safe-area-context' import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {AppBskyRichtextFacet, RichText} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {shortenLinks} from '#/lib/strings/rich-text-manip'
import {isIOS} from '#/platform/detection' import {isIOS} from '#/platform/detection'
import {useConvo} from '#/state/messages/convo' import {useConvo} from '#/state/messages/convo'
import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types' import {ConvoItem, ConvoStatus} from '#/state/messages/convo/types'
import {useAgent} from '#/state/session'
import {ScrollProvider} from 'lib/ScrollContext' import {ScrollProvider} from 'lib/ScrollContext'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
import {List} from 'view/com/util/List' import {List} from 'view/com/util/List'
@ -87,6 +90,7 @@ function onScrollToIndexFailed() {
export function MessagesList() { export function MessagesList() {
const convo = useConvo() const convo = useConvo()
const {getAgent} = useAgent()
const flatListRef = useRef<FlatList>(null) const flatListRef = useRef<FlatList>(null)
// We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items // We need to keep track of when the scroll offset is at the bottom of the list to know when to scroll as new items
@ -159,14 +163,30 @@ export function MessagesList() {
}, [convo, hasInitiallyScrolled]) }, [convo, hasInitiallyScrolled])
const onSendMessage = useCallback( const onSendMessage = useCallback(
(text: string) => { async (text: string) => {
let rt = new RichText({text}, {cleanNewlines: true})
await rt.detectFacets(getAgent())
rt = shortenLinks(rt)
// filter out any mention facets that didn't map to a user
rt.facets = rt.facets?.filter(facet => {
const mention = facet.features.find(feature =>
AppBskyRichtextFacet.isMention(feature),
)
if (mention && !mention.did) {
return false
}
return true
})
if (convo.status === ConvoStatus.Ready) { if (convo.status === ConvoStatus.Ready) {
convo.sendMessage({ convo.sendMessage({
text, text: rt.text,
facets: rt.facets,
}) })
} }
}, },
[convo], [convo, getAgent],
) )
const onScroll = React.useCallback( const onScroll = React.useCallback(