mirror of
https://github.com/outline/outline.git
synced 2025-12-19 09:39:39 -06:00
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:
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 />,
|
||||
|
||||
@@ -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",
|
||||
|
||||
176
server/emails/templates/GroupCommentMentionedEmail.tsx
Normal file
176
server/emails/templates/GroupCommentMentionedEmail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
167
server/emails/templates/GroupDocumentMentionedEmail.tsx
Normal file
167
server/emails/templates/GroupDocumentMentionedEmail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user