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 { apiFetchNotifications } from 'mastodon/api/notifications';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts'; 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 { allNotificationTypes } from 'mastodon/api_types/notifications';
import type { ApiStatusJSON } from 'mastodon/api_types/statuses'; import type { ApiStatusJSON } from 'mastodon/api_types/statuses';
import type { NotificationGap } from 'mastodon/reducers/notifications_groups'; import type { NotificationGap } from 'mastodon/reducers/notifications_groups';
@ -24,7 +27,7 @@ function excludeAllTypesExcept(filter: string) {
function dispatchAssociatedRecords( function dispatchAssociatedRecords(
dispatch: AppDispatch, dispatch: AppDispatch,
notifications: NotificationGroupJSON[], notifications: NotificationGroupJSON[] | NotificationJSON[],
) { ) {
const fetchedAccounts: ApiAccountJSON[] = []; const fetchedAccounts: ApiAccountJSON[] = [];
const fetchedStatuses: ApiStatusJSON[] = []; 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( export const setNotificationsFilter = createAppAsyncThunk(
'notifications/filter/set', 'notifications/filter/set',
({ filterType }: { filterType: string }, { dispatch }) => { ({ filterType }: { filterType: string }, { dispatch }) => {

View File

@ -10,6 +10,7 @@ import {
deleteAnnouncement, deleteAnnouncement,
} from './announcements'; } from './announcements';
import { updateConversations } from './conversations'; import { updateConversations } from './conversations';
import { processNewNotificationForGroups } from './notification_groups';
import { updateNotifications, expandNotifications } from './notifications'; import { updateNotifications, expandNotifications } from './notifications';
import { updateStatus } from './statuses'; import { updateStatus } from './statuses';
import { import {
@ -98,10 +99,16 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
case 'delete': case 'delete':
dispatch(deleteFromTimelines(data.payload)); dispatch(deleteFromTimelines(data.payload));
break; break;
case 'notification': case 'notification': {
// @ts-expect-error // @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; break;
}
case 'conversation': case 'conversation':
// @ts-expect-error // @ts-expect-error
dispatch(updateConversations(JSON.parse(data.payload))); dispatch(updateConversations(JSON.parse(data.payload)));

View File

@ -39,6 +39,14 @@ export type NotificationType =
| 'admin.sign_up' | 'admin.sign_up'
| 'admin.report'; | 'admin.report';
export interface BaseNotificationJSON {
id: string;
type: NotificationType;
created_at: string;
group_key: string;
account: ApiAccountJSON;
}
export interface BaseNotificationGroupJSON { export interface BaseNotificationGroupJSON {
group_key: string; group_key: string;
notifications_count: number; notifications_count: number;
@ -55,13 +63,28 @@ interface NotificationGroupWithStatusJSON extends BaseNotificationGroupJSON {
status: ApiStatusJSON; status: ApiStatusJSON;
} }
interface NotificationWithStatusJSON extends BaseNotificationJSON {
type: NotificationWithStatusType;
status: ApiStatusJSON;
}
interface ReportNotificationGroupJSON extends BaseNotificationGroupJSON { interface ReportNotificationGroupJSON extends BaseNotificationGroupJSON {
type: 'admin.report'; type: 'admin.report';
report: ApiReportJSON; report: ApiReportJSON;
} }
interface ReportNotificationJSON extends BaseNotificationJSON {
type: 'admin.report';
report: ApiReportJSON;
}
type SimpleNotificationTypes = 'follow' | 'follow_request' | 'admin.sign_up';
interface SimpleNotificationGroupJSON extends BaseNotificationGroupJSON { interface SimpleNotificationGroupJSON extends BaseNotificationGroupJSON {
type: 'follow' | 'follow_request' | 'admin.sign_up'; type: SimpleNotificationTypes;
}
interface SimpleNotificationJSON extends BaseNotificationJSON {
type: SimpleNotificationTypes;
} }
export interface ApiAccountWarningJSON { export interface ApiAccountWarningJSON {
@ -80,6 +103,11 @@ interface ModerationWarningNotificationGroupJSON
moderation_warning: ApiAccountWarningJSON; moderation_warning: ApiAccountWarningJSON;
} }
interface ModerationWarningNotificationJSON extends BaseNotificationJSON {
type: 'moderation_warning';
moderation_warning: ApiAccountWarningJSON;
}
export interface ApiAccountRelationshipSeveranceEventJSON { export interface ApiAccountRelationshipSeveranceEventJSON {
id: string; id: string;
type: 'account_suspension' | 'domain_block' | 'user_domain_block'; type: 'account_suspension' | 'domain_block' | 'user_domain_block';
@ -96,6 +124,19 @@ interface AccountRelationshipSeveranceNotificationGroupJSON
event: ApiAccountRelationshipSeveranceEventJSON; event: ApiAccountRelationshipSeveranceEventJSON;
} }
interface AccountRelationshipSeveranceNotificationJSON
extends BaseNotificationJSON {
type: 'severed_relationships';
event: ApiAccountRelationshipSeveranceEventJSON;
}
export type NotificationJSON =
| SimpleNotificationJSON
| ReportNotificationJSON
| AccountRelationshipSeveranceNotificationJSON
| NotificationWithStatusJSON
| ModerationWarningNotificationJSON;
export type NotificationGroupJSON = export type NotificationGroupJSON =
| SimpleNotificationGroupJSON | SimpleNotificationGroupJSON
| ReportNotificationGroupJSON | ReportNotificationGroupJSON

View File

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

View File

@ -3,6 +3,7 @@ import type {
ApiAccountWarningJSON, ApiAccountWarningJSON,
BaseNotificationGroupJSON, BaseNotificationGroupJSON,
NotificationGroupJSON, NotificationGroupJSON,
NotificationJSON,
NotificationType, NotificationType,
NotificationWithStatusType, NotificationWithStatusType,
} from 'mastodon/api_types/notifications'; } 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 { notificationPolicyReducer } from './notification_policy';
import { notificationRequestsReducer } from './notification_requests'; import { notificationRequestsReducer } from './notification_requests';
import notifications from './notifications'; import notifications from './notifications';
import { notificationGroupsReducer } from './notifications_groups'; import { notificationsGroupsReducer } from './notifications_groups';
import { pictureInPictureReducer } from './picture_in_picture'; import { pictureInPictureReducer } from './picture_in_picture';
import polls from './polls'; import polls from './polls';
import push_notifications from './push_notifications'; import push_notifications from './push_notifications';
@ -66,7 +66,7 @@ const reducers = {
search, search,
media_attachments, media_attachments,
notifications, notifications,
notificationsGroups: notificationGroupsReducer, notificationsGroups: notificationsGroupsReducer,
height_cache, height_cache,
custom_emojis, custom_emojis,
lists, lists,

View File

@ -3,8 +3,12 @@ import { createReducer, isAnyOf } from '@reduxjs/toolkit';
import { import {
fetchNotifications, fetchNotifications,
fetchNotificationsGap, fetchNotificationsGap,
processNewNotificationForGroups,
} from 'mastodon/actions/notification_groups'; } 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'; import type { NotificationGroup } from 'mastodon/models/notification_group';
export interface NotificationGap { export interface NotificationGap {
@ -28,9 +32,8 @@ const initialState: NotificationGroupsState = {
readMarkerId: '0', readMarkerId: '0',
}; };
export const notificationGroupsReducer = createReducer<NotificationGroupsState>( export const notificationsGroupsReducer =
initialState, createReducer<NotificationGroupsState>(initialState, (builder) => {
(builder) => {
builder builder
.addCase(fetchNotifications.fulfilled, (state, action) => { .addCase(fetchNotifications.fulfilled, (state, action) => {
state.groups = action.payload.map((json) => state.groups = action.payload.map((json) =>
@ -69,6 +72,36 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
state.isLoading = false; 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( .addMatcher(
isAnyOf(fetchNotifications.pending, fetchNotificationsGap.pending), isAnyOf(fetchNotifications.pending, fetchNotificationsGap.pending),
(state) => { (state) => {
@ -81,5 +114,4 @@ export const notificationGroupsReducer = createReducer<NotificationGroupsState>(
state.isLoading = false; state.isLoading = false;
}, },
); );
}, });
);

View File

@ -30,6 +30,7 @@ class Notification < ApplicationRecord
'Poll' => :poll, 'Poll' => :poll,
}.freeze }.freeze
# Please update app/javascript/api_types/notification.ts if you change this
PROPERTIES = { PROPERTIES = {
mention: { mention: {
filterable: true, filterable: true,

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class REST::NotificationGroupSerializer < ActiveModel::Serializer 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 attributes :group_key, :notifications_count, :type, :most_recent_notification_id
attribute :page_min_id, if: :paginated? attribute :page_min_id, if: :paginated?

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class REST::NotificationSerializer < ActiveModel::Serializer 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 attributes :id, :type, :created_at, :group_key
belongs_to :from_account, key: :account, serializer: REST::AccountSerializer belongs_to :from_account, key: :account, serializer: REST::AccountSerializer