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:
Aryan Goharzad
2023-02-20 15:09:08 -05:00
committed by GitHub
parent af1396e396
commit 1861f95a1f
15 changed files with 556 additions and 31 deletions
+123
View File
@@ -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)
}
+123
View File
@@ -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')
})
})
+2
View File
@@ -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",
+32
View File
@@ -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
}
+216
View File
@@ -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
}
}
+5 -1
View File
@@ -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)
+7
View File
@@ -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
View File
@@ -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',
+7 -4
View File
@@ -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},
)
}
}
+4 -6
View File
@@ -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}
/>
+2 -3
View File
@@ -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>
+2 -3
View File
@@ -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>
+2 -3
View File
@@ -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 ? (
+12 -11
View File
@@ -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 = {
+12
View File
@@ -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"