Compare commits

...

2 Commits

Author SHA1 Message Date
Eugen Rochko
cd6a8305f0
Merge 76d708dd08 into e9170e2de1 2025-07-09 17:06:33 +00:00
Eugen Rochko
76d708dd08 WIP: Add pagination to threads 2025-06-25 13:48:02 +02:00
7 changed files with 143 additions and 23 deletions

View File

@ -41,7 +41,7 @@ class Api::V1::StatusesController < Api::BaseController
ancestors_limit = CONTEXT_LIMIT
descendants_limit = CONTEXT_LIMIT
descendants_depth_limit = nil
descendants_depth_limit = CONTEXT_LIMIT
if current_account.nil?
ancestors_limit = ANCESTORS_LIMIT
@ -50,14 +50,14 @@ class Api::V1::StatusesController < Api::BaseController
end
ancestors_results = @status.in_reply_to_id.nil? ? [] : @status.ancestors(ancestors_limit, current_account)
descendants_results = @status.descendants(descendants_limit, current_account, descendants_depth_limit)
descendants_results = @status.descendants(current_account, limit: descendants_limit, depth: descendants_depth_limit).select { |result| result.is_a?(Status) }
loaded_ancestors = preload_collection(ancestors_results, Status)
loaded_descendants = preload_collection(descendants_results, Status)
@context = Context.new(ancestors: loaded_ancestors, descendants: loaded_descendants)
statuses = [@status] + @context.ancestors + @context.descendants
render json: @context, serializer: REST::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
render json: @context, serializer: REST::V1::ContextSerializer, relationships: StatusRelationshipsPresenter.new(statuses, current_user&.account_id)
ActivityPub::FetchAllRepliesWorker.perform_async(@status.id) if !current_account.nil? && @status.should_fetch_replies?
end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
class Api::V2::StatusesController < Api::BaseController
include Authorization
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
before_action :set_status
def context
descendants_results = @status.descendants(current_account, limit: 3, depth: 2, after_id: nil)
@context = Context.new(ancestors: [], descendants: descendants_results)
render json: @context, serializer: REST::ContextSerializer
end
private
def set_status
@status = Status.find(params[:id])
authorize @status, :show?
rescue Mastodon::NotPermittedError
not_found
end
end

View File

@ -24,8 +24,25 @@ module Status::ThreadingConcern
find_statuses_from_tree_path(ancestor_ids(limit), account)
end
def descendants(limit, account = nil, depth = nil)
find_statuses_from_tree_path(descendant_ids(limit, depth), account, promote: true)
def descendants(account, limit:, depth:, after_id:)
tree = descendant_ids(limit:, depth:, after_id:)
statuses_map = Status.with_accounts(tree.reject { |id_or_placeholder| id_or_placeholder.is_a?(Context::Gap) }).index_by(&:id)
account_ids = statuses_map.values.map(&:account_id).uniq
domains = statuses_map.values.filter_map(&:account_domain).uniq
relations = account&.relations_map(account_ids, domains) || {}
statuses_map.values.each do |status|
statuses_map[status.id] = Context::FilterGap.new(id: status.id) if StatusFilter.new(status, account, relations).filtered?
end
tree.map do |id_or_placeholder|
if id_or_placeholder.is_a?(Context::Gap)
id_or_placeholder
else
statuses_map[id_or_placeholder]
end
end
end
def self_replies(limit)
@ -67,29 +84,51 @@ module Status::ThreadingConcern
SQL
end
def descendant_ids(limit, depth)
# use limit + 1 and depth + 1 because 'self' is included
depth += 1 if depth.present?
limit += 1 if limit.present?
descendants_with_self = Status.find_by_sql([<<-SQL.squish, id: id, limit: limit, depth: depth])
WITH RECURSIVE search_tree(id, path) AS (
SELECT id, ARRAY[id]
FROM statuses
WHERE id = :id
def descendant_ids(after_id:, limit:, depth:)
# We also fetch nodes that are one level deeper than requested so we can create pagination markers
descendant_leaves = Status.find_by_sql([<<-SQL.squish, id: id, after_id: after_id || 0, account_id: account_id, limit: limit, depth: depth])
WITH RECURSIVE search_tree(id, account_id, path) AS (
(
SELECT statuses.id, statuses.account_id, ARRAY[statuses.id]
FROM statuses
WHERE statuses.in_reply_to_id = :id
AND statuses.id > :after_id
LIMIT :limit + 1
)
UNION ALL
SELECT statuses.id, path || statuses.id
SELECT statuses.id, statuses.account_id, path || statuses.id
FROM search_tree
JOIN statuses ON statuses.in_reply_to_id = search_tree.id
WHERE COALESCE(array_length(path, 1) < :depth, TRUE) AND NOT statuses.id = ANY(path)
WHERE array_length(path, 1) < :depth + 1 AND NOT statuses.id = ANY(path)
)
SELECT id
SELECT id, path
FROM search_tree
ORDER BY path
LIMIT :limit
ORDER BY CASE WHEN account_id = :account_id THEN 1 ELSE 0 END DESC, path ASC
SQL
descendants_with_self.pluck(:id) - [id]
current_top_level_leaf = nil
top_level_leaves = 0
past_cut_off = false
descendant_leaves.filter_map do |result|
if result.path.size == 1
if top_level_leaves == limit
past_cut_off = true
next Context::LimitGap.new(id: current_top_level_leaf.id)
end
current_top_level_leaf = result
top_level_leaves += 1
end
if past_cut_off
nil
elsif result.path.size > depth # Nodes that are deeper than requested are pagination markers
Context::DepthGap.new(id: result.path[result.path.size - 2])
else
result.id
end
end
end
def find_statuses_from_tree_path(ids, account, promote: false)

View File

@ -2,4 +2,12 @@
class Context < ActiveModelSerializers::Model
attributes :ancestors, :descendants
class Gap < ActiveModelSerializers::Model
attributes :id
end
class DepthGap < Gap; end
class LimitGap < Gap; end
class FilterGap < Gap; end
end

View File

@ -1,6 +1,44 @@
# frozen_string_literal: true
class REST::ContextSerializer < ActiveModel::Serializer
has_many :ancestors, serializer: REST::StatusSerializer
has_many :descendants, serializer: REST::StatusSerializer
class DepthGapSerializer < ActiveModel::Serializer
attributes :more_under
def more_under
object.id.to_s
end
end
class LimitGapSerializer < ActiveModel::Serializer
attributes :more_after
def more_after
object.id.to_s
end
end
class FilterGapSerializer < ActiveModel::Serializer
attributes :filtered
def filtered
object.id.to_s
end
end
def self.serializer_for(model, options)
case model.class.name
when 'Status'
REST::StatusSerializer
when 'Context::DepthGap'
DepthGapSerializer
when 'Context::LimitGap'
LimitGapSerializer
when 'Context::FilterGap'
FilterGapSerializer
else
super
end
end
has_many :descendants
end

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
class REST::V1::ContextSerializer < ActiveModel::Serializer
has_many :ancestors, serializer: REST::StatusSerializer
has_many :descendants, serializer: REST::StatusSerializer
end

View File

@ -356,6 +356,12 @@ namespace :api, format: false do
resources :accounts, only: [:index], module: :notifications
end
resources :statuses, only: [] do
member do
get :context
end
end
end
namespace :web do