diff --git a/Gemfile.lock b/Gemfile.lock index 699e2f83e1..e4d787eb64 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -758,7 +758,7 @@ GEM rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rspec (3.0.3) + rubocop-rspec (3.0.4) rubocop (~> 1.61) rubocop-rspec_rails (2.30.0) rubocop (~> 1.61) diff --git a/app/controllers/api/v1/notifications/policies_controller.rb b/app/controllers/api/v1/notifications/policies_controller.rb index 1ec336f9a5..9d70c283be 100644 --- a/app/controllers/api/v1/notifications/policies_controller.rb +++ b/app/controllers/api/v1/notifications/policies_controller.rb @@ -8,12 +8,12 @@ class Api::V1::Notifications::PoliciesController < Api::BaseController before_action :set_policy def show - render json: @policy, serializer: REST::NotificationPolicySerializer + render json: @policy, serializer: REST::V1::NotificationPolicySerializer end def update @policy.update!(resource_params) - render json: @policy, serializer: REST::NotificationPolicySerializer + render json: @policy, serializer: REST::V1::NotificationPolicySerializer end private diff --git a/app/controllers/api/v1/notifications/requests_controller.rb b/app/controllers/api/v1/notifications/requests_controller.rb index b4207147c8..0710166d05 100644 --- a/app/controllers/api/v1/notifications/requests_controller.rb +++ b/app/controllers/api/v1/notifications/requests_controller.rb @@ -29,7 +29,7 @@ class Api::V1::Notifications::RequestsController < Api::BaseController end def dismiss - @request.destroy! + DismissNotificationRequestService.new.call(@request) render_empty end diff --git a/app/controllers/api/v2/notifications/policies_controller.rb b/app/controllers/api/v2/notifications/policies_controller.rb new file mode 100644 index 0000000000..637587967f --- /dev/null +++ b/app/controllers/api/v2/notifications/policies_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class Api::V2::Notifications::PoliciesController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:notifications' }, only: :show + before_action -> { doorkeeper_authorize! :write, :'write:notifications' }, only: :update + + before_action :require_user! + before_action :set_policy + + def show + render json: @policy, serializer: REST::NotificationPolicySerializer + end + + def update + @policy.update!(resource_params) + render json: @policy, serializer: REST::NotificationPolicySerializer + end + + private + + def set_policy + @policy = NotificationPolicy.find_or_initialize_by(account: current_account) + + with_read_replica do + @policy.summarize! + end + end + + def resource_params + params.permit( + :for_not_following, + :for_not_followers, + :for_new_accounts, + :for_private_mentions, + :for_limited_accounts + ) + end +end diff --git a/app/controllers/api/v2_alpha/notifications_controller.rb b/app/controllers/api/v2_alpha/notifications_controller.rb index 837499e898..d0205ad6af 100644 --- a/app/controllers/api/v2_alpha/notifications_controller.rb +++ b/app/controllers/api/v2_alpha/notifications_controller.rb @@ -16,10 +16,10 @@ class Api::V2Alpha::NotificationsController < Api::BaseController @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) + @presenter = GroupedNotificationsPresenter.new(@grouped_notifications, expand_accounts: expand_accounts_param) # Preload associations to avoid N+1s - ActiveRecord::Associations::Preloader.new(records: @sample_accounts, associations: [:account_stat, { user: :role }]).call + ActiveRecord::Associations::Preloader.new(records: @presenter.accounts, associations: [:account_stat, { user: :role }]).call end MastodonOTELTracer.in_span('Api::V2Alpha::NotificationsController#index rendering') do |span| @@ -27,14 +27,14 @@ class Api::V2Alpha::NotificationsController < Api::BaseController 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.account.count' => @presenter.accounts.size, + 'app.notification_grouping.partial_account.count' => @presenter.partial_accounts.size, 'app.notification_grouping.status.count' => statuses.size, - 'app.notification_grouping.status.unique_count' => statuses.uniq.size + 'app.notification_grouping.status.unique_count' => statuses.uniq.size, + 'app.notification_grouping.expand_accounts_param' => expand_accounts_param ) - presenter = GroupedNotificationsPresenter.new(@grouped_notifications) - render json: presenter, serializer: REST::DedupNotificationGroupSerializer, relationships: @relationships, group_metadata: @group_metadata + render json: @presenter, serializer: REST::DedupNotificationGroupSerializer, relationships: @relationships, group_metadata: @group_metadata, expand_accounts: expand_accounts_param end end @@ -131,4 +131,15 @@ class Api::V2Alpha::NotificationsController < Api::BaseController def pagination_params(core_params) params.slice(:limit, :types, :exclude_types, :include_filtered).permit(:limit, :include_filtered, types: [], exclude_types: []).merge(core_params) end + + def expand_accounts_param + case params[:expand_accounts] + when nil, 'full' + 'full' + when 'partial_avatars' + 'partial_avatars' + else + raise Mastodon::InvalidParameterError, "Invalid value for 'expand_accounts': '#{params[:expand_accounts]}', allowed values are 'full' and 'partial_avatars'" + end + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index b75c995864..11aa202dda 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -118,7 +118,7 @@ module ApplicationHelper def material_symbol(icon, attributes = {}) inline_svg_tag( "400-24px/#{icon}.svg", - class: %w(icon).concat(attributes[:class].to_s.split), + class: ['icon', "material-#{icon}"].concat(attributes[:class].to_s.split), role: :img ) end @@ -129,27 +129,27 @@ module ApplicationHelper def visibility_icon(status) if status.public_visibility? - fa_icon('globe', title: I18n.t('statuses.visibilities.public')) + material_symbol('globe', title: I18n.t('statuses.visibilities.public')) elsif status.unlisted_visibility? - fa_icon('unlock', title: I18n.t('statuses.visibilities.unlisted')) + material_symbol('lock_open', title: I18n.t('statuses.visibilities.unlisted')) elsif status.public_unlisted_visibility? - fa_icon('cloud', title: I18n.t('statuses.visibilities.public_unlisted')) + material_symbol('cloud', title: I18n.t('statuses.visibilities.public_unlisted')) elsif status.login_visibility? - fa_icon('key', title: I18n.t('statuses.visibilities.login')) + material_symbol('key', title: I18n.t('statuses.visibilities.login')) elsif status.private_visibility? || status.limited_visibility? - fa_icon('lock', title: I18n.t('statuses.visibilities.private')) + material_symbol('lock', title: I18n.t('statuses.visibilities.private')) elsif status.direct_visibility? - fa_icon('at', title: I18n.t('statuses.visibilities.direct')) + material_symbol('alternate_email', title: I18n.t('statuses.visibilities.direct')) end end def interrelationships_icon(relationships, account_id) if relationships.following[account_id] && relationships.followed_by[account_id] - fa_icon('exchange', title: I18n.t('relationships.mutual'), class: 'fa-fw active passive') + material_symbol('sync_alt', title: I18n.t('relationships.mutual'), class: 'active passive') elsif relationships.following[account_id] - fa_icon(locale_direction == 'ltr' ? 'arrow-right' : 'arrow-left', title: I18n.t('relationships.following'), class: 'fa-fw active') + material_symbol(locale_direction == 'ltr' ? 'arrow_right_alt' : 'arrow_left_alt', title: I18n.t('relationships.following'), class: 'active') elsif relationships.followed_by[account_id] - fa_icon(locale_direction == 'ltr' ? 'arrow-left' : 'arrow-right', title: I18n.t('relationships.followers'), class: 'fa-fw passive') + material_symbol(locale_direction == 'ltr' ? 'arrow_left_alt' : 'arrow_right_alt', title: I18n.t('relationships.followers'), class: 'passive') end end diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index 8ded11e03d..648652623b 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -60,19 +60,19 @@ module StatusesHelper def fa_visibility_icon(status) case status.visibility when 'public' - fa_icon 'globe fw' + material_symbol 'globe' when 'unlisted' - fa_icon 'unlock fw' + material_symbol 'lock_open' when 'public_unlisted' - fa_icon 'cloud fw' + material_symbol 'cloud' when 'login' - fa_icon 'key fw' + material_symbol 'key' when 'private' - fa_icon 'lock fw' + material_symbol 'lock' when 'limited' - fa_icon 'get-pocket fw' + material_symbol 'shield' when 'direct' - fa_icon 'at fw' + material_symbol 'alternate_email' end end diff --git a/app/javascript/mastodon/actions/notification_groups.ts b/app/javascript/mastodon/actions/notification_groups.ts index d4fdd2b4b5..fb5cf9b316 100644 --- a/app/javascript/mastodon/actions/notification_groups.ts +++ b/app/javascript/mastodon/actions/notification_groups.ts @@ -15,6 +15,7 @@ import type { NotificationGap } from 'mastodon/reducers/notification_groups'; import { selectSettingsNotificationsExcludedTypes, selectSettingsNotificationsQuickFilterActive, + selectSettingsNotificationsShows, } from 'mastodon/selectors/settings'; import type { AppDispatch } from 'mastodon/store'; import { @@ -107,7 +108,31 @@ export const fetchNotificationsGap = createDataLoadingThunk( export const processNewNotificationForGroups = createAppAsyncThunk( 'notificationGroups/processNew', - (notification: ApiNotificationJSON, { dispatch }) => { + (notification: ApiNotificationJSON, { dispatch, getState }) => { + const state = getState(); + const activeFilter = selectSettingsNotificationsQuickFilterActive(state); + const notificationShows = selectSettingsNotificationsShows(state); + + const showInColumn = + activeFilter === 'all' + ? notificationShows[notification.type] + : activeFilter === notification.type; + + if (!showInColumn) return; + + if ( + (notification.type === 'mention' || notification.type === 'update') && + notification.status.filtered + ) { + const filters = notification.status.filtered.filter((result) => + result.filter.context.includes('notifications'), + ); + + if (filters.some((result) => result.filter.filter_action === 'hide')) { + return; + } + } + dispatchAssociatedRecords(dispatch, [notification]); return notification; diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js index b26e206c34..83a20e55b3 100644 --- a/app/javascript/mastodon/actions/notifications.js +++ b/app/javascript/mastodon/actions/notifications.js @@ -65,6 +65,14 @@ export const NOTIFICATION_REQUEST_DISMISS_REQUEST = 'NOTIFICATION_REQUEST_DISMIS export const NOTIFICATION_REQUEST_DISMISS_SUCCESS = 'NOTIFICATION_REQUEST_DISMISS_SUCCESS'; export const NOTIFICATION_REQUEST_DISMISS_FAIL = 'NOTIFICATION_REQUEST_DISMISS_FAIL'; +export const NOTIFICATION_REQUESTS_ACCEPT_REQUEST = 'NOTIFICATION_REQUESTS_ACCEPT_REQUEST'; +export const NOTIFICATION_REQUESTS_ACCEPT_SUCCESS = 'NOTIFICATION_REQUESTS_ACCEPT_SUCCESS'; +export const NOTIFICATION_REQUESTS_ACCEPT_FAIL = 'NOTIFICATION_REQUESTS_ACCEPT_FAIL'; + +export const NOTIFICATION_REQUESTS_DISMISS_REQUEST = 'NOTIFICATION_REQUESTS_DISMISS_REQUEST'; +export const NOTIFICATION_REQUESTS_DISMISS_SUCCESS = 'NOTIFICATION_REQUESTS_DISMISS_SUCCESS'; +export const NOTIFICATION_REQUESTS_DISMISS_FAIL = 'NOTIFICATION_REQUESTS_DISMISS_FAIL'; + export const NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST = 'NOTIFICATIONS_FOR_REQUEST_FETCH_REQUEST'; export const NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS = 'NOTIFICATIONS_FOR_REQUEST_FETCH_SUCCESS'; export const NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL = 'NOTIFICATIONS_FOR_REQUEST_FETCH_FAIL'; @@ -513,6 +521,62 @@ export const dismissNotificationRequestFail = (id, error) => ({ error, }); +export const acceptNotificationRequests = (ids) => (dispatch, getState) => { + const count = ids.reduce((count, id) => count + selectNotificationCountForRequest(getState(), id), 0); + dispatch(acceptNotificationRequestsRequest(ids)); + + api().post(`/api/v1/notifications/requests/accept`, { id: ids }).then(() => { + dispatch(acceptNotificationRequestsSuccess(ids)); + dispatch(decreasePendingNotificationsCount(count)); + }).catch(err => { + dispatch(acceptNotificationRequestFail(ids, err)); + }); +}; + +export const acceptNotificationRequestsRequest = ids => ({ + type: NOTIFICATION_REQUESTS_ACCEPT_REQUEST, + ids, +}); + +export const acceptNotificationRequestsSuccess = ids => ({ + type: NOTIFICATION_REQUESTS_ACCEPT_SUCCESS, + ids, +}); + +export const acceptNotificationRequestsFail = (ids, error) => ({ + type: NOTIFICATION_REQUESTS_ACCEPT_FAIL, + ids, + error, +}); + +export const dismissNotificationRequests = (ids) => (dispatch, getState) => { + const count = ids.reduce((count, id) => count + selectNotificationCountForRequest(getState(), id), 0); + dispatch(acceptNotificationRequestsRequest(ids)); + + api().post(`/api/v1/notifications/requests/dismiss`, { id: ids }).then(() => { + dispatch(dismissNotificationRequestsSuccess(ids)); + dispatch(decreasePendingNotificationsCount(count)); + }).catch(err => { + dispatch(dismissNotificationRequestFail(ids, err)); + }); +}; + +export const dismissNotificationRequestsRequest = ids => ({ + type: NOTIFICATION_REQUESTS_DISMISS_REQUEST, + ids, +}); + +export const dismissNotificationRequestsSuccess = ids => ({ + type: NOTIFICATION_REQUESTS_DISMISS_SUCCESS, + ids, +}); + +export const dismissNotificationRequestsFail = (ids, error) => ({ + type: NOTIFICATION_REQUESTS_DISMISS_FAIL, + ids, + error, +}); + export const fetchNotificationsForRequest = accountId => (dispatch, getState) => { const current = getState().getIn(['notificationRequests', 'current']); const params = { account_id: accountId }; diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index 6ee9ac687b..60e49ddf34 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -105,7 +105,7 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti 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) { + if(getState().settings.getIn(['notifications', 'groupingBeta'], false)) { dispatch(processNewNotificationForGroups(notificationJSON)); } break; diff --git a/app/javascript/mastodon/api/notification_policies.ts b/app/javascript/mastodon/api/notification_policies.ts index 4032134fb5..3bc8174139 100644 --- a/app/javascript/mastodon/api/notification_policies.ts +++ b/app/javascript/mastodon/api/notification_policies.ts @@ -2,8 +2,8 @@ import { apiRequestGet, apiRequestPut } from 'mastodon/api'; import type { NotificationPolicyJSON } from 'mastodon/api_types/notification_policies'; export const apiGetNotificationPolicy = () => - apiRequestGet('/v1/notifications/policy'); + apiRequestGet('v2/notifications/policy'); export const apiUpdateNotificationsPolicy = ( policy: Partial, -) => apiRequestPut('/v1/notifications/policy', policy); +) => apiRequestPut('v2/notifications/policy', policy); diff --git a/app/javascript/mastodon/api_types/notification_policies.ts b/app/javascript/mastodon/api_types/notification_policies.ts index 0f4a2d132e..1c3970782c 100644 --- a/app/javascript/mastodon/api_types/notification_policies.ts +++ b/app/javascript/mastodon/api_types/notification_policies.ts @@ -1,10 +1,13 @@ // See app/serializers/rest/notification_policy_serializer.rb +export type NotificationPolicyValue = 'accept' | 'filter' | 'drop'; + export interface NotificationPolicyJSON { - filter_not_following: boolean; - filter_not_followers: boolean; - filter_new_accounts: boolean; - filter_private_mentions: boolean; + for_not_following: NotificationPolicyValue; + for_not_followers: NotificationPolicyValue; + for_new_accounts: NotificationPolicyValue; + for_private_mentions: NotificationPolicyValue; + for_limited_accounts: NotificationPolicyValue; summary: { pending_requests_count: number; pending_notifications_count: number; diff --git a/app/javascript/mastodon/api_types/statuses.ts b/app/javascript/mastodon/api_types/statuses.ts index 8e76078c5a..a83c99351e 100644 --- a/app/javascript/mastodon/api_types/statuses.ts +++ b/app/javascript/mastodon/api_types/statuses.ts @@ -64,6 +64,29 @@ export interface ApiPreviewCardJSON { authors: ApiPreviewCardAuthorJSON[]; } +export type FilterContext = + | 'home' + | 'notifications' + | 'public' + | 'thread' + | 'account'; + +export interface ApiFilterJSON { + id: string; + title: string; + context: FilterContext; + expires_at: string; + filter_action: 'warn' | 'hide'; + keywords?: unknown[]; // TODO: FilterKeywordSerializer + statuses?: unknown[]; // TODO: FilterStatusSerializer +} + +export interface ApiFilterResultJSON { + filter: ApiFilterJSON; + keyword_matches: string[]; + status_matches: string[]; +} + export interface ApiStatusJSON { id: string; created_at: string; @@ -86,8 +109,7 @@ export interface ApiStatusJSON { bookmarked?: boolean; pinned?: boolean; - // filtered: FilterResult[] - filtered: unknown; // TODO + filtered?: ApiFilterResultJSON[]; content?: string; text?: string; diff --git a/app/javascript/mastodon/components/account.jsx b/app/javascript/mastodon/components/account.jsx index 8f5d9c1507..6204dcdf35 100644 --- a/app/javascript/mastodon/components/account.jsx +++ b/app/javascript/mastodon/components/account.jsx @@ -106,7 +106,7 @@ const Account = ({ size = 46, account, onFollow, onBlock, onMute, onMuteNotifica ); } else if (defaultAction === 'mute') { - buttons =