diff --git a/__tests__/string-utils.ts b/__tests__/string-utils.ts
index 7e8eeb9a95..37e0012a86 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 c8a9171a3d..ea2a4dd9fc 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 c104041aad..cc9eca068b 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(
-
- {match[0]}
- ,
- )
- 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 (
+
+ {v.link}
+
+ )
+ }
+ })
}, [text])
return (
diff --git a/src/view/com/util/RichText.tsx b/src/view/com/util/RichText.tsx
index 3c54094ba2..a67f90a630 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
+}