about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2016-12-22 23:03:57 +0100
committerEugen Rochko <eugen@zeonfederated.com>2016-12-22 23:03:57 +0100
commitb891a81008d2cf595cb37432a8e1f36606db16d6 (patch)
treee3b083966fc14dda46a2ec75586fdf566c2585aa
parent2d2154ba75279186b064c887452b7d6ee70b8ba2 (diff)
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
-rw-r--r--app/assets/javascripts/components/components/icon_button.jsx17
-rw-r--r--app/assets/javascripts/components/components/status_action_bar.jsx2
-rw-r--r--app/assets/javascripts/components/features/account/components/header.jsx19
-rw-r--r--app/assets/javascripts/components/features/status/components/action_bar.jsx2
-rw-r--r--app/assets/stylesheets/components.scss3
-rw-r--r--app/assets/stylesheets/forms.scss10
-rw-r--r--app/controllers/api/v1/accounts_controller.rb3
-rw-r--r--app/controllers/stream_entries_controller.rb4
-rw-r--r--app/controllers/xrd_controller.rb2
-rw-r--r--app/lib/feed_manager.rb10
-rw-r--r--app/models/account.rb6
-rw-r--r--app/models/follow_request.rb19
-rw-r--r--app/models/status.rb2
-rw-r--r--app/services/follow_service.rb29
-rw-r--r--app/services/reblog_service.rb2
-rw-r--r--app/views/api/v1/accounts/relationship.rabl1
-rw-r--r--app/views/api/v1/accounts/show.rabl6
-rw-r--r--app/views/settings/profiles/show.html.haml12
-rw-r--r--config/initializers/simple_form.rb8
-rw-r--r--config/locales/simple_form.en.yml4
-rw-r--r--db/migrate/20161222204147_create_follow_requests.rb12
-rw-r--r--db/schema.rb10
-rw-r--r--spec/fabricators/follow_request_fabricator.rb3
-rw-r--r--spec/models/follow_request_spec.rb6
24 files changed, 145 insertions, 47 deletions
diff --git a/app/assets/javascripts/components/components/icon_button.jsx b/app/assets/javascripts/components/components/icon_button.jsx
index d8f00f5d8..e9a7228e4 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 9c6d13bdf..80b51456e 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 b890e15c1..fe400e50b 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 1f46b956e..00b1dd415 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 210e722cc..1689193a8 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 81270edf6..cf9b4fba6 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 de53a9602..05ff806c5 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 438d51a84..3f60bb0c4 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 9e0277860..c3c38063c 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 e08f9a0da..c07e4b05f 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 aa904588b..273c09833 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 000000000..132316fb4
--- /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 1e6298a0e..033ae0529 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 02baa6553..a73ec344d 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 1a78b8f69..23b35ffd2 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 84043e9cd..22b37586e 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 22cb87f6c..151a5080d 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 a8ea9bbc4..6bb458aa2 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 e0c22e0c2..065999d0b 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 516d79b08..deecff3fd 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 000000000..fbe5edf3d
--- /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 47e1b098d..180d3b14d 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 000000000..9c3733cef
--- /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 000000000..f2ec642d8
--- /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