diff options
author | Eugen <eugen@zeonfederated.com> | 2017-04-24 00:38:37 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-04-24 00:38:37 +0200 |
commit | 501514960a9de238e23cd607d2e8f4c1ff9f16c1 (patch) | |
tree | cf15e7726e7dfda032502c237af4e91cc92ed46a | |
parent | ef5937da1ff2d6caca244439dd9b9b9ed85fb278 (diff) |
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
27 files changed, 394 insertions, 134 deletions
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 = ( - <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, 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 ( + <div className='compose-form__warning'> + {message} + </div> + ); + } + +} + +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 <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); 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. <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 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<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" 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: 「<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: 显示更多 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| |