233 lines
6.3 KiB
TypeScript
233 lines
6.3 KiB
TypeScript
// Copyright 2020 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import emojiRegex from 'emoji-regex';
|
|
import Delta from 'quill-delta';
|
|
import type { LeafBlot, DeltaOperation } from 'quill';
|
|
import type Op from 'quill-delta/dist/Op';
|
|
|
|
import type { DraftBodyRangeType, DraftBodyRangesType } from '../types/Util';
|
|
import type { MentionBlot } from './mentions/blot';
|
|
|
|
export type MentionBlotValue = {
|
|
uuid: string;
|
|
title: string;
|
|
};
|
|
|
|
export const isMentionBlot = (blot: LeafBlot): blot is MentionBlot =>
|
|
blot.value() && blot.value().mention;
|
|
|
|
export type RetainOp = Op & { retain: number };
|
|
export type InsertOp<K extends string, T> = Op & { insert: { [V in K]: T } };
|
|
|
|
export type InsertMentionOp = InsertOp<'mention', MentionBlotValue>;
|
|
export type InsertEmojiOp = InsertOp<'emoji', string>;
|
|
|
|
export const isRetainOp = (op?: Op): op is RetainOp =>
|
|
op !== undefined && op.retain !== undefined;
|
|
|
|
export const isSpecificInsertOp = (op: Op, type: string): boolean => {
|
|
return (
|
|
op.insert !== undefined &&
|
|
typeof op.insert === 'object' &&
|
|
Object.hasOwnProperty.call(op.insert, type)
|
|
);
|
|
};
|
|
|
|
export const isInsertEmojiOp = (op: Op): op is InsertEmojiOp =>
|
|
isSpecificInsertOp(op, 'emoji');
|
|
|
|
export const isInsertMentionOp = (op: Op): op is InsertMentionOp =>
|
|
isSpecificInsertOp(op, 'mention');
|
|
|
|
export const getTextFromOps = (ops: Array<DeltaOperation>): string =>
|
|
ops
|
|
.reduce((acc, op) => {
|
|
if (typeof op.insert === 'string') {
|
|
return acc + op.insert;
|
|
}
|
|
|
|
if (isInsertEmojiOp(op)) {
|
|
return acc + op.insert.emoji;
|
|
}
|
|
|
|
if (isInsertMentionOp(op)) {
|
|
return `${acc}@${op.insert.mention.title}`;
|
|
}
|
|
|
|
return acc;
|
|
}, '')
|
|
.trim();
|
|
|
|
export const getTextAndMentionsFromOps = (
|
|
ops: Array<Op>
|
|
): [string, DraftBodyRangesType] => {
|
|
const mentions: Array<DraftBodyRangeType> = [];
|
|
|
|
const text = ops
|
|
.reduce((acc, op, index) => {
|
|
if (typeof op.insert === 'string') {
|
|
const toAdd = index === 0 ? op.insert.trimStart() : op.insert;
|
|
return acc + toAdd;
|
|
}
|
|
|
|
if (isInsertEmojiOp(op)) {
|
|
return acc + op.insert.emoji;
|
|
}
|
|
|
|
if (isInsertMentionOp(op)) {
|
|
mentions.push({
|
|
length: 1, // The length of `\uFFFC`
|
|
mentionUuid: op.insert.mention.uuid,
|
|
replacementText: op.insert.mention.title,
|
|
start: acc.length,
|
|
});
|
|
|
|
return `${acc}\uFFFC`;
|
|
}
|
|
|
|
return acc;
|
|
}, '')
|
|
.trimEnd(); // Trimming the start of this string will mess up mention indices
|
|
|
|
return [text, mentions];
|
|
};
|
|
|
|
export const getBlotTextPartitions = (
|
|
blotText: string | undefined,
|
|
index: number
|
|
): [string, string] => {
|
|
const lowerCaseBlotText = (blotText || '').toLowerCase();
|
|
const leftLeafText = lowerCaseBlotText.substr(0, index);
|
|
const rightLeafText = lowerCaseBlotText.substr(index);
|
|
|
|
return [leftLeafText, rightLeafText];
|
|
};
|
|
|
|
export const matchBlotTextPartitions = (
|
|
blot: LeafBlot,
|
|
index: number,
|
|
leftRegExp: RegExp,
|
|
rightRegExp?: RegExp
|
|
): Array<RegExpMatchArray | null> => {
|
|
const [leftText, rightText] = getBlotTextPartitions(blot.text, index);
|
|
|
|
const leftMatch = leftRegExp.exec(leftText);
|
|
let rightMatch = null;
|
|
|
|
if (rightRegExp) {
|
|
rightMatch = rightRegExp.exec(rightText);
|
|
}
|
|
|
|
return [leftMatch, rightMatch];
|
|
};
|
|
|
|
export const getDeltaToRestartMention = (ops: Array<Op>): Delta => {
|
|
const changes = ops.reduce((acc, op): Array<Op> => {
|
|
if (op.insert && typeof op.insert === 'string') {
|
|
acc.push({ retain: op.insert.length });
|
|
} else {
|
|
acc.push({ retain: 1 });
|
|
}
|
|
return acc;
|
|
}, Array<Op>());
|
|
changes.push({ delete: 1 });
|
|
changes.push({ insert: '@' });
|
|
return new Delta(changes);
|
|
};
|
|
|
|
export const getDeltaToRemoveStaleMentions = (
|
|
ops: Array<Op>,
|
|
memberUuids: Array<string>
|
|
): Delta => {
|
|
const newOps = ops.reduce((memo, op) => {
|
|
if (op.insert) {
|
|
if (
|
|
isInsertMentionOp(op) &&
|
|
!memberUuids.includes(op.insert.mention.uuid)
|
|
) {
|
|
const deleteOp = { delete: 1 };
|
|
const textOp = { insert: `@${op.insert.mention.title}` };
|
|
return [...memo, deleteOp, textOp];
|
|
}
|
|
|
|
if (typeof op.insert === 'string') {
|
|
const retainStringOp = { retain: op.insert.length };
|
|
return [...memo, retainStringOp];
|
|
}
|
|
|
|
const retainEmbedOp = { retain: 1 };
|
|
return [...memo, retainEmbedOp];
|
|
}
|
|
|
|
return [...memo, op];
|
|
}, Array<Op>());
|
|
|
|
return new Delta(newOps);
|
|
};
|
|
|
|
export const insertMentionOps = (
|
|
incomingOps: Array<Op>,
|
|
bodyRanges: DraftBodyRangesType
|
|
): Array<Op> => {
|
|
const ops = [...incomingOps];
|
|
|
|
const sortableBodyRanges: Array<DraftBodyRangeType> = bodyRanges.slice();
|
|
|
|
// Working backwards through bodyRanges (to avoid offsetting later mentions),
|
|
// Shift off the op with the text to the left of the last mention,
|
|
// Insert a mention based on the current bodyRange,
|
|
// Unshift the mention and surrounding text to leave the ops ready for the next range
|
|
sortableBodyRanges
|
|
.sort((a, b) => b.start - a.start)
|
|
.forEach(({ start, length, mentionUuid, replacementText }) => {
|
|
const op = ops.shift();
|
|
|
|
if (op) {
|
|
const { insert } = op;
|
|
|
|
if (typeof insert === 'string') {
|
|
const left = insert.slice(0, start);
|
|
const right = insert.slice(start + length);
|
|
|
|
const mention = {
|
|
uuid: mentionUuid,
|
|
title: replacementText,
|
|
};
|
|
|
|
ops.unshift({ insert: right });
|
|
ops.unshift({ insert: { mention } });
|
|
ops.unshift({ insert: left });
|
|
} else {
|
|
ops.unshift(op);
|
|
}
|
|
}
|
|
});
|
|
|
|
return ops;
|
|
};
|
|
|
|
export const insertEmojiOps = (incomingOps: Array<Op>): Array<Op> => {
|
|
return incomingOps.reduce((ops, op) => {
|
|
if (typeof op.insert === 'string') {
|
|
const text = op.insert;
|
|
const re = emojiRegex();
|
|
let index = 0;
|
|
let match: RegExpExecArray | null;
|
|
|
|
// eslint-disable-next-line no-cond-assign
|
|
while ((match = re.exec(text))) {
|
|
const [emoji] = match;
|
|
ops.push({ insert: text.slice(index, match.index) });
|
|
ops.push({ insert: { emoji } });
|
|
index = match.index + emoji.length;
|
|
}
|
|
|
|
ops.push({ insert: text.slice(index, text.length) });
|
|
} else {
|
|
ops.push(op);
|
|
}
|
|
|
|
return ops;
|
|
}, [] as Array<Op>);
|
|
};
|