Follow call on locked account creates follow request instead

Reflect "requested" relationship in API and UI
Reflect inability of private posts to be reblogged in the UI
Disable Webfinger for locked accounts
master
Eugen Rochko 7 years ago
parent 2d2154ba75
commit b891a81008
  1. 17
      app/assets/javascripts/components/components/icon_button.jsx
  2. 2
      app/assets/javascripts/components/components/status_action_bar.jsx
  3. 19
      app/assets/javascripts/components/features/account/components/header.jsx
  4. 2
      app/assets/javascripts/components/features/status/components/action_bar.jsx
  5. 3
      app/assets/stylesheets/components.scss
  6. 10
      app/assets/stylesheets/forms.scss
  7. 3
      app/controllers/api/v1/accounts_controller.rb
  8. 4
      app/controllers/stream_entries_controller.rb
  9. 2
      app/controllers/xrd_controller.rb
  10. 10
      app/lib/feed_manager.rb
  11. 6
      app/models/account.rb
  12. 19
      app/models/follow_request.rb
  13. 2
      app/models/status.rb
  14. 29
      app/services/follow_service.rb
  15. 2
      app/services/reblog_service.rb
  16. 1
      app/views/api/v1/accounts/relationship.rabl
  17. 6
      app/views/api/v1/accounts/show.rabl
  18. 12
      app/views/settings/profiles/show.html.haml
  19. 8
      config/initializers/simple_form.rb
  20. 4
      config/locales/simple_form.en.yml
  21. 12
      db/migrate/20161222204147_create_follow_requests.rb
  22. 10
      db/schema.rb
  23. 3
      spec/fabricators/follow_request_fabricator.rb
  24. 6
      spec/models/follow_request_spec.rb

