0
0
Fork 0

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

View file

@ -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' },

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 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) {

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.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",

View file

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

View file

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

View file

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

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?
end
def followers_domains
followers.reorder(nil).pluck('distinct accounts.domain')
end
def favourited?(status)
status.proper.favourites.where(account: self).count.positive?
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
= 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|

View file

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

View file

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

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