Skip to content

Commit

Permalink
Merge commit '82344342c1c5adb3f6a4b376559db737a9e982b7' into glitch-s…
Browse files Browse the repository at this point in the history
…oc/merge-upstream
  • Loading branch information
ClearlyClaire committed Jul 18, 2024
2 parents fb29f40 + 8234434 commit c75fe09
Show file tree
Hide file tree
Showing 85 changed files with 5,462 additions and 338 deletions.
5 changes: 5 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ You can contribute in the following ways:

If your contributions are accepted into Mastodon, you can request to be paid through [our OpenCollective](https://opencollective.com/mastodon).

Please review the org-level [contribution guidelines] for high-level acceptance
criteria guidance.

[contribution guidelines]: https://github.com/mastodon/.github/blob/main/CONTRIBUTING.md

## API Changes and Additions

Please note that any changes or additions made to the API should have an accompanying pull request on [our documentation repository](https://github.com/mastodon/documentation).
Expand Down
3 changes: 2 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -766,8 +766,9 @@ GEM
ruby-saml (1.16.0)
nokogiri (>= 1.13.10)
rexml
ruby-vips (2.2.1)
ruby-vips (2.2.2)
ffi (~> 1.12)
logger
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
rufus-scheduler (3.9.1)
Expand Down
55 changes: 41 additions & 14 deletions app/controllers/api/v2_alpha/notifications_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,27 @@ def index
with_read_replica do
@notifications = load_notifications
@group_metadata = load_group_metadata
@grouped_notifications = load_grouped_notifications
@relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id)
@sample_accounts = @grouped_notifications.flat_map(&:sample_accounts)

# Preload associations to avoid N+1s
ActiveRecord::Associations::Preloader.new(records: @sample_accounts, associations: [:account_stat, { user: :role }]).call
end

render json: @notifications.map { |notification| NotificationGroup.from_notification(notification, max_id: @group_metadata.dig(notification.group_key, :max_id)) }, each_serializer: REST::NotificationGroupSerializer, relationships: @relationships, group_metadata: @group_metadata
MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#index rendering') do |span|
statuses = @grouped_notifications.filter_map { |group| group.target_status&.id }

span.add_attributes(
'app.notification_grouping.count' => @grouped_notifications.size,
'app.notification_grouping.sample_account.count' => @sample_accounts.size,
'app.notification_grouping.sample_account.unique_count' => @sample_accounts.pluck(:id).uniq.size,
'app.notification_grouping.status.count' => statuses.size,
'app.notification_grouping.status.unique_count' => statuses.uniq.size
)

render json: @grouped_notifications, each_serializer: REST::NotificationGroupSerializer, relationships: @relationships, group_metadata: @group_metadata
end
end

def show
Expand All @@ -36,25 +53,35 @@ def dismiss
private

def load_notifications
notifications = browserable_account_notifications.includes(from_account: [:account_stat, :user]).to_a_grouped_paginated_by_id(
limit_param(DEFAULT_NOTIFICATIONS_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)

Notification.preload_cache_collection_target_statuses(notifications) do |target_statuses|
preload_collection(target_statuses, Status)
MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_notifications') do
notifications = browserable_account_notifications.includes(from_account: [:account_stat, :user]).to_a_grouped_paginated_by_id(
limit_param(DEFAULT_NOTIFICATIONS_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)

Notification.preload_cache_collection_target_statuses(notifications) do |target_statuses|
preload_collection(target_statuses, Status)
end
end
end

def load_group_metadata
return {} if @notifications.empty?

browserable_account_notifications
.where(group_key: @notifications.filter_map(&:group_key))
.where(id: (@notifications.last.id)..(@notifications.first.id))
.group(:group_key)
.pluck(:group_key, 'min(notifications.id) as min_id', 'max(notifications.id) as max_id', 'max(notifications.created_at) as latest_notification_at')
.to_h { |group_key, min_id, max_id, latest_notification_at| [group_key, { min_id: min_id, max_id: max_id, latest_notification_at: latest_notification_at }] }
MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_group_metadata') do
browserable_account_notifications
.where(group_key: @notifications.filter_map(&:group_key))
.where(id: (@notifications.last.id)..(@notifications.first.id))
.group(:group_key)
.pluck(:group_key, 'min(notifications.id) as min_id', 'max(notifications.id) as max_id', 'max(notifications.created_at) as latest_notification_at')
.to_h { |group_key, min_id, max_id, latest_notification_at| [group_key, { min_id: min_id, max_id: max_id, latest_notification_at: latest_notification_at }] }
end
end

def load_grouped_notifications
MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#load_grouped_notifications') do
@notifications.map { |notification| NotificationGroup.from_notification(notification, max_id: @group_metadata.dig(notification.group_key, :max_id)) }
end
end

def browserable_account_notifications
Expand Down
14 changes: 11 additions & 3 deletions app/javascript/mastodon/actions/markers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,17 @@ interface MarkerParam {
}

function getLastNotificationId(state: RootState): string | undefined {
// @ts-expect-error state.notifications is not yet typed
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call
return state.getIn(['notifications', 'lastReadId']);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
const enableBeta = state.settings.getIn(
['notifications', 'groupingBeta'],
false,
) as boolean;
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return enableBeta
? state.notificationGroups.lastReadId
: // @ts-expect-error state.notifications is not yet typed
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
state.getIn(['notifications', 'lastReadId']);
}

const buildPostMarkersParams = (state: RootState) => {
Expand Down
144 changes: 144 additions & 0 deletions app/javascript/mastodon/actions/notification_groups.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { createAction } from '@reduxjs/toolkit';

import {
apiClearNotifications,
apiFetchNotifications,
} from 'mastodon/api/notifications';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import type {
ApiNotificationGroupJSON,
ApiNotificationJSON,
} 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/notification_groups';
import {
selectSettingsNotificationsExcludedTypes,
selectSettingsNotificationsQuickFilterActive,
} from 'mastodon/selectors/settings';
import type { AppDispatch } from 'mastodon/store';
import {
createAppAsyncThunk,
createDataLoadingThunk,
} from 'mastodon/store/typed_functions';

import { importFetchedAccounts, importFetchedStatuses } from './importer';
import { NOTIFICATIONS_FILTER_SET } from './notifications';
import { saveSettings } from './settings';

function excludeAllTypesExcept(filter: string) {
return allNotificationTypes.filter((item) => item !== filter);
}

function dispatchAssociatedRecords(
dispatch: AppDispatch,
notifications: ApiNotificationGroupJSON[] | ApiNotificationJSON[],
) {
const fetchedAccounts: ApiAccountJSON[] = [];
const fetchedStatuses: ApiStatusJSON[] = [];

notifications.forEach((notification) => {
if ('sample_accounts' in notification) {
fetchedAccounts.push(...notification.sample_accounts);
}

if (notification.type === 'admin.report') {
fetchedAccounts.push(notification.report.target_account);
}

if (notification.type === 'moderation_warning') {
fetchedAccounts.push(notification.moderation_warning.target_account);
}

if ('status' in notification) {
fetchedStatuses.push(notification.status);
}
});

if (fetchedAccounts.length > 0)
dispatch(importFetchedAccounts(fetchedAccounts));

if (fetchedStatuses.length > 0)
dispatch(importFetchedStatuses(fetchedStatuses));
}

export const fetchNotifications = createDataLoadingThunk(
'notificationGroups/fetch',
async (_params, { getState }) => {
const activeFilter =
selectSettingsNotificationsQuickFilterActive(getState());

return apiFetchNotifications({
exclude_types:
activeFilter === 'all'
? selectSettingsNotificationsExcludedTypes(getState())
: excludeAllTypesExcept(activeFilter),
});
},
({ notifications }, { dispatch }) => {
dispatchAssociatedRecords(dispatch, notifications);
const payload: (ApiNotificationGroupJSON | NotificationGap)[] =
notifications;

// TODO: might be worth not using gaps for that…
// if (nextLink) payload.push({ type: 'gap', loadUrl: nextLink.uri });
if (notifications.length > 1)
payload.push({ type: 'gap', maxId: notifications.at(-1)?.page_min_id });

return payload;
// dispatch(submitMarkers());
},
);

export const fetchNotificationsGap = createDataLoadingThunk(
'notificationGroups/fetchGap',
async (params: { gap: NotificationGap }) =>
apiFetchNotifications({ max_id: params.gap.maxId }),

({ notifications }, { dispatch }) => {
dispatchAssociatedRecords(dispatch, notifications);

return { notifications };
},
);

export const processNewNotificationForGroups = createAppAsyncThunk(
'notificationGroups/processNew',
(notification: ApiNotificationJSON, { dispatch }) => {
dispatchAssociatedRecords(dispatch, [notification]);

return notification;
},
);

export const loadPending = createAction('notificationGroups/loadPending');

export const updateScrollPosition = createAction<{ top: boolean }>(
'notificationGroups/updateScrollPosition',
);

export const setNotificationsFilter = createAppAsyncThunk(
'notifications/filter/set',
({ filterType }: { filterType: string }, { dispatch }) => {
dispatch({
type: NOTIFICATIONS_FILTER_SET,
path: ['notifications', 'quickFilter', 'active'],
value: filterType,
});
// dispatch(expandNotifications({ forceLoad: true }));
void dispatch(fetchNotifications());
dispatch(saveSettings());
},
);

export const clearNotifications = createDataLoadingThunk(
'notifications/clear',
() => apiClearNotifications(),
);

export const markNotificationsAsRead = createAction(
'notificationGroups/markAsRead',
);

export const mountNotifications = createAction('notificationGroups/mount');
export const unmountNotifications = createAction('notificationGroups/unmount');
13 changes: 1 addition & 12 deletions app/javascript/mastodon/actions/notifications.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';

export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET';

export const NOTIFICATIONS_CLEAR = 'NOTIFICATIONS_CLEAR';
export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP';
export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';

Expand Down Expand Up @@ -174,7 +173,7 @@ const noOp = () => {};

let expandNotificationsController = new AbortController();

export function expandNotifications({ maxId, forceLoad } = {}, done = noOp) {
export function expandNotifications({ maxId, forceLoad = false } = {}, done = noOp) {
return (dispatch, getState) => {
const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
const notifications = getState().get('notifications');
Expand Down Expand Up @@ -257,16 +256,6 @@ export function expandNotificationsFail(error, isLoadingMore) {
};
}

export function clearNotifications() {
return (dispatch) => {
dispatch({
type: NOTIFICATIONS_CLEAR,
});

api().post('/api/v1/notifications/clear');
};
}

export function scrollTopNotifications(top) {
return {
type: NOTIFICATIONS_SCROLL_TOP,
Expand Down
18 changes: 18 additions & 0 deletions app/javascript/mastodon/actions/notifications_migration.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { createAppAsyncThunk } from 'mastodon/store';

import { fetchNotifications } from './notification_groups';
import { expandNotifications } from './notifications';

export const initializeNotifications = createAppAsyncThunk(
'notifications/initialize',
(_, { dispatch, getState }) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
const enableBeta = getState().settings.getIn(
['notifications', 'groupingBeta'],
false,
) as boolean;

if (enableBeta) void dispatch(fetchNotifications());
else dispatch(expandNotifications());
},
);
9 changes: 2 additions & 7 deletions app/javascript/mastodon/actions/notifications_typed.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,14 @@
import { createAction } from '@reduxjs/toolkit';

import type { ApiAccountJSON } from '../api_types/accounts';
// To be replaced once ApiNotificationJSON type exists
interface FakeApiNotificationJSON {
type: string;
account: ApiAccountJSON;
}
import type { ApiNotificationJSON } from 'mastodon/api_types/notifications';

export const notificationsUpdate = createAction(
'notifications/update',
({
playSound,
...args
}: {
notification: FakeApiNotificationJSON;
notification: ApiNotificationJSON;
usePendingItems: boolean;
playSound: boolean;
}) => ({
Expand Down
11 changes: 9 additions & 2 deletions app/javascript/mastodon/actions/streaming.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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().notificationGroups.groups.length > 0) {
dispatch(processNewNotificationForGroups(notificationJSON));
}
break;
}
case 'conversation':
// @ts-expect-error
dispatch(updateConversations(JSON.parse(data.payload)));
Expand Down
18 changes: 18 additions & 0 deletions app/javascript/mastodon/api/notifications.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import api, { apiRequest, getLinks } from 'mastodon/api';
import type { ApiNotificationGroupJSON } from 'mastodon/api_types/notifications';

export const apiFetchNotifications = async (params?: {
exclude_types?: string[];
max_id?: string;
}) => {
const response = await api().request<ApiNotificationGroupJSON[]>({
method: 'GET',
url: '/api/v2_alpha/notifications',
params,
});

return { notifications: response.data, links: getLinks(response) };
};

export const apiClearNotifications = () =>
apiRequest<undefined>('POST', 'v1/notifications/clear');
Loading

0 comments on commit c75fe09

Please sign in to comment.