Implement mentions rendering

zio/stable
Paul Frazee 2022-10-03 16:02:03 -05:00
parent 2058505bf1
commit 195d2f7d2b
7 changed files with 177 additions and 19 deletions

View File

@ -6,9 +6,15 @@
// import {ReactNativeStore} from './auth' // import {ReactNativeStore} from './auth'
import AdxApi from '../../third-party/api' import AdxApi from '../../third-party/api'
import {ServiceClient} from '../../third-party/api/src/index' import {ServiceClient} from '../../third-party/api/src/index'
import {
TextSlice,
Entity as Entities,
} from '../../third-party/api/src/types/todo/social/post'
import {AdxUri} from '../../third-party/uri' import {AdxUri} from '../../third-party/uri'
import {RootStoreModel} from '../models/root-store' import {RootStoreModel} from '../models/root-store'
type Entity = Entities[0]
export function doPolyfill() { export function doPolyfill() {
AdxApi.xrpc.fetch = fetchHandler AdxApi.xrpc.fetch = fetchHandler
} }
@ -32,11 +38,13 @@ export async function post(
} }
} }
} }
const entities = extractEntities(text)
return await store.api.todo.social.post.create( return await store.api.todo.social.post.create(
{did: store.me.did || ''}, {did: store.me.did || ''},
{ {
text, text,
reply, reply,
entities,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
}, },
) )
@ -196,3 +204,20 @@ async function iterateAll(
} }
} while (res.records.length === 100) } while (res.records.length === 100)
}*/ }*/
function extractEntities(text: string): Entity[] | undefined {
let match
let ents: Entity[] = []
const re = /(^|\s)@([a-zA-Z0-9\.-]+)(\b)/g
while ((match = re.exec(text))) {
ents.push({
type: 'mention',
value: match[2],
index: [
match.index + 1, // skip the (^|\s) but include the '@'
match.index + 2 + match[2].length,
],
})
}
return ents.length > 0 ? ents : undefined
}

View File

