diff options
author | Eugen <eugen@zeonfederated.com> | 2017-04-24 00:38:37 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-04-24 00:38:37 +0200 |
commit | 501514960a9de238e23cd607d2e8f4c1ff9f16c1 (patch) | |
tree | cf15e7726e7dfda032502c237af4e91cc92ed46a /app | |
parent | ef5937da1ff2d6caca244439dd9b9b9ed85fb278 (diff) |
Followers-only post federation (#2111)
* Make private toots get PuSHed to subscription URLs that belong to domains where you have approved followers * Authorized followers controller, stub for bulk action * Soft block in the background * Add simple test for new controller * Rename Settings::FollowersController to Settings::FollowerDomainsController, paginate results, rename "private" post setting to "followers-only", fix pagination style, improve post privacy preferences style, improve warning style * Extract compose form warnings into own container, show warning when posting to followers-only with unlocked account
Diffstat (limited to 'app')
17 files changed, 272 insertions, 55 deletions
diff --git a/app/assets/javascripts/components/features/compose/components/compose_form.jsx b/app/assets/javascripts/components/features/compose/components/compose_form.jsx index c148dded5..464327cb5 100644 --- a/app/assets/javascripts/components/features/compose/components/compose_form.jsx +++ b/app/assets/javascripts/components/features/compose/components/compose_form.jsx @@ -15,6 +15,7 @@ import SensitiveButtonContainer from '../containers/sensitive_button_container'; import EmojiPickerDropdown from './emoji_picker_dropdown'; import UploadFormContainer from '../containers/upload_form_container'; import TextIconButton from './text_icon_button'; +import WarningContainer from '../containers/warning_container'; const messages = defineMessages({ placeholder: { id: 'compose_form.placeholder', defaultMessage: 'What is on your mind?' }, @@ -116,26 +117,13 @@ class ComposeForm extends React.PureComponent { } render () { - const { intl, needsPrivacyWarning, mentionedDomains, onPaste } = this.props; + const { intl, onPaste } = this.props; const disabled = this.props.is_submitting; const text = [this.props.spoiler_text, this.props.text].join(''); let publishText = ''; - let privacyWarning = ''; let reply_to_other = false; - if (needsPrivacyWarning) { - privacyWarning = ( - <div className='compose-form__warning'> - <FormattedMessage - id='compose_form.privacy_disclaimer' - defaultMessage='Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}} to not leak your status?' - values={{ domains: <strong>{mentionedDomains.join(', ')}</strong>, domainsCount: mentionedDomains.length }} - /> - </div> - ); - } - if (this.props.privacy === 'private' || this.props.privacy === 'direct') { publishText = <span className='compose-form__publish-private'><i className='fa fa-lock' /> {intl.formatMessage(messages.publish)}</span>; } else { @@ -150,7 +138,7 @@ class ComposeForm extends React.PureComponent { </div> </Collapsable> - {privacyWarning} + <WarningContainer /> <ReplyIndicatorContainer /> @@ -208,8 +196,6 @@ ComposeForm.propTypes = { is_submitting: PropTypes.bool, is_uploading: PropTypes.bool, me: PropTypes.number, - needsPrivacyWarning: PropTypes.bool, - mentionedDomains: PropTypes.array.isRequired, onChange: PropTypes.func.isRequired, onSubmit: PropTypes.func.isRequired, onClearSuggestions: PropTypes.func.isRequired, diff --git a/app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx b/app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx index 507fe7b58..82b3454c6 100644 --- a/app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx +++ b/app/assets/javascripts/components/features/compose/components/privacy_dropdown.jsx @@ -7,7 +7,7 @@ const messages = defineMessages({ public_long: { id: 'privacy.public.long', defaultMessage: 'Post to public timelines' }, unlisted_short: { id: 'privacy.unlisted.short', defaultMessage: 'Unlisted' }, unlisted_long: { id: 'privacy.unlisted.long', defaultMessage: 'Do not show in public timelines' }, - private_short: { id: 'privacy.private.short', defaultMessage: 'Private' }, + private_short: { id: 'privacy.private.short', defaultMessage: 'Followers-only' }, private_long: { id: 'privacy.private.long', defaultMessage: 'Post to followers only' }, direct_short: { id: 'privacy.direct.short', defaultMessage: 'Direct' }, direct_long: { id: 'privacy.direct.long', defaultMessage: 'Post to mentioned users only' }, diff --git a/app/assets/javascripts/components/features/compose/components/warning.jsx b/app/assets/javascripts/components/features/compose/components/warning.jsx new file mode 100644 index 000000000..ff1989755 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/components/warning.jsx @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; + +class Warning extends React.PureComponent { + + constructor (props) { + super(props); + } + + render () { + const { message } = this.props; + + return ( + <div className='compose-form__warning'> + {message} + </div> + ); + } + +} + +Warning.propTypes = { + message: PropTypes.node.isRequired +}; + +export default Warning; diff --git a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx index 604e1182f..892183b83 100644 --- a/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx +++ b/app/assets/javascripts/components/features/compose/containers/compose_form_container.jsx @@ -1,7 +1,6 @@ import { connect } from 'react-redux'; import ComposeForm from '../components/compose_form'; import { uploadCompose } from '../../../actions/compose'; -import { createSelector } from 'reselect'; import { changeCompose, submitCompose, @@ -12,33 +11,20 @@ import { insertEmojiCompose } from '../../../actions/compose'; -const getMentionedUsernames = createSelector(state => state.getIn(['compose', 'text']), text => text.match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig)); - -const getMentionedDomains = createSelector(getMentionedUsernames, mentionedUsernamesWithDomains => { - return mentionedUsernamesWithDomains !== null ? [...new Set(mentionedUsernamesWithDomains.map(item => item.split('@')[2]))] : []; +const mapStateToProps = state => ({ + text: state.getIn(['compose', 'text']), + suggestion_token: state.getIn(['compose', 'suggestion_token']), + suggestions: state.getIn(['compose', 'suggestions']), + spoiler: state.getIn(['compose', 'spoiler']), + spoiler_text: state.getIn(['compose', 'spoiler_text']), + privacy: state.getIn(['compose', 'privacy']), + focusDate: state.getIn(['compose', 'focusDate']), + preselectDate: state.getIn(['compose', 'preselectDate']), + is_submitting: state.getIn(['compose', 'is_submitting']), + is_uploading: state.getIn(['compose', 'is_uploading']), + me: state.getIn(['compose', 'me']) }); -const mapStateToProps = (state, props) => { - const mentionedUsernames = getMentionedUsernames(state); - const mentionedUsernamesWithDomains = getMentionedDomains(state); - - return { - text: state.getIn(['compose', 'text']), - suggestion_token: state.getIn(['compose', 'suggestion_token']), - suggestions: state.getIn(['compose', 'suggestions']), - spoiler: state.getIn(['compose', 'spoiler']), - spoiler_text: state.getIn(['compose', 'spoiler_text']), - privacy: state.getIn(['compose', 'privacy']), - focusDate: state.getIn(['compose', 'focusDate']), - preselectDate: state.getIn(['compose', 'preselectDate']), - is_submitting: state.getIn(['compose', 'is_submitting']), - is_uploading: state.getIn(['compose', 'is_uploading']), - me: state.getIn(['compose', 'me']), - needsPrivacyWarning: (state.getIn(['compose', 'privacy']) === 'private' || state.getIn(['compose', 'privacy']) === 'direct') && mentionedUsernames !== null, - mentionedDomains: mentionedUsernamesWithDomains - }; -}; - const mapDispatchToProps = (dispatch) => ({ onChange (text) { diff --git a/app/assets/javascripts/components/features/compose/containers/warning_container.jsx b/app/assets/javascripts/components/features/compose/containers/warning_container.jsx new file mode 100644 index 000000000..62a9bb571 --- /dev/null +++ b/app/assets/javascripts/components/features/compose/containers/warning_container.jsx @@ -0,0 +1,48 @@ +import { connect } from 'react-redux'; +import Warning from '../components/warning'; +import { createSelector } from 'reselect'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; + +const getMentionedUsernames = createSelector(state => state.getIn(['compose', 'text']), text => text.match(/(?:^|[^\/\w])@([a-z0-9_]+@[a-z0-9\.\-]+)/ig)); + +const getMentionedDomains = createSelector(getMentionedUsernames, mentionedUsernamesWithDomains => { + return mentionedUsernamesWithDomains !== null ? [...new Set(mentionedUsernamesWithDomains.map(item => item.split('@')[2]))] : []; +}); + +const mapStateToProps = state => { + const mentionedUsernames = getMentionedUsernames(state); + const mentionedUsernamesWithDomains = getMentionedDomains(state); + + return { + needsLeakWarning: (state.getIn(['compose', 'privacy']) === 'private' || state.getIn(['compose', 'privacy']) === 'direct') && mentionedUsernames !== null, + mentionedDomains: mentionedUsernamesWithDomains, + needsLockWarning: state.getIn(['compose', 'privacy']) === 'private' && !state.getIn(['accounts', state.getIn(['meta', 'me']), 'locked']) + }; +}; + +const WarningWrapper = ({ needsLeakWarning, needsLockWarning, mentionedDomains }) => { + if (needsLockWarning) { + return <Warning message={<FormattedMessage id='compose_form.lock_disclaimer' defaultMessage='Your account is not {locked}. Anyone can follow you to view your follower-only posts.' values={{ locked: <a href='/settings/profile'><FormattedMessage id='compose_form.lock_disclaimer.lock' defaultMessage='locked' /></a> }} />} />; + } else if (needsLeakWarning) { + return ( + <Warning + message={<FormattedMessage + id='compose_form.privacy_disclaimer' + defaultMessage='Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}} to not leak your status?' + values={{ domains: <strong>{mentionedDomains.join(', ')}</strong>, domainsCount: mentionedDomains.length }} + />} + /> + ); + } + + return null; +}; + +WarningWrapper.propTypes = { + needsLeakWarning: PropTypes.bool, + needsLockWarning: PropTypes.bool, + mentionedDomains: PropTypes.array.isRequired, +}; + +export default connect(mapStateToProps)(WarningWrapper); diff --git a/app/assets/javascripts/components/locales/en.jsx b/app/assets/javascripts/components/locales/en.jsx index 180caeaf1..ae14843c1 100644 --- a/app/assets/javascripts/components/locales/en.jsx +++ b/app/assets/javascripts/components/locales/en.jsx @@ -99,7 +99,7 @@ const en = { "privacy.direct.long": "Post to mentioned users only", "privacy.direct.short": "Direct", "privacy.private.long": "Post to followers only", - "privacy.private.short": "Private", + "privacy.private.short": "Followers-only", "privacy.public.long": "Post to public timelines", "privacy.public.short": "Public", "privacy.unlisted.long": "Do not show in public timelines", diff --git a/app/assets/stylesheets/accounts.scss b/app/assets/stylesheets/accounts.scss index 11d155d54..99af9c982 100644 --- a/app/assets/stylesheets/accounts.scss +++ b/app/assets/stylesheets/accounts.scss @@ -173,7 +173,7 @@ text-align: center; overflow: hidden; - a, .current, .page, .gap { + a, .current, .next, .prev, .page, .gap { font-size: 14px; color: $color5; font-weight: 500; @@ -187,6 +187,7 @@ border-radius: 100px; color: $color1; cursor: default; + margin: 0 10px; } .gap { diff --git a/app/assets/stylesheets/components.scss b/app/assets/stylesheets/components.scss index 1c798f2f2..800c97a6b 100644 --- a/app/assets/stylesheets/components.scss +++ b/app/assets/stylesheets/components.scss @@ -1,6 +1,6 @@ @import 'variables'; -.app-body{ +.app-body { -webkit-overflow-scrolling: touch; -ms-overflow-style: -ms-autohiding-scrollbar; } @@ -203,18 +203,29 @@ } .compose-form__warning { - color: $color2; + color: darken($color3, 33%); margin-bottom: 15px; - border: 1px solid $color3; + background: $color3; + box-shadow: 0 2px 6px rgba($color8, 0.3); padding: 8px 10px; border-radius: 4px; - font-size: 12px; + font-size: 13px; font-weight: 400; strong { - color: $color5; + color: darken($color3, 33%); font-weight: 500; } + + a { + color: darken($color3, 33%); + font-weight: 500; + text-decoration: underline; + + &:hover, &:active, &:focus { + text-decoration: none; + } + } } .compose-form__modifiers { @@ -1619,7 +1630,7 @@ a.status__content__spoiler-link { } .character-counter { - cursor: default; + cursor: default; font-size: 16px; } @@ -1667,7 +1678,7 @@ a.status__content__spoiler-link { font-size: 16px; } } - + @import 'boost'; button.icon-button i.fa-retweet { @@ -1766,6 +1777,7 @@ button.icon-button.active i.fa-retweet { cursor: pointer; position: relative; z-index: 2; + outline: 0; &.active { box-shadow: 0 1px 0 rgba($color4, 0.3); @@ -1781,6 +1793,10 @@ button.icon-button.active i.fa-retweet { display: none; } } + + &:focus, &:active { + outline: 0; + } } .column-header__icon { diff --git a/app/assets/stylesheets/forms.scss b/app/assets/stylesheets/forms.scss index c6a8b5b02..890a00510 100644 --- a/app/assets/stylesheets/forms.scss +++ b/app/assets/stylesheets/forms.scss @@ -269,3 +269,60 @@ code { font-size: 14px; } } + +.table-form { + p { + max-width: 400px; + margin-bottom: 15px; + + strong { + font-weight: 500; + } + } + + .warning { + max-width: 400px; + box-sizing: border-box; + background: rgba($color6, 0.5); + color: $color5; + text-shadow: 1px 1px 0 rgba($color8, 0.3); + box-shadow: 0 2px 6px rgba($color8, 0.4); + border-radius: 4px; + padding: 10px; + margin-bottom: 15px; + + a { + color: $color5; + text-decoration: underline; + + &:hover, &:focus, &:active { + text-decoration: none; + } + } + + strong { + font-weight: 600; + display: block; + margin-bottom: 5px; + + .fa { + font-weight: 400; + } + } + } +} + +.action-pagination { + display: flex; + align-items: center; + + .actions, .pagination { + flex: 1 1 auto; + } + + .actions { + padding: 30px 0; + padding-right: 20px; + flex: 0 0 auto; + } +} diff --git a/app/controllers/settings/follower_domains_controller.rb b/app/controllers/settings/follower_domains_controller.rb new file mode 100644 index 000000000..13722345f --- /dev/null +++ b/app/controllers/settings/follower_domains_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class Settings::FollowerDomainsController < ApplicationController + layout 'admin' + + before_action :authenticate_user! + + def show + @account = current_account + @domains = current_account.followers.reorder(nil).group('accounts.domain').select('accounts.domain, count(accounts.id) as accounts_from_domain').page(params[:page]).per(10) + end + + def update + domains = bulk_params[:select] || [] + + domains.each do |domain| + SoftBlockDomainFollowersWorker.perform_async(current_account.id, domain) + end + + redirect_to settings_follower_domains_path, notice: I18n.t('followers.success', count: domains.size) + end + + private + + def bulk_params + params.permit(select: []) + end +end diff --git a/app/models/account.rb b/app/models/account.rb index b497a90a3..084b17f43 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -135,6 +135,10 @@ class Account < ApplicationRecord !subscription_expires_at.blank? end + def followers_domains + followers.reorder(nil).pluck('distinct accounts.domain') + end + def favourited?(status) status.proper.favourites.where(account: self).count.positive? end diff --git a/app/views/settings/follower_domains/show.html.haml b/app/views/settings/follower_domains/show.html.haml new file mode 100644 index 000000000..dad2770f1 --- /dev/null +++ b/app/views/settings/follower_domains/show.html.haml @@ -0,0 +1,33 @@ +- content_for :page_title do + = t('settings.followers') + += form_tag settings_follower_domains_path, method: :patch, class: 'table-form' do + - unless @account.locked? + .warning + %strong + = fa_icon('warning') + = t('followers.unlocked_warning_title') + = t('followers.unlocked_warning_html', lock_link: link_to(t('followers.lock_link'), settings_profile_url)) + + %p= t('followers.explanation_html') + %p= t('followers.true_privacy_html') + + %table.table + %thead + %tr + %th + %th= t('followers.domain') + %th= t('followers.followers_count') + %tbody + - @domains.each do |domain| + %tr + %td + = check_box_tag 'select[]', domain.domain, false, disabled: !@account.locked? unless domain.domain.nil? + %td + %samp= domain.domain.presence || Rails.configuration.x.local_domain + %td= number_with_delimiter domain.accounts_from_domain + + .action-pagination + .actions + = button_tag t('followers.purge'), type: :submit, class: 'button', disabled: !@account.locked? + = paginate @domains diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml index d009e51ec..8a4113ab4 100644 --- a/app/views/settings/preferences/show.html.haml +++ b/app/views/settings/preferences/show.html.haml @@ -7,7 +7,7 @@ .fields-group = f.input :locale, collection: I18n.available_locales, wrapper: :with_label, include_blank: false, label_method: lambda { |locale| human_locale(locale) } - = f.input :setting_default_privacy, collection: Status.visibilities.keys - ['direct'], wrapper: :with_label, include_blank: false, label_method: lambda { |visibility| I18n.t("statuses.visibilities.#{visibility}") }, required: false, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' + = f.input :setting_default_privacy, collection: Status.visibilities.keys - ['direct'], wrapper: :with_label, include_blank: false, label_method: lambda { |visibility| safe_join([I18n.t("statuses.visibilities.#{visibility}"), content_tag(:span, I18n.t("statuses.visibilities.#{visibility}_long"), class: 'hint')]) }, required: false, as: :radio_buttons, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' .fields-group = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff| diff --git a/app/workers/import_worker.rb b/app/workers/import_worker.rb index bb21468e7..e93fa33cf 100644 --- a/app/workers/import_worker.rb +++ b/app/workers/import_worker.rb @@ -4,6 +4,7 @@ require 'csv' class ImportWorker include Sidekiq::Worker + sidekiq_options queue: 'pull', retry: false attr_reader :import diff --git a/app/workers/pubsubhubbub/distribution_worker.rb b/app/workers/pubsubhubbub/distribution_worker.rb index 68ca0f870..c0e03990a 100644 --- a/app/workers/pubsubhubbub/distribution_worker.rb +++ b/app/workers/pubsubhubbub/distribution_worker.rb @@ -8,12 +8,14 @@ class Pubsubhubbub::DistributionWorker def perform(stream_entry_id) stream_entry = StreamEntry.find(stream_entry_id) - return if stream_entry.hidden? + return if stream_entry.status&.direct_visibility? account = stream_entry.account payload = AtomSerializer.render(AtomSerializer.new.feed(account, [stream_entry])) + domains = account.followers_domains Subscription.where(account: account).active.select('id, callback_url').find_each do |subscription| + next unless domains.include?(Addressable::URI.parse(subscription.callback_url).host) Pubsubhubbub::DeliveryWorker.perform_async(subscription.id, payload) end rescue ActiveRecord::RecordNotFound diff --git a/app/workers/soft_block_domain_followers_worker.rb b/app/workers/soft_block_domain_followers_worker.rb new file mode 100644 index 000000000..2782d05d2 --- /dev/null +++ b/app/workers/soft_block_domain_followers_worker.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class SoftBlockDomainFollowersWorker + include Sidekiq::Worker + + sidekiq_options queue: 'pull' + + def perform(account_id, domain) + Account.find(account_id).followers.where(domain: domain).pluck(:id).each do |follower_id| + SoftBlockWorker.perform_async(account_id, follower_id) + end + end +end diff --git a/app/workers/soft_block_worker.rb b/app/workers/soft_block_worker.rb new file mode 100644 index 000000000..312d880b9 --- /dev/null +++ b/app/workers/soft_block_worker.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class SoftBlockWorker + include Sidekiq::Worker + + sidekiq_options queue: 'pull' + + def perform(account_id, target_account_id) + account = Account.find(account_id) + target_account = Account.find(target_account_id) + + BlockService.new.call(account, target_account) + UnblockService.new.call(account, target_account) + rescue ActiveRecord::RecordNotFound + true + end +end |