Composition area: Only paste HTML that originated in Signal
Co-authored-by: Chris Svenningsen <chris@carbonfive.com>
This commit is contained in:
parent
98da8746e8
commit
7af2284c6b
6 changed files with 148 additions and 77 deletions
|
@ -6,10 +6,8 @@ import * as React from 'react';
|
||||||
import Delta from 'quill-delta';
|
import Delta from 'quill-delta';
|
||||||
import ReactQuill from 'react-quill';
|
import ReactQuill from 'react-quill';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import emojiRegex from 'emoji-regex';
|
|
||||||
import { Manager, Reference } from 'react-popper';
|
import { Manager, Reference } from 'react-popper';
|
||||||
import Quill, { KeyboardStatic, RangeStatic } from 'quill';
|
import Quill, { KeyboardStatic, RangeStatic } from 'quill';
|
||||||
import Op from 'quill-delta/dist/Op';
|
|
||||||
|
|
||||||
import { MentionCompletion } from '../quill/mentions/completion';
|
import { MentionCompletion } from '../quill/mentions/completion';
|
||||||
import { EmojiBlot, EmojiCompletion } from '../quill/emoji';
|
import { EmojiBlot, EmojiCompletion } from '../quill/emoji';
|
||||||
|
@ -22,6 +20,7 @@ import {
|
||||||
matchEmojiImage,
|
matchEmojiImage,
|
||||||
matchEmojiBlot,
|
matchEmojiBlot,
|
||||||
matchReactEmoji,
|
matchReactEmoji,
|
||||||
|
matchEmojiText,
|
||||||
} from '../quill/emoji/matchers';
|
} from '../quill/emoji/matchers';
|
||||||
import { matchMention } from '../quill/mentions/matchers';
|
import { matchMention } from '../quill/mentions/matchers';
|
||||||
import { MemberRepository } from '../quill/memberRepository';
|
import { MemberRepository } from '../quill/memberRepository';
|
||||||
|
@ -30,6 +29,8 @@ import {
|
||||||
getTextAndMentionsFromOps,
|
getTextAndMentionsFromOps,
|
||||||
isMentionBlot,
|
isMentionBlot,
|
||||||
getDeltaToRestartMention,
|
getDeltaToRestartMention,
|
||||||
|
insertMentionOps,
|
||||||
|
insertEmojiOps,
|
||||||
} from '../quill/util';
|
} from '../quill/util';
|
||||||
import { SignalClipboard } from '../quill/signal-clipboard';
|
import { SignalClipboard } from '../quill/signal-clipboard';
|
||||||
|
|
||||||
|
@ -117,70 +118,6 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
||||||
new MemberRepository()
|
new MemberRepository()
|
||||||
);
|
);
|
||||||
|
|
||||||
const insertMentionOps = (
|
|
||||||
incomingOps: Array<Op>,
|
|
||||||
bodyRanges: Array<BodyRangeType>
|
|
||||||
) => {
|
|
||||||
const ops = [...incomingOps];
|
|
||||||
|
|
||||||
// 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
|
|
||||||
bodyRanges
|
|
||||||
.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;
|
|
||||||
};
|
|
||||||
|
|
||||||
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>);
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateDelta = (
|
const generateDelta = (
|
||||||
text: string,
|
text: string,
|
||||||
bodyRanges: Array<BodyRangeType>
|
bodyRanges: Array<BodyRangeType>
|
||||||
|
@ -258,7 +195,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
||||||
|
|
||||||
quill.setText('');
|
quill.setText('');
|
||||||
|
|
||||||
const historyModule: HistoryStatic = quill.getModule('history');
|
const historyModule = quill.getModule('history');
|
||||||
|
|
||||||
if (historyModule === undefined) {
|
if (historyModule === undefined) {
|
||||||
return;
|
return;
|
||||||
|
@ -544,6 +481,7 @@ export const CompositionInput: React.ComponentType<Props> = props => {
|
||||||
['IMG', matchEmojiImage],
|
['IMG', matchEmojiImage],
|
||||||
['IMG', matchEmojiBlot],
|
['IMG', matchEmojiBlot],
|
||||||
['SPAN', matchReactEmoji],
|
['SPAN', matchReactEmoji],
|
||||||
|
[Node.TEXT_NODE, matchEmojiText],
|
||||||
['SPAN', matchMention(memberRepositoryRef)],
|
['SPAN', matchMention(memberRepositoryRef)],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import Delta from 'quill-delta';
|
import Delta from 'quill-delta';
|
||||||
|
import { insertEmojiOps } from '../util';
|
||||||
|
|
||||||
export const matchEmojiImage = (node: Element): Delta => {
|
export const matchEmojiImage = (node: Element): Delta => {
|
||||||
if (node.classList.contains('emoji')) {
|
if (node.classList.contains('emoji')) {
|
||||||
|
@ -26,3 +27,9 @@ export const matchReactEmoji = (node: HTMLElement, delta: Delta): Delta => {
|
||||||
}
|
}
|
||||||
return delta;
|
return delta;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const matchEmojiText = (node: Text): Delta => {
|
||||||
|
const nodeAsInsert = { insert: node.data };
|
||||||
|
|
||||||
|
return new Delta(insertEmojiOps([nodeAsInsert]));
|
||||||
|
};
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import Quill from 'quill';
|
import Quill from 'quill';
|
||||||
|
import Delta from 'quill-delta';
|
||||||
|
|
||||||
import { getTextFromOps } from '../util';
|
import { getTextFromOps } from '../util';
|
||||||
|
|
||||||
const getSelectionHTML = () => {
|
const getSelectionHTML = () => {
|
||||||
|
@ -28,6 +30,7 @@ export class SignalClipboard {
|
||||||
|
|
||||||
this.quill.root.addEventListener('copy', e => this.onCaptureCopy(e, false));
|
this.quill.root.addEventListener('copy', e => this.onCaptureCopy(e, false));
|
||||||
this.quill.root.addEventListener('cut', e => this.onCaptureCopy(e, true));
|
this.quill.root.addEventListener('cut', e => this.onCaptureCopy(e, true));
|
||||||
|
this.quill.root.addEventListener('paste', e => this.onCapturePaste(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
onCaptureCopy(event: ClipboardEvent, isCut = false): void {
|
onCaptureCopy(event: ClipboardEvent, isCut = false): void {
|
||||||
|
@ -59,10 +62,45 @@ export class SignalClipboard {
|
||||||
const html = getSelectionHTML();
|
const html = getSelectionHTML();
|
||||||
|
|
||||||
event.clipboardData.setData('text/plain', text);
|
event.clipboardData.setData('text/plain', text);
|
||||||
event.clipboardData.setData('text/html', html);
|
event.clipboardData.setData('text/signal', html);
|
||||||
|
|
||||||
if (isCut) {
|
if (isCut) {
|
||||||
this.quill.deleteText(range.index, range.length, 'user');
|
this.quill.deleteText(range.index, range.length, 'user');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCapturePaste(event: ClipboardEvent): void {
|
||||||
|
if (event.clipboardData === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.quill.focus();
|
||||||
|
|
||||||
|
const clipboard = this.quill.getModule('clipboard');
|
||||||
|
const selection = this.quill.getSelection();
|
||||||
|
|
||||||
|
if (selection === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = event.clipboardData.getData('text/plain');
|
||||||
|
const html = event.clipboardData.getData('text/signal');
|
||||||
|
|
||||||
|
const { scrollTop } = this.quill.scrollingContainer;
|
||||||
|
|
||||||
|
this.quill.selection.update('silent');
|
||||||
|
|
||||||
|
if (selection) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const delta = new Delta()
|
||||||
|
.retain(selection.index)
|
||||||
|
.concat(clipboard.convert(html || text));
|
||||||
|
this.quill.updateContents(delta, 'user');
|
||||||
|
this.quill.setSelection(delta.length(), 0, 'silent');
|
||||||
|
this.quill.scrollingContainer.scrollTop = scrollTop;
|
||||||
|
}, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
23
ts/quill/types.d.ts
vendored
23
ts/quill/types.d.ts
vendored
|
@ -2,6 +2,8 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import UpdatedDelta from 'quill-delta';
|
import UpdatedDelta from 'quill-delta';
|
||||||
|
import { MentionCompletion } from './mentions/completion';
|
||||||
|
import { EmojiCompletion } from './emoji/completion';
|
||||||
|
|
||||||
declare module 'react-quill' {
|
declare module 'react-quill' {
|
||||||
// `react-quill` uses a different but compatible version of Delta
|
// `react-quill` uses a different but compatible version of Delta
|
||||||
|
@ -30,6 +32,19 @@ declare module 'quill' {
|
||||||
value(): any;
|
value(): any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface HistoryStatic {
|
||||||
|
undo(): void;
|
||||||
|
clear(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClipboardStatic {
|
||||||
|
convert(html: string): UpdatedDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectionStatic {
|
||||||
|
update(source: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
interface Quill {
|
interface Quill {
|
||||||
updateContents(delta: UpdatedDelta, source?: Sources): UpdatedDelta;
|
updateContents(delta: UpdatedDelta, source?: Sources): UpdatedDelta;
|
||||||
getContents(index?: number, length?: number): UpdatedDelta;
|
getContents(index?: number, length?: number): UpdatedDelta;
|
||||||
|
@ -41,6 +56,14 @@ declare module 'quill' {
|
||||||
eventName: 'text-change',
|
eventName: 'text-change',
|
||||||
handler: UpdatedTextChangeHandler
|
handler: UpdatedTextChangeHandler
|
||||||
): EventEmitter;
|
): EventEmitter;
|
||||||
|
|
||||||
|
getModule(module: 'history'): HistoryStatic;
|
||||||
|
getModule(module: 'clipboard'): ClipboardStatic;
|
||||||
|
getModule(module: 'mentionCompletion'): MentionCompletion;
|
||||||
|
getModule(module: 'emojiCompletion'): EmojiCompletion;
|
||||||
|
getModule(module: string): unknown;
|
||||||
|
|
||||||
|
selection: SelectionStatic;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface KeyboardStatic {
|
interface KeyboardStatic {
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
// Copyright 2020 Signal Messenger, LLC
|
// Copyright 2020 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import emojiRegex from 'emoji-regex';
|
||||||
import Delta from 'quill-delta';
|
import Delta from 'quill-delta';
|
||||||
import { LeafBlot, DeltaOperation } from 'quill';
|
import { LeafBlot, DeltaOperation } from 'quill';
|
||||||
import Op from 'quill-delta/dist/Op';
|
import Op from 'quill-delta/dist/Op';
|
||||||
|
@ -195,3 +196,67 @@ export const getDeltaToRemoveStaleMentions = (
|
||||||
|
|
||||||
return new Delta(newOps);
|
return new Delta(newOps);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const insertMentionOps = (
|
||||||
|
incomingOps: Array<Op>,
|
||||||
|
bodyRanges: Array<BodyRangeType>
|
||||||
|
): Array<Op> => {
|
||||||
|
const ops = [...incomingOps];
|
||||||
|
|
||||||
|
// 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
|
||||||
|
bodyRanges
|
||||||
|
.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>);
|
||||||
|
};
|
||||||
|
|
|
@ -14544,7 +14544,7 @@
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CompositionInput.js",
|
"path": "ts/components/CompositionInput.js",
|
||||||
"line": " const emojiCompletionRef = React.useRef();",
|
"line": " const emojiCompletionRef = React.useRef();",
|
||||||
"lineNumber": 45,
|
"lineNumber": 44,
|
||||||
"reasonCategory": "falseMatch",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2020-10-26T19:12:24.410Z",
|
"updated": "2020-10-26T19:12:24.410Z",
|
||||||
"reasonDetail": "Doesn't refer to a DOM element."
|
"reasonDetail": "Doesn't refer to a DOM element."
|
||||||
|
@ -14553,7 +14553,7 @@
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CompositionInput.js",
|
"path": "ts/components/CompositionInput.js",
|
||||||
"line": " const mentionCompletionRef = React.useRef();",
|
"line": " const mentionCompletionRef = React.useRef();",
|
||||||
"lineNumber": 46,
|
"lineNumber": 45,
|
||||||
"reasonCategory": "falseMatch",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2020-10-26T23:54:34.273Z",
|
"updated": "2020-10-26T23:54:34.273Z",
|
||||||
"reasonDetail": "Doesn't refer to a DOM element."
|
"reasonDetail": "Doesn't refer to a DOM element."
|
||||||
|
@ -14562,7 +14562,7 @@
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CompositionInput.js",
|
"path": "ts/components/CompositionInput.js",
|
||||||
"line": " const quillRef = React.useRef();",
|
"line": " const quillRef = React.useRef();",
|
||||||
"lineNumber": 47,
|
"lineNumber": 46,
|
||||||
"reasonCategory": "falseMatch",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2020-10-26T19:12:24.410Z",
|
"updated": "2020-10-26T19:12:24.410Z",
|
||||||
"reasonDetail": "Doesn't refer to a DOM element."
|
"reasonDetail": "Doesn't refer to a DOM element."
|
||||||
|
@ -14571,7 +14571,7 @@
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CompositionInput.js",
|
"path": "ts/components/CompositionInput.js",
|
||||||
"line": " const scrollerRef = React.useRef(null);",
|
"line": " const scrollerRef = React.useRef(null);",
|
||||||
"lineNumber": 48,
|
"lineNumber": 47,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2020-10-26T19:12:24.410Z",
|
"updated": "2020-10-26T19:12:24.410Z",
|
||||||
"reasonDetail": "Used with Quill for scrolling."
|
"reasonDetail": "Used with Quill for scrolling."
|
||||||
|
@ -14580,7 +14580,7 @@
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CompositionInput.js",
|
"path": "ts/components/CompositionInput.js",
|
||||||
"line": " const propsRef = React.useRef(props);",
|
"line": " const propsRef = React.useRef(props);",
|
||||||
"lineNumber": 49,
|
"lineNumber": 48,
|
||||||
"reasonCategory": "falseMatch",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2020-10-26T19:12:24.410Z",
|
"updated": "2020-10-26T19:12:24.410Z",
|
||||||
"reasonDetail": "Doesn't refer to a DOM element."
|
"reasonDetail": "Doesn't refer to a DOM element."
|
||||||
|
@ -14589,7 +14589,7 @@
|
||||||
"rule": "React-useRef",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/CompositionInput.js",
|
"path": "ts/components/CompositionInput.js",
|
||||||
"line": " const memberRepositoryRef = React.useRef(new memberRepository_1.MemberRepository());",
|
"line": " const memberRepositoryRef = React.useRef(new memberRepository_1.MemberRepository());",
|
||||||
"lineNumber": 50,
|
"lineNumber": 49,
|
||||||
"reasonCategory": "falseMatch",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2020-10-26T23:56:13.482Z",
|
"updated": "2020-10-26T23:56:13.482Z",
|
||||||
"reasonDetail": "Doesn't refer to a DOM element."
|
"reasonDetail": "Doesn't refer to a DOM element."
|
||||||
|
@ -14930,7 +14930,7 @@
|
||||||
"rule": "DOM-innerHTML",
|
"rule": "DOM-innerHTML",
|
||||||
"path": "ts/quill/signal-clipboard/index.js",
|
"path": "ts/quill/signal-clipboard/index.js",
|
||||||
"line": " return div.innerHTML;",
|
"line": " return div.innerHTML;",
|
||||||
"lineNumber": 15,
|
"lineNumber": 19,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2020-11-06T17:43:07.381Z",
|
"updated": "2020-11-06T17:43:07.381Z",
|
||||||
"reasonDetail": "used for figuring out clipboard contents"
|
"reasonDetail": "used for figuring out clipboard contents"
|
||||||
|
@ -14939,7 +14939,7 @@
|
||||||
"rule": "DOM-innerHTML",
|
"rule": "DOM-innerHTML",
|
||||||
"path": "ts/quill/signal-clipboard/index.ts",
|
"path": "ts/quill/signal-clipboard/index.ts",
|
||||||
"line": " return div.innerHTML;",
|
"line": " return div.innerHTML;",
|
||||||
"lineNumber": 20,
|
"lineNumber": 22,
|
||||||
"reasonCategory": "usageTrusted",
|
"reasonCategory": "usageTrusted",
|
||||||
"updated": "2020-11-06T17:43:07.381Z",
|
"updated": "2020-11-06T17:43:07.381Z",
|
||||||
"reasonDetail": "used for figuring out clipboard contents"
|
"reasonDetail": "used for figuring out clipboard contents"
|
||||||
|
@ -15176,4 +15176,4 @@
|
||||||
"reasonCategory": "falseMatch",
|
"reasonCategory": "falseMatch",
|
||||||
"updated": "2020-09-08T23:07:22.682Z"
|
"updated": "2020-09-08T23:07:22.682Z"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue