[🐴] 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 stylezio/stable
parent
03b2796976
commit
becc708c61
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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(
|
||||||
|
|
Loading…
Reference in New Issue