diff options
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 |