Followers-only post federation (#2111)
* Make private toots get PuSHed to subscription URLs that belong to domains where you have approved followers * Authorized followers controller, stub for bulk action * Soft block in the background * Add simple test for new controller * Rename Settings::FollowersController to Settings::FollowerDomainsController, paginate results, rename "private" post setting to "followers-only", fix pagination style, improve post privacy preferences style, improve warning style * Extract compose form warnings into own container, show warning when posting to followers-only with unlocked account
This commit is contained in:
		| @@ -15,6 +15,7 @@ import SensitiveButtonContainer from '../containers/sensitive_button_container'; | ||||
| import EmojiPickerDropdown from './emoji_picker_dropdown'; | ||||
| import UploadFormContainer from '../containers/upload_form_container'; | ||||
| import TextIconButton from './text_icon_button'; | ||||
| import WarningContainer from '../containers/warning_container'; | ||||
|  | ||||
| const messages = defineMessages({ | ||||
|   placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, | ||||
| @@ -116,26 +117,13 @@ class ComposeForm extends React.PureComponent { | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { intl, needsPrivacyWarning, mentionedDomains, onPaste } = this.props; | ||||
|     const { intl, onPaste } = this.props; | ||||
|     const disabled = this.props.is_submitting; | ||||
|     const text = [this.props.spoiler_text, this.props.text].join(''); | ||||
|  | ||||
|     let publishText    = ''; | ||||
|     let privacyWarning = ''; | ||||
|     let reply_to_other = false; | ||||
|  | ||||
|     if (needsPrivacyWarning) { | ||||
|       privacyWarning = ( | ||||
|         <div className='compose-form__warning'> | ||||
|           <FormattedMessage | ||||
|             id='compose_form.privacy_disclaimer' | ||||
|             defaultMessage='Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}} to not leak your status?' | ||||
|             values={{ domains: <strong>{mentionedDomains.join(', ')}</strong>, domainsCount: mentionedDomains.length }} | ||||
|           /> | ||||
|         </div> | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     if (this.props.privacy === 'private' || this.props.privacy === 'direct') { | ||||
|       publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>; | ||||
|     } else { | ||||
| @@ -150,7 +138,7 @@ class ComposeForm extends React.PureComponent { | ||||
|           </div> | ||||
|         </Collapsable> | ||||
|  | ||||
|         {privacyWarning} | ||||
|         <WarningContainer /> | ||||
|  | ||||
|         <ReplyIndicatorContainer /> | ||||
|  | ||||
| @@ -208,8 +196,6 @@ ComposeForm.propTypes = { | ||||
|   is_submitting: PropTypes.bool, | ||||
|   is_uploading: PropTypes.bool, | ||||
|   me: PropTypes.number, | ||||
|   needsPrivacyWarning: PropTypes.bool, | ||||
|   mentionedDomains: PropTypes.array.isRequired, | ||||
|   onChange: PropTypes.func.isRequired, | ||||
|   onSubmit: PropTypes.func.isRequired, | ||||
|   onClearSuggestions: PropTypes.func.isRequired, | ||||
|   | ||||
| @@ -7,7 +7,7 @@ const messages = defineMessages({ | ||||
|   public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' }, | ||||
|   unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, | ||||
|   unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' }, | ||||
|   private_short: { id: 'privacy.private.short', defaultMessage: 'Private' }, | ||||
|   private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, | ||||
|   private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' }, | ||||
|   direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, | ||||
|   direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' }, | ||||
|   | ||||
| @@ -0,0 +1,25 @@ | ||||
| import PropTypes from 'prop-types'; | ||||
|  | ||||
| class Warning extends React.PureComponent { | ||||
|  | ||||
|   constructor (props) { | ||||
|     super(props); | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { message } = this.props; | ||||
|  | ||||
|     return ( | ||||
|       <div className='compose-form__warning'> | ||||
|         {message} | ||||
|       </div> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
|  | ||||
| Warning.propTypes = { | ||||
|   message: PropTypes.node.isRequired | ||||
| }; | ||||
|  | ||||
| export default Warning; | ||||
| @@ -1,7 +1,6 @@ | ||||
| import { connect } from 'react-redux'; | ||||
| import ComposeForm from '../components/compose_form'; | ||||
| import { uploadCompose } from '../../../actions/compose'; | ||||
| import { createSelector } from 'reselect'; | ||||
| import { | ||||
|   changeCompose, | ||||
|   submitCompose, | ||||
| @@ -12,33 +11,20 @@ import { | ||||
|   insertEmojiCompose | ||||
| } from '../../../actions/compose'; | ||||
|  | ||||
| const getMentionedUsernames = createSelector(state => state.getIn(['compose', 'text']), text => text.match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig)); | ||||
|  | ||||
| const getMentionedDomains = createSelector(getMentionedUsernames, mentionedUsernamesWithDomains => { | ||||
|   return mentionedUsernamesWithDomains !== null ? [...new Set(mentionedUsernamesWithDomains.map(item => item.split('@')[2]))] : []; | ||||
| const mapStateToProps = state => ({ | ||||
|   text: state.getIn(['compose', 'text']), | ||||
|   suggestion_token: state.getIn(['compose', 'suggestion_token']), | ||||
|   suggestions: state.getIn(['compose', 'suggestions']), | ||||
|   spoiler: state.getIn(['compose', 'spoiler']), | ||||
|   spoiler_text: state.getIn(['compose', 'spoiler_text']), | ||||
|   privacy: state.getIn(['compose', 'privacy']), | ||||
|   focusDate: state.getIn(['compose', 'focusDate']), | ||||
|   preselectDate: state.getIn(['compose', 'preselectDate']), | ||||
|   is_submitting: state.getIn(['compose', 'is_submitting']), | ||||
|   is_uploading: state.getIn(['compose', 'is_uploading']), | ||||
|   me: state.getIn(['compose', 'me']) | ||||
| }); | ||||
|  | ||||
| const mapStateToProps = (state, props) => { | ||||
|   const mentionedUsernames = getMentionedUsernames(state); | ||||
|   const mentionedUsernamesWithDomains = getMentionedDomains(state); | ||||
|  | ||||
|   return { | ||||
|     text: state.getIn(['compose', 'text']), | ||||
|     suggestion_token: state.getIn(['compose', 'suggestion_token']), | ||||
|     suggestions: state.getIn(['compose', 'suggestions']), | ||||
|     spoiler: state.getIn(['compose', 'spoiler']), | ||||
|     spoiler_text: state.getIn(['compose', 'spoiler_text']), | ||||
|     privacy: state.getIn(['compose', 'privacy']), | ||||
|     focusDate: state.getIn(['compose', 'focusDate']), | ||||
|     preselectDate: state.getIn(['compose', 'preselectDate']), | ||||
|     is_submitting: state.getIn(['compose', 'is_submitting']), | ||||
|     is_uploading: state.getIn(['compose', 'is_uploading']), | ||||
|     me: state.getIn(['compose', 'me']), | ||||
|     needsPrivacyWarning: (state.getIn(['compose', 'privacy']) === 'private' || state.getIn(['compose', 'privacy']) === 'direct') && mentionedUsernames !== null, | ||||
|     mentionedDomains: mentionedUsernamesWithDomains | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| const mapDispatchToProps = (dispatch) => ({ | ||||
|  | ||||
|   onChange (text) { | ||||
|   | ||||
| @@ -0,0 +1,48 @@ | ||||
| import { connect } from 'react-redux'; | ||||
| import Warning from '../components/warning'; | ||||
| import { createSelector } from 'reselect'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
|  | ||||
| const getMentionedUsernames = createSelector(state => state.getIn(['compose', 'text']), text => text.match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig)); | ||||
|  | ||||
| const getMentionedDomains = createSelector(getMentionedUsernames, mentionedUsernamesWithDomains => { | ||||
|   return mentionedUsernamesWithDomains !== null ? [...new Set(mentionedUsernamesWithDomains.map(item => item.split('@')[2]))] : []; | ||||
| }); | ||||
|  | ||||
| const mapStateToProps = state => { | ||||
|   const mentionedUsernames = getMentionedUsernames(state); | ||||
|   const mentionedUsernamesWithDomains = getMentionedDomains(state); | ||||
|  | ||||
|   return { | ||||
|     needsLeakWarning: (state.getIn(['compose', 'privacy']) === 'private' || state.getIn(['compose', 'privacy']) === 'direct') && mentionedUsernames !== null, | ||||
|     mentionedDomains: mentionedUsernamesWithDomains, | ||||
|     needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', state.getIn(['meta', 'me']), 'locked']) | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| const WarningWrapper = ({ needsLeakWarning, needsLockWarning, mentionedDomains }) => { | ||||
|   if (needsLockWarning) { | ||||
|     return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />; | ||||
|   } else if (needsLeakWarning) { | ||||
|     return ( | ||||
|       <Warning | ||||
|         message={<FormattedMessage | ||||
|           id='compose_form.privacy_disclaimer' | ||||
|           defaultMessage='Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}} to not leak your status?' | ||||
|           values={{ domains: <strong>{mentionedDomains.join(', ')}</strong>, domainsCount: mentionedDomains.length }} | ||||
|         />} | ||||
|       /> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   return null; | ||||
| }; | ||||
|  | ||||
| WarningWrapper.propTypes = { | ||||
|   needsLeakWarning: PropTypes.bool, | ||||
|   needsLockWarning: PropTypes.bool, | ||||
|   mentionedDomains: PropTypes.array.isRequired, | ||||
| }; | ||||
|  | ||||
| export default connect(mapStateToProps)(WarningWrapper); | ||||
| @@ -99,7 +99,7 @@ const en = { | ||||
|   "privacy.direct.long": "Post to mentioned users only", | ||||
|   "privacy.direct.short": "Direct", | ||||
|   "privacy.private.long": "Post to followers only", | ||||
|   "privacy.private.short": "Private", | ||||
|   "privacy.private.short": "Followers-only", | ||||
|   "privacy.public.long": "Post to public timelines", | ||||
|   "privacy.public.short": "Public", | ||||
|   "privacy.unlisted.long": "Do not show in public timelines", | ||||
|   | ||||
| @@ -173,7 +173,7 @@ | ||||
|   text-align: center; | ||||
|   overflow: hidden; | ||||
|  | ||||
|   a, .current, .page, .gap { | ||||
|   a, .current, .next, .prev, .page, .gap { | ||||
|     font-size: 14px; | ||||
|     color: $color5; | ||||
|     font-weight: 500; | ||||
| @@ -187,6 +187,7 @@ | ||||
|     border-radius: 100px; | ||||
|     color: $color1; | ||||
|     cursor: default; | ||||
|     margin: 0 10px; | ||||
|   } | ||||
|  | ||||
|   .gap { | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| @import 'variables'; | ||||
|  | ||||
| .app-body{ | ||||
| .app-body { | ||||
|  -webkit-overflow-scrolling: touch; | ||||
|  -ms-overflow-style: -ms-autohiding-scrollbar; | ||||
| } | ||||
| @@ -203,18 +203,29 @@ | ||||
| } | ||||
|  | ||||
| .compose-form__warning { | ||||
|   color: $color2; | ||||
|   color: darken($color3, 33%); | ||||
|   margin-bottom: 15px; | ||||
|   border: 1px solid $color3; | ||||
|   background: $color3; | ||||
|   box-shadow: 0 2px 6px rgba($color8, 0.3); | ||||
|   padding: 8px 10px; | ||||
|   border-radius: 4px; | ||||
|   font-size: 12px; | ||||
|   font-size: 13px; | ||||
|   font-weight: 400; | ||||
|  | ||||
|   strong { | ||||
|     color: $color5; | ||||
|     color: darken($color3, 33%); | ||||
|     font-weight: 500; | ||||
|   } | ||||
|  | ||||
|   a { | ||||
|     color: darken($color3, 33%); | ||||
|     font-weight: 500; | ||||
|     text-decoration: underline; | ||||
|  | ||||
|     &:hover, &:active, &:focus { | ||||
|       text-decoration: none; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .compose-form__modifiers { | ||||
| @@ -1619,7 +1630,7 @@ a.status__content__spoiler-link { | ||||
| } | ||||
|  | ||||
| .character-counter { | ||||
|   cursor: default;  | ||||
|   cursor: default; | ||||
|   font-size: 16px; | ||||
| } | ||||
|  | ||||
| @@ -1667,7 +1678,7 @@ a.status__content__spoiler-link { | ||||
|     font-size: 16px; | ||||
|   } | ||||
| } | ||||
|      | ||||
|  | ||||
| @import 'boost'; | ||||
|  | ||||
| button.icon-button i.fa-retweet { | ||||
| @@ -1766,6 +1777,7 @@ button.icon-button.active i.fa-retweet { | ||||
|   cursor: pointer; | ||||
|   position: relative; | ||||
|   z-index: 2; | ||||
|   outline: 0; | ||||
|  | ||||
|   &.active { | ||||
|     box-shadow: 0 1px 0 rgba($color4, 0.3); | ||||
| @@ -1781,6 +1793,10 @@ button.icon-button.active i.fa-retweet { | ||||
|       display: none; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   &:focus, &:active { | ||||
|     outline: 0; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .column-header__icon { | ||||
|   | ||||
| @@ -269,3 +269,60 @@ code { | ||||
|     font-size: 14px; | ||||
|   } | ||||
| } | ||||
|  | ||||
| .table-form { | ||||
|   p { | ||||
|     max-width: 400px; | ||||
|     margin-bottom: 15px; | ||||
|  | ||||
|     strong { | ||||
|       font-weight: 500; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   .warning { | ||||
|     max-width: 400px; | ||||
|     box-sizing: border-box; | ||||
|     background: rgba($color6, 0.5); | ||||
|     color: $color5; | ||||
|     text-shadow: 1px 1px 0 rgba($color8, 0.3); | ||||
|     box-shadow: 0 2px 6px rgba($color8, 0.4); | ||||
|     border-radius: 4px; | ||||
|     padding: 10px; | ||||
|     margin-bottom: 15px; | ||||
|  | ||||
|     a { | ||||
|       color: $color5; | ||||
|       text-decoration: underline; | ||||
|  | ||||
|       &:hover, &:focus, &:active { | ||||
|         text-decoration: none; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     strong { | ||||
|       font-weight: 600; | ||||
|       display: block; | ||||
|       margin-bottom: 5px; | ||||
|  | ||||
|       .fa { | ||||
|         font-weight: 400; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| .action-pagination { | ||||
|   display: flex; | ||||
|   align-items: center; | ||||
|  | ||||
|   .actions, .pagination { | ||||
|     flex: 1 1 auto; | ||||
|   } | ||||
|  | ||||
|   .actions { | ||||
|     padding: 30px 0; | ||||
|     padding-right: 20px; | ||||
|     flex: 0 0 auto; | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										28
									
								
								app/controllers/settings/follower_domains_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								app/controllers/settings/follower_domains_controller.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class Settings::FollowerDomainsController < ApplicationController | ||||
|   layout 'admin' | ||||
|  | ||||
|   before_action :authenticate_user! | ||||
|  | ||||
|   def show | ||||
|     @account = current_account | ||||
|     @domains = current_account.followers.reorder(nil).group('accounts.domain').select('accounts.domain, count(accounts.id) as accounts_from_domain').page(params[:page]).per(10) | ||||
|   end | ||||
|  | ||||
|   def update | ||||
|     domains = bulk_params[:select] || [] | ||||
|  | ||||
|     domains.each do |domain| | ||||
|       SoftBlockDomainFollowersWorker.perform_async(current_account.id, domain) | ||||
|     end | ||||
|  | ||||
|     redirect_to settings_follower_domains_path, notice: I18n.t('followers.success', count: domains.size) | ||||
|   end | ||||
|  | ||||
|   private | ||||
|  | ||||
|   def bulk_params | ||||
|     params.permit(select: []) | ||||
|   end | ||||
| end | ||||
| @@ -135,6 +135,10 @@ class Account < ApplicationRecord | ||||
|     !subscription_expires_at.blank? | ||||
|   end | ||||
|  | ||||
|   def followers_domains | ||||
|     followers.reorder(nil).pluck('distinct accounts.domain') | ||||
|   end | ||||
|  | ||||
|   def favourited?(status) | ||||
|     status.proper.favourites.where(account: self).count.positive? | ||||
|   end | ||||
|   | ||||
							
								
								
									
										33
									
								
								app/views/settings/follower_domains/show.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								app/views/settings/follower_domains/show.html.haml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| - content_for :page_title do | ||||
|   = t('settings.followers') | ||||
|  | ||||
| = form_tag settings_follower_domains_path, method: :patch, class: 'table-form' do | ||||
|   - unless @account.locked? | ||||
|     .warning | ||||
|       %strong | ||||
|         = fa_icon('warning') | ||||
|         = t('followers.unlocked_warning_title') | ||||
|       = t('followers.unlocked_warning_html', lock_link: link_to(t('followers.lock_link'), settings_profile_url)) | ||||
|  | ||||
|   %p= t('followers.explanation_html') | ||||
|   %p= t('followers.true_privacy_html') | ||||
|  | ||||
|   %table.table | ||||
|     %thead | ||||
|       %tr | ||||
|         %th | ||||
|         %th= t('followers.domain') | ||||
|         %th= t('followers.followers_count') | ||||
|     %tbody | ||||
|       - @domains.each do |domain| | ||||
|         %tr | ||||
|           %td | ||||
|             = check_box_tag 'select[]', domain.domain, false, disabled: !@account.locked? unless domain.domain.nil? | ||||
|           %td | ||||
|             %samp= domain.domain.presence || Rails.configuration.x.local_domain | ||||
|           %td= number_with_delimiter domain.accounts_from_domain | ||||
|  | ||||
|   .action-pagination | ||||
|     .actions | ||||
|       = button_tag t('followers.purge'), type: :submit, class: 'button', disabled: !@account.locked? | ||||
|     = paginate @domains | ||||
| @@ -7,7 +7,7 @@ | ||||
|   .fields-group | ||||
|     = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) } | ||||
|  | ||||
|     = f.input :setting_default_privacy, collection: Status.visibilities.keys - ['direct'], wrapper: :with_label, include_blank: false, label_method: lambda { |visibility| I18n.t("statuses.visibilities.#{visibility}") }, required: false, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' | ||||
|     = f.input :setting_default_privacy, collection: Status.visibilities.keys - ['direct'], wrapper: :with_label, include_blank: false, label_method: lambda { |visibility| safe_join([I18n.t("statuses.visibilities.#{visibility}"), content_tag(:span, I18n.t("statuses.visibilities.#{visibility}_long"), class: 'hint')]) }, required: false, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' | ||||
|  | ||||
|   .fields-group | ||||
|     = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff| | ||||
|   | ||||
| @@ -4,6 +4,7 @@ require 'csv' | ||||
|  | ||||
| class ImportWorker | ||||
|   include Sidekiq::Worker | ||||
|  | ||||
|   sidekiq_options queue: 'pull', retry: false | ||||
|  | ||||
|   attr_reader :import | ||||
|   | ||||
| @@ -8,12 +8,14 @@ class Pubsubhubbub::DistributionWorker | ||||
|   def perform(stream_entry_id) | ||||
|     stream_entry = StreamEntry.find(stream_entry_id) | ||||
|  | ||||
|     return if stream_entry.hidden? | ||||
|     return if stream_entry.status&.direct_visibility? | ||||
|  | ||||
|     account = stream_entry.account | ||||
|     payload = AtomSerializer.render(AtomSerializer.new.feed(account, [stream_entry])) | ||||
|     domains = account.followers_domains | ||||
|  | ||||
|     Subscription.where(account: account).active.select('id, callback_url').find_each do |subscription| | ||||
|       next unless domains.include?(Addressable::URI.parse(subscription.callback_url).host) | ||||
|       Pubsubhubbub::DeliveryWorker.perform_async(subscription.id, payload) | ||||
|     end | ||||
|   rescue ActiveRecord::RecordNotFound | ||||
|   | ||||
							
								
								
									
										13
									
								
								app/workers/soft_block_domain_followers_worker.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								app/workers/soft_block_domain_followers_worker.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class SoftBlockDomainFollowersWorker | ||||
|   include Sidekiq::Worker | ||||
|  | ||||
|   sidekiq_options queue: 'pull' | ||||
|  | ||||
|   def perform(account_id, domain) | ||||
|     Account.find(account_id).followers.where(domain: domain).pluck(:id).each do |follower_id| | ||||
|       SoftBlockWorker.perform_async(account_id, follower_id) | ||||
|     end | ||||
|   end | ||||
| end | ||||
							
								
								
									
										17
									
								
								app/workers/soft_block_worker.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								app/workers/soft_block_worker.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| # frozen_string_literal: true | ||||
|  | ||||
| class SoftBlockWorker | ||||
|   include Sidekiq::Worker | ||||
|  | ||||
|   sidekiq_options queue: 'pull' | ||||
|  | ||||
|   def perform(account_id, target_account_id) | ||||
|     account        = Account.find(account_id) | ||||
|     target_account = Account.find(target_account_id) | ||||
|  | ||||
|     BlockService.new.call(account, target_account) | ||||
|     UnblockService.new.call(account, target_account) | ||||
|   rescue ActiveRecord::RecordNotFound | ||||
|     true | ||||
|   end | ||||
| end | ||||
| @@ -41,14 +41,14 @@ en: | ||||
|     remote_follow: Remote follow | ||||
|     unfollow: Unfollow | ||||
|   activitypub: | ||||
|     outbox: | ||||
|       name: "%{account_name}'s Outbox" | ||||
|       summary: "A collection of activities from user %{account_name}." | ||||
|     activity: | ||||
|       create: | ||||
|         name: "%{account_name} created a note." | ||||
|       announce: | ||||
|         name: "%{account_name} announced an activity." | ||||
|       create: | ||||
|         name: "%{account_name} created a note." | ||||
|     outbox: | ||||
|       name: "%{account_name}'s Outbox" | ||||
|       summary: A collection of activities from user %{account_name}. | ||||
|   admin: | ||||
|     accounts: | ||||
|       are_you_sure: Are you sure? | ||||
| @@ -227,6 +227,18 @@ en: | ||||
|     follows: You follow | ||||
|     mutes: You mute | ||||
|     storage: Media storage | ||||
|   followers: | ||||
|     domain: Domain | ||||
|     explanation_html: If you want to ensure the privacy of your statuses, you must be aware of who is following you. <strong>Your private statuses are delivered to all instances where you have followers</strong>. You may wish to review them, and remove followers if you do not trust your privacy to be respected by the staff or software of those instances. | ||||
|     followers_count: Number of followers | ||||
|     lock_link: Lock your account | ||||
|     purge: Remove from followers | ||||
|     success: | ||||
|       one: In the process of soft-blocking followers from one domain... | ||||
|       other: In the process of soft-blocking followers from %{count} domains... | ||||
|     true_privacy_html: Please mind that <strong>true privacy can only be achieved with end-to-end encryption</strong>. | ||||
|     unlocked_warning_html: Anyone can follow you to immediately view your private statuses. %{lock_link} to be able to review and reject followers. | ||||
|     unlocked_warning_title: Your account is not locked | ||||
|   generic: | ||||
|     changes_saved_msg: Changes successfully saved! | ||||
|     powered_by: powered by %{link} | ||||
| @@ -286,6 +298,7 @@ en: | ||||
|     back: Back to Mastodon | ||||
|     edit_profile: Edit profile | ||||
|     export: Data export | ||||
|     followers: Authorized followers | ||||
|     import: Import | ||||
|     preferences: Preferences | ||||
|     settings: Settings | ||||
| @@ -295,9 +308,12 @@ en: | ||||
|     over_character_limit: character limit of %{max} exceeded | ||||
|     show_more: Show more | ||||
|     visibilities: | ||||
|       private: Only show to followers | ||||
|       private: Followers-only | ||||
|       private_long: Only show to followers | ||||
|       public: Public | ||||
|       unlisted: Public, but do not display on the public timeline | ||||
|       public_long: Everyone can see | ||||
|       unlisted: Unlisted | ||||
|       unlisted_long: Everyone can see, but not listed on public timelines | ||||
|   stream_entries: | ||||
|     click_to_show: Click to show | ||||
|     reblogged: boosted | ||||
|   | ||||
| @@ -39,6 +39,48 @@ nl: | ||||
|     posts: Berichten | ||||
|     remote_follow: Extern volgen | ||||
|     unfollow: Ontvolgen | ||||
|   admin: | ||||
|     settings: | ||||
|       click_to_edit: Klik om te bewerken | ||||
|       contact_information: | ||||
|         email: Vul een openbaar gebruikt e-mailadres in | ||||
|         label: Contactgegevens | ||||
|         username: Vul een gebruikersnaam in | ||||
|       registrations: | ||||
|         closed_message: | ||||
|           desc_html: Wordt op de voorpagina weergegeven wanneer registratie van nieuwe accounts is uitgeschakeld<br>En ook hier kan je HTML gebruiken | ||||
|           title: Bericht wanneer registratie is uitgeschakeld | ||||
|         open: | ||||
|           disabled: Uitgeschakeld | ||||
|           enabled: Ingeschakeld | ||||
|           title: Open registratie | ||||
|       setting: Instelling | ||||
|       site_description: | ||||
|         desc_html: Dit wordt als een alinea op de voorpagina getoond en gebruikt als meta-tag in de paginabron.<br>Je kan HTML gebruiken, zoals <code><a></code> en <code><em></code>. | ||||
|         title: Omschrijving Mastodon-server | ||||
|       site_description_extended: | ||||
|         desc_html: Wordt op de uitgebreide informatiepagina weergegeven<br>Je kan ook hier HTML gebruiken | ||||
|         title: Uitgebreide omschrijving Mastodon-server | ||||
|       site_title: Naam Mastodon-server | ||||
|       title: Server-instellingen | ||||
|   admin.reports: | ||||
|     comment: | ||||
|       label: Opmerking | ||||
|       none: Geen | ||||
|     delete: Verwijderen | ||||
|     id: ID | ||||
|     mark_as_resolved: Markeer als opgelost | ||||
|     report: 'Gerapporteerde toot #%{id}' | ||||
|     reported_account: Gerapporteerde account | ||||
|     reported_by: Gerapporteerd door | ||||
|     resolved: Opgelost | ||||
|     silence_account: Account stilzwijgen | ||||
|     status: Toot | ||||
|     suspend_account: Account blokkeren | ||||
|     target: Target | ||||
|     title: Gerapporteerde toots | ||||
|     unresolved: Onopgelost | ||||
|     view: Weergeven | ||||
|   application_mailer: | ||||
|     settings: 'E-mailvoorkeuren wijzigen: %{link}' | ||||
|     signature: Mastodon-meldingen van %{instance} | ||||
| @@ -74,6 +116,12 @@ nl: | ||||
|       x_minutes: "%{count}m" | ||||
|       x_months: "%{count}ma" | ||||
|       x_seconds: "%{count}s" | ||||
|   errors: | ||||
|     '404': De pagina waarnaar jij op zoek bent bestaat niet. | ||||
|     '410': De pagina waarnaar jij op zoek bent bestaat niet meer. | ||||
|     '422': | ||||
|       content: Veiligheidsverificatie mislukt. Blokkeer je toevallig cookies? | ||||
|       title: Veiligheidsverificatie mislukt | ||||
|   exports: | ||||
|     blocks: Jij blokkeert | ||||
|     csv: CSV | ||||
| @@ -161,52 +209,3 @@ nl: | ||||
|   users: | ||||
|     invalid_email: E-mailadres is ongeldig | ||||
|     invalid_otp_token: Ongeldige tweestaps-aanmeldcode | ||||
|   errors: | ||||
|       404: De pagina waarnaar jij op zoek bent bestaat niet. | ||||
|       410: De pagina waarnaar jij op zoek bent bestaat niet meer. | ||||
|       422: | ||||
|         title: Veiligheidsverificatie mislukt | ||||
|         content: Veiligheidsverificatie mislukt. Blokkeer je toevallig cookies? | ||||
|   admin.reports: | ||||
|     title: Gerapporteerde toots | ||||
|     status: Toot | ||||
|     unresolved: Onopgelost | ||||
|     resolved: Opgelost | ||||
|     id: ID | ||||
|     target: Target | ||||
|     reported_by: Gerapporteerd door | ||||
|     comment: | ||||
|       label: Opmerking | ||||
|       none: Geen | ||||
|     view: Weergeven | ||||
|     report: 'Gerapporteerde toot #%{id}' | ||||
|     delete: Verwijderen | ||||
|     reported_account: Gerapporteerde account | ||||
|     reported_by: Gerapporteerd door | ||||
|     silence_account: Account stilzwijgen | ||||
|     suspend_account: Account blokkeren | ||||
|     mark_as_resolved: Markeer als opgelost | ||||
|   admin: | ||||
|     settings: | ||||
|       title: Server-instellingen | ||||
|       setting: Instelling | ||||
|       click_to_edit: Klik om te bewerken | ||||
|       contact_information: | ||||
|         label: Contactgegevens | ||||
|         username: Vul een gebruikersnaam in | ||||
|         email: Vul een openbaar gebruikt e-mailadres in | ||||
|       site_title: Naam Mastodon-server | ||||
|       site_description: | ||||
|         title: Omschrijving Mastodon-server | ||||
|         desc_html: "Dit wordt als een alinea op de voorpagina getoond en gebruikt als meta-tag in de paginabron.<br>Je kan HTML gebruiken, zoals <code><a></code> en <code><em></code>." | ||||
|       site_description_extended: | ||||
|         title: Uitgebreide omschrijving Mastodon-server | ||||
|         desc_html: "Wordt op de uitgebreide informatiepagina weergegeven<br>Je kan ook hier HTML gebruiken" | ||||
|       registrations: | ||||
|         open: | ||||
|           title: Open registratie | ||||
|           enabled: Ingeschakeld | ||||
|           disabled: Uitgeschakeld | ||||
|         closed_message: | ||||
|           title: Bericht wanneer registratie is uitgeschakeld | ||||
|           desc_html: "Wordt op de voorpagina weergegeven wanneer registratie van nieuwe accounts is uitgeschakeld<br>En ook hier kan je HTML gebruiken" | ||||
|   | ||||
| @@ -22,8 +22,8 @@ pt-BR: | ||||
|     features_headline: O que torna Mastodon diferente | ||||
|     get_started: Comece aqui | ||||
|     links: Links | ||||
|     source_code: Source code | ||||
|     other_instances: Outras instâncias | ||||
|     source_code: Source code | ||||
|     terms: Termos | ||||
|     user_count_after: usuários | ||||
|     user_count_before: Lugar de | ||||
|   | ||||
| @@ -23,7 +23,7 @@ en: | ||||
|         email: E-mail address | ||||
|         header: Header | ||||
|         locale: Language | ||||
|         locked: Make account private | ||||
|         locked: Lock account | ||||
|         new_password: New password | ||||
|         note: Bio | ||||
|         otp_attempt: Two-factor code | ||||
|   | ||||
| @@ -30,8 +30,8 @@ zh-CN: | ||||
|     user_count_before: 这里共注册有 | ||||
|   accounts: | ||||
|     follow: 关注 | ||||
|     followers: 粉丝 # "Fans" | ||||
|     following: 关注 # "Follow" | ||||
|     followers: 粉丝 | ||||
|     following: 关注 | ||||
|     nothing_here: 神马都没有! | ||||
|     people_followed_by: 正关注 | ||||
|     people_who_follow: 粉丝 | ||||
| @@ -80,15 +80,14 @@ zh-CN: | ||||
|       web: 用户页面 | ||||
|     domain_blocks: | ||||
|       add_new: 添加 | ||||
|       domain: 域名阻隔 | ||||
|       created_msg: 正处理域名阻隔 | ||||
|       destroyed_msg: 已撤销域名阻隔 | ||||
|       domain: 域名阻隔 | ||||
|       new: | ||||
|         create: 添加域名阻隔 | ||||
|         hint: 「域名阻隔」不会隔绝该域名用户的嘟账户入本站数据库,但会嘟文抵达后,自动套用特定的审批操作。 | ||||
|         hint: "「域名阻隔」不会隔绝该域名用户的嘟账户入本站数据库,但会嘟文抵达后,自动套用特定的审批操作。" | ||||
|         severity: | ||||
|           desc_html: 「<strong>自动静音</strong>」令该域名用户的嘟文,设为只对关注者显示,没有关注的人会看不到。 | ||||
|             「<strong>自动除名</strong>」会自动将该域名用户的嘟文、媒体文件、个人资料自本服务站删除。 | ||||
|           desc_html: "「<strong>自动静音</strong>」令该域名用户的嘟文,设为只对关注者显示,没有关注的人会看不到。 「<strong>自动除名</strong>」会自动将该域名用户的嘟文、媒体文件、个人资料自本服务站删除。" | ||||
|           silence: 自动静音 | ||||
|           suspend: 自动除名 | ||||
|         title: 添加域名阻隔 | ||||
| @@ -99,10 +98,8 @@ zh-CN: | ||||
|         suspend: 自动除名 | ||||
|       severity: 阻隔程度 | ||||
|       show: | ||||
|         # It turns out that Chinese only uses an "other" | ||||
|         # Well, we don't have these -s magic anyway... | ||||
|         affected_accounts: | ||||
|           other: "数据库中有%{count}个账户受影响" | ||||
|           other: 数据库中有%{count}个账户受影响 | ||||
|         retroactive: | ||||
|           silence: 对此域名的所有账户取消静音 | ||||
|           suspend: 对此域名的所有账户取消除名 | ||||
| @@ -147,8 +144,7 @@ zh-CN: | ||||
|         username: 输入用户名称 | ||||
|       registrations: | ||||
|         closed_message: | ||||
|           desc_html: 当本站暂停接受注册时,会显示这个消息。<br/> | ||||
|             可使用 HTML | ||||
|           desc_html: 当本站暂停接受注册时,会显示这个消息。<br/> 可使用 HTML | ||||
|           title: 暂停注册消息 | ||||
|         open: | ||||
|           disabled: 停用 | ||||
| @@ -187,11 +183,10 @@ zh-CN: | ||||
|     title: 关注 %{acct} | ||||
|   datetime: | ||||
|     distance_in_words: | ||||
|       # Ditching "about" as in en | ||||
|       about_x_hours: "%{count} 小时" | ||||
|       about_x_months: "%{count} 个月" | ||||
|       about_x_years: "%{count} 年" | ||||
|       almost_x_years: "接近 %{count} 年" | ||||
|       almost_x_years: 接近 %{count} 年 | ||||
|       half_a_minute: 刚刚 | ||||
|       less_than_x_minutes: "%{count} 分不到" | ||||
|       less_than_x_seconds: 刚刚 | ||||
| @@ -232,7 +227,6 @@ zh-CN: | ||||
|       body: 自从你在%{since}使用%{instance}以后,错过了这些嘟嘟滴滴: | ||||
|       mention: "%{name} 在此提及了你︰" | ||||
|       new_followers_summary: | ||||
|         # censorship note: Better not mention "don't move your chicken", even if it's a phonetic joke | ||||
|         one: 有人关注你了!耶! | ||||
|         other: 有 %{count} 个人关注了你!别激动! | ||||
|       subject: | ||||
| @@ -271,7 +265,6 @@ zh-CN: | ||||
|     settings: 设置 | ||||
|     two_factor_authentication: 两步认证 | ||||
|   statuses: | ||||
|     # Hey, this is already in a web browser! | ||||
|     open_in_web: 打开网页 | ||||
|     over_character_limit: 超过了 %{max} 字的限制 | ||||
|     show_more: 显示更多 | ||||
|   | ||||
| @@ -12,6 +12,7 @@ SimpleNavigation::Configuration.run do |navigation| | ||||
|       settings.item :import, safe_join([fa_icon('cloud-upload fw'), t('settings.import')]), settings_import_url | ||||
|       settings.item :export, safe_join([fa_icon('cloud-download fw'), t('settings.export')]), settings_export_url | ||||
|       settings.item :authorized_apps, safe_join([fa_icon('list fw'), t('settings.authorized_apps')]), oauth_authorized_applications_url | ||||
|       settings.item :follower_domains, safe_join([fa_icon('users fw'), t('settings.followers')]), settings_follower_domains_url | ||||
|     end | ||||
|  | ||||
|     primary.item :admin, safe_join([fa_icon('cogs fw'), t('admin.title')]), admin_reports_url, if: proc { current_user.admin? } do |admin| | ||||
|   | ||||
| @@ -63,6 +63,8 @@ Rails.application.routes.draw do | ||||
|       resources :recovery_codes, only: [:create] | ||||
|       resource :confirmation, only: [:new, :create] | ||||
|     end | ||||
|  | ||||
|     resource :follower_domains, only: [:show, :update] | ||||
|   end | ||||
|  | ||||
|   resources :media, only: [:show] | ||||
| @@ -109,9 +111,7 @@ Rails.application.routes.draw do | ||||
|     # ActivityPub | ||||
|     namespace :activitypub do | ||||
|       get '/users/:id/outbox', to: 'outbox#show', as: :outbox | ||||
|  | ||||
|       get '/statuses/:id', to: 'activities#show_status', as: :status | ||||
|  | ||||
|       resources :notes, only: [:show] | ||||
|     end | ||||
|  | ||||
|   | ||||
| @@ -0,0 +1,34 @@ | ||||
| require 'rails_helper' | ||||
|  | ||||
| describe Settings::FollowerDomainsController do | ||||
|   let(:user) { Fabricate(:user) } | ||||
|  | ||||
|   before do | ||||
|     sign_in user, scope: :user | ||||
|   end | ||||
|  | ||||
|   describe 'GET #show' do | ||||
|     it 'returns http success' do | ||||
|       get :show | ||||
|       expect(response).to have_http_status(:success) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe 'PATCH #update' do | ||||
|     let(:poopfeast) { Fabricate(:account, username: 'poopfeast', domain: 'example.com', salmon_url: 'http://example.com/salmon') } | ||||
|  | ||||
|     before do | ||||
|       stub_request(:post, 'http://example.com/salmon').to_return(status: 200) | ||||
|       poopfeast.follow!(user.account) | ||||
|       patch :update, params: { select: ['example.com'] } | ||||
|     end | ||||
|  | ||||
|     it 'redirects back to followers page' do | ||||
|       expect(response).to redirect_to(settings_follower_domains_path) | ||||
|     end | ||||
|  | ||||
|     it 'soft-blocks followers from selected domains' do | ||||
|       expect(poopfeast.following?(user.account)).to be false | ||||
|     end | ||||
|   end | ||||
| end | ||||
| @@ -2,6 +2,7 @@ require 'rails_helper' | ||||
|  | ||||
| describe Settings::PreferencesController do | ||||
|   let(:user) { Fabricate(:user) } | ||||
|  | ||||
|   before do | ||||
|     sign_in user, scope: :user | ||||
|   end | ||||
| @@ -9,13 +10,12 @@ describe Settings::PreferencesController do | ||||
|   describe 'GET #show' do | ||||
|     it 'returns http success' do | ||||
|       get :show | ||||
|  | ||||
|       expect(response).to have_http_status(:success) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   describe 'PUT #update' do | ||||
|     it 'udpates the user record' do | ||||
|     it 'updates the user record' do | ||||
|       put :update, params: { user: { locale: 'en' } } | ||||
|  | ||||
|       expect(response).to redirect_to(settings_preferences_path) | ||||
| @@ -31,7 +31,7 @@ describe Settings::PreferencesController do | ||||
|         user: { | ||||
|           setting_boost_modal: '1', | ||||
|           notification_emails: { follow: '1' }, | ||||
|           interactions: { must_be_follower: '0' } | ||||
|           interactions: { must_be_follower: '0' }, | ||||
|         } | ||||
|       } | ||||
|  | ||||
|   | ||||
| @@ -12,7 +12,7 @@ require 'capybara/rspec' | ||||
| Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f } | ||||
|  | ||||
| ActiveRecord::Migration.maintain_test_schema! | ||||
| WebMock.disable_net_connect!(allow: 'localhost:7575') | ||||
| WebMock.disable_net_connect! | ||||
| Sidekiq::Testing.inline! | ||||
|  | ||||
| RSpec.configure do |config| | ||||
|   | ||||
		Reference in New Issue
	
	Block a user