Followers-only post federation (#2111)

* Make private toots get PuSHed to subscription URLs that belong to domains where you have approved followers

* Authorized followers controller, stub for bulk action

* Soft block in the background

* Add simple test for new controller

* Rename Settings::FollowersController to Settings::FollowerDomainsController, paginate results,
rename "private" post setting to "followers-only", fix pagination style, improve post privacy
preferences style, improve warning style

* Extract compose form warnings into own container, show warning when posting to followers-only with unlocked account
This commit is contained in:
Eugen 2017-04-24 00:38:37 +02:00 committed by GitHub
parent ef5937da1f
commit 501514960a
27 changed files with 394 additions and 134 deletions

View file

@ -15,6 +15,7 @@ import SensitiveButtonContainer from '../containers/sensitive_button_container';
import EmojiPickerDropdown from './emoji_picker_dropdown'; import EmojiPickerDropdown from './emoji_picker_dropdown';
import UploadFormContainer from '../containers/upload_form_container'; import UploadFormContainer from '../containers/upload_form_container';
import TextIconButton from './text_icon_button'; import TextIconButton from './text_icon_button';
import WarningContainer from '../containers/warning_container';
const messages = defineMessages({ const messages = defineMessages({
placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' },
@ -116,26 +117,13 @@ class ComposeForm extends React.PureComponent {
} }
render () { render () {
const { intl, needsPrivacyWarning, mentionedDomains, onPaste } = this.props; const { intl, onPaste } = this.props;
const disabled = this.props.is_submitting; const disabled = this.props.is_submitting;
const text = [this.props.spoiler_text, this.props.text].join(''); const text = [this.props.spoiler_text, this.props.text].join('');
let publishText = ''; let publishText = '';
let privacyWarning = '';
let reply_to_other = false; 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') { 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>; publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>;
} else { } else {
@ -150,7 +138,7 @@ class ComposeForm extends React.PureComponent {
</div> </div>
</Collapsable> </Collapsable>
{privacyWarning} <WarningContainer />
<ReplyIndicatorContainer /> <ReplyIndicatorContainer />
@ -208,8 +196,6 @@ ComposeForm.propTypes = {
is_submitting: PropTypes.bool, is_submitting: PropTypes.bool,
is_uploading: PropTypes.bool, is_uploading: PropTypes.bool,
me: PropTypes.number, me: PropTypes.number,
needsPrivacyWarning: PropTypes.bool,
mentionedDomains: PropTypes.array.isRequired,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired, onSubmit: PropTypes.func.isRequired,
onClearSuggestions: PropTypes.func.isRequired, onClearSuggestions: PropTypes.func.isRequired,

View file

@ -7,7 +7,7 @@ const messages = defineMessages({
public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' }, public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' },
unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' },
unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' }, 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' }, private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' },
direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' },
direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' }, direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' },

View file

@ -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;

View file

@ -1,7 +1,6 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import ComposeForm from '../components/compose_form'; import ComposeForm from '../components/compose_form';
import { uploadCompose } from '../../../actions/compose'; import { uploadCompose } from '../../../actions/compose';
import { createSelector } from 'reselect';
import { import {
changeCompose, changeCompose,
submitCompose, submitCompose,
@ -12,33 +11,20 @@ import {
insertEmojiCompose insertEmojiCompose
} from '../../../actions/compose'; } from '../../../actions/compose';
const getMentionedUsernames = createSelector(state => state.getIn(['compose', 'text']), text => text.match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig)); const mapStateToProps = state => ({
text: state.getIn(['compose', 'text']),
const getMentionedDomains = createSelector(getMentionedUsernames, mentionedUsernamesWithDomains => { suggestion_token: state.getIn(['compose', 'suggestion_token']),
return mentionedUsernamesWithDomains !== null ? [...new Set(mentionedUsernamesWithDomains.map(item => item.split('@')[2]))] : []; 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) => ({ const mapDispatchToProps = (dispatch) => ({
onChange (text) { onChange (text) {

View file

@ -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);

View file

@ -99,7 +99,7 @@ const en = {
"privacy.direct.long": "Post to mentioned users only", "privacy.direct.long": "Post to mentioned users only",
"privacy.direct.short": "Direct", "privacy.direct.short": "Direct",
"privacy.private.long": "Post to followers only", "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.long": "Post to public timelines",
"privacy.public.short": "Public", "privacy.public.short": "Public",
"privacy.unlisted.long": "Do not show in public timelines", "privacy.unlisted.long": "Do not show in public timelines",

View file

@ -173,7 +173,7 @@
text-align: center; text-align: center;
overflow: hidden; overflow: hidden;
a, .current, .page, .gap { a, .current, .next, .prev, .page, .gap {
font-size: 14px; font-size: 14px;
color: $color5; color: $color5;
font-weight: 500; font-weight: 500;
@ -187,6 +187,7 @@
border-radius: 100px; border-radius: 100px;
color: $color1; color: $color1;
cursor: default; cursor: default;
margin: 0 10px;
} }
.gap { .gap {

View file

@ -1,6 +1,6 @@
@import 'variables'; @import 'variables';
.app-body{ .app-body {
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
-ms-overflow-style: -ms-autohiding-scrollbar; -ms-overflow-style: -ms-autohiding-scrollbar;
} }
@ -203,18 +203,29 @@
} }
.compose-form__warning { .compose-form__warning {
color: $color2; color: darken($color3, 33%);
margin-bottom: 15px; margin-bottom: 15px;
border: 1px solid $color3; background: $color3;
box-shadow: 0 2px 6px rgba($color8, 0.3);
padding: 8px 10px; padding: 8px 10px;
border-radius: 4px; border-radius: 4px;
font-size: 12px; font-size: 13px;
font-weight: 400; font-weight: 400;
strong { strong {
color: $color5; color: darken($color3, 33%);
font-weight: 500; font-weight: 500;
} }
a {
color: darken($color3, 33%);
font-weight: 500;
text-decoration: underline;
&:hover, &:active, &:focus {
text-decoration: none;
}
}
} }
.compose-form__modifiers { .compose-form__modifiers {
@ -1766,6 +1777,7 @@ button.icon-button.active i.fa-retweet {
cursor: pointer; cursor: pointer;
position: relative; position: relative;
z-index: 2; z-index: 2;
outline: 0;
&.active { &.active {
box-shadow: 0 1px 0 rgba($color4, 0.3); box-shadow: 0 1px 0 rgba($color4, 0.3);
@ -1781,6 +1793,10 @@ button.icon-button.active i.fa-retweet {
display: none; display: none;
} }
} }
&:focus, &:active {
outline: 0;
}
} }
.column-header__icon { .column-header__icon {

View file

@ -269,3 +269,60 @@ code {
font-size: 14px; 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;
}
}

View file

@ -0,0 +1,28 @@
# frozen_string_literal: true
class Settings::FollowerDomainsController < ApplicationController
layout 'admin'
before_action :authenticate_user!
def show
@account = current_account
@domains = current_account.followers.reorder(nil).group('accounts.domain').select('accounts.domain, count(accounts.id) as accounts_from_domain').page(params[:page]).per(10)
end
def update
domains = bulk_params[:select] || []
domains.each do |domain|
SoftBlockDomainFollowersWorker.perform_async(current_account.id, domain)
end
redirect_to settings_follower_domains_path, notice: I18n.t('followers.success', count: domains.size)
end
private
def bulk_params
params.permit(select: [])
end
end

View file

@ -135,6 +135,10 @@ class Account < ApplicationRecord
!subscription_expires_at.blank? !subscription_expires_at.blank?
end end
def followers_domains
followers.reorder(nil).pluck('distinct accounts.domain')
end
def favourited?(status) def favourited?(status)
status.proper.favourites.where(account: self).count.positive? status.proper.favourites.where(account: self).count.positive?
end end

View file

@ -0,0 +1,33 @@
- content_for :page_title do
= t('settings.followers')
= form_tag settings_follower_domains_path, method: :patch, class: 'table-form' do
- unless @account.locked?
.warning
%strong
= fa_icon('warning')
= t('followers.unlocked_warning_title')
= t('followers.unlocked_warning_html', lock_link: link_to(t('followers.lock_link'), settings_profile_url))
%p= t('followers.explanation_html')
%p= t('followers.true_privacy_html')
%table.table
%thead
%tr
%th
%th= t('followers.domain')
%th= t('followers.followers_count')
%tbody
- @domains.each do |domain|
%tr
%td
= check_box_tag 'select[]', domain.domain, false, disabled: !@account.locked? unless domain.domain.nil?
%td
%samp= domain.domain.presence || Rails.configuration.x.local_domain
%td= number_with_delimiter domain.accounts_from_domain
.action-pagination
.actions
= button_tag t('followers.purge'), type: :submit, class: 'button', disabled: !@account.locked?
= paginate @domains

View file

@ -7,7 +7,7 @@
.fields-group .fields-group
= f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) } = 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 .fields-group
= f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff| = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff|

View file

@ -4,6 +4,7 @@ require 'csv'
class ImportWorker class ImportWorker
include Sidekiq::Worker include Sidekiq::Worker
sidekiq_options queue: 'pull', retry: false sidekiq_options queue: 'pull', retry: false
attr_reader :import attr_reader :import

View file

@ -8,12 +8,14 @@ class Pubsubhubbub::DistributionWorker
def perform(stream_entry_id) def perform(stream_entry_id)
stream_entry = StreamEntry.find(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 account = stream_entry.account
payload = AtomSerializer.render(AtomSerializer.new.feed(account, [stream_entry])) 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| 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) Pubsubhubbub::DeliveryWorker.perform_async(subscription.id, payload)
end end
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
class SoftBlockDomainFollowersWorker
include Sidekiq::Worker
sidekiq_options queue: 'pull'
def perform(account_id, domain)
Account.find(account_id).followers.where(domain: domain).pluck(:id).each do |follower_id|
SoftBlockWorker.perform_async(account_id, follower_id)
end
end
end

View file

@ -0,0 +1,17 @@
# frozen_string_literal: true
class SoftBlockWorker
include Sidekiq::Worker
sidekiq_options queue: 'pull'
def perform(account_id, target_account_id)
account = Account.find(account_id)
target_account = Account.find(target_account_id)
BlockService.new.call(account, target_account)
UnblockService.new.call(account, target_account)
rescue ActiveRecord::RecordNotFound
true
end
end

View file

@ -41,14 +41,14 @@ en:
remote_follow: Remote follow remote_follow: Remote follow
unfollow: Unfollow unfollow: Unfollow
activitypub: activitypub:
outbox:
name: "%{account_name}'s Outbox"
summary: "A collection of activities from user %{account_name}."
activity: activity:
create:
name: "%{account_name} created a note."
announce: announce:
name: "%{account_name} announced an activity." 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: admin:
accounts: accounts:
are_you_sure: Are you sure? are_you_sure: Are you sure?
@ -227,6 +227,18 @@ en:
follows: You follow follows: You follow
mutes: You mute mutes: You mute
storage: Media storage 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: generic:
changes_saved_msg: Changes successfully saved! changes_saved_msg: Changes successfully saved!
powered_by: powered by %{link} powered_by: powered by %{link}
@ -286,6 +298,7 @@ en:
back: Back to Mastodon back: Back to Mastodon
edit_profile: Edit profile edit_profile: Edit profile
export: Data export export: Data export
followers: Authorized followers
import: Import import: Import
preferences: Preferences preferences: Preferences
settings: Settings settings: Settings
@ -295,9 +308,12 @@ en:
over_character_limit: character limit of %{max} exceeded over_character_limit: character limit of %{max} exceeded
show_more: Show more show_more: Show more
visibilities: visibilities:
private: Only show to followers private: Followers-only
private_long: Only show to followers
public: Public 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: stream_entries:
click_to_show: Click to show click_to_show: Click to show
reblogged: boosted reblogged: boosted

View file

@ -39,6 +39,48 @@ nl:
posts: Berichten posts: Berichten
remote_follow: Extern volgen remote_follow: Extern volgen
unfollow: Ontvolgen 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>&lt;a&gt;</code> en <code>&lt;em&gt;</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: application_mailer:
settings: 'E-mailvoorkeuren wijzigen: %{link}' settings: 'E-mailvoorkeuren wijzigen: %{link}'
signature: Mastodon-meldingen van %{instance} signature: Mastodon-meldingen van %{instance}
@ -74,6 +116,12 @@ nl:
x_minutes: "%{count}m" x_minutes: "%{count}m"
x_months: "%{count}ma" x_months: "%{count}ma"
x_seconds: "%{count}s" 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: exports:
blocks: Jij blokkeert blocks: Jij blokkeert
csv: CSV csv: CSV
@ -161,52 +209,3 @@ nl:
users: users:
invalid_email: E-mailadres is ongeldig invalid_email: E-mailadres is ongeldig
invalid_otp_token: Ongeldige tweestaps-aanmeldcode 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>&lt;a&gt;</code> en <code>&lt;em&gt;</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"

View file

@ -22,8 +22,8 @@ pt-BR:
features_headline: O que torna Mastodon diferente features_headline: O que torna Mastodon diferente
get_started: Comece aqui get_started: Comece aqui
links: Links links: Links
source_code: Source code
other_instances: Outras instâncias other_instances: Outras instâncias
source_code: Source code
terms: Termos terms: Termos
user_count_after: usuários user_count_after: usuários
user_count_before: Lugar de user_count_before: Lugar de

View file

@ -23,7 +23,7 @@ en:
email: E-mail address email: E-mail address
header: Header header: Header
locale: Language locale: Language
locked: Make account private locked: Lock account
new_password: New password new_password: New password
note: Bio note: Bio
otp_attempt: Two-factor code otp_attempt: Two-factor code

View file

@ -30,8 +30,8 @@ zh-CN:
user_count_before: 这里共注册有 user_count_before: 这里共注册有
accounts: accounts:
follow: 关注 follow: 关注
followers: 粉丝 # "Fans" followers: 粉丝
following: 关注 # "Follow" following: 关注
nothing_here: 神马都没有! nothing_here: 神马都没有!
people_followed_by: 正关注 people_followed_by: 正关注
people_who_follow: 粉丝 people_who_follow: 粉丝
@ -80,15 +80,14 @@ zh-CN:
web: 用户页面 web: 用户页面
domain_blocks: domain_blocks:
add_new: 添加 add_new: 添加
domain: 域名阻隔
created_msg: 正处理域名阻隔 created_msg: 正处理域名阻隔
destroyed_msg: 已撤销域名阻隔 destroyed_msg: 已撤销域名阻隔
domain: 域名阻隔
new: new:
create: 添加域名阻隔 create: 添加域名阻隔
hint: 「域名阻隔」不会隔绝该域名用户的嘟账户入本站数据库,但会嘟文抵达后,自动套用特定的审批操作。 hint: "「域名阻隔」不会隔绝该域名用户的嘟账户入本站数据库,但会嘟文抵达后,自动套用特定的审批操作。"
severity: severity:
desc_html: 「<strong>自动静音</strong>」令该域名用户的嘟文,设为只对关注者显示,没有关注的人会看不到。 desc_html: "「<strong>自动静音</strong>」令该域名用户的嘟文,设为只对关注者显示,没有关注的人会看不到。 「<strong>自动除名</strong>」会自动将该域名用户的嘟文、媒体文件、个人资料自本服务站删除。"
「<strong>自动除名</strong>」会自动将该域名用户的嘟文、媒体文件、个人资料自本服务站删除。
silence: 自动静音 silence: 自动静音
suspend: 自动除名 suspend: 自动除名
title: 添加域名阻隔 title: 添加域名阻隔
@ -99,10 +98,8 @@ zh-CN:
suspend: 自动除名 suspend: 自动除名
severity: 阻隔程度 severity: 阻隔程度
show: show:
# It turns out that Chinese only uses an "other"
# Well, we don't have these -s magic anyway...
affected_accounts: affected_accounts:
other: "数据库中有%{count}个账户受影响" other: 数据库中有%{count}个账户受影响
retroactive: retroactive:
silence: 对此域名的所有账户取消静音 silence: 对此域名的所有账户取消静音
suspend: 对此域名的所有账户取消除名 suspend: 对此域名的所有账户取消除名
@ -147,8 +144,7 @@ zh-CN:
username: 输入用户名称 username: 输入用户名称
registrations: registrations:
closed_message: closed_message:
desc_html: 当本站暂停接受注册时,会显示这个消息。<br/> desc_html: 当本站暂停接受注册时,会显示这个消息。<br/> 可使用 HTML
可使用 HTML
title: 暂停注册消息 title: 暂停注册消息
open: open:
disabled: 停用 disabled: 停用
@ -187,11 +183,10 @@ zh-CN:
title: 关注 %{acct} title: 关注 %{acct}
datetime: datetime:
distance_in_words: distance_in_words:
# Ditching "about" as in en
about_x_hours: "%{count} 小时" about_x_hours: "%{count} 小时"
about_x_months: "%{count} 个月" about_x_months: "%{count} 个月"
about_x_years: "%{count} 年" about_x_years: "%{count} 年"
almost_x_years: "接近 %{count} 年" almost_x_years: 接近 %{count} 年
half_a_minute: 刚刚 half_a_minute: 刚刚
less_than_x_minutes: "%{count} 分不到" less_than_x_minutes: "%{count} 分不到"
less_than_x_seconds: 刚刚 less_than_x_seconds: 刚刚
@ -232,7 +227,6 @@ zh-CN:
body: 自从你在%{since}使用%{instance}以后,错过了这些嘟嘟滴滴: body: 自从你在%{since}使用%{instance}以后,错过了这些嘟嘟滴滴:
mention: "%{name} 在此提及了你︰" mention: "%{name} 在此提及了你︰"
new_followers_summary: new_followers_summary:
# censorship note: Better not mention "don't move your chicken", even if it's a phonetic joke
one: 有人关注你了!耶! one: 有人关注你了!耶!
other: 有 %{count} 个人关注了你!别激动! other: 有 %{count} 个人关注了你!别激动!
subject: subject:
@ -271,7 +265,6 @@ zh-CN:
settings: 设置 settings: 设置
two_factor_authentication: 两步认证 two_factor_authentication: 两步认证
statuses: statuses:
# Hey, this is already in a web browser!
open_in_web: 打开网页 open_in_web: 打开网页
over_character_limit: 超过了 %{max} 字的限制 over_character_limit: 超过了 %{max} 字的限制
show_more: 显示更多 show_more: 显示更多

View file

@ -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 :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 :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 :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 end
primary.item :admin, safe_join([fa_icon('cogs fw'), t('admin.title')]), admin_reports_url, if: proc { current_user.admin? } do |admin| primary.item :admin, safe_join([fa_icon('cogs fw'), t('admin.title')]), admin_reports_url, if: proc { current_user.admin? } do |admin|

View file

@ -63,6 +63,8 @@ Rails.application.routes.draw do
resources :recovery_codes, only: [:create] resources :recovery_codes, only: [:create]
resource :confirmation, only: [:new, :create] resource :confirmation, only: [:new, :create]
end end
resource :follower_domains, only: [:show, :update]
end end
resources :media, only: [:show] resources :media, only: [:show]
@ -109,9 +111,7 @@ Rails.application.routes.draw do
# ActivityPub # ActivityPub
namespace :activitypub do namespace :activitypub do
get '/users/:id/outbox', to: 'outbox#show', as: :outbox get '/users/:id/outbox', to: 'outbox#show', as: :outbox
get '/statuses/:id', to: 'activities#show_status', as: :status get '/statuses/:id', to: 'activities#show_status', as: :status
resources :notes, only: [:show] resources :notes, only: [:show]
end end

View file

@ -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

View file

@ -2,6 +2,7 @@ require 'rails_helper'
describe Settings::PreferencesController do describe Settings::PreferencesController do
let(:user) { Fabricate(:user) } let(:user) { Fabricate(:user) }
before do before do
sign_in user, scope: :user sign_in user, scope: :user
end end
@ -9,13 +10,12 @@ describe Settings::PreferencesController do
describe 'GET #show' do describe 'GET #show' do
it 'returns http success' do it 'returns http success' do
get :show get :show
expect(response).to have_http_status(:success) expect(response).to have_http_status(:success)
end end
end end
describe 'PUT #update' do describe 'PUT #update' do
it 'udpates the user record' do it 'updates the user record' do
put :update, params: { user: { locale: 'en' } } put :update, params: { user: { locale: 'en' } }
expect(response).to redirect_to(settings_preferences_path) expect(response).to redirect_to(settings_preferences_path)
@ -31,7 +31,7 @@ describe Settings::PreferencesController do
user: { user: {
setting_boost_modal: '1', setting_boost_modal: '1',
notification_emails: { follow: '1' }, notification_emails: { follow: '1' },
interactions: { must_be_follower: '0' } interactions: { must_be_follower: '0' },
} }
} }

View file

@ -12,7 +12,7 @@ require 'capybara/rspec'
Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f } Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f }
ActiveRecord::Migration.maintain_test_schema! ActiveRecord::Migration.maintain_test_schema!
WebMock.disable_net_connect!(allow: 'localhost:7575') WebMock.disable_net_connect!
Sidekiq::Testing.inline! Sidekiq::Testing.inline!
RSpec.configure do |config| RSpec.configure do |config|