about summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/state/lib/api.ts25
-rw-r--r--src/view/com/modals/ComposePost.tsx2
-rw-r--r--src/view/com/post-thread/PostThreadItem.tsx37
-rw-r--r--src/view/com/post/Post.tsx16
-rw-r--r--src/view/com/posts/FeedItem.tsx18
-rw-r--r--src/view/com/util/RichText.tsx95
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('@', '')
+}