Sanitizes posts on publish and render (#217)
* Sanatizes posts on publish and render * lint * lint and added sanitize to thread view as well * adjusts indices based on replaced text * Woops, fixes a bug * bugfix + cleanup * comment * lint * move sanitize text to later in the flow * undo changes to compose post * Add RichText library building upon the sanitizePost library method * Add lodash.clonedeep dep * Switch to RichText processing on record load & render * Fix lint --------- Co-authored-by: Paul Frazee <pfrazee@gmail.com>
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import {AppBskyFeedPost} from '@atproto/api'
|
||||
type Entity = AppBskyFeedPost.Entity
|
||||
import {RichText} from '../../../src/lib/strings/rich-text'
|
||||
import {removeExcessNewlines} from '../../../src/lib/strings/rich-text-sanitize'
|
||||
|
||||
describe('removeExcessNewlines', () => {
|
||||
it('removes more than two consecutive new lines', () => {
|
||||
const input = new RichText(
|
||||
'test\n\n\n\n\ntest\n\n\n\n\n\n\ntest\n\n\n\n\n\n\ntest\n\n\n\n\n\n\ntest',
|
||||
)
|
||||
const output = removeExcessNewlines(input)
|
||||
expect(output.text).toEqual('test\n\ntest\n\ntest\n\ntest\n\ntest')
|
||||
})
|
||||
|
||||
it('removes more than two consecutive new lines with spaces', () => {
|
||||
const input = new RichText(
|
||||
'test\n\n\n\n\ntest\n \n \n \n \n\n\ntest\n\n\n\n\n\n\ntest\n\n\n\n\n \n\ntest',
|
||||
)
|
||||
const output = removeExcessNewlines(input)
|
||||
expect(output.text).toEqual('test\n\ntest\n\ntest\n\ntest\n\ntest')
|
||||
})
|
||||
|
||||
it('returns original string if there are no consecutive new lines', () => {
|
||||
const input = new RichText('test\n\ntest\n\ntest\n\ntest\n\ntest')
|
||||
const output = removeExcessNewlines(input)
|
||||
expect(output.text).toEqual(input.text)
|
||||
})
|
||||
|
||||
it('returns original string if there are no new lines', () => {
|
||||
const input = new RichText('test test test test test')
|
||||
const output = removeExcessNewlines(input)
|
||||
expect(output.text).toEqual(input.text)
|
||||
})
|
||||
|
||||
it('returns empty string if input is empty', () => {
|
||||
const input = new RichText('')
|
||||
const output = removeExcessNewlines(input)
|
||||
expect(output.text).toEqual('')
|
||||
})
|
||||
|
||||
it('works with different types of new line characters', () => {
|
||||
const input = new RichText(
|
||||
'test\r\ntest\n\rtest\rtest\n\n\n\ntest\n\r \n \n \n \n\n\ntest',
|
||||
)
|
||||
const output = removeExcessNewlines(input)
|
||||
expect(output.text).toEqual('test\r\ntest\n\rtest\rtest\n\ntest\n\ntest')
|
||||
})
|
||||
|
||||
it('removes more than two consecutive new lines with zero width space', () => {
|
||||
const input = new RichText(
|
||||
'test\n\n\n\n\ntest\n\u200B\u200B\n\n\n\ntest\n \u200B\u200B \n\n\n\ntest\n\n\n\n\n\n\ntest',
|
||||
)
|
||||
const output = removeExcessNewlines(input)
|
||||
expect(output.text).toEqual('test\n\ntest\n\ntest\n\ntest\n\ntest')
|
||||
})
|
||||
|
||||
it('removes more than two consecutive new lines with zero width non-joiner', () => {
|
||||
const input = new RichText(
|
||||
'test\n\n\n\n\ntest\n\u200C\u200C\n\n\n\ntest\n \u200C\u200C \n\n\n\ntest\n\n\n\n\n\n\ntest',
|
||||
)
|
||||
const output = removeExcessNewlines(input)
|
||||
expect(output.text).toEqual('test\n\ntest\n\ntest\n\ntest\n\ntest')
|
||||
})
|
||||
|
||||
it('removes more than two consecutive new lines with zero width joiner', () => {
|
||||
const input = new RichText(
|
||||
'test\n\n\n\n\ntest\n\u200D\u200D\n\n\n\ntest\n \u200D\u200D \n\n\n\ntest\n\n\n\n\n\n\ntest',
|
||||
)
|
||||
const output = removeExcessNewlines(input)
|
||||
expect(output.text).toEqual('test\n\ntest\n\ntest\n\ntest\n\ntest')
|
||||
})
|
||||
|
||||
it('removes more than two consecutive new lines with soft hyphen', () => {
|
||||
const input = new RichText(
|
||||
'test\n\n\n\n\ntest\n\u00AD\u00AD\n\n\n\ntest\n \u00AD\u00AD \n\n\n\ntest\n\n\n\n\n\n\ntest',
|
||||
)
|
||||
const output = removeExcessNewlines(input)
|
||||
expect(output.text).toEqual('test\n\ntest\n\ntest\n\ntest\n\ntest')
|
||||
})
|
||||
|
||||
it('removes more than two consecutive new lines with word joiner', () => {
|
||||
const input = new RichText(
|
||||
'test\n\n\n\n\ntest\n\u2060\u2060\n\n\n\ntest\n \u2060\u2060 \n\n\n\ntest\n\n\n\n\n\n\ntest',
|
||||
)
|
||||
const output = removeExcessNewlines(input)
|
||||
expect(output.text).toEqual('test\n\ntest\n\ntest\n\ntest\n\ntest')
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeExcessNewlines w/entities', () => {
|
||||
it('preserves entities as expected', () => {
|
||||
const input = new RichText(
|
||||
'test\n\n\n\n\ntest\n\n\n\n\n\n\ntest\n\n\n\n\n\n\ntest\n\n\n\n\n\n\ntest',
|
||||
[
|
||||
{index: {start: 0, end: 13}, type: '', value: ''},
|
||||
{index: {start: 13, end: 24}, type: '', value: ''},
|
||||
{index: {start: 9, end: 15}, type: '', value: ''},
|
||||
{index: {start: 4, end: 9}, type: '', value: ''},
|
||||
],
|
||||
)
|
||||
const output = removeExcessNewlines(input)
|
||||
expect(entToStr(input.text, input.entities?.[0])).toEqual(
|
||||
'test\n\n\n\n\ntest',
|
||||
)
|
||||
expect(entToStr(input.text, input.entities?.[1])).toEqual(
|
||||
'\n\n\n\n\n\n\ntest',
|
||||
)
|
||||
expect(entToStr(input.text, input.entities?.[2])).toEqual('test\n\n')
|
||||
expect(entToStr(input.text, input.entities?.[3])).toEqual('\n\n\n\n\n')
|
||||
expect(output.text).toEqual('test\n\ntest\n\ntest\n\ntest\n\ntest')
|
||||
expect(entToStr(output.text, output.entities?.[0])).toEqual('test\n\ntest')
|
||||
expect(entToStr(output.text, output.entities?.[1])).toEqual('test')
|
||||
expect(entToStr(output.text, output.entities?.[2])).toEqual('test')
|
||||
expect(output.entities?.[3]).toEqual(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
function entToStr(str: string, ent?: Entity) {
|
||||
if (!ent) {
|
||||
return ''
|
||||
}
|
||||
return str.slice(ent.index.start, ent.index.end)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import {RichText} from '../../../src/lib/strings/rich-text'
|
||||
|
||||
describe('richText.insert', () => {
|
||||
const input = new RichText('hello world', [
|
||||
{index: {start: 2, end: 7}, type: '', value: ''},
|
||||
])
|
||||
|
||||
it('correctly adjusts entities (scenario A - before)', () => {
|
||||
const output = input.clone().insert(0, 'test')
|
||||
expect(output.text).toEqual('testhello world')
|
||||
expect(output.entities?.[0].index.start).toEqual(6)
|
||||
expect(output.entities?.[0].index.end).toEqual(11)
|
||||
expect(
|
||||
output.text.slice(
|
||||
output.entities?.[0].index.start,
|
||||
output.entities?.[0].index.end,
|
||||
),
|
||||
).toEqual('llo w')
|
||||
})
|
||||
|
||||
it('correctly adjusts entities (scenario B - inner)', () => {
|
||||
const output = input.clone().insert(4, 'test')
|
||||
expect(output.text).toEqual('helltesto world')
|
||||
expect(output.entities?.[0].index.start).toEqual(2)
|
||||
expect(output.entities?.[0].index.end).toEqual(11)
|
||||
expect(
|
||||
output.text.slice(
|
||||
output.entities?.[0].index.start,
|
||||
output.entities?.[0].index.end,
|
||||
),
|
||||
).toEqual('lltesto w')
|
||||
})
|
||||
|
||||
it('correctly adjusts entities (scenario C - after)', () => {
|
||||
const output = input.clone().insert(8, 'test')
|
||||
expect(output.text).toEqual('hello wotestrld')
|
||||
expect(output.entities?.[0].index.start).toEqual(2)
|
||||
expect(output.entities?.[0].index.end).toEqual(7)
|
||||
expect(
|
||||
output.text.slice(
|
||||
output.entities?.[0].index.start,
|
||||
output.entities?.[0].index.end,
|
||||
),
|
||||
).toEqual('llo w')
|
||||
})
|
||||
})
|
||||
|
||||
describe('richText.delete', () => {
|
||||
const input = new RichText('hello world', [
|
||||
{index: {start: 2, end: 7}, type: '', value: ''},
|
||||
])
|
||||
|
||||
it('correctly adjusts entities (scenario A - entirely outer)', () => {
|
||||
const output = input.clone().delete(0, 9)
|
||||
expect(output.text).toEqual('ld')
|
||||
expect(output.entities?.length).toEqual(0)
|
||||
})
|
||||
|
||||
it('correctly adjusts entities (scenario B - entirely after)', () => {
|
||||
const output = input.clone().delete(7, 11)
|
||||
expect(output.text).toEqual('hello w')
|
||||
expect(output.entities?.[0].index.start).toEqual(2)
|
||||
expect(output.entities?.[0].index.end).toEqual(7)
|
||||
expect(
|
||||
output.text.slice(
|
||||
output.entities?.[0].index.start,
|
||||
output.entities?.[0].index.end,
|
||||
),
|
||||
).toEqual('llo w')
|
||||
})
|
||||
|
||||
it('correctly adjusts entities (scenario C - partially after)', () => {
|
||||
const output = input.clone().delete(4, 11)
|
||||
expect(output.text).toEqual('hell')
|
||||
expect(output.entities?.[0].index.start).toEqual(2)
|
||||
expect(output.entities?.[0].index.end).toEqual(4)
|
||||
expect(
|
||||
output.text.slice(
|
||||
output.entities?.[0].index.start,
|
||||
output.entities?.[0].index.end,
|
||||
),
|
||||
).toEqual('ll')
|
||||
})
|
||||
|
||||
it('correctly adjusts entities (scenario D - entirely inner)', () => {
|
||||
const output = input.clone().delete(3, 5)
|
||||
expect(output.text).toEqual('hel world')
|
||||
expect(output.entities?.[0].index.start).toEqual(2)
|
||||
expect(output.entities?.[0].index.end).toEqual(5)
|
||||
expect(
|
||||
output.text.slice(
|
||||
output.entities?.[0].index.start,
|
||||
output.entities?.[0].index.end,
|
||||
),
|
||||
).toEqual('l w')
|
||||
})
|
||||
|
||||
it('correctly adjusts entities (scenario E - partially before)', () => {
|
||||
const output = input.clone().delete(1, 5)
|
||||
expect(output.text).toEqual('h world')
|
||||
expect(output.entities?.[0].index.start).toEqual(1)
|
||||
expect(output.entities?.[0].index.end).toEqual(3)
|
||||
expect(
|
||||
output.text.slice(
|
||||
output.entities?.[0].index.start,
|
||||
output.entities?.[0].index.end,
|
||||
),
|
||||
).toEqual(' w')
|
||||
})
|
||||
|
||||
it('correctly adjusts entities (scenario F - entirely before)', () => {
|
||||
const output = input.clone().delete(0, 2)
|
||||
expect(output.text).toEqual('llo world')
|
||||
expect(output.entities?.[0].index.start).toEqual(0)
|
||||
expect(output.entities?.[0].index.end).toEqual(5)
|
||||
expect(
|
||||
output.text.slice(
|
||||
output.entities?.[0].index.start,
|
||||
output.entities?.[0].index.end,
|
||||
),
|
||||
).toEqual('llo w')
|
||||
})
|
||||
})
|
||||
@@ -39,6 +39,7 @@
|
||||
"email-validator": "^2.0.4",
|
||||
"he": "^1.2.0",
|
||||
"lodash.chunk": "^4.2.0",
|
||||
"lodash.clonedeep": "^4.5.0",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"lodash.omit": "^4.5.0",
|
||||
"lodash.shuffle": "^4.2.0",
|
||||
@@ -91,6 +92,7 @@
|
||||
"@types/he": "^1.1.2",
|
||||
"@types/jest": "^26.0.23",
|
||||
"@types/lodash.chunk": "^4.2.7",
|
||||
"@types/lodash.clonedeep": "^4.5.7",
|
||||
"@types/lodash.isequal": "^4.5.6",
|
||||
"@types/lodash.omit": "^4.5.7",
|
||||
"@types/lodash.shuffle": "^4.2.7",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import {RichText} from './rich-text'
|
||||
|
||||
const EXCESS_SPACE_RE = /[\r\n]([\u00AD\u2060\u200D\u200C\u200B\s]*[\r\n]){2,}/
|
||||
const REPLACEMENT_STR = '\n\n'
|
||||
|
||||
export function removeExcessNewlines(richText: RichText): RichText {
|
||||
return clean(richText, EXCESS_SPACE_RE, REPLACEMENT_STR)
|
||||
}
|
||||
|
||||
// TODO: check on whether this works correctly with multi-byte codepoints
|
||||
export function clean(
|
||||
richText: RichText,
|
||||
targetRegexp: RegExp,
|
||||
replacementString: string,
|
||||
): RichText {
|
||||
richText = richText.clone()
|
||||
|
||||
let match = richText.text.match(targetRegexp)
|
||||
while (match && typeof match.index !== 'undefined') {
|
||||
const oldText = richText.text
|
||||
const removeStartIndex = match.index
|
||||
const removeEndIndex = removeStartIndex + match[0].length
|
||||
richText.delete(removeStartIndex, removeEndIndex)
|
||||
if (richText.text === oldText) {
|
||||
break // sanity check
|
||||
}
|
||||
richText.insert(removeStartIndex, replacementString)
|
||||
match = richText.text.match(targetRegexp)
|
||||
}
|
||||
|
||||
return richText
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
= Rich Text Manipulation
|
||||
|
||||
When we sanitize rich text, we have to update the entity indices as the
|
||||
text is modified. This can be modeled as inserts() and deletes() of the
|
||||
rich text string. The possible scenarios are outlined below, along with
|
||||
their expected behaviors.
|
||||
|
||||
NOTE: Slices are start inclusive, end exclusive
|
||||
|
||||
== richTextInsert()
|
||||
|
||||
Target string:
|
||||
|
||||
0 1 2 3 4 5 6 7 8 910 // string indices
|
||||
h e l l o w o r l d // string value
|
||||
^-------^ // target slice {start: 2, end: 7}
|
||||
|
||||
Scenarios:
|
||||
|
||||
A: ^ // insert "test" at 0
|
||||
B: ^ // insert "test" at 4
|
||||
C: ^ // insert "test" at 8
|
||||
|
||||
A = before -> move both by num added
|
||||
B = inner -> move end by num added
|
||||
C = after -> noop
|
||||
|
||||
Results:
|
||||
|
||||
A: 0 1 2 3 4 5 6 7 8 910 // string indices
|
||||
t e s t h e l l o w // string value
|
||||
^-------^ // target slice {start: 6, end: 11}
|
||||
|
||||
B: 0 1 2 3 4 5 6 7 8 910 // string indices
|
||||
h e l l t e s t o w // string value
|
||||
^---------------^ // target slice {start: 2, end: 11}
|
||||
|
||||
C: 0 1 2 3 4 5 6 7 8 910 // string indices
|
||||
h e l l o w o t e s // string value
|
||||
^-------^ // target slice {start: 2, end: 7}
|
||||
|
||||
== richTextDelete()
|
||||
|
||||
Target string:
|
||||
|
||||
0 1 2 3 4 5 6 7 8 910 // string indices
|
||||
h e l l o w o r l d // string value
|
||||
^-------^ // target slice {start: 2, end: 7}
|
||||
|
||||
Scenarios:
|
||||
|
||||
A: ^---------------^ // remove slice {start: 0, end: 9}
|
||||
B: ^-----^ // remove slice {start: 7, end: 11}
|
||||
C: ^-----------^ // remove slice {start: 4, end: 11}
|
||||
D: ^-^ // remove slice {start: 3, end: 5}
|
||||
E: ^-----^ // remove slice {start: 1, end: 5}
|
||||
F: ^-^ // remove slice {start: 0, end: 2}
|
||||
|
||||
A = entirely outer -> delete slice
|
||||
B = entirely after -> noop
|
||||
C = partially after -> move end to remove-start
|
||||
D = entirely inner -> move end by num removed
|
||||
E = partially before -> move start to remove-start index, move end by num removed
|
||||
F = entirely before -> move both by num removed
|
||||
|
||||
Results:
|
||||
|
||||
A: 0 1 2 3 4 5 6 7 8 910 // string indices
|
||||
l d // string value
|
||||
// target slice (deleted)
|
||||
|
||||
B: 0 1 2 3 4 5 6 7 8 910 // string indices
|
||||
h e l l o w // string value
|
||||
^-------^ // target slice {start: 2, end: 7}
|
||||
|
||||
C: 0 1 2 3 4 5 6 7 8 910 // string indices
|
||||
h e l l // string value
|
||||
^-^ // target slice {start: 2, end: 4}
|
||||
|
||||
D: 0 1 2 3 4 5 6 7 8 910 // string indices
|
||||
h e l w o r l d // string value
|
||||
^---^ // target slice {start: 2, end: 5}
|
||||
|
||||
E: 0 1 2 3 4 5 6 7 8 910 // string indices
|
||||
h w o r l d // string value
|
||||
^-^ // target slice {start: 1, end: 3}
|
||||
|
||||
F: 0 1 2 3 4 5 6 7 8 910 // string indices
|
||||
l l o w o r l d // string value
|
||||
^-------^ // target slice {start: 0, end: 5}
|
||||
*/
|
||||
|
||||
import cloneDeep from 'lodash.clonedeep'
|
||||
import {AppBskyFeedPost} from '@atproto/api'
|
||||
import {removeExcessNewlines} from './rich-text-sanitize'
|
||||
|
||||
export type Entity = AppBskyFeedPost.Entity
|
||||
export interface RichTextOpts {
|
||||
cleanNewlines?: boolean
|
||||
}
|
||||
|
||||
export class RichText {
|
||||
constructor(
|
||||
public text: string,
|
||||
public entities?: Entity[],
|
||||
opts?: RichTextOpts,
|
||||
) {
|
||||
if (opts?.cleanNewlines) {
|
||||
removeExcessNewlines(this).copyInto(this)
|
||||
}
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new RichText(this.text, cloneDeep(this.entities))
|
||||
}
|
||||
|
||||
copyInto(target: RichText) {
|
||||
target.text = this.text
|
||||
target.entities = cloneDeep(this.entities)
|
||||
}
|
||||
|
||||
insert(insertIndex: number, insertText: string) {
|
||||
this.text =
|
||||
this.text.slice(0, insertIndex) +
|
||||
insertText +
|
||||
this.text.slice(insertIndex)
|
||||
|
||||
if (!this.entities?.length) {
|
||||
return this
|
||||
}
|
||||
|
||||
const numCharsAdded = insertText.length
|
||||
for (const ent of this.entities) {
|
||||
// see comment at top of file for labels of each scenario
|
||||
// scenario A (before)
|
||||
if (insertIndex <= ent.index.start) {
|
||||
// move both by num added
|
||||
ent.index.start += numCharsAdded
|
||||
ent.index.end += numCharsAdded
|
||||
}
|
||||
// scenario B (inner)
|
||||
else if (insertIndex >= ent.index.start && insertIndex < ent.index.end) {
|
||||
// move end by num added
|
||||
ent.index.end += numCharsAdded
|
||||
}
|
||||
// scenario C (after)
|
||||
// noop
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
delete(removeStartIndex: number, removeEndIndex: number) {
|
||||
this.text =
|
||||
this.text.slice(0, removeStartIndex) + this.text.slice(removeEndIndex)
|
||||
|
||||
if (!this.entities?.length) {
|
||||
return this
|
||||
}
|
||||
|
||||
const numCharsRemoved = removeEndIndex - removeStartIndex
|
||||
for (const ent of this.entities) {
|
||||
// see comment at top of file for labels of each scenario
|
||||
// scenario A (entirely outer)
|
||||
if (
|
||||
removeStartIndex <= ent.index.start &&
|
||||
removeEndIndex >= ent.index.end
|
||||
) {
|
||||
// delete slice (will get removed in final pass)
|
||||
ent.index.start = 0
|
||||
ent.index.end = 0
|
||||
}
|
||||
// scenario B (entirely after)
|
||||
else if (removeStartIndex > ent.index.end) {
|
||||
// noop
|
||||
}
|
||||
// scenario C (partially after)
|
||||
else if (
|
||||
removeStartIndex > ent.index.start &&
|
||||
removeStartIndex <= ent.index.end &&
|
||||
removeEndIndex > ent.index.end
|
||||
) {
|
||||
// move end to remove start
|
||||
ent.index.end = removeStartIndex
|
||||
}
|
||||
// scenario D (entirely inner)
|
||||
else if (
|
||||
removeStartIndex >= ent.index.start &&
|
||||
removeEndIndex <= ent.index.end
|
||||
) {
|
||||
// move end by num removed
|
||||
ent.index.end -= numCharsRemoved
|
||||
}
|
||||
// scenario E (partially before)
|
||||
else if (
|
||||
removeStartIndex < ent.index.start &&
|
||||
removeEndIndex >= ent.index.start &&
|
||||
removeEndIndex <= ent.index.end
|
||||
) {
|
||||
// move start to remove-start index, move end by num removed
|
||||
ent.index.start = removeStartIndex
|
||||
ent.index.end -= numCharsRemoved
|
||||
}
|
||||
// scenario F (entirely before)
|
||||
else if (removeEndIndex < ent.index.start) {
|
||||
// move both by num removed
|
||||
ent.index.start -= numCharsRemoved
|
||||
ent.index.end -= numCharsRemoved
|
||||
}
|
||||
}
|
||||
|
||||
// filter out any entities that were made irrelevant
|
||||
this.entities = this.entities.filter(ent => ent.index.start < ent.index.end)
|
||||
return this
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {extractEntities} from '../../lib/strings'
|
||||
import {isNetworkError} from '../../lib/errors'
|
||||
import {LinkMeta} from '../../lib/link-meta'
|
||||
import {Image} from '../../lib/images'
|
||||
import {RichText} from './../../lib/strings/rich-text'
|
||||
|
||||
const TIMEOUT = 10e3 // 10s
|
||||
|
||||
@@ -35,7 +36,7 @@ export async function resolveName(store: RootStoreModel, didOrHandle: string) {
|
||||
|
||||
export async function post(
|
||||
store: RootStoreModel,
|
||||
text: string,
|
||||
rawText: string,
|
||||
replyTo?: string,
|
||||
extLink?: ExternalEmbedDraft,
|
||||
images?: string[],
|
||||
@@ -44,6 +45,9 @@ export async function post(
|
||||
) {
|
||||
let embed: AppBskyEmbedImages.Main | AppBskyEmbedExternal.Main | undefined
|
||||
let reply
|
||||
const text = new RichText(rawText, undefined, {
|
||||
cleanNewlines: true,
|
||||
}).text.trim()
|
||||
|
||||
onStateChange?.('Processing...')
|
||||
const entities = extractEntities(text, knownHandles)
|
||||
|
||||
@@ -14,6 +14,7 @@ import {AtUri} from '../../third-party/uri'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import * as apilib from '../lib/api'
|
||||
import {cleanError} from '../../lib/strings'
|
||||
import {RichText} from '../../lib/strings/rich-text'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
|
||||
@@ -39,6 +40,7 @@ export class FeedItemModel {
|
||||
reply?: FeedViewPost['reply']
|
||||
replyParent?: FeedItemModel
|
||||
reason?: FeedViewPost['reason']
|
||||
richText?: RichText
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
@@ -51,6 +53,11 @@ export class FeedItemModel {
|
||||
const valid = AppBskyFeedPost.validateRecord(this.post.record)
|
||||
if (valid.success) {
|
||||
this.postRecord = this.post.record
|
||||
this.richText = new RichText(
|
||||
this.postRecord.text,
|
||||
this.postRecord.entities,
|
||||
{cleanNewlines: true},
|
||||
)
|
||||
} else {
|
||||
rootStore.log.warn(
|
||||
'Received an invalid app.bsky.feed.post record',
|
||||
|
||||
@@ -7,6 +7,7 @@ import {AtUri} from '../../third-party/uri'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import * as apilib from '../lib/api'
|
||||
import {cleanError} from '../../lib/strings'
|
||||
import {RichText} from '../../lib/strings/rich-text'
|
||||
|
||||
function* reactKeyGenerator(): Generator<string> {
|
||||
let counter = 0
|
||||
@@ -27,6 +28,7 @@ export class PostThreadViewPostModel {
|
||||
postRecord?: FeedPost.Record
|
||||
parent?: PostThreadViewPostModel | GetPostThread.NotFoundPost
|
||||
replies?: (PostThreadViewPostModel | GetPostThread.NotFoundPost)[]
|
||||
richText?: RichText
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
@@ -39,6 +41,11 @@ export class PostThreadViewPostModel {
|
||||
const valid = FeedPost.validateRecord(this.post.record)
|
||||
if (valid.success) {
|
||||
this.postRecord = this.post.record
|
||||
this.richText = new RichText(
|
||||
this.postRecord.text,
|
||||
this.postRecord.entities,
|
||||
{cleanNewlines: true},
|
||||
)
|
||||
} else {
|
||||
rootStore.log.warn(
|
||||
'Received an invalid app.bsky.feed.post record',
|
||||
|
||||
@@ -4,14 +4,13 @@ import {
|
||||
AppBskyActorGetProfile as GetProfile,
|
||||
AppBskyActorProfile as Profile,
|
||||
AppBskySystemDeclRef,
|
||||
AppBskyFeedPost,
|
||||
} from '@atproto/api'
|
||||
type DeclRef = AppBskySystemDeclRef.Main
|
||||
type Entity = AppBskyFeedPost.Entity
|
||||
import {extractEntities} from '../../lib/strings'
|
||||
import {RootStoreModel} from './root-store'
|
||||
import * as apilib from '../lib/api'
|
||||
import {cleanError} from '../../lib/strings'
|
||||
import {RichText} from '../../lib/strings/rich-text'
|
||||
|
||||
export const ACTOR_TYPE_USER = 'app.bsky.system.actorUser'
|
||||
|
||||
@@ -51,7 +50,7 @@ export class ProfileViewModel {
|
||||
viewer = new ProfileViewViewerModel()
|
||||
|
||||
// added data
|
||||
descriptionEntities?: Entity[]
|
||||
descriptionRichText?: RichText
|
||||
|
||||
constructor(
|
||||
public rootStore: RootStoreModel,
|
||||
@@ -230,6 +229,10 @@ export class ProfileViewModel {
|
||||
Object.assign(this.viewer, res.data.viewer)
|
||||
this.rootStore.me.follows.hydrate(this.did, res.data.viewer.following)
|
||||
}
|
||||
this.descriptionEntities = extractEntities(this.description || '')
|
||||
this.descriptionRichText = new RichText(
|
||||
this.description || '',
|
||||
extractEntities(this.description || ''),
|
||||
{cleanNewlines: true},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||
</View>
|
||||
</View>
|
||||
<View style={[s.pl10, s.pr10, s.pb10]}>
|
||||
{record.text ? (
|
||||
{item.richText?.text ? (
|
||||
<View
|
||||
style={[
|
||||
styles.postTextContainer,
|
||||
@@ -185,8 +185,7 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||
]}>
|
||||
<RichText
|
||||
type="post-text-lg"
|
||||
text={record.text}
|
||||
entities={record.entities}
|
||||
richText={item.richText}
|
||||
lineHeight={1.3}
|
||||
/>
|
||||
</View>
|
||||
@@ -301,12 +300,11 @@ export const PostThreadItem = observer(function PostThreadItem({
|
||||
<FontAwesomeIcon icon={['far', 'eye-slash']} style={s.mr2} />
|
||||
<Text type="sm">This post is by a muted account.</Text>
|
||||
</View>
|
||||
) : record.text ? (
|
||||
) : item.richText?.text ? (
|
||||
<View style={styles.postTextContainer}>
|
||||
<RichText
|
||||
type="post-text"
|
||||
text={record.text}
|
||||
entities={record.entities}
|
||||
richText={item.richText}
|
||||
style={pal.text}
|
||||
lineHeight={1.3}
|
||||
/>
|
||||
|
||||
@@ -180,12 +180,11 @@ export const Post = observer(function Post({
|
||||
<FontAwesomeIcon icon={['far', 'eye-slash']} style={s.mr2} />
|
||||
<Text type="sm">This post is by a muted account.</Text>
|
||||
</View>
|
||||
) : record.text ? (
|
||||
) : item.richText?.text ? (
|
||||
<View style={styles.postTextContainer}>
|
||||
<RichText
|
||||
type="post-text"
|
||||
text={record.text}
|
||||
entities={record.entities}
|
||||
richText={item.richText}
|
||||
lineHeight={1.3}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -194,12 +194,11 @@ export const FeedItem = observer(function ({
|
||||
<FontAwesomeIcon icon={['far', 'eye-slash']} style={s.mr2} />
|
||||
<Text type="sm">This post is by a muted account.</Text>
|
||||
</View>
|
||||
) : record.text ? (
|
||||
) : item.richText?.text ? (
|
||||
<View style={styles.postTextContainer}>
|
||||
<RichText
|
||||
type="post-text"
|
||||
text={record.text}
|
||||
entities={record.entities}
|
||||
richText={item.richText}
|
||||
lineHeight={1.3}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -256,12 +256,11 @@ export const ProfileHeader = observer(function ProfileHeader({
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{view.description ? (
|
||||
{view.descriptionRichText ? (
|
||||
<RichText
|
||||
style={[styles.description, pal.text]}
|
||||
numberOfLines={15}
|
||||
text={view.description}
|
||||
entities={view.descriptionEntities}
|
||||
richText={view.descriptionRichText}
|
||||
/>
|
||||
) : undefined}
|
||||
{view.viewer.muted ? (
|
||||
|
||||
@@ -4,27 +4,22 @@ import {TextLink} from '../Link'
|
||||
import {Text} from './Text'
|
||||
import {lh} from '../../../lib/styles'
|
||||
import {toShortUrl} from '../../../../lib/strings'
|
||||
import {
|
||||
RichText as RichTextObj,
|
||||
Entity,
|
||||
} from '../../../../lib/strings/rich-text'
|
||||
import {useTheme, TypographyVariant} from '../../../lib/ThemeContext'
|
||||
import {usePalette} from '../../../lib/hooks/usePalette'
|
||||
|
||||
type TextSlice = {start: number; end: number}
|
||||
type Entity = {
|
||||
index: TextSlice
|
||||
type: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export function RichText({
|
||||
type = 'md',
|
||||
text,
|
||||
entities,
|
||||
richText,
|
||||
lineHeight = 1.2,
|
||||
style,
|
||||
numberOfLines,
|
||||
}: {
|
||||
type?: TypographyVariant
|
||||
text: string
|
||||
entities?: Entity[]
|
||||
richText?: RichTextObj
|
||||
lineHeight?: number
|
||||
style?: StyleProp<TextStyle>
|
||||
numberOfLines?: number
|
||||
@@ -32,6 +27,12 @@ export function RichText({
|
||||
const theme = useTheme()
|
||||
const pal = usePalette('default')
|
||||
const lineHeightStyle = lh(theme, type, lineHeight)
|
||||
|
||||
if (!richText) {
|
||||
return null
|
||||
}
|
||||
|
||||
const {text, entities} = richText
|
||||
if (!entities?.length) {
|
||||
if (/^\p{Extended_Pictographic}+$/u.test(text) && text.length <= 5) {
|
||||
style = {
|
||||
|
||||
@@ -2824,6 +2824,13 @@
|
||||
dependencies:
|
||||
"@types/lodash" "*"
|
||||
|
||||
"@types/lodash.clonedeep@^4.5.7":
|
||||
version "4.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.7.tgz#0e119f582ed6f9e6b373c04a644651763214f197"
|
||||
integrity sha512-ccNqkPptFIXrpVqUECi60/DFxjNKsfoQxSQsgcBJCX/fuX1wgyQieojkcWH/KpE3xzLoWN/2k+ZeGqIN3paSvw==
|
||||
dependencies:
|
||||
"@types/lodash" "*"
|
||||
|
||||
"@types/lodash.isequal@^4.5.6":
|
||||
version "4.5.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/lodash.isequal/-/lodash.isequal-4.5.6.tgz#ff42a1b8e20caa59a97e446a77dc57db923bc02b"
|
||||
@@ -8937,6 +8944,11 @@ lodash.chunk@^4.2.0:
|
||||
resolved "https://registry.yarnpkg.com/lodash.chunk/-/lodash.chunk-4.2.0.tgz#66e5ce1f76ed27b4303d8c6512e8d1216e8106bc"
|
||||
integrity sha512-ZzydJKfUHJwHa+hF5X66zLFCBrWn5GeF28OHEr4WVWtNDXlQ/IjWKPBiikqKo2ne0+v6JgCgJ0GzJp8k8bHC7w==
|
||||
|
||||
lodash.clonedeep@^4.5.0:
|
||||
version "4.5.0"
|
||||
resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef"
|
||||
integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==
|
||||
|
||||
lodash.debounce@^4.0.8:
|
||||
version "4.0.8"
|
||||
resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af"
|
||||
|
||||
Reference in New Issue
Block a user