about summary refs log tree commit diff
diff options
context:
space:
mode:
-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