feat: add group mentions (#10331)

* add group mentions

* group mention functionality

* add notification test

* fix: Group icon in mention menu

* language

* toast message

* fix: Group icon in mention menu light mode color

---------

Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
Salihu
2025-10-19 20:40:10 +01:00
committed by GitHub
parent 285b770b3d
commit e86593f234
24 changed files with 825 additions and 33 deletions

View File

@@ -26,6 +26,7 @@ export function GroupAvatar({
return (
<Squircle color={color ?? theme.text} size={size} className={className}>
<GroupIcon
data-fixed-color
color={backgroundColor ?? theme.background}
size={size * 0.75}
/>

View File

@@ -57,7 +57,7 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
box-shadow: none;
cursor: var(--pointer);
svg {
svg:not([data-fixed-color]) {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}
@@ -78,7 +78,7 @@ const BaseMenuItemCSS = css<BaseMenuItemProps>`
box-shadow: none;
cursor: var(--pointer);
svg {
svg:not([data-fixed-color]) {
color: ${props.theme.accentText};
fill: ${props.theme.accentText};
}

View File

@@ -9,13 +9,14 @@ import Icon from "@shared/components/Icon";
import { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { Avatar, AvatarSize } from "~/components/Avatar";
import { Avatar, AvatarSize, GroupAvatar } from "~/components/Avatar";
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import Flex from "~/components/Flex";
import {
DocumentsSection,
UserSection,
CollectionsSection,
GroupSection,
} from "~/actions/sections";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
@@ -44,7 +45,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const [loaded, setLoaded] = useState(false);
const [items, setItems] = useState<MentionItem[]>([]);
const { t } = useTranslation();
const { auth, documents, users, collections } = useStores();
const { auth, documents, users, collections, groups } = useStores();
const actorId = auth.currentUserId;
const location = useLocation();
const documentId = parseDocumentSlug(location.pathname);
@@ -99,6 +100,32 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
},
}) as MentionItem
)
.concat(
groups
.findByQuery(search, { maxResults: maxResultsInSection })
.map((group) => ({
name: "mention",
icon: (
<Flex
align="center"
justify="center"
style={{ width: 24, height: 24, marginRight: 4 }}
>
<GroupAvatar group={group} size={AvatarSize.Small} />
</Flex>
),
title: group.name,
section: GroupSection,
appendSpace: true,
attrs: {
id: crypto.randomUUID(),
type: MentionType.Group,
modelId: group.id,
actorId,
label: group.name,
},
}))
)
.concat(
documents
.findByQuery(search, { maxResults: maxResultsInSection })
@@ -183,7 +210,17 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
setItems(items);
setLoaded(true);
}
}, [t, actorId, loading, search, users, documents, maxResultsInSection]);
}, [
t,
actorId,
loading,
search,
users,
documents,
maxResultsInSection,
groups,
collections,
]);
const handleSelect = useCallback(
async (item: MentionItem) => {
@@ -196,29 +233,44 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
if (!documentId) {
return;
}
// Check if the mentioned user has access to the document
const res = await client.post("/documents.users", {
id: documentId,
userId: item.attrs.modelId,
});
if (!res.data.length) {
const user = users.get(item.attrs.modelId);
if (item.attrs.type === MentionType.User) {
// Check if the mentioned user has access to the document
const res = await client.post("/documents.users", {
id: documentId,
userId: item.attrs.modelId,
});
if (!res.data.length) {
const user = users.get(item.attrs.modelId);
toast.message(
t(
"{{ userName }} won't be notified, as they do not have access to this document",
{
userName: item.attrs.label,
}
),
{
icon: <Avatar model={user} size={AvatarSize.Toast} />,
duration: 10000,
}
);
}
} else if (item.attrs.type === MentionType.Group) {
const group = groups.get(item.attrs.modelId);
toast.message(
t(
"{{ userName }} won't be notified, as they do not have access to this document",
`Members of "{{ groupName }}" that have access to this document will be notified`,
{
userName: item.attrs.label,
groupName: item.attrs.label,
}
),
{
icon: <Avatar model={user} size={AvatarSize.Toast} />,
icon: group ? <GroupAvatar group={group} /> : undefined,
duration: 10000,
}
);
}
},
[t, users, documentId]
[t, users, documentId, groups]
);
// Prevent showing the menu until we have data otherwise it will be positioned

View File

@@ -26,6 +26,11 @@ class Group extends Model {
return users.inGroup(this.id);
}
@computed
get searchContent(): string[] {
return [this.name].filter(Boolean);
}
@computed
get admins() {
const { groupUsers } = this.store.rootStore;

View File

@@ -122,6 +122,9 @@ class Notification extends Model {
case NotificationEventType.MentionedInDocument:
case NotificationEventType.MentionedInComment:
return t("mentioned you in");
case NotificationEventType.GroupMentionedInComment:
case NotificationEventType.GroupMentionedInDocument:
return t("mentioned your group in");
case NotificationEventType.CreateComment:
return t("left a comment on");
case NotificationEventType.ResolveComment:
@@ -177,9 +180,11 @@ class Notification extends Model {
return collection ? collectionPath(collection.path) : "";
}
case NotificationEventType.AddUserToDocument:
case NotificationEventType.GroupMentionedInDocument:
case NotificationEventType.MentionedInDocument: {
return this.document?.path;
}
case NotificationEventType.GroupMentionedInComment:
case NotificationEventType.MentionedInComment:
case NotificationEventType.ResolveComment:
case NotificationEventType.CreateComment:

View File

@@ -13,6 +13,7 @@ import {
SmileyIcon,
StarredIcon,
UserIcon,
GroupIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
@@ -70,6 +71,14 @@ function Notifications() {
"Receive a notification when someone mentions you in a document or comment"
),
},
{
event: NotificationEventType.GroupMentionedInDocument,
icon: <GroupIcon />,
title: t("Group mentions"),
description: t(
"Receive a notification when someone mentions a group you are a member of in a document or comment"
),
},
{
event: NotificationEventType.ResolveComment,
icon: <DoneIcon />,

View File

@@ -181,7 +181,7 @@
"node-fetch": "2.7.0",
"nodemailer": "^7.0.7",
"octokit": "^3.2.2",
"outline-icons": "^3.13.0",
"outline-icons": "^3.13.1",
"oy-vey": "^0.12.1",
"passport": "^0.7.0",
"passport-google-oauth2": "^0.2.0",

View File

@@ -0,0 +1,176 @@
import * as React from "react";
import { NotificationEventType } from "@shared/types";
import { Collection, Comment, Document, Group } from "@server/models";
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { can } from "@server/policies";
import BaseEmail, { EmailMessageCategory, EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import Diff from "./components/Diff";
import EmailTemplate from "./components/EmailLayout";
import EmptySpace from "./components/EmptySpace";
import Footer from "./components/Footer";
import Header from "./components/Header";
import Heading from "./components/Heading";
type InputProps = EmailProps & {
groupId: string;
userId: string;
documentId: string;
actorName: string;
commentId: string;
teamUrl: string;
};
type BeforeSend = {
document: Document;
collection: Collection;
body: string | undefined;
unsubscribeUrl: string;
groupName: string;
};
type Props = InputProps & BeforeSend;
/**
* Email sent to a user when they are a member of a group mentioned in a comment.
*/
export default class GroupCommentMentionedEmail extends BaseEmail<
InputProps,
BeforeSend
> {
protected get category() {
return EmailMessageCategory.Notification;
}
protected async beforeSend(props: InputProps) {
const { documentId, commentId, groupId } = props;
const document = await Document.unscoped().findByPk(documentId);
if (!document) {
return false;
}
const group = await Group.findByPk(groupId);
if (!group) {
return false;
}
const collection = await document.$get("collection");
if (!collection) {
return false;
}
const [comment, team] = await Promise.all([
Comment.findByPk(commentId),
document.$get("team"),
]);
if (!comment || !team) {
return false;
}
const body = await this.htmlForData(
team,
ProsemirrorHelper.toProsemirror(comment.data)
);
return {
document,
collection,
body,
groupName: group.name,
unsubscribeUrl: this.unsubscribeUrl(props),
};
}
protected unsubscribeUrl({ userId }: InputProps) {
return NotificationSettingsHelper.unsubscribeUrl(
userId,
NotificationEventType.GroupMentionedInComment
);
}
protected replyTo({ notification }: Props) {
if (notification?.user && notification.actor?.email) {
if (can(notification.user, "readEmail", notification.actor)) {
return notification.actor.email;
}
}
return;
}
protected subject({ document, groupName }: Props) {
return `The ${groupName} group was mentioned in “${document.titleWithDefault}`;
}
protected preview({ actorName, groupName }: Props): string {
return `${actorName} mentioned the "${groupName}" group in a thread`;
}
protected fromName({ actorName }: Props): string {
return actorName;
}
protected renderAsText({
actorName,
teamUrl,
document,
commentId,
collection,
groupName,
}: Props): string {
return `
${actorName} mentioned the "${groupName}" group in a comment on "${document.titleWithDefault}"${
collection.name ? ` in the ${collection.name} collection` : ""
}.
Open Thread: ${teamUrl}${document.url}?commentId=${commentId}
`;
}
protected render(props: Props) {
const {
document,
collection,
actorName,
teamUrl,
commentId,
unsubscribeUrl,
body,
groupName,
} = props;
const threadLink = `${teamUrl}${document.url}?commentId=${commentId}&ref=notification-email`;
return (
<EmailTemplate
previewText={this.preview(props)}
goToAction={{ url: threadLink, name: "View Thread" }}
>
<Header />
<Body>
<Heading>{document.titleWithDefault}</Heading>
<p>
{actorName} mentioned the "{groupName}" group in a comment on{" "}
<a href={threadLink}>{document.titleWithDefault}</a>{" "}
{collection.name ? ` in the ${collection.name} collection` : ""}.
</p>
{body && (
<>
<EmptySpace height={20} />
<Diff>
<div dangerouslySetInnerHTML={{ __html: body }} />
</Diff>
<EmptySpace height={20} />
</>
)}
<p>
<Button href={threadLink}>Open Thread</Button>
</p>
</Body>
<Footer unsubscribeUrl={unsubscribeUrl} />
</EmailTemplate>
);
}
}

View File

@@ -0,0 +1,167 @@
import differenceBy from "lodash/differenceBy";
import * as React from "react";
import { MentionType } from "@shared/types";
import { Document, Revision, Group } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { can } from "@server/policies";
import BaseEmail, { EmailMessageCategory, EmailProps } from "./BaseEmail";
import Body from "./components/Body";
import Button from "./components/Button";
import Diff from "./components/Diff";
import EmailTemplate from "./components/EmailLayout";
import EmptySpace from "./components/EmptySpace";
import Header from "./components/Header";
import Heading from "./components/Heading";
type InputProps = EmailProps & {
documentId: string;
revisionId: string | undefined;
actorName: string;
teamUrl: string;
groupId: string;
};
type BeforeSend = {
document: Document;
groupName: string;
body: string | undefined;
};
type Props = InputProps & BeforeSend;
/**
* Email sent to a user when they are a member of a group mentioned in a document.
*/
export default class GroupDocumentMentionedEmail extends BaseEmail<
InputProps,
BeforeSend
> {
protected get category() {
return EmailMessageCategory.Notification;
}
protected async beforeSend({ documentId, revisionId, groupId }: InputProps) {
const document = await Document.unscoped().findByPk(documentId);
if (!document) {
return false;
}
const group = await Group.findByPk(groupId);
if (!group) {
return false;
}
const team = await document.$get("team");
if (!team) {
return false;
}
let currDoc: Document | Revision = document;
let prevDoc: Revision | undefined;
if (revisionId) {
const revision = await Revision.findByPk(revisionId);
if (!revision) {
return false;
}
currDoc = revision;
prevDoc = (await revision.before()) ?? undefined;
}
const currMentions = DocumentHelper.parseMentions(currDoc, {
type: MentionType.Group,
modelId: groupId,
});
const prevMentions = prevDoc
? DocumentHelper.parseMentions(prevDoc, {
type: MentionType.Group,
modelId: groupId,
})
: [];
const firstNewMention = differenceBy(currMentions, prevMentions, "id")[0];
let body: string | undefined;
if (firstNewMention) {
const node = ProsemirrorHelper.getNodeForMentionEmail(
DocumentHelper.toProsemirror(currDoc),
firstNewMention
);
if (node) {
body = await this.htmlForData(team, node);
}
}
return { document, body, groupName: group.name };
}
protected subject({ document, groupName }: Props) {
return `The ${groupName} group was mentioned in “${document.titleWithDefault}`;
}
protected preview({ actorName, groupName }: Props): string {
return `${actorName} mentioned the "${groupName}" group`;
}
protected fromName({ actorName }: Props) {
return actorName;
}
protected replyTo({ notification }: Props) {
if (notification?.user && notification.actor?.email) {
if (can(notification.user, "readEmail", notification.actor)) {
return notification.actor.email;
}
}
return;
}
protected renderAsText({
actorName,
teamUrl,
document,
groupName,
}: Props): string {
return `
${actorName} mentioned the “${groupName}” group in the document “${document.titleWithDefault}”.
Open Document: ${teamUrl}${document.url}
`;
}
protected render(props: Props) {
const { document, actorName, teamUrl, body, groupName } = props;
const documentLink = `${teamUrl}${document.url}?ref=notification-email`;
return (
<EmailTemplate
previewText={this.preview(props)}
goToAction={{ url: documentLink, name: "View Document" }}
>
<Header />
<Body>
<Heading>Your group was mentioned</Heading>
<p>
{actorName} mentioned the "{groupName}" group in the document{" "}
<a href={documentLink}>{document.titleWithDefault}</a>.
</p>
{body && (
<>
<EmptySpace height={20} />
<Diff>
<div dangerouslySetInnerHTML={{ __html: body }} />
</Diff>
<EmptySpace height={20} />
</>
)}
<p>
<Button href={documentLink}>Open Document</Button>
</p>
</Body>
</EmailTemplate>
);
}
}

View File

@@ -0,0 +1,19 @@
"use strict";
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
await queryInterface.addColumn("notifications", "groupId", {
type: Sequelize.UUID,
allowNull: true,
onDelete: "cascade",
references: {
model: "groups",
},
});
},
async down(queryInterface) {
await queryInterface.removeColumn("notifications", "groupId");
},
};

View File

@@ -81,6 +81,36 @@ describe("Notification", () => {
expect(references?.length).toBe(1);
expect(references![0]).toBe(expectedReference);
});
it("group mentioned in document", async () => {
const document = await buildDocument();
const notification = await buildNotification({
event: NotificationEventType.GroupMentionedInDocument,
documentId: document.id,
});
const references = await Notification.emailReferences(notification);
const expectedReference = Notification.emailMessageId(
`${document.id}-group-mentions`
);
expect(references?.length).toBe(1);
expect(references![0]).toBe(expectedReference);
});
it("group mentioned in comment", async () => {
const document = await buildDocument();
const notification = await buildNotification({
event: NotificationEventType.GroupMentionedInComment,
documentId: document.id,
});
const references = await Notification.emailReferences(notification);
const expectedReference = Notification.emailMessageId(
`${document.id}-group-mentions`
);
expect(references?.length).toBe(1);
expect(references![0]).toBe(expectedReference);
});
});
describe("should return comment reference", () => {

View File

@@ -30,6 +30,7 @@ import Event from "./Event";
import Revision from "./Revision";
import Team from "./Team";
import User from "./User";
import Group from "./Group";
import Fix from "./decorators/Fix";
let baseDomain;
@@ -128,6 +129,13 @@ class Notification extends Model<
event: NotificationEventType;
// associations
@BelongsTo(() => Group, "groupId")
group: Group;
@AllowNull
@ForeignKey(() => User)
@Column(DataType.UUID)
groupId: string;
@BelongsTo(() => User, "userId")
user: User;
@@ -202,6 +210,7 @@ class Notification extends Model<
collectionId: model.collectionId,
actorId: model.actorId,
membershipId: model.membershipId,
groupId: model.groupId,
};
if (options.transaction) {
@@ -260,6 +269,10 @@ class Notification extends Model<
case NotificationEventType.UpdateDocument:
name = `${notification.documentId}-updates`;
break;
case NotificationEventType.GroupMentionedInComment:
case NotificationEventType.GroupMentionedInDocument:
name = `${notification.documentId}-group-mentions`;
break;
case NotificationEventType.MentionedInDocument:
case NotificationEventType.MentionedInComment:
name = `${notification.documentId}-mentions`;

View File

@@ -11,6 +11,8 @@ import DocumentSharedEmail from "@server/emails/templates/DocumentSharedEmail";
import { Notification } from "@server/models";
import { Event, NotificationEvent } from "@server/types";
import BaseProcessor from "./BaseProcessor";
import GroupDocumentMentionedEmail from "@server/emails/templates/GroupDocumentMentionedEmail";
import GroupCommentMentionedEmail from "@server/emails/templates/GroupCommentMentionedEmail";
export default class EmailsProcessor extends BaseProcessor {
static applicableEvents: Event["name"][] = ["notifications.create"];
@@ -83,6 +85,21 @@ export default class EmailsProcessor extends BaseProcessor {
return;
}
case NotificationEventType.GroupMentionedInDocument: {
await new GroupDocumentMentionedEmail(
{
to: notification.user.email,
documentId: notification.documentId,
revisionId: notification.revisionId,
groupId: notification.groupId,
teamUrl: notification.team.url,
actorName: notification.actor.name,
},
{ notificationId }
).schedule();
return;
}
case NotificationEventType.MentionedInDocument: {
// No need to delay email here as the notification itself is already delayed
await new DocumentMentionedEmail(
@@ -99,6 +116,24 @@ export default class EmailsProcessor extends BaseProcessor {
return;
}
case NotificationEventType.GroupMentionedInComment: {
await new GroupCommentMentionedEmail(
{
to: notification.user.email,
userId: notification.userId,
documentId: notification.documentId,
teamUrl: notification.team.url,
actorName: notification.actor.name,
commentId: notification.commentId,
groupId: notification.groupId,
},
{ notificationId }
).schedule({
delay: Minute.ms,
});
return;
}
case NotificationEventType.MentionedInComment: {
await new CommentMentionedEmail(
{

View File

@@ -5,7 +5,13 @@ import {
} from "@shared/types";
import subscriptionCreator from "@server/commands/subscriptionCreator";
import { createContext } from "@server/context";
import { Comment, Document, Notification, User } from "@server/models";
import {
Comment,
Document,
GroupUser,
Notification,
User,
} from "@server/models";
import NotificationHelper from "@server/models/helpers/NotificationHelper";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { sequelize } from "@server/storage/database";
@@ -77,6 +83,58 @@ export default class CommentCreatedNotificationsTask extends BaseTask<CommentEve
}
}
// send notifications to users in mentioned groups
const groupMentions = ProsemirrorHelper.parseMentions(
ProsemirrorHelper.toProsemirror(comment.data),
{
type: MentionType.Group,
}
);
const mentionedGroup: string[] = [];
for (const group of groupMentions) {
if (mentionedGroup.includes(group.modelId)) {
continue;
}
const usersFromMentionedGroup = await GroupUser.findAll({
where: {
groupId: group.modelId,
},
order: [["permission", "ASC"]],
});
const mentionedUser: string[] = [];
for (const user of usersFromMentionedGroup) {
if (mentionedUser.includes(user.userId)) {
continue;
}
const recipient = await User.findByPk(user.userId);
if (
recipient &&
recipient.id !== group.actorId &&
recipient.subscribedToEventType(
NotificationEventType.GroupMentionedInComment
) &&
(await canUserAccessDocument(recipient, document.id))
) {
await Notification.create({
event: NotificationEventType.GroupMentionedInComment,
groupId: group.modelId,
userId: recipient.id,
actorId: group.actorId,
teamId: document.teamId,
documentId: document.id,
commentId: comment.id,
});
mentionedUser.push(user.userId);
}
}
mentionedGroup.push(group.modelId);
}
const recipients = (
await NotificationHelper.getCommentNotificationRecipients(
document,

View File

@@ -1,7 +1,13 @@
import invariant from "invariant";
import { Op } from "sequelize";
import { MentionType, NotificationEventType } from "@shared/types";
import { Comment, Document, Notification, User } from "@server/models";
import {
Comment,
Document,
GroupUser,
Notification,
User,
} from "@server/models";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { CommentEvent, CommentUpdateEvent } from "@server/types";
import { canUserAccessDocument } from "@server/utils/permissions";
@@ -69,6 +75,55 @@ export default class CommentUpdatedNotificationsTask extends BaseTask<CommentEve
userIdsMentioned.push(mention.modelId);
}
}
const groupMentions = ProsemirrorHelper.parseMentions(
ProsemirrorHelper.toProsemirror(comment.data),
{ type: MentionType.Group }
).filter((mention) => newMentionIds.includes(mention.id));
const mentionedGroup: string[] = [];
for (const group of groupMentions) {
if (mentionedGroup.includes(group.modelId)) {
continue;
}
const usersFromMentionedGroup = await GroupUser.findAll({
where: {
groupId: group.modelId,
},
order: [["permission", "ASC"]],
});
const mentionedUser: string[] = [];
for (const user of usersFromMentionedGroup) {
if (mentionedUser.includes(user.userId)) {
continue;
}
const recipient = await User.findByPk(user.userId);
if (
recipient &&
recipient.id !== group.actorId &&
recipient.subscribedToEventType(
NotificationEventType.GroupMentionedInComment
) &&
(await canUserAccessDocument(recipient, document.id))
) {
await Notification.create({
event: NotificationEventType.GroupMentionedInComment,
groupId: group.modelId,
userId: recipient.id,
actorId: group.actorId,
teamId: document.teamId,
documentId: document.id,
commentId: comment.id,
});
mentionedUser.push(user.userId);
}
}
mentionedGroup.push(group.modelId);
}
}
private async handleResolvedComment(event: CommentUpdateEvent) {

View File

@@ -1,6 +1,6 @@
import { MentionType, NotificationEventType } from "@shared/types";
import { createSubscriptionsForDocument } from "@server/commands/subscriptionCreator";
import { Document, Notification, User } from "@server/models";
import { Document, Notification, User, GroupUser } from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import NotificationHelper from "@server/models/helpers/NotificationHelper";
import { DocumentEvent } from "@server/types";
@@ -50,6 +50,53 @@ export default class DocumentPublishedNotificationsTask extends BaseTask<Documen
}
}
// send notifications to users in mentioned groups
const groupMentions = DocumentHelper.parseMentions(document, {
type: MentionType.Group,
});
const mentionedGroup: string[] = [];
for (const group of groupMentions) {
if (mentionedGroup.includes(group.modelId)) {
continue;
}
const usersFromMentionedGroup = await GroupUser.findAll({
where: {
groupId: group.modelId,
},
order: [["permission", "ASC"]],
});
const mentionedUser: string[] = [];
for (const user of usersFromMentionedGroup) {
if (mentionedUser.includes(user.userId)) {
continue;
}
const recipient = await User.findByPk(user.userId);
if (
recipient &&
recipient.id !== group.actorId &&
recipient.subscribedToEventType(
NotificationEventType.GroupMentionedInDocument
) &&
(await canUserAccessDocument(recipient, document.id))
) {
await Notification.create({
event: NotificationEventType.GroupMentionedInDocument,
groupId: group.modelId,
userId: recipient.id,
actorId: group.actorId,
teamId: document.teamId,
documentId: document.id,
});
mentionedUser.push(user.userId);
}
}
mentionedGroup.push(group.modelId);
}
const recipients = (
await NotificationHelper.getDocumentNotificationRecipients({
document,

View File

@@ -5,7 +5,14 @@ import { MentionType, NotificationEventType } from "@shared/types";
import { createSubscriptionsForDocument } from "@server/commands/subscriptionCreator";
import env from "@server/env";
import Logger from "@server/logging/Logger";
import { Document, Revision, Notification, User, View } from "@server/models";
import {
Document,
Revision,
Notification,
User,
View,
GroupUser,
} from "@server/models";
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
import NotificationHelper from "@server/models/helpers/NotificationHelper";
import { RevisionEvent } from "@server/types";
@@ -38,20 +45,23 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
// Send notifications to mentioned users first
const oldMentions = before
? DocumentHelper.parseMentions(before, { type: MentionType.User })
? [...DocumentHelper.parseMentions(before, { type: MentionType.User })]
: [];
const newMentions = DocumentHelper.parseMentions(document, {
type: MentionType.User,
});
const newMentions = [
...DocumentHelper.parseMentions(document, {
type: MentionType.User,
}),
];
const mentions = differenceBy(newMentions, oldMentions, "id");
const userIdsMentioned: string[] = [];
for (const mention of mentions) {
if (userIdsMentioned.includes(mention.modelId)) {
continue;
}
const recipient = await User.findByPk(mention.modelId);
if (
recipient &&
recipient.id !== mention.actorId &&
@@ -68,10 +78,68 @@ export default class RevisionCreatedNotificationsTask extends BaseTask<RevisionE
teamId: document.teamId,
documentId: document.id,
});
userIdsMentioned.push(recipient.id);
}
}
// send notifications to users in mentioned groups
const oldGroupMentions = before
? DocumentHelper.parseMentions(before, { type: MentionType.Group })
: [];
const newGroupMentions = DocumentHelper.parseMentions(document, {
type: MentionType.Group,
});
const groupMentions = differenceBy(
newGroupMentions,
oldGroupMentions,
"id"
);
const mentionedGroup: string[] = [];
for (const group of groupMentions) {
if (mentionedGroup.includes(group.modelId)) {
continue;
}
const usersFromMentionedGroup = await GroupUser.findAll({
where: {
groupId: group.modelId,
},
order: [["permission", "ASC"]],
});
const mentionedUser: string[] = [];
for (const user of usersFromMentionedGroup) {
if (mentionedUser.includes(user.userId)) {
continue;
}
const recipient = await User.findByPk(user.userId);
if (
recipient &&
recipient.id !== group.actorId &&
recipient.subscribedToEventType(
NotificationEventType.GroupMentionedInDocument
) &&
(await canUserAccessDocument(recipient, document.id))
) {
await Notification.create({
event: NotificationEventType.GroupMentionedInDocument,
groupId: group.modelId,
userId: recipient.id,
revisionId: event.modelId,
actorId: group.actorId,
teamId: document.teamId,
documentId: document.id,
});
mentionedUser.push(user.userId);
}
}
mentionedGroup.push(group.modelId);
}
const recipients = (
await NotificationHelper.getDocumentNotificationRecipients({
document,

View File

@@ -250,7 +250,20 @@ router.post(
{ type: MentionType.User }
).map((mention) => mention.id);
newMentionIds = difference(updatedMentionIds, existingMentionIds);
const existingGroupMentionIds = ProsemirrorHelper.parseMentions(
ProsemirrorHelper.toProsemirror(comment.data),
{ type: MentionType.Group }
).map((mention) => mention.id);
const updatedGroupMentionIds = ProsemirrorHelper.parseMentions(
ProsemirrorHelper.toProsemirror(data),
{ type: MentionType.Group }
).map((mention) => mention.id);
newMentionIds = [
...difference(updatedMentionIds, existingMentionIds),
...difference(updatedGroupMentionIds, existingGroupMentionIds),
];
comment.data = data;
}

View File

@@ -31,7 +31,13 @@ const Squircle: React.FC<Props> = ({
className={className}
style={style}
>
<svg width={size} height={size} fill={color} viewBox="0 0 28 28">
<svg
width={size}
height={size}
fill={color}
viewBox="0 0 28 28"
data-fixed-color
>
<path d="M0 11.1776C0 1.97285 1.97285 0 11.1776 0H16.8224C26.0272 0 28 1.97285 28 11.1776V16.8224C28 26.0272 26.0272 28 16.8224 28H11.1776C1.97285 28 0 26.0272 0 16.8224V11.1776Z" />
</svg>
<Content>{children}</Content>

View File

@@ -70,6 +70,27 @@ export const MentionUser = observer(function MentionUser_(
);
});
export const MentionGroup = observer(function MentionGroup_(
props: ComponentProps
) {
const { isSelected, node } = props;
const { groups } = useStores();
const group = groups.get(node.attrs.modelId);
const { className, ...attrs } = getAttributesFromNode(node);
return (
<span
{...attrs}
className={cn(className, {
"ProseMirror-selectednode": isSelected,
})}
>
<EmailIcon size={18} />
{group?.name || node.attrs.label}
</span>
);
});
export const MentionDocument = observer(function MentionDocument_(
props: ComponentProps
) {

View File

@@ -18,6 +18,7 @@ import { MentionType, UnfurlResourceType, UnfurlResponse } from "../../types";
import {
MentionCollection,
MentionDocument,
MentionGroup,
MentionIssue,
MentionPullRequest,
MentionURL,
@@ -126,6 +127,8 @@ export default class Mention extends Node {
switch (props.node.attrs.type) {
case MentionType.User:
return <MentionUser {...props} />;
case MentionType.Group:
return <MentionGroup {...props} />;
case MentionType.Document:
return <MentionDocument {...props} />;
case MentionType.Collection:

View File

@@ -484,6 +484,7 @@
"Profile picture": "Profile picture",
"Create a new doc": "Create a new doc",
"{{ userName }} won't be notified, as they do not have access to this document": "{{ userName }} won't be notified, as they do not have access to this document",
"Members of \"{{ groupName }}\" that have access to this document will be notified": "Members of \"{{ groupName }}\" that have access to this document will be notified",
"Keep as link": "Keep as link",
"Mention": "Mention",
"Embed": "Embed",
@@ -627,6 +628,7 @@
"edited": "edited",
"created the collection": "created the collection",
"mentioned you in": "mentioned you in",
"mentioned your group in": "mentioned your group in",
"left a comment on": "left a comment on",
"resolved a comment on": "resolved a comment on",
"reacted {{ emoji }} to your comment on": "reacted {{ emoji }} to your comment on",
@@ -1104,6 +1106,8 @@
"Receive a notification when a document you are subscribed to or a thread you participated in receives a comment": "Receive a notification when a document you are subscribed to or a thread you participated in receives a comment",
"Mentioned": "Mentioned",
"Receive a notification when someone mentions you in a document or comment": "Receive a notification when someone mentions you in a document or comment",
"Group mentions": "Group mentions",
"Receive a notification when someone mentions a group you are a member of in a document or comment": "Receive a notification when someone mentions a group you are a member of in a document or comment",
"Receive a notification when a comment thread you were involved in is resolved": "Receive a notification when a comment thread you were involved in is resolved",
"Reaction added": "Reaction added",
"Receive a notification when someone reacts to your comment": "Receive a notification when someone reacts to your comment",

View File

@@ -83,6 +83,7 @@ export enum MentionType {
User = "user",
Document = "document",
Collection = "collection",
Group = "group",
Issue = "issue",
PullRequest = "pull_request",
URL = "url",
@@ -361,6 +362,8 @@ export enum NotificationEventType {
ReactionsCreate = "reactions.create",
MentionedInDocument = "documents.mentioned",
MentionedInComment = "comments.mentioned",
GroupMentionedInDocument = "documents.group_mentioned",
GroupMentionedInComment = "comments.group_mentioned",
InviteAccepted = "emails.invite_accepted",
Onboarding = "emails.onboarding",
Features = "emails.features",
@@ -396,6 +399,8 @@ export const NotificationEventDefaults: Record<NotificationEventType, boolean> =
[NotificationEventType.CreateRevision]: false,
[NotificationEventType.MentionedInDocument]: true,
[NotificationEventType.MentionedInComment]: true,
[NotificationEventType.GroupMentionedInDocument]: true,
[NotificationEventType.GroupMentionedInComment]: true,
[NotificationEventType.InviteAccepted]: true,
[NotificationEventType.Onboarding]: true,
[NotificationEventType.Features]: true,

View File

@@ -11602,10 +11602,10 @@ os-tmpdir@~1.0.2:
resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
integrity "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="
outline-icons@^3.13.0:
version "3.13.0"
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-3.13.0.tgz#d44126f87e3a8be5d9c47585a885e0fab3ea9467"
integrity sha512-yJLnLdwr1Kn/ZzcBXvbboEh/iml0h5RtSUo5K5fTO7kN3MMSLf/VeX6zy62VZzoWMWdqv3lBudHDukmbODi7Fg==
outline-icons@^3.13.1:
version "3.13.1"
resolved "https://registry.yarnpkg.com/outline-icons/-/outline-icons-3.13.1.tgz#1a2ca99b76d962d1e626662e5ce486b0773c5371"
integrity sha512-3ahmELg1g5JWNqtJWmOH4Dv4ESR9A1a1iROs2D+h9wn17+Pcy1sPJhe1YpldZYHIxVPdVKNuMlHIfxKiWheinw==
own-keys@^1.0.0:
version "1.0.1"