Support streaming notifications

Renaud Chaput 2024-06-14 22:15:10 +02:00
parent 006ef1081e
commit a885a2e824
No known key found for this signature in database
GPG Key ID: BCFC859D49B46990
10 changed files with 160 additions and 15 deletions

View File

@ -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 }) => {

View File

@ -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)));

View File

@ -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

View File

@ -52,8 +52,14 @@ export const NotificationWithStatus: React.FC<{
{label}
</div>
{/* @ts-expect-error -- <Status> is not yet typed */}
<Status id={statusId} contextType='notifications' withDismiss skipPrepend avatarSize={40} />
<Status
// @ts-expect-error -- <Status> is not yet typed
id={statusId}
contextType='notifications'
withDismiss
skipPrepend
avatarSize={40}
/>
</div>
);
};

View File

@ -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;
}
}

View File

@ -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,

View File

@ -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<NotificationGroupsState>(
initialState,
(builder) => {
export const notificationsGroupsReducer =
createReducer<NotificationGroupsState>(initialState, (builder) => {
builder
.addCase(fetchNotifications.fulfilled, (state, action) => {
state.groups = action.payload.map((json) =>
@ -69,6 +72,36 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
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<NotificationGroupsState>(
state.isLoading = false;
},
);
},
);
});

View File

@ -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,

View File

@ -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?

View File

@ -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