Implement mentions rendering
parent
2058505bf1
commit
195d2f7d2b
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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('@', '')
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue