diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index d3b0e89e97..b842c9503f 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -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 diff --git a/app/controllers/api/v2/statuses_controller.rb b/app/controllers/api/v2/statuses_controller.rb new file mode 100644 index 0000000000..9c9c9606c4 --- /dev/null +++ b/app/controllers/api/v2/statuses_controller.rb @@ -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 diff --git a/app/models/concerns/status/threading_concern.rb b/app/models/concerns/status/threading_concern.rb index 478a139d63..c57f66f89a 100644 --- a/app/models/concerns/status/threading_concern.rb +++ b/app/models/concerns/status/threading_concern.rb @@ -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) diff --git a/app/models/context.rb b/app/models/context.rb index cc667999ed..40c52c3ea9 100644 --- a/app/models/context.rb +++ b/app/models/context.rb @@ -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 diff --git a/app/serializers/rest/context_serializer.rb b/app/serializers/rest/context_serializer.rb index 44515c85d7..1e6b165468 100644 --- a/app/serializers/rest/context_serializer.rb +++ b/app/serializers/rest/context_serializer.rb @@ -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 diff --git a/app/serializers/rest/v1/context_serializer.rb b/app/serializers/rest/v1/context_serializer.rb new file mode 100644 index 0000000000..c3b5e0efbb --- /dev/null +++ b/app/serializers/rest/v1/context_serializer.rb @@ -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 diff --git a/config/routes/api.rb b/config/routes/api.rb index 4040a4350f..9c59aef6f7 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -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