Tag linked FeaturedCollection objects over ActivityPub (#38115)

This commit is contained in:
Claire 2026-03-23 14:11:33 +01:00 committed by GitHub
parent 1935f4db79
commit 39d9da3b82
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 320 additions and 16 deletions

View File

@ -42,6 +42,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
def process_status
@tags = []
@mentions = []
@tagged_objects = []
@unresolved_mentions = []
@silenced_account_ids = []
@params = {}
@ -56,6 +57,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
ApplicationRecord.transaction do
@status = Status.create!(@params.merge(quote: @quote))
attach_tags(@status)
attach_tagged_objects(@status)
attach_mentions(@status)
attach_counts(@status)
end
@ -181,6 +183,13 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
end
def attach_tagged_objects(status)
@tagged_objects.each do |tagged_object|
tagged_object.status = status
tagged_object.save
end
end
def attach_mentions(status)
@mentions.each do |mention|
mention.status = status
@ -210,6 +219,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
process_mention tag
elsif equals_or_includes?(tag['type'], 'Emoji')
process_emoji tag
elsif equals_or_includes?(tag['type'], 'FeaturedCollection')
process_tagged_collection tag
end
end
end
@ -266,6 +277,15 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
end
end
def process_tagged_collection(tag)
return if tag['id'].blank?
# TODO: We probably want to resolve unknown objects and push them to an `@unresolved_tagged_objects` on failure
collection = ActivityPub::TagManager.instance.uri_to_resource(tag['id'], Collection)
@tagged_objects << TaggedObject.new(uri: ActivityPub::TagManager.instance.uri_for(collection), object: collection, ap_type: 'FeaturedCollection') if collection.present?
end
def process_attachments
return [] if @object['attachment'].nil?

View File

@ -262,6 +262,14 @@ class ActivityPub::TagManager
uri_to_resource(uri, Account)
end
def uri_to_local_collection(uri)
path_params = Rails.application.routes.recognize_path(uri)
return unless path_params[:controller] == 'collections'
# TODO: check account, but this requires handling potentially two different schemes
Collection.find_by(id: path_params[:id])
end
def uri_to_local_conversation(uri)
path_params = Rails.application.routes.recognize_path(uri)
return unless path_params[:controller] == 'activitypub/contexts'
@ -279,6 +287,8 @@ class ActivityPub::TagManager
uris_to_local_accounts([uri]).first
when 'Conversation'
uri_to_local_conversation(uri)
when 'Collection'
uri_to_local_collection(uri)
else
StatusFinder.new(uri).status
end

View File

@ -80,6 +80,7 @@ class Status < ApplicationRecord
has_many :mentions, dependent: :destroy, inverse_of: :status
has_many :mentioned_accounts, through: :mentions, source: :account, class_name: 'Account'
has_many :media_attachments, dependent: :nullify
has_many :tagged_objects, dependent: :destroy
has_many :quotes, foreign_key: 'quoted_status_id', inverse_of: :quoted_status, dependent: :nullify
# The `dependent` option is enabled by the initial `mentions` association declaration
@ -172,6 +173,7 @@ class Status < ApplicationRecord
preview_cards_status: { preview_card: { author_account: [:account_stat, user: :role] } },
account: [:account_stat, user: :role],
active_mentions: :account,
tagged_objects: :object,
reblog: [
:application,
:media_attachments,

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: tagged_objects
#
# id :bigint(8) not null, primary key
# ap_type :string not null
# object_type :string
# uri :string
# created_at :datetime not null
# updated_at :datetime not null
# object_id :bigint(8)
# status_id :bigint(8) not null
#
class TaggedObject < ApplicationRecord
belongs_to :status, inverse_of: :tagged_objects
belongs_to :object, polymorphic: true, optional: true
end

View File

@ -139,7 +139,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
end
def virtual_tags
object.active_mentions.to_a.sort_by(&:id) + object.tags + object.emojis
object.active_mentions.to_a.sort_by(&:id) + object.tags + object.emojis + object.tagged_objects.map(&:object)
end
def atom_uri
@ -345,8 +345,9 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
end
end
class CustomEmojiSerializer < ActivityPub::EmojiSerializer
end
class CustomEmojiSerializer < ActivityPub::EmojiSerializer; end
class CollectionSerializer < ActivityPub::FeaturedCollectionSerializer; end
class OptionSerializer < ActivityPub::Serializer
class RepliesSerializer < ActivityPub::Serializer

View File

@ -5,7 +5,7 @@ class REST::CollectionSerializer < ActiveModel::Serializer
:local, :sensitive, :discoverable, :item_count,
:created_at, :updated_at
belongs_to :tag, serializer: REST::StatusSerializer::TagSerializer
belongs_to :tag, serializer: REST::ShallowTagSerializer
has_many :items, serializer: REST::CollectionItemSerializer

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class REST::ShallowTagSerializer < ActiveModel::Serializer
include RoutingHelper
attributes :name, :url
def url
tag_url(object)
end
end

View File

@ -28,6 +28,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
has_many :ordered_mentions, key: :mentions
has_many :tags
has_many :emojis, serializer: REST::CustomEmojiSerializer
has_many :tagged_collections, serializer: REST::CollectionSerializer
# Due to a ActiveModel::Serializer quirk, if you change any of the following, have a look at
# updating `app/serializers/rest/shallow_status_serializer.rb` as well
@ -166,6 +167,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
object.active_mentions.to_a.sort_by(&:id)
end
def tagged_collections
object.tagged_objects.filter_map { |tagged_object| tagged_object.object if tagged_object.ap_type == 'FeaturedCollection' }
end
def quote_approval
{
automatic: object.proper.quote_policy_as_keys(:automatic),
@ -208,13 +213,5 @@ class REST::StatusSerializer < ActiveModel::Serializer
end
end
class TagSerializer < ActiveModel::Serializer
include RoutingHelper
attributes :name, :url
def url
tag_url(object)
end
end
class TagSerializer < REST::ShallowTagSerializer; end
end

View File

@ -182,9 +182,10 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
end
def update_metadata!
@raw_tags = []
@raw_tags = []
@raw_mentions = []
@raw_emojis = []
@raw_tagged_objects = []
@raw_emojis = []
as_array(@json['tag']).each do |tag|
if equals_or_includes?(tag['type'], 'Hashtag')
@ -193,10 +194,13 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
@raw_mentions << tag['href'] if tag['href'].present?
elsif equals_or_includes?(tag['type'], 'Emoji')
@raw_emojis << tag
elsif equals_or_includes?(tag['type'], 'FeaturedCollection')
@raw_tagged_objects << tag if tag['id']
end
end
update_tags!
update_tagged_objects!
update_mentions!
update_emojis!
update_quote!
@ -229,6 +233,24 @@ class ActivityPub::ProcessStatusUpdateService < BaseService
end
end
def update_tagged_objects!
current_tagged_objects = @raw_tagged_objects.filter_map do |tagged_object|
url = tagged_object['id']
# TODO: We probably want to resolve unknown objects at authoring time
ActivityPub::TagManager.instance.uri_to_resource(url, Collection)
end
# Any previously-unresolved URI would be resolved here
@status.tagged_objects.upsert_all(
current_tagged_objects.uniq.map { |object| { object_type: object.class.name, object_id: object.id, uri: ActivityPub::TagManager.instance.uri_for(object), ap_type: 'FeaturedCollection' } },
unique_by: %w(status_id uri)
)
# Remove unused links
@status.tagged_objects.where.not(uri: current_tagged_objects.map { |object| ActivityPub::TagManager.instance.uri_for(object) }).delete_all
end
def update_mentions!
unresolved_mentions = []

View File

@ -80,6 +80,7 @@ class PostStatusService < BaseService
process_mentions_service.call(@status)
safeguard_mentions!(@status)
safeguard_private_mention_quote!(@status)
attach_tagged_objects!(@status)
attach_quote!(@status)
antispam = Antispam.new(@status)
@ -111,6 +112,10 @@ class PostStatusService < BaseService
status.quote.accept! if @quoted_status.local? && StatusPolicy.new(@status.account, @quoted_status).quote?
end
def attach_tagged_objects!(status)
ProcessLinksService.new.call(status)
end
def safeguard_mentions!(status)
return if @options[:allowed_mentions].nil?

View File

@ -0,0 +1,52 @@
# frozen_string_literal: true
class ProcessLinksService < BaseService
include Payloadable
# Scan status for links to ActivityPub objects and attach them to statuses
# @param [Status] status
def call(status)
return unless status.local?
@status = status
@previous_objects = @status.tagged_objects.includes(:object).to_a
@current_objects = []
Status.transaction do
scan_text!
assign_tagged_objects!
end
end
private
def scan_text!
urls = @status.text.scan(FetchLinkCardService::URL_PATTERN).map { |array| Addressable::URI.parse(array[1]).normalize }
urls.each do |url|
# We only support `FeaturedCollection` at this time
# TODO: We probably want to resolve unknown objects at authoring time
object = ActivityPub::TagManager.instance.uri_to_resource(url.to_s, Collection)
tagged_object = @previous_objects.find { |x| x.object == object || x.uri == url }
tagged_object ||= @current_objects.find { |x| x.object == object || x.uri == url }
tagged_object ||= @status.tagged_objects.new(object: object, ap_type: 'FeaturedCollection', uri: ActivityPub::TagManager.instance.uri_for(object))
@current_objects << tagged_object
end
end
def assign_tagged_objects!
return unless @status.persisted?
@current_objects.each do |object|
object.save if object.new_record?
end
# If previous objects are no longer contained in the text, remove them to lighten the database
removed_objects = @previous_objects - @current_objects
TaggedObject.where(id: removed_objects.map(&:id)).delete_all unless removed_objects.empty?
end
end

View File

@ -134,6 +134,7 @@ class UpdateStatusService < BaseService
def update_metadata!
ProcessHashtagsService.new.call(@status)
ProcessMentionsService.new.call(@status)
ProcessLinksService.new.call(@status)
end
def broadcast_updates!

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
class CreateTaggedObjects < ActiveRecord::Migration[8.1]
def change
create_table :tagged_objects do |t|
t.references :status, null: false, foreign_key: { on_delete: :cascade }, index: false
t.references :object, polymorphic: true, null: true
t.string :ap_type, null: false
t.string :uri
t.timestamps
end
add_index :tagged_objects, [:status_id, :object_type, :object_id], unique: true, where: 'object_type IS NOT NULL AND object_id IS NOT NULL'
add_index :tagged_objects, [:status_id, :uri], unique: true, where: 'uri IS NOT NULL'
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_03_18_144837) do
ActiveRecord::Schema[8.1].define(version: 2026_03_19_142348) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@ -1242,6 +1242,19 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_18_144837) do
t.index ["tag_id", "language"], name: "index_tag_trends_on_tag_id_and_language", unique: true
end
create_table "tagged_objects", force: :cascade do |t|
t.string "ap_type", null: false
t.datetime "created_at", null: false
t.bigint "object_id"
t.string "object_type"
t.bigint "status_id", null: false
t.datetime "updated_at", null: false
t.string "uri"
t.index ["object_type", "object_id"], name: "index_tagged_objects_on_object"
t.index ["status_id", "object_type", "object_id"], name: "idx_on_status_id_object_type_object_id_d6ebe374bd", unique: true, where: "((object_type IS NOT NULL) AND (object_id IS NOT NULL))"
t.index ["status_id", "uri"], name: "index_tagged_objects_on_status_id_and_uri", unique: true, where: "(uri IS NOT NULL)"
end
create_table "tags", force: :cascade do |t|
t.datetime "created_at", precision: nil, null: false
t.string "display_name"
@ -1547,6 +1560,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_18_144837) do
add_foreign_key "tag_follows", "accounts", on_delete: :cascade
add_foreign_key "tag_follows", "tags", on_delete: :cascade
add_foreign_key "tag_trends", "tags", on_delete: :cascade
add_foreign_key "tagged_objects", "statuses", on_delete: :cascade
add_foreign_key "tombstones", "accounts", on_delete: :cascade
add_foreign_key "user_invite_requests", "users", on_delete: :cascade
add_foreign_key "users", "accounts", name: "fk_50500f500d", on_delete: :cascade

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
Fabricator(:tagged_object) do
status
object nil
ap_type 'FeaturedCollection'
uri { Faker::Internet.device_token }
end

