diff --git a/app/controllers/api/v1/accounts/circles_controller.rb b/app/controllers/api/v1/accounts/circles_controller.rb new file mode 100644 index 0000000000..1a4e8b9c55 --- /dev/null +++ b/app/controllers/api/v1/accounts/circles_controller.rb @@ -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 diff --git a/app/controllers/api/v1/circles/accounts_controller.rb b/app/controllers/api/v1/circles/accounts_controller.rb new file mode 100644 index 0000000000..e0a67dbdfc --- /dev/null +++ b/app/controllers/api/v1/circles/accounts_controller.rb @@ -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 diff --git a/app/controllers/api/v1/circles_controller.rb b/app/controllers/api/v1/circles_controller.rb new file mode 100644 index 0000000000..e0bdc55186 --- /dev/null +++ b/app/controllers/api/v1/circles_controller.rb @@ -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 diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 8d6cb84b66..03bbd59aff 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -8,6 +8,7 @@ class Api::V1::StatusesController < Api::BaseController before_action :require_user!, except: [:show, :context] before_action :set_status, only: [:show, :context] before_action :set_thread, only: [:create] + before_action :set_circle, only: [:create] override_rate_limit_headers :create, family: :statuses @@ -38,6 +39,7 @@ class Api::V1::StatusesController < Api::BaseController @status = PostStatusService.new.call(current_user.account, text: status_params[:status], thread: @thread, + circle: @circle, media_ids: status_params[:media_ids], sensitive: status_params[:sensitive], spoiler_text: status_params[:spoiler_text], @@ -76,10 +78,17 @@ class Api::V1::StatusesController < Api::BaseController render json: { error: I18n.t('statuses.errors.in_reply_not_found') }, status: 404 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 params.permit( :status, :in_reply_to_id, + :circle_id, :sensitive, :spoiler_text, :visibility, diff --git a/app/models/circle.rb b/app/models/circle.rb new file mode 100644 index 0000000000..dcbf47a2e7 --- /dev/null +++ b/app/models/circle.rb @@ -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 diff --git a/app/models/circle_account.rb b/app/models/circle_account.rb new file mode 100644 index 0000000000..6366d6efa2 --- /dev/null +++ b/app/models/circle_account.rb @@ -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 diff --git a/app/models/concerns/account_associations.rb b/app/models/concerns/account_associations.rb index cca3a17fa4..d5144d4b8e 100644 --- a/app/models/concerns/account_associations.rb +++ b/app/models/concerns/account_associations.rb @@ -48,9 +48,12 @@ module AccountAssociations # Lists (that the account is on, not owned by the account) has_many :list_accounts, inverse_of: :account, dependent: :destroy 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) 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 belongs_to :moved_to_account, class_name: 'Account', optional: true diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 0a383d6a39..2df72e6c40 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -15,6 +15,7 @@ class PostStatusService < BaseService # @option [String] :spoiler_text # @option [String] :language # @option [String] :scheduled_at + # @option [Circle] :circle Optional circle to target the status to # @option [Hash] :poll Optional poll to attach # @option [Enumerable] :media_ids Optional array of media IDs to attach # @option [Doorkeeper::Application] :application @@ -26,6 +27,7 @@ class PostStatusService < BaseService @options = options @text = @options[:text] || '' @in_reply_to = @options[:thread] + @circle = @options[:circle] 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? @visibility = @options[:visibility] || @account.user&.setting_default_privacy @visibility = :unlisted if @visibility&.to_sym == :public && @account.silenced? + @visibility = :limited if @circle.present? @scheduled_at = @options[:scheduled_at]&.to_datetime @scheduled_at = nil if scheduled_in_the_past? rescue ArgumentError @@ -67,7 +70,7 @@ class PostStatusService < BaseService end process_hashtags_service.call(@status) - process_mentions_service.call(@status) + process_mentions_service.call(@status, @circle) end def schedule_status! diff --git a/app/services/process_mentions_service.rb b/app/services/process_mentions_service.rb index 3822b7dc5c..33243c0ba7 100644 --- a/app/services/process_mentions_service.rb +++ b/app/services/process_mentions_service.rb @@ -7,7 +7,8 @@ class ProcessMentionsService < BaseService # local mention pointers, send Salmon notifications to mentioned # remote users # @param [Status] status - def call(status) + # @param [Circle] circle + def call(status, circle = nil) return unless status.local? @status = status @@ -41,6 +42,12 @@ class ProcessMentionsService < BaseService "@#{mentioned_account.acct}" end + if circle.present? + circle.accounts.find_each do |target_account| + status.mentions.create(silent: true, account: target_account) + end + end + status.save! check_for_spam(status) diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index 63cff7c598..9fafcc4e65 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -66,6 +66,7 @@ Doorkeeper.configure do :'write:accounts', :'write:blocks', :'write:bookmarks', + :'write:circles', :'write:conversations', :'write:favourites', :'write:filters', @@ -80,6 +81,7 @@ Doorkeeper.configure do :'read:accounts', :'read:blocks', :'read:bookmarks', + :'read:circles', :'read:favourites', :'read:filters', :'read:follows', diff --git a/config/routes.rb b/config/routes.rb index 349db09343..5b768807b4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -417,6 +417,7 @@ Rails.application.routes.draw do resources :followers, only: :index, controller: 'accounts/follower_accounts' resources :following, only: :index, controller: 'accounts/following_accounts' resources :lists, only: :index, controller: 'accounts/lists' + resources :circles, only: :index, controller: 'accounts/circles' resources :identity_proofs, only: :index, controller: 'accounts/identity_proofs' member do @@ -437,6 +438,10 @@ Rails.application.routes.draw do resource :accounts, only: [:show, :create, :destroy], controller: 'lists/accounts' end + resources :circles, only: [:index, :create, :show, :update, :destroy] do + resource :accounts, only: [:show, :create, :destroy], controller: 'circles/accounts' + end + namespace :featured_tags do get :suggestions, to: 'suggestions#index' end diff --git a/db/migrate/20200718225713_create_circles.rb b/db/migrate/20200718225713_create_circles.rb new file mode 100644 index 0000000000..210d878cf8 --- /dev/null +++ b/db/migrate/20200718225713_create_circles.rb @@ -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 diff --git a/db/migrate/20200718225817_create_circle_accounts.rb b/db/migrate/20200718225817_create_circle_accounts.rb new file mode 100644 index 0000000000..988479b835 --- /dev/null +++ b/db/migrate/20200718225817_create_circle_accounts.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index cf31b6d23d..91869ce1aa 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2020_06_28_133322) do +ActiveRecord::Schema.define(version: 2020_07_18_225817) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -272,6 +272,27 @@ ActiveRecord::Schema.define(version: 2020_06_28_133322) do t.index ["status_id"], name: "index_bookmarks_on_status_id" 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| t.bigint "conversation_id", null: false t.bigint "account_id", null: false @@ -938,6 +959,10 @@ ActiveRecord::Schema.define(version: 2020_06_28_133322) do add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade add_foreign_key "bookmarks", "accounts", 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", "conversations", on_delete: :cascade add_foreign_key "custom_filters", "accounts", on_delete: :cascade diff --git a/spec/fabricators/circle_account_fabricator.rb b/spec/fabricators/circle_account_fabricator.rb new file mode 100644 index 0000000000..7f461ad6ca --- /dev/null +++ b/spec/fabricators/circle_account_fabricator.rb @@ -0,0 +1,5 @@ +Fabricator(:circle_account) do + circle nil + account nil + follow nil +end diff --git a/spec/fabricators/circle_fabricator.rb b/spec/fabricators/circle_fabricator.rb new file mode 100644 index 0000000000..96ba0137c4 --- /dev/null +++ b/spec/fabricators/circle_fabricator.rb @@ -0,0 +1,4 @@ +Fabricator(:circle) do + account + title "Family" +end diff --git a/spec/models/circle_account_spec.rb b/spec/models/circle_account_spec.rb new file mode 100644 index 0000000000..23c11dd423 --- /dev/null +++ b/spec/models/circle_account_spec.rb @@ -0,0 +1,4 @@ +require 'rails_helper' + +RSpec.describe CircleAccount, type: :model do +end diff --git a/spec/models/circle_spec.rb b/spec/models/circle_spec.rb new file mode 100644 index 0000000000..4e0c151fd5 --- /dev/null +++ b/spec/models/circle_spec.rb @@ -0,0 +1,4 @@ +require 'rails_helper' + +RSpec.describe Circle, type: :model do +end diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb index 147a59fc31..15a4c6fb77 100644 --- a/spec/services/post_status_service_spec.rb +++ b/spec/services/post_status_service_spec.rb @@ -25,6 +25,26 @@ RSpec.describe PostStatusService, type: :service do expect(status.thread).to eq in_reply_to_status 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 account = Fabricate(:account) future = Time.now.utc + 2.hours @@ -134,7 +154,7 @@ RSpec.describe PostStatusService, type: :service do status = subject.call(account, text: "test status update") 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 it 'processes hashtags' do