Compare commits

...

2 commits

Author SHA1 Message Date
Takeshi Umeda
824d1b8893
Merge branch 'master' into feature-circles 2021-01-05 23:46:40 +09:00
Eugen Rochko
6358072bc0 Add REST API for managing and posting to circles
Circles are the conceptual opposite of lists. A list is a subdivision
of your follows, a circle is a subdivision of your followers. Posting
to a circle means making content available to only some of your
followers. Circles have been internally supported in Mastodon for
the purposes of federation since #8950, this adds the REST API
necessary for making use of them in Mastodon itsef.
2020-07-19 02:05:16 +02:00
19 changed files with 352 additions and 3 deletions

View file

@ -0,0 +1,18 @@
# frozen_string_literal: true
class Api::V1::Accounts::CirclesController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:circles' }
before_action :require_user!
before_action :set_account
def index
@circles = @account.circles.where(account: current_account)
render json: @circles, each_serializer: REST::CircleSerializer
end
private
def set_account
@account = Account.find(params[:account_id])
end
end

View file

@ -0,0 +1,93 @@
# frozen_string_literal: true
class Api::V1::Circles::AccountsController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:circles' }, only: [:show]
before_action -> { doorkeeper_authorize! :write, :'write:circles' }, except: [:show]
before_action :require_user!
before_action :set_circle
after_action :insert_pagination_headers, only: :show
def show
@accounts = load_accounts
render json: @accounts, each_serializer: REST::AccountSerializer
end
def create
ApplicationRecord.transaction do
circle_accounts.each do |account|
@circle.accounts << account
end
end
render_empty
end
def destroy
CircleAccount.where(circle: @circle, account_id: account_ids).destroy_all
render_empty
end
private
def set_circle
@circle = current_account.owned_circles.find(params[:circle_id])
end
def load_accounts
if unlimited?
@circle.accounts.includes(:account_stat).all
else
@circle.accounts.includes(:account_stat).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])
end
end
def circle_accounts
Account.find(account_ids)
end
def account_ids
Array(resource_params[:account_ids])
end
def resource_params
params.permit(account_ids: [])
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
return if unlimited?
api_v1_circle_accounts_url(pagination_params(max_id: pagination_max_id)) if records_continue?
end
def prev_path
return if unlimited?
api_v1_circle_accounts_url(pagination_params(since_id: pagination_since_id)) unless @accounts.empty?
end
def pagination_max_id
@accounts.last.id
end
def pagination_since_id
@accounts.first.id
end
def records_continue?
@accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
end
def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params)
end
def unlimited?
params[:limit] == '0'
end
end

View file

@ -0,0 +1,73 @@
# frozen_string_literal: true
class Api::V1::CirclesController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:circles' }, only: [:index, :show]
before_action -> { doorkeeper_authorize! :write, :'write:circles' }, except: [:index, :show]
before_action :require_user!
before_action :set_circle, except: [:index, :create]
after_action :insert_pagination_headers, only: :index
def index
@circles = current_account.owned_circles.paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])
render json: @circles, each_serializer: REST::CircleSerializer
end
def show
render json: @circle, serializer: REST::CircleSerializer
end
def create
@circle = current_account.owned_circles.create!(circle_params)
render json: @circle, serializer: REST::CircleSerializer
end
def update
@circle.update!(circle_params)
render json: @circle, serializer: REST::CircleSerializer
end
def destroy
@circle.destroy!
render_empty
end
private
def set_circle
@circle = current_account.owned_circles.find(params[:id])
end
def circle_params
params.permit(:title)
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def next_path
api_v1_circles_url(pagination_params(max_id: pagination_max_id)) if records_continue?
end
def prev_path
api_v1_circles_url(pagination_params(since_id: pagination_since_id)) unless @circles.empty?
end
def pagination_max_id
@circles.last.id
end
def pagination_since_id
@circles.first.id
end
def records_continue?
@circles.size == limit_param(DEFAULT_ACCOUNTS_LIMIT)
end
def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params)
end
end

View file

