From e488cf8f44a0d6b2b05e50a2cc2e5467ea6f7e37 Mon Sep 17 00:00:00 2001 From: Paul Frazee Date: Tue, 22 Nov 2022 14:30:35 -0600 Subject: [PATCH] Add support for links with no scheme in composer --- __tests__/string-utils.ts | 177 ++++++++++++++++++++++---- src/lib/strings.ts | 46 ++++++- src/view/com/composer/ComposePost.tsx | 29 ++--- src/view/com/util/RichText.tsx | 10 +- 4 files changed, 216 insertions(+), 46 deletions(-) diff --git a/__tests__/string-utils.ts b/__tests__/string-utils.ts index 7e8eeb9a..37e0012a 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 c8a9171a..ea2a4dd9 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 c104041a..cc9eca06 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 3c54094b..a67f90a6 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 +}