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:
codegen-sh[bot]
2025-08-11 18:54:43 -04:00
committed by GitHub
parent 289302fd2e
commit 4fc6ac1f15
14 changed files with 235 additions and 10 deletions
@@ -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}
+12 -2
View File
@@ -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;
+9
View File
@@ -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 />,
+9 -2
View File
@@ -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);
}
}
+2 -2
View File
@@ -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");
},
};
+4 -1
View File
@@ -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;
+1
View File
@@ -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",
+6
View File
@@ -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,