@ -8,6 +8,7 @@ class Api::V1::StatusesController < Api::BaseController
before_action :require_user!, except: [:show, :context] before_action :require_user!, except: [:show, :context]
before_action :set_status, only: [:show, :context] before_action :set_status, only: [:show, :context]
before_action :set_thread, only: [:create] before_action :set_thread, only: [:create]
before_action :set_circle, only: [:create]
override_rate_limit_headers :create, family: :statuses override_rate_limit_headers :create, family: :statuses
@ -38,6 +39,7 @@ class Api::V1::StatusesController < Api::BaseController
@status = PostStatusService.new.call(current_user.account, @status = PostStatusService.new.call(current_user.account,
text: status_params[:status], text: status_params[:status],
thread: @thread, thread: @thread,
circle: @circle,
media_ids: status_params[:media_ids], media_ids: status_params[:media_ids],
sensitive: status_params[:sensitive], sensitive: status_params[:sensitive],
spoiler_text: status_params[:spoiler_text], spoiler_text: status_params[:spoiler_text],
@ -77,10 +79,17 @@ class Api::V1::StatusesController < Api::BaseController
render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404 render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404
end end
def set_circle
@circle = status_params[:circle_id].blank? ? nil : current_account.owned_circles.find(status_params[:circle_id])
rescue ActiveRecord::RecordNotFound
render json: { error: I18n.t('statuses.errors.circle_not_found') }, status: 404
end
def status_params def status_params
params.permit( params.permit(
:status, :status,
:in_reply_to_id, :in_reply_to_id,
:circle_id,
:sensitive, :sensitive,
:spoiler_text, :spoiler_text,
:visibility, :visibility,

22
app/models/circle.rb Normal file
View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: circles
#
# id :bigint(8) not null, primary key
# account_id :bigint(8) not null
# title :string default(""), not null
# created_at :datetime not null
# updated_at :datetime not null
#
class Circle < ApplicationRecord
include Paginable
belongs_to :account
has_many :circle_accounts, inverse_of: :circle, dependent: :destroy
has_many :accounts, through: :circle_accounts
validates :title, presence: true
end

View file

@ -0,0 +1,28 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: circle_accounts
#
# id :bigint(8) not null, primary key
# circle_id :bigint(8) not null
# account_id :bigint(8) not null
# follow_id :bigint(8) not null
# created_at :datetime not null
# updated_at :datetime not null
#
class CircleAccount < ApplicationRecord
belongs_to :circle
belongs_to :account
belongs_to :follow, optional: true
validates :account_id, uniqueness: { scope: :circle_id }
before_validation :set_follow
private
def set_follow
self.follow = Follow.find_by!(target_account_id: circle.account_id, account_id: account.id)
end
end

View file

@ -48,9 +48,12 @@ module AccountAssociations
# Lists (that the account is on, not owned by the account) # Lists (that the account is on, not owned by the account)
has_many :list_accounts, inverse_of: :account, dependent: :destroy has_many :list_accounts, inverse_of: :account, dependent: :destroy
has_many :lists, through: :list_accounts has_many :lists, through: :list_accounts
has_many :circle_accounts, inverse_of: :account, dependent: :destroy
has_many :circles, through: :circle_accounts
# Lists (owned by the account) # Lists (owned by the account)
has_many :owned_lists, class_name: 'List', dependent: :destroy, inverse_of: :account has_many :owned_lists, class_name: 'List', dependent: :destroy, inverse_of: :account
has_many :owned_circles, class_name: 'Circle', dependent: :destroy, inverse_of: :account
# Account migrations # Account migrations
belongs_to :moved_to_account, class_name: 'Account', optional: true belongs_to :moved_to_account, class_name: 'Account', optional: true

View file

@ -15,6 +15,7 @@ class PostStatusService < BaseService
# @option [String] :spoiler_text # @option [String] :spoiler_text
# @option [String] :language # @option [String] :language
# @option [String] :scheduled_at # @option [String] :scheduled_at
# @option [Circle] :circle Optional circle to target the status to
# @option [Hash] :poll Optional poll to attach # @option [Hash] :poll Optional poll to attach
# @option [Enumerable] :media_ids Optional array of media IDs to attach # @option [Enumerable] :media_ids Optional array of media IDs to attach
# @option [Doorkeeper::Application] :application # @option [Doorkeeper::Application] :application
@ -26,6 +27,7 @@ class PostStatusService < BaseService
@options = options @options = options
@text = @options[:text] || '' @text = @options[:text] || ''
@in_reply_to = @options[:thread] @in_reply_to = @options[:thread]
@circle = @options[:circle]
return idempotency_duplicate if idempotency_given? && idempotency_duplicate? return idempotency_duplicate if idempotency_given? && idempotency_duplicate?
@ -52,6 +54,7 @@ class PostStatusService < BaseService
@text = @options.delete(:spoiler_text) if @text.blank? && @options[:spoiler_text].present? @text = @options.delete(:spoiler_text) if @text.blank? && @options[:spoiler_text].present?
@visibility = @options[:visibility] || @account.user&.setting_default_privacy @visibility = @options[:visibility] || @account.user&.setting_default_privacy
@visibility = :unlisted if @visibility&.to_sym == :public && @account.silenced? @visibility = :unlisted if @visibility&.to_sym == :public && @account.silenced?
@visibility = :limited if @circle.present?
@scheduled_at = @options[:scheduled_at]&.to_datetime @scheduled_at = @options[:scheduled_at]&.to_datetime
@scheduled_at = nil if scheduled_in_the_past? @scheduled_at = nil if scheduled_in_the_past?
rescue ArgumentError rescue ArgumentError
@ -67,7 +70,7 @@ class PostStatusService < BaseService
end end
process_hashtags_service.call(@status) process_hashtags_service.call(@status)
process_mentions_service.call(@status) process_mentions_service.call(@status, @circle)
end end
def schedule_status! def schedule_status!

View file

@ -7,7 +7,8 @@ class ProcessMentionsService < BaseService
# local mention pointers, send Salmon notifications to mentioned # local mention pointers, send Salmon notifications to mentioned
# remote users # remote users
# @param [Status] status # @param [Status] status
def call(status) # @param [Circle] circle
def call(status, circle = nil)
return unless status.local? return unless status.local?
@status = status @status = status
@ -42,6 +43,12 @@ class ProcessMentionsService < BaseService
"@#{mentioned_account.acct}" "@#{mentioned_account.acct}"
end end
if circle.present?
circle.accounts.find_each do |target_account|
status.mentions.create(silent: true, account: target_account)
end
end
status.save! status.save!
check_for_spam(status) check_for_spam(status)

View file

@ -66,6 +66,7 @@ Doorkeeper.configure do
:'write:accounts', :'write:accounts',
:'write:blocks', :'write:blocks',
:'write:bookmarks', :'write:bookmarks',
:'write:circles',
:'write:conversations', :'write:conversations',
:'write:favourites', :'write:favourites',
:'write:filters', :'write:filters',
@ -80,6 +81,7 @@ Doorkeeper.configure do
:'read:accounts', :'read:accounts',
:'read:blocks', :'read:blocks',
:'read:bookmarks', :'read:bookmarks',
:'read:circles',
:'read:favourites', :'read:favourites',
:'read:filters', :'read:filters',
:'read:follows', :'read:follows',

View file

@ -439,6 +439,7 @@ Rails.application.routes.draw do
resources :followers, only: :index, controller: 'accounts/follower_accounts' resources :followers, only: :index, controller: 'accounts/follower_accounts'
resources :following, only: :index, controller: 'accounts/following_accounts' resources :following, only: :index, controller: 'accounts/following_accounts'
resources :lists, only: :index, controller: 'accounts/lists' resources :lists, only: :index, controller: 'accounts/lists'
resources :circles, only: :index, controller: 'accounts/circles'
resources :identity_proofs, only: :index, controller: 'accounts/identity_proofs' resources :identity_proofs, only: :index, controller: 'accounts/identity_proofs'
resources :featured_tags, only: :index, controller: 'accounts/featured_tags' resources :featured_tags, only: :index, controller: 'accounts/featured_tags'
@ -460,6 +461,10 @@ Rails.application.routes.draw do
resource :accounts, only: [:show, :create, :destroy], controller: 'lists/accounts' resource :accounts, only: [:show, :create, :destroy], controller: 'lists/accounts'
end end
resources :circles, only: [:index, :create, :show, :update, :destroy] do
resource :accounts, only: [:show, :create, :destroy], controller: 'circles/accounts'
end
namespace :featured_tags do namespace :featured_tags do
get :suggestions, to: 'suggestions#index' get :suggestions, to: 'suggestions#index'
end end

View file

@ -0,0 +1,10 @@
class CreateCircles < ActiveRecord::Migration[5.2]
def change
create_table :circles do |t|
t.belongs_to :account, foreign_key: { on_delete: :cascade }, null: false
t.string :title, default: '', null: false
t.timestamps
end
end
end

View file

@ -0,0 +1,14 @@
class CreateCircleAccounts < ActiveRecord::Migration[5.2]
def change
create_table :circle_accounts do |t|
t.belongs_to :circle, foreign_key: { on_delete: :cascade }, null: false
t.belongs_to :account, foreign_key: { on_delete: :cascade }, null: false
t.belongs_to :follow, foreign_key: { on_delete: :cascade }, null: false
t.timestamps
end
add_index :circle_accounts, [:account_id, :circle_id], unique: true
add_index :circle_accounts, [:circle_id, :account_id]
end
end

View file

@ -281,6 +281,27 @@ ActiveRecord::Schema.define(version: 2020_12_18_054746) do
t.index ["status_id"], name: "index_bookmarks_on_status_id" t.index ["status_id"], name: "index_bookmarks_on_status_id"
end end
create_table "circle_accounts", force: :cascade do |t|
t.bigint "circle_id", null: false
t.bigint "account_id", null: false
t.bigint "follow_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "circle_id"], name: "index_circle_accounts_on_account_id_and_circle_id", unique: true
t.index ["account_id"], name: "index_circle_accounts_on_account_id"
t.index ["circle_id", "account_id"], name: "index_circle_accounts_on_circle_id_and_account_id"
t.index ["circle_id"], name: "index_circle_accounts_on_circle_id"
t.index ["follow_id"], name: "index_circle_accounts_on_follow_id"
end
create_table "circles", force: :cascade do |t|
t.bigint "account_id", null: false
t.string "title", default: "", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_circles_on_account_id"
end
create_table "conversation_mutes", force: :cascade do |t| create_table "conversation_mutes", force: :cascade do |t|
t.bigint "conversation_id", null: false t.bigint "conversation_id", null: false
t.bigint "account_id", null: false t.bigint "account_id", null: false
@ -977,6 +998,10 @@ ActiveRecord::Schema.define(version: 2020_12_18_054746) do
add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade
add_foreign_key "bookmarks", "accounts", on_delete: :cascade add_foreign_key "bookmarks", "accounts", on_delete: :cascade
add_foreign_key "bookmarks", "statuses", on_delete: :cascade add_foreign_key "bookmarks", "statuses", on_delete: :cascade
add_foreign_key "circle_accounts", "accounts", on_delete: :cascade
add_foreign_key "circle_accounts", "circles", on_delete: :cascade
add_foreign_key "circle_accounts", "follows", on_delete: :cascade
add_foreign_key "circles", "accounts", on_delete: :cascade
add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade
add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade
add_foreign_key "custom_filters", "accounts", on_delete: :cascade add_foreign_key "custom_filters", "accounts", on_delete: :cascade

View file

@ -0,0 +1,5 @@
Fabricator(:circle_account) do
circle nil
account nil
follow nil
end

View file

@ -0,0 +1,4 @@
Fabricator(:circle) do
account
title "Family"
end

View file

@ -0,0 +1,4 @@
require 'rails_helper'
RSpec.describe CircleAccount, type: :model do
end

View file

@ -0,0 +1,4 @@
require 'rails_helper'
RSpec.describe Circle, type: :model do
end

View file

@ -25,6 +25,26 @@ RSpec.describe PostStatusService, type: :service do
expect(status.thread).to eq in_reply_to_status expect(status.thread).to eq in_reply_to_status
end end
it 'creates a new status for a given circle' do
account = Fabricate(:account)
circle = Fabricate(:circle, account: account)
circle_accounts = Fabricate.times(4, :account)
circle_accounts.each do |target_account|
target_account.follow!(account)
circle.accounts << target_account
end
text = 'Circle... hello'
status = subject.call(account, text: text, circle: circle)
expect(status).to be_persisted
expect(status.text).to eq text
expect(status.visibility).to eq 'limited'
expect(status.mentions.map(&:account)).to include(*circle_accounts)
end
it 'schedules a status' do it 'schedules a status' do
account = Fabricate(:account) account = Fabricate(:account)
future = Time.now.utc + 2.hours future = Time.now.utc + 2.hours
@ -134,7 +154,7 @@ RSpec.describe PostStatusService, type: :service do
status = subject.call(account, text: "test status update") status = subject.call(account, text: "test status update")
expect(ProcessMentionsService).to have_received(:new) expect(ProcessMentionsService).to have_received(:new)
expect(mention_service).to have_received(:call).with(status) expect(mention_service).to have_received(:call).with(status, nil)
end end
it 'processes hashtags' do it 'processes hashtags' do