Apply mention ranges to body when multi-forwarding

This commit is contained in:
Jamie Kyle 2023-10-30 11:39:14 -07:00 committed by GitHub
parent 063a1d9df3
commit 6bd802a03d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 738 additions and 208 deletions

View file

@ -32,7 +32,7 @@ import { LinkPreviewSourceType } from '../types/LinkPreview';
import { ToastType } from '../types/Toast'; import { ToastType } from '../types/Toast';
import type { ShowToastAction } from '../state/ducks/toast'; import type { ShowToastAction } from '../state/ducks/toast';
import type { HydratedBodyRangesType } from '../types/BodyRange'; import type { HydratedBodyRangesType } from '../types/BodyRange';
import { BodyRange } from '../types/BodyRange'; import { applyRangesToText } from '../types/BodyRange';
import { UserText } from './UserText'; import { UserText } from './UserText';
import { Modal } from './Modal'; import { Modal } from './Modal';
import { SizeObserver } from '../hooks/useSizeObserver'; import { SizeObserver } from '../hooks/useSizeObserver';
@ -135,11 +135,25 @@ export function ForwardMessagesModal({
} else { } else {
doForwardMessages( doForwardMessages(
conversationIds, conversationIds,
drafts.map(draft => ({ drafts.map(draft => {
...draft,
// We don't keep @mention bodyRanges in multi-forward scenarios // 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,
};
})
); );
} }
}, [ }, [

View file

@ -51,11 +51,8 @@ import type {
import type { EmbeddedContactType } from '../../types/EmbeddedContact'; import type { EmbeddedContactType } from '../../types/EmbeddedContact';
import { embeddedContactSelector } from '../../types/EmbeddedContact'; import { embeddedContactSelector } from '../../types/EmbeddedContact';
import type { import type { HydratedBodyRangesType } from '../../types/BodyRange';
HydratedBodyRangeMention, import { hydrateRanges } from '../../types/BodyRange';
HydratedBodyRangesType,
} from '../../types/BodyRange';
import { BodyRange, hydrateRanges } from '../../types/BodyRange';
import type { AssertProps } from '../../types/Util'; import type { AssertProps } from '../../types/Util';
import type { LinkPreviewType } from '../../types/message/LinkPreviews'; import type { LinkPreviewType } from '../../types/message/LinkPreviews';
import { getMentionsRegex } from '../../types/Message'; import { getMentionsRegex } from '../../types/Message';
@ -336,29 +333,6 @@ export const processBodyRanges = (
); );
}; };
export const extractHydratedMentions = (
{ bodyRanges }: Pick<MessageWithUIFieldsType, 'bodyRanges'>,
options: { conversationSelector: GetConversationByIdType }
): ReadonlyArray<HydratedBodyRangeMention> | 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 = ( const getAuthorForMessage = (
message: MessageWithUIFieldsType, message: MessageWithUIFieldsType,
options: GetContactOptions options: GetContactOptions

View file

@ -2,11 +2,15 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai'; import { assert } from 'chai';
import type { RangeNode } from '../../types/BodyRange'; import type {
HydratedBodyRangeMention,
RangeNode,
} from '../../types/BodyRange';
import { import {
BodyRange, BodyRange,
DisplayStyle, DisplayStyle,
applyRangesForText, applyRangeToText,
applyRangesToText,
collapseRangeTree, collapseRangeTree,
insertRange, insertRange,
processBodyRangesForSearchResult, processBodyRangesForSearchResult,
@ -15,8 +19,6 @@ import { generateAci } from '../../types/ServiceId';
const SERVICE_ID_1 = generateAci(); const SERVICE_ID_1 = generateAci();
const SERVICE_ID_2 = generateAci(); const SERVICE_ID_2 = generateAci();
const SERVICE_ID_3 = generateAci();
const SERVICE_ID_4 = generateAci();
const mentionInfo = { const mentionInfo = {
mentionAci: SERVICE_ID_1, mentionAci: SERVICE_ID_1,
@ -941,123 +943,513 @@ describe('BodyRanges', () => {
}); });
}); });
describe('applyRangesForText', () => { describe('applying ranges', () => {
it('handles mentions, replaces in reverse order', () => { function mention(start: number, title: string): HydratedBodyRangeMention {
const mentions = [ return {
{ start,
start: 0,
length: 1, length: 1,
mentionAci: SERVICE_ID_3, mentionAci: generateAci(),
replacementText: 'jerry', replacementText: title,
conversationID: 'x', conversationID: '',
}, };
{ }
start: 7,
length: 1, function style(
mentionAci: SERVICE_ID_4, start: number,
replacementText: 'fred', length: number,
conversationID: 'x', styleValue: BodyRange.Style
}, ): BodyRange<BodyRange.Formatting> {
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 text = "\uFFFC says \uFFFC, I'm here"; const result = applyRangeToText({ body, bodyRanges }, replacement);
assert.strictEqual( assert.deepEqual(result, {
applyRangesForText({ text, mentions, spoilers: [] }), body: 'abc■■■■hij',
"@jerry says @fred, I'm here" 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 });
});
});
});
});
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 spoilers, replaces in reverse order', () => { it('handles spoilers, replaces in reverse order', () => {
const spoilers = [ const body =
{
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!"; "It's so cool when the balrog fight happens in Lord of the Rings!";
assert.strictEqual( const bodyRanges = [
applyRangesForText({ text, mentions: [], spoilers }), style(18, 16, BodyRange.Style.SPOILER),
"It's so cool when ■■■■ happens in ■■■■!" style(46, 17, BodyRange.Style.SPOILER),
];
assert.deepStrictEqual(
applyRangesToText(
{ body, bodyRanges },
{ replaceMentions: true, replaceSpoilers: true }
),
{ body: "It's so cool when ■■■■ happens in ■■■■!", bodyRanges: [] }
); );
}); });
it('handles mentions that are removed by spoilers', () => { it('handles mentions that are removed by spoilers', () => {
const mentions = [ const body =
{
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,
},
];
const text =
"The recipients of today's appreciation award are \uFFFC and \uFFFC!"; "The recipients of today's appreciation award are \uFFFC and \uFFFC!";
assert.strictEqual( const bodyRanges = [
applyRangesForText({ text, mentions, spoilers }), mention(49, 'alice'),
"The recipients of today's appreciation award are ■■■■!" mention(55, 'bob'),
style(49, 7, BodyRange.Style.SPOILER),
];
assert.deepStrictEqual(
applyRangesToText(
{ body, bodyRanges },
{ replaceMentions: true, replaceSpoilers: true }
),
{
body: "The recipients of today's appreciation award are ■■■■!",
bodyRanges: [],
}
); );
}); });
it('handles mentions that need to be moved because of spoilers', () => { it('handles applying mentions but not spoilers', () => {
const mentions = [ const body = 'before \uFFFC after';
{ const bodyRanges = [
start: 0, mention(7, 'jamie'),
length: 1, style(0, 8, BodyRange.Style.BOLD),
mentionAci: SERVICE_ID_4, style(7, 1, BodyRange.Style.SPOILER),
replacementText: 'eve', style(7, 6, BodyRange.Style.ITALIC),
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 = [ assert.deepStrictEqual(
applyRangesToText(
{ body, bodyRanges },
{ replaceMentions: true, replaceSpoilers: false }
),
{ {
start: 21, body: 'before @jamie after',
length: 26, bodyRanges: [
style: BodyRange.Style.SPOILER, style(0, 13, BodyRange.Style.BOLD),
}, style(7, 6, BodyRange.Style.SPOILER),
]; style(7, 11, BodyRange.Style.ITALIC),
],
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!'
); );
}); });
}); });
}); });
});

