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
};
},
handleClick (e) {
e.preventDefault();
- this.props.onClick();
- e.stopPropagation();
+
+ if (!this.props.disabled) {
+ this.props.onClick();
+ }
},
render () {
width: `${this.props.size * 1.28571429}px`,
height: `${this.props.size}px`,
lineHeight: `${this.props.size}px`,
- cursor: 'pointer',
...this.props.style
};
}
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>
);
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' }}>
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({
}
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')) };
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>
color: #616b86;
border: none;
background: transparent;
+ cursor: pointer;
&:hover {
color: #717b98;
}
&.disabled {
- color: #535b72;
+ color: #454b5e;
cursor: default;
}
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;
top: 1px;
margin: 0;
}
+
+ .hint {
+ padding-left: 25px;
+ }
}
input[type=text], input[type=email], input[type=password], textarea {
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
@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
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
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)
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
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
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
--- /dev/null
+# 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
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
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?
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
# @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: '')
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 }
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) }
= 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
# 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`.
# 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.
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
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: "*"
--- /dev/null
+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
#
# 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"
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
--- /dev/null
+Fabricator(:follow_request) do
+
+end
--- /dev/null
+require 'rails_helper'
+
+RSpec.describe FollowRequest, type: :model do
+ describe '#authorize!'
+ describe '#reject!'
+end