@ -79,7 +79,7 @@ export function Component({replyTo}: {replyTo?: string}) {
const textDecorated = useMemo(() => { const textDecorated = useMemo(() => {
return (text || '').split(/(\s)/g).map((item, i) => { return (text || '').split(/(\s)/g).map((item, i) => {
if (/@[a-zA-Z0-9]+/g.test(item)) { if (/^@[a-zA-Z0-9\.-]+$/g.test(item)) {
return ( return (
<Text key={i} style={{color: colors.blue3}}> <Text key={i} style={{color: colors.blue3}}>
{item} {item}

View File

@ -1,13 +1,14 @@
import React, {useMemo} from 'react' import React, {useMemo} from 'react'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {Image, StyleSheet, Text, TouchableOpacity, View} from 'react-native' import {Image, StyleSheet, Text, TouchableOpacity, View} from 'react-native'
import Svg, {Line, Circle} from 'react-native-svg' import Svg, {Line} from 'react-native-svg'
import {AdxUri} from '../../../third-party/uri' import {AdxUri} from '../../../third-party/uri'
import * as PostType from '../../../third-party/api/src/types/todo/social/post' import * as PostType from '../../../third-party/api/src/types/todo/social/post'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {PostThreadViewPostModel} from '../../../state/models/post-thread-view' import {PostThreadViewPostModel} from '../../../state/models/post-thread-view'
import {ComposePostModel} from '../../../state/models/shell' import {ComposePostModel} from '../../../state/models/shell'
import {Link} from '../util/Link' import {Link} from '../util/Link'
import {RichText} from '../util/RichText'
import {PostDropdownBtn} from '../util/DropdownBtn' import {PostDropdownBtn} from '../util/DropdownBtn'
import {s, colors} from '../../lib/styles' import {s, colors} from '../../lib/styles'
import {ago, pluralize} from '../../lib/strings' import {ago, pluralize} from '../../lib/strings'
@ -144,9 +145,14 @@ export const PostThreadItem = observer(function PostThreadItem({
</View> </View>
</View> </View>
<View style={[s.pl10, s.pr10, s.pb10]}> <View style={[s.pl10, s.pr10, s.pb10]}>
<Text style={[styles.postText, styles.postTextLarge]}> <View
{record.text} style={[styles.postTextContainer, styles.postTextLargeContainer]}>
</Text> <RichText
text={record.text}
entities={record.entities}
style={[styles.postText, styles.postTextLarge]}
/>
</View>
{item._isHighlightedPost && hasEngagement ? ( {item._isHighlightedPost && hasEngagement ? (
<View style={styles.expandedInfo}> <View style={styles.expandedInfo}>
{item.repostCount ? ( {item.repostCount ? (
@ -266,9 +272,13 @@ export const PostThreadItem = observer(function PostThreadItem({
/> />
</PostDropdownBtn> </PostDropdownBtn>
</View> </View>
<Text style={[styles.postText, s.f15, s['lh15-1.3']]}> <View style={styles.postTextContainer}>
{record.text} <RichText
</Text> text={record.text}
entities={record.entities}
style={[styles.postText, s.f15, s['lh15-1.3']]}
/>
</View>
<Ctrls /> <Ctrls />
</View> </View>
</View> </View>
@ -325,16 +335,23 @@ const styles = StyleSheet.create({
paddingRight: 5, paddingRight: 5,
}, },
postText: { postText: {
paddingBottom: 8,
fontFamily: 'Helvetica Neue', fontFamily: 'Helvetica Neue',
}, },
postTextContainer: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
paddingBottom: 8,
},
postTextLarge: { postTextLarge: {
paddingLeft: 4,
paddingBottom: 20,
fontSize: 24, fontSize: 24,
lineHeight: 32, lineHeight: 32,
fontWeight: '300', fontWeight: '300',
}, },
postTextLargeContainer: {
paddingLeft: 4,
paddingBottom: 20,
},
expandedInfo: { expandedInfo: {
flexDirection: 'row', flexDirection: 'row',
padding: 10, padding: 10,

View File

@ -15,6 +15,7 @@ import {PostThreadViewModel} from '../../../state/models/post-thread-view'
import {ComposePostModel} from '../../../state/models/shell' import {ComposePostModel} from '../../../state/models/shell'
import {Link} from '../util/Link' import {Link} from '../util/Link'
import {UserInfoText} from '../util/UserInfoText' import {UserInfoText} from '../util/UserInfoText'
import {RichText} from '../util/RichText'
import {useStores} from '../../../state' import {useStores} from '../../../state'
import {s, colors} from '../../lib/styles' import {s, colors} from '../../lib/styles'
import {ago} from '../../lib/strings' import {ago} from '../../lib/strings'
@ -115,9 +116,13 @@ export const Post = observer(function Post({uri}: {uri: string}) {
</Link> </Link>
</View> </View>
)} )}
<Text style={[styles.postText, s.f15, s['lh15-1.3']]}> <View style={styles.postTextContainer}>
{record.text} <RichText
</Text> text={record.text}
entities={record.entities}
style={[s.f15, s['lh15-1.3']]}
/>
</View>
<View style={styles.ctrls}> <View style={styles.ctrls}>
<TouchableOpacity style={styles.ctrl} onPress={onPressReply}> <TouchableOpacity style={styles.ctrl} onPress={onPressReply}>
<FontAwesomeIcon <FontAwesomeIcon
@ -195,7 +200,10 @@ const styles = StyleSheet.create({
metaItem: { metaItem: {
paddingRight: 5, paddingRight: 5,
}, },
postText: { postTextContainer: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
paddingBottom: 8, paddingBottom: 8,
}, },
ctrls: { ctrls: {

View File

@ -9,6 +9,7 @@ import {ComposePostModel, SharePostModel} from '../../../state/models/shell'
import {Link} from '../util/Link' import {Link} from '../util/Link'
import {PostDropdownBtn} from '../util/DropdownBtn' import {PostDropdownBtn} from '../util/DropdownBtn'
import {UserInfoText} from '../util/UserInfoText' import {UserInfoText} from '../util/UserInfoText'
import {RichText} from '../util/RichText'
import {s, colors} from '../../lib/styles' import {s, colors} from '../../lib/styles'
import {ago} from '../../lib/strings' import {ago} from '../../lib/strings'
import {DEF_AVATER} from '../../lib/assets' import {DEF_AVATER} from '../../lib/assets'
@ -114,9 +115,13 @@ export const FeedItem = observer(function FeedItem({
</Link> </Link>
</View> </View>
)} )}
<Text style={[styles.postText, s.f15, s['lh15-1.3']]}> <View style={styles.postTextContainer}>
{record.text} <RichText
</Text> text={record.text}
entities={record.entities}
style={[s.f15, s['lh15-1.3']]}
/>
</View>
<View style={styles.ctrls}> <View style={styles.ctrls}>
<TouchableOpacity style={styles.ctrl} onPress={onPressReply}> <TouchableOpacity style={styles.ctrl} onPress={onPressReply}>
<FontAwesomeIcon <FontAwesomeIcon
@ -209,8 +214,13 @@ const styles = StyleSheet.create({
metaItem: { metaItem: {
paddingRight: 5, paddingRight: 5,
}, },
postText: { postTextContainer: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
paddingBottom: 8, paddingBottom: 8,
},
postText: {
fontFamily: 'Helvetica Neue', fontFamily: 'Helvetica Neue',
}, },
ctrls: { ctrls: {

View File

@ -0,0 +1,95 @@
import React from 'react'
import {Text, TextStyle, StyleProp} from 'react-native'
import {Link} from './Link'
import {s} from '../../lib/styles'
type TextSlice = [number, number]
type Entity = {
index: TextSlice
type: string
value: string
}
export function RichText({
text,
entities,
style,
}: {
text: string
entities?: Entity[]
style?: StyleProp<TextStyle>
}) {
if (!entities?.length) {
return <Text style={style}>{text}</Text>
}
if (!style) style = []
else if (!Array.isArray(style)) style = [style]
entities.sort(sortByIndex)
const segments = Array.from(toSegments(text, entities))
const els = []
let key = 0
for (const segment of segments) {
if (typeof segment === 'string') {
els.push(
<Text key={key} style={style}>
{segment}
</Text>,
)
} else {
els.push(
<Link
key={key}
title={segment.text}
href={`/profile/${segment.entity.value}`}>
<Text key={key} style={[style, s.blue3]}>
{segment.text}
</Text>
</Link>,
)
}
key++
}
return <>{els}</>
}
function sortByIndex(a: Entity, b: Entity) {
return a.index[0] - b.index[0]
}
function* toSegments(text: string, entities: Entity[]) {
let cursor = 0
let i = 0
do {
let currEnt = entities[i]
if (cursor < currEnt.index[0]) {
yield text.slice(cursor, currEnt.index[0])
} else {
i++
continue
}
if (currEnt.index[0] < currEnt.index[1]) {
let subtext = text.slice(currEnt.index[0], currEnt.index[1])
if (
!subtext.trim() ||
stripUsername(subtext) !== stripUsername(currEnt.value)
) {
// dont yield links to empty strings or strings that don't match the entity value
yield subtext
} else {
yield {
entity: currEnt,
text: subtext,
}
}
}
cursor = currEnt.index[1]
i++
} while (i < entities.length)
if (cursor < text.length) {
yield text.slice(cursor, text.length)
}
}
function stripUsername(v: string): string {
return v.trim().replace('@', '')
}

View File

@ -3,6 +3,8 @@ Paul's todo list
- General - General
- Update to RN 0.70 - Update to RN 0.70
- Go through every button and make sure it does what it's supposed to - Go through every button and make sure it does what it's supposed to
- Cache some profile/userinfo lookups
- Cursor behaviors on all views
- Onboarding flow - Onboarding flow
- * - *
- Private beta - Private beta
@ -10,6 +12,7 @@ Paul's todo list
- Firehose - Firehose
- Composer - Composer
- Update the view after creating a post - Update the view after creating a post
- Mentions
- Profile - Profile
- Disable badges for now - Disable badges for now
- Disable editing avi or banner - Disable editing avi or banner