From b891a81008d2cf595cb37432a8e1f36606db16d6 Mon Sep 17 00:00:00 2001
From: Eugen Rochko <eugen@zeonfederated.com>
Date: Thu, 22 Dec 2016 23:03:57 +0100
Subject: [PATCH] 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
---
 .../components/components/icon_button.jsx     | 17 ++++++-----
 .../components/status_action_bar.jsx          |  2 +-
 .../features/account/components/header.jsx    | 19 ++++++++----
 .../features/status/components/action_bar.jsx |  2 +-
 app/assets/stylesheets/components.scss        |  3 +-
 app/assets/stylesheets/forms.scss             | 10 +++++++
 app/controllers/api/v1/accounts_controller.rb |  3 ++
 app/controllers/stream_entries_controller.rb  |  4 ++-
 app/controllers/xrd_controller.rb             |  2 +-
 app/lib/feed_manager.rb                       | 10 +++++++
 app/models/account.rb                         |  6 ++++
 app/models/follow_request.rb                  | 19 ++++++++++++
 app/models/status.rb                          |  2 +-
 app/services/follow_service.rb                | 29 ++++++++++---------
 app/services/reblog_service.rb                |  2 +-
 app/views/api/v1/accounts/relationship.rabl   |  1 +
 app/views/api/v1/accounts/show.rabl           |  6 ++--
 app/views/settings/profiles/show.html.haml    | 12 ++++----
 config/initializers/simple_form.rb            |  8 ++---
 config/locales/simple_form.en.yml             |  4 +++
 .../20161222204147_create_follow_requests.rb  | 12 ++++++++
 db/schema.rb                                  | 10 ++++++-
 spec/fabricators/follow_request_fabricator.rb |  3 ++
 spec/models/follow_request_spec.rb            |  6 ++++
 24 files changed, 145 insertions(+), 47 deletions(-)
 create mode 100644 app/models/follow_request.rb
 create mode 100644 db/migrate/20161222204147_create_follow_requests.rb
 create mode 100644 spec/fabricators/follow_request_fabricator.rb
 create mode 100644 spec/models/follow_request_spec.rb
