From 93fc8aa14c914d03643197306c325becbaed2581 Mon Sep 17 00:00:00 2001 From: beatrix-bitrot Date: Mon, 5 Jun 2017 22:34:08 +0000 Subject: keyword muting and local only tooting WIP --- app/services/post_status_service.rb | 5 ++++- app/services/reblog_service.rb | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) (limited to 'app/services') diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 2e6fbb5c3..d86ae04d4 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -36,7 +36,10 @@ class PostStatusService < BaseService LinkCrawlWorker.perform_async(status.id) unless status.spoiler_text? DistributionWorker.perform_async(status.id) - Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id) + + unless /👁$/.match?(status.content) + Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id) + end if options[:idempotency].present? redis.setex("idempotency:status:#{account.id}:#{options[:idempotency]}", 3_600, status.id) diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index ba24b1f9d..497cdb4f5 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -20,7 +20,10 @@ class ReblogService < BaseService reblog = account.statuses.create!(reblog: reblogged_status, text: '') DistributionWorker.perform_async(reblog.id) - Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id) + unless /👁$/.match?(reblogged_status.content) + Pubsubhubbub::DistributionWorker.perform_async(reblog.stream_entry.id) + end + if reblogged_status.local? NotifyService.new.call(reblog.reblog.account, reblog) -- cgit From a6f5111c7910cff5b0765f2452519e9ca51658b0 Mon Sep 17 00:00:00 2001 From: Surinna Curtis Date: Mon, 26 Jun 2017 20:51:11 -0500 Subject: Eyes with variation-selector-16 now also do local-only --- app/services/post_status_service.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'app/services') diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index d86ae04d4..ae9b63abe 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -37,7 +37,8 @@ class PostStatusService < BaseService LinkCrawlWorker.perform_async(status.id) unless status.spoiler_text? DistributionWorker.perform_async(status.id) - unless /👁$/.match?(status.content) + # match both with and without U+FE0F (the emoji variation selector) + unless /[👁👁️]$/.match?(status.content) Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id) end -- cgit From 3464bb30f83dcfc8e8096d191fd110d84b9ad42f Mon Sep 17 00:00:00 2001 From: Surinna Curtis Date: Thu, 6 Jul 2017 22:30:21 -0500 Subject: replies to local-only toots default to local-only, and fix some regex bugs --- app/javascript/mastodon/reducers/compose.js | 4 +++- app/services/post_status_service.rb | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) (limited to 'app/services') diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index a83df62dc..21d801f2a 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -180,7 +180,9 @@ export default function compose(state = initialState, action) { map.set('in_reply_to', action.status.get('id')); map.set('text', statusToTextMentions(state, action.status)); map.set('privacy', privacyPreference(action.status.get('visibility'), state.get('default_privacy'))); - map.set('advanced_options', state.get('default_advanced_options')); + map.set('advanced_options', new Immutable.Map({ + do_not_federate: /👁\ufe0f?<\/p>$/.test(action.status.get('content')), + })); map.set('focusDate', new Date()); map.set('preselectDate', new Date()); map.set('idempotencyKey', uuid()); diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index ae9b63abe..9fb1a2b12 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -38,7 +38,7 @@ class PostStatusService < BaseService DistributionWorker.perform_async(status.id) # match both with and without U+FE0F (the emoji variation selector) - unless /[👁👁️]$/.match?(status.content) + unless /👁\ufe0f?\z/.match?(status.content) Pubsubhubbub::DistributionWorker.perform_async(status.stream_entry.id) end -- cgit From 27f8d7069beeb1996fe8121dddfbcf3fe60248a6 Mon Sep 17 00:00:00 2001 From: Surinna Curtis Date: Thu, 27 Jul 2017 07:52:45 -0500 Subject: block notifications in notify_service from hard muted accounts --- app/services/notify_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app/services') diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index ca53c61c5..fb09df983 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -41,7 +41,7 @@ class NotifyService < BaseService blocked ||= @recipient.id == @notification.from_account.id # Skip for interactions with self blocked ||= @recipient.domain_blocking?(@notification.from_account.domain) && !@recipient.following?(@notification.from_account) # Skip for domain blocked accounts blocked ||= @recipient.blocking?(@notification.from_account) # Skip for blocked accounts - blocked ||= @recipient.muting?(@notification.from_account) # Skip for muted accounts + blocked ||= @recipient.muting_notifications?(@notification.from_account) blocked ||= (@notification.from_account.silenced? && !@recipient.following?(@notification.from_account)) # Hellban blocked ||= (@recipient.user.settings.interactions['must_be_follower'] && !@notification.from_account.following?(@recipient)) # Options blocked ||= (@recipient.user.settings.interactions['must_be_following'] && !@recipient.following?(@notification.from_account)) # Options -- cgit From 6f7d00bfddb03ead2e5b4011cbe21c4e97b92421 Mon Sep 17 00:00:00 2001 From: Surinna Curtis Date: Thu, 27 Jul 2017 23:28:57 -0500 Subject: Add support for muting notifications in MuteService --- app/services/mute_service.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) (limited to 'app/services') diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb index 92f92cc7d..d8a95f94b 100644 --- a/app/services/mute_service.rb +++ b/app/services/mute_service.rb @@ -1,9 +1,13 @@ # frozen_string_literal: true class MuteService < BaseService - def call(account, target_account) + def call(account, target_account, notifications: nil) return if account.id == target_account.id FeedManager.instance.clear_from_timeline(account, target_account) - account.mute!(target_account) + # This unwieldy approach avoids duplicating the default value here + # and in mute!. + opts = {} + opts[:notifications] = notifications unless notifications.nil? + account.mute!(target_account, **opts) end end -- cgit From 0c547faf92a5433bcf12811d76ece583c50beaf9 Mon Sep 17 00:00:00 2001 From: Surinna Curtis Date: Fri, 28 Jul 2017 00:12:34 -0500 Subject: Less gross passing of notifications flag --- app/controllers/api/v1/accounts_controller.rb | 2 +- app/services/mute_service.rb | 8 ++------ 2 files changed, 3 insertions(+), 7 deletions(-) (limited to 'app/services') diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index ec3abfbf5..de2cb0d97 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -33,7 +33,7 @@ class Api::V1::AccountsController < Api::BaseController end def mute - MuteService.new.call(current_user.account, @account, notifications: params[:notifications]) + MuteService.new.call(current_user.account, @account, **params.permit(:notifications).to_hash.symbolize_keys) render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships end diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb index d8a95f94b..fc63f83e9 100644 --- a/app/services/mute_service.rb +++ b/app/services/mute_service.rb @@ -1,13 +1,9 @@ # frozen_string_literal: true class MuteService < BaseService - def call(account, target_account, notifications: nil) + def call(account, target_account, **opts) return if account.id == target_account.id FeedManager.instance.clear_from_timeline(account, target_account) - # This unwieldy approach avoids duplicating the default value here - # and in mute!. - opts = {} - opts[:notifications] = notifications unless notifications.nil? - account.mute!(target_account, **opts) + account.mute!(target_account, **opts.slice(:notifications)) end end -- cgit From f9d7b8a94f4a89d76081a6265103f6d7439be250 Mon Sep 17 00:00:00 2001 From: Surinna Curtis Date: Wed, 13 Sep 2017 18:32:10 -0500 Subject: Refactor handling of default params for muting to make code cleaner --- app/controllers/api/v1/accounts_controller.rb | 2 +- app/models/concerns/account_interactions.rb | 3 ++- app/services/mute_service.rb | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) (limited to 'app/services') diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index de2cb0d97..3e9ac1025 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -33,7 +33,7 @@ class Api::V1::AccountsController < Api::BaseController end def mute - MuteService.new.call(current_user.account, @account, **params.permit(:notifications).to_hash.symbolize_keys) + MuteService.new.call(current_user.account, @account, notifications: params(:notifications)) render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships end diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index 6f69ce1d4..0afdebf89 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -74,7 +74,8 @@ module AccountInteractions block_relationships.find_or_create_by!(target_account: other_account) end - def mute!(other_account, notifications: true) + def mute!(other_account, notifications: nil) + notifications = true if notifications.nil? mute = mute_relationships.create_with(hide_notifications: notifications).find_or_create_by!(target_account: other_account) # When toggling a mute between hiding and allowing notifications, the mute will already exist, so the find_or_create_by! call will return the existing Mute without updating the hide_notifications attribute. Therefore, we check that hide_notifications? is what we want and set it if it isn't. if mute.hide_notifications? != notifications diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb index fc63f83e9..56cbebd5d 100644 --- a/app/services/mute_service.rb +++ b/app/services/mute_service.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true class MuteService < BaseService - def call(account, target_account, **opts) + def call(account, target_account, notifications: nil) return if account.id == target_account.id FeedManager.instance.clear_from_timeline(account, target_account) - account.mute!(target_account, **opts.slice(:notifications)) + account.mute!(target_account, notifications: notifications) end end -- cgit From edd1a00fafb5d41afad0c177e7c2f2f6adae6d19 Mon Sep 17 00:00:00 2001 From: David Yip Date: Thu, 12 Oct 2017 04:27:45 -0500 Subject: Restore variable assignment in MuteService#call. 291feba6f113588cce4f06206754b31eba60044b made MuteService return the result of Account#mute!; this commit restores that behavior. --- app/services/mute_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app/services') diff --git a/app/services/mute_service.rb b/app/services/mute_service.rb index a9a02937e..547b2efa1 100644 --- a/app/services/mute_service.rb +++ b/app/services/mute_service.rb @@ -3,7 +3,7 @@ class MuteService < BaseService def call(account, target_account, notifications: nil) return if account.id == target_account.id - account.mute!(target_account, notifications: notifications) + mute = account.mute!(target_account, notifications: notifications) BlockWorker.perform_async(account.id, target_account.id) mute end -- cgit From 3db80f75a6d76a7eea576413c5ae9b206d2ab385 Mon Sep 17 00:00:00 2001 From: Matthew Walsh Date: Sun, 15 Oct 2017 21:02:39 -0700 Subject: Added a timeline for Direct statuses * Lists all Direct statuses you've sent and received * Displayed in Getting Started * Streaming server support for direct TL --- .../api/v1/timelines/direct_controller.rb | 60 ++++++++++++ app/javascript/mastodon/actions/compose.js | 2 + app/javascript/mastodon/actions/streaming.js | 1 + app/javascript/mastodon/actions/timelines.js | 2 + .../containers/column_settings_container.js | 17 ++++ .../mastodon/features/direct_timeline/index.js | 107 +++++++++++++++++++++ .../mastodon/features/getting_started/index.js | 15 ++- .../features/ui/components/columns_area.js | 3 +- app/javascript/mastodon/features/ui/index.js | 2 + .../mastodon/features/ui/util/async-components.js | 4 + .../mastodon/locales/defaultMessages.json | 17 ++++ app/javascript/mastodon/locales/en.json | 3 + app/javascript/mastodon/reducers/settings.js | 6 ++ app/models/status.rb | 8 ++ app/services/batched_remove_status_service.rb | 11 +++ app/services/fan_out_on_write_service.rb | 13 ++- app/services/remove_status_service.rb | 8 ++ config/routes.rb | 1 + spec/models/status_spec.rb | 49 ++++++++++ streaming/index.js | 7 ++ 20 files changed, 329 insertions(+), 7 deletions(-) create mode 100644 app/controllers/api/v1/timelines/direct_controller.rb create mode 100644 app/javascript/mastodon/features/direct_timeline/containers/column_settings_container.js create mode 100644 app/javascript/mastodon/features/direct_timeline/index.js (limited to 'app/services') diff --git a/app/controllers/api/v1/timelines/direct_controller.rb b/app/controllers/api/v1/timelines/direct_controller.rb new file mode 100644 index 000000000..d455227eb --- /dev/null +++ b/app/controllers/api/v1/timelines/direct_controller.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class Api::V1::Timelines::DirectController < Api::BaseController + before_action -> { doorkeeper_authorize! :read }, only: [:show] + before_action :require_user!, only: [:show] + after_action :insert_pagination_headers, unless: -> { @statuses.empty? } + + respond_to :json + + def show + @statuses = load_statuses + render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id) + end + + private + + def load_statuses + cached_direct_statuses + end + + def cached_direct_statuses + cache_collection direct_statuses, Status + end + + def direct_statuses + direct_timeline_statuses.paginate_by_max_id( + limit_param(DEFAULT_STATUSES_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def direct_timeline_statuses + Status.as_direct_timeline(current_account) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def pagination_params(core_params) + params.permit(:local, :limit).merge(core_params) + end + + def next_path + api_v1_timelines_direct_url pagination_params(max_id: pagination_max_id) + end + + def prev_path + api_v1_timelines_direct_url pagination_params(since_id: pagination_since_id) + end + + def pagination_max_id + @statuses.last.id + end + + def pagination_since_id + @statuses.first.id + end +end diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 8a35049b3..278fbc898 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -128,6 +128,8 @@ export function submitCompose() { if (response.data.in_reply_to_id === null && response.data.visibility === 'public') { insertOrRefresh('community', refreshCommunityTimeline); insertOrRefresh('public', refreshPublicTimeline); + } else if (response.data.visibility === 'direct') { + dispatch(updateTimeline('direct', { ...response.data })); } }).catch(function (error) { dispatch(submitComposeFail(error)); diff --git a/app/javascript/mastodon/actions/streaming.js b/app/javascript/mastodon/actions/streaming.js index 7802694a3..a2e25c930 100644 --- a/app/javascript/mastodon/actions/streaming.js +++ b/app/javascript/mastodon/actions/streaming.js @@ -92,3 +92,4 @@ export const connectCommunityStream = () => connectTimelineStream('community', ' export const connectMediaStream = () => connectTimelineStream('community', 'public:local'); export const connectPublicStream = () => connectTimelineStream('public', 'public'); export const connectHashtagStream = (tag) => connectTimelineStream(`hashtag:${tag}`, `hashtag&tag=${tag}`); +export const connectDirectStream = () => connectTimelineStream('direct', 'direct'); diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js index 09abe2702..935bbb6f0 100644 --- a/app/javascript/mastodon/actions/timelines.js +++ b/app/javascript/mastodon/actions/timelines.js @@ -115,6 +115,7 @@ export function refreshTimeline(timelineId, path, params = {}) { export const refreshHomeTimeline = () => refreshTimeline('home', '/api/v1/timelines/home'); export const refreshPublicTimeline = () => refreshTimeline('public', '/api/v1/timelines/public'); export const refreshCommunityTimeline = () => refreshTimeline('community', '/api/v1/timelines/public', { local: true }); +export const refreshDirectTimeline = () => refreshTimeline('direct', '/api/v1/timelines/direct'); export const refreshAccountTimeline = accountId => refreshTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`); export const refreshAccountMediaTimeline = accountId => refreshTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true }); export const refreshHashtagTimeline = hashtag => refreshTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`); @@ -155,6 +156,7 @@ export function expandTimeline(timelineId, path, params = {}) { export const expandHomeTimeline = () => expandTimeline('home', '/api/v1/timelines/home'); export const expandPublicTimeline = () => expandTimeline('public', '/api/v1/timelines/public'); export const expandCommunityTimeline = () => expandTimeline('community', '/api/v1/timelines/public', { local: true }); +export const expandDirectTimeline = () => expandTimeline('direct', '/api/v1/timelines/direct'); export const expandAccountTimeline = accountId => expandTimeline(`account:${accountId}`, `/api/v1/accounts/${accountId}/statuses`); export const expandAccountMediaTimeline = accountId => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { only_media: true }); export const expandHashtagTimeline = hashtag => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`); diff --git a/app/javascript/mastodon/features/direct_timeline/containers/column_settings_container.js b/app/javascript/mastodon/features/direct_timeline/containers/column_settings_container.js new file mode 100644 index 000000000..1833f69e5 --- /dev/null +++ b/app/javascript/mastodon/features/direct_timeline/containers/column_settings_container.js @@ -0,0 +1,17 @@ +import { connect } from 'react-redux'; +import ColumnSettings from '../../community_timeline/components/column_settings'; +import { changeSetting } from '../../../actions/settings'; + +const mapStateToProps = state => ({ + settings: state.getIn(['settings', 'direct']), +}); + +const mapDispatchToProps = dispatch => ({ + + onChange (key, checked) { + dispatch(changeSetting(['direct', ...key], checked)); + }, + +}); + +export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings); diff --git a/app/javascript/mastodon/features/direct_timeline/index.js b/app/javascript/mastodon/features/direct_timeline/index.js new file mode 100644 index 000000000..05e092ee0 --- /dev/null +++ b/app/javascript/mastodon/features/direct_timeline/index.js @@ -0,0 +1,107 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import StatusListContainer from '../ui/containers/status_list_container'; +import Column from '../../components/column'; +import ColumnHeader from '../../components/column_header'; +import { + refreshDirectTimeline, + expandDirectTimeline, +} from '../../actions/timelines'; +import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; +import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; +import ColumnSettingsContainer from './containers/column_settings_container'; +import { connectDirectStream } from '../../actions/streaming'; + +const messages = defineMessages({ + title: { id: 'column.direct', defaultMessage: 'Direct messages' }, +}); + +const mapStateToProps = state => ({ + hasUnread: state.getIn(['timelines', 'direct', 'unread']) > 0, +}); + +@connect(mapStateToProps) +@injectIntl +export default class DirectTimeline extends React.PureComponent { + + static propTypes = { + dispatch: PropTypes.func.isRequired, + columnId: PropTypes.string, + intl: PropTypes.object.isRequired, + hasUnread: PropTypes.bool, + multiColumn: PropTypes.bool, + }; + + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('DIRECT', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + componentDidMount () { + const { dispatch } = this.props; + + dispatch(refreshDirectTimeline()); + this.disconnect = dispatch(connectDirectStream()); + } + + componentWillUnmount () { + if (this.disconnect) { + this.disconnect(); + this.disconnect = null; + } + } + + setRef = c => { + this.column = c; + } + + handleLoadMore = () => { + this.props.dispatch(expandDirectTimeline()); + } + + render () { + const { intl, hasUnread, columnId, multiColumn } = this.props; + const pinned = !!columnId; + + return ( + + + + + + } + /> + + ); + } + +} diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js index 973c8a4ae..94dabd4ad 100644 --- a/app/javascript/mastodon/features/getting_started/index.js +++ b/app/javascript/mastodon/features/getting_started/index.js @@ -16,6 +16,7 @@ const messages = defineMessages({ navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' }, settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' }, community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, + direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, @@ -65,18 +66,22 @@ export default class GettingStarted extends ImmutablePureComponent { } } + if (!multiColumn || !columns.find(item => item.get('id') === 'DIRECT')) { + navItems.push(); + } + navItems = navItems.concat([ - , - , + , + , ]); if (me.get('locked')) { - navItems.push(); + navItems.push(); } navItems = navItems.concat([ - , - , + , + , ]); return ( diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js index 5610095b9..ee1064229 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.js +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -11,7 +11,7 @@ import BundleContainer from '../containers/bundle_container'; import ColumnLoading from './column_loading'; import DrawerLoading from './drawer_loading'; import BundleColumnError from './bundle_column_error'; -import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses } from '../../ui/util/async-components'; +import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, DirectTimeline, FavouritedStatuses } from '../../ui/util/async-components'; import detectPassiveEvents from 'detect-passive-events'; import { scrollRight } from '../../../scroll'; @@ -23,6 +23,7 @@ const componentMap = { 'PUBLIC': PublicTimeline, 'COMMUNITY': CommunityTimeline, 'HASHTAG': HashtagTimeline, + 'DIRECT': DirectTimeline, 'FAVOURITES': FavouritedStatuses, }; diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 70e451373..cf51f0fb6 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -28,6 +28,7 @@ import { Following, Reblogs, Favourites, + DirectTimeline, HashtagTimeline, Notifications, FollowRequests, @@ -350,6 +351,7 @@ export default class UI extends React.Component { + diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 8f7b91d21..f86c2266c 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -26,6 +26,10 @@ export function HashtagTimeline () { return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline'); } +export function DirectTimeline() { + return import(/* webpackChunkName: "features/direct_timeline" */'../../direct_timeline'); +} + export function Status () { return import(/* webpackChunkName: "features/status" */'../../status'); } diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index f400b283f..ebb514e69 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -755,6 +755,19 @@ ], "path": "app/javascript/mastodon/features/compose/index.json" }, + { + "descriptors": [ + { + "defaultMessage": "Direct messages", + "id": "column.direct" + }, + { + "defaultMessage": "You don't have any direct messages yet. When you send or receive one, it will show up here.", + "id": "empty_column.direct" + } + ], + "path": "app/javascript/mastodon/features/direct_timeline/index.json" + }, { "descriptors": [ { @@ -816,6 +829,10 @@ "defaultMessage": "Local timeline", "id": "navigation_bar.community_timeline" }, + { + "defaultMessage": "Direct messages", + "id": "navigation_bar.direct" + }, { "defaultMessage": "Preferences", "id": "navigation_bar.preferences" diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 1d0bbcee5..efe0e1de9 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -28,6 +28,7 @@ "bundle_modal_error.retry": "Try again", "column.blocks": "Blocked users", "column.community": "Local timeline", + "column.direct": "Direct messages", "column.favourites": "Favourites", "column.follow_requests": "Follow requests", "column.home": "Home", @@ -80,6 +81,7 @@ "emoji_button.symbols": "Symbols", "emoji_button.travel": "Travel & Places", "empty_column.community": "The local timeline is empty. Write something publicly to get the ball rolling!", + "empty_column.direct": "You don't have any direct messages yet. When you send or receive one, it will show up here.", "empty_column.hashtag": "There is nothing in this hashtag yet.", "empty_column.home": "Your home timeline is empty! Visit {public} or use search to get started and meet other users.", "empty_column.home.public_timeline": "the public timeline", @@ -106,6 +108,7 @@ "missing_indicator.label": "Not found", "navigation_bar.blocks": "Blocked users", "navigation_bar.community_timeline": "Local timeline", + "navigation_bar.direct": "Direct messages", "navigation_bar.edit_profile": "Edit profile", "navigation_bar.favourites": "Favourites", "navigation_bar.follow_requests": "Follow requests", diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js index a9f3f9529..8b8bf165a 100644 --- a/app/javascript/mastodon/reducers/settings.js +++ b/app/javascript/mastodon/reducers/settings.js @@ -57,6 +57,12 @@ const initialState = ImmutableMap({ body: '', }), }), + + direct: ImmutableMap({ + regex: ImmutableMap({ + body: '', + }), + }), }); const defaultColumns = fromJS([ diff --git a/app/models/status.rb b/app/models/status.rb index 5a7245613..346282e2a 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -154,6 +154,14 @@ class Status < ApplicationRecord where(account: [account] + account.following).where(visibility: [:public, :unlisted, :private]) end + def as_direct_timeline(account) + query = joins("LEFT OUTER JOIN mentions ON statuses.id = mentions.status_id AND mentions.account_id = #{account.id}") + .where("mentions.account_id = #{account.id} OR statuses.account_id = #{account.id}") + .where(visibility: [:direct]) + + apply_timeline_filters(query, account, false) + end + def as_public_timeline(account = nil, local_only = false) query = timeline_scope(local_only).without_replies diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index 5d83771c9..aa2229f13 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -40,6 +40,7 @@ class BatchedRemoveStatusService < BaseService # Cannot be batched statuses.each do |status| unpush_from_public_timelines(status) + unpush_from_direct_timelines(status) if status.direct_visibility? batch_salmon_slaps(status) if status.local? end @@ -100,6 +101,16 @@ class BatchedRemoveStatusService < BaseService end end + def unpush_from_direct_timelines(status) + payload = @json_payloads[status.id] + redis.pipelined do + @mentions[status.id].each do |mention| + redis.publish("timeline:direct:#{mention.account.id}", payload) if mention.account.local? + end + redis.publish("timeline:direct:#{status.account.id}", payload) if status.account.local? + end + end + def batch_salmon_slaps(status) return if @mentions[status.id].empty? diff --git a/app/services/fan_out_on_write_service.rb b/app/services/fan_out_on_write_service.rb index 47a47a735..2214d73dd 100644 --- a/app/services/fan_out_on_write_service.rb +++ b/app/services/fan_out_on_write_service.rb @@ -10,15 +10,17 @@ class FanOutOnWriteService < BaseService deliver_to_self(status) if status.account.local? + render_anonymous_payload(status) + if status.direct_visibility? deliver_to_mentioned_followers(status) + deliver_to_direct_timelines(status) else deliver_to_followers(status) end return if status.account.silenced? || !status.public_visibility? || status.reblog? - render_anonymous_payload(status) deliver_to_hashtags(status) return if status.reply? && status.in_reply_to_account_id != status.account_id @@ -73,4 +75,13 @@ class FanOutOnWriteService < BaseService Redis.current.publish('timeline:public', @payload) Redis.current.publish('timeline:public:local', @payload) if status.local? end + + def deliver_to_direct_timelines(status) + Rails.logger.debug "Delivering status #{status.id} to direct timelines" + + status.mentions.includes(:account).each do |mention| + Redis.current.publish("timeline:direct:#{mention.account.id}", @payload) if mention.account.local? + end + Redis.current.publish("timeline:direct:#{status.account.id}", @payload) if status.account.local? + end end diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index 96d9208cc..8eef3e57e 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -18,6 +18,7 @@ class RemoveStatusService < BaseService remove_reblogs remove_from_hashtags remove_from_public + remove_from_direct if status.direct_visibility? @status.destroy! @@ -121,6 +122,13 @@ class RemoveStatusService < BaseService Redis.current.publish('timeline:public:local', @payload) if @status.local? end + def remove_from_direct + @mentions.each do |mention| + Redis.current.publish("timeline:direct:#{mention.account.id}", @payload) if mention.account.local? + end + Redis.current.publish("timeline:direct:#{@account.id}", @payload) if @account.local? + end + def redis Redis.current end diff --git a/config/routes.rb b/config/routes.rb index 5a6351f77..8263c477b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -193,6 +193,7 @@ Rails.application.routes.draw do end namespace :timelines do + resource :direct, only: :show, controller: :direct resource :home, only: :show, controller: :home resource :public, only: :show, controller: :public resources :tag, only: :show diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb index 9cb71d715..12e857169 100644 --- a/spec/models/status_spec.rb +++ b/spec/models/status_spec.rb @@ -232,6 +232,55 @@ RSpec.describe Status, type: :model do end end + describe '.as_direct_timeline' do + let(:account) { Fabricate(:account) } + let(:followed) { Fabricate(:account) } + let(:not_followed) { Fabricate(:account) } + + before do + Fabricate(:follow, account: account, target_account: followed) + + @self_public_status = Fabricate(:status, account: account, visibility: :public) + @self_direct_status = Fabricate(:status, account: account, visibility: :direct) + @followed_public_status = Fabricate(:status, account: followed, visibility: :public) + @followed_direct_status = Fabricate(:status, account: followed, visibility: :direct) + @not_followed_direct_status = Fabricate(:status, account: not_followed, visibility: :direct) + + @results = Status.as_direct_timeline(account) + end + + it 'does not include public statuses from self' do + expect(@results).to_not include(@self_public_status) + end + + it 'includes direct statuses from self' do + expect(@results).to include(@self_direct_status) + end + + it 'does not include public statuses from followed' do + expect(@results).to_not include(@followed_public_status) + end + + it 'includes direct statuses mentioning recipient from followed' do + Fabricate(:mention, account: account, status: @followed_direct_status) + expect(@results).to include(@followed_direct_status) + end + + it 'does not include direct statuses not mentioning recipient from followed' do + expect(@results).to_not include(@followed_direct_status) + end + + it 'includes direct statuses mentioning recipient from non-followed' do + Fabricate(:mention, account: account, status: @not_followed_direct_status) + expect(@results).to include(@not_followed_direct_status) + end + + it 'does not include direct statuses not mentioning recipient from non-followed' do + expect(@results).to_not include(@not_followed_direct_status) + end + + end + describe '.as_public_timeline' do it 'only includes statuses with public visibility' do public_status = Fabricate(:status, visibility: :public) diff --git a/streaming/index.js b/streaming/index.js index 83903b89b..8adc5174a 100644 --- a/streaming/index.js +++ b/streaming/index.js @@ -402,6 +402,10 @@ const startWorker = (workerId) => { streamFrom('timeline:public:local', req, streamToHttp(req, res), streamHttpEnd(req), true); }); + app.get('/api/v1/streaming/direct', (req, res) => { + streamFrom(`timeline:direct:${req.accountId}`, req, streamToHttp(req, res), streamHttpEnd(req), true); + }); + app.get('/api/v1/streaming/hashtag', (req, res) => { streamFrom(`timeline:hashtag:${req.query.tag.toLowerCase()}`, req, streamToHttp(req, res), streamHttpEnd(req), true); }); @@ -437,6 +441,9 @@ const startWorker = (workerId) => { case 'public:local': streamFrom('timeline:public:local', req, streamToWs(req, ws), streamWsEnd(req, ws), true); break; + case 'direct': + streamFrom(`timeline:direct:${req.accountId}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true); + break; case 'hashtag': streamFrom(`timeline:hashtag:${location.query.tag.toLowerCase()}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true); break; -- cgit From 49445150202f0bdaae942b9ae1ba44802a1c22e9 Mon Sep 17 00:00:00 2001 From: aschmitz Date: Thu, 9 Nov 2017 08:41:10 -0600 Subject: "Show reblogs" per-follower UI/database changes TODO: * Tests (particularly for FollowRequests). * Anything to respect the setting when putting reblogs in timelines. --- app/controllers/api/v1/accounts_controller.rb | 6 +++-- app/javascript/glitch/components/account/header.js | 2 +- app/javascript/mastodon/actions/accounts.js | 4 +-- app/javascript/mastodon/components/account.js | 2 +- .../features/account/components/action_bar.js | 12 +++++++++ .../features/account_timeline/components/header.js | 6 +++++ .../containers/header_container.js | 8 ++++++ app/models/concerns/account_interactions.rb | 23 +++++++++++++--- app/models/follow.rb | 1 + app/models/follow_request.rb | 3 ++- app/services/follow_service.rb | 31 ++++++++++++++++------ db/schema.rb | 4 ++- 12 files changed, 82 insertions(+), 20 deletions(-) (limited to 'app/services') diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 4676f60de..afdbf6e2d 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -13,9 +13,11 @@ class Api::V1::AccountsController < Api::BaseController end def follow - FollowService.new.call(current_user.account, @account.acct) + reblogs_arg = { reblogs: params[:reblogs] } + + FollowService.new.call(current_user.account, @account.acct, reblogs_arg) - options = @account.locked? ? {} : { following_map: { @account.id => true }, requested_map: { @account.id => false } } + options = @account.locked? ? {} : { following_map: reblogs_arg, requested_map: { @account.id => false } } render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options) end diff --git a/app/javascript/glitch/components/account/header.js b/app/javascript/glitch/components/account/header.js index f4a413aa3..c94fb0851 100644 --- a/app/javascript/glitch/components/account/header.js +++ b/app/javascript/glitch/components/account/header.js @@ -152,7 +152,7 @@ appropriate icon. diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index fbaebf786..cabf72bde 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -105,11 +105,11 @@ export function fetchAccountFail(id, error) { }; }; -export function followAccount(id) { +export function followAccount(id, reblogs = true) { return (dispatch, getState) => { dispatch(followAccountRequest(id)); - api(getState).post(`/api/v1/accounts/${id}/follow`).then(response => { + api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then(response => { dispatch(followAccountSuccess(response.data)); }).catch(error => { dispatch(followAccountFail(error)); diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js index 7cdb8c672..376e544fb 100644 --- a/app/javascript/mastodon/components/account.js +++ b/app/javascript/mastodon/components/account.js @@ -93,7 +93,7 @@ export default class Account extends ImmutablePureComponent { ); } else { - buttons = ; + buttons = ; } } diff --git a/app/javascript/mastodon/features/account/components/action_bar.js b/app/javascript/mastodon/features/account/components/action_bar.js index 2819ae252..718e7fbad 100644 --- a/app/javascript/mastodon/features/account/components/action_bar.js +++ b/app/javascript/mastodon/features/account/components/action_bar.js @@ -19,6 +19,8 @@ const messages = defineMessages({ media: { id: 'account.media', defaultMessage: 'Media' }, blockDomain: { id: 'account.block_domain', defaultMessage: 'Hide everything from {domain}' }, unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, + hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' }, + showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' }, }); @injectIntl @@ -30,6 +32,7 @@ export default class ActionBar extends React.PureComponent { onFollow: PropTypes.func, onBlock: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, + onReblogToggle: PropTypes.func.isRequired, onReport: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired, onBlockDomain: PropTypes.func.isRequired, @@ -60,6 +63,15 @@ export default class ActionBar extends React.PureComponent { if (account.get('id') === me) { menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }); } else { + const following = account.getIn(['relationship', 'following']); + if (following) { + if (following.get('reblogs')) { + menu.push({ text: intl.formatMessage(messages.hideReblogs, { name: account.get('username') }), action: this.props.onReblogToggle }); + } else { + menu.push({ text: intl.formatMessage(messages.showReblogs, { name: account.get('username') }), action: this.props.onReblogToggle }); + } + } + if (account.getIn(['relationship', 'muting'])) { menu.push({ text: intl.formatMessage(messages.unmute, { name: account.get('username') }), action: this.props.onMute }); } else { diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js index c3cd4e55d..b33df282f 100644 --- a/app/javascript/mastodon/features/account_timeline/components/header.js +++ b/app/javascript/mastodon/features/account_timeline/components/header.js @@ -14,6 +14,7 @@ export default class Header extends ImmutablePureComponent { onFollow: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, + onReblogToggle: PropTypes.func.isRequired, onReport: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired, onBlockDomain: PropTypes.func.isRequired, @@ -40,6 +41,10 @@ export default class Header extends ImmutablePureComponent { this.props.onReport(this.props.account); } + handleReblogToggle = () => { + this.props.onReblogToggle(this.props.account); + } + handleMute = () => { this.props.onMute(this.props.account); } @@ -80,6 +85,7 @@ export default class Header extends ImmutablePureComponent { me={me} onBlock={this.handleBlock} onMention={this.handleMention} + onReblogToggle={this.handleReblogToggle} onReport={this.handleReport} onMute={this.handleMute} onBlockDomain={this.handleBlockDomain} diff --git a/app/javascript/mastodon/features/account_timeline/containers/header_container.js b/app/javascript/mastodon/features/account_timeline/containers/header_container.js index 9ad13a231..68c037e9b 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js @@ -68,6 +68,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ dispatch(mentionCompose(account, router)); }, + onReblogToggle (account) { + if (account.getIn(['relationship', 'following', 'reblogs'])) { + dispatch(followAccount(account.get('id'), false)); + } else { + dispatch(followAccount(account.get('id'), true)); + } + }, + onReport (account) { dispatch(initReport(account)); }, diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index 0afdebf89..088fef4da 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -5,7 +5,11 @@ module AccountInteractions class_methods do def following_map(target_account_ids, account_id) - follow_mapping(Follow.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) + Follow.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |follow, mapping| + mapping[follow.target_account_id] = { + reblogs: follow.show_reblogs? + } + end end def followed_by_map(target_account_ids, account_id) @@ -25,7 +29,11 @@ module AccountInteractions end def requested_map(target_account_ids, account_id) - follow_mapping(FollowRequest.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) + FollowRequest.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |follow_request, mapping| + mapping[follow_request.target_account_id] = { + reblogs: follow_request.show_reblogs? + } + end end def domain_blocking_map(target_account_ids, account_id) @@ -66,8 +74,15 @@ module AccountInteractions has_many :domain_blocks, class_name: 'AccountDomainBlock', dependent: :destroy end - def follow!(other_account) - active_relationships.find_or_create_by!(target_account: other_account) + def follow!(other_account, reblogs: nil) + reblogs = true if reblogs.nil? + rel = active_relationships.create_with(show_reblogs: reblogs).find_or_create_by!(target_account: other_account) + if rel.show_reblogs != reblogs + rel.show_reblogs = reblogs + rel.save! + end + + rel end def block!(other_account) diff --git a/app/models/follow.rb b/app/models/follow.rb index 667720a88..a8ddcb7f0 100644 --- a/app/models/follow.rb +++ b/app/models/follow.rb @@ -8,6 +8,7 @@ # account_id :integer not null # id :integer not null, primary key # target_account_id :integer not null +# show_reblogs :boolean default(TRUE), not null # class Follow < ApplicationRecord diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb index 60036d903..0608ffabc 100644 --- a/app/models/follow_request.rb +++ b/app/models/follow_request.rb @@ -8,6 +8,7 @@ # account_id :integer not null # id :integer not null, primary key # target_account_id :integer not null +# show_reblogs :boolean default(TRUE), not null # class FollowRequest < ApplicationRecord @@ -21,7 +22,7 @@ class FollowRequest < ApplicationRecord validates :account_id, uniqueness: { scope: :target_account_id } def authorize! - account.follow!(target_account) + account.follow!(target_account, reblogs: reblogs) MergeWorker.perform_async(target_account.id, account.id) destroy! diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 791773f25..70572110d 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -6,25 +6,40 @@ class FollowService < BaseService # Follow a remote user, notify remote user about the follow # @param [Account] source_account From which to follow # @param [String, Account] uri User URI to follow in the form of username@domain (or account record) - def call(source_account, uri) + def call(source_account, uri, reblogs: nil) + reblogs = true if reblogs.nil? target_account = uri.is_a?(Account) ? uri : ResolveRemoteAccountService.new.call(uri) raise ActiveRecord::RecordNotFound if target_account.nil? || target_account.id == source_account.id || target_account.suspended? raise Mastodon::NotPermittedError if target_account.blocking?(source_account) || source_account.blocking?(target_account) - return if source_account.following?(target_account) || source_account.requested?(target_account) + if source_account.following?(target_account) + # We're already following this account, but we'll call follow! again to + # make sure the reblogs status is set correctly. + source_account.follow!(target_account, reblogs: reblogs) + return + elsif source_account.requested?(target_account) + # This isn't managed by a method in AccountInteractions, so we modify it + # ourselves if necessary. + req = follow_requests.find_by(target_account: other_account) + if req.show_reblogs != reblogs + req.show_reblogs = reblogs + req.save! + end + return + end if target_account.locked? || target_account.activitypub? - request_follow(source_account, target_account) + request_follow(source_account, target_account, reblogs: reblogs) else - direct_follow(source_account, target_account) + direct_follow(source_account, target_account, reblogs: reblogs) end end private - def request_follow(source_account, target_account) - follow_request = FollowRequest.create!(account: source_account, target_account: target_account) + def request_follow(source_account, target_account, reblogs: true) + follow_request = FollowRequest.create!(account: source_account, target_account: target_account, reblogs: reblogs) if target_account.local? NotifyService.new.call(target_account, follow_request) @@ -38,8 +53,8 @@ class FollowService < BaseService follow_request end - def direct_follow(source_account, target_account) - follow = source_account.follow!(target_account) + def direct_follow(source_account, target_account, reblogs: true) + follow = source_account.follow!(target_account, reblogs: reblogs) if target_account.local? NotifyService.new.call(target_account, follow) diff --git a/db/schema.rb b/db/schema.rb index f96a5340f..93505f9a0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20171021191900) do +ActiveRecord::Schema.define(version: 20171028221157) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -145,6 +145,7 @@ ActiveRecord::Schema.define(version: 20171021191900) do t.datetime "updated_at", null: false t.bigint "account_id", null: false t.bigint "target_account_id", null: false + t.boolean "show_reblogs", default: true, null: false t.index ["account_id", "target_account_id"], name: "index_follow_requests_on_account_id_and_target_account_id", unique: true end @@ -153,6 +154,7 @@ ActiveRecord::Schema.define(version: 20171021191900) do t.datetime "updated_at", null: false t.bigint "account_id", null: false t.bigint "target_account_id", null: false + t.boolean "show_reblogs", default: true, null: false t.index ["account_id", "target_account_id"], name: "index_follows_on_account_id_and_target_account_id", unique: true end -- cgit From b95c48748cd4e7a1181cdf3f17e23d6e526a9d95 Mon Sep 17 00:00:00 2001 From: aschmitz Date: Fri, 10 Nov 2017 20:11:10 -0600 Subject: Per-user reblog hiding implementation/fixes/tests Note that this will only hide/show *future* reblogs by a user, and does nothing to remove/add reblogs that are already in the timeline. I don't think that's a particularly confusing behavior, and it's a lot easier to implement (similar to mutes, I believe). --- app/models/concerns/account_interactions.rb | 6 ++- app/models/follow_request.rb | 2 +- app/services/follow_service.rb | 2 +- app/services/notify_service.rb | 2 +- .../20171028221157_add_reblogs_to_follows.rb | 21 ++++++++ .../v1/accounts/relationships_controller_spec.rb | 4 +- .../controllers/api/v1/accounts_controller_spec.rb | 8 +-- spec/models/concerns/account_interactions_spec.rb | 37 ++++++++++++++ spec/models/follow_request_spec.rb | 24 ++++++++- spec/services/follow_service_spec.rb | 58 ++++++++++++++++++++-- spec/services/notify_service_spec.rb | 20 ++++++++ 11 files changed, 170 insertions(+), 14 deletions(-) create mode 100644 db/migrate/20171028221157_add_reblogs_to_follows.rb (limited to 'app/services') diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index 088fef4da..60fd6ded5 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -81,7 +81,7 @@ module AccountInteractions rel.show_reblogs = reblogs rel.save! end - + rel end @@ -156,6 +156,10 @@ module AccountInteractions mute_relationships.where(target_account: other_account, hide_notifications: true).exists? end + def muting_reblogs?(other_account) + active_relationships.where(target_account: other_account, show_reblogs: false).exists? + end + def requested?(other_account) follow_requests.where(target_account: other_account).exists? end diff --git a/app/models/follow_request.rb b/app/models/follow_request.rb index 0608ffabc..1a1c52382 100644 --- a/app/models/follow_request.rb +++ b/app/models/follow_request.rb @@ -22,7 +22,7 @@ class FollowRequest < ApplicationRecord validates :account_id, uniqueness: { scope: :target_account_id } def authorize! - account.follow!(target_account, reblogs: reblogs) + account.follow!(target_account, reblogs: show_reblogs) MergeWorker.perform_async(target_account.id, account.id) destroy! diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 70572110d..6db591999 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -39,7 +39,7 @@ class FollowService < BaseService private def request_follow(source_account, target_account, reblogs: true) - follow_request = FollowRequest.create!(account: source_account, target_account: target_account, reblogs: reblogs) + follow_request = FollowRequest.create!(account: source_account, target_account: target_account, show_reblogs: reblogs) if target_account.local? NotifyService.new.call(target_account, follow_request) diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index fb09df983..3fa3f152c 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -29,7 +29,7 @@ class NotifyService < BaseService end def blocked_reblog? - false + @recipient.muting_reblogs?(@notification.from_account) end def blocked_follow_request? diff --git a/db/migrate/20171028221157_add_reblogs_to_follows.rb b/db/migrate/20171028221157_add_reblogs_to_follows.rb new file mode 100644 index 000000000..eb4640a20 --- /dev/null +++ b/db/migrate/20171028221157_add_reblogs_to_follows.rb @@ -0,0 +1,21 @@ +require Rails.root.join('lib', 'mastodon', 'migration_helpers') + +class AddReblogsToFollows < ActiveRecord::Migration[5.1] + include Mastodon::MigrationHelpers + + safety_assured do + disable_ddl_transaction! + end + + def up + safety_assured do + add_column_with_default :follows, :show_reblogs, :boolean, default: true, allow_null: false + add_column_with_default :follow_requests, :show_reblogs, :boolean, default: true, allow_null: false + end + end + + def down + remove_column :follows, :show_reblogs + remove_column :follow_requests, :show_reblogs + end +end diff --git a/spec/controllers/api/v1/accounts/relationships_controller_spec.rb b/spec/controllers/api/v1/accounts/relationships_controller_spec.rb index 431fc2194..f25b86ac1 100644 --- a/spec/controllers/api/v1/accounts/relationships_controller_spec.rb +++ b/spec/controllers/api/v1/accounts/relationships_controller_spec.rb @@ -32,7 +32,7 @@ describe Api::V1::Accounts::RelationshipsController do json = body_as_json expect(json).to be_a Enumerable - expect(json.first[:following]).to be true + expect(json.first[:following]).to be_truthy expect(json.first[:followed_by]).to be false end end @@ -51,7 +51,7 @@ describe Api::V1::Accounts::RelationshipsController do expect(json).to be_a Enumerable expect(json.first[:id]).to eq simon.id.to_s - expect(json.first[:following]).to be true + expect(json.first[:following]).to be_truthy expect(json.first[:followed_by]).to be false expect(json.first[:muting]).to be false expect(json.first[:requested]).to be false diff --git a/spec/controllers/api/v1/accounts_controller_spec.rb b/spec/controllers/api/v1/accounts_controller_spec.rb index 053c53e5a..f3b879421 100644 --- a/spec/controllers/api/v1/accounts_controller_spec.rb +++ b/spec/controllers/api/v1/accounts_controller_spec.rb @@ -31,10 +31,10 @@ RSpec.describe Api::V1::AccountsController, type: :controller do expect(response).to have_http_status(:success) end - it 'returns JSON with following=true and requested=false' do + it 'returns JSON with following=truthy and requested=false' do json = body_as_json - expect(json[:following]).to be true + expect(json[:following]).to be_truthy expect(json[:requested]).to be false end @@ -50,11 +50,11 @@ RSpec.describe Api::V1::AccountsController, type: :controller do expect(response).to have_http_status(:success) end - it 'returns JSON with following=false and requested=true' do + it 'returns JSON with following=false and requested=truthy' do json = body_as_json expect(json[:following]).to be false - expect(json[:requested]).to be true + expect(json[:requested]).to be_truthy end it 'creates a follow request relation between user and target user' do diff --git a/spec/models/concerns/account_interactions_spec.rb b/spec/models/concerns/account_interactions_spec.rb index ef957fc1d..f47d9d057 100644 --- a/spec/models/concerns/account_interactions_spec.rb +++ b/spec/models/concerns/account_interactions_spec.rb @@ -37,4 +37,41 @@ describe AccountInteractions do end end end + + describe 'ignoring reblogs from an account' do + before do + @me = Fabricate(:account, username: 'Me') + @you = Fabricate(:account, username: 'You') + end + + context 'with the reblogs option unspecified' do + before do + @me.follow!(@you) + end + + it 'defaults to showing reblogs' do + expect(@me.muting_reblogs?(@you)).to be(false) + end + end + + context 'with the reblogs option set to false' do + before do + @me.follow!(@you, reblogs: false) + end + + it 'does mute reblogs' do + expect(@me.muting_reblogs?(@you)).to be(true) + end + end + + context 'with the reblogs option set to true' do + before do + @me.follow!(@you, reblogs: true) + end + + it 'does not mute reblogs' do + expect(@me.muting_reblogs?(@you)).to be(false) + end + end + end end diff --git a/spec/models/follow_request_spec.rb b/spec/models/follow_request_spec.rb index cc6f8ee62..62bd724d7 100644 --- a/spec/models/follow_request_spec.rb +++ b/spec/models/follow_request_spec.rb @@ -1,7 +1,29 @@ require 'rails_helper' RSpec.describe FollowRequest, type: :model do - describe '#authorize!' + describe '#authorize!' do + it 'generates a Follow' do + follow_request = Fabricate.create(:follow_request) + follow_request.authorize! + target = follow_request.target_account + expect(follow_request.account.following?(target)).to be true + end + + it 'correctly passes show_reblogs when true' do + follow_request = Fabricate.create(:follow_request, show_reblogs: true) + follow_request.authorize! + target = follow_request.target_account + expect(follow_request.account.muting_reblogs?(target)).to be false + end + + it 'correctly passes show_reblogs when false' do + follow_request = Fabricate.create(:follow_request, show_reblogs: false) + follow_request.authorize! + target = follow_request.target_account + expect(follow_request.account.muting_reblogs?(target)).to be true + end + end + describe '#reject!' describe 'validations' do diff --git a/spec/services/follow_service_spec.rb b/spec/services/follow_service_spec.rb index ceb39e5e6..e59a2f1a6 100644 --- a/spec/services/follow_service_spec.rb +++ b/spec/services/follow_service_spec.rb @@ -13,8 +13,20 @@ RSpec.describe FollowService do subject.call(sender, bob.acct) end - it 'creates a follow request' do - expect(FollowRequest.find_by(account: sender, target_account: bob)).to_not be_nil + it 'creates a follow request with reblogs' do + expect(FollowRequest.find_by(account: sender, target_account: bob, show_reblogs: true)).to_not be_nil + end + end + + describe 'locked account, no reblogs' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, locked: true, username: 'bob')).account } + + before do + subject.call(sender, bob.acct, reblogs: false) + end + + it 'creates a follow request without reblogs' do + expect(FollowRequest.find_by(account: sender, target_account: bob, show_reblogs: false)).to_not be_nil end end @@ -25,8 +37,22 @@ RSpec.describe FollowService do subject.call(sender, bob.acct) end - it 'creates a following relation' do + it 'creates a following relation with reblogs' do + expect(sender.following?(bob)).to be true + expect(sender.muting_reblogs?(bob)).to be false + end + end + + describe 'unlocked account, no reblogs' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + + before do + subject.call(sender, bob.acct, reblogs: false) + end + + it 'creates a following relation without reblogs' do expect(sender.following?(bob)).to be true + expect(sender.muting_reblogs?(bob)).to be true end end @@ -42,6 +68,32 @@ RSpec.describe FollowService do expect(sender.following?(bob)).to be true end end + + describe 'already followed account, turning reblogs off' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + + before do + sender.follow!(bob, reblogs: true) + subject.call(sender, bob.acct, reblogs: false) + end + + it 'disables reblogs' do + expect(sender.muting_reblogs?(bob)).to be true + end + end + + describe 'already followed account, turning reblogs on' do + let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob')).account } + + before do + sender.follow!(bob, reblogs: false) + subject.call(sender, bob.acct, reblogs: true) + end + + it 'disables reblogs' do + expect(sender.muting_reblogs?(bob)).to be false + end + end end context 'remote OStatus account' do diff --git a/spec/services/notify_service_spec.rb b/spec/services/notify_service_spec.rb index 7088ae9d1..250a880a2 100644 --- a/spec/services/notify_service_spec.rb +++ b/spec/services/notify_service_spec.rb @@ -48,6 +48,26 @@ RSpec.describe NotifyService do is_expected.to_not change(Notification, :count) end + describe 'reblogs' do + let(:status) { Fabricate(:status, account: Fabricate(:account)) } + let(:activity) { Fabricate(:status, account: sender, reblog: status) } + + it 'shows reblogs by default' do + recipient.follow!(sender) + is_expected.to change(Notification, :count) + end + + it 'shows reblogs when explicitly enabled' do + recipient.follow!(sender, reblogs: true) + is_expected.to change(Notification, :count) + end + + it 'hides reblogs when disabled' do + recipient.follow!(sender, reblogs: false) + is_expected.to_not change(Notification, :count) + end + end + context do let(:asshole) { Fabricate(:account, username: 'asshole') } let(:reply_to) { Fabricate(:status, account: asshole) } -- cgit From 5128c4261e8c067321e70ffda560f14036f56bb0 Mon Sep 17 00:00:00 2001 From: aschmitz Date: Sat, 11 Nov 2017 14:37:23 -0600 Subject: Updates per code review Thanks, @valerauko! --- app/controllers/api/v1/accounts_controller.rb | 2 +- app/models/concerns/account_interactions.rb | 5 +---- app/services/follow_service.rb | 6 ++---- 3 files changed, 4 insertions(+), 9 deletions(-) (limited to 'app/services') diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index afdbf6e2d..85eb2d60e 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -17,7 +17,7 @@ class Api::V1::AccountsController < Api::BaseController FollowService.new.call(current_user.account, @account.acct, reblogs_arg) - options = @account.locked? ? {} : { following_map: reblogs_arg, requested_map: { @account.id => false } } + options = @account.locked? ? {} : { following_map: { @account.id => reblogs_arg }, requested_map: { @account.id => false } } render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options) end diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index 60fd6ded5..a68f7c3d8 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -77,10 +77,7 @@ module AccountInteractions def follow!(other_account, reblogs: nil) reblogs = true if reblogs.nil? rel = active_relationships.create_with(show_reblogs: reblogs).find_or_create_by!(target_account: other_account) - if rel.show_reblogs != reblogs - rel.show_reblogs = reblogs - rel.save! - end + rel.update!(show_reblogs: reblogs) rel end diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index 6db591999..20579ca63 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -6,6 +6,7 @@ class FollowService < BaseService # Follow a remote user, notify remote user about the follow # @param [Account] source_account From which to follow # @param [String, Account] uri User URI to follow in the form of username@domain (or account record) + # @param [true, false, nil] reblogs Whether or not to show reblogs, defaults to true def call(source_account, uri, reblogs: nil) reblogs = true if reblogs.nil? target_account = uri.is_a?(Account) ? uri : ResolveRemoteAccountService.new.call(uri) @@ -22,10 +23,7 @@ class FollowService < BaseService # This isn't managed by a method in AccountInteractions, so we modify it # ourselves if necessary. req = follow_requests.find_by(target_account: other_account) - if req.show_reblogs != reblogs - req.show_reblogs = reblogs - req.save! - end + req.update!(show_reblogs: reblogs) return end -- cgit