commit: 501514960a9de238e23cd607d2e8f4c1ff9f16c1
parent: ef5937da1ff2d6caca244439dd9b9b9ed85fb278
Author: Eugen <eugen@zeonfederated.com>
Date: Mon, 24 Apr 2017 00:38:37 +0200
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
Diffstat:
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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|