View file

@ -3,7 +3,7 @@
/* eslint-disable @typescript-eslint/no-namespace */ /* 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 { SignalService as Proto } from '../protobuf';
import * as log from '../logging/log'; import * as log from '../logging/log';
@ -643,55 +643,208 @@ export function processBodyRangesForSearchResult({
export const SPOILER_REPLACEMENT = '■■■■'; export const SPOILER_REPLACEMENT = '■■■■';
export function applyRangesForText({ /**
text, * Replace text in a string at a given range, returning the new string. The
mentions, * replacement can be a different length than the text it's replacing.
spoilers, * @example
}: { * ```ts
text: string | undefined; * replaceText('hello world!!!', 'jamie', 6, 11) === 'hello jamie!!!'
mentions: ReadonlyArray<HydratedBodyRangeMention>; * ```
spoilers: ReadonlyArray<BodyRange<BodyRange.Formatting>>; */
}): string | undefined { function replaceText(
if (!text) { input: string,
return text; insert: string,
start: number,
end: number
): string {
return input.slice(0, start) + insert + input.slice(end);
} }
let updatedText = text; export type BodyWithBodyRanges = {
let sortableMentions: Array<HydratedBodyRangeMention> = mentions.slice(); body: string;
bodyRanges: HydratedBodyRangesType;
const sortableSpoilers: Array<BodyRange<BodyRange.Formatting>> =
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);
// 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),
}; };
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;
} }
return mention; // 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;
}
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;
}
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;
}
// If this made the span empty, we can remove it
if (start === end) {
return null;
}
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 `${left}${SPOILER_REPLACEMENT}${right}`; return { body: updatedBody, bodyRanges: updatedRanges };
}, updatedText); }
return sortableMentions function _applyRangeOfType(
.sort((a, b) => b.start - a.start) input: BodyWithBodyRanges,
.reduce((acc, { start, length, replacementText }) => { condition: (bodyRange: HydratedBodyRangeType) => boolean
const left = acc.slice(0, start); ) {
const right = acc.slice(start + length); const [matchedRanges, otherRanges] = partition(input.bodyRanges, condition);
return `${left}@${replacementText}${right}`; return matchedRanges
}, updatedText); .sort((a, b) => {
return b.start - a.start;
})
.reduce<BodyWithBodyRanges>(
(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;
} }

View file

@ -2,8 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { MessageAttributesType } from '../model-types.d'; import type { MessageAttributesType } from '../model-types.d';
import { BodyRange, applyRangesForText } from '../types/BodyRange'; import { applyRangesToText, hydrateRanges } from '../types/BodyRange';
import { extractHydratedMentions } from '../state/selectors/message';
import { findAndFormatContact } from './findAndFormatContact'; import { findAndFormatContact } from './findAndFormatContact';
import { getNotificationDataForMessage } from './getNotificationDataForMessage'; import { getNotificationDataForMessage } from './getNotificationDataForMessage';
import { isConversationAccepted } from './isConversationAccepted'; import { isConversationAccepted } from './isConversationAccepted';
@ -12,7 +11,7 @@ import { strictAssert } from './assert';
export function getNotificationTextForMessage( export function getNotificationTextForMessage(
attributes: MessageAttributesType attributes: MessageAttributesType
): string { ): string {
const { text, emoji } = getNotificationDataForMessage(attributes); const { text, emoji, bodyRanges } = getNotificationDataForMessage(attributes);
const conversation = window.ConversationController.get( const conversation = window.ConversationController.get(
attributes.conversationId attributes.conversationId
@ -64,25 +63,23 @@ export function getNotificationTextForMessage(
return window.i18n('icu:Quote__story-reaction--single'); return window.i18n('icu:Quote__story-reaction--single');
} }
const mentions = const result = applyRangesToText(
extractHydratedMentions(attributes, { {
conversationSelector: findAndFormatContact, body: text,
}) || []; bodyRanges: hydrateRanges(bodyRanges, findAndFormatContact) ?? [],
const spoilers = (attributes.bodyRanges || []).filter( },
range => { replaceMentions: true, replaceSpoilers: true }
BodyRange.isFormatting(range) && range.style === BodyRange.Style.SPOILER );
) as Array<BodyRange<BodyRange.Formatting>>;
const modifiedText = applyRangesForText({ text, mentions, spoilers });
// Linux emoji support is mixed, so we disable it. (Note that this doesn't touch // Linux emoji support is mixed, so we disable it. (Note that this doesn't touch
// the `text`, which can contain emoji.) // the `text`, which can contain emoji.)
const shouldIncludeEmoji = Boolean(emoji) && !window.Signal.OS.isLinux(); const shouldIncludeEmoji = Boolean(emoji) && !window.Signal.OS.isLinux();
if (shouldIncludeEmoji) { if (shouldIncludeEmoji) {
return window.i18n('icu:message--getNotificationText--text-with-emoji', { return window.i18n('icu:message--getNotificationText--text-with-emoji', {
text: modifiedText, text: result.body,
emoji, emoji,
}); });
} }
return modifiedText || ''; return result.body ?? '';
} }