diff --git a/app/assets/javascripts/components/components/icon_button.jsx b/app/assets/javascripts/components/components/icon_button.jsx
index d8f00f5d8b..e9a7228e4d 100644
--- a/app/assets/javascripts/components/components/icon_button.jsx
+++ b/app/assets/javascripts/components/components/icon_button.jsx
@@ -5,17 +5,19 @@ const IconButton = React.createClass({
   propTypes: {
     title: React.PropTypes.string.isRequired,
     icon: React.PropTypes.string.isRequired,
-    onClick: React.PropTypes.func.isRequired,
+    onClick: React.PropTypes.func,
     size: React.PropTypes.number,
     active: React.PropTypes.bool,
     style: React.PropTypes.object,
-    activeStyle: React.PropTypes.object
+    activeStyle: React.PropTypes.object,
+    disabled: React.PropTypes.bool
   },
 
   getDefaultProps () {
     return {
       size: 18,
-      active: false
+      active: false,
+      disabled: false
     };
   },
 
@@ -23,8 +25,10 @@ const IconButton = React.createClass({
 
   handleClick (e) {
     e.preventDefault();
-    this.props.onClick();
-    e.stopPropagation();
+
+    if (!this.props.disabled) {
+      this.props.onClick();
+    }
   },
 
   render () {
@@ -37,7 +41,6 @@ const IconButton = React.createClass({
       width: `${this.props.size * 1.28571429}px`,
       height: `${this.props.size}px`,
       lineHeight: `${this.props.size}px`,
-      cursor: 'pointer',
       ...this.props.style
     };
 
@@ -46,7 +49,7 @@ const IconButton = React.createClass({
     }
 
     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' />
       </button>
     );
diff --git a/app/assets/javascripts/components/components/status_action_bar.jsx b/app/assets/javascripts/components/components/status_action_bar.jsx
index 9c6d13bdfc..80b51456e2 100644
--- a/app/assets/javascripts/components/components/status_action_bar.jsx
+++ b/app/assets/javascripts/components/components/status_action_bar.jsx
@@ -76,7 +76,7 @@ const StatusActionBar = React.createClass({
     return (
       <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 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={{ width: '18px', height: '18px', float: 'left' }}>
diff --git a/app/assets/javascripts/components/features/account/components/header.jsx b/app/assets/javascripts/components/features/account/components/header.jsx
index b890e15c19..fe400e50bc 100644
--- a/app/assets/javascripts/components/features/account/components/header.jsx
+++ b/app/assets/javascripts/components/features/account/components/header.jsx
@@ -8,6 +8,7 @@ import IconButton from '../../../components/icon_button';
 const messages = defineMessages({
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
   follow: { id: 'account.follow', defaultMessage: 'Follow' },
+  requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }
 });
 
 const Header = React.createClass({
@@ -36,11 +37,19 @@ const Header = React.createClass({
     }
 
     if (me !== account.get('id')) {
-      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>
-      );
+      if (account.getIn(['relationship', 'requested'])) {
+        actionBtn = (
+          <div style={{ position: 'absolute', top: '10px', left: '20px' }}>
+            <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')) };
diff --git a/app/assets/javascripts/components/features/status/components/action_bar.jsx b/app/assets/javascripts/components/features/status/components/action_bar.jsx
index 1f46b956e8..00b1dd415e 100644
--- a/app/assets/javascripts/components/features/status/components/action_bar.jsx
+++ b/app/assets/javascripts/components/features/status/components/action_bar.jsx
@@ -60,7 +60,7 @@ const ActionBar = React.createClass({
     return (
       <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 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' }}><DropdownMenu size={18} icon='ellipsis-h' items={menu} /></div>
       </div>
diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss
index 210e722cc5..1689193a86 100644
--- a/app/assets/stylesheets/components.scss
+++ b/app/assets/stylesheets/components.scss
@@ -44,13 +44,14 @@
   color: #616b86;
   border: none;
   background: transparent;
+  cursor: pointer;
 
   &:hover {
     color: #717b98;
   }
 
   &.disabled {
-    color: #535b72;
+    color: #454b5e;
     cursor: default;
   }
 
diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss
index 81270edf6c..cf9b4fba6c 100644
--- a/app/assets/stylesheets/forms.scss
+++ b/app/assets/stylesheets/forms.scss
@@ -14,6 +14,12 @@ code {
     margin-bottom: 15px;
   }
 
+  .hint {
+    display: block;
+    color: rgba(255, 255, 255, 0.8);
+    font-size: 12px;
+  }
+
   .input.file, .input.select {
     padding: 15px 0;
     margin-bottom: 0;
@@ -59,6 +65,10 @@ code {
       top: 1px;
       margin: 0;
     }
+
+    .hint {
+      padding-left: 25px;
+    }
   }
 
   input[type=text], input[type=email], input[type=password], textarea {
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index de53a96026..05ff806c58 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -84,10 +84,12 @@ class Api::V1::AccountsController < ApiController
 
   def relationships
     ids = params[:id].is_a?(Enumerable) ? params[:id].map(&:to_i) : [params[:id].to_i]
+
     @accounts    = Account.where(id: ids).select('id')
     @following   = Account.following_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)
+    @requested   = Account.requested_map(ids, current_user.account_id)
   end
 
   def search
@@ -109,5 +111,6 @@ class Api::V1::AccountsController < ApiController
     @following   = Account.following_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)
+    @requested   = Account.requested_map([@account.id], current_user.account_id)
   end
 end
diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb
index 438d51a84c..3f60bb0c4c 100644
--- a/app/controllers/stream_entries_controller.rb
+++ b/app/controllers/stream_entries_controller.rb
@@ -43,8 +43,10 @@ class StreamEntriesController < ApplicationController
   end
 
   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
+
+    raise ActiveRecord::RecordNotFound if @stream_entry.hidden? && (@stream_entry.activity_type != 'Status' || (@stream_entry.activity_type == 'Status' && !@stream_entry.activity.permitted?(current_account)))
   end
 
   def check_account_suspension
diff --git a/app/controllers/xrd_controller.rb b/app/controllers/xrd_controller.rb
index 9e02778607..c3c38063c1 100644
--- a/app/controllers/xrd_controller.rb
+++ b/app/controllers/xrd_controller.rb
@@ -13,7 +13,7 @@ class XrdController < ApplicationController
   end
 
   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}"
     @magic_key = pem_to_magic_key(@account.keypair.public_key)
 
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index e08f9a0dad..c07e4b05f9 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -39,6 +39,16 @@ class FeedManager
     redis.zremrangebyscore(key(type, account_id), '-inf', "(#{last.last}")
   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)
     rabl_scope = Class.new do
       include RoutingHelper
diff --git a/app/models/account.rb b/app/models/account.rb
index aa904588ba..273c098331 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -34,6 +34,8 @@ class Account < ApplicationRecord
   has_many :notifications, inverse_of: :account, dependent: :destroy
 
   # Follow relations
+  has_many :follow_requests, 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
 
@@ -179,6 +181,10 @@ class Account < ApplicationRecord
     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
     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
 
   before_create do
diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb
new file mode 100644
index 0000000000..132316fb40
--- /dev/null
+++ b/app/models/follow_request.rb
@@ -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
diff --git a/app/models/status.rb b/app/models/status.rb
index 1e6298a0e2..033ae05291 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -170,7 +170,7 @@ class Status < ApplicationRecord
     text.strip!
     self.reblog = reblog.reblog if reblog? && reblog.reblog?
     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
 
   private
diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb
index 02baa65536..a73ec344d0 100644
--- a/app/services/follow_service.rb
+++ b/app/services/follow_service.rb
@@ -10,6 +10,20 @@ class FollowService < BaseService
     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)
 
+    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)
 
     if target_account.local?
@@ -19,25 +33,12 @@ class FollowService < BaseService
       NotificationWorker.perform_async(follow.stream_entry.id, target_account.id)
     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)
 
     follow
   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
     Redis.current
   end
diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb
index 1a78b8f697..23b35ffd22 100644
--- a/app/services/reblog_service.rb
+++ b/app/services/reblog_service.rb
@@ -6,7 +6,7 @@ class ReblogService < BaseService
   # @param [Status] reblogged_status Status to be reblogged
   # @return [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: '')
 
diff --git a/app/views/api/v1/accounts/relationship.rabl b/app/views/api/v1/accounts/relationship.rabl
index 84043e9cd3..22b37586e5 100644
--- a/app/views/api/v1/accounts/relationship.rabl
+++ b/app/views/api/v1/accounts/relationship.rabl
@@ -4,3 +4,4 @@ attribute :id
 node(:following)   { |account| @following[account.id]   || false }
 node(:followed_by) { |account| @followed_by[account.id] || false }
 node(:blocking)    { |account| @blocking[account.id]    || false }
+node(:requested)   { |account| @requested[account.id]   || false }
diff --git a/app/views/api/v1/accounts/show.rabl b/app/views/api/v1/accounts/show.rabl
index 22cb87f6ca..151a5080d3 100644
--- a/app/views/api/v1/accounts/show.rabl
+++ b/app/views/api/v1/accounts/show.rabl
@@ -1,11 +1,11 @@
 object @account
 
-attributes :id, :username, :acct, :display_name
+attributes :id, :username, :acct, :display_name, :locked
 
 node(:note)            { |account| Formatter.instance.simplified_format(account) }
 node(:url)             { |account| TagManager.instance.url_for(account) }
-node(:avatar)          { |account| full_asset_url(account.avatar.url( :original)) }
-node(:header)          { |account| full_asset_url(account.header.url( :original)) }
+node(:avatar)          { |account| full_asset_url(account.avatar.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(: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) }
diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml
index a8ea9bbc4b..6bb458aa28 100644
--- a/app/views/settings/profiles/show.html.haml
+++ b/app/views/settings/profiles/show.html.haml
@@ -4,11 +4,13 @@
 = simple_form_for @account, url: settings_profile_path, html: { method: :put } do |f|
   = render 'shared/error_messages', object: @account
 
-  = f.input :display_name, placeholder: t('simple_form.labels.defaults.display_name')
-  = f.input :note, placeholder: t('simple_form.labels.defaults.note')
-  = f.input :avatar, wrapper: :with_label
-  = f.input :header, wrapper: :with_label
-  = f.input :locked, as: :boolean, wrapper: :with_label
+  .fields-group
+    = f.input :display_name, placeholder: t('simple_form.labels.defaults.display_name')
+    = f.input :note, placeholder: t('simple_form.labels.defaults.note')
+    = f.input :avatar, 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
     = f.button :button, t('generic.save_changes'), type: :submit
diff --git a/config/initializers/simple_form.rb b/config/initializers/simple_form.rb
index e0c22e0c27..065999d0b8 100644
--- a/config/initializers/simple_form.rb
+++ b/config/initializers/simple_form.rb
@@ -5,8 +5,7 @@ SimpleForm.setup do |config|
   # wrapper, change the order or even add your own to the
   # stack. The options given below are used to wrap the
   # whole input.
-  config.wrappers :default, class: :input,
-    hint_class: :field_with_hint, error_class: :field_with_errors do |b|
+  config.wrappers :default, class: :input, hint_class: :field_with_hint, error_class: :field_with_errors do |b|
     ## Extensions enabled by default
     # Any of these extensions can be disabled for a
     # 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 }
   end
 
-  config.wrappers :with_label, class: :input,
-    hint_class: :field_with_hint, error_class: :field_with_errors do |b|
+  config.wrappers :with_label, class: :input, hint_class: :field_with_hint, error_class: :field_with_errors do |b|
     b.use :html5
+    b.use :label_input
     b.use :hint,  wrap_with: { tag: :span, class: :hint }
     b.use :error, wrap_with: { tag: :span, class: :error }
-    b.use :label_input
   end
 
   # The default wrapper to be used by the FormBuilder.
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index 516d79b089..deecff3fd8 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -15,6 +15,7 @@ en:
         note: Bio
         password: Password
         username: Username
+        locked: Make account private
       interactions:
         must_be_follower: Block notifications from non-followers
         must_be_following: Block notifications from people you don't follow
@@ -23,6 +24,9 @@ en:
         follow: Send e-mail when someone follows you
         mention: Send e-mail when someone mentions you
         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'
     required:
       mark: "*"
diff --git a/db/migrate/20161222204147_create_follow_requests.rb b/db/migrate/20161222204147_create_follow_requests.rb
new file mode 100644
index 0000000000..fbe5edf3de
--- /dev/null
+++ b/db/migrate/20161222204147_create_follow_requests.rb
@@ -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
diff --git a/db/schema.rb b/db/schema.rb
index 47e1b098d4..180d3b14df 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: 20161222201034) do
+ActiveRecord::Schema.define(version: 20161222204147) do
 
   # These are extensions that must be enabled in order to support this database
   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
   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|
     t.integer  "account_id",        null: false
     t.integer  "target_account_id", null: false
diff --git a/spec/fabricators/follow_request_fabricator.rb b/spec/fabricators/follow_request_fabricator.rb
new file mode 100644
index 0000000000..9c3733cef8
--- /dev/null
+++ b/spec/fabricators/follow_request_fabricator.rb
@@ -0,0 +1,3 @@
+Fabricator(:follow_request) do
+
+end
diff --git a/spec/models/follow_request_spec.rb b/spec/models/follow_request_spec.rb
new file mode 100644
index 0000000000..f2ec642d80
--- /dev/null
+++ b/spec/models/follow_request_spec.rb
@@ -0,0 +1,6 @@
+require 'rails_helper'
+
+RSpec.describe FollowRequest, type: :model do
+  describe '#authorize!'
+  describe '#reject!'
+end
-- 
GitLab