diff --git a/app/javascript/mastodon/actions/notification_groups.ts b/app/javascript/mastodon/actions/notification_groups.ts index 01c0c7ef2f..ccd5d3bf71 100644 --- a/app/javascript/mastodon/actions/notification_groups.ts +++ b/app/javascript/mastodon/actions/notification_groups.ts @@ -1,6 +1,9 @@ import { apiFetchNotifications } from 'mastodon/api/notifications'; import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; -import type { NotificationGroupJSON } from 'mastodon/api_types/notifications'; +import type { + NotificationGroupJSON, + NotificationJSON, +} from 'mastodon/api_types/notifications'; import { allNotificationTypes } from 'mastodon/api_types/notifications'; import type { ApiStatusJSON } from 'mastodon/api_types/statuses'; import type { NotificationGap } from 'mastodon/reducers/notifications_groups'; @@ -24,7 +27,7 @@ function excludeAllTypesExcept(filter: string) { function dispatchAssociatedRecords( dispatch: AppDispatch, - notifications: NotificationGroupJSON[], + notifications: NotificationGroupJSON[] | NotificationJSON[], ) { const fetchedAccounts: ApiAccountJSON[] = []; const fetchedStatuses: ApiStatusJSON[] = []; @@ -97,6 +100,15 @@ export const fetchNotificationsGap = createDataLoadingThunk( }, ); +export const processNewNotificationForGroups = createAppAsyncThunk( + 'notificationsGroups/processNew', + (notification: NotificationJSON, { dispatch }) => { + dispatchAssociatedRecords(dispatch, [notification]); + + return notification; + }, +); + export const setNotificationsFilter = createAppAsyncThunk( 'notifications/filter/set', ({ filterType }: { filterType: string }, { dispatch }) => { diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index e7fe1c53ed..75b9c15c40 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -10,6 +10,7 @@ import { deleteAnnouncement, } from './announcements'; import { updateConversations } from './conversations'; +import { processNewNotificationForGroups } from './notification_groups'; import { updateNotifications, expandNotifications } from './notifications'; import { updateStatus } from './statuses'; import { @@ -98,10 +99,16 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti case 'delete': dispatch(deleteFromTimelines(data.payload)); break; - case 'notification': + case 'notification': { // @ts-expect-error - dispatch(updateNotifications(JSON.parse(data.payload), messages, locale)); + const notificationJSON = JSON.parse(data.payload); + dispatch(updateNotifications(notificationJSON, messages, locale)); + // TODO: remove this once the groups feature replaces the previous one + if(getState().notificationsGroups.groups.length > 0) { + dispatch(processNewNotificationForGroups(notificationJSON)); + } break; + } case 'conversation': // @ts-expect-error dispatch(updateConversations(JSON.parse(data.payload))); diff --git a/app/javascript/mastodon/api_types/notifications.ts b/app/javascript/mastodon/api_types/notifications.ts index 73b89cbde4..e60c0e1821 100644 --- a/app/javascript/mastodon/api_types/notifications.ts +++ b/app/javascript/mastodon/api_types/notifications.ts @@ -39,6 +39,14 @@ export type NotificationType = | 'admin.sign_up' | 'admin.report'; +export interface BaseNotificationJSON { + id: string; + type: NotificationType; + created_at: string; + group_key: string; + account: ApiAccountJSON; +} + export interface BaseNotificationGroupJSON { group_key: string; notifications_count: number; @@ -55,13 +63,28 @@ interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON { status: ApiStatusJSON; } +interface NotificationWithStatusJSON extends BaseNotificationJSON { + type: NotificationWithStatusType; + status: ApiStatusJSON; +} + interface ReportNotificationGroupJSON extends BaseNotificationGroupJSON { type: 'admin.report'; report: ApiReportJSON; } +interface ReportNotificationJSON extends BaseNotificationJSON { + type: 'admin.report'; + report: ApiReportJSON; +} + +type SimpleNotificationTypes = 'follow' | 'follow_request' | 'admin.sign_up'; interface SimpleNotificationGroupJSON extends BaseNotificationGroupJSON { - type: 'follow' | 'follow_request' | 'admin.sign_up'; + type: SimpleNotificationTypes; +} + +interface SimpleNotificationJSON extends BaseNotificationJSON { + type: SimpleNotificationTypes; } export interface ApiAccountWarningJSON { @@ -80,6 +103,11 @@ interface ModerationWarningNotificationGroupJSON moderation_warning: ApiAccountWarningJSON; } +interface ModerationWarningNotificationJSON extends BaseNotificationJSON { + type: 'moderation_warning'; + moderation_warning: ApiAccountWarningJSON; +} + export interface ApiAccountRelationshipSeveranceEventJSON { id: string; type: 'account_suspension' | 'domain_block' | 'user_domain_block'; @@ -96,6 +124,19 @@ interface AccountRelationshipSeveranceNotificationGroupJSON event: ApiAccountRelationshipSeveranceEventJSON; } +interface AccountRelationshipSeveranceNotificationJSON + extends BaseNotificationJSON { + type: 'severed_relationships'; + event: ApiAccountRelationshipSeveranceEventJSON; +} + +export type NotificationJSON = + | SimpleNotificationJSON + | ReportNotificationJSON + | AccountRelationshipSeveranceNotificationJSON + | NotificationWithStatusJSON + | ModerationWarningNotificationJSON; + export type NotificationGroupJSON = | SimpleNotificationGroupJSON | ReportNotificationGroupJSON diff --git a/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx b/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx index 3b616b7e11..6daf648166 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/notification_with_status.tsx @@ -52,8 +52,14 @@ export const NotificationWithStatus: React.FC<{ {label} - {/* @ts-expect-error -- is not yet typed */} - + is not yet typed + id={statusId} + contextType='notifications' + withDismiss + skipPrepend + avatarSize={40} + /> ); }; diff --git a/app/javascript/mastodon/models/notification_group.ts b/app/javascript/mastodon/models/notification_group.ts index f9c76e80e7..c8e078c747 100644 --- a/app/javascript/mastodon/models/notification_group.ts +++ b/app/javascript/mastodon/models/notification_group.ts @@ -3,6 +3,7 @@ import type { ApiAccountWarningJSON, BaseNotificationGroupJSON, NotificationGroupJSON, + NotificationJSON, NotificationType, NotificationWithStatusType, } from 'mastodon/api_types/notifications'; @@ -157,3 +158,46 @@ export function createNotificationGroupFromJSON( }; } } + +export function createNotificationGroupFromNotificationJSON( + notification: NotificationJSON, +) { + const group = { + sampleAccountsIds: [notification.account.id], + group_key: notification.group_key, + notifications_count: 1, + type: notification.type, + most_recent_notification_id: notification.id, + page_min_id: notification.id, + page_max_id: notification.id, + latest_page_notification_at: notification.created_at, + } as NotificationGroup; + + switch (notification.type) { + case 'favourite': + case 'reblog': + case 'status': + case 'mention': + case 'poll': + case 'update': + return { ...group, statusId: notification.status.id }; + case 'admin.report': + return { ...group, report: createReportFromJSON(notification.report) }; + case 'severed_relationships': + return { + ...group, + event: createAccountRelationshipSeveranceEventFromJSON( + notification.event, + ), + }; + case 'moderation_warning': + return { + ...group, + moderationWarning: createAccountWarningFromJSON( + notification.moderation_warning, + ), + }; + default: + return group; + } +} diff --git a/app/javascript/mastodon/reducers/index.ts b/app/javascript/mastodon/reducers/index.ts index 8e3af28143..99c10442a4 100644 --- a/app/javascript/mastodon/reducers/index.ts +++ b/app/javascript/mastodon/reducers/index.ts @@ -27,7 +27,7 @@ import { modalReducer } from './modal'; import { notificationPolicyReducer } from './notification_policy'; import { notificationRequestsReducer } from './notification_requests'; import notifications from './notifications'; -import { notificationGroupsReducer } from './notifications_groups'; +import { notificationsGroupsReducer } from './notifications_groups'; import { pictureInPictureReducer } from './picture_in_picture'; import polls from './polls'; import push_notifications from './push_notifications'; @@ -66,7 +66,7 @@ const reducers = { search, media_attachments, notifications, - notificationsGroups: notificationGroupsReducer, + notificationsGroups: notificationsGroupsReducer, height_cache, custom_emojis, lists, diff --git a/app/javascript/mastodon/reducers/notifications_groups.ts b/app/javascript/mastodon/reducers/notifications_groups.ts index 0e1908c84b..e42d753958 100644 --- a/app/javascript/mastodon/reducers/notifications_groups.ts +++ b/app/javascript/mastodon/reducers/notifications_groups.ts @@ -3,8 +3,12 @@ import { createReducer, isAnyOf } from '@reduxjs/toolkit'; import { fetchNotifications, fetchNotificationsGap, + processNewNotificationForGroups, } from 'mastodon/actions/notification_groups'; -import { createNotificationGroupFromJSON } from 'mastodon/models/notification_group'; +import { + createNotificationGroupFromJSON, + createNotificationGroupFromNotificationJSON, +} from 'mastodon/models/notification_group'; import type { NotificationGroup } from 'mastodon/models/notification_group'; export interface NotificationGap { @@ -28,9 +32,8 @@ const initialState: NotificationGroupsState = { readMarkerId: '0', }; -export const notificationGroupsReducer = createReducer( - initialState, - (builder) => { +export const notificationsGroupsReducer = + createReducer(initialState, (builder) => { builder .addCase(fetchNotifications.fulfilled, (state, action) => { state.groups = action.payload.map((json) => @@ -69,6 +72,36 @@ export const notificationGroupsReducer = createReducer( state.isLoading = false; }) + .addCase(processNewNotificationForGroups.fulfilled, (state, action) => { + const notification = action.payload; + const existingGroupIndex = state.groups.findIndex( + (group) => + group.type !== 'gap' && group.group_key === notification.group_key, + ); + + if (existingGroupIndex > -1) { + const existingGroup = state.groups[existingGroupIndex]; + + if (existingGroup && existingGroup.type !== 'gap') { + // Update the existing group + existingGroup.sampleAccountsIds.unshift(notification.account.id); + existingGroup.sampleAccountsIds.pop(); + + existingGroup.most_recent_notification_id = notification.id; + existingGroup.page_max_id = notification.id; + existingGroup.latest_page_notification_at = notification.created_at; + existingGroup.notifications_count += 1; + + state.groups.splice(existingGroupIndex, 1); + state.groups.unshift(existingGroup); + } + } else { + // Create a new group + state.groups.unshift( + createNotificationGroupFromNotificationJSON(notification), + ); + } + }) .addMatcher( isAnyOf(fetchNotifications.pending, fetchNotificationsGap.pending), (state) => { @@ -81,5 +114,4 @@ export const notificationGroupsReducer = createReducer( state.isLoading = false; }, ); - }, -); + }); diff --git a/app/models/notification.rb b/app/models/notification.rb index 01abe74f5e..6d40411478 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -30,6 +30,7 @@ class Notification < ApplicationRecord 'Poll' => :poll, }.freeze + # Please update app/javascript/api_types/notification.ts if you change this PROPERTIES = { mention: { filterable: true, diff --git a/app/serializers/rest/notification_group_serializer.rb b/app/serializers/rest/notification_group_serializer.rb index 9aa5663f4e..749f717754 100644 --- a/app/serializers/rest/notification_group_serializer.rb +++ b/app/serializers/rest/notification_group_serializer.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class REST::NotificationGroupSerializer < ActiveModel::Serializer + # Please update app/javascript/api_types/notification.ts when making changes to the attributes attributes :group_key, :notifications_count, :type, :most_recent_notification_id attribute :page_min_id, if: :paginated? diff --git a/app/serializers/rest/notification_serializer.rb b/app/serializers/rest/notification_serializer.rb index 966819585f..97b89e57dd 100644 --- a/app/serializers/rest/notification_serializer.rb +++ b/app/serializers/rest/notification_serializer.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class REST::NotificationSerializer < ActiveModel::Serializer + # Please update app/javascript/api_types/notification.ts when making changes to the attributes attributes :id, :type, :created_at, :group_key belongs_to :from_account, key: :account, serializer: REST::AccountSerializer