Apply mention ranges to body when multi-forwarding
This commit is contained in:
parent
063a1d9df3
commit
6bd802a03d
5 changed files with 738 additions and 208 deletions
|
@ -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,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
],
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 ?? '';
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue