diff --git a/ts/components/ForwardMessagesModal.tsx b/ts/components/ForwardMessagesModal.tsx index 7ea463566fb7..9ecef2f4506a 100644 --- a/ts/components/ForwardMessagesModal.tsx +++ b/ts/components/ForwardMessagesModal.tsx @@ -32,7 +32,7 @@ import { LinkPreviewSourceType } from '../types/LinkPreview'; import { ToastType } from '../types/Toast'; import type { ShowToastAction } from '../state/ducks/toast'; import type { HydratedBodyRangesType } from '../types/BodyRange'; -import { BodyRange } from '../types/BodyRange'; +import { applyRangesToText } from '../types/BodyRange'; import { UserText } from './UserText'; import { Modal } from './Modal'; import { SizeObserver } from '../hooks/useSizeObserver'; @@ -135,11 +135,25 @@ export function ForwardMessagesModal({ } else { doForwardMessages( conversationIds, - drafts.map(draft => ({ - ...draft, + drafts.map(draft => { // We don't keep @mention bodyRanges in multi-forward scenarios - bodyRanges: draft.bodyRanges?.filter(BodyRange.isFormatting), - })) + const result = applyRangesToText( + { + body: draft.messageBody ?? '', + bodyRanges: draft.bodyRanges ?? [], + }, + { + replaceMentions: true, + replaceSpoilers: false, + } + ); + + return { + ...draft, + messageBody: result.body, + bodyRanges: result.bodyRanges, + }; + }) ); } }, [ diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index c79804cb2c2c..ec7dfa3fc71a 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -51,11 +51,8 @@ import type { import type { EmbeddedContactType } from '../../types/EmbeddedContact'; import { embeddedContactSelector } from '../../types/EmbeddedContact'; -import type { - HydratedBodyRangeMention, - HydratedBodyRangesType, -} from '../../types/BodyRange'; -import { BodyRange, hydrateRanges } from '../../types/BodyRange'; +import type { HydratedBodyRangesType } from '../../types/BodyRange'; +import { hydrateRanges } from '../../types/BodyRange'; import type { AssertProps } from '../../types/Util'; import type { LinkPreviewType } from '../../types/message/LinkPreviews'; import { getMentionsRegex } from '../../types/Message'; @@ -336,29 +333,6 @@ export const processBodyRanges = ( ); }; -export const extractHydratedMentions = ( - { bodyRanges }: Pick, - options: { conversationSelector: GetConversationByIdType } -): ReadonlyArray | undefined => { - if (!bodyRanges) { - return undefined; - } - - return bodyRanges - .filter(BodyRange.isMention) - .map(range => { - const { conversationSelector } = options; - const conversation = conversationSelector(range.mentionAci); - - return { - ...range, - conversationID: conversation.id, - replacementText: conversation.title, - }; - }) - .sort((a, b) => b.start - a.start); -}; - const getAuthorForMessage = ( message: MessageWithUIFieldsType, options: GetContactOptions diff --git a/ts/test-both/types/BodyRange_test.ts b/ts/test-both/types/BodyRange_test.ts index 50b88bba505e..ba88f0514e8e 100644 --- a/ts/test-both/types/BodyRange_test.ts +++ b/ts/test-both/types/BodyRange_test.ts @@ -2,11 +2,15 @@ // SPDX-License-Identifier: AGPL-3.0-only import { assert } from 'chai'; -import type { RangeNode } from '../../types/BodyRange'; +import type { + HydratedBodyRangeMention, + RangeNode, +} from '../../types/BodyRange'; import { BodyRange, DisplayStyle, - applyRangesForText, + applyRangeToText, + applyRangesToText, collapseRangeTree, insertRange, processBodyRangesForSearchResult, @@ -15,8 +19,6 @@ import { generateAci } from '../../types/ServiceId'; const SERVICE_ID_1 = generateAci(); const SERVICE_ID_2 = generateAci(); -const SERVICE_ID_3 = generateAci(); -const SERVICE_ID_4 = generateAci(); const mentionInfo = { mentionAci: SERVICE_ID_1, @@ -941,123 +943,513 @@ describe('BodyRanges', () => { }); }); - describe('applyRangesForText', () => { - it('handles mentions, replaces in reverse order', () => { - const mentions = [ - { - start: 0, - length: 1, - mentionAci: SERVICE_ID_3, - replacementText: 'jerry', - conversationID: 'x', - }, - { - start: 7, - length: 1, - mentionAci: SERVICE_ID_4, - replacementText: 'fred', - conversationID: 'x', - }, - ]; - const text = "\uFFFC says \uFFFC, I'm here"; - assert.strictEqual( - applyRangesForText({ text, mentions, spoilers: [] }), - "@jerry says @fred, I'm here" - ); + describe('applying ranges', () => { + function mention(start: number, title: string): HydratedBodyRangeMention { + return { + start, + length: 1, + mentionAci: generateAci(), + replacementText: title, + conversationID: '', + }; + } + + function style( + start: number, + length: number, + styleValue: BodyRange.Style + ): BodyRange { + return { + start, + length, + style: styleValue, + }; + } + + describe('applyRangesToText', () => { + it('handles mentions', () => { + const replacement = mention(3, 'jamie'); + const body = '012\uFFFC456'; + const result = applyRangeToText({ body, bodyRanges: [] }, replacement); + assert.deepEqual(result, { + body: '012@jamie456', + bodyRanges: [], + }); + }); + + it('handles spoilers', () => { + const replacement = style(3, 4, BodyRange.Style.SPOILER); + const body = '012|45|789'; + const result = applyRangeToText({ body, bodyRanges: [] }, replacement); + assert.deepEqual(result, { + body: '012■■■■789', + bodyRanges: [], + }); + }); + + describe('updating ranges', () => { + describe('replacement same length', () => { + function check( + input: { start: number; length: number }, + expected: { start: number; length: number } | null + ) { + const replacement = style(3, 4, BodyRange.Style.SPOILER); + const body = 'abc|ef|hij'; + const bodyRanges = [ + style(input.start, input.length, BodyRange.Style.BOLD), + ]; + const result = applyRangeToText({ body, bodyRanges }, replacement); + assert.deepEqual(result, { + body: 'abc■■■■hij', + bodyRanges: + expected == null + ? [] + : [ + style( + expected.start, + expected.length, + BodyRange.Style.BOLD + ), + ], + }); + } + + // start before + it('start before, end before', () => { + // abc|ef|hij -> abc■■■■hij + // ^^ -> ^^ + // 0123456789 -> 0123456789 + check({ start: 0, length: 2 }, { start: 0, length: 2 }); + }); + it('start before, end at start', () => { + // abc|ef|hij -> abc■■■■hij + // ^^^ -> ^^^ + // 0123456789 -> 0123456789 + check({ start: 0, length: 3 }, { start: 0, length: 3 }); + }); + it('start before, end in middle', () => { + // abc|ef|hij -> abc■■■■hij + // ^^^^^ -> ^^^^^^^ + // 0123456789 -> 0123456789 + check({ start: 0, length: 5 }, { start: 0, length: 7 }); + }); + it('start before, end at end', () => { + // abc|ef|hij -> abc■■■■hij + // ^^^^^^^ -> ^^^^^^^ + // 0123456789 -> 0123456789 + check({ start: 0, length: 7 }, { start: 0, length: 7 }); + }); + it('start before, end after', () => { + // abc|ef|hij -> abc■■■■hij + // ^^^^^^^^^^ -> ^^^^^^^^^^ + // 0123456789 -> 0123456789 + check({ start: 0, length: 10 }, { start: 0, length: 10 }); + }); + + // start at start + it('start at start, end at start', () => { + // abc|ef|hij -> abc■■■■hij + // \ -> null + // 0123456789 -> 0123456789 + check({ start: 3, length: 0 }, null); + }); + it('start at start, end in middle', () => { + // abc|ef|hij -> abc■■■■hij + // ^^ -> null + // 0123456789 -> 0123456789 + check({ start: 3, length: 2 }, null); + }); + it('start at start, end at end', () => { + // abc|ef|hij -> abc■■■■hij + // ^^^^ -> ^^^^ + // 0123456789 -> 0123456789 + check({ start: 3, length: 4 }, { start: 3, length: 4 }); + }); + it('start at start, end after', () => { + // abc|ef|hij -> abc■■■■hij + // ^^^^^^ -> ^^^^^^ + // 0123456789 -> 0123456789 + check({ start: 3, length: 6 }, { start: 3, length: 6 }); + }); + + // start in middle + it('start in middle, end in middle', () => { + // abc|ef|hij -> abc■■■■hij + // ^^ -> null + // 0123456789 -> 0123456789 + check({ start: 4, length: 2 }, null); + }); + it('start in middle, end at end', () => { + // abc|ef|hij -> abc■■■■hij + // ^^^ -> null + // 0123456789 -> 0123456789 + check({ start: 4, length: 3 }, null); + }); + it('start in middle, end after', () => { + // abc|ef|hij -> abc■■■■hij + // ^^^^^ -> ^^^^^^ + // 0123456789 -> 0123456789 + check({ start: 4, length: 5 }, { start: 3, length: 6 }); + }); + + // start at end + it('start at end, end at end', () => { + // abc|ef|hij -> abc■■■■hij + // \ -> null + // 0123456789 -> 0123456789 + check({ start: 7, length: 0 }, null); + }); + it('start at end, end after', () => { + // abc|ef|hij -> abc■■■■hij + // ^^ -> ^^ + // 0123456789 -> 0123456789 + check({ start: 7, length: 2 }, { start: 7, length: 2 }); + }); + + // start after + it('start after, end after', () => { + // abc|ef|hij -> abc■■■■hij + // ^^ -> ^^ + // 0123456789 -> 0123456789 + check({ start: 8, length: 2 }, { start: 8, length: 2 }); + }); + }); + + describe('replacement shortens', () => { + function check( + input: { start: number; length: number }, + expected: { start: number; length: number } | null + ) { + const replacement = style(3, 5, BodyRange.Style.SPOILER); + const body = 'abc|efg|ijk'; + const bodyRanges = [ + style(input.start, input.length, BodyRange.Style.BOLD), + ]; + const result = applyRangeToText({ body, bodyRanges }, replacement); + assert.deepEqual(result, { + body: 'abc■■■■ijk', + bodyRanges: + expected == null + ? [] + : [ + style( + expected.start, + expected.length, + BodyRange.Style.BOLD + ), + ], + }); + } + + // start before + it('start before, end before', () => { + // abc|efg|ijk -> abc■■■■ijk + // ^^ -> ^^ + // 01234567890 -> 0123456789 + check({ start: 0, length: 2 }, { start: 0, length: 2 }); + }); + it('start before, end at start', () => { + // abc|efg|ijk -> abc■■■■ijk + // ^^^ -> ^^^ + // 01234567890 -> 0123456789 + check({ start: 0, length: 3 }, { start: 0, length: 3 }); + }); + it('start before, end in middle', () => { + // abc|efg|ijk -> abc■■■■ijk + // ^^^^^ -> ^^^^^^^ + // 01234567890 -> 0123456789 + check({ start: 0, length: 5 }, { start: 0, length: 7 }); + }); + it('start before, end at end', () => { + // abc|efg|ijk -> abc■■■■ijk + // ^^^^^^^^ -> ^^^^^^^ + // 01234567890 -> 0123456789 + check({ start: 0, length: 8 }, { start: 0, length: 7 }); + }); + it('start before, end after', () => { + // abc|efg|ijk -> abc■■■■ijk + // ^^^^^^^^^^^ -> ^^^^^^^^^^ + // 01234567890 -> 0123456789 + check({ start: 0, length: 11 }, { start: 0, length: 10 }); + }); + + // start at start + it('start at start, end at start', () => { + // abc|efg|ijk -> abc■■■■ijk + // \ -> null + // 01234567890 -> 0123456789 + check({ start: 3, length: 0 }, null); + }); + it('start at start, end in middle', () => { + // abc|efg|ijk -> abc■■■■ijk + // ^^ -> null + // 01234567890 -> 0123456789 + check({ start: 3, length: 2 }, null); + }); + it('start at start, end at end', () => { + // abc|efg|ijk -> abc■■■■ijk + // ^^^^^ -> ^^^^ + // 01234567890 -> 0123456789 + check({ start: 3, length: 5 }, { start: 3, length: 4 }); + }); + it('start at start, end after', () => { + // abc|efg|ijk -> abc■■■■ijk + // ^^^^^^ -> ^^^^^ + // 01234567890 -> 0123456789 + check({ start: 3, length: 6 }, { start: 3, length: 5 }); + }); + + // start in middle + it('start in middle, end in middle', () => { + // abc|efg|ijk -> abc■■■■ijk + // ^^ -> null + // 01234567890 -> 0123456789 + check({ start: 4, length: 2 }, null); + }); + it('start in middle, end at end', () => { + // abc|efg|ijk -> abc■■■■ijk + // ^^^ -> null + // 01234567890 -> 0123456789 + check({ start: 4, length: 3 }, null); + }); + it('start in middle, end after', () => { + // abc|efg|ijk -> abc■■■■ijk + // ^^^^^^ -> ^^^^^^ + // 01234567890 -> 0123456789 + check({ start: 4, length: 6 }, { start: 3, length: 6 }); + }); + + // start at end + it('start at end, end at end', () => { + // abc|efg|ijk -> abc■■■■ijk + // \ -> null + // 01234567890 -> 0123456789 + check({ start: 7, length: 0 }, null); + }); + it('start at end, end after', () => { + // abc|efg|ijk -> abc■■■■ijk + // ^^ -> ^^ + // 01234567890 -> 0123456789 + check({ start: 8, length: 2 }, { start: 7, length: 2 }); + }); + + // start after + it('start after, end after', () => { + // abc|efg|ijk -> abc■■■■ijk + // ^^ -> ^^ + // 01234567890 -> 0123456789 + check({ start: 8, length: 2 }, { start: 7, length: 2 }); + }); + }); + + describe('replacement lengthens', () => { + function check( + input: { start: number; length: number }, + expected: { start: number; length: number } | null + ) { + const replacement = style(3, 3, BodyRange.Style.SPOILER); + const body = 'abc|e|ghi'; + const bodyRanges = [ + style(input.start, input.length, BodyRange.Style.BOLD), + ]; + const result = applyRangeToText({ body, bodyRanges }, replacement); + assert.deepEqual(result, { + body: 'abc■■■■ghi', + bodyRanges: + expected == null + ? [] + : [ + style( + expected.start, + expected.length, + BodyRange.Style.BOLD + ), + ], + }); + } + + // start before + it('start before, end before', () => { + // abc|e|ghi -> abc■■■■ghi + // ^^ -> ^^ + // 012345678 -> 0123456789 + check({ start: 0, length: 2 }, { start: 0, length: 2 }); + }); + it('start before, end at start', () => { + // abc|e|ghi -> abc■■■■ghi + // ^^^ -> ^^^ + // 012345678 -> 0123456789 + check({ start: 0, length: 3 }, { start: 0, length: 3 }); + }); + it('start before, end in middle', () => { + // abc|e|ghi -> abc■■■■ghi + // ^^^^^ -> ^^^^^^^ + // 012345678 -> 0123456789 + check({ start: 0, length: 5 }, { start: 0, length: 7 }); + }); + it('start before, end at end', () => { + // abc|e|ghi -> abc■■■■ghi + // ^^^^^^ -> ^^^^^^^ + // 012345678 -> 0123456789 + check({ start: 0, length: 6 }, { start: 0, length: 7 }); + }); + it('start before, end after', () => { + // abc|e|ghi -> abc■■■■ghi + // ^^^^^^^^^ -> ^^^^^^^^^^ + // 012345678 -> 0123456789 + check({ start: 0, length: 9 }, { start: 0, length: 10 }); + }); + + // start at start + it('start at start, end at start', () => { + // abc|e|ghi -> abc■■■■ghi + // \ -> null + // 012345678 -> 0123456789 + check({ start: 3, length: 0 }, null); + }); + it('start at start, end in middle', () => { + // abc|e|ghi -> abc■■■■ghi + // ^^ -> null + // 012345678 -> 0123456789 + check({ start: 3, length: 2 }, null); + }); + it('start at start, end at end', () => { + // abc|e|ghi -> abc■■■■ghi + // ^^^ -> ^^^^ + // 012345678 -> 0123456789 + check({ start: 3, length: 3 }, { start: 3, length: 4 }); + }); + it('start at start, end after', () => { + // abc|e|ghi -> abc■■■■ghi + // ^^^^^^ -> ^^^^^^^ + // 012345678 -> 0123456789 + check({ start: 3, length: 6 }, { start: 3, length: 7 }); + }); + + // start in middle + it('start in middle, end in middle', () => { + // abc|e|ghi -> abc■■■■ghi + // ^ -> null + // 012345678 -> 0123456789 + check({ start: 4, length: 1 }, null); + }); + it('start in middle, end at end', () => { + // abc|e|ghi -> abc■■■■ghi + // ^^ -> null + // 012345678 -> 0123456789 + check({ start: 4, length: 2 }, null); + }); + it('start in middle, end after', () => { + // abc|e|ghi -> abc■■■■ghi + // ^^^^^ -> ^^^^^^^ + // 012345678 -> 0123456789 + check({ start: 4, length: 5 }, { start: 3, length: 7 }); + }); + + // start at end + it('start at end, end at end', () => { + // abc|e|ghi -> abc■■■■ghi + // \ -> null + // 012345678 -> 0123456789 + check({ start: 6, length: 0 }, null); + }); + it('start at end, end after', () => { + // abc|e|ghi -> abc■■■■ghi + // ^^ -> ^^ + // 012345678 -> 0123456789 + check({ start: 6, length: 2 }, { start: 7, length: 2 }); + }); + + // start after + it('start after, end after', () => { + // abc|e|ghi -> abc■■■■ghi + // ^^ -> ^^ + // 012345678 -> 0123456789 + check({ start: 7, length: 2 }, { start: 8, length: 2 }); + }); + }); + }); }); - it('handles spoilers, replaces in reverse order', () => { - const spoilers = [ - { - start: 18, - length: 16, - style: BodyRange.Style.SPOILER, - }, - { - start: 46, - length: 17, - style: BodyRange.Style.SPOILER, - }, - ]; - const text = - "It's so cool when the balrog fight happens in Lord of the Rings!"; - assert.strictEqual( - applyRangesForText({ text, mentions: [], spoilers }), - "It's so cool when ■■■■ happens in ■■■■!" - ); - }); + describe('applyRangesToText', () => { + it('handles mentions, replaces in reverse order', () => { + const body = "\uFFFC says \uFFFC, I'm here"; + const bodyRanges = [mention(0, 'jerry'), mention(7, 'fred')]; + assert.deepStrictEqual( + applyRangesToText( + { body, bodyRanges }, + { + replaceMentions: true, + replaceSpoilers: true, + } + ), + { + body: "@jerry says @fred, I'm here", + bodyRanges: [], + } + ); + }); - it('handles mentions that are removed by spoilers', () => { - const mentions = [ - { - start: 49, - length: 1, - mentionAci: SERVICE_ID_4, - replacementText: 'alice', - conversationID: 'x', - }, - { - start: 55, - length: 1, - mentionAci: SERVICE_ID_4, - replacementText: 'bob', - conversationID: 'x', - }, - ]; - const spoilers = [ - { - start: 49, - length: 7, - style: BodyRange.Style.SPOILER, - }, - ]; + it('handles spoilers, replaces in reverse order', () => { + const body = + "It's so cool when the balrog fight happens in Lord of the Rings!"; + const bodyRanges = [ + style(18, 16, BodyRange.Style.SPOILER), + style(46, 17, BodyRange.Style.SPOILER), + ]; + assert.deepStrictEqual( + applyRangesToText( + { body, bodyRanges }, + { replaceMentions: true, replaceSpoilers: true } + ), + { body: "It's so cool when ■■■■ happens in ■■■■!", bodyRanges: [] } + ); + }); - const text = - "The recipients of today's appreciation award are \uFFFC and \uFFFC!"; - assert.strictEqual( - applyRangesForText({ text, mentions, spoilers }), - "The recipients of today's appreciation award are ■■■■!" - ); - }); + it('handles mentions that are removed by spoilers', () => { + const body = + "The recipients of today's appreciation award are \uFFFC and \uFFFC!"; + const bodyRanges = [ + mention(49, 'alice'), + mention(55, 'bob'), + style(49, 7, BodyRange.Style.SPOILER), + ]; - it('handles mentions that need to be moved because of spoilers', () => { - const mentions = [ - { - start: 0, - length: 1, - mentionAci: SERVICE_ID_4, - replacementText: 'eve', - conversationID: 'x', - }, - { - start: 52, - length: 1, - mentionAci: SERVICE_ID_4, - replacementText: 'alice', - conversationID: 'x', - }, - { - start: 58, - length: 1, - mentionAci: SERVICE_ID_4, - replacementText: 'bob', - conversationID: 'x', - }, - ]; - const spoilers = [ - { - start: 21, - length: 26, - style: BodyRange.Style.SPOILER, - }, - ]; + assert.deepStrictEqual( + applyRangesToText( + { body, bodyRanges }, + { replaceMentions: true, replaceSpoilers: true } + ), + { + body: "The recipients of today's appreciation award are ■■■■!", + bodyRanges: [], + } + ); + }); - const text = - "\uFFFC: The recipients of today's appreciation award are \uFFFC and \uFFFC!"; - assert.strictEqual( - applyRangesForText({ text, mentions, spoilers }), - '@eve: The recipients of ■■■■ are @alice and @bob!' - ); + it('handles applying mentions but not spoilers', () => { + const body = 'before \uFFFC after'; + const bodyRanges = [ + mention(7, 'jamie'), + style(0, 8, BodyRange.Style.BOLD), + style(7, 1, BodyRange.Style.SPOILER), + style(7, 6, BodyRange.Style.ITALIC), + ]; + assert.deepStrictEqual( + applyRangesToText( + { body, bodyRanges }, + { replaceMentions: true, replaceSpoilers: false } + ), + { + body: 'before @jamie after', + bodyRanges: [ + style(0, 13, BodyRange.Style.BOLD), + style(7, 6, BodyRange.Style.SPOILER), + style(7, 11, BodyRange.Style.ITALIC), + ], + } + ); + }); }); }); }); diff --git a/ts/types/BodyRange.ts b/ts/types/BodyRange.ts index bdd98e7f6648..b890ad61b686 100644 --- a/ts/types/BodyRange.ts +++ b/ts/types/BodyRange.ts @@ -3,7 +3,7 @@ /* eslint-disable @typescript-eslint/no-namespace */ -import { escapeRegExp, isNumber, omit } from 'lodash'; +import { escapeRegExp, isNumber, omit, partition } from 'lodash'; import { SignalService as Proto } from '../protobuf'; import * as log from '../logging/log'; @@ -643,55 +643,208 @@ export function processBodyRangesForSearchResult({ export const SPOILER_REPLACEMENT = '■■■■'; -export function applyRangesForText({ - text, - mentions, - spoilers, -}: { - text: string | undefined; - mentions: ReadonlyArray; - spoilers: ReadonlyArray>; -}): string | undefined { - if (!text) { - return text; +/** + * Replace text in a string at a given range, returning the new string. The + * replacement can be a different length than the text it's replacing. + * @example + * ```ts + * replaceText('hello world!!!', 'jamie', 6, 11) === 'hello jamie!!!' + * ``` + */ +function replaceText( + input: string, + insert: string, + start: number, + end: number +): string { + return input.slice(0, start) + insert + input.slice(end); +} + +export type BodyWithBodyRanges = { + body: string; + bodyRanges: HydratedBodyRangesType; +}; + +type Span = { + start: number; + end: number; +}; + +function snapSpanToEdgesOfReplacement( + span: Span, + replacement: Span +): Span | null { + // If the span is empty, we can just remove it + if (span.start >= span.end) { + return null; } - let updatedText = text; - let sortableMentions: Array = mentions.slice(); + // If the span is inside the replacement (not exactly the same), we remove it + if ( + (span.start > replacement.start && span.end <= replacement.end) || + (span.start >= replacement.start && span.end < replacement.end) + ) { + return null; + } - const sortableSpoilers: Array> = - spoilers.slice(); - updatedText = sortableSpoilers - .sort((a, b) => b.start - a.start) - .reduce((acc, { start, length }) => { - const left = acc.slice(0, start); - const end = start + length; - const right = acc.slice(end); + let start: number; + if (span.start < replacement.start) { + start = span.start; + } else if (span.start === replacement.start) { + start = replacement.start; + } else if (span.start < replacement.end) { + start = replacement.start; // snap to the start of the replacement + } else if (span.start === replacement.end) { + start = replacement.end; // snap to the end of the replacement + } else { + start = span.start; + } - // Note: this is a simplified filter because mentions always have length=1 - sortableMentions = sortableMentions - .filter(mention => { - return mention.start < start || mention.start >= end; - }) - .map(mention => { - if (mention.start >= end) { - return { - ...mention, - start: mention.start - (length - SPOILER_REPLACEMENT.length), - }; - } + let end: number; + if (span.end < replacement.start) { + end = span.end; + } else if (span.end === replacement.start) { + end = replacement.start; + } else if (span.end < replacement.end) { + end = replacement.end; // snap to the start of the replacement + } else if (span.end === replacement.end) { + end = replacement.end; // snap to the end of the replacement + } else { + end = span.end; + } - return mention; - }); + // If this made the span empty, we can remove it + if (start === end) { + return null; + } - return `${left}${SPOILER_REPLACEMENT}${right}`; - }, updatedText); - - return sortableMentions - .sort((a, b) => b.start - a.start) - .reduce((acc, { start, length, replacementText }) => { - const left = acc.slice(0, start); - const right = acc.slice(start + length); - return `${left}@${replacementText}${right}`; - }, updatedText); + return { start, end }; +} + +function toSpan(range: HydratedBodyRangeType) { + return { start: range.start, end: range.start + range.length }; +} + +/** + * Apply a single replacement range to a string, returning the new string and + * updated ranges. This only works for mentions and spoilers. The other ranges + * are updated to stay outside of the replaced text, or removed if are only + * inside the replaced text. + */ +export function applyRangeToText( + input: BodyWithBodyRanges, + // mention or spoiler + replacement: HydratedBodyRangeType +): BodyWithBodyRanges { + let insert: string; + + if (BodyRange.isMention(replacement)) { + insert = `@${replacement.replacementText}`; + } else if ( + BodyRange.isFormatting(replacement) && + replacement.style === BodyRange.Style.SPOILER + ) { + insert = SPOILER_REPLACEMENT; + } else { + throw new Error('Invalid range'); + } + + const updatedBody = replaceText( + input.body, + insert, + replacement.start, + replacement.start + replacement.length + ); + + const updatedRanges = input.bodyRanges + .map((otherRange): HydratedBodyRangeType | null => { + // It is easier to work with a `start-end` here because we can easily + // adjust it at the end based on the diff of the inserted text + const otherRangeSpan = toSpan(otherRange); + const replacementSpan = toSpan(replacement); + + const result = snapSpanToEdgesOfReplacement( + otherRangeSpan, + replacementSpan + ); + if (result == null) { + return null; + } + + let { start, end } = result; + + // The difference between the length of the range we're inserting and the + // length of the inserted text + // - "\uFFFC".length == 1 -> "@jamie".length == 6, so diff == 5 + // - "spoiler".length == 7 -> "■■■■".length == 4, so diff == -3 + const insertionDiff = insert.length - replacement.length; + // We only need to adjust positions at or after the end of the replacement + if (start >= replacementSpan.end) { + start += insertionDiff; + } + if (end >= replacementSpan.end) { + end += insertionDiff; + } + + return { ...otherRange, start, length: end - start }; + }) + .filter((r): r is HydratedBodyRangeType => { + return r != null; + }); + + return { body: updatedBody, bodyRanges: updatedRanges }; +} + +function _applyRangeOfType( + input: BodyWithBodyRanges, + condition: (bodyRange: HydratedBodyRangeType) => boolean +) { + const [matchedRanges, otherRanges] = partition(input.bodyRanges, condition); + return matchedRanges + .sort((a, b) => { + return b.start - a.start; + }) + .reduce( + (prev, matchedRange) => { + return applyRangeToText(prev, matchedRange); + }, + { body: input.body, bodyRanges: otherRanges } + ); +} + +/** + * Apply some body ranges to body, returning the new string and updated ranges. + * This only works for mentions and spoilers. The other ranges are updated to + * stay outside of the replaced text, or removed if are only inside the + * replaced text. + * + * You can optionally enable/disable replacing mentions and spoilers. + */ +export function applyRangesToText( + input: BodyWithBodyRanges, + options: { + replaceMentions: boolean; // "@jamie" + replaceSpoilers: boolean; // "■■■■" + } +): BodyWithBodyRanges { + let state = input; + + // Short-circuit if there are no ranges + if (state.bodyRanges.length === 0) { + return state; + } + + if (options.replaceSpoilers) { + state = _applyRangeOfType(state, bodyRange => { + return BodyRange.isFormatting(bodyRange) && bodyRange.style === SPOILER; + }); + } + + if (options.replaceMentions) { + state = _applyRangeOfType(state, bodyRange => { + return BodyRange.isMention(bodyRange); + }); + } + + return state; } diff --git a/ts/util/getNotificationTextForMessage.ts b/ts/util/getNotificationTextForMessage.ts index a48fcf66b3f8..da161bf2bc57 100644 --- a/ts/util/getNotificationTextForMessage.ts +++ b/ts/util/getNotificationTextForMessage.ts @@ -2,8 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { MessageAttributesType } from '../model-types.d'; -import { BodyRange, applyRangesForText } from '../types/BodyRange'; -import { extractHydratedMentions } from '../state/selectors/message'; +import { applyRangesToText, hydrateRanges } from '../types/BodyRange'; import { findAndFormatContact } from './findAndFormatContact'; import { getNotificationDataForMessage } from './getNotificationDataForMessage'; import { isConversationAccepted } from './isConversationAccepted'; @@ -12,7 +11,7 @@ import { strictAssert } from './assert'; export function getNotificationTextForMessage( attributes: MessageAttributesType ): string { - const { text, emoji } = getNotificationDataForMessage(attributes); + const { text, emoji, bodyRanges } = getNotificationDataForMessage(attributes); const conversation = window.ConversationController.get( attributes.conversationId @@ -64,25 +63,23 @@ export function getNotificationTextForMessage( return window.i18n('icu:Quote__story-reaction--single'); } - const mentions = - extractHydratedMentions(attributes, { - conversationSelector: findAndFormatContact, - }) || []; - const spoilers = (attributes.bodyRanges || []).filter( - range => - BodyRange.isFormatting(range) && range.style === BodyRange.Style.SPOILER - ) as Array>; - const modifiedText = applyRangesForText({ text, mentions, spoilers }); + const result = applyRangesToText( + { + body: text, + bodyRanges: hydrateRanges(bodyRanges, findAndFormatContact) ?? [], + }, + { replaceMentions: true, replaceSpoilers: true } + ); // Linux emoji support is mixed, so we disable it. (Note that this doesn't touch // the `text`, which can contain emoji.) const shouldIncludeEmoji = Boolean(emoji) && !window.Signal.OS.isLinux(); if (shouldIncludeEmoji) { return window.i18n('icu:message--getNotificationText--text-with-emoji', { - text: modifiedText, + text: result.body, emoji, }); } - return modifiedText || ''; + return result.body ?? ''; }