Add added_to_collection and collection_updated notification types (#38491)

This commit is contained in:
Claire 2026-04-08 14:56:07 +02:00 committed by GitHub
parent df64716b34
commit 39c70649ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 183 additions and 47 deletions

View File

@ -3,6 +3,7 @@
import type { AccountWarningAction } from 'mastodon/models/notification_group';
import type { ApiAccountJSON } from './accounts';
import type { ApiCollectionJSON } from './collections';
import type { ApiReportJSON } from './reports';
import type { ApiStatusJSON } from './statuses';
@ -22,6 +23,8 @@ export const allNotificationTypes: NotificationType[] = [
'moderation_warning',
'severed_relationships',
'annual_report',
'added_to_collection',
'collection_update',
];
export type NotificationWithStatusType =
@ -42,7 +45,9 @@ export type NotificationType =
| 'severed_relationships'
| 'admin.sign_up'
| 'admin.report'
| 'annual_report';
| 'annual_report'
| 'added_to_collection'
| 'collection_update';
export interface BaseNotificationJSON {
id: string;
@ -83,6 +88,26 @@ interface ReportNotificationJSON extends BaseNotificationJSON {
report: ApiReportJSON;
}
interface AddedToCollectionNotificationGroupJSON extends BaseNotificationGroupJSON {
type: 'added_to_collection';
collection: ApiCollectionJSON;
}
interface AddedToCollectionNotificationJSON extends BaseNotificationJSON {
type: 'added_to_collection';
collection: ApiCollectionJSON;
}
interface CollectionUpdateNotificationGroupJSON extends BaseNotificationGroupJSON {
type: 'collection_update';
collection: ApiCollectionJSON;
}
interface CollectionUpdateNotificationJSON extends BaseNotificationJSON {
type: 'collection_update';
collection: ApiCollectionJSON;
}
type SimpleNotificationTypes = 'follow' | 'follow_request' | 'admin.sign_up';
interface SimpleNotificationGroupJSON extends BaseNotificationGroupJSON {
type: SimpleNotificationTypes;
@ -146,7 +171,9 @@ export type ApiNotificationJSON =
| ReportNotificationJSON
| AccountRelationshipSeveranceNotificationJSON
| NotificationWithStatusJSON
| ModerationWarningNotificationJSON;
| ModerationWarningNotificationJSON
| AddedToCollectionNotificationJSON
| CollectionUpdateNotificationJSON;
export type ApiNotificationGroupJSON =
| SimpleNotificationGroupJSON
@ -154,7 +181,9 @@ export type ApiNotificationGroupJSON =
| AccountRelationshipSeveranceNotificationGroupJSON
| NotificationGroupWithStatusJSON
| ModerationWarningNotificationGroupJSON
| AnnualReportNotificationGroupJSON;
| AnnualReportNotificationGroupJSON
| AddedToCollectionNotificationGroupJSON
| CollectionUpdateNotificationGroupJSON;
export interface ApiNotificationGroupsResultJSON {
accounts: ApiAccountJSON[];

View File

@ -10,6 +10,8 @@ import type {
} from 'mastodon/api_types/notifications';
import type { ApiReportJSON } from 'mastodon/api_types/reports';
import type { ApiCollectionJSON } from '../api_types/collections';
// Maximum number of avatars displayed in a notification group
// This corresponds to the max length of `group.sampleAccountIds`
export const NOTIFICATIONS_GROUP_MAX_AVATARS = 8;
@ -87,6 +89,15 @@ export interface NotificationGroupAdminReport extends BaseNotification<'admin.re
report: Report;
}
type Collection = ApiCollectionJSON;
export interface NotificationGroupAddedToCollection extends BaseNotification<'added_to_collection'> {
collection: Collection;
}
export interface NotificationGroupCollectionUpdate extends BaseNotification<'collection_update'> {
collection: Collection;
}
export type NotificationGroup =
| NotificationGroupFavourite
| NotificationGroupReblog
@ -102,7 +113,9 @@ export type NotificationGroup =
| NotificationGroupSeveredRelationships
| NotificationGroupAdminSignUp
| NotificationGroupAdminReport
| NotificationGroupAnnualReport;
| NotificationGroupAnnualReport
| NotificationGroupAddedToCollection
| NotificationGroupCollectionUpdate;
function createReportFromJSON(reportJSON: ApiReportJSON): Report {
const { target_account, ...report } = reportJSON;
@ -249,6 +262,13 @@ export function createNotificationGroupFromNotificationJSON(
notification.moderation_warning,
),
};
case 'added_to_collection':
case 'collection_update':
return {
...group,
type: notification.type,
collection: notification.collection,
};
default:
return {
...group,

View File

@ -26,6 +26,7 @@ class ActivityPub::Activity::FeatureRequest < ActivityPub::Activity
collection_item_attributes(:accepted)
)
notify_local_user!(collection_item)
queue_delivery!(collection_item, ActivityPub::AcceptFeatureRequestSerializer)
end
@ -52,6 +53,10 @@ class ActivityPub::Activity::FeatureRequest < ActivityPub::Activity
{ account: @featured_account, activity_uri: @json['id'], state: }
end
def notify_local_user!(collection_item)
LocalNotificationWorker.perform_async(collection_item.account_id, collection_item.id, collection_item.class.name, 'added_to_collection')
end
def queue_delivery!(collection_item, serializer)
json = JSON.generate(serialize_payload(collection_item, serializer))
ActivityPub::DeliveryWorker.perform_async(json, @featured_account.id, @account.inbox_url)

View File

@ -33,7 +33,7 @@ class Notification < ApplicationRecord
'Quote' => :quote,
}.freeze
# Please update app/javascript/api_types/notification.ts if you change this
# Please update app/javascript/mastodon/api_types/notifications.ts if you change this
PROPERTIES = {
mention: {
filterable: true,
@ -80,6 +80,12 @@ class Notification < ApplicationRecord
quoted_update: {
filterable: false,
}.freeze,
added_to_collection: {
filterable: true,
}.freeze,
collection_update: {
filterable: false,
},
}.freeze
TYPES = PROPERTIES.keys.freeze
@ -112,6 +118,8 @@ class Notification < ApplicationRecord
belongs_to :account_warning, inverse_of: false
belongs_to :generated_annual_report, inverse_of: false
belongs_to :quote, inverse_of: :notification
belongs_to :collection_item, inverse_of: false # TODO: have an inverse?
belongs_to :collection, inverse_of: false # TODO: have an inverse?
end
validates :type, inclusion: { in: TYPES }
@ -208,8 +216,10 @@ class Notification < ApplicationRecord
case activity_type
when 'Status'
self.from_account_id = type == :quoted_update ? activity&.quote&.quoted_account_id : activity&.account_id
when 'Follow', 'Favourite', 'FollowRequest', 'Poll', 'Report', 'Quote'
when 'Follow', 'Favourite', 'FollowRequest', 'Poll', 'Report', 'Quote', 'Collection'
self.from_account_id = activity&.account_id
when 'CollectionItem'
self.from_account_id = activity&.collection&.account_id
when 'Mention'
self.from_account_id = activity&.status&.account_id
when 'Account'

View File

@ -1,7 +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
# 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
attribute :page_min_id, if: :paginated?
@ -14,6 +14,7 @@ class REST::NotificationGroupSerializer < ActiveModel::Serializer
belongs_to :account_relationship_severance_event, key: :event, if: :relationship_severance_event?, serializer: REST::AccountRelationshipSeveranceEventSerializer
belongs_to :account_warning, key: :moderation_warning, if: :moderation_warning_event?, serializer: REST::AccountWarningSerializer
belongs_to :generated_annual_report, key: :annual_report, if: :annual_report_event?, serializer: REST::AnnualReportEventSerializer
belongs_to :collection, if: :collection_type?, serializer: REST::CollectionSerializer
def sample_account_ids
object.sample_accounts.pluck(:id).map(&:to_s)
@ -27,6 +28,10 @@ class REST::NotificationGroupSerializer < ActiveModel::Serializer
[:favourite, :reblog, :status, :mention, :poll, :update, :quote, :quoted_update].include?(object.type)
end
def collection_type?
[:added_to_collection, :collection_update].include?(object.type)
end
def report_type?
object.type == :'admin.report'
end

View File

@ -1,7 +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
# 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?
@ -11,6 +11,7 @@ class REST::NotificationSerializer < ActiveModel::Serializer
belongs_to :report, if: :report_type?, serializer: REST::ReportSerializer
belongs_to :account_relationship_severance_event, key: :event, if: :relationship_severance_event?, serializer: REST::AccountRelationshipSeveranceEventSerializer
belongs_to :account_warning, key: :moderation_warning, if: :moderation_warning_event?, serializer: REST::AccountWarningSerializer
belongs_to :collection, if: :collection_type?, serializer: REST::CollectionSerializer
def id
object.id.to_s
@ -24,6 +25,10 @@ class REST::NotificationSerializer < ActiveModel::Serializer
[:favourite, :reblog, :status, :mention, :poll, :update, :quoted_update, :quote].include?(object.type)
end
def collection_type?
[:added_to_collection, :collection_update].include?(object.type)
end
def report_type?
object.type == :'admin.report'
end

View File

@ -25,6 +25,7 @@ class ActivityPub::ProcessFeaturedCollectionService
end
process_items!
notify_about_update!
@collection
end
@ -32,6 +33,10 @@ class ActivityPub::ProcessFeaturedCollectionService
private
def notify_about_update!
NotifyOfCollectionUpdateService.new.call(@collection)
end
def truncated_summary
text = @json['summaryMap']&.values&.first || @json['summary'] || ''
text[0, Collection::DESCRIPTION_LENGTH_HARD_LIMIT]

View File

@ -11,6 +11,7 @@ class AddAccountToCollectionService
@collection_item = create_collection_item
notify_local_user if @account.local?
distribute_add_activity if @account.local?
distribute_feature_request_activity if @account.remote?
@ -24,6 +25,10 @@ class AddAccountToCollectionService
@collection.collection_items.create!(account: @account, state:)
end
def notify_local_user
LocalNotificationWorker.perform_async(@account.id, @collection_item.id, @collection_item.class.name, 'added_to_collection')
end
def distribute_add_activity
ActivityPub::AccountRawDistributionWorker.perform_async(add_activity_json, @collection.account_id)
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class NotifyOfCollectionUpdateService
def call(collection)
return unless significantly_changed?(collection)
collection.collection_items.includes(:account).references(:account).merge(Account.local).accepted.find_each do |collection_item|
LocalNotificationWorker.perform_async(collection_item.account_id, collection.id, collection.class.name, 'collection_update')
end
end
private
def significantly_changed?(collection)
# If the collection is brand new, we don't need to look at its members
return false if collection.previously_new_record?
# Only notify of change to description or name
%i(description description_html name).any? { |attr| collection.attribute_previously_changed?(attr) }
end
end

View File

@ -14,6 +14,8 @@ class NotifyService < BaseService
moderation_warning
severed_relationships
annual_report
added_to_collection
collection_update
).freeze
class BaseCondition
@ -21,15 +23,6 @@ class NotifyService < BaseService
NEW_FOLLOWER_THRESHOLD = 3.days.freeze
NON_FILTERABLE_TYPES = %i(
admin.sign_up
admin.report
poll
update
account_warning
annual_report
).freeze
def initialize(notification, **options)
@recipient = notification.account
@sender = notification.from_account

View File

@ -7,6 +7,7 @@ class UpdateCollectionService
@collection = collection
@collection.update!(params)
notify_about_update
distribute_update_activity
end
@ -18,6 +19,10 @@ class UpdateCollectionService
ActivityPub::AccountRawDistributionWorker.perform_async(activity_json, @collection.account.id)
end
def notify_about_update
NotifyOfCollectionUpdateService.new.call(@collection)
end
def activity_json
ActiveModelSerializers::SerializableResource.new(@collection, serializer: ActivityPub::UpdateFeaturedCollectionSerializer, adapter: ActivityPub::Adapter).to_json
end

View File

@ -10,10 +10,8 @@ class LocalNotificationWorker
# For most notification types, only one notification should exist, and the older one is
# preferred. For updates, such as when a status is edited, the new notification
# should replace the previous ones.
if type == 'update'
Notification.where(account: receiver, activity: activity, type: 'update').in_batches.delete_all
elsif type == 'quoted_update'
Notification.where(account: receiver, activity: activity, type: 'quoted_update').in_batches.delete_all
if %w(update quoted_update collection_update).include?(type)
Notification.where(account: receiver, activity: activity, type: type).in_batches.delete_all
elsif Notification.where(account: receiver, activity: activity, type: type).any?
return
end

View File

@ -26,9 +26,10 @@ RSpec.describe ActivityPub::Activity::FeatureRequest do
context 'when recipient is discoverable' do
let(:discoverable) { true }
it 'schedules a job to send an `Accept` activity' do
it 'schedules a job to send an `Accept` activity as well as a notification worker' do
expect { subject.perform }
.to enqueue_sidekiq_job(ActivityPub::DeliveryWorker)
.to enqueue_sidekiq_job(LocalNotificationWorker).with(recipient.id, anything, 'CollectionItem', 'added_to_collection')
.and enqueue_sidekiq_job(ActivityPub::DeliveryWorker)
.with(satisfying do |body|
response_json = JSON.parse(body)
response_json['type'] == 'Accept' &&

View File

@ -259,6 +259,9 @@ RSpec.describe ActivityPub::Activity::Update do
context 'with a `FeaturedCollection` object', feature: :collections do
let(:collection) { Fabricate(:remote_collection, account: sender, name: 'old name', discoverable: false) }
let(:account) { Fabricate(:account) }
let!(:collection_item) { Fabricate(:collection_item, account:, collection:, uri: 'https://example.com/featured_stamps/1') }
let(:featured_collection_json) do
{
'@context' => 'https://www.w3.org/ns/activitystreams',
@ -267,13 +270,21 @@ RSpec.describe ActivityPub::Activity::Update do
'attributedTo' => sender.uri,
'name' => 'Cool people',
'summary' => 'People you should follow.',
'totalItems' => 0,
'totalItems' => 1,
'sensitive' => false,
'discoverable' => true,
'published' => '2026-03-09T15:19:25Z',
'updated' => Time.zone.now.iso8601,
'orderedItems' => [
{
'type' => 'FeaturedItem',
'id' => ActivityPub::TagManager.instance.uri_for(collection_item),
'object' => ActivityPub::TagManager.instance.uri_for(account),
},
],
}
end
let(:json) do
{
'@context' => 'https://www.w3.org/ns/activitystreams',
@ -282,18 +293,41 @@ RSpec.describe ActivityPub::Activity::Update do
'object' => featured_collection_json,
}
end
let(:stubbed_service) do
instance_double(ActivityPub::ProcessFeaturedCollectionService, call: true)
it 'updates the collection and notifies local user' do
expect { subject.perform }
.to change { collection.reload.name }.to(featured_collection_json['name'])
.and enqueue_sidekiq_job(LocalNotificationWorker).with(account.id, collection.id, 'Collection', 'collection_update')
end
before do
allow(ActivityPub::ProcessFeaturedCollectionService).to receive(:new).and_return(stubbed_service)
end
context 'when the metadata does not actually change' do
let(:featured_collection_json) do
{
'@context' => 'https://www.w3.org/ns/activitystreams',
'id' => collection.uri,
'type' => 'FeaturedCollection',
'attributedTo' => sender.uri,
'name' => collection.name,
'summary' => collection.description_html,
'totalItems' => 1,
'sensitive' => false,
'discoverable' => true,
'published' => '2026-03-09T15:19:25Z',
'updated' => Time.zone.now.iso8601,
'orderedItems' => [
{
'type' => 'FeaturedItem',
'id' => ActivityPub::TagManager.instance.uri_for(collection_item),
'object' => ActivityPub::TagManager.instance.uri_for(account),
},
],
}
end
it 'updates the collection' do
subject.perform
expect(stubbed_service).to have_received(:call).with(sender, featured_collection_json)
it 'does not notify the local user' do
expect { subject.perform }
.to_not enqueue_sidekiq_job(LocalNotificationWorker)
end
end
end
end

View File

@ -22,10 +22,15 @@ RSpec.describe AddAccountToCollectionService do
end
context 'when the account is local' do
it 'federates an `Add` activity' do
it 'federates an `Add` activity and schedules a notification' do
subject.call(collection, account)
expect(ActivityPub::AccountRawDistributionWorker).to have_enqueued_sidekiq_job
expect(ActivityPub::AccountRawDistributionWorker)
.to have_enqueued_sidekiq_job
.with(anything, collection.account_id)
expect(LocalNotificationWorker)
.to have_enqueued_sidekiq_job
.with(account.id, anything, 'CollectionItem', 'added_to_collection')
end
end

View File

@ -6,21 +6,15 @@ RSpec.describe UpdateCollectionService do
subject { described_class.new }
let(:collection) { Fabricate(:collection) }
let!(:collection_item) { Fabricate(:collection_item, collection:) }
describe '#call' do
context 'when given valid parameters' do
it 'updates the collection' do
subject.call(collection, { name: 'Newly updated name' })
expect(collection.name).to eq 'Newly updated name'
end
context 'when something actually changed' do
it 'federates an `Update` activity' do
subject.call(collection, { name: 'updated' })
expect(ActivityPub::AccountRawDistributionWorker).to have_enqueued_sidekiq_job
end
it 'updates the collection, sends a notification and federates an `Update` activity' do
expect { subject.call(collection, { name: 'Newly updated name' }) }
.to change(collection, :name).to('Newly updated name')
.and enqueue_sidekiq_job(LocalNotificationWorker).with(collection_item.account_id, collection.id, collection.class.name, 'collection_update')
.and enqueue_sidekiq_job(ActivityPub::AccountRawDistributionWorker)
end
context 'when nothing changed' do
@ -28,6 +22,7 @@ RSpec.describe UpdateCollectionService do
subject.call(collection, { name: collection.name })
expect(ActivityPub::AccountRawDistributionWorker).to_not have_enqueued_sidekiq_job
expect(LocalNotificationWorker).to_not have_enqueued_sidekiq_job
end
end
end