From 4fc6ac1f15633b9748f07fe128ac816b35f2c3aa Mon Sep 17 00:00:00 2001 From: "codegen-sh[bot]" <131295404+codegen-sh[bot]@users.noreply.github.com> Date: Mon, 11 Aug 2025 18:54:43 -0400 Subject: [PATCH] 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 --- .../Notifications/Notifications.tsx | 4 +- app/models/Notification.ts | 14 ++- app/scenes/Settings/Notifications.tsx | 9 ++ app/stores/NotificationsStore.ts | 11 ++- plugins/oidc/server/auth/OIDCStrategy.ts | 4 +- .../emails/templates/CommentCreatedEmail.tsx | 2 +- ...0250810210844-add-data-to-notifications.js | 15 +++ server/models/Notification.ts | 5 +- server/presenters/notification.ts | 1 + .../processors/NotificationsProcessor.ts | 17 ++++ .../tasks/ReactionCreatedNotificationsTask.ts | 95 +++++++++++++++++++ .../tasks/ReactionRemovedNotificationsTask.ts | 59 ++++++++++++ shared/i18n/locales/en_US/translation.json | 3 + shared/types.ts | 6 ++ 14 files changed, 235 insertions(+), 10 deletions(-) create mode 100644 server/migrations/20250810210844-add-data-to-notifications.js create mode 100644 server/queues/tasks/ReactionCreatedNotificationsTask.ts create mode 100644 server/queues/tasks/ReactionRemovedNotificationsTask.ts diff --git a/app/components/Notifications/Notifications.tsx b/app/components/Notifications/Notifications.tsx index 611aea0589..3d6c28a313 100644 --- a/app/components/Notifications/Notifications.tsx +++ b/app/components/Notifications/Notifications.tsx @@ -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( fetch={notifications.fetchPage} options={{ archived: false }} - items={notifications.orderedData} + items={notifications.active} renderItem={(item) => ( , + title: t("Reaction added"), + description: t( + "Receive a notification when someone reacts to your comment" + ), + }, { event: NotificationEventType.CreateCollection, icon: , diff --git a/app/stores/NotificationsStore.ts b/app/stores/NotificationsStore.ts index 205ca93afb..22c20253ba 100644 --- a/app/stores/NotificationsStore.ts +++ b/app/stores/NotificationsStore.ts @@ -73,8 +73,7 @@ export default class NotificationsStore extends Store { */ @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 { (item) => (item.viewedAt ? 1 : -1) ); } + + /** + * Returns only the active (non-archived) notifications. + */ + @computed + get active(): Notification[] { + return this.orderedData.filter((n) => !n.archivedAt); + } } diff --git a/plugins/oidc/server/auth/OIDCStrategy.ts b/plugins/oidc/server/auth/OIDCStrategy.ts index 49caa844ed..c2a43f8fce 100644 --- a/plugins/oidc/server/auth/OIDCStrategy.ts +++ b/plugins/oidc/server/auth/OIDCStrategy.ts @@ -21,8 +21,8 @@ export class OIDCStrategy extends Strategy { authorizationParams(options: any) { return { - ...(options.originalQuery || {}), - ...(super.authorizationParams?.(options) || {}), + ...options.originalQuery, + ...super.authorizationParams?.(options), }; } } diff --git a/server/emails/templates/CommentCreatedEmail.tsx b/server/emails/templates/CommentCreatedEmail.tsx index ba9b8230bc..2158079305 100644 --- a/server/emails/templates/CommentCreatedEmail.tsx +++ b/server/emails/templates/CommentCreatedEmail.tsx @@ -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} `; diff --git a/server/migrations/20250810210844-add-data-to-notifications.js b/server/migrations/20250810210844-add-data-to-notifications.js new file mode 100644 index 0000000000..7d6eb5bd5f --- /dev/null +++ b/server/migrations/20250810210844-add-data-to-notifications.js @@ -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"); + }, +}; diff --git a/server/models/Notification.ts b/server/models/Notification.ts index 950aadb32b..2bf17302e5 100644 --- a/server/models/Notification.ts +++ b/server/models/Notification.ts @@ -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; diff --git a/server/presenters/notification.ts b/server/presenters/notification.ts index 12372832ec..c319761425 100644 --- a/server/presenters/notification.ts +++ b/server/presenters/notification.ts @@ -26,5 +26,6 @@ export default async function presentNotification( : undefined, revisionId: notification.revisionId, collectionId: notification.collectionId, + data: notification.data, }; } diff --git a/server/queues/processors/NotificationsProcessor.ts b/server/queues/processors/NotificationsProcessor.ts index 54689a3110..a2acddae3f 100644 --- a/server/queues/processors/NotificationsProcessor.ts +++ b/server/queues/processors/NotificationsProcessor.ts @@ -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); + } } diff --git a/server/queues/tasks/ReactionCreatedNotificationsTask.ts b/server/queues/tasks/ReactionCreatedNotificationsTask.ts new file mode 100644 index 0000000000..31c3356348 --- /dev/null +++ b/server/queues/tasks/ReactionCreatedNotificationsTask.ts @@ -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 { + 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, + }; + } +} diff --git a/server/queues/tasks/ReactionRemovedNotificationsTask.ts b/server/queues/tasks/ReactionRemovedNotificationsTask.ts new file mode 100644 index 0000000000..9f762c1d7a --- /dev/null +++ b/server/queues/tasks/ReactionRemovedNotificationsTask.ts @@ -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 { + 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, + }; + } +} diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index c753954bff..041e1a0c77 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -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", diff --git a/shared/types.ts b/shared/types.ts index 6cb817bcbe..94381d6ea0 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -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.CreateCollection]: false, [NotificationEventType.CreateComment]: true, [NotificationEventType.ResolveComment]: true, + [NotificationEventType.ReactionsCreate]: true, [NotificationEventType.CreateRevision]: false, [NotificationEventType.MentionedInDocument]: true, [NotificationEventType.MentionedInComment]: true,