965 lines
26 KiB
TypeScript
965 lines
26 KiB
TypeScript
// Copyright 2023 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
/* eslint-disable @typescript-eslint/no-namespace */
|
|
|
|
import { isEqual, isNumber, omit, orderBy, partition } from 'lodash';
|
|
|
|
import { SignalService as Proto } from '../protobuf';
|
|
import { createLogger } from '../logging/log';
|
|
import { missingCaseError } from '../util/missingCaseError';
|
|
import { isNotNil } from '../util/isNotNil';
|
|
import type { ConversationType } from '../state/ducks/conversations';
|
|
import {
|
|
SNIPPET_LEFT_PLACEHOLDER,
|
|
SNIPPET_RIGHT_PLACEHOLDER,
|
|
SNIPPET_TRUNCATION_PLACEHOLDER,
|
|
} from '../util/search';
|
|
import { assertDev } from '../util/assert';
|
|
import type { AciString } from './ServiceId';
|
|
import { normalizeAci } from '../util/normalizeAci';
|
|
|
|
const log = createLogger('BodyRange');
|
|
|
|
// Cold storage of body ranges
|
|
|
|
export type BodyRange<T extends object> = {
|
|
start: number;
|
|
length: number;
|
|
} & T;
|
|
|
|
/** Body range as parsed from proto (No "Link" since those don't come from proto) */
|
|
export type RawBodyRange = BodyRange<BodyRange.Mention | BodyRange.Formatting>;
|
|
|
|
export enum DisplayStyle {
|
|
SearchKeywordHighlight = 'SearchKeywordHighlight',
|
|
}
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
|
export namespace BodyRange {
|
|
// re-export for convenience
|
|
export type Style = Proto.BodyRange.Style;
|
|
export const { Style } = Proto.BodyRange;
|
|
|
|
export type Mention = {
|
|
mentionAci: AciString;
|
|
};
|
|
export type Link = {
|
|
url: string;
|
|
};
|
|
export type Formatting = {
|
|
style: Style;
|
|
spoilerId?: number;
|
|
};
|
|
export type DisplayOnly = {
|
|
displayStyle: DisplayStyle;
|
|
};
|
|
|
|
export function isRawRange(range: BodyRange<object>): range is RawBodyRange {
|
|
return isMention(range) || isFormatting(range);
|
|
}
|
|
|
|
// these overloads help inference along
|
|
export function isMention(
|
|
bodyRange: HydratedBodyRangeType
|
|
): bodyRange is HydratedBodyRangeMention;
|
|
export function isMention(
|
|
bodyRange: BodyRange<object>
|
|
): bodyRange is BodyRange<Mention>;
|
|
export function isMention<T extends object, X extends BodyRange<Mention> & T>(
|
|
bodyRange: BodyRange<T>
|
|
): bodyRange is X {
|
|
// satisfies keyof Mention
|
|
return ('mentionAci' as const) in bodyRange;
|
|
}
|
|
export function isFormatting(
|
|
bodyRange: BodyRange<object>
|
|
): bodyRange is BodyRange<Formatting> {
|
|
// satisfies keyof Formatting
|
|
return ('style' as const) in bodyRange;
|
|
}
|
|
|
|
export function isLink<T extends Mention | Link | Formatting | DisplayOnly>(
|
|
node: T
|
|
): node is T & Link {
|
|
// satisfies keyof Link
|
|
return ('url' as const) in node;
|
|
}
|
|
export function isDisplayOnly<
|
|
T extends Mention | Link | Formatting | DisplayOnly,
|
|
>(node: T): node is T & DisplayOnly {
|
|
// satisfies keyof DisplayOnly
|
|
return ('displayStyle' as const) in node;
|
|
}
|
|
}
|
|
|
|
// Used exclusive in CompositionArea and related conversation_view.tsx calls.
|
|
|
|
export type DraftBodyRangeMention = BodyRange<
|
|
BodyRange.Mention & {
|
|
replacementText: string;
|
|
}
|
|
>;
|
|
export type DraftBodyRange =
|
|
| DraftBodyRangeMention
|
|
| BodyRange<BodyRange.Formatting>;
|
|
export type DraftBodyRanges = ReadonlyArray<DraftBodyRange>;
|
|
|
|
// Fully hydrated body range to be used in UI components.
|
|
|
|
export type HydratedBodyRangeMention = DraftBodyRangeMention & {
|
|
conversationID: string;
|
|
};
|
|
|
|
export type HydratedBodyRangeType =
|
|
| HydratedBodyRangeMention
|
|
| BodyRange<BodyRange.Formatting>;
|
|
|
|
export type HydratedBodyRangesType = ReadonlyArray<HydratedBodyRangeType>;
|
|
|
|
export type DisplayBodyRangeType =
|
|
| HydratedBodyRangeType
|
|
| BodyRange<BodyRange.DisplayOnly>;
|
|
export type BodyRangesForDisplayType = ReadonlyArray<DisplayBodyRangeType>;
|
|
|
|
type HydratedMention = BodyRange.Mention & {
|
|
conversationID: string;
|
|
replacementText: string;
|
|
};
|
|
|
|
/**
|
|
* A range that can contain other nested ranges
|
|
* Inner range start fields are relative to the start of the containing range
|
|
*/
|
|
export type RangeNode = BodyRange<
|
|
(
|
|
| HydratedMention
|
|
| BodyRange.Link
|
|
| BodyRange.Formatting
|
|
| BodyRange.DisplayOnly
|
|
) & {
|
|
ranges: ReadonlyArray<RangeNode>;
|
|
}
|
|
>;
|
|
|
|
const { BOLD, ITALIC, MONOSPACE, SPOILER, STRIKETHROUGH, NONE } =
|
|
BodyRange.Style;
|
|
const MAX_PER_TYPE = 250;
|
|
const MENTION_NAME = 'mention';
|
|
|
|
// We drop unknown bodyRanges and remove extra stuff so they serialize properly
|
|
export function filterAndClean(
|
|
ranges: ReadonlyArray<Proto.IBodyRange | RawBodyRange> | undefined | null
|
|
): ReadonlyArray<RawBodyRange> | undefined {
|
|
if (!ranges) {
|
|
return undefined;
|
|
}
|
|
|
|
const countByTypeRecord: Record<
|
|
BodyRange.Style | typeof MENTION_NAME,
|
|
number
|
|
> = {
|
|
[MENTION_NAME]: 0,
|
|
[BOLD]: 0,
|
|
[ITALIC]: 0,
|
|
[MONOSPACE]: 0,
|
|
[SPOILER]: 0,
|
|
[STRIKETHROUGH]: 0,
|
|
[NONE]: 0,
|
|
};
|
|
|
|
return ranges
|
|
.map(range => {
|
|
const { start: startFromRange, length, ...restOfRange } = range;
|
|
|
|
const start = startFromRange ?? 0;
|
|
if (!isNumber(length)) {
|
|
log.warn('filterAndClean: Dropping bodyRange with non-number length');
|
|
return undefined;
|
|
}
|
|
|
|
let mentionAci: AciString | undefined;
|
|
if ('mentionAci' in range && range.mentionAci) {
|
|
mentionAci = normalizeAci(range.mentionAci, 'BodyRange.mentionAci');
|
|
}
|
|
|
|
if (mentionAci) {
|
|
countByTypeRecord[MENTION_NAME] += 1;
|
|
if (countByTypeRecord[MENTION_NAME] > MAX_PER_TYPE) {
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
...restOfRange,
|
|
start,
|
|
length,
|
|
mentionAci,
|
|
};
|
|
}
|
|
if ('style' in range && range.style) {
|
|
countByTypeRecord[range.style] += 1;
|
|
if (countByTypeRecord[range.style] > MAX_PER_TYPE) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
...restOfRange,
|
|
start,
|
|
length,
|
|
style: range.style,
|
|
};
|
|
}
|
|
|
|
log.warn('filterAndClean: Dropping unknown bodyRange');
|
|
return undefined;
|
|
})
|
|
.filter(isNotNil);
|
|
}
|
|
|
|
export function hydrateRanges(
|
|
ranges: ReadonlyArray<BodyRange<object>> | undefined,
|
|
conversationSelector: (id: string) => ConversationType
|
|
): Array<HydratedBodyRangeType> | undefined {
|
|
if (!ranges) {
|
|
return undefined;
|
|
}
|
|
|
|
return filterAndClean(ranges)?.map(range => {
|
|
if (BodyRange.isMention(range)) {
|
|
const conversation = conversationSelector(range.mentionAci);
|
|
|
|
return {
|
|
...range,
|
|
conversationID: conversation.id,
|
|
replacementText: conversation.title,
|
|
};
|
|
}
|
|
|
|
return range;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Insert a range into an existing range tree, splitting up the range if it intersects
|
|
* with an existing range
|
|
*
|
|
* @param range The range to insert the tree
|
|
* @param rangeTree A list of nested non-intersecting range nodes, these starting ranges
|
|
* will not be split up
|
|
*/
|
|
export function insertRange(
|
|
range: BodyRange<
|
|
| HydratedMention
|
|
| BodyRange.Link
|
|
| BodyRange.Formatting
|
|
| BodyRange.DisplayOnly
|
|
>,
|
|
rangeTree: ReadonlyArray<RangeNode>
|
|
): ReadonlyArray<RangeNode> {
|
|
const [current, ...rest] = rangeTree;
|
|
|
|
if (!current) {
|
|
return [{ ...range, ranges: [] }];
|
|
}
|
|
const rangeEnd = range.start + range.length;
|
|
const currentEnd = current.start + current.length;
|
|
|
|
// ends before current starts
|
|
if (rangeEnd <= current.start) {
|
|
return [{ ...range, ranges: [] }, current, ...rest];
|
|
}
|
|
|
|
// starts after current one ends
|
|
if (range.start >= currentEnd) {
|
|
return [current, ...insertRange(range, rest)];
|
|
}
|
|
|
|
// range is contained by first
|
|
if (range.start >= current.start && rangeEnd <= currentEnd) {
|
|
return [
|
|
{
|
|
...current,
|
|
ranges: insertRange(
|
|
{ ...range, start: range.start - current.start },
|
|
current.ranges
|
|
),
|
|
},
|
|
...rest,
|
|
];
|
|
}
|
|
|
|
// range contains first (but might contain more)
|
|
// split range into 3
|
|
if (range.start < current.start && rangeEnd > currentEnd) {
|
|
return [
|
|
{ ...range, length: current.start - range.start, ranges: [] },
|
|
{
|
|
...current,
|
|
ranges: insertRange(
|
|
{ ...range, start: 0, length: current.length },
|
|
current.ranges
|
|
),
|
|
},
|
|
...insertRange(
|
|
{ ...range, start: currentEnd, length: rangeEnd - currentEnd },
|
|
rest
|
|
),
|
|
];
|
|
}
|
|
|
|
// range intersects beginning
|
|
// split range into 2
|
|
if (range.start < current.start && rangeEnd <= currentEnd) {
|
|
return [
|
|
{ ...range, length: current.start - range.start, ranges: [] },
|
|
{
|
|
...current,
|
|
ranges: insertRange(
|
|
{
|
|
...range,
|
|
start: 0,
|
|
length: range.length - (current.start - range.start),
|
|
},
|
|
current.ranges
|
|
),
|
|
},
|
|
...rest,
|
|
];
|
|
}
|
|
|
|
// range intersects ending
|
|
// split range into 2
|
|
if (range.start >= current.start && rangeEnd > currentEnd) {
|
|
return [
|
|
{
|
|
...current,
|
|
ranges: insertRange(
|
|
{
|
|
...range,
|
|
start: range.start - current.start,
|
|
length: currentEnd - range.start,
|
|
},
|
|
current.ranges
|
|
),
|
|
},
|
|
...insertRange(
|
|
{
|
|
...range,
|
|
start: currentEnd,
|
|
length: range.length - (currentEnd - range.start),
|
|
},
|
|
rest
|
|
),
|
|
];
|
|
}
|
|
|
|
log.error(`MessageTextRenderer: unhandled range ${range}`);
|
|
throw new Error('unhandled range');
|
|
}
|
|
|
|
// A flat list, ready for display
|
|
|
|
export type DisplayNode = {
|
|
text: string;
|
|
start: number;
|
|
length: number;
|
|
mentions: ReadonlyArray<BodyRange<HydratedMention>>;
|
|
|
|
// Formatting
|
|
isBold?: boolean;
|
|
isItalic?: boolean;
|
|
isMonospace?: boolean;
|
|
isSpoiler?: boolean;
|
|
isStrikethrough?: boolean;
|
|
|
|
// Link
|
|
url?: string;
|
|
|
|
// DisplayOnly
|
|
isKeywordHighlight?: boolean;
|
|
|
|
// Only for spoilers, only to make sure we honor original spoiler breakdown
|
|
spoilerId?: number;
|
|
spoilerChildren?: ReadonlyArray<DisplayNode>;
|
|
};
|
|
type PartialDisplayNode = Omit<
|
|
DisplayNode,
|
|
'mentions' | 'text' | 'start' | 'length'
|
|
>;
|
|
|
|
function rangeToPartialNode(
|
|
range: BodyRange<
|
|
BodyRange.Link | BodyRange.Formatting | BodyRange.DisplayOnly
|
|
>
|
|
): PartialDisplayNode {
|
|
if (BodyRange.isFormatting(range)) {
|
|
if (range.style === BodyRange.Style.BOLD) {
|
|
return { isBold: true };
|
|
}
|
|
if (range.style === BodyRange.Style.ITALIC) {
|
|
return { isItalic: true };
|
|
}
|
|
if (range.style === BodyRange.Style.MONOSPACE) {
|
|
return { isMonospace: true };
|
|
}
|
|
if (range.style === BodyRange.Style.SPOILER) {
|
|
return { isSpoiler: true, spoilerId: range.spoilerId };
|
|
}
|
|
if (range.style === BodyRange.Style.STRIKETHROUGH) {
|
|
return { isStrikethrough: true };
|
|
}
|
|
if (range.style === BodyRange.Style.NONE) {
|
|
return {};
|
|
}
|
|
return {};
|
|
}
|
|
if (BodyRange.isLink(range)) {
|
|
return {
|
|
url: range.url,
|
|
};
|
|
}
|
|
if (BodyRange.isDisplayOnly(range)) {
|
|
if (range.displayStyle === DisplayStyle.SearchKeywordHighlight) {
|
|
return { isKeywordHighlight: true };
|
|
}
|
|
throw missingCaseError(range.displayStyle);
|
|
}
|
|
|
|
throw missingCaseError(range);
|
|
}
|
|
|
|
/**
|
|
* Turns a range tree into a flat list that can be rendered, with a walk across the tree.
|
|
*
|
|
* * @param rangeTree A list of nested non-intersecting ranges.
|
|
*/
|
|
export function collapseRangeTree({
|
|
parentData,
|
|
parentOffset = 0,
|
|
text,
|
|
tree,
|
|
}: {
|
|
parentData?: PartialDisplayNode;
|
|
parentOffset?: number;
|
|
text: string;
|
|
tree: ReadonlyArray<RangeNode>;
|
|
}): ReadonlyArray<DisplayNode> {
|
|
let collapsed: Array<DisplayNode> = [];
|
|
|
|
let offset = 0;
|
|
let mentions: Array<HydratedBodyRangeMention> = [];
|
|
|
|
tree.forEach(range => {
|
|
if (BodyRange.isMention(range)) {
|
|
mentions.push({
|
|
...omit(range, ['ranges']),
|
|
start: range.start - offset,
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Empty space between start of current
|
|
if (range.start > offset) {
|
|
collapsed.push({
|
|
...parentData,
|
|
text: text.slice(offset, range.start),
|
|
start: offset + parentOffset,
|
|
length: range.start - offset,
|
|
mentions,
|
|
});
|
|
mentions = [];
|
|
}
|
|
|
|
// What sub-breaks can we make within this node?
|
|
const partialNode = { ...parentData, ...rangeToPartialNode(range) };
|
|
collapsed = collapsed.concat(
|
|
collapseRangeTree({
|
|
parentData: partialNode,
|
|
parentOffset: range.start + parentOffset,
|
|
text: text.slice(range.start, range.start + range.length),
|
|
tree: range.ranges,
|
|
})
|
|
);
|
|
|
|
offset = range.start + range.length;
|
|
});
|
|
|
|
// Empty space after the last range
|
|
if (text.length > offset) {
|
|
collapsed.push({
|
|
...parentData,
|
|
text: text.slice(offset, text.length),
|
|
start: offset + parentOffset,
|
|
length: text.length - offset,
|
|
mentions,
|
|
});
|
|
}
|
|
|
|
return collapsed;
|
|
}
|
|
|
|
export function groupContiguousSpoilers(
|
|
nodes: ReadonlyArray<DisplayNode>
|
|
): ReadonlyArray<DisplayNode> {
|
|
const result: Array<DisplayNode> = [];
|
|
|
|
let spoilerContainer: DisplayNode | undefined;
|
|
|
|
nodes.forEach(node => {
|
|
if (node.isSpoiler) {
|
|
if (
|
|
spoilerContainer &&
|
|
isNumber(spoilerContainer.spoilerId) &&
|
|
spoilerContainer.spoilerId === node.spoilerId
|
|
) {
|
|
spoilerContainer.spoilerChildren = [
|
|
...(spoilerContainer.spoilerChildren || []),
|
|
node,
|
|
];
|
|
} else {
|
|
spoilerContainer = undefined;
|
|
}
|
|
|
|
if (!spoilerContainer) {
|
|
spoilerContainer = {
|
|
...node,
|
|
isSpoiler: true,
|
|
spoilerChildren: [node],
|
|
};
|
|
result.push(spoilerContainer);
|
|
}
|
|
} else {
|
|
spoilerContainer = undefined;
|
|
result.push(node);
|
|
}
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
const TRUNCATION_CHAR = '...';
|
|
const TRUNCATION_START = new RegExp(`^${SNIPPET_TRUNCATION_PLACEHOLDER}`);
|
|
const TRUNCATION_END = new RegExp(`${SNIPPET_TRUNCATION_PLACEHOLDER}$`);
|
|
// This function exists because bodyRanges tells us the character position
|
|
// where the at-mention starts at according to the full body text. The snippet
|
|
// we get back is a portion of the text and we don't know where it starts. This
|
|
// function will find the relevant bodyRanges that apply to the snippet and
|
|
// then update the proper start position of each body range.
|
|
export function processBodyRangesForSearchResult({
|
|
snippet,
|
|
body,
|
|
bodyRanges,
|
|
}: {
|
|
snippet: string;
|
|
body: string;
|
|
bodyRanges: BodyRangesForDisplayType;
|
|
}): {
|
|
cleanedSnippet: string;
|
|
bodyRanges: BodyRangesForDisplayType;
|
|
} {
|
|
// Find where the snippet starts in the full text
|
|
const cleanedSnippet = snippet
|
|
.replace(new RegExp(SNIPPET_LEFT_PLACEHOLDER, 'g'), '')
|
|
.replace(new RegExp(SNIPPET_RIGHT_PLACEHOLDER, 'g'), '');
|
|
const withNoStartTruncation = cleanedSnippet.replace(TRUNCATION_START, '');
|
|
const withNoEndTruncation = withNoStartTruncation.replace(TRUNCATION_END, '');
|
|
const finalSnippet = cleanedSnippet
|
|
.replace(TRUNCATION_START, TRUNCATION_CHAR)
|
|
.replace(TRUNCATION_END, TRUNCATION_CHAR);
|
|
const truncationDelta =
|
|
withNoStartTruncation.length !== cleanedSnippet.length
|
|
? TRUNCATION_CHAR.length
|
|
: 0;
|
|
|
|
let startOfSnippet = body.indexOf(withNoEndTruncation);
|
|
if (startOfSnippet === -1) {
|
|
assertDev(false, `No match found for "${snippet}" inside "${body}"`);
|
|
startOfSnippet = 0;
|
|
}
|
|
|
|
const endOfSnippet = startOfSnippet + withNoEndTruncation.length;
|
|
|
|
// We want only the ranges that include the snippet
|
|
const filteredBodyRanges = bodyRanges.filter(range => {
|
|
const { start } = range;
|
|
const end = range.start + range.length;
|
|
return end > startOfSnippet && start < endOfSnippet;
|
|
});
|
|
|
|
// Adjust ranges, with numbers for the original message body, to work with snippet
|
|
const adjustedBodyRanges: Array<DisplayBodyRangeType> =
|
|
filteredBodyRanges.map(range => {
|
|
const normalizedStart = range.start - startOfSnippet + truncationDelta;
|
|
const start = Math.max(normalizedStart, truncationDelta);
|
|
const end = Math.min(
|
|
normalizedStart + range.length,
|
|
withNoEndTruncation.length + truncationDelta
|
|
);
|
|
|
|
return {
|
|
...range,
|
|
start,
|
|
length: end - start,
|
|
};
|
|
});
|
|
|
|
// To format the matches identified by FTS, we create synthetic BodyRanges to mix in
|
|
// with all the other formatting embedded in this message.
|
|
const highlightMatches = snippet.matchAll(
|
|
new RegExp(
|
|
`${SNIPPET_LEFT_PLACEHOLDER}(.*?)${SNIPPET_RIGHT_PLACEHOLDER}`,
|
|
'dg'
|
|
)
|
|
);
|
|
|
|
let placeholderCharsSkipped = 0;
|
|
for (const highlightMatch of highlightMatches) {
|
|
// TS < 5 does not have types for RegExpIndicesArray
|
|
const { indices } = highlightMatch as RegExpMatchArray & {
|
|
indices: Array<Array<number>>;
|
|
};
|
|
const [wholeMatchStartIdx] = indices[0];
|
|
const [matchedWordStartIdx, matchedWordEndIdx] = indices[1];
|
|
adjustedBodyRanges.push({
|
|
start:
|
|
wholeMatchStartIdx +
|
|
-placeholderCharsSkipped +
|
|
(truncationDelta
|
|
? TRUNCATION_CHAR.length - SNIPPET_TRUNCATION_PLACEHOLDER.length
|
|
: 0),
|
|
length: matchedWordEndIdx - matchedWordStartIdx,
|
|
displayStyle: DisplayStyle.SearchKeywordHighlight,
|
|
});
|
|
placeholderCharsSkipped +=
|
|
SNIPPET_LEFT_PLACEHOLDER.length + SNIPPET_RIGHT_PLACEHOLDER.length;
|
|
}
|
|
|
|
return {
|
|
cleanedSnippet: finalSnippet,
|
|
bodyRanges: adjustedBodyRanges,
|
|
};
|
|
}
|
|
|
|
export const SPOILER_REPLACEMENT = '■■■■';
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
// 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 { 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;
|
|
}
|
|
|
|
export function trimMessageWhitespace(input: {
|
|
body?: string;
|
|
bodyRanges?: ReadonlyArray<RawBodyRange>;
|
|
}): { body?: string; bodyRanges?: ReadonlyArray<RawBodyRange> } {
|
|
if (input.body == null) {
|
|
return input;
|
|
}
|
|
|
|
let trimmedAtStart = input.body.trimStart();
|
|
let minimumIndex = input.body.length - trimmedAtStart.length;
|
|
|
|
let allTrimmed = trimmedAtStart.trimEnd();
|
|
let maximumIndex = allTrimmed.length;
|
|
|
|
if (minimumIndex === 0 && trimmedAtStart.length === maximumIndex) {
|
|
return input;
|
|
}
|
|
|
|
let earliestMonospaceIndex = Number.MAX_SAFE_INTEGER;
|
|
input.bodyRanges?.forEach(range => {
|
|
if (earliestMonospaceIndex === 0) {
|
|
return;
|
|
}
|
|
if (
|
|
!BodyRange.isFormatting(range) ||
|
|
range.style !== BodyRange.Style.MONOSPACE
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (range.start < earliestMonospaceIndex) {
|
|
earliestMonospaceIndex = range.start;
|
|
}
|
|
});
|
|
if (earliestMonospaceIndex < minimumIndex) {
|
|
trimmedAtStart = input.body.slice(earliestMonospaceIndex);
|
|
minimumIndex = input.body.length - trimmedAtStart.length;
|
|
allTrimmed = trimmedAtStart.trimEnd();
|
|
maximumIndex = allTrimmed.length;
|
|
}
|
|
|
|
if (earliestMonospaceIndex === 0 && trimmedAtStart.length === maximumIndex) {
|
|
return input;
|
|
}
|
|
|
|
const bodyRanges = input.bodyRanges
|
|
?.map(range => {
|
|
let workingRange = range;
|
|
|
|
const rangeEnd = workingRange.start + workingRange.length;
|
|
if (rangeEnd <= minimumIndex) {
|
|
return undefined;
|
|
}
|
|
|
|
if (workingRange.start < minimumIndex) {
|
|
const underMinimum = workingRange.start - minimumIndex;
|
|
workingRange = {
|
|
...workingRange,
|
|
start: Math.max(underMinimum, 0),
|
|
length: workingRange.length + underMinimum,
|
|
};
|
|
} else {
|
|
workingRange = {
|
|
...workingRange,
|
|
start: workingRange.start - minimumIndex,
|
|
};
|
|
}
|
|
|
|
const newRangeEnd = workingRange.start + workingRange.length;
|
|
|
|
if (workingRange.start >= maximumIndex) {
|
|
return undefined;
|
|
}
|
|
|
|
const overMaximum = newRangeEnd - maximumIndex;
|
|
if (overMaximum > 0) {
|
|
workingRange = {
|
|
...workingRange,
|
|
length: workingRange.length - overMaximum,
|
|
};
|
|
}
|
|
|
|
return workingRange;
|
|
})
|
|
.filter(isNotNil);
|
|
|
|
return {
|
|
body: allTrimmed,
|
|
bodyRanges,
|
|
};
|
|
}
|
|
|
|
// For ease of working with draft mentions in Quill, a conversationID field is present.
|
|
function normalizeBodyRanges(bodyRanges: DraftBodyRanges) {
|
|
return orderBy(bodyRanges, ['start', 'length']).map(item => {
|
|
if (BodyRange.isMention(item)) {
|
|
return { ...item, conversationID: undefined };
|
|
}
|
|
return item;
|
|
});
|
|
}
|
|
|
|
export function areBodyRangesEqual(
|
|
left: DraftBodyRanges,
|
|
right: DraftBodyRanges
|
|
): boolean {
|
|
const normalizedLeft = normalizeBodyRanges(left);
|
|
const sortedRight = normalizeBodyRanges(right);
|
|
|
|
if (normalizedLeft.length !== sortedRight.length) {
|
|
return false;
|
|
}
|
|
|
|
return isEqual(normalizedLeft, sortedRight);
|
|
}
|