diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/state/lib/api.ts | 25 | ||||
-rw-r--r-- | src/view/com/modals/ComposePost.tsx | 2 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThreadItem.tsx | 37 | ||||
-rw-r--r-- | src/view/com/post/Post.tsx | 16 | ||||
-rw-r--r-- | src/view/com/posts/FeedItem.tsx | 18 | ||||
-rw-r--r-- | src/view/com/util/RichText.tsx | 95 |
6 files changed, 174 insertions, 19 deletions
diff --git a/src/state/lib/api.ts b/src/state/lib/api.ts index feed41c41..db7e2ab21 100644 --- a/src/state/lib/api.ts +++ b/src/state/lib/api.ts @@ -6,9 +6,15 @@ // import {ReactNativeStore} from './auth' import AdxApi from '../../third-party/api' 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 {RootStoreModel} from '../models/root-store' +type Entity = Entities[0] + export function doPolyfill() { AdxApi.xrpc.fetch = fetchHandler } @@ -32,11 +38,13 @@ export async function post( } } } + const entities = extractEntities(text) return await store.api.todo.social.post.create( {did: store.me.did || ''}, { text, reply, + entities, createdAt: new Date().toISOString(), }, ) @@ -196,3 +204,20 @@ async function iterateAll( } } 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 +} diff --git a/src/view/com/modals/ComposePost.tsx b/src/view/com/modals/ComposePost.tsx index 6acb01005..e1e7cac5f 100644 --- a/src/view/com/modals/ComposePost.tsx +++ b/src/view/com/modals/ComposePost.tsx @@ -79,7 +79,7 @@ export function Component({replyTo}: {replyTo?: string}) { const textDecorated = useMemo(() => { 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 ( <Text key={i} style={{color: colors.blue3}}> {item} diff --git a/src/view/com/post-thread/PostThreadItem.tsx b/src/view/com/post-thread/PostThreadItem.tsx index daba54b5a..d28017e44 100644 --- a/src/view/com/post-thread/PostThreadItem.tsx +++ b/src/view/com/post-thread/PostThreadItem.tsx @@ -1,13 +1,14 @@ import React, {useMemo} from 'react' import {observer} from 'mobx-react-lite' 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 * as PostType from '../../../third-party/api/src/types/todo/social/post' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {PostThreadViewPostModel} from '../../../state/models/post-thread-view' import {ComposePostModel} from '../../../state/models/shell' import {Link} from '../util/Link' +import {RichText} from '../util/RichText' import {PostDropdownBtn} from '../util/DropdownBtn' import {s, colors} from '../../lib/styles' import {ago, pluralize} from '../../lib/strings' @@ -144,9 +145,14 @@ export const PostThreadItem = observer(function PostThreadItem({ </View> </View> <View style={[s.pl10, s.pr10, s.pb10]}> - <Text style={[styles.postText, styles.postTextLarge]}> - {record.text} - </Text> + <View + style={[styles.postTextContainer, styles.postTextLargeContainer]}> + <RichText + text={record.text} + entities={record.entities} + style={[styles.postText, styles.postTextLarge]} + /> + </View> {item._isHighlightedPost && hasEngagement ? ( <View style={styles.expandedInfo}> {item.repostCount ? ( @@ -266,9 +272,13 @@ export const PostThreadItem = observer(function PostThreadItem({ /> </PostDropdownBtn> </View> - <Text style={[styles.postText, s.f15, s['lh15-1.3']]}> - {record.text} - </Text> + <View style={styles.postTextContainer}> + <RichText + text={record.text} + entities={record.entities} + style={[styles.postText, s.f15, s['lh15-1.3']]} + /> + </View> <Ctrls /> </View> </View> @@ -325,16 +335,23 @@ const styles = StyleSheet.create({ paddingRight: 5, }, postText: { - paddingBottom: 8, fontFamily: 'Helvetica Neue', }, + postTextContainer: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', + paddingBottom: 8, + }, postTextLarge: { - paddingLeft: 4, - paddingBottom: 20, fontSize: 24, lineHeight: 32, fontWeight: '300', }, + postTextLargeContainer: { + paddingLeft: 4, + paddingBottom: 20, + }, expandedInfo: { flexDirection: 'row', padding: 10, diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index 92113b50b..4cd35659f 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -15,6 +15,7 @@ import {PostThreadViewModel} from '../../../state/models/post-thread-view' import {ComposePostModel} from '../../../state/models/shell' import {Link} from '../util/Link' import {UserInfoText} from '../util/UserInfoText' +import {RichText} from '../util/RichText' import {useStores} from '../../../state' import {s, colors} from '../../lib/styles' import {ago} from '../../lib/strings' @@ -115,9 +116,13 @@ export const Post = observer(function Post({uri}: {uri: string}) { </Link> </View> )} - <Text style={[styles.postText, s.f15, s['lh15-1.3']]}> - {record.text} - </Text> + <View style={styles.postTextContainer}> + <RichText + text={record.text} + entities={record.entities} + style={[s.f15, s['lh15-1.3']]} + /> + </View> <View style={styles.ctrls}> <TouchableOpacity style={styles.ctrl} onPress={onPressReply}> <FontAwesomeIcon @@ -195,7 +200,10 @@ const styles = StyleSheet.create({ metaItem: { paddingRight: 5, }, - postText: { + postTextContainer: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', paddingBottom: 8, }, ctrls: { diff --git a/src/view/com/posts/FeedItem.tsx b/src/view/com/posts/FeedItem.tsx index 53abc4309..c24762730 100644 --- a/src/view/com/posts/FeedItem.tsx +++ b/src/view/com/posts/FeedItem.tsx @@ -9,6 +9,7 @@ import {ComposePostModel, SharePostModel} from '../../../state/models/shell' import {Link} from '../util/Link' import {PostDropdownBtn} from '../util/DropdownBtn' import {UserInfoText} from '../util/UserInfoText' +import {RichText} from '../util/RichText' import {s, colors} from '../../lib/styles' import {ago} from '../../lib/strings' import {DEF_AVATER} from '../../lib/assets' @@ -114,9 +115,13 @@ export const FeedItem = observer(function FeedItem({ </Link> </View> )} - <Text style={[styles.postText, s.f15, s['lh15-1.3']]}> - {record.text} - </Text> + <View style={styles.postTextContainer}> + <RichText + text={record.text} + entities={record.entities} + style={[s.f15, s['lh15-1.3']]} + /> + </View> <View style={styles.ctrls}> <TouchableOpacity style={styles.ctrl} onPress={onPressReply}> <FontAwesomeIcon @@ -209,8 +214,13 @@ const styles = StyleSheet.create({ metaItem: { paddingRight: 5, }, - postText: { + postTextContainer: { + flexDirection: 'row', + alignItems: 'center', + flexWrap: 'wrap', paddingBottom: 8, + }, + postText: { fontFamily: 'Helvetica Neue', }, ctrls: { diff --git a/src/view/com/util/RichText.tsx b/src/view/com/util/RichText.tsx new file mode 100644 index 000000000..25e031556 --- /dev/null +++ b/src/view/com/util/RichText.tsx @@ -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('@', '') +} |