* 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 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, | ||||
@@ -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' }, | ||||
@@ -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 { 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 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) => ({ | const mapDispatchToProps = (dispatch) => ({ | ||||
onChange (text) { | 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.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", | ||||
@@ -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 { | ||||
@@ -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 { | ||||
@@ -1619,7 +1630,7 @@ a.status__content__spoiler-link { | |||||
} | } | ||||
.character-counter { | .character-counter { | ||||
cursor: default; | |||||
cursor: default; | |||||
font-size: 16px; | font-size: 16px; | ||||
} | } | ||||
@@ -1667,7 +1678,7 @@ a.status__content__spoiler-link { | |||||
font-size: 16px; | font-size: 16px; | ||||
} | } | ||||
} | } | ||||
@import 'boost'; | @import 'boost'; | ||||
button.icon-button i.fa-retweet { | button.icon-button i.fa-retweet { | ||||
@@ -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 { | ||||
@@ -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; | |||||
} | |||||
} |
@@ -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? | !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 | ||||
@@ -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 | .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| | ||||
@@ -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 | ||||
@@ -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 | ||||
@@ -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 | 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 | ||||
@@ -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><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: | 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><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 | 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 | ||||
@@ -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 | ||||
@@ -30,8 +30,8 @@ zh-CN: | |||||
user_count_before: 这里共注册有 | user_count_before: 这里共注册有 | ||||
accounts: | accounts: | ||||
follow: 关注 | follow: 关注 | ||||
followers: 粉丝 # "Fans" | |||||
following: 关注 # "Follow" | |||||
followers: 粉丝 | |||||
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>」令该域名用户的嘟文,设为只对关注者显示,没有关注的人会看不到。 | |||||
「<strong>自动除名</strong>」会自动将该域名用户的嘟文、媒体文件、个人资料自本服务站删除。 | |||||
desc_html: "「<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/> | |||||
可使用 HTML | |||||
desc_html: 当本站暂停接受注册时,会显示这个消息。<br/> 可使用 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: 显示更多 | ||||
@@ -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| | ||||
@@ -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 | ||||
@@ -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 | 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' }, | |||||
} | } | ||||
} | } | ||||
@@ -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| | ||||