Add support for links with no scheme in composer
parent
1df48d4dad
commit
e488cf8f44
|
@ -1,8 +1,121 @@
|
||||||
import {extractEntities} from '../src/lib/strings'
|
import {extractEntities, detectLinkables} from '../src/lib/strings'
|
||||||
|
|
||||||
describe('extractEntities', () => {
|
describe('extractEntities', () => {
|
||||||
|
const knownHandles = new Set(['handle', 'full123.test-of-chars'])
|
||||||
const inputs = [
|
const inputs = [
|
||||||
'no mention',
|
'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',
|
'start @middle end',
|
||||||
'start middle @end',
|
'start middle @end',
|
||||||
|
@ -11,37 +124,51 @@ describe('extractEntities', () => {
|
||||||
'not@right',
|
'not@right',
|
||||||
'@bad!@#$chars',
|
'@bad!@#$chars',
|
||||||
'@newline1\n@newline2',
|
'@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 = [
|
const outputs = [
|
||||||
undefined,
|
['no linkable'],
|
||||||
[{index: [0, 6], type: 'mention', value: 'start'}],
|
[{link: '@start'}, ' middle end'],
|
||||||
[{index: [6, 13], type: 'mention', value: 'middle'}],
|
['start ', {link: '@middle'}, ' end'],
|
||||||
[{index: [13, 17], type: 'mention', value: 'end'}],
|
['start middle ', {link: '@end'}],
|
||||||
[
|
[{link: '@start'}, ' ', {link: '@middle'}, ' ', {link: '@end'}],
|
||||||
{index: [0, 6], type: 'mention', value: 'start'},
|
[{link: '@full123.test-of-chars'}],
|
||||||
{index: [7, 14], type: 'mention', value: 'middle'},
|
['not@right'],
|
||||||
{index: [15, 19], type: 'mention', value: 'end'},
|
[{link: '@bad'}, '!@#$chars'],
|
||||||
],
|
[{link: '@newline1'}, '\n', {link: '@newline2'}],
|
||||||
[{index: [0, 22], type: 'mention', value: 'full123.test-of-chars'}],
|
['start ', {link: 'https://middle.com'}, ' end'],
|
||||||
undefined,
|
['start ', {link: 'https://middle.com/foo/bar'}, ' end'],
|
||||||
[{index: [0, 4], type: 'mention', value: 'bad'}],
|
['start ', {link: 'https://middle.com/foo/bar?baz=bux'}, ' end'],
|
||||||
[
|
['start ', {link: 'https://middle.com/foo/bar?baz=bux#hash'}, ' end'],
|
||||||
{index: [0, 9], type: 'mention', value: 'newline1'},
|
[{link: 'https://start.com/foo/bar?baz=bux#hash'}, ' middle end'],
|
||||||
{index: [10, 19], type: 'mention', value: 'newline2'},
|
['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', () => {
|
it('correctly handles a set of text inputs', () => {
|
||||||
for (let i = 0; i < inputs.length; i++) {
|
for (let i = 0; i < inputs.length; i++) {
|
||||||
const input = inputs[i]
|
const input = inputs[i]
|
||||||
const output = extractEntities(input)
|
const output = detectLinkables(input)
|
||||||
expect(output).toEqual(outputs[i])
|
expect(output).toEqual(outputs[i])
|
||||||
if (output) {
|
|
||||||
for (const outputItem of output) {
|
|
||||||
expect(input.slice(outputItem.index[0], outputItem.index[1])).toBe(
|
|
||||||
`@${outputItem.value}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -82,13 +82,18 @@ export function extractEntities(
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
// links
|
// 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))) {
|
while ((match = re.exec(text))) {
|
||||||
|
let value = match[2]
|
||||||
|
if (!value.startsWith('http')) {
|
||||||
|
value = `https://${value}`
|
||||||
|
}
|
||||||
ents.push({
|
ents.push({
|
||||||
type: 'link',
|
type: 'link',
|
||||||
value: match[2],
|
value,
|
||||||
index: {
|
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],
|
end: match.indices[2][1],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
@ -97,6 +102,41 @@ export function extractEntities(
|
||||||
return ents.length > 0 ? ents : undefined
|
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 {
|
export function makeValidHandle(str: string): string {
|
||||||
if (str.length > 20) {
|
if (str.length > 20) {
|
||||||
str = str.slice(0, 20)
|
str = str.slice(0, 20)
|
||||||
|
|
|
@ -20,6 +20,7 @@ import {useStores} from '../../../state'
|
||||||
import * as apilib from '../../../state/lib/api'
|
import * as apilib from '../../../state/lib/api'
|
||||||
import {ComposerOpts} from '../../../state/models/shell-ui'
|
import {ComposerOpts} from '../../../state/models/shell-ui'
|
||||||
import {s, colors, gradients} from '../../lib/styles'
|
import {s, colors, gradients} from '../../lib/styles'
|
||||||
|
import {detectLinkables} from '../../../lib/strings'
|
||||||
|
|
||||||
const MAX_TEXT_LENGTH = 256
|
const MAX_TEXT_LENGTH = 256
|
||||||
const WARNING_TEXT_LENGTH = 200
|
const WARNING_TEXT_LENGTH = 200
|
||||||
|
@ -108,24 +109,18 @@ export const ComposePost = observer(function ComposePost({
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const textDecorated = useMemo(() => {
|
const textDecorated = useMemo(() => {
|
||||||
const re = /(@[a-z0-9\.]*)|(https?:\/\/[\S]+)/gi
|
|
||||||
const segments = []
|
|
||||||
let match
|
|
||||||
let start = 0
|
|
||||||
let i = 0
|
let i = 0
|
||||||
while ((match = re.exec(text))) {
|
return detectLinkables(text).map(v => {
|
||||||
segments.push(text.slice(start, match.index))
|
if (typeof v === 'string') {
|
||||||
segments.push(
|
return v
|
||||||
<Text key={i++} style={{color: colors.blue3}}>
|
} else {
|
||||||
{match[0]}
|
return (
|
||||||
</Text>,
|
<Text key={i++} style={{color: colors.blue3}}>
|
||||||
)
|
{v.link}
|
||||||
start = match.index + match[0].length
|
</Text>
|
||||||
}
|
)
|
||||||
if (start < text.length) {
|
}
|
||||||
segments.push(text.slice(start))
|
})
|
||||||
}
|
|
||||||
return segments
|
|
||||||
}, [text])
|
}, [text])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -77,7 +77,9 @@ function* toSegments(text: string, entities: Entity[]) {
|
||||||
let subtext = text.slice(currEnt.index.start, currEnt.index.end)
|
let subtext = text.slice(currEnt.index.start, currEnt.index.end)
|
||||||
if (
|
if (
|
||||||
!subtext.trim() ||
|
!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
|
// dont yield links to empty strings or strings that don't match the entity value
|
||||||
yield subtext
|
yield subtext
|
||||||
|
@ -99,3 +101,9 @@ function* toSegments(text: string, entities: Entity[]) {
|
||||||
function stripUsername(v: string): string {
|
function stripUsername(v: string): string {
|
||||||
return v.trim().replace('@', '')
|
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
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue