mirror of
https://github.com/outline/outline.git
synced 2026-06-13 03:14:59 +03:00
Add in-app reaction notifications (#9893)
* Add ReactionsCreate notification event type - Add ReactionsCreate to NotificationEventType enum and defaults - Add notification settings UI with SmileyIcon and proper labels - Create ReactionsCreateNotificationsTask to handle comment reactions - Update NotificationsProcessor to handle comments.add_reaction events - Add eventText and path handling in client Notification model - Notifications are enabled by default but never send emails * Applied automatic fixes * Show the actual emoji in the notification * Cleanup notifications if reaction is removed * PR feedback --------- Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com> Co-authored-by: Tom Moor <tom@getoutline.com>
This commit is contained in:
@@ -35,7 +35,7 @@ function Notifications(
|
||||
const context = useActionContext();
|
||||
const { notifications } = useStores();
|
||||
const { t } = useTranslation();
|
||||
const isEmpty = notifications.orderedData.length === 0;
|
||||
const isEmpty = notifications.active.length === 0;
|
||||
|
||||
// Update the notification count in the dock icon, if possible.
|
||||
React.useEffect(() => {
|
||||
@@ -80,7 +80,7 @@ function Notifications(
|
||||
<PaginatedList<Notification>
|
||||
fetch={notifications.fetchPage}
|
||||
options={{ archived: false }}
|
||||
items={notifications.orderedData}
|
||||
items={notifications.active}
|
||||
renderItem={(item) => (
|
||||
<NotificationListItem
|
||||
key={item.id}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { action, computed, observable } from "mobx";
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import { NotificationData, NotificationEventType } from "@shared/types";
|
||||
import {
|
||||
collectionPath,
|
||||
commentPath,
|
||||
@@ -73,6 +73,11 @@ class Notification extends Model {
|
||||
*/
|
||||
event: NotificationEventType;
|
||||
|
||||
/**
|
||||
* Additional data associated with the notification.
|
||||
*/
|
||||
data: NotificationData;
|
||||
|
||||
/**
|
||||
* Mark the notification as read or unread
|
||||
*
|
||||
@@ -121,6 +126,10 @@ class Notification extends Model {
|
||||
return t("left a comment on");
|
||||
case NotificationEventType.ResolveComment:
|
||||
return t("resolved a comment on");
|
||||
case NotificationEventType.ReactionsCreate:
|
||||
return t("reacted {{ emoji }} to your comment on", {
|
||||
emoji: this.data.emoji,
|
||||
});
|
||||
case NotificationEventType.AddUserToDocument:
|
||||
return t("shared");
|
||||
case NotificationEventType.AddUserToCollection:
|
||||
@@ -173,7 +182,8 @@ class Notification extends Model {
|
||||
}
|
||||
case NotificationEventType.MentionedInComment:
|
||||
case NotificationEventType.ResolveComment:
|
||||
case NotificationEventType.CreateComment: {
|
||||
case NotificationEventType.CreateComment:
|
||||
case NotificationEventType.ReactionsCreate: {
|
||||
return this.document && this.comment
|
||||
? commentPath(this.document, this.comment)
|
||||
: this.document?.path;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
EditIcon,
|
||||
EmailIcon,
|
||||
PublishIcon,
|
||||
SmileyIcon,
|
||||
StarredIcon,
|
||||
UserIcon,
|
||||
} from "outline-icons";
|
||||
@@ -77,6 +78,14 @@ function Notifications() {
|
||||
"Receive a notification when a comment thread you were involved in is resolved"
|
||||
),
|
||||
},
|
||||
{
|
||||
event: NotificationEventType.ReactionsCreate,
|
||||
icon: <SmileyIcon />,
|
||||
title: t("Reaction added"),
|
||||
description: t(
|
||||
"Receive a notification when someone reacts to your comment"
|
||||
),
|
||||
},
|
||||
{
|
||||
event: NotificationEventType.CreateCollection,
|
||||
icon: <CollectionIcon />,
|
||||
|
||||
@@ -73,8 +73,7 @@ export default class NotificationsStore extends Store<Notification> {
|
||||
*/
|
||||
@computed
|
||||
get approximateUnreadCount(): number {
|
||||
return this.orderedData.filter((notification) => !notification.viewedAt)
|
||||
.length;
|
||||
return this.active.filter((notification) => !notification.viewedAt).length;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,4 +86,12 @@ export default class NotificationsStore extends Store<Notification> {
|
||||
(item) => (item.viewedAt ? 1 : -1)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns only the active (non-archived) notifications.
|
||||
*/
|
||||
@computed
|
||||
get active(): Notification[] {
|
||||
return this.orderedData.filter((n) => !n.archivedAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ export class OIDCStrategy extends Strategy {
|
||||
|
||||
authorizationParams(options: any) {
|
||||
return {
|
||||
...(options.originalQuery || {}),
|
||||
...(super.authorizationParams?.(options) || {}),
|
||||
...options.originalQuery,
|
||||
...super.authorizationParams?.(options),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ export default class CommentCreatedEmail extends BaseEmail<
|
||||
return `
|
||||
${actorName} ${isReply ? "replied to a thread in" : "commented on"} "${
|
||||
document.titleWithDefault
|
||||
}"${collection?.name ? `in the ${collection.name} collection` : ""}.
|
||||
}" ${collection?.name ? `in the ${collection.name} collection` : ""}.
|
||||
|
||||
Open Thread: ${teamUrl}${document.url}?commentId=${commentId}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn("notifications", "data", {
|
||||
type: Sequelize.JSON,
|
||||
allowNull: true,
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.removeColumn("notifications", "data");
|
||||
},
|
||||
};
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
AfterCreate,
|
||||
DefaultScope,
|
||||
} from "sequelize-typescript";
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import { NotificationData, NotificationEventType } from "@shared/types";
|
||||
import { getBaseDomain } from "@shared/utils/domains";
|
||||
import env from "@server/env";
|
||||
import Model from "@server/models/base/Model";
|
||||
@@ -121,6 +121,9 @@ class Notification extends Model<
|
||||
@CreatedAt
|
||||
createdAt: Date;
|
||||
|
||||
@Column(DataType.JSONB)
|
||||
data: NotificationData | null;
|
||||
|
||||
@Column(DataType.STRING)
|
||||
event: NotificationEventType;
|
||||
|
||||
|
||||
@@ -26,5 +26,6 @@ export default async function presentNotification(
|
||||
: undefined,
|
||||
revisionId: notification.revisionId,
|
||||
collectionId: notification.collectionId,
|
||||
data: notification.data,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,11 +7,14 @@ import {
|
||||
CollectionUserEvent,
|
||||
DocumentUserEvent,
|
||||
DocumentGroupEvent,
|
||||
CommentReactionEvent,
|
||||
} from "@server/types";
|
||||
import CollectionAddUserNotificationsTask from "../tasks/CollectionAddUserNotificationsTask";
|
||||
import CollectionCreatedNotificationsTask from "../tasks/CollectionCreatedNotificationsTask";
|
||||
import CommentCreatedNotificationsTask from "../tasks/CommentCreatedNotificationsTask";
|
||||
import CommentUpdatedNotificationsTask from "../tasks/CommentUpdatedNotificationsTask";
|
||||
import ReactionCreatedNotificationsTask from "../tasks/ReactionCreatedNotificationsTask";
|
||||
import ReactionRemovedNotificationsTask from "../tasks/ReactionRemovedNotificationsTask";
|
||||
import DocumentAddGroupNotificationsTask from "../tasks/DocumentAddGroupNotificationsTask";
|
||||
import DocumentAddUserNotificationsTask from "../tasks/DocumentAddUserNotificationsTask";
|
||||
import DocumentPublishedNotificationsTask from "../tasks/DocumentPublishedNotificationsTask";
|
||||
@@ -28,6 +31,8 @@ export default class NotificationsProcessor extends BaseProcessor {
|
||||
"collections.add_user",
|
||||
"comments.create",
|
||||
"comments.update",
|
||||
"comments.add_reaction",
|
||||
"comments.remove_reaction",
|
||||
];
|
||||
|
||||
async perform(event: Event) {
|
||||
@@ -48,6 +53,10 @@ export default class NotificationsProcessor extends BaseProcessor {
|
||||
return this.commentCreated(event);
|
||||
case "comments.update":
|
||||
return this.commentUpdated(event);
|
||||
case "comments.add_reaction":
|
||||
return this.reactionCreated(event);
|
||||
case "comments.remove_reaction":
|
||||
return this.reactionRemoved(event);
|
||||
default:
|
||||
}
|
||||
}
|
||||
@@ -110,4 +119,12 @@ export default class NotificationsProcessor extends BaseProcessor {
|
||||
async commentUpdated(event: CommentEvent) {
|
||||
await new CommentUpdatedNotificationsTask().schedule(event);
|
||||
}
|
||||
|
||||
async reactionCreated(event: CommentReactionEvent) {
|
||||
await new ReactionCreatedNotificationsTask().schedule(event);
|
||||
}
|
||||
|
||||
async reactionRemoved(event: CommentReactionEvent) {
|
||||
await new ReactionRemovedNotificationsTask().schedule(event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import { Comment, Document, Notification, User } from "@server/models";
|
||||
import { CommentReactionEvent } from "@server/types";
|
||||
import { canUserAccessDocument } from "@server/utils/permissions";
|
||||
import BaseTask, { TaskPriority } from "./BaseTask";
|
||||
|
||||
export default class ReactionCreatedNotificationsTask extends BaseTask<CommentReactionEvent> {
|
||||
public async perform(event: CommentReactionEvent) {
|
||||
const { emoji } = event.data;
|
||||
|
||||
// Only handle add_reaction events, not remove_reaction
|
||||
if (event.name !== "comments.add_reaction") {
|
||||
return;
|
||||
}
|
||||
|
||||
const [document, comment] = await Promise.all([
|
||||
Document.scope("withCollection").findOne({
|
||||
where: {
|
||||
id: event.documentId,
|
||||
},
|
||||
}),
|
||||
Comment.findByPk(event.modelId),
|
||||
]);
|
||||
|
||||
if (!document || !comment) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the user who reacted (the actor)
|
||||
const actor = await User.findByPk(event.actorId);
|
||||
if (!actor) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the comment author (the recipient of the notification)
|
||||
const recipient = await User.findByPk(comment.createdById);
|
||||
if (!recipient) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't notify if the user reacted to their own comment
|
||||
if (actor.id === recipient.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the comment author has this notification type enabled
|
||||
if (
|
||||
!recipient.subscribedToEventType(NotificationEventType.ReactionsCreate)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the comment author can access the document
|
||||
if (!(await canUserAccessDocument(recipient, document.id))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await Notification.findOne({
|
||||
where: {
|
||||
event: NotificationEventType.ReactionsCreate,
|
||||
userId: recipient.id,
|
||||
commentId: comment.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// If a notification already exists for this reaction, update it
|
||||
// as we have a unique constraint on userId, commentId, and event.
|
||||
await existing.update({
|
||||
viewedAt: null,
|
||||
archivedAt: null,
|
||||
actorId: actor.id,
|
||||
data: { emoji },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the notification
|
||||
await Notification.create({
|
||||
event: NotificationEventType.ReactionsCreate,
|
||||
userId: recipient.id,
|
||||
actorId: actor.id,
|
||||
teamId: document.teamId,
|
||||
commentId: comment.id,
|
||||
documentId: document.id,
|
||||
data: { emoji },
|
||||
});
|
||||
}
|
||||
|
||||
public get options() {
|
||||
return {
|
||||
priority: TaskPriority.Background,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NotificationEventType } from "@shared/types";
|
||||
import { Notification, User } from "@server/models";
|
||||
import { CommentReactionEvent } from "@server/types";
|
||||
import BaseTask, { TaskPriority } from "./BaseTask";
|
||||
import { createContext } from "@server/context";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { Op } from "sequelize";
|
||||
|
||||
export default class ReactionRemovedNotificationsTask extends BaseTask<CommentReactionEvent> {
|
||||
public async perform(event: CommentReactionEvent) {
|
||||
const { emoji } = event.data;
|
||||
|
||||
if (event.name !== "comments.remove_reaction") {
|
||||
return;
|
||||
}
|
||||
|
||||
await sequelize.transaction(async (transaction) => {
|
||||
const user = await User.findByPk(event.actorId, {
|
||||
rejectOnEmpty: true,
|
||||
transaction,
|
||||
});
|
||||
|
||||
const notifications = await Notification.findAll({
|
||||
lock: {
|
||||
level: transaction.LOCK.UPDATE,
|
||||
of: Notification,
|
||||
},
|
||||
where: {
|
||||
actorId: event.actorId,
|
||||
commentId: event.modelId,
|
||||
viewedAt: {
|
||||
[Op.eq]: null, // Only target notifications that haven't been viewed
|
||||
},
|
||||
event: NotificationEventType.ReactionsCreate,
|
||||
data: {
|
||||
emoji,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const ctx = createContext({
|
||||
user,
|
||||
transaction,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
notifications.map(async (notification) =>
|
||||
notification.updateWithCtx(ctx, { archivedAt: new Date() })
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
public get options() {
|
||||
return {
|
||||
priority: TaskPriority.Background,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -606,6 +606,7 @@
|
||||
"mentioned you in": "mentioned you 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",
|
||||
"shared": "shared",
|
||||
"invited you to": "invited you to",
|
||||
"Choose a date": "Choose a date",
|
||||
@@ -1079,6 +1080,8 @@
|
||||
"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",
|
||||
"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",
|
||||
"Collection created": "Collection created",
|
||||
"Receive a notification whenever a new collection is created": "Receive a notification whenever a new collection is created",
|
||||
"Invite accepted": "Invite accepted",
|
||||
|
||||
@@ -347,6 +347,7 @@ export enum NotificationEventType {
|
||||
CreateCollection = "collections.create",
|
||||
CreateComment = "comments.create",
|
||||
ResolveComment = "comments.resolve",
|
||||
ReactionsCreate = "reactions.create",
|
||||
MentionedInDocument = "documents.mentioned",
|
||||
MentionedInComment = "comments.mentioned",
|
||||
InviteAccepted = "emails.invite_accepted",
|
||||
@@ -361,6 +362,10 @@ export enum NotificationChannelType {
|
||||
Chat = "chat",
|
||||
}
|
||||
|
||||
export type NotificationData = {
|
||||
emoji?: string;
|
||||
};
|
||||
|
||||
export type NotificationSettings = {
|
||||
[event in NotificationEventType]?:
|
||||
| {
|
||||
@@ -376,6 +381,7 @@ export const NotificationEventDefaults: Record<NotificationEventType, boolean> =
|
||||
[NotificationEventType.CreateCollection]: false,
|
||||
[NotificationEventType.CreateComment]: true,
|
||||
[NotificationEventType.ResolveComment]: true,
|
||||
[NotificationEventType.ReactionsCreate]: true,
|
||||
[NotificationEventType.CreateRevision]: false,
|
||||
[NotificationEventType.MentionedInDocument]: true,
|
||||
[NotificationEventType.MentionedInComment]: true,
|
||||
|
||||
Reference in New Issue
Block a user