From e86593f234e8750ac15c628d98fc8d9fc68e4f94 Mon Sep 17 00:00:00 2001 From: Salihu <91833785+salihudickson@users.noreply.github.com> Date: Sun, 19 Oct 2025 20:40:10 +0100 Subject: [PATCH] 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 --- app/components/Avatar/GroupAvatar.tsx | 1 + app/components/primitives/components/Menu.tsx | 4 +- app/editor/components/MentionMenu.tsx | 82 ++++++-- app/models/Group.ts | 5 + app/models/Notification.ts | 5 + app/scenes/Settings/Notifications.tsx | 9 + package.json | 2 +- .../templates/GroupCommentMentionedEmail.tsx | 176 ++++++++++++++++++ .../templates/GroupDocumentMentionedEmail.tsx | 167 +++++++++++++++++ ...251013204025-add-group-to-notifications.js | 19 ++ server/models/Notification.test.ts | 30 +++ server/models/Notification.ts | 13 ++ server/queues/processors/EmailsProcessor.ts | 35 ++++ .../tasks/CommentCreatedNotificationsTask.ts | 60 +++++- .../tasks/CommentUpdatedNotificationsTask.ts | 57 +++++- .../DocumentPublishedNotificationsTask.ts | 49 ++++- .../tasks/RevisionCreatedNotificationsTask.ts | 80 +++++++- server/routes/api/comments/comments.ts | 15 +- shared/components/Squircle.tsx | 8 +- shared/editor/components/Mentions.tsx | 21 +++ shared/editor/nodes/Mention.tsx | 3 + shared/i18n/locales/en_US/translation.json | 4 + shared/types.ts | 5 + yarn.lock | 8 +- 24 files changed, 825 insertions(+), 33 deletions(-) create mode 100644 server/emails/templates/GroupCommentMentionedEmail.tsx create mode 100644 server/emails/templates/GroupDocumentMentionedEmail.tsx create mode 100644 server/migrations/20251013204025-add-group-to-notifications.js diff --git a/app/components/Avatar/GroupAvatar.tsx b/app/components/Avatar/GroupAvatar.tsx index e6c403bbd4..738783adf0 100644 --- a/app/components/Avatar/GroupAvatar.tsx +++ b/app/components/Avatar/GroupAvatar.tsx @@ -26,6 +26,7 @@ export function GroupAvatar({ return ( diff --git a/app/components/primitives/components/Menu.tsx b/app/components/primitives/components/Menu.tsx index b29a1a25e2..7f3fb3b272 100644 --- a/app/components/primitives/components/Menu.tsx +++ b/app/components/primitives/components/Menu.tsx @@ -57,7 +57,7 @@ const BaseMenuItemCSS = css` 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` box-shadow: none; cursor: var(--pointer); - svg { + svg:not([data-fixed-color]) { color: ${props.theme.accentText}; fill: ${props.theme.accentText}; } diff --git a/app/editor/components/MentionMenu.tsx b/app/editor/components/MentionMenu.tsx index c0bd873527..8cb6e60338 100644 --- a/app/editor/components/MentionMenu.tsx +++ b/app/editor/components/MentionMenu.tsx @@ -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([]); 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: ( + + + + ), + 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: , + 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: , + icon: group ? : undefined, duration: 10000, } ); } }, - [t, users, documentId] + [t, users, documentId, groups] ); // Prevent showing the menu until we have data otherwise it will be positioned diff --git a/app/models/Group.ts b/app/models/Group.ts index b4cbb1212f..5c7aaaa690 100644 --- a/app/models/Group.ts +++ b/app/models/Group.ts @@ -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; diff --git a/app/models/Notification.ts b/app/models/Notification.ts index 07634bda42..dc9a73bec4 100644 --- a/app/models/Notification.ts +++ b/app/models/Notification.ts @@ -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: diff --git a/app/scenes/Settings/Notifications.tsx b/app/scenes/Settings/Notifications.tsx index 9223ec6e26..dc1f4185be 100644 --- a/app/scenes/Settings/Notifications.tsx +++ b/app/scenes/Settings/Notifications.tsx @@ -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: , + 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: , diff --git a/package.json b/package.json index 5247cd32d9..be8ce46d5e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/emails/templates/GroupCommentMentionedEmail.tsx b/server/emails/templates/GroupCommentMentionedEmail.tsx new file mode 100644 index 0000000000..e6525824cf --- /dev/null +++ b/server/emails/templates/GroupCommentMentionedEmail.tsx @@ -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 ( + +
+ + + {document.titleWithDefault} +

+ {actorName} mentioned the "{groupName}" group in a comment on{" "} + {document.titleWithDefault}{" "} + {collection.name ? ` in the ${collection.name} collection` : ""}. +

+ {body && ( + <> + + +
+ + + + )} +

+ +

+ + +