View File

@ -681,6 +681,30 @@ RSpec.describe ActivityPub::Activity::Create do
end
end
context 'with tagged Featured Collections' do
let(:featured_collection) { Fabricate(:collection) }
let(:object_json) do
build_object(
tag: [
{
type: 'FeaturedCollection',
id: ActivityPub::TagManager.instance.uri_for(featured_collection),
},
]
)
end
it 'creates the status with appropriate tagged objects' do
expect { subject.perform }
.to change(sender.statuses, :count).by(1)
status = sender.statuses.first
expect(status.tagged_objects.map(&:object)).to contain_exactly(featured_collection)
end
end
context 'with hashtags' do
let(:object_json) do
build_object(

View File

@ -671,5 +671,15 @@ RSpec.describe ActivityPub::TagManager do
status = Fabricate(:status, uri: 'https://example.com/123')
expect(subject.uri_to_resource('https://example.com/123#456', Status)).to eq status
end
it 'returns the local featured collection' do
collection = Fabricate(:collection)
expect(subject.uri_to_resource(subject.uri_for(collection), Collection)).to eq collection
end
it 'returns the remote featured collection' do
collection = Fabricate(:remote_collection)
expect(subject.uri_to_resource(subject.uri_for(collection), Collection)).to eq collection
end
end
end

View File

@ -43,6 +43,26 @@ RSpec.describe ActivityPub::NoteSerializer do
.and(not_include(reply_by_account_visibility_direct.uri)) # Replies with direct visibility
end
context 'with tagged featured collections' do
let(:collection) { Fabricate(:collection) }
before do
parent.tagged_objects.create!(object: collection, ap_type: 'FeaturedCollection', uri: ActivityPub::TagManager.instance.uri_for(collection))
end
it 'has the expected shape' do
expect(subject).to include({
'type' => 'Note',
'tag' => include(
a_hash_including({
'type' => 'FeaturedCollection',
'id' => ActivityPub::TagManager.instance.uri_for(collection),
})
),
})
end
end
context 'with a quote' do
let(:quoted_status) { Fabricate(:status) }
let!(:quote) { Fabricate(:quote, status: parent, quoted_status: quoted_status, state: :accepted) }

View File

@ -92,5 +92,22 @@ RSpec.describe REST::StatusSerializer do
)
end
end
context 'with a tagged collection' do
let(:collection) { Fabricate(:collection) }
before do
status.tagged_objects.create!(object: collection, ap_type: 'FeaturedCollection', uri: ActivityPub::TagManager.instance.uri_for(collection))
end
it 'contains the tagged collection' do
expect(subject)
.to include(
'tagged_collections' => [a_hash_including(
'id' => collection.id.to_s
)]
)
end
end
end
end

