diff --git a/app/javascript/mastodon/api_types/notifications.ts b/app/javascript/mastodon/api_types/notifications.ts index d698a1a699..4836c37e6a 100644 --- a/app/javascript/mastodon/api_types/notifications.ts +++ b/app/javascript/mastodon/api_types/notifications.ts @@ -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[]; diff --git a/app/javascript/mastodon/models/notification_group.ts b/app/javascript/mastodon/models/notification_group.ts index bb8799c0ec..bfb89d894e 100644 --- a/app/javascript/mastodon/models/notification_group.ts +++ b/app/javascript/mastodon/models/notification_group.ts @@ -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, diff --git a/app/lib/activitypub/activity/feature_request.rb b/app/lib/activitypub/activity/feature_request.rb index 5e319734a4..16a3860a0e 100644 --- a/app/lib/activitypub/activity/feature_request.rb +++ b/app/lib/activitypub/activity/feature_request.rb @@ -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) diff --git a/app/models/notification.rb b/app/models/notification.rb index 9f03539f76..19b057108e 100644 --- a/app/models/notification.rb +++ b/app/models/notification.rb @@ -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' diff --git a/app/serializers/rest/notification_group_serializer.rb b/app/serializers/rest/notification_group_serializer.rb index 40f7f04ede..6089a2446a 100644 --- a/app/serializers/rest/notification_group_serializer.rb +++ b/app/serializers/rest/notification_group_serializer.rb @@ -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 diff --git a/app/serializers/rest/notification_serializer.rb b/app/serializers/rest/notification_serializer.rb index c4b77709fb..ab1626cf3b 100644 --- a/app/serializers/rest/notification_serializer.rb +++ b/app/serializers/rest/notification_serializer.rb @@ -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 diff --git a/app/services/activitypub/process_featured_collection_service.rb b/app/services/activitypub/process_featured_collection_service.rb index 9e7c82af42..f7ae08dd26 100644 --- a/app/services/activitypub/process_featured_collection_service.rb +++ b/app/services/activitypub/process_featured_collection_service.rb @@ -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] diff --git a/app/services/add_account_to_collection_service.rb b/app/services/add_account_to_collection_service.rb index 0cca1a31ff..3c2ecacf4f 100644 --- a/app/services/add_account_to_collection_service.rb +++ b/app/services/add_account_to_collection_service.rb @@ -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 diff --git a/app/services/notify_of_collection_update_service.rb b/app/services/notify_of_collection_update_service.rb new file mode 100644 index 0000000000..9e0449996e --- /dev/null +++ b/app/services/notify_of_collection_update_service.rb @@ -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 diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index ed292736d8..af2f74ce56 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -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 diff --git a/app/services/update_collection_service.rb b/app/services/update_collection_service.rb index f097a8cfb8..5ffb4bad81 100644 --- a/app/services/update_collection_service.rb +++ b/app/services/update_collection_service.rb @@ -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 diff --git a/app/workers/local_notification_worker.rb b/app/workers/local_notification_worker.rb index 7e9bd0c92b..0a99907424 100644 --- a/app/workers/local_notification_worker.rb +++ b/app/workers/local_notification_worker.rb @@ -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 diff --git a/spec/lib/activitypub/activity/feature_request_spec.rb b/spec/lib/activitypub/activity/feature_request_spec.rb index a7fc14e325..5d134b6ceb 100644 --- a/spec/lib/activitypub/activity/feature_request_spec.rb +++ b/spec/lib/activitypub/activity/feature_request_spec.rb @@ -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' && diff --git a/spec/lib/activitypub/activity/update_spec.rb b/spec/lib/activitypub/activity/update_spec.rb index 78678bde81..701a2ff1f5 100644 --- a/spec/lib/activitypub/activity/update_spec.rb +++ b/spec/lib/activitypub/activity/update_spec.rb @@ -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 diff --git a/spec/services/add_account_to_collection_service_spec.rb b/spec/services/add_account_to_collection_service_spec.rb index 4f87a2e1b9..dd7983ca62 100644 --- a/spec/services/add_account_to_collection_service_spec.rb +++ b/spec/services/add_account_to_collection_service_spec.rb @@ -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 diff --git a/spec/services/update_collection_service_spec.rb b/spec/services/update_collection_service_spec.rb index 32dd88216c..088d5768ed 100644 --- a/spec/services/update_collection_service_spec.rb +++ b/spec/services/update_collection_service_spec.rb @@ -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