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 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,
};
})
);
}
}, [

View file

@ -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<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 = (
message: MessageWithUIFieldsType,
options: GetContactOptions

View file

@ -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<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 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),
],
}
);
});
});
});
});

View file

@ -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<HydratedBodyRangeMention>;
spoilers: ReadonlyArray<BodyRange<BodyRange.Formatting>>;
}): 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<HydratedBodyRangeMention> = 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<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);
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<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
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<BodyRange<BodyRange.Formatting>>;
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 ?? '';
}