Implement FEP 7888: Part 1 - publish conversation context (#35959)
Some checks failed
Check i18n / check-i18n (push) Has been cancelled
Chromatic / Run Chromatic (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (ruby) (push) Has been cancelled
Crowdin / Upload translations / upload-translations (push) Has been cancelled
Check formatting / lint (push) Has been cancelled
JavaScript Linting / lint (push) Has been cancelled
Ruby Linting / lint (push) Has been cancelled
JavaScript Testing / test (push) Has been cancelled
Historical data migration test / test (14-alpine) (push) Has been cancelled
Historical data migration test / test (15-alpine) (push) Has been cancelled
Historical data migration test / test (16-alpine) (push) Has been cancelled
Historical data migration test / test (17-alpine) (push) Has been cancelled
Ruby Testing / build (production) (push) Has been cancelled
Ruby Testing / build (test) (push) Has been cancelled
Ruby Testing / test (.ruby-version) (push) Has been cancelled
Ruby Testing / test (3.2) (push) Has been cancelled
Ruby Testing / test (3.3) (push) Has been cancelled
Ruby Testing / ImageMagick tests (.ruby-version) (push) Has been cancelled
Ruby Testing / ImageMagick tests (3.2) (push) Has been cancelled
Ruby Testing / ImageMagick tests (3.3) (push) Has been cancelled
Ruby Testing / End to End testing (.ruby-version) (push) Has been cancelled
Ruby Testing / End to End testing (3.2) (push) Has been cancelled
Ruby Testing / End to End testing (3.3) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Has been cancelled
Bundler Audit / security (push) Has been cancelled

This commit is contained in:
Jesse Karmani 2025-09-05 12:28:29 -07:00 committed by GitHub
parent 9463a31107
commit 65b4a0a6f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 309 additions and 12 deletions

View File

@ -0,0 +1,81 @@
# frozen_string_literal: true
class ActivityPub::ContextsController < ActivityPub::BaseController
vary_by -> { 'Signature' if authorized_fetch_mode? }
before_action :require_account_signature!, if: :authorized_fetch_mode?
before_action :set_conversation
before_action :set_items
DESCENDANTS_LIMIT = 60
def show
expires_in 3.minutes, public: public_fetch_mode?
render_with_cache json: context_presenter, serializer: ActivityPub::ContextSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end
def items
expires_in 3.minutes, public: public_fetch_mode?
render_with_cache json: items_collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
end
private
def account_required?
false
end
def set_conversation
@conversation = Conversation.local.find(params[:id])
end
def set_items
@items = @conversation.statuses.distributable_visibility.paginate_by_min_id(DESCENDANTS_LIMIT, params[:min_id])
end
def context_presenter
first_page = ActivityPub::CollectionPresenter.new(
id: items_context_url(@conversation, page_params),
type: :unordered,
part_of: items_context_url(@conversation),
next: next_page,
items: @items.map { |status| status.local? ? ActivityPub::TagManager.instance.uri_for(status) : status.uri }
)
ActivityPub::ContextPresenter.from_conversation(@conversation).tap do |presenter|
presenter.first = first_page
end
end
def items_collection_presenter
page = ActivityPub::CollectionPresenter.new(
id: items_context_url(@conversation, page_params),
type: :unordered,
part_of: items_context_url(@conversation),
next: next_page,
items: @items.map { |status| status.local? ? ActivityPub::TagManager.instance.uri_for(status) : status.uri }
)
return page if page_requested?
ActivityPub::CollectionPresenter.new(
id: items_context_url(@conversation),
type: :unordered,
first: page
)
end
def page_requested?
truthy_param?(:page)
end
def next_page
return nil if @items.size < DESCENDANTS_LIMIT
items_context_url(@conversation, page: true, min_id: @items.last.id)
end
def page_params
params.permit(:page, :min_id)
end
end

View File

@ -40,6 +40,8 @@ class ActivityPub::TagManager
case target.object_type case target.object_type
when :person when :person
target.instance_actor? ? instance_actor_url : account_url(target) target.instance_actor? ? instance_actor_url : account_url(target)
when :conversation
context_url(target)
when :note, :comment, :activity when :note, :comment, :activity
return activity_account_status_url(target.account, target) if target.reblog? return activity_account_status_url(target.account, target) if target.reblog?
@ -76,6 +78,12 @@ class ActivityPub::TagManager
activity_account_status_url(target.account, target) activity_account_status_url(target.account, target)
end end
def context_uri_for(target, page_params = nil)
raise ArgumentError, 'target must be a local activity' unless %i(note comment activity).include?(target.object_type) && target.local?
items_context_url(target.conversation, page_params)
end
def replies_uri_for(target, page_params = nil) def replies_uri_for(target, page_params = nil)
raise ArgumentError, 'target must be a local activity' unless %i(note comment activity).include?(target.object_type) && target.local? raise ArgumentError, 'target must be a local activity' unless %i(note comment activity).include?(target.object_type) && target.local?

View File

@ -4,10 +4,12 @@
# #
# Table name: conversations # Table name: conversations
# #
# id :bigint(8) not null, primary key # id :bigint(8) not null, primary key
# uri :string # uri :string
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# parent_account_id :bigint(8)
# parent_status_id :bigint(8)
# #
class Conversation < ApplicationRecord class Conversation < ApplicationRecord
@ -15,7 +17,24 @@ class Conversation < ApplicationRecord
has_many :statuses, dependent: nil has_many :statuses, dependent: nil
belongs_to :parent_status, class_name: 'Status', optional: true, inverse_of: :conversation
belongs_to :parent_account, class_name: 'Account', optional: true
scope :local, -> { where(uri: nil) }
before_validation :set_parent_account, on: :create
def local? def local?
uri.nil? uri.nil?
end end
def object_type
:conversation
end
private
def set_parent_account
self.parent_account = parent_status.account if parent_status.present?
end
end end

View File

@ -70,6 +70,8 @@ class Status < ApplicationRecord
belongs_to :reblog, foreign_key: 'reblog_of_id', inverse_of: :reblogs belongs_to :reblog, foreign_key: 'reblog_of_id', inverse_of: :reblogs
end end
has_one :owned_conversation, class_name: 'Conversation', foreign_key: 'parent_status_id', inverse_of: :parent_status, dependent: nil
has_many :favourites, inverse_of: :status, dependent: :destroy has_many :favourites, inverse_of: :status, dependent: :destroy
has_many :bookmarks, inverse_of: :status, dependent: :destroy has_many :bookmarks, inverse_of: :status, dependent: :destroy
has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy has_many :reblogs, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblog, dependent: :destroy
@ -442,7 +444,8 @@ class Status < ApplicationRecord
self.in_reply_to_account_id = carried_over_reply_to_account_id self.in_reply_to_account_id = carried_over_reply_to_account_id
self.conversation_id = thread.conversation_id if conversation_id.nil? self.conversation_id = thread.conversation_id if conversation_id.nil?
elsif conversation_id.nil? elsif conversation_id.nil?
self.conversation = Conversation.new conversation = build_owned_conversation
self.conversation = conversation
end end
end end

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
class ActivityPub::ContextPresenter < ActiveModelSerializers::Model
attributes :id, :type, :attributed_to, :first, :object_type
class << self
def from_conversation(conversation)
new.tap do |presenter|
presenter.id = ActivityPub::TagManager.instance.uri_for(conversation)
presenter.attributed_to = ActivityPub::TagManager.instance.uri_for(conversation.parent_account)
end
end
end
end

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
class ActivityPub::ContextSerializer < ActivityPub::Serializer
include RoutingHelper
attributes :id, :type, :attributed_to, :first
def type
'Collection'
end
end

View File

@ -9,7 +9,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
:in_reply_to, :published, :url, :in_reply_to, :published, :url,
:attributed_to, :to, :cc, :sensitive, :attributed_to, :to, :cc, :sensitive,
:atom_uri, :in_reply_to_atom_uri, :atom_uri, :in_reply_to_atom_uri,
:conversation :conversation, :context
attribute :content attribute :content
attribute :content_map, if: :language? attribute :content_map, if: :language?
@ -163,6 +163,12 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
end end
end end
def context
return if object.conversation.nil?
ActivityPub::TagManager.instance.uri_for(object.conversation)
end
def local? def local?
object.account.local? object.account.local?
end end

View File

@ -120,6 +120,11 @@ Rails.application.routes.draw do
end end
resource :inbox, only: [:create], module: :activitypub resource :inbox, only: [:create], module: :activitypub
resources :contexts, only: [:show], module: :activitypub do
member do
get :items
end
end
constraints(encoded_path: /%40.*/) do constraints(encoded_path: /%40.*/) do
get '/:encoded_path', to: redirect { |params| get '/:encoded_path', to: redirect { |params|

View File

@ -0,0 +1,8 @@
# frozen_string_literal: true
class AddParentStatusAndParentAccountToConversations < ActiveRecord::Migration[8.0]
def change
add_column :conversations, :parent_status_id, :bigint, null: true, default: nil
add_column :conversations, :parent_account_id, :bigint, null: true, default: nil
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AddIndexOnConversationToStatuses < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def change
add_index :statuses, :conversation_id, algorithm: :concurrently
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_08_20_084312) do ActiveRecord::Schema[8.0].define(version: 2025_09_02_221600) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql" enable_extension "pg_catalog.plpgsql"
@ -359,6 +359,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_20_084312) do
t.string "uri" t.string "uri"
t.datetime "created_at", precision: nil, null: false t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false
t.bigint "parent_status_id"
t.bigint "parent_account_id"
t.index ["uri"], name: "index_conversations_on_uri", unique: true, opclass: :text_pattern_ops, where: "(uri IS NOT NULL)" t.index ["uri"], name: "index_conversations_on_uri", unique: true, opclass: :text_pattern_ops, where: "(uri IS NOT NULL)"
end end
@ -1145,6 +1147,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_08_20_084312) do
t.integer "quote_approval_policy", default: 0, null: false t.integer "quote_approval_policy", default: 0, null: false
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)" t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
t.index ["account_id"], name: "index_statuses_on_account_id" t.index ["account_id"], name: "index_statuses_on_account_id"
t.index ["conversation_id"], name: "index_statuses_on_conversation_id"
t.index ["deleted_at"], name: "index_statuses_on_deleted_at", where: "(deleted_at IS NOT NULL)" t.index ["deleted_at"], name: "index_statuses_on_deleted_at", where: "(deleted_at IS NOT NULL)"
t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))" t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
t.index ["id", "language", "account_id"], name: "index_statuses_public_20250129", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))" t.index ["id", "language", "account_id"], name: "index_statuses_public_20250129", order: { id: :desc }, where: "((deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
Fabricator(:conversation) Fabricator(:conversation) do
parent_account { Fabricate(:account) }
end

View File

@ -21,7 +21,7 @@ RSpec.describe AccountConversation do
it 'appends to old record when there is a match' do it 'appends to old record when there is a match' do
last_status = Fabricate(:status, account: alice, visibility: :direct) last_status = Fabricate(:status, account: alice, visibility: :direct)
conversation = described_class.create!(account: alice, conversation: last_status.conversation, participant_account_ids: [bob.id], status_ids: [last_status.id]) conversation = described_class.create!(account: alice, conversation_id: last_status.conversation_id, participant_account_ids: [bob.id], status_ids: [last_status.id])
status = Fabricate(:status, account: bob, visibility: :direct, thread: last_status) status = Fabricate(:status, account: bob, visibility: :direct, thread: last_status)
status.mentions.create(account: alice) status.mentions.create(account: alice)
@ -36,7 +36,7 @@ RSpec.describe AccountConversation do
it 'creates new record when new participants are added' do it 'creates new record when new participants are added' do
last_status = Fabricate(:status, account: alice, visibility: :direct) last_status = Fabricate(:status, account: alice, visibility: :direct)
conversation = described_class.create!(account: alice, conversation: last_status.conversation, participant_account_ids: [bob.id], status_ids: [last_status.id]) conversation = described_class.create!(account: alice, conversation_id: last_status.conversation_id, participant_account_ids: [bob.id], status_ids: [last_status.id])
status = Fabricate(:status, account: bob, visibility: :direct, thread: last_status) status = Fabricate(:status, account: bob, visibility: :direct, thread: last_status)
status.mentions.create(account: alice) status.mentions.create(account: alice)
@ -55,7 +55,7 @@ RSpec.describe AccountConversation do
it 'updates last status to a previous value' do it 'updates last status to a previous value' do
last_status = Fabricate(:status, account: alice, visibility: :direct) last_status = Fabricate(:status, account: alice, visibility: :direct)
status = Fabricate(:status, account: alice, visibility: :direct) status = Fabricate(:status, account: alice, visibility: :direct)
conversation = described_class.create!(account: alice, conversation: last_status.conversation, participant_account_ids: [bob.id], status_ids: [status.id, last_status.id]) conversation = described_class.create!(account: alice, conversation_id: last_status.conversation_id, participant_account_ids: [bob.id], status_ids: [status.id, last_status.id])
last_status.mentions.create(account: bob) last_status.mentions.create(account: bob)
last_status.destroy! last_status.destroy!
conversation.reload conversation.reload
@ -65,7 +65,7 @@ RSpec.describe AccountConversation do
it 'removes the record if no other statuses are referenced' do it 'removes the record if no other statuses are referenced' do
last_status = Fabricate(:status, account: alice, visibility: :direct) last_status = Fabricate(:status, account: alice, visibility: :direct)
conversation = described_class.create!(account: alice, conversation: last_status.conversation, participant_account_ids: [bob.id], status_ids: [last_status.id]) conversation = described_class.create!(account: alice, conversation_id: last_status.conversation_id, participant_account_ids: [bob.id], status_ids: [last_status.id])
last_status.mentions.create(account: bob) last_status.mentions.create(account: bob)
last_status.destroy! last_status.destroy!
expect(described_class.where(id: conversation.id).count).to eq 0 expect(described_class.where(id: conversation.id).count).to eq 0

View File

@ -0,0 +1,127 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'ActivityPub Contexts' do
let(:conversation) { Fabricate(:conversation) }
describe 'GET #show' do
subject { get context_path(id: conversation.id), headers: nil }
let!(:status) { Fabricate(:status, conversation: conversation) }
let!(:unrelated_status) { Fabricate(:status) }
it 'returns http success and correct media type and correct items' do
subject
expect(response)
.to have_http_status(200)
.and have_cacheable_headers
expect(response.media_type)
.to eq 'application/activity+json'
expect(response.parsed_body[:type])
.to eq 'Collection'
expect(response.parsed_body[:first][:items])
.to be_an(Array)
.and have_attributes(size: 1)
.and include(ActivityPub::TagManager.instance.uri_for(status))
.and not_include(ActivityPub::TagManager.instance.uri_for(unrelated_status))
end
context 'with pagination' do
context 'with few statuses' do
before do
3.times do
Fabricate(:status, conversation: conversation)
end
end
it 'does not include a next page link' do
subject
expect(response.parsed_body[:first][:next]).to be_nil
end
end
context 'with many statuses' do
before do
(ActivityPub::ContextsController::DESCENDANTS_LIMIT + 1).times do
Fabricate(:status, conversation: conversation)
end
end
it 'includes a next page link' do
subject
expect(response.parsed_body['first']['next']).to_not be_nil
end
end
end
end
describe 'GET #items' do
subject { get items_context_path(id: conversation.id, page: 0, min_id: nil), headers: nil }
context 'with few statuses' do
before do
3.times do
Fabricate(:status, conversation: conversation)
end
end
it 'returns http success and correct media type and correct items' do
subject
expect(response)
.to have_http_status(200)
expect(response.media_type)
.to eq 'application/activity+json'
expect(response.parsed_body[:type])
.to eq 'Collection'
expect(response.parsed_body[:first][:items])
.to be_an(Array)
.and have_attributes(size: 3)
expect(response.parsed_body[:first][:next]).to be_nil
end
end
context 'with many statuses' do
before do
(ActivityPub::ContextsController::DESCENDANTS_LIMIT + 1).times do
Fabricate(:status, conversation: conversation)
end
end
it 'includes a next page link' do
subject
expect(response.parsed_body['first']['next']).to_not be_nil
end
end
context 'with page requested' do
before do
(ActivityPub::ContextsController::DESCENDANTS_LIMIT + 1).times do |_i|
Fabricate(:status, conversation: conversation)
end
end
it 'returns the correct items' do
get items_context_path(id: conversation.id, page: 0, min_id: nil), headers: nil
next_page = response.parsed_body['first']['next']
get next_page, headers: nil
expect(response.parsed_body['items'])
.to be_an(Array)
.and have_attributes(size: 1)
end
end
end
end

View File

@ -23,6 +23,7 @@ RSpec.describe ActivityPub::NoteSerializer do
'zh-TW' => a_kind_of(String), 'zh-TW' => a_kind_of(String),
}), }),
'replies' => replies_collection_values, 'replies' => replies_collection_values,
'context' => ActivityPub::TagManager.instance.uri_for(parent.conversation),
}) })
end end