From 501514960a9de238e23cd607d2e8f4c1ff9f16c1 Mon Sep 17 00:00:00 2001 From: Eugen Date: Mon, 24 Apr 2017 00:38:37 +0200 Subject: [PATCH] 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 --- .../compose/components/compose_form.jsx | 20 +--- .../compose/components/privacy_dropdown.jsx | 2 +- .../features/compose/components/warning.jsx | 25 +++++ .../containers/compose_form_container.jsx | 38 +++----- .../compose/containers/warning_container.jsx | 48 +++++++++ .../javascripts/components/locales/en.jsx | 2 +- app/assets/stylesheets/accounts.scss | 3 +- app/assets/stylesheets/components.scss | 30 ++++-- app/assets/stylesheets/forms.scss | 57 +++++++++++ .../settings/follower_domains_controller.rb | 28 ++++++ app/models/account.rb | 4 + .../settings/follower_domains/show.html.haml | 33 +++++++ app/views/settings/preferences/show.html.haml | 2 +- app/workers/import_worker.rb | 1 + .../pubsubhubbub/distribution_worker.rb | 4 +- .../soft_block_domain_followers_worker.rb | 13 +++ app/workers/soft_block_worker.rb | 17 ++++ config/locales/en.yml | 30 ++++-- config/locales/nl.yml | 97 +++++++++---------- config/locales/pt-BR.yml | 2 +- config/locales/simple_form.en.yml | 2 +- config/locales/zh-CN.yml | 23 ++--- config/navigation.rb | 1 + config/routes.rb | 4 +- .../follower_domains_controller_spec.rb | 34 +++++++ .../settings/preferences_controller_spec.rb | 6 +- spec/rails_helper.rb | 2 +- 27 files changed, 394 insertions(+), 134 deletions(-) create mode 100644 app/assets/javascripts/components/features/compose/components/warning.jsx create mode 100644 app/assets/javascripts/components/features/compose/containers/warning_container.jsx create mode 100644 app/controllers/settings/follower_domains_controller.rb create mode 100644 app/views/settings/follower_domains/show.html.haml create mode 100644 app/workers/soft_block_domain_followers_worker.rb create mode 100644 app/workers/soft_block_worker.rb create mode 100644 spec/controllers/settings/follower_domains_controller_spec.rb diff --git a/app/assets/javascripts/components/features/compose/components/compose_form.jsx b/app/assets/javascripts/components/features/compose/components/compose_form.jsx index c148dded5..464327cb5 100644 --- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx +++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx @@ -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 = ( -
- {mentionedDomains.join(', ')}, domainsCount: mentionedDomains.length }} - /> -
- ); - } - if (this.props.privacy === 'private' || this.props.privacy === 'direct') { publishText = {intl.formatMessage(messages.publish)}; } else { @@ -150,7 +138,7 @@ class ComposeForm extends React.PureComponent { - {privacyWarning} + @@ -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, diff --git a/app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx b/app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx index 507fe7b58..82b3454c6 100644 --- a/app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx +++ b/app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx @@ -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' }, diff --git a/app/assets/javascripts/components/features/compose/components/warning.jsx b/app/assets/javascripts/components/features/compose/components/warning.jsx new file mode 100644 index 000000000..ff1989755 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/components/warning.jsx @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; + +class Warning extends React.PureComponent { + + constructor (props) { + super(props); + } + + render () { + const { message } = this.props; + + return ( +
+ {message} +
+ ); + } + +} + +Warning.propTypes = { + message: PropTypes.node.isRequired +}; + +export default Warning; diff --git a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx index 604e1182f..892183b83 100644 --- a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx +++ b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx @@ -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) { diff --git a/app/assets/javascripts/components/features/compose/containers/warning_container.jsx b/app/assets/javascripts/components/features/compose/containers/warning_container.jsx new file mode 100644 index 000000000..62a9bb571 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/warning_container.jsx @@ -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 }} />} />; + } else if (needsLeakWarning) { + return ( + {mentionedDomains.join(', ')}, domainsCount: mentionedDomains.length }} + />} + /> + ); + } + + return null; +}; + +WarningWrapper.propTypes = { + needsLeakWarning: PropTypes.bool, + needsLockWarning: PropTypes.bool, + mentionedDomains: PropTypes.array.isRequired, +}; + +export default connect(mapStateToProps)(WarningWrapper); diff --git a/app/assets/javascripts/components/locales/en.jsx b/app/assets/javascripts/components/locales/en.jsx index 180caeaf1..ae14843c1 100644 --- a/app/assets/javascripts/components/locales/en.jsx +++ b/app/assets/javascripts/components/locales/en.jsx @@ -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", diff --git a/app/assets/stylesheets/accounts.scss b/app/assets/stylesheets/accounts.scss index 11d155d54..99af9c982 100644 --- a/app/assets/stylesheets/accounts.scss +++ b/app/assets/stylesheets/accounts.scss @@ -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 { diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss index 1c798f2f2..800c97a6b 100644 --- a/app/assets/stylesheets/components.scss +++ b/app/assets/stylesheets/components.scss @@ -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 { diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss index c6a8b5b02..890a00510 100644 --- a/app/assets/stylesheets/forms.scss +++ b/app/assets/stylesheets/forms.scss @@ -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; + } +} diff --git a/app/controllers/settings/follower_domains_controller.rb b/app/controllers/settings/follower_domains_controller.rb new file mode 100644 index 000000000..13722345f --- /dev/null +++ b/app/controllers/settings/follower_domains_controller.rb @@ -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 diff --git a/app/models/account.rb b/app/models/account.rb index b497a90a3..084b17f43 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -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 diff --git a/app/views/settings/follower_domains/show.html.haml b/app/views/settings/follower_domains/show.html.haml new file mode 100644 index 000000000..dad2770f1 --- /dev/null +++ b/app/views/settings/follower_domains/show.html.haml @@ -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 diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml index d009e51ec..8a4113ab4 100644 --- a/app/views/settings/preferences/show.html.haml +++ b/app/views/settings/preferences/show.html.haml @@ -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| diff --git a/app/workers/import_worker.rb b/app/workers/import_worker.rb index bb21468e7..e93fa33cf 100644 --- a/app/workers/import_worker.rb +++ b/app/workers/import_worker.rb @@ -4,6 +4,7 @@ require 'csv' class ImportWorker include Sidekiq::Worker + sidekiq_options queue: 'pull', retry: false attr_reader :import diff --git a/app/workers/pubsubhubbub/distribution_worker.rb b/app/workers/pubsubhubbub/distribution_worker.rb index 68ca0f870..c0e03990a 100644 --- a/app/workers/pubsubhubbub/distribution_worker.rb +++ b/app/workers/pubsubhubbub/distribution_worker.rb @@ -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 diff --git a/app/workers/soft_block_domain_followers_worker.rb b/app/workers/soft_block_domain_followers_worker.rb new file mode 100644 index 000000000..2782d05d2 --- /dev/null +++ b/app/workers/soft_block_domain_followers_worker.rb @@ -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 diff --git a/app/workers/soft_block_worker.rb b/app/workers/soft_block_worker.rb new file mode 100644 index 000000000..312d880b9 --- /dev/null +++ b/app/workers/soft_block_worker.rb @@ -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 diff --git a/config/locales/en.yml b/config/locales/en.yml index cbe2b4cbd..dda2acc13 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -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. Your private statuses are delivered to all instances where you have followers. 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 true privacy can only be achieved with end-to-end encryption. + 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 diff --git a/config/locales/nl.yml b/config/locales/nl.yml index 492849f5e..acf9bd9dc 100644 --- a/config/locales/nl.yml +++ b/config/locales/nl.yml @@ -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
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.
Je kan HTML gebruiken, zoals <a> en <em>. + title: Omschrijving Mastodon-server + site_description_extended: + desc_html: Wordt op de uitgebreide informatiepagina weergegeven
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.
Je kan HTML gebruiken, zoals <a> en <em>." - site_description_extended: - title: Uitgebreide omschrijving Mastodon-server - desc_html: "Wordt op de uitgebreide informatiepagina weergegeven
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
En ook hier kan je HTML gebruiken" diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index 551e92271..e8ad1279b 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -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 diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 790d56452..4aa3818fd 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -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 diff --git a/config/locales/zh-CN.yml b/config/locales/zh-CN.yml index 7b3ba7444..9b3608f24 100644 --- a/config/locales/zh-CN.yml +++ b/config/locales/zh-CN.yml @@ -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: 「自动静音」令该域名用户的嘟文,设为只对关注者显示,没有关注的人会看不到。 - 「自动除名」会自动将该域名用户的嘟文、媒体文件、个人资料自本服务站删除。 + desc_html: "「自动静音」令该域名用户的嘟文,设为只对关注者显示,没有关注的人会看不到。 「自动除名」会自动将该域名用户的嘟文、媒体文件、个人资料自本服务站删除。" 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: 当本站暂停接受注册时,会显示这个消息。
- 可使用 HTML + desc_html: 当本站暂停接受注册时,会显示这个消息。
可使用 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: 显示更多 diff --git a/config/navigation.rb b/config/navigation.rb index bdc0a7b6c..16bc86696 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -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| diff --git a/config/routes.rb b/config/routes.rb index 6893aa06b..34c4fca4c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/spec/controllers/settings/follower_domains_controller_spec.rb b/spec/controllers/settings/follower_domains_controller_spec.rb new file mode 100644 index 000000000..1afdb9757 --- /dev/null +++ b/spec/controllers/settings/follower_domains_controller_spec.rb @@ -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 diff --git a/spec/controllers/settings/preferences_controller_spec.rb b/spec/controllers/settings/preferences_controller_spec.rb index cdf595d4d..0d3dc059a 100644 --- a/spec/controllers/settings/preferences_controller_spec.rb +++ b/spec/controllers/settings/preferences_controller_spec.rb @@ -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' }, } } diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 60d45ddc0..4ddc6d032 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -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|