View File

@ -20,6 +20,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
{ type: 'Mention', href: ActivityPub::TagManager.instance.uri_for(alice) },
{ type: 'Mention', href: ActivityPub::TagManager.instance.uri_for(alice) },
{ type: 'Mention', href: bogus_mention },
{ type: 'FeaturedCollection', id: ActivityPub::TagManager.instance.uri_for(featured_collection) },
],
}
end
@ -27,6 +28,7 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
let(:alice) { Fabricate(:account) }
let(:bob) { Fabricate(:account) }
let(:featured_collection) { Fabricate(:collection) }
let(:mentions) { [] }
let(:tags) { [] }
@ -276,6 +278,16 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do
end
end
context 'when originally without tagged objects' do
before do
subject.call(status, json, json)
end
it 'updates tags' do
expect(status.tagged_objects.reload.map(&:object)).to contain_exactly(featured_collection)
end
end
context 'when originally without tags' do
before do
subject.call(status, json, json)

View File

@ -209,6 +209,16 @@ RSpec.describe PostStatusService do
expect(hashtags_service).to have_received(:call).with(status)
end
it 'processes tagged objects' do
account = Fabricate(:account)
collection = Fabricate(:collection)
status = subject.call(account, text: "test #{ActivityPub::TagManager.instance.uri_for(collection)} #{ActivityPub::TagManager.instance.uri_for(collection)}")
expect(status.tagged_objects.map(&:object))
.to contain_exactly(collection)
end
it 'gets distributed' do
allow(DistributionWorker).to receive(:perform_async)
allow(ActivityPub::DistributionWorker).to receive(:perform_async)

View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ProcessLinksService do
subject { described_class.new }
let(:account) { Fabricate(:account, username: 'alice') }
context 'when status mentions known collections' do
let!(:collection) { Fabricate(:collection) }
let(:status) { Fabricate(:status, account: account, text: "Hello check out this collection! #{ActivityPub::TagManager.instance.uri_for(collection)}", visibility: :public) }
it 'creates a tagged object' do
expect { subject.call(status) }
.to change { status.tagged_objects.count }.by(1)
end
end
end

View File

@ -161,6 +161,19 @@ RSpec.describe UpdateStatusService do
end
end
context 'when tagged objects in text change' do
let!(:old_collection) { Fabricate(:collection) }
let!(:new_collection) { Fabricate(:collection) }
let!(:account) { Fabricate(:account) }
let!(:status) { PostStatusService.new.call(account, text: "Check out #{ActivityPub::TagManager.instance.uri_for(old_collection)}") }
it 'changes tagged objects' do
expect { subject.call(status, status.account_id, text: "Check out #{ActivityPub::TagManager.instance.uri_for(new_collection)} #{ActivityPub::TagManager.instance.uri_for(new_collection)}") }
.to change { status.reload.tagged_objects.map(&:object) }.from([old_collection]).to([new_collection])
end
end
context 'when hashtags in text change' do
let!(:account) { Fabricate(:account) }
let!(:status) { PostStatusService.new.call(account, text: 'Hello #foo') }