@ -5,17 +5,19 @@ const IconButton = React.createClass({
propTypes: { propTypes: {
title: React.PropTypes.string.isRequired, title: React.PropTypes.string.isRequired,
icon: React.PropTypes.string.isRequired, icon: React.PropTypes.string.isRequired,
onClick: React.PropTypes.func.isRequired, onClick: React.PropTypes.func,
size: React.PropTypes.number, size: React.PropTypes.number,
active: React.PropTypes.bool, active: React.PropTypes.bool,
style: React.PropTypes.object, style: React.PropTypes.object,
activeStyle: React.PropTypes.object activeStyle: React.PropTypes.object,
disabled: React.PropTypes.bool
}, },
getDefaultProps () { getDefaultProps () {
return { return {
size: 18, size: 18,
active: false active: false,
disabled: false
}; };
}, },
@ -23,8 +25,10 @@ const IconButton = React.createClass({
handleClick (e) { handleClick (e) {
e.preventDefault(); e.preventDefault();
this.props.onClick();
e.stopPropagation(); if (!this.props.disabled) {
this.props.onClick();
}
}, },
render () { render () {
@ -37,7 +41,6 @@ const IconButton = React.createClass({
width: `${this.props.size * 1.28571429}px`, width: `${this.props.size * 1.28571429}px`,
height: `${this.props.size}px`, height: `${this.props.size}px`,
lineHeight: `${this.props.size}px`, lineHeight: `${this.props.size}px`,
cursor: 'pointer',
...this.props.style ...this.props.style
}; };
@ -46,7 +49,7 @@ const IconButton = React.createClass({
} }
return ( return (
<button aria-label={this.props.title} title={this.props.title} className={`icon-button ${this.props.active ? 'active' : ''}`} onClick={this.handleClick} style={style}> <button aria-label={this.props.title} title={this.props.title} className={`icon-button ${this.props.active ? 'active' : ''} ${this.props.disabled ? 'disabled' : ''}`} onClick={this.handleClick} style={style}>
<i className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' /> <i className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
</button> </button>
); );

@ -76,7 +76,7 @@ const StatusActionBar = React.createClass({
return ( return (
<div style={{ marginTop: '10px', overflow: 'hidden' }}> <div style={{ marginTop: '10px', overflow: 'hidden' }}>
<div style={{ float: 'left', marginRight: '18px'}}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div> <div style={{ float: 'left', marginRight: '18px'}}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div>
<div style={{ float: 'left', marginRight: '18px'}}><IconButton active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon='retweet' onClick={this.handleReblogClick} /></div> <div style={{ float: 'left', marginRight: '18px'}}><IconButton disabled={status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon='retweet' onClick={this.handleReblogClick} /></div>
<div style={{ float: 'left', marginRight: '18px'}}><IconButton active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div> <div style={{ float: 'left', marginRight: '18px'}}><IconButton active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
<div style={{ width: '18px', height: '18px', float: 'left' }}> <div style={{ width: '18px', height: '18px', float: 'left' }}>

@ -8,6 +8,7 @@ import IconButton from '../../../components/icon_button';
const messages = defineMessages({ const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
follow: { id: 'account.follow', defaultMessage: 'Follow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }
}); });
const Header = React.createClass({ const Header = React.createClass({
@ -36,11 +37,19 @@ const Header = React.createClass({
} }
if (me !== account.get('id')) { if (me !== account.get('id')) {
actionBtn = ( if (account.getIn(['relationship', 'requested'])) {
<div style={{ position: 'absolute', top: '10px', left: '20px' }}> actionBtn = (
<IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} /> <div style={{ position: 'absolute', top: '10px', left: '20px' }}>
</div> <IconButton size={26} disabled={true} icon='hourglass' title={intl.formatMessage(messages.requested)} />
); </div>
);
} else {
actionBtn = (
<div style={{ position: 'absolute', top: '10px', left: '20px' }}>
<IconButton size={26} icon={account.getIn(['relationship', 'following']) ? 'user-times' : 'user-plus'} active={account.getIn(['relationship', 'following'])} title={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />
</div>
);
}
} }
const content = { __html: emojify(account.get('note')) }; const content = { __html: emojify(account.get('note')) };

@ -60,7 +60,7 @@ const ActionBar = React.createClass({
return ( return (
<div style={{ background: '#2f3441', display: 'flex', flexDirection: 'row', borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', padding: '10px 0' }}> <div style={{ background: '#2f3441', display: 'flex', flexDirection: 'row', borderTop: '1px solid #363c4b', borderBottom: '1px solid #363c4b', padding: '10px 0' }}>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div> <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton title={intl.formatMessage(messages.reply)} icon='reply' onClick={this.handleReplyClick} /></div>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon='retweet' onClick={this.handleReblogClick} /></div> <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton disabled={status.get('visibility') === 'private'} active={status.get('reblogged')} title={intl.formatMessage(messages.reblog)} icon='retweet' onClick={this.handleReblogClick} /></div>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div> <div style={{ flex: '1 1 auto', textAlign: 'center' }}><IconButton active={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} activeStyle={{ color: '#ca8f04' }} /></div>
<div style={{ flex: '1 1 auto', textAlign: 'center' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} /></div> <div style={{ flex: '1 1 auto', textAlign: 'center' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} /></div>
</div> </div>

@ -44,13 +44,14 @@
color: #616b86; color: #616b86;
border: none; border: none;
background: transparent; background: transparent;
cursor: pointer;
&:hover { &:hover {
color: #717b98; color: #717b98;
} }
&.disabled { &.disabled {
color: #535b72; color: #454b5e;
cursor: default; cursor: default;
} }

@ -14,6 +14,12 @@ code {
margin-bottom: 15px; margin-bottom: 15px;
} }
.hint {
display: block;
color: rgba(255, 255, 255, 0.8);
font-size: 12px;
}
.input.file, .input.select { .input.file, .input.select {
padding: 15px 0; padding: 15px 0;
margin-bottom: 0; margin-bottom: 0;
@ -59,6 +65,10 @@ code {
top: 1px; top: 1px;
margin: 0; margin: 0;
} }
.hint {
padding-left: 25px;
}
} }
input[type=text], input[type=email], input[type=password], textarea { input[type=text], input[type=email], input[type=password], textarea {

@ -84,10 +84,12 @@ class Api::V1::AccountsController < ApiController
def relationships def relationships
ids = params[:id].is_a?(Enumerable) ? params[:id].map(&:to_i) : [params[:id].to_i] ids = params[:id].is_a?(Enumerable) ? params[:id].map(&:to_i) : [params[:id].to_i]
@accounts = Account.where(id: ids).select('id') @accounts = Account.where(id: ids).select('id')
@following = Account.following_map(ids, current_user.account_id) @following = Account.following_map(ids, current_user.account_id)
@followed_by = Account.followed_by_map(ids, current_user.account_id) @followed_by = Account.followed_by_map(ids, current_user.account_id)
@blocking = Account.blocking_map(ids, current_user.account_id) @blocking = Account.blocking_map(ids, current_user.account_id)
@requested = Account.requested_map(ids, current_user.account_id)
end end
def search def search
@ -109,5 +111,6 @@ class Api::V1::AccountsController < ApiController
@following = Account.following_map([@account.id], current_user.account_id) @following = Account.following_map([@account.id], current_user.account_id)
@followed_by = Account.followed_by_map([@account.id], current_user.account_id) @followed_by = Account.followed_by_map([@account.id], current_user.account_id)
@blocking = Account.blocking_map([@account.id], current_user.account_id) @blocking = Account.blocking_map([@account.id], current_user.account_id)
@requested = Account.requested_map([@account.id], current_user.account_id)
end end
end end

@ -43,8 +43,10 @@ class StreamEntriesController < ApplicationController
end end
def set_stream_entry def set_stream_entry
@stream_entry = @account.stream_entries.where(hidden: false).find(params[:id]) @stream_entry = @account.stream_entries.find(params[:id])
@type = @stream_entry.activity_type.downcase @type = @stream_entry.activity_type.downcase
raise ActiveRecord::RecordNotFound if @stream_entry.hidden? && (@stream_entry.activity_type != 'Status' || (@stream_entry.activity_type == 'Status' && !@stream_entry.activity.permitted?(current_account)))
end end
def check_account_suspension def check_account_suspension

@ -13,7 +13,7 @@ class XrdController < ApplicationController
end end
def webfinger def webfinger
@account = Account.find_local!(username_from_resource) @account = Account.where(locked: false).find_local!(username_from_resource)
@canonical_account_uri = "acct:#{@account.username}@#{Rails.configuration.x.local_domain}" @canonical_account_uri = "acct:#{@account.username}@#{Rails.configuration.x.local_domain}"
@magic_key = pem_to_magic_key(@account.keypair.public_key) @magic_key = pem_to_magic_key(@account.keypair.public_key)

@ -39,6 +39,16 @@ class FeedManager
redis.zremrangebyscore(key(type, account_id), '-inf', "(#{last.last}") redis.zremrangebyscore(key(type, account_id), '-inf', "(#{last.last}")
end end
def merge_into_timeline(from_account, into_account)
timeline_key = key(:home, into_account.id)
from_account.statuses.limit(MAX_ITEMS).each do |status|
redis.zadd(timeline_key, status.id, status.id)
end
trim(:home, into_account.id)
end
def inline_render(target_account, template, object) def inline_render(target_account, template, object)
rabl_scope = Class.new do rabl_scope = Class.new do
include RoutingHelper include RoutingHelper

@ -34,6 +34,8 @@ class Account < ApplicationRecord
has_many :notifications, inverse_of: :account, dependent: :destroy has_many :notifications, inverse_of: :account, dependent: :destroy
# Follow relations # Follow relations
has_many :follow_requests, dependent: :destroy
has_many :active_relationships, class_name: 'Follow', foreign_key: 'account_id', dependent: :destroy has_many :active_relationships, class_name: 'Follow', foreign_key: 'account_id', dependent: :destroy
has_many :passive_relationships, class_name: 'Follow', foreign_key: 'target_account_id', dependent: :destroy has_many :passive_relationships, class_name: 'Follow', foreign_key: 'target_account_id', dependent: :destroy
@ -179,6 +181,10 @@ class Account < ApplicationRecord
def blocking_map(target_account_ids, account_id) def blocking_map(target_account_ids, account_id)
Block.where(target_account_id: target_account_ids).where(account_id: account_id).map { |b| [b.target_account_id, true] }.to_h Block.where(target_account_id: target_account_ids).where(account_id: account_id).map { |b| [b.target_account_id, true] }.to_h
end end
def requested_map(target_account_ids, account_id)
FollowRequest.where(target_account_id: target_account_ids).where(account_id: account_id).map { |r| [r.target_account_id, true] }.to_h
end
end end
before_create do before_create do

@ -0,0 +1,19 @@
# frozen_string_literal: true
class FollowRequest < ApplicationRecord
belongs_to :account
belongs_to :target_account, class_name: 'Account'
validates :account, :target_account, presence: true
validates :account_id, uniqueness: { scope: :target_account_id }
def authorize!
account.follow!(target_account)
FeedManager.instance.merge_into_timeline(target_account, account)
destroy!
end
def reject!
destroy!
end
end

@ -170,7 +170,7 @@ class Status < ApplicationRecord
text.strip! text.strip!
self.reblog = reblog.reblog if reblog? && reblog.reblog? self.reblog = reblog.reblog if reblog? && reblog.reblog?
self.in_reply_to_account_id = thread.account_id if reply? self.in_reply_to_account_id = thread.account_id if reply?
self.visibility = :public if visibility.nil? self.visibility = (account.locked? ? :private : :public) if visibility.nil?
end end
private private

@ -10,6 +10,20 @@ class FollowService < BaseService
raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended? raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended?
raise Mastodon::NotPermitted if target_account.blocking?(source_account) raise Mastodon::NotPermitted if target_account.blocking?(source_account)
if target_account.locked?
request_follow(source_account, target_account)
else
direct_follow(source_account, target_account)
end
end
private
def request_follow(source_account, target_account)
FollowRequest.create!(account: source_account, target_account: target_account)
end
def direct_follow(source_account, target_account)
follow = source_account.follow!(target_account) follow = source_account.follow!(target_account)
if target_account.local? if target_account.local?
@ -19,25 +33,12 @@ class FollowService < BaseService
NotificationWorker.perform_async(follow.stream_entry.id, target_account.id) NotificationWorker.perform_async(follow.stream_entry.id, target_account.id)
end end
merge_into_timeline(target_account, source_account) FeedManager.instance.merge_into_timeline(target_account, source_account)
Pubsubhubbub::DistributionWorker.perform_async(follow.stream_entry.id) Pubsubhubbub::DistributionWorker.perform_async(follow.stream_entry.id)
follow follow
end end
private
def merge_into_timeline(from_account, into_account)
timeline_key = FeedManager.instance.key(:home, into_account.id)
from_account.statuses.find_each do |status|
redis.zadd(timeline_key, status.id, status.id)
end
FeedManager.instance.trim(:home, into_account.id)
end
def redis def redis
Redis.current Redis.current
end end

@ -6,7 +6,7 @@ class ReblogService < BaseService
# @param [Status] reblogged_status Status to be reblogged # @param [Status] reblogged_status Status to be reblogged
# @return [Status] # @return [Status]
def call(account, reblogged_status) def call(account, reblogged_status)
raise ActiveRecord::RecordInvalid if reblogged_status.private_visibility? raise Mastodon::NotPermitted if reblogged_status.private_visibility?
reblog = account.statuses.create!(reblog: reblogged_status, text: '') reblog = account.statuses.create!(reblog: reblogged_status, text: '')

@ -4,3 +4,4 @@ attribute :id
node(:following) { |account| @following[account.id] || false } node(:following) { |account| @following[account.id] || false }
node(:followed_by) { |account| @followed_by[account.id] || false } node(:followed_by) { |account| @followed_by[account.id] || false }
node(:blocking) { |account| @blocking[account.id] || false } node(:blocking) { |account| @blocking[account.id] || false }
node(:requested) { |account| @requested[account.id] || false }

@ -1,11 +1,11 @@
object @account object @account
attributes :id, :username, :acct, :display_name attributes :id, :username, :acct, :display_name, :locked
node(:note) { |account| Formatter.instance.simplified_format(account) } node(:note) { |account| Formatter.instance.simplified_format(account) }
node(:url) { |account| TagManager.instance.url_for(account) } node(:url) { |account| TagManager.instance.url_for(account) }
node(:avatar) { |account| full_asset_url(account.avatar.url( :original)) } node(:avatar) { |account| full_asset_url(account.avatar.url(:original)) }
node(:header) { |account| full_asset_url(account.header.url( :original)) } node(:header) { |account| full_asset_url(account.header.url(:original)) }
node(:followers_count) { |account| defined?(@followers_counts_map) ? (@followers_counts_map[account.id] || 0) : (account.try(:followers_count) || account.followers.count) } node(:followers_count) { |account| defined?(@followers_counts_map) ? (@followers_counts_map[account.id] || 0) : (account.try(:followers_count) || account.followers.count) }
node(:following_count) { |account| defined?(@following_counts_map) ? (@following_counts_map[account.id] || 0) : (account.try(:following_count) || account.following.count) } node(:following_count) { |account| defined?(@following_counts_map) ? (@following_counts_map[account.id] || 0) : (account.try(:following_count) || account.following.count) }
node(:statuses_count) { |account| defined?(@statuses_counts_map) ? (@statuses_counts_map[account.id] || 0) : (account.try(:statuses_count) || account.statuses.count) } node(:statuses_count) { |account| defined?(@statuses_counts_map) ? (@statuses_counts_map[account.id] || 0) : (account.try(:statuses_count) || account.statuses.count) }

@ -4,11 +4,13 @@
= simple_form_for @account, url: settings_profile_path, html: { method: :put } do |f| = simple_form_for @account, url: settings_profile_path, html: { method: :put } do |f|
= render 'shared/error_messages', object: @account = render 'shared/error_messages', object: @account
= f.input :display_name, placeholder: t('simple_form.labels.defaults.display_name') .fields-group
= f.input :note, placeholder: t('simple_form.labels.defaults.note') = f.input :display_name, placeholder: t('simple_form.labels.defaults.display_name')
= f.input :avatar, wrapper: :with_label = f.input :note, placeholder: t('simple_form.labels.defaults.note')
= f.input :header, wrapper: :with_label = f.input :avatar, wrapper: :with_label
= f.input :locked, as: :boolean, wrapper: :with_label = f.input :header, wrapper: :with_label
= f.input :locked, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.locked')
.actions .actions
= f.button :button, t('generic.save_changes'), type: :submit = f.button :button, t('generic.save_changes'), type: :submit

@ -5,8 +5,7 @@ SimpleForm.setup do |config|
# wrapper, change the order or even add your own to the # wrapper, change the order or even add your own to the
# stack. The options given below are used to wrap the # stack. The options given below are used to wrap the
# whole input. # whole input.
config.wrappers :default, class: :input, config.wrappers :default, class: :input, hint_class: :field_with_hint, error_class: :field_with_errors do |b|
hint_class: :field_with_hint, error_class: :field_with_errors do |b|
## Extensions enabled by default ## Extensions enabled by default
# Any of these extensions can be disabled for a # Any of these extensions can be disabled for a
# given input by passing: `f.input EXTENSION_NAME => false`. # given input by passing: `f.input EXTENSION_NAME => false`.
@ -51,12 +50,11 @@ SimpleForm.setup do |config|
# b.use :full_error, wrap_with: { tag: :span, class: :error } # b.use :full_error, wrap_with: { tag: :span, class: :error }
end end
config.wrappers :with_label, class: :input, config.wrappers :with_label, class: :input, hint_class: :field_with_hint, error_class: :field_with_errors do |b|
hint_class: :field_with_hint, error_class: :field_with_errors do |b|
b.use :html5 b.use :html5
b.use :label_input
b.use :hint, wrap_with: { tag: :span, class: :hint } b.use :hint, wrap_with: { tag: :span, class: :hint }
b.use :error, wrap_with: { tag: :span, class: :error } b.use :error, wrap_with: { tag: :span, class: :error }
b.use :label_input
end end
# The default wrapper to be used by the FormBuilder. # The default wrapper to be used by the FormBuilder.

@ -15,6 +15,7 @@ en:
note: Bio note: Bio
password: Password password: Password
username: Username username: Username
locked: Make account private
interactions: interactions:
must_be_follower: Block notifications from non-followers must_be_follower: Block notifications from non-followers
must_be_following: Block notifications from people you don't follow must_be_following: Block notifications from people you don't follow
@ -23,6 +24,9 @@ en:
follow: Send e-mail when someone follows you follow: Send e-mail when someone follows you
mention: Send e-mail when someone mentions you mention: Send e-mail when someone mentions you
reblog: Send e-mail when someone reblogs your status reblog: Send e-mail when someone reblogs your status
hints:
defaults:
locked: Requires you to approve followers, defaults post privacy to followers-only and disables federation
'no': 'No' 'no': 'No'
required: required:
mark: "*" mark: "*"

@ -0,0 +1,12 @@
class CreateFollowRequests < ActiveRecord::Migration[5.0]
def change
create_table :follow_requests do |t|
t.integer :account_id, null: false
t.integer :target_account_id, null: false
t.timestamps null: false
end
add_index :follow_requests, [:account_id, :target_account_id], unique: true
end
end

@ -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.define(version: 20161222201034) do ActiveRecord::Schema.define(version: 20161222204147) 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 "plpgsql" enable_extension "plpgsql"
@ -69,6 +69,14 @@ ActiveRecord::Schema.define(version: 20161222201034) do
t.index ["account_id", "status_id"], name: "index_favourites_on_account_id_and_status_id", unique: true, using: :btree t.index ["account_id", "status_id"], name: "index_favourites_on_account_id_and_status_id", unique: true, using: :btree
end end
create_table "follow_requests", force: :cascade do |t|
t.integer "account_id", null: false
t.integer "target_account_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id", "target_account_id"], name: "index_follow_requests_on_account_id_and_target_account_id", unique: true, using: :btree
end
create_table "follows", force: :cascade do |t| create_table "follows", force: :cascade do |t|
t.integer "account_id", null: false t.integer "account_id", null: false
t.integer "target_account_id", null: false t.integer "target_account_id", null: false

@ -0,0 +1,3 @@
Fabricator(:follow_request) do
end

@ -0,0 +1,6 @@
require 'rails_helper'
RSpec.describe FollowRequest, type: :model do
describe '#authorize!'
describe '#reject!'
end
Loading…
Cancel
Save