diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index c451a455b..b7322abdc 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -44,9 +44,9 @@ import { ToastType } from '../state/ducks/toast'; import { getAvatarColor } from '../types/Colors'; import { getStoryBackground } from '../util/getStoryBackground'; import { getStoryDuration } from '../util/getStoryDuration'; -import { graphemeAwareSlice } from '../util/graphemeAwareSlice'; import type { saveAttachment } from '../util/saveAttachment'; import { isVideoAttachment } from '../types/Attachment'; +import { graphemeAndLinkAwareSlice } from '../util/graphemeAndLinkAwareSlice'; import { useEscapeHandling } from '../hooks/useEscapeHandling'; import { useRetryStorySend } from '../hooks/useRetryStorySend'; import { resolveStorySendStatus } from '../util/resolveStorySendStatus'; @@ -228,7 +228,7 @@ export function StoryViewer({ return; } - return graphemeAwareSlice( + return graphemeAndLinkAwareSlice( attachment.caption, hasExpandedCaption ? CAPTION_MAX_LENGTH : CAPTION_INITIAL_LENGTH, CAPTION_BUFFER diff --git a/ts/components/conversation/MessageBodyReadMore.tsx b/ts/components/conversation/MessageBodyReadMore.tsx index b587e362d..8f2339b6b 100644 --- a/ts/components/conversation/MessageBodyReadMore.tsx +++ b/ts/components/conversation/MessageBodyReadMore.tsx @@ -5,7 +5,7 @@ import React from 'react'; import type { Props as MessageBodyPropsType } from './MessageBody'; import { MessageBody } from './MessageBody'; -import { graphemeAwareSlice } from '../../util/graphemeAwareSlice'; +import { graphemeAndLinkAwareSlice } from '../../util/graphemeAndLinkAwareSlice'; export type Props = Pick< MessageBodyPropsType, @@ -46,7 +46,7 @@ export function MessageBodyReadMore({ }: Props): JSX.Element { const maxLength = displayLimit || INITIAL_LENGTH; - const { hasReadMore, text: slicedText } = graphemeAwareSlice( + const { hasReadMore, text: slicedText } = graphemeAndLinkAwareSlice( text, maxLength, BUFFER diff --git a/ts/test-both/util/graphemeAndLinkAwareSlice.ts b/ts/test-both/util/graphemeAndLinkAwareSlice.ts new file mode 100644 index 000000000..d27832b3b --- /dev/null +++ b/ts/test-both/util/graphemeAndLinkAwareSlice.ts @@ -0,0 +1,70 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { graphemeAndLinkAwareSlice } from '../../util/graphemeAndLinkAwareSlice'; + +describe('graphemeAndLinkAwareSlice', () => { + it('returns entire string when shorter than maximum', () => { + const shortString = 'Hello, Signal!'; + const result = graphemeAndLinkAwareSlice(shortString, 50); + + assert.strictEqual(result.text, shortString); + assert.isFalse(result.hasReadMore); + }); + + it('should return string longer than max but within buffer', () => { + const input = 'Hello, Signal!'; + const result = graphemeAndLinkAwareSlice(input, 5, 10); + + assert.strictEqual(result.text, input); + assert.isFalse(result.hasReadMore); + }); + + it('should include entire url and detect no more to read', () => { + const input = 'Hello, Signal! https://signal.org'; + const result = graphemeAndLinkAwareSlice(input, 16, 0); + + assert.strictEqual(result.text, input); + assert.isFalse(result.hasReadMore); + }); + + it('should include entire url and detect more to read', () => { + const input = 'Hello, Signal! https://signal.org additional text'; + const inputProperlyTruncated = 'Hello, Signal! https://signal.org'; + + const result = graphemeAndLinkAwareSlice(input, 16, 0); + assert.strictEqual(result.text, inputProperlyTruncated); + assert.isTrue(result.hasReadMore); + }); + + it('should truncate normally when url present after truncation', () => { + const input = 'Hello, Signal! https://signal.org additional text'; + const inputProperlyTruncated = 'Hello, Signal!'; + + const result = graphemeAndLinkAwareSlice(input, 14, 0); + assert.strictEqual(result.text, inputProperlyTruncated); + assert.isTrue(result.hasReadMore); + }); + + it('truncates after url when url present before and at truncation point', () => { + const input = + 'Hello, Signal! https://signal.org additional text https://example.com/example more text'; + const inputProperlyTruncated = + 'Hello, Signal! https://signal.org additional text https://example.com/example'; + + const result = graphemeAndLinkAwareSlice(input, 55, 0); + assert.strictEqual(result.text, inputProperlyTruncated); + assert.isTrue(result.hasReadMore); + }); + + it('truncates after url when url present at and after truncation point', () => { + const input = + 'Hello, Signal! https://signal.org additional text https://example.com/example more text'; + const inputProperlyTruncated = 'Hello, Signal! https://signal.org'; + + const result = graphemeAndLinkAwareSlice(input, 26, 0); + assert.strictEqual(result.text, inputProperlyTruncated); + assert.isTrue(result.hasReadMore); + }); +}); diff --git a/ts/util/graphemeAndLinkAwareSlice.ts b/ts/util/graphemeAndLinkAwareSlice.ts new file mode 100644 index 000000000..f5bd881d2 --- /dev/null +++ b/ts/util/graphemeAndLinkAwareSlice.ts @@ -0,0 +1,68 @@ +// Copyright 2021-2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import LinkifyIt from 'linkify-it'; + +export function graphemeAndLinkAwareSlice( + str: string, + length: number, + buffer = 100 +): { + hasReadMore: boolean; + text: string; +} { + if (str.length <= length + buffer) { + return { text: str, hasReadMore: false }; + } + + let text: string | undefined; + + for (const { index } of new Intl.Segmenter().segment(str)) { + if (!text && index >= length) { + text = str.slice(0, index); + } + + if (text && index > length) { + text = expandToIncludeEntireLink(str, text); + + return { + text, + hasReadMore: text.length < str.length, + }; + } + } + + return { + text: str, + hasReadMore: false, + }; +} + +const expandToIncludeEntireLink = ( + original: string, + truncated: string +): string => { + const linksInText = new LinkifyIt().match(original); + + if (!linksInText) { + return truncated; + } + + const invalidTruncationRanges: Array = linksInText.map( + ({ index: startIndex, lastIndex }) => ({ startIndex, lastIndex }) + ); + + const truncatedLink: Array = invalidTruncationRanges.filter( + ({ startIndex, lastIndex }) => + startIndex < truncated.length && lastIndex > truncated.length + ); + + if (truncatedLink.length === 0) return truncated; + + return original.slice(0, truncatedLink[0].lastIndex); +}; + +type LinkRange = { + startIndex: number; + lastIndex: number; +}; diff --git a/ts/util/graphemeAwareSlice.ts b/ts/util/graphemeAwareSlice.ts deleted file mode 100644 index 04d5ae0b3..000000000 --- a/ts/util/graphemeAwareSlice.ts +++ /dev/null @@ -1,34 +0,0 @@ -// Copyright 2021-2022 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only - -export function graphemeAwareSlice( - str: string, - length: number, - buffer = 100 -): { - hasReadMore: boolean; - text: string; -} { - if (str.length <= length + buffer) { - return { text: str, hasReadMore: false }; - } - - let text: string | undefined; - - for (const { index } of new Intl.Segmenter().segment(str)) { - if (!text && index >= length) { - text = str.slice(0, index); - } - if (text && index > length) { - return { - text, - hasReadMore: true, - }; - } - } - - return { - text: str, - hasReadMore: false, - }; -}