From 5b395774c05d7deb619e7fef725bfe957615f452 Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 29 Apr 2026 17:53:29 +0200 Subject: [PATCH] Add fallback attributes to notifications for new and infrequent notifications (#38832) --- .../api/v1/notifications_controller.rb | 8 +- .../api/v2/notifications_controller.rb | 8 +- app/lib/text_formatter.rb | 16 ++-- app/models/notification.rb | 17 ++++ .../concerns/notification_fallback_concern.rb | 96 +++++++++++++++++++ .../rest/notification_group_serializer.rb | 6 ++ .../rest/notification_serializer.rb | 6 ++ config/locales/en.yml | 21 ++++ .../notification_group_serializer_spec.rb | 32 ++++++- .../rest/notification_serializer_spec.rb | 29 +++++- 10 files changed, 225 insertions(+), 14 deletions(-) create mode 100644 app/serializers/concerns/notification_fallback_concern.rb diff --git a/app/controllers/api/v1/notifications_controller.rb b/app/controllers/api/v1/notifications_controller.rb index 13919b400d..91995f8421 100644 --- a/app/controllers/api/v1/notifications_controller.rb +++ b/app/controllers/api/v1/notifications_controller.rb @@ -16,7 +16,7 @@ class Api::V1::NotificationsController < Api::BaseController @relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id) end - render json: @notifications, each_serializer: REST::NotificationSerializer, relationships: @relationships + render json: @notifications, each_serializer: REST::NotificationSerializer, relationships: @relationships, supported_notification_types: params[:supported_types] end def unread_count @@ -29,7 +29,7 @@ class Api::V1::NotificationsController < Api::BaseController def show @notification = current_account.notifications.without_suspended.find(params[:id]) - render json: @notification, serializer: REST::NotificationSerializer + render json: @notification, serializer: REST::NotificationSerializer, supported_notification_types: params[:supported_types] end def clear @@ -89,6 +89,8 @@ class Api::V1::NotificationsController < Api::BaseController end def pagination_params(core_params) - params.slice(:limit, :account_id, :types, :exclude_types, :include_filtered).permit(:limit, :account_id, :include_filtered, types: [], exclude_types: []).merge(core_params) + params.slice(:limit, :account_id, :types, :exclude_types, :include_filtered, :supported_types) + .permit(:limit, :account_id, :include_filtered, types: [], exclude_types: [], supported_types: []) + .merge(core_params) end end diff --git a/app/controllers/api/v2/notifications_controller.rb b/app/controllers/api/v2/notifications_controller.rb index 848c361cfc..f54cd230b7 100644 --- a/app/controllers/api/v2/notifications_controller.rb +++ b/app/controllers/api/v2/notifications_controller.rb @@ -33,7 +33,7 @@ class Api::V2::NotificationsController < Api::BaseController 'app.notification_grouping.expand_accounts_param' => expand_accounts_param ) - render json: @presenter, serializer: REST::DedupNotificationGroupSerializer, relationships: @relationships, expand_accounts: expand_accounts_param + render json: @presenter, serializer: REST::DedupNotificationGroupSerializer, relationships: @relationships, expand_accounts: expand_accounts_param, supported_notification_types: params[:supported_types] end end @@ -48,7 +48,7 @@ class Api::V2::NotificationsController < Api::BaseController def show @notification = current_account.notifications.without_suspended.by_group_key(params[:group_key]).take! presenter = GroupedNotificationsPresenter.new(NotificationGroup.from_notifications([@notification])) - render json: presenter, serializer: REST::DedupNotificationGroupSerializer + render json: presenter, serializer: REST::DedupNotificationGroupSerializer, supported_notification_types: params[:supported_types] end def clear @@ -138,7 +138,9 @@ class Api::V2::NotificationsController < Api::BaseController end def pagination_params(core_params) - params.slice(:limit, :include_filtered, :types, :exclude_types, :grouped_types).permit(:limit, :include_filtered, types: [], exclude_types: [], grouped_types: []).merge(core_params) + params.slice(:limit, :include_filtered, :types, :exclude_types, :grouped_types, :supported_types) + .permit(:limit, :include_filtered, types: [], exclude_types: [], grouped_types: [], supported_types: []) + .merge(core_params) end def expand_accounts_param diff --git a/app/lib/text_formatter.rb b/app/lib/text_formatter.rb index 10f007ff37..597135314b 100644 --- a/app/lib/text_formatter.rb +++ b/app/lib/text_formatter.rb @@ -76,6 +76,15 @@ class TextFormatter rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError h(url) end + + def link_to_mention(account, with_domain: false) + url = ActivityPub::TagManager.instance.url_for(account) + display_username = with_domain ? account.pretty_acct : account.username + + <<~HTML.squish + @#{h(display_username)} + HTML + end end private @@ -136,12 +145,7 @@ class TextFormatter return "@#{h(entity[:screen_name])}" if account.nil? - url = ActivityPub::TagManager.instance.url_for(account) - display_username = same_username_hits&.positive? || with_domains? ? account.pretty_acct : account.username - - <<~HTML.squish - @#{h(display_username)} - HTML + TextFormatter.link_to_mention(account, with_domain: same_username_hits&.positive? || with_domains?) end def entity_cache diff --git a/app/models/notification.rb b/app/models/notification.rb index a54788dd2e..9a1a366869 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -37,54 +37,71 @@ class Notification < ApplicationRecord PROPERTIES = { mention: { filterable: true, + baseline: true, }.freeze, status: { filterable: false, + baseline: true, }.freeze, reblog: { filterable: true, + baseline: true, }.freeze, follow: { filterable: true, + baseline: true, }.freeze, follow_request: { filterable: true, + baseline: true, }.freeze, favourite: { filterable: true, + baseline: true, }.freeze, poll: { filterable: false, + baseline: true, }.freeze, update: { filterable: false, + baseline: true, }.freeze, severed_relationships: { filterable: false, + baseline: false, }.freeze, moderation_warning: { filterable: false, + baseline: false, }.freeze, annual_report: { filterable: false, + baseline: true, }.freeze, 'admin.sign_up': { filterable: false, + baseline: false, }.freeze, 'admin.report': { filterable: false, + baseline: false, }.freeze, quote: { filterable: true, + baseline: true, }.freeze, quoted_update: { filterable: false, + baseline: true, }.freeze, added_to_collection: { filterable: true, + baseline: false, }.freeze, collection_update: { filterable: false, + baseline: false, }.freeze, }.freeze diff --git a/app/serializers/concerns/notification_fallback_concern.rb b/app/serializers/concerns/notification_fallback_concern.rb new file mode 100644 index 0000000000..a3fbf4f569 --- /dev/null +++ b/app/serializers/concerns/notification_fallback_concern.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module NotificationFallbackConcern + extend ActiveSupport::Concern + + def fallback + { + title: fallback_title, + summary: fallback_summary, + description: nil, + } + end + + def needs_fallback? + return false if instance_options[:supported_notification_types].nil? + return false if Notification::PROPERTIES.dig(object.type, :baseline) || instance_options[:supported_notification_types].include?(object.type.to_s) + + # In rare cases, a notification might be missing its activity, in which case we can't do much + case object.type + when :severed_relationships + object.account_relationship_severance_event.present? + when :'admin.report' + object.report.present? + when :added_to_collection, :collection_update + object.target_collection.present? + else + true + end + end + + def fallback_title + account = object.is_a?(NotificationGroup) ? object.sample_accounts.first : object.from_account + + case object.type + when :severed_relationships + I18n.t( + 'notification_fallbacks.severed_relationships.title', + name: object.account_relationship_severance_event.target_name + ) + when :moderation_warning + I18n.t('notification_fallbacks.moderation_warning.title') + when :'admin.sign_up' + count = object.is_a?(NotificationGroup) ? object.sample_accounts.count : 1 + if count > 1 + I18n.t( + 'notification_fallbacks.admin_sign_up.title_and_others_html', + name: TextFormatter.link_to_mention(account), + count: count - 1 + ) + else + I18n.t( + 'notification_fallbacks.admin_sign_up.title_html', + name: TextFormatter.link_to_mention(account) + ) + end + when :'admin.report' + I18n.t( + 'notification_fallbacks.admin_report.title_html', + name: account.remote? ? account.domain : TextFormatter.link_to_mention(account), + target: TextFormatter.link_to_mention(object.report.target_account) + ) + when :added_to_collection + I18n.t( + 'notification_fallbacks.added_to_collection.title_html', + name: TextFormatter.link_to_mention(account) + ) + when :collection_update + I18n.t( + 'notification_fallbacks.collection_update.title_html', + name: account + ) + end + end + + def fallback_summary + case object.type + when :severed_relationships + I18n.t( + 'notification_fallbacks.severed_relationships.summary_html', + from: Rails.configuration.x.local_domain, + target: object.account_relationship_severance_event.target_name, + link: link_to(I18n.t('notification_fallbacks.generic.sign_in'), severed_relationships_url) + ) + when :moderation_warning + I18n.t( + 'notification_fallbacks.moderation_warning.summary_html', + link: link_to(I18n.t('notification_fallbacks.generic.sign_in'), disputes_strike_url(object.account_warning.id)) + ) + when :'admin.sign_up', :'admin.report', :added_to_collection, :collection_update + I18n.t( + 'notification_fallbacks.generic.summary_html', + link: link_to(I18n.t('notification_fallbacks.generic.sign_in'), root_url) + ) + end + end +end diff --git a/app/serializers/rest/notification_group_serializer.rb b/app/serializers/rest/notification_group_serializer.rb index 3f95d78fc4..b47ff41cec 100644 --- a/app/serializers/rest/notification_group_serializer.rb +++ b/app/serializers/rest/notification_group_serializer.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class REST::NotificationGroupSerializer < ActiveModel::Serializer + include RoutingHelper + include ActionView::Helpers::UrlHelper + include NotificationFallbackConcern + # Please update app/javascript/mastodon/api_types/notifications.ts when making changes to the attributes attributes :group_key, :notifications_count, :type, :most_recent_notification_id @@ -8,6 +12,8 @@ class REST::NotificationGroupSerializer < ActiveModel::Serializer attribute :page_max_id, if: :paginated? attribute :latest_page_notification_at, if: :paginated? + attribute :fallback, if: :needs_fallback? + attribute :sample_account_ids attribute :status_id, if: :status_type? belongs_to :report, if: :report_type?, serializer: REST::ReportSerializer diff --git a/app/serializers/rest/notification_serializer.rb b/app/serializers/rest/notification_serializer.rb index f337aab943..0fb809fcb0 100644 --- a/app/serializers/rest/notification_serializer.rb +++ b/app/serializers/rest/notification_serializer.rb @@ -1,11 +1,17 @@ # frozen_string_literal: true class REST::NotificationSerializer < ActiveModel::Serializer + include RoutingHelper + include ActionView::Helpers::UrlHelper + include NotificationFallbackConcern + # Please update app/javascript/mastodon/api_types/notifications.ts when making changes to the attributes attributes :id, :type, :created_at, :group_key attribute :filtered, if: :filtered? + attribute :fallback, if: :needs_fallback? + belongs_to :from_account, key: :account, serializer: REST::AccountSerializer belongs_to :target_status, key: :status, if: :status_type?, serializer: REST::StatusSerializer belongs_to :report, if: :report_type?, serializer: REST::ReportSerializer diff --git a/config/locales/en.yml b/config/locales/en.yml index 2c92862102..7904388db3 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1731,6 +1731,27 @@ en: copy_account_note_text: 'This user moved from %{acct}, here were your previous notes about them:' navigation: toggle_menu: Toggle menu + notification_fallbacks: + added_to_collection: + title_html: "%{name} added you to a collection" + admin_report: + title_html: "%{name} reported %{target}" + admin_sign_up: + title_and_others_html: + one: "%{name} and one other signed up" + other: "%{name} and %{count} others signed up" + title_html: "%{name} signed up" + collection_update: + title_html: "%{name} updated a collection you are in" + generic: + sign_in: Sign in to the Mastodon web app + summary_html: You're on an app that does not support the most recent version of Mastodon. %link for full functionality. + moderation_warning: + summary_html: You're on an app that does not support the most recent version of Mastodon. %link + title: You have received a moderation warning. + severed_relationships: + summary_html: An admin from %{from} has suspended %{target}, which means you can no longer receive updates from them or interact with them. %{link} to retrieve a list of the lost relationships. + title: Lost connections with %{name} notification_mailer: admin: report: diff --git a/spec/serializers/rest/notification_group_serializer_spec.rb b/spec/serializers/rest/notification_group_serializer_spec.rb index 01fd8ce0a1..bd9bbe0fc7 100644 --- a/spec/serializers/rest/notification_group_serializer_spec.rb +++ b/spec/serializers/rest/notification_group_serializer_spec.rb @@ -6,10 +6,16 @@ RSpec.describe REST::NotificationGroupSerializer do subject do serialized_record_json( notification_group, - described_class + described_class, + options: { + scope: current_user, + scope_name: :current_user, + supported_notification_types: [], + } ) end + let(:current_user) { Fabricate(:user) } let(:notification_group) { NotificationGroup.new pagination_data: { latest_notification_at: 3.days.ago }, notification: Fabricate(:notification), sample_accounts: [] } context 'when latest_page_notification_at is populated' do @@ -20,4 +26,28 @@ RSpec.describe REST::NotificationGroupSerializer do ) end end + + shared_examples 'with fallback notifications' do |type, fabricators| + let(:activities) { fabricators.map { |fabricator| Fabricate(fabricator) } } + let(:notifications) { activities.map { |activity| Fabricate(:notification, type:, activity:, account: current_user.account) } } + let(:notification_group) { NotificationGroup.new(notification: notifications.last, sample_accounts: notifications.map(&:from_account)) } + + it 'renders correctly' do + expect(subject) + .to include( + 'type' => type, + 'fallback' => include( + 'title' => anything, + 'summary' => anything + ) + ) + end + end + + it_behaves_like 'with fallback notifications', 'severed_relationships', [:account_relationship_severance_event] + it_behaves_like 'with fallback notifications', 'moderation_warning', [:account_warning] + it_behaves_like 'with fallback notifications', 'admin.report', [:report] + it_behaves_like 'with fallback notifications', 'admin.report', [:report, :report] + it_behaves_like 'with fallback notifications', 'added_to_collection', [:collection_item] + it_behaves_like 'with fallback notifications', 'collection_update', [:collection] end diff --git a/spec/serializers/rest/notification_serializer_spec.rb b/spec/serializers/rest/notification_serializer_spec.rb index b833bcb6e1..377efdb93b 100644 --- a/spec/serializers/rest/notification_serializer_spec.rb +++ b/spec/serializers/rest/notification_serializer_spec.rb @@ -6,10 +6,16 @@ RSpec.describe REST::NotificationSerializer do subject do serialized_record_json( notification, - described_class + described_class, + options: { + scope: current_user, + scope_name: :current_user, + supported_notification_types: [], + } ) end + let(:current_user) { Fabricate(:user) } let(:notification) { Fabricate :notification } context 'when created_at is populated' do @@ -20,4 +26,25 @@ RSpec.describe REST::NotificationSerializer do ) end end + + shared_examples 'with fallback notifications' do |type, fabricator| + let(:notification) { Fabricate(:notification, type:, activity: Fabricate(fabricator), account: current_user.account) } + + it 'renders correctly' do + expect(subject) + .to include( + 'type' => type, + 'fallback' => include( + 'title' => anything, + 'summary' => anything + ) + ) + end + end + + it_behaves_like 'with fallback notifications', 'severed_relationships', :account_relationship_severance_event + it_behaves_like 'with fallback notifications', 'moderation_warning', :account_warning + it_behaves_like 'with fallback notifications', 'admin.report', :report + it_behaves_like 'with fallback notifications', 'added_to_collection', :collection_item + it_behaves_like 'with fallback notifications', 'collection_update', :collection end