Capability to mention users in a document (#4838)

* feat: mention user

* fix: trigger api call on every letter typed

* fix: this allows command menu to re-render upon props change, shouldComponentUpdate prevented re-rendering when necessary

* fix: add node

* fix: mention node styling

* fix: Caret not visible after inserting mention

* fix: apply mentionRule

* fix: label is to be obtained from content, not attrs

* feat: add mentions table and model

* fix: typo

* fix: make all mention nodes visible in shared doc

* feat: parse mention ids from doc text

* feat: MentionsProcessor

* feat: documents.publish tests

* feat: tests for MentionsProcessor

* feat: schedule notifs for mentions

* fix: get rid of Mention model

* fix: put actor id and mention id in raw md

* Revert "fix: put actor id and mention id in raw md"

This reverts commit 3bb8a22e3c560971dccad6d2f82266256bcb2d96.

* Revert "Revert "fix: put actor id and mention id in raw md""

This reverts commit 3c5b36c40cebf147663908cf27d0dce6488adfad.

* fix: review

* fix: no need of set

* fix: show avatar

* fix: get rid of eventName

* fix: font-weight

* fix: prioritize mention notifs

* fix: store id in md

* fix: no need of prepending m

* fix: fetchPage

* fix: Avatars incorrect color

* fix: remove scanRE

* fix: test

* fix: include alphabet other than latin

* lockfile

* fix: regex should test for letters, marks and digits

---------

Co-authored-by: Tom Moor <tom.moor@gmail.com>
This commit is contained in:
Apoorv Mishra
2023-03-07 04:24:57 +05:30
committed by GitHub
parent 09435ed798
commit de031b365c
18 changed files with 704 additions and 20 deletions

View File

@@ -0,0 +1,83 @@
import * as React from "react";
import { Document } from "@server/models";
import BaseEmail from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import EmailTemplate from "./components/EmailLayout";
import Header from "./components/Header";
import Heading from "./components/Heading";
type InputProps = {
to: string;
documentId: string;
actorName: string;
teamUrl: string;
mentionId: string;
};
type BeforeSend = {
document: Document;
};
type Props = InputProps & BeforeSend;
/**
* Email sent to a user when someone mentions them in a doucment
*/
export default class MentionNotificationEmail extends BaseEmail<
InputProps,
BeforeSend
> {
protected async beforeSend({ documentId }: InputProps) {
const document = await Document.unscoped().findByPk(documentId);
if (!document) {
return false;
}
return { document };
}
protected subject({ actorName, document }: Props) {
return `${actorName} mentioned you in "${document.title}"`;
}
protected preview({ actorName }: Props): string {
return `${actorName} mentioned you`;
}
protected renderAsText({
actorName,
teamUrl,
document,
mentionId,
}: Props): string {
return `
You were mentioned
${actorName} mentioned you in the document "${document.title}".
Open Document: ${teamUrl}${document.url}?mentionId=${mentionId}
`;
}
protected render({ document, actorName, teamUrl, mentionId }: Props) {
const link = `${teamUrl}${document.url}?ref=notification-email&mentionId=${mentionId}`;
return (
<EmailTemplate>
<Header />
<Body>
<Heading>You were mentioned</Heading>
<p>
{actorName} mentioned you in the document{" "}
<a href={link}>{document.title}</a>.
</p>
<p>
<Button href={link}>Open Document</Button>
</p>
</Body>
</EmailTemplate>
);
}
}

View File

@@ -1,10 +1,12 @@
import { subHours } from "date-fns";
import { differenceBy } from "lodash";
import { Op } from "sequelize";
import { Minute } from "@shared/utils/time";
import subscriptionCreator from "@server/commands/subscriptionCreator";
import { sequelize } from "@server/database/sequelize";
import CollectionNotificationEmail from "@server/emails/templates/CollectionNotificationEmail";
import DocumentNotificationEmail from "@server/emails/templates/DocumentNotificationEmail";
import MentionNotificationEmail from "@server/emails/templates/MentionNotificationEmail";
import env from "@server/env";
import Logger from "@server/logging/Logger";
import {
@@ -25,6 +27,7 @@ import {
DocumentEvent,
CommentEvent,
} from "@server/types";
import parseMentions from "@server/utils/parseMentions";
import CommentCreatedNotificationTask from "../tasks/CommentCreatedNotificationTask";
import BaseProcessor from "./BaseProcessor";
@@ -68,7 +71,7 @@ export default class NotificationsProcessor extends BaseProcessor {
const [collection, document, team] = await Promise.all([
Collection.findByPk(event.collectionId),
Document.findByPk(event.documentId),
Document.findByPk(event.documentId, { includeState: true }),
Team.findByPk(event.teamId),
]);
@@ -85,6 +88,34 @@ export default class NotificationsProcessor extends BaseProcessor {
false
);
// send notifs to mentioned users
const mentions = parseMentions(document);
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: document.updatedBy.id,
teamId: team.id,
documentId: document.id,
});
await MentionNotificationEmail.schedule(
{
to: recipient.email,
documentId: event.documentId,
actorName: actor.name,
teamUrl: team.url,
mentionId: mention.id,
},
{ notificationId: notification.id }
);
}
}
for (const recipient of recipients) {
const notify = await this.shouldNotify(document, recipient.user);
@@ -115,7 +146,7 @@ export default class NotificationsProcessor extends BaseProcessor {
async revisionCreated(event: RevisionEvent) {
const [collection, document, revision, team] = await Promise.all([
Collection.findByPk(event.collectionId),
Document.findByPk(event.documentId),
Document.findByPk(event.documentId, { includeState: true }),
Revision.findByPk(event.modelId),
Team.findByPk(event.teamId),
]);
@@ -147,6 +178,37 @@ export default class NotificationsProcessor extends BaseProcessor {
return;
}
// send notifs to newly mentioned users
const prev = await revision.previous();
const oldMentions = prev ? parseMentions(prev) : [];
const newMentions = parseMentions(document);
const mentions = differenceBy(newMentions, oldMentions, "id");
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: document.updatedBy.id,
teamId: team.id,
documentId: document.id,
});
await MentionNotificationEmail.schedule(
{
to: recipient.email,
documentId: event.documentId,
actorName: actor.name,
teamUrl: team.url,
mentionId: mention.id,
},
{ notificationId: notification.id }
);
}
}
for (const recipient of recipients) {
const notify = await this.shouldNotify(document, recipient.user);

View File

@@ -0,0 +1,30 @@
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

@@ -0,0 +1,35 @@
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;
}