feat: Add @mention support to comments (#5001)

* Refactor, remove confusing 'packages' language

* Basic notifications when mentioned in comment

* fix: Incorrect trimming of comments

* test
This commit is contained in:
Tom Moor
2023-03-06 22:19:49 -05:00
committed by GitHub
parent 28c4854985
commit d3b099819d
18 changed files with 301 additions and 220 deletions

View File

@@ -24,9 +24,9 @@ import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import { MarkdownSerializer } from "@shared/editor/lib/markdown/serializer";
import textBetween from "@shared/editor/lib/textBetween";
import Mark from "@shared/editor/marks/Mark";
import { richExtensions, withComments } from "@shared/editor/nodes";
import Node from "@shared/editor/nodes/Node";
import ReactNode from "@shared/editor/nodes/ReactNode";
import fullExtensionsPackage from "@shared/editor/packages/full";
import { EventType } from "@shared/editor/types";
import { UserPreferences } from "@shared/types";
import ProsemirrorHelper from "@shared/utils/ProsemirrorHelper";
@@ -45,6 +45,8 @@ import MentionMenu from "./components/MentionMenu";
import SelectionToolbar from "./components/SelectionToolbar";
import WithTheme from "./components/WithTheme";
const extensions = withComments(richExtensions);
export { default as Extension } from "@shared/editor/lib/Extension";
export type Props = {
@@ -167,7 +169,7 @@ export class Editor extends React.PureComponent<
// no default behavior
},
embeds: [],
extensions: fullExtensionsPackage,
extensions,
};
state = {

View File

@@ -1,8 +1,10 @@
import * as React from "react";
import extensions from "@shared/editor/packages/basic";
import { basicExtensions, withComments } from "@shared/editor/nodes";
import Editor, { Props as EditorProps } from "~/components/Editor";
import type { Editor as SharedEditor } from "~/editor";
const extensions = withComments(basicExtensions);
const CommentEditor = (
props: EditorProps,
ref: React.RefObject<SharedEditor>

View File

@@ -3,7 +3,7 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { mergeRefs } from "react-merge-refs";
import { useHistory, useRouteMatch } from "react-router-dom";
import fullWithCommentsPackage from "@shared/editor/packages/fullWithComments";
import { richExtensions, withComments } from "@shared/editor/nodes";
import { TeamPreference } from "@shared/types";
import Comment from "~/models/Comment";
import Document from "~/models/Document";
@@ -22,6 +22,8 @@ import MultiplayerEditor from "./AsyncMultiplayerEditor";
import DocumentMeta from "./DocumentMeta";
import EditableTitle from "./EditableTitle";
const extensions = withComments(richExtensions);
type Props = Omit<EditorProps, "extensions"> & {
onChangeTitle: (text: string) => void;
id: string;
@@ -185,7 +187,7 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
? handleRemoveComment
: undefined
}
extensions={fullWithCommentsPackage}
extensions={extensions}
bottomPadding={`calc(50vh - ${childRef.current?.offsetHeight || 0}px)`}
{...rest}
/>

View File

@@ -8,7 +8,7 @@ rm -rf ./build/plugins
yarn concurrently "yarn babel --extensions .ts,.tsx --quiet -d ./build/server ./server" \
"yarn babel --extensions .ts,.tsx --quiet -d ./build/shared ./shared"
# Compile code in packages
# Compile code in plugins
for d in ./plugins/*; do
# Get the name of the folder
package=$(basename "$d")

View File

@@ -1,17 +1,18 @@
import { Schema } from "prosemirror-model";
import ExtensionManager from "@shared/editor/lib/ExtensionManager";
import extensionsPackage from "@shared/editor/packages/fullWithComments";
import { richExtensions, withComments } from "@shared/editor/nodes";
const extensions = new ExtensionManager(extensionsPackage);
const extensions = withComments(richExtensions);
const extensionManager = new ExtensionManager(extensions);
export const schema = new Schema({
nodes: extensions.nodes,
marks: extensions.marks,
nodes: extensionManager.nodes,
marks: extensionManager.marks,
});
export const parser = extensions.parser({
export const parser = extensionManager.parser({
schema,
plugins: extensions.rulePlugins,
plugins: extensionManager.rulePlugins,
});
export const serializer = extensions.serializer();
export const serializer = extensionManager.serializer();

View File

@@ -1,7 +1,38 @@
import Revision from "@server/models/Revision";
import { buildDocument } from "@server/test/factories";
import DocumentHelper from "./DocumentHelper";
describe("DocumentHelper", () => {
describe("parseMentions", () => {
it("should not parse normal links as mentions", async () => {
const document = await buildDocument({
text: `# Header
[link not mention](http://google.com)`,
});
const result = DocumentHelper.parseMentions(document);
expect(result.length).toBe(0);
});
it("should return an array of mentions", async () => {
const document = await buildDocument({
text: `# Header
@[Alan Kay](mention://2767ba0e-ac5c-4533-b9cf-4f5fc456600e/user/34095ac1-c808-45c0-8c6e-6c554497de64) :wink:
More text
@[Bret Victor](mention://34095ac1-c808-45c0-8c6e-6c554497de64/user/2767ba0e-ac5c-4533-b9cf-4f5fc456600e) :fire:`,
});
const result = DocumentHelper.parseMentions(document);
expect(result.length).toBe(2);
expect(result[0].id).toBe("2767ba0e-ac5c-4533-b9cf-4f5fc456600e");
expect(result[1].id).toBe("34095ac1-c808-45c0-8c6e-6c554497de64");
expect(result[0].modelId).toBe("34095ac1-c808-45c0-8c6e-6c554497de64");
expect(result[1].modelId).toBe("2767ba0e-ac5c-4533-b9cf-4f5fc456600e");
});
});
describe("toEmailDiff", () => {
it("should render a compact diff", async () => {
const before = new Revision({

View File

@@ -119,6 +119,17 @@ export default class DocumentHelper {
return output;
}
/**
* Parse a list of mentions contained in a document or revision
*
* @param document Document or Revision
* @returns An array of mentions in passed document or revision
*/
static parseMentions(document: Document | Revision) {
const node = DocumentHelper.toProsemirror(document);
return ProsemirrorHelper.parseMentions(node);
}
/**
* Generates a HTML diff between documents or revisions.
*

View File

@@ -20,8 +20,55 @@ export type HTMLOptions = {
centered?: boolean;
};
type MentionAttrs = {
type: string;
label: string;
modelId: string;
actorId: string | undefined;
id: string;
};
@trace()
export default class ProsemirrorHelper {
/**
* Returns the data as a Prosemirror Node.
*
* @param node The node to parse
* @returns The content as a Prosemirror Node
*/
static toProsemirror(data: Record<string, any>) {
return Node.fromJSON(schema, data);
}
/**
* Returns an array of attributes of all mentions in the node.
*
* @param node The node to parse mentions from
* @returns An array of mention attributes
*/
static parseMentions(node: Node) {
const mentions: MentionAttrs[] = [];
function findMentions(node: Node) {
if (
node.type.name === "mention" &&
!mentions.some((m) => m.id === node.attrs.id)
) {
mentions.push(node.attrs as MentionAttrs);
}
if (!node.content.size) {
return;
}
node.content.descendants(findMentions);
}
findMentions(node);
return mentions;
}
/**
* Returns the node as HTML. This is a lossy conversion and should only be used
* for export.

View File

@@ -27,7 +27,6 @@ import {
DocumentEvent,
CommentEvent,
} from "@server/types";
import parseMentions from "@server/utils/parseMentions";
import CommentCreatedNotificationTask from "../tasks/CommentCreatedNotificationTask";
import BaseProcessor from "./BaseProcessor";
@@ -82,7 +81,7 @@ export default class NotificationsProcessor extends BaseProcessor {
await this.createDocumentSubscriptions(document, event);
// Send notifications to mentioned users first
const mentions = parseMentions(document);
const mentions = DocumentHelper.parseMentions(document);
const userIdsSentNotifications: string[] = [];
for (const mention of mentions) {
@@ -166,8 +165,8 @@ export default class NotificationsProcessor extends BaseProcessor {
// Send notifications to mentioned users first
const prev = await revision.previous();
const oldMentions = prev ? parseMentions(prev) : [];
const newMentions = parseMentions(document);
const oldMentions = prev ? DocumentHelper.parseMentions(prev) : [];
const newMentions = DocumentHelper.parseMentions(document);
const mentions = differenceBy(newMentions, oldMentions, "id");
const userIdsSentNotifications: string[] = [];

View File

@@ -3,7 +3,7 @@ import subscriptionCreator from "@server/commands/subscriptionCreator";
import { sequelize } from "@server/database/sequelize";
import { schema } from "@server/editor";
import CommentCreatedEmail from "@server/emails/templates/CommentCreatedEmail";
import { Comment, Document, Notification, Team } from "@server/models";
import { Comment, Document, Notification, Team, User } from "@server/models";
import DocumentHelper from "@server/models/helpers/DocumentHelper";
import NotificationHelper from "@server/models/helpers/NotificationHelper";
import ProsemirrorHelper from "@server/models/helpers/ProsemirrorHelper";
@@ -40,15 +40,6 @@ export default class CommentCreatedNotificationTask extends BaseTask<
});
});
const recipients = await NotificationHelper.getCommentNotificationRecipients(
document,
comment,
comment.createdById
);
if (!recipients.length) {
return;
}
let content = ProsemirrorHelper.toHTML(
Node.fromJSON(schema, comment.data),
{
@@ -65,6 +56,54 @@ export default class CommentCreatedNotificationTask extends BaseTask<
86400 * 4
);
const mentions = ProsemirrorHelper.parseMentions(
ProsemirrorHelper.toProsemirror(comment.data)
);
const userIdsSentNotifications: string[] = [];
for (const mention of mentions) {
const [recipient, actor] = await Promise.all([
User.findByPk(mention.modelId),
User.findByPk(mention.actorId),
]);
if (recipient && actor && recipient.id !== actor.id) {
const notification = await Notification.create({
event: event.name,
userId: recipient.id,
actorId: actor.id,
teamId: team.id,
documentId: document.id,
});
userIdsSentNotifications.push(recipient.id);
await CommentCreatedEmail.schedule(
{
to: recipient.email,
documentId: document.id,
teamUrl: team.url,
isReply: !!comment.parentCommentId,
actorName: comment.createdBy.name,
commentId: comment.id,
content,
collectionName: document.collection?.name,
},
{ notificationId: notification.id }
);
}
}
const recipients = (
await NotificationHelper.getCommentNotificationRecipients(
document,
comment,
comment.createdById
)
).filter(
(recipient) => !userIdsSentNotifications.includes(recipient.userId)
);
if (!recipients.length) {
return;
}
for (const recipient of recipients) {
const notification = await Notification.create({
event: event.name,

View File

@@ -1,30 +0,0 @@
import { buildDocument } from "@server/test/factories";
import parseMentions from "./parseMentions";
it("should not parse normal links as mentions", async () => {
const document = await buildDocument({
text: `# Header
[link not mention](http://google.com)`,
});
const result = parseMentions(document);
expect(result.length).toBe(0);
});
it("should return an array of mentions", async () => {
const document = await buildDocument({
text: `# Header
@[Alan Kay](mention://2767ba0e-ac5c-4533-b9cf-4f5fc456600e/user/34095ac1-c808-45c0-8c6e-6c554497de64) :wink:
More text
@[Bret Victor](mention://34095ac1-c808-45c0-8c6e-6c554497de64/user/2767ba0e-ac5c-4533-b9cf-4f5fc456600e) :fire:`,
});
const result = parseMentions(document);
expect(result.length).toBe(2);
expect(result[0].id).toBe("2767ba0e-ac5c-4533-b9cf-4f5fc456600e");
expect(result[1].id).toBe("34095ac1-c808-45c0-8c6e-6c554497de64");
expect(result[0].modelId).toBe("34095ac1-c808-45c0-8c6e-6c554497de64");
expect(result[1].modelId).toBe("2767ba0e-ac5c-4533-b9cf-4f5fc456600e");
});

View File

@@ -1,35 +0,0 @@
import { Node } from "prosemirror-model";
import { Document, Revision } from "@server/models";
import DocumentHelper from "@server/models/helpers/DocumentHelper";
/**
* Parse a list of mentions contained in a document or revision
*
* @param document Document or Revision
* @returns An array of mentions in passed document or revision
*/
export default function parseMentions(
document: Document | Revision
): Record<string, string>[] {
const node = DocumentHelper.toProsemirror(document);
const mentions: Record<string, string>[] = [];
function findMentions(node: Node) {
if (
node.type.name === "mention" &&
!mentions.some((m) => m.id === node.attrs.id)
) {
mentions.push(node.attrs);
}
if (!node.content.size) {
return;
}
node.content.descendants(findMentions);
}
findMentions(node);
return mentions;
}

View File

@@ -0,0 +1,117 @@
import Extension from "../lib/Extension";
import Bold from "../marks/Bold";
import Code from "../marks/Code";
import Comment from "../marks/Comment";
import Highlight from "../marks/Highlight";
import Italic from "../marks/Italic";
import Link from "../marks/Link";
import Mark from "../marks/Mark";
import TemplatePlaceholder from "../marks/Placeholder";
import Strikethrough from "../marks/Strikethrough";
import Underline from "../marks/Underline";
import BlockMenuTrigger from "../plugins/BlockMenuTrigger";
import ClipboardTextSerializer from "../plugins/ClipboardTextSerializer";
import DateTime from "../plugins/DateTime";
import Folding from "../plugins/Folding";
import History from "../plugins/History";
import Keys from "../plugins/Keys";
import MaxLength from "../plugins/MaxLength";
import PasteHandler from "../plugins/PasteHandler";
import Placeholder from "../plugins/Placeholder";
import PreventTab from "../plugins/PreventTab";
import SmartText from "../plugins/SmartText";
import TrailingNode from "../plugins/TrailingNode";
import Attachment from "./Attachment";
import Blockquote from "./Blockquote";
import BulletList from "./BulletList";
import CheckboxItem from "./CheckboxItem";
import CheckboxList from "./CheckboxList";
import CodeBlock from "./CodeBlock";
import CodeFence from "./CodeFence";
import Doc from "./Doc";
import Embed from "./Embed";
import Emoji from "./Emoji";
import HardBreak from "./HardBreak";
import Heading from "./Heading";
import HorizontalRule from "./HorizontalRule";
import Image from "./Image";
import ListItem from "./ListItem";
import Math from "./Math";
import MathBlock from "./MathBlock";
import Mention from "./Mention";
import Node from "./Node";
import Notice from "./Notice";
import OrderedList from "./OrderedList";
import Paragraph from "./Paragraph";
import Table from "./Table";
import TableCell from "./TableCell";
import TableHeadCell from "./TableHeadCell";
import TableRow from "./TableRow";
import Text from "./Text";
type Nodes = (typeof Node | typeof Mark | typeof Extension)[];
/**
* The basic set of nodes that are used in the editor. This is used for simple
* editors that need basic formatting.
*/
export const basicExtensions: Nodes = [
Doc,
Paragraph,
Emoji,
Text,
Image,
Bold,
Code,
Italic,
Underline,
Link,
Strikethrough,
History,
SmartText,
TrailingNode,
PasteHandler,
Placeholder,
MaxLength,
DateTime,
Keys,
ClipboardTextSerializer,
];
/**
* The full set of nodes that are used in the editor. This is used for rich
* editors that need advanced formatting.
*/
export const richExtensions: Nodes = [
...basicExtensions,
HardBreak,
CodeBlock,
CodeFence,
CheckboxList,
CheckboxItem,
Blockquote,
BulletList,
OrderedList,
Embed,
ListItem,
Attachment,
Notice,
Heading,
HorizontalRule,
Table,
TableCell,
TableHeadCell,
TableRow,
Highlight,
TemplatePlaceholder,
Folding,
BlockMenuTrigger,
Math,
MathBlock,
PreventTab,
];
/**
* Add commenting and mentions to a set of nodes
*/
export const withComments = (nodes: Nodes) => [...nodes, Mention, Comment];

View File

@@ -1,2 +0,0 @@
Packages are preselected collections of extensions that form the different types
of editors within Outline.

View File

@@ -1,50 +0,0 @@
import Extension from "../lib/Extension";
import Bold from "../marks/Bold";
import Code from "../marks/Code";
import Italic from "../marks/Italic";
import Link from "../marks/Link";
import Mark from "../marks/Mark";
import Strikethrough from "../marks/Strikethrough";
import Underline from "../marks/Underline";
import Doc from "../nodes/Doc";
import Emoji from "../nodes/Emoji";
import Image from "../nodes/Image";
import Mention from "../nodes/Mention";
import Node from "../nodes/Node";
import Paragraph from "../nodes/Paragraph";
import Text from "../nodes/Text";
import ClipboardTextSerializer from "../plugins/ClipboardTextSerializer";
import DateTime from "../plugins/DateTime";
import History from "../plugins/History";
import Keys from "../plugins/Keys";
import MaxLength from "../plugins/MaxLength";
import PasteHandler from "../plugins/PasteHandler";
import Placeholder from "../plugins/Placeholder";
import SmartText from "../plugins/SmartText";
import TrailingNode from "../plugins/TrailingNode";
const basicPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
Doc,
Paragraph,
Emoji,
Text,
Image,
Bold,
Code,
Italic,
Underline,
Link,
Strikethrough,
History,
SmartText,
TrailingNode,
PasteHandler,
Placeholder,
MaxLength,
DateTime,
Keys,
ClipboardTextSerializer,
Mention,
];
export default basicPackage;

View File

@@ -1,60 +0,0 @@
import Extension from "../lib/Extension";
import Highlight from "../marks/Highlight";
import Mark from "../marks/Mark";
import TemplatePlaceholder from "../marks/Placeholder";
import Attachment from "../nodes/Attachment";
import Blockquote from "../nodes/Blockquote";
import BulletList from "../nodes/BulletList";
import CheckboxItem from "../nodes/CheckboxItem";
import CheckboxList from "../nodes/CheckboxList";
import CodeBlock from "../nodes/CodeBlock";
import CodeFence from "../nodes/CodeFence";
import Embed from "../nodes/Embed";
import HardBreak from "../nodes/HardBreak";
import Heading from "../nodes/Heading";
import HorizontalRule from "../nodes/HorizontalRule";
import ListItem from "../nodes/ListItem";
import Math from "../nodes/Math";
import MathBlock from "../nodes/MathBlock";
import Node from "../nodes/Node";
import Notice from "../nodes/Notice";
import OrderedList from "../nodes/OrderedList";
import Table from "../nodes/Table";
import TableCell from "../nodes/TableCell";
import TableHeadCell from "../nodes/TableHeadCell";
import TableRow from "../nodes/TableRow";
import BlockMenuTrigger from "../plugins/BlockMenuTrigger";
import Folding from "../plugins/Folding";
import PreventTab from "../plugins/PreventTab";
import basicPackage from "./basic";
const fullPackage: (typeof Node | typeof Mark | typeof Extension)[] = [
...basicPackage,
HardBreak,
CodeBlock,
CodeFence,
CheckboxList,
CheckboxItem,
Blockquote,
BulletList,
OrderedList,
Embed,
ListItem,
Attachment,
Notice,
Heading,
HorizontalRule,
Table,
TableCell,
TableHeadCell,
TableRow,
Highlight,
TemplatePlaceholder,
Folding,
BlockMenuTrigger,
Math,
MathBlock,
PreventTab,
];
export default fullPackage;

View File

@@ -1,13 +0,0 @@
import Extension from "../lib/Extension";
import Comment from "../marks/Comment";
import Mark from "../marks/Mark";
import Node from "../nodes/Node";
import fullPackage from "./full";
const fullWithCommentsPackage: (
| typeof Node
| typeof Mark
| typeof Extension
)[] = [...fullPackage, Comment];
export default fullWithCommentsPackage;

View File

@@ -1,4 +1,5 @@
import { Node } from "prosemirror-model";
import { Node, Schema } from "prosemirror-model";
import textBetween from "@shared/editor/lib/textBetween";
import headingToSlug from "../editor/lib/headingToSlug";
export type Heading = {
@@ -25,6 +26,23 @@ export type Task = {
};
export default class ProsemirrorHelper {
/**
* Returns the node as plain text.
*
* @param node The node to convert.
* @param schema The schema to use.
* @returns The document content as plain text without formatting.
*/
static toPlainText(node: Node, schema: Schema) {
const textSerializers = Object.fromEntries(
Object.entries(schema.nodes)
.filter(([, node]) => node.spec.toPlainText)
.map(([name, node]) => [name, node.spec.toPlainText])
);
return textBetween(node, 0, node.content.size, textSerializers);
}
/**
* Removes any empty paragraphs from the beginning and end of the document.
*
@@ -34,9 +52,11 @@ export default class ProsemirrorHelper {
const first = doc.firstChild;
const last = doc.lastChild;
const firstIsEmpty =
first?.type.name === "paragraph" && !first.textContent.trim();
first &&
ProsemirrorHelper.toPlainText(first, doc.type.schema).trim() === "";
const lastIsEmpty =
last?.type.name === "paragraph" && !last.textContent.trim();
last &&
ProsemirrorHelper.toPlainText(last, doc.type.schema).trim() === "";
const firstIsLast = first === last;
return doc.cut(