about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--__tests__/string-utils.ts177
-rw-r--r--src/lib/strings.ts46
-rw-r--r--src/view/com/composer/ComposePost.tsx29
-rw-r--r--src/view/com/util/RichText.tsx10
4 files changed, 216 insertions, 46 deletions
diff --git a/__tests__/string-utils.ts b/__tests__/string-utils.ts
index 7e8eeb9a9..37e0012a8 100644
--- a/__tests__/string-utils.ts
+++ b/__tests__/string-utils.ts
@@ -1,8 +1,121 @@
-import {extractEntities} from '../src/lib/strings'
+import {extractEntities, detectLinkables} from '../src/lib/strings'
 
 describe('extractEntities', () => {
+  const knownHandles = new Set(['handle', 'full123.test-of-chars'])
   const inputs = [
     'no mention',
+    '@handle middle end',
+    'start @handle end',
+    'start middle @handle',
+    '@handle @handle @handle',
+    '@full123.test-of-chars',
+    'not@right',
+    '@handle!@#$chars',
+    '@handle\n@handle',
+    'start https://middle.com end',
+    'start https://middle.com/foo/bar end',
+    'start https://middle.com/foo/bar?baz=bux end',
+    'start https://middle.com/foo/bar?baz=bux#hash end',
+    'https://start.com/foo/bar?baz=bux#hash middle end',
+    'start middle https://end.com/foo/bar?baz=bux#hash',
+    'https://newline1.com\nhttps://newline2.com',
+    'start middle.com end',
+    'start middle.com/foo/bar end',
+    'start middle.com/foo/bar?baz=bux end',
+    'start middle.com/foo/bar?baz=bux#hash end',
+    'start.com/foo/bar?baz=bux#hash middle end',
+    'start middle end.com/foo/bar?baz=bux#hash',
+    'newline1.com\nnewline2.com',
+  ]
+  interface Output {
+    type: string
+    value: string
+    noScheme?: boolean
+  }
+  const outputs: Output[][] = [
+    [],
+    [{type: 'mention', value: 'handle'}],
+    [{type: 'mention', value: 'handle'}],
+    [{type: 'mention', value: 'handle'}],
+    [
+      {type: 'mention', value: 'handle'},
+      {type: 'mention', value: 'handle'},
+      {type: 'mention', value: 'handle'},
+    ],
+    [
+      {
+        type: 'mention',
+        value: 'full123.test-of-chars',
+      },
+    ],
+    [],
+    [{type: 'mention', value: 'handle'}],
+    [
+      {type: 'mention', value: 'handle'},
+      {type: 'mention', value: 'handle'},
+    ],
+    [{type: 'link', value: 'https://middle.com'}],
+    [{type: 'link', value: 'https://middle.com/foo/bar'}],
+    [{type: 'link', value: 'https://middle.com/foo/bar?baz=bux'}],
+    [{type: 'link', value: 'https://middle.com/foo/bar?baz=bux#hash'}],
+    [{type: 'link', value: 'https://start.com/foo/bar?baz=bux#hash'}],
+    [{type: 'link', value: 'https://end.com/foo/bar?baz=bux#hash'}],
+    [
+      {type: 'link', value: 'https://newline1.com'},
+      {type: 'link', value: 'https://newline2.com'},
+    ],
+    [{type: 'link', value: 'middle.com', noScheme: true}],
+    [{type: 'link', value: 'middle.com/foo/bar', noScheme: true}],
+    [{type: 'link', value: 'middle.com/foo/bar?baz=bux', noScheme: true}],
+    [{type: 'link', value: 'middle.com/foo/bar?baz=bux#hash', noScheme: true}],
+    [{type: 'link', value: 'start.com/foo/bar?baz=bux#hash', noScheme: true}],
+    [{type: 'link', value: 'end.com/foo/bar?baz=bux#hash', noScheme: true}],
+    [
+      {type: 'link', value: 'newline1.com', noScheme: true},
+      {type: 'link', value: 'newline2.com', noScheme: true},
+    ],
+  ]
+  it('correctly handles a set of text inputs', () => {
+    for (let i = 0; i < inputs.length; i++) {
+      const input = inputs[i]
+      const result = extractEntities(input, knownHandles)
+      if (!outputs[i].length) {
+        expect(result).toBeFalsy()
+      } else if (outputs[i].length && !result) {
+        expect(result).toBeTruthy()
+      } else if (result) {
+        expect(result.length).toBe(outputs[i].length)
+        for (let j = 0; j < outputs[i].length; j++) {
+          expect(result[j].type).toEqual(outputs[i][j].type)
+          if (outputs[i][j].noScheme) {
+            expect(result[j].value).toEqual(`https://${outputs[i][j].value}`)
+          } else {
+            expect(result[j].value).toEqual(outputs[i][j].value)
+          }
+          if (outputs[i]?.[j].type === 'mention') {
+            expect(
+              input.slice(result[j].index.start, result[j].index.end),
+            ).toBe(`@${result[j].value}`)
+          } else {
+            if (!outputs[i]?.[j].noScheme) {
+              expect(
+                input.slice(result[j].index.start, result[j].index.end),
+              ).toBe(result[j].value)
+            } else {
+              expect(
+                input.slice(result[j].index.start, result[j].index.end),
+              ).toBe(result[j].value.slice('https://'.length))
+            }
+          }
+        }
+      }
+    }
+  })
+})
+
+describe('detectLinkables', () => {
+  const inputs = [
+    'no linkable',
     '@start middle end',
     'start @middle end',
     'start middle @end',
@@ -11,37 +124,51 @@ describe('extractEntities', () => {
     'not@right',
     '@bad!@#$chars',
     '@newline1\n@newline2',
+    'start https://middle.com end',
+    'start https://middle.com/foo/bar end',
+    'start https://middle.com/foo/bar?baz=bux end',
+    'start https://middle.com/foo/bar?baz=bux#hash end',
+    'https://start.com/foo/bar?baz=bux#hash middle end',
+    'start middle https://end.com/foo/bar?baz=bux#hash',
+    'https://newline1.com\nhttps://newline2.com',
+    'start middle.com end',
+    'start middle.com/foo/bar end',
+    'start middle.com/foo/bar?baz=bux end',
+    'start middle.com/foo/bar?baz=bux#hash end',
+    'start.com/foo/bar?baz=bux#hash middle end',
+    'start middle end.com/foo/bar?baz=bux#hash',
+    'newline1.com\nnewline2.com',
   ]
   const outputs = [
-    undefined,
-    [{index: [0, 6], type: 'mention', value: 'start'}],
-    [{index: [6, 13], type: 'mention', value: 'middle'}],
-    [{index: [13, 17], type: 'mention', value: 'end'}],
-    [
-      {index: [0, 6], type: 'mention', value: 'start'},
-      {index: [7, 14], type: 'mention', value: 'middle'},
-      {index: [15, 19], type: 'mention', value: 'end'},
-    ],
-    [{index: [0, 22], type: 'mention', value: 'full123.test-of-chars'}],
-    undefined,
-    [{index: [0, 4], type: 'mention', value: 'bad'}],
-    [
-      {index: [0, 9], type: 'mention', value: 'newline1'},
-      {index: [10, 19], type: 'mention', value: 'newline2'},
-    ],
+    ['no linkable'],
+    [{link: '@start'}, ' middle end'],
+    ['start ', {link: '@middle'}, ' end'],
+    ['start middle ', {link: '@end'}],
+    [{link: '@start'}, ' ', {link: '@middle'}, ' ', {link: '@end'}],
+    [{link: '@full123.test-of-chars'}],
+    ['not@right'],
+    [{link: '@bad'}, '!@#$chars'],
+    [{link: '@newline1'}, '\n', {link: '@newline2'}],
+    ['start ', {link: 'https://middle.com'}, ' end'],
+    ['start ', {link: 'https://middle.com/foo/bar'}, ' end'],
+    ['start ', {link: 'https://middle.com/foo/bar?baz=bux'}, ' end'],
+    ['start ', {link: 'https://middle.com/foo/bar?baz=bux#hash'}, ' end'],
+    [{link: 'https://start.com/foo/bar?baz=bux#hash'}, ' middle end'],
+    ['start middle ', {link: 'https://end.com/foo/bar?baz=bux#hash'}],
+    [{link: 'https://newline1.com'}, '\n', {link: 'https://newline2.com'}],
+    ['start ', {link: 'middle.com'}, ' end'],
+    ['start ', {link: 'middle.com/foo/bar'}, ' end'],
+    ['start ', {link: 'middle.com/foo/bar?baz=bux'}, ' end'],
+    ['start ', {link: 'middle.com/foo/bar?baz=bux#hash'}, ' end'],
+    [{link: 'start.com/foo/bar?baz=bux#hash'}, ' middle end'],
+    ['start middle ', {link: 'end.com/foo/bar?baz=bux#hash'}],
+    [{link: 'newline1.com'}, '\n', {link: 'newline2.com'}],
   ]
   it('correctly handles a set of text inputs', () => {
     for (let i = 0; i < inputs.length; i++) {
       const input = inputs[i]
-      const output = extractEntities(input)
+      const output = detectLinkables(input)
       expect(output).toEqual(outputs[i])
-      if (output) {
-        for (const outputItem of output) {
-          expect(input.slice(outputItem.index[0], outputItem.index[1])).toBe(
-            `@${outputItem.value}`,
-          )
-        }
-      }
     }
   })
 })
diff --git a/src/lib/strings.ts b/src/lib/strings.ts
index c8a9171a3..ea2a4dd9f 100644
--- a/src/lib/strings.ts
+++ b/src/lib/strings.ts
@@ -82,13 +82,18 @@ export function extractEntities(
   }
   {
     // links
-    const re = /(^|\s)(https?:\/\/[\S]+)(\b)/dg
+    const re =
+      /(^|\s)((https?:\/\/[\S]+)|([a-z][a-z0-9]*\.[a-z0-9\.]+[\S]*))(\b)/dg
     while ((match = re.exec(text))) {
+      let value = match[2]
+      if (!value.startsWith('http')) {
+        value = `https://${value}`
+      }
       ents.push({
         type: 'link',
-        value: match[2],
+        value,
         index: {
-          start: match.indices[1][0], // skip the (^|\s) but include the '@'
+          start: match.indices[2][0], // skip the (^|\s)
           end: match.indices[2][1],
         },
       })
@@ -97,6 +102,41 @@ export function extractEntities(
   return ents.length > 0 ? ents : undefined
 }
 
+interface DetectedLink {
+  link: string
+}
+type DetectedLinkable = string | DetectedLink
+export function detectLinkables(text: string): DetectedLinkable[] {
+  const re =
+    /((^|\s)@[a-z0-9\.-]*)|((^|\s)https?:\/\/[\S]+)|((^|\s)[a-z][a-z0-9]*\.[a-z0-9\.]+[\S]*)/gi
+  const segments = []
+  let match
+  let start = 0
+  while ((match = re.exec(text))) {
+    let matchIndex = match.index
+    let matchValue = match[0]
+
+    if (/\s/.test(matchValue)) {
+      // HACK
+      // skip the starting space
+      // we have to do this because RN doesnt support negative lookaheads
+      // -prf
+      matchIndex++
+      matchValue = matchValue.slice(1)
+    }
+
+    if (start !== matchIndex) {
+      segments.push(text.slice(start, matchIndex))
+    }
+    segments.push({link: matchValue})
+    start = matchIndex + matchValue.length
+  }
+  if (start < text.length) {
+    segments.push(text.slice(start))
+  }
+  return segments
+}
+
 export function makeValidHandle(str: string): string {
   if (str.length > 20) {
     str = str.slice(0, 20)
diff --git a/src/view/com/composer/ComposePost.tsx b/src/view/com/composer/ComposePost.tsx
index c104041aa..cc9eca068 100644
--- a/src/view/com/composer/ComposePost.tsx
+++ b/src/view/com/composer/ComposePost.tsx
@@ -20,6 +20,7 @@ import {useStores} from '../../../state'
 import * as apilib from '../../../state/lib/api'
 import {ComposerOpts} from '../../../state/models/shell-ui'
 import {s, colors, gradients} from '../../lib/styles'
+import {detectLinkables} from '../../../lib/strings'
 
 const MAX_TEXT_LENGTH = 256
 const WARNING_TEXT_LENGTH = 200
@@ -108,24 +109,18 @@ export const ComposePost = observer(function ComposePost({
       : undefined
 
   const textDecorated = useMemo(() => {
-    const re = /(@[a-z0-9\.]*)|(https?:\/\/[\S]+)/gi
-    const segments = []
-    let match
-    let start = 0
     let i = 0
-    while ((match = re.exec(text))) {
-      segments.push(text.slice(start, match.index))
-      segments.push(
-        <Text key={i++} style={{color: colors.blue3}}>
-          {match[0]}
-        </Text>,
-      )
-      start = match.index + match[0].length
-    }
-    if (start < text.length) {
-      segments.push(text.slice(start))
-    }
-    return segments
+    return detectLinkables(text).map(v => {
+      if (typeof v === 'string') {
+        return v
+      } else {
+        return (
+          <Text key={i++} style={{color: colors.blue3}}>
+            {v.link}
+          </Text>
+        )
+      }
+    })
   }, [text])
 
   return (
diff --git a/src/view/com/util/RichText.tsx b/src/view/com/util/RichText.tsx
index 3c54094ba..a67f90a63 100644
--- a/src/view/com/util/RichText.tsx
+++ b/src/view/com/util/RichText.tsx
@@ -77,7 +77,9 @@ function* toSegments(text: string, entities: Entity[]) {
       let subtext = text.slice(currEnt.index.start, currEnt.index.end)
       if (
         !subtext.trim() ||
-        stripUsername(subtext) !== stripUsername(currEnt.value)
+        (currEnt.type === 'mention' &&
+          stripUsername(subtext) !== stripUsername(currEnt.value)) ||
+        (currEnt.type === 'link' && !isSameLink(subtext, currEnt.value))
       ) {
         // dont yield links to empty strings or strings that don't match the entity value
         yield subtext
@@ -99,3 +101,9 @@ function* toSegments(text: string, entities: Entity[]) {
 function stripUsername(v: string): string {
   return v.trim().replace('@', '')
 }
+
+function isSameLink(a: string, b: string) {
+  a = a.startsWith('http') ? a : `https://${a}`
+  b = b.startsWith('http') ? b : `https://${b}`
+  return a === b
+}