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