From 9fdfc2d8d517a3085182997f2ad189a3108af0b1 Mon Sep 17 00:00:00 2001 From: noellabo Date: Thu, 12 Dec 2019 20:02:35 +0900 Subject: [PATCH] Add account subscribe support to WebUI --- app/chewy/accounts_index.rb | 1 + .../api/v1/account_subscribes_controller.rb | 43 ----- .../subscribing_accounts_controller.rb | 64 +++++++ app/controllers/api/v1/accounts_controller.rb | 14 +- .../settings/preferences_controller.rb | 1 + app/javascript/mastodon/actions/accounts.js | 181 ++++++++++++++++++ app/javascript/mastodon/components/account.js | 27 ++- .../mastodon/containers/account_container.js | 21 +- .../features/account/components/header.js | 28 +++ .../account_timeline/components/header.js | 6 + .../containers/header_container.js | 21 +- .../directory/components/account_card.js | 72 +++++-- .../mastodon/features/subscribing/index.js | 103 ++++++++++ app/javascript/mastodon/features/ui/index.js | 2 + .../features/ui/util/async-components.js | 4 + app/javascript/mastodon/initial_state.js | 1 + app/javascript/mastodon/locales/en.json | 6 + app/javascript/mastodon/locales/ja.json | 6 + app/javascript/mastodon/reducers/accounts.js | 1 + .../mastodon/reducers/accounts_counters.js | 1 + .../mastodon/reducers/relationships.js | 16 ++ app/javascript/mastodon/reducers/timelines.js | 2 + .../mastodon/reducers/user_lists.js | 17 ++ .../styles/mastodon/components.scss | 7 +- app/lib/user_settings_decorator.rb | 5 + app/models/account_stat.rb | 1 + app/models/account_subscribe.rb | 16 ++ app/models/concerns/account_counters.rb | 2 + app/models/concerns/account_interactions.rb | 10 +- .../concerns/status_threading_concern.rb | 1 + app/models/export.rb | 4 + app/models/user.rb | 2 +- .../account_relationships_presenter.rb | 6 +- app/serializers/initial_state_serializer.rb | 1 + app/serializers/rest/account_serializer.rb | 2 +- .../rest/relationship_serializer.rb | 6 +- app/services/account_subscribe_service.rb | 7 +- app/services/delete_account_service.rb | 1 + app/services/follow_service.rb | 1 - app/services/search_service.rb | 1 + .../preferences/appearance/show.html.haml | 1 + config/locales/simple_form.en.yml | 1 + config/locales/simple_form.ja.yml | 1 + config/routes.rb | 4 +- config/settings.yml | 1 + ...8_add_subscribing_count_to_account_stat.rb | 5 + db/schema.rb | 1 + lib/mastodon/cache_cli.rb | 9 +- spec/lib/settings/scoped_settings_spec.rb | 2 +- spec/lib/user_settings_decorator_spec.rb | 7 + 50 files changed, 658 insertions(+), 85 deletions(-) delete mode 100644 app/controllers/api/v1/account_subscribes_controller.rb create mode 100644 app/controllers/api/v1/accounts/subscribing_accounts_controller.rb create mode 100644 app/javascript/mastodon/features/subscribing/index.js create mode 100644 db/migrate/20191214025518_add_subscribing_count_to_account_stat.rb diff --git a/app/chewy/accounts_index.rb b/app/chewy/accounts_index.rb index b814e009e..4ef383c12 100644 --- a/app/chewy/accounts_index.rb +++ b/app/chewy/accounts_index.rb @@ -37,6 +37,7 @@ class AccountsIndex < Chewy::Index field :following_count, type: 'long', value: ->(account) { account.following.local.count } field :followers_count, type: 'long', value: ->(account) { account.followers.local.count } + field :subscribing_count, type: 'long', value: ->(account) { account.subscribing.local.count } field :last_status_at, type: 'date', value: ->(account) { account.last_status_at || account.created_at } end end diff --git a/app/controllers/api/v1/account_subscribes_controller.rb b/app/controllers/api/v1/account_subscribes_controller.rb deleted file mode 100644 index 5da428bbd..000000000 --- a/app/controllers/api/v1/account_subscribes_controller.rb +++ /dev/null @@ -1,43 +0,0 @@ -# frozen_string_literal: true - -class Api::V1::AccountSubscribesController < Api::BaseController - before_action -> { doorkeeper_authorize! :read, :'read:follows' }, only: [:index, :show] - before_action -> { doorkeeper_authorize! :write, :'write:follows' }, except: [:index, :show] - - before_action :require_user! - before_action :set_account_subscribe, except: [:index, :create] - - def index - @account_subscribes = AccountSubscribe.where(account: current_account).all - render json: @account_subscribes, each_serializer: REST::AccountSubscribeSerializer - end - - def show - render json: @account_subscribe, serializer: REST::AccountSubscribeSerializer - end - - def create - @account_subscribe = AccountSubscribe.create!(account_subscribe_params.merge(account: current_account)) - render json: @account_subscribe, serializer: REST::AccountSubscribeSerializer - end - - def update - @account_subscribe.update!(account_subscribe_params) - render json: @account_subscribe, serializer: REST::AccountSubscribeSerializer - end - - def destroy - @account_subscribe.destroy! - render_empty - end - - private - - def set_account_subscribe - @account_subscribe = AccountSubscribe.where(account: current_account).find(params[:id]) - end - - def account_subscribe_params - params.permit(:acct) - end -end diff --git a/app/controllers/api/v1/accounts/subscribing_accounts_controller.rb b/app/controllers/api/v1/accounts/subscribing_accounts_controller.rb new file mode 100644 index 000000000..3fbfcc70d --- /dev/null +++ b/app/controllers/api/v1/accounts/subscribing_accounts_controller.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +class Api::V1::Accounts::SubscribingAccountsController < Api::BaseController + before_action -> { doorkeeper_authorize! :read, :'read:accounts' } + before_action :require_user! + after_action :insert_pagination_headers + + respond_to :json + + def index + @accounts = load_accounts + render json: @accounts, each_serializer: REST::AccountSerializer + end + + private + + def load_accounts + default_accounts.merge(paginated_subscribings).to_a + end + + def default_accounts + Account.includes(:passive_subscribes, :account_stat).references(:passive_subscribes) + end + + def paginated_subscribings + AccountSubscribe.where(account_id: current_user.account_id).paginate_by_max_id( + limit_param(DEFAULT_ACCOUNTS_LIMIT), + params[:max_id], + params[:since_id] + ) + end + + def insert_pagination_headers + set_pagination_headers(next_path, prev_path) + end + + def next_path + if records_continue? + api_v1_accounts_subscribing_index_url pagination_params(max_id: pagination_max_id) + end + end + + def prev_path + unless @accounts.empty? + api_v1_accounts_subscribing_index_url pagination_params(since_id: pagination_since_id) + end + end + + def pagination_max_id + @accounts.last.passive_subscribes.first.id + end + + def pagination_since_id + @accounts.first.passive_subscribes.first.id + end + + def records_continue? + @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end +end diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb index 95869f554..fe3218815 100644 --- a/app/controllers/api/v1/accounts_controller.rb +++ b/app/controllers/api/v1/accounts_controller.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true class Api::V1::AccountsController < Api::BaseController - before_action -> { authorize_if_got_token! :read, :'read:accounts' }, except: [:create, :follow, :unfollow, :block, :unblock, :mute, :unmute] - before_action -> { doorkeeper_authorize! :follow, :'write:follows' }, only: [:follow, :unfollow] + before_action -> { authorize_if_got_token! :read, :'read:accounts' }, except: [:create, :follow, :unfollow, :subscribe, :unsubscribe, :block, :unblock, :mute, :unmute] + before_action -> { doorkeeper_authorize! :follow, :'write:follows' }, only: [:follow, :unfollow, :subscribe, :unsubscribe] before_action -> { doorkeeper_authorize! :follow, :'write:mutes' }, only: [:mute, :unmute] before_action -> { doorkeeper_authorize! :follow, :'write:blocks' }, only: [:block, :unblock] before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: [:create] @@ -38,6 +38,11 @@ class Api::V1::AccountsController < Api::BaseController render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(**options) end + def subscribe + AccountSubscribeService.new.call(current_user.account, @account) + render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships + end + def block BlockService.new.call(current_user.account, @account) render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships @@ -53,6 +58,11 @@ class Api::V1::AccountsController < Api::BaseController render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships end + def unsubscribe + UnsubscribeAccountService.new.call(current_user.account, @account) + render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships + end + def unblock UnblockService.new.call(current_user.account, @account) render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 32b5d7948..3f48e2ba8 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -37,6 +37,7 @@ class Settings::PreferencesController < Settings::BaseController :setting_default_sensitive, :setting_default_language, :setting_unfollow_modal, + :setting_unsubscribe_modal, :setting_boost_modal, :setting_delete_modal, :setting_auto_play_gif, diff --git a/app/javascript/mastodon/actions/accounts.js b/app/javascript/mastodon/actions/accounts.js index 58b636602..3d29916f4 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -13,6 +13,14 @@ export const ACCOUNT_UNFOLLOW_REQUEST = 'ACCOUNT_UNFOLLOW_REQUEST'; export const ACCOUNT_UNFOLLOW_SUCCESS = 'ACCOUNT_UNFOLLOW_SUCCESS'; export const ACCOUNT_UNFOLLOW_FAIL = 'ACCOUNT_UNFOLLOW_FAIL'; +export const ACCOUNT_SUBSCRIBE_REQUEST = 'ACCOUNT_SUBSCRIBE_REQUEST'; +export const ACCOUNT_SUBSCRIBE_SUCCESS = 'ACCOUNT_SUBSCRIBE_SUCCESS'; +export const ACCOUNT_SUBSCRIBE_FAIL = 'ACCOUNT_SUBSCRIBE_FAIL'; + +export const ACCOUNT_UNSUBSCRIBE_REQUEST = 'ACCOUNT_UNSUBSCRIBE_REQUEST'; +export const ACCOUNT_UNSUBSCRIBE_SUCCESS = 'ACCOUNT_UNSUBSCRIBE_SUCCESS'; +export const ACCOUNT_UNSUBSCRIBE_FAIL = 'ACCOUNT_UNSUBSCRIBE_FAIL'; + export const ACCOUNT_BLOCK_REQUEST = 'ACCOUNT_BLOCK_REQUEST'; export const ACCOUNT_BLOCK_SUCCESS = 'ACCOUNT_BLOCK_SUCCESS'; export const ACCOUNT_BLOCK_FAIL = 'ACCOUNT_BLOCK_FAIL'; @@ -53,6 +61,14 @@ export const FOLLOWING_EXPAND_REQUEST = 'FOLLOWING_EXPAND_REQUEST'; export const FOLLOWING_EXPAND_SUCCESS = 'FOLLOWING_EXPAND_SUCCESS'; export const FOLLOWING_EXPAND_FAIL = 'FOLLOWING_EXPAND_FAIL'; +export const SUBSCRIBING_FETCH_REQUEST = 'SUBSCRIBING_FETCH_REQUEST'; +export const SUBSCRIBING_FETCH_SUCCESS = 'SUBSCRIBING_FETCH_SUCCESS'; +export const SUBSCRIBING_FETCH_FAIL = 'SUBSCRIBING_FETCH_FAIL'; + +export const SUBSCRIBING_EXPAND_REQUEST = 'SUBSCRIBING_EXPAND_REQUEST'; +export const SUBSCRIBING_EXPAND_SUCCESS = 'SUBSCRIBING_EXPAND_SUCCESS'; +export const SUBSCRIBING_EXPAND_FAIL = 'SUBSCRIBING_EXPAND_FAIL'; + export const RELATIONSHIPS_FETCH_REQUEST = 'RELATIONSHIPS_FETCH_REQUEST'; export const RELATIONSHIPS_FETCH_SUCCESS = 'RELATIONSHIPS_FETCH_SUCCESS'; export const RELATIONSHIPS_FETCH_FAIL = 'RELATIONSHIPS_FETCH_FAIL'; @@ -188,6 +204,85 @@ export function unfollowAccountFail(error) { }; }; +export function subscribeAccount(id, reblogs = true) { + return (dispatch, getState) => { + const alreadySubscribe = getState().getIn(['relationships', id, 'subscribing']); + const locked = getState().getIn(['accounts', id, 'locked'], false); + + dispatch(subscribeAccountRequest(id, locked)); + + api(getState).post(`/api/v1/accounts/${id}/subscribe`).then(response => { + dispatch(subscribeAccountSuccess(response.data, alreadySubscribe)); + }).catch(error => { + dispatch(subscribeAccountFail(error, locked)); + }); + }; +}; + +export function unsubscribeAccount(id) { + return (dispatch, getState) => { + dispatch(unsubscribeAccountRequest(id)); + + api(getState).post(`/api/v1/accounts/${id}/unsubscribe`).then(response => { + dispatch(unsubscribeAccountSuccess(response.data, getState().get('statuses'))); + }).catch(error => { + dispatch(unsubscribeAccountFail(error)); + }); + }; +}; + +export function subscribeAccountRequest(id, locked) { + return { + type: ACCOUNT_SUBSCRIBE_REQUEST, + id, + locked, + skipLoading: true, + }; +}; + +export function subscribeAccountSuccess(relationship, alreadySubscribe) { + return { + type: ACCOUNT_SUBSCRIBE_SUCCESS, + relationship, + alreadySubscribe, + skipLoading: true, + }; +}; + +export function subscribeAccountFail(error, locked) { + return { + type: ACCOUNT_SUBSCRIBE_FAIL, + error, + locked, + skipLoading: true, + }; +}; + +export function unsubscribeAccountRequest(id) { + return { + type: ACCOUNT_UNSUBSCRIBE_REQUEST, + id, + skipLoading: true, + }; +}; + +export function unsubscribeAccountSuccess(relationship, statuses) { + return { + type: ACCOUNT_UNSUBSCRIBE_SUCCESS, + relationship, + statuses, + skipLoading: true, + }; +}; + +export function unsubscribeAccountFail(error) { + return { + type: ACCOUNT_UNSUBSCRIBE_FAIL, + error, + skipLoading: true, + }; +}; + export function blockAccount(id) { return (dispatch, getState) => { dispatch(blockAccountRequest(id)); @@ -500,6 +595,92 @@ export function expandFollowingFail(id, error) { }; }; +export function fetchSubscribing(id) { + return (dispatch, getState) => { + dispatch(fetchSubscribeRequest(id)); + + api(getState).get(`/api/v1/accounts/subscribing`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchSubscribeSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => { + dispatch(fetchSubscribeFail(id, error)); + }); + }; +}; + +export function fetchSubscribeRequest(id) { + return { + type: SUBSCRIBING_FETCH_REQUEST, + id, + }; +}; + +export function fetchSubscribeSuccess(id, accounts, next) { + return { + type: SUBSCRIBING_FETCH_SUCCESS, + id, + accounts, + next, + }; +}; + +export function fetchSubscribeFail(id, error) { + return { + type: SUBSCRIBING_FETCH_FAIL, + id, + error, + }; +}; + +export function expandSubscribing(id) { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'subscribing', id, 'next']); + + if (url === null) { + return; + } + + dispatch(expandSubscribeRequest(id)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(expandSubscribeSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => { + dispatch(expandSubscribeFail(id, error)); + }); + }; +}; + +export function expandSubscribeRequest(id) { + return { + type: SUBSCRIBING_EXPAND_REQUEST, + id, + }; +}; + +export function expandSubscribeSuccess(id, accounts, next) { + return { + type: SUBSCRIBING_EXPAND_SUCCESS, + id, + accounts, + next, + }; +}; + +export function expandSubscribeFail(id, error) { + return { + type: SUBSCRIBING_EXPAND_FAIL, + id, + error, + }; +}; + export function fetchRelationships(accountIds) { return (dispatch, getState) => { const loadedRelationships = getState().get('relationships'); diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js index a85d683a7..cd3cebb14 100644 --- a/app/javascript/mastodon/components/account.js +++ b/app/javascript/mastodon/components/account.js @@ -13,6 +13,8 @@ import RelativeTimestamp from './relative_timestamp'; const messages = defineMessages({ follow: { id: 'account.follow', defaultMessage: 'Follow' }, unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + unsubscribe: { id: 'account.unsubscribe', defaultMessage: 'Unsubscribe' }, + subscribe: { id: 'account.subscribe', defaultMessage: 'Subscribe' }, requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, @@ -26,6 +28,7 @@ class Account extends ImmutablePureComponent { static propTypes = { account: ImmutablePropTypes.map.isRequired, onFollow: PropTypes.func.isRequired, + onSubscribe: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired, onMuteNotifications: PropTypes.func.isRequired, @@ -40,6 +43,10 @@ class Account extends ImmutablePureComponent { this.props.onFollow(this.props.account); } + handleSubscribe = () => { + this.props.onSubscribe(this.props.account); + } + handleBlock = () => { this.props.onBlock(this.props.account); } @@ -83,10 +90,11 @@ class Account extends ImmutablePureComponent { buttons = ; } } else if (account.get('id') !== me && account.get('relationship', null) !== null) { - const following = account.getIn(['relationship', 'following']); - const requested = account.getIn(['relationship', 'requested']); - const blocking = account.getIn(['relationship', 'blocking']); - const muting = account.getIn(['relationship', 'muting']); + const following = account.getIn(['relationship', 'following']); + const subscribing = account.getIn(['relationship', 'subscribing']); + const requested = account.getIn(['relationship', 'requested']); + const blocking = account.getIn(['relationship', 'blocking']); + const muting = account.getIn(['relationship', 'muting']); if (requested) { buttons = ; @@ -105,8 +113,15 @@ class Account extends ImmutablePureComponent { {hidingNotificationsButton} ); - } else if (!account.get('moved') || following) { - buttons = ; + } else { + let following_buttons, subscribing_buttons; + if (!account.get('moved') || subscribing ) { + subscribing_buttons = ; + } + if (!account.get('moved') || following) { + following_buttons = ; + } + buttons = {subscribing_buttons}{following_buttons} } } diff --git a/app/javascript/mastodon/containers/account_container.js b/app/javascript/mastodon/containers/account_container.js index 5a5136dd1..ac69b9150 100644 --- a/app/javascript/mastodon/containers/account_container.js +++ b/app/javascript/mastodon/containers/account_container.js @@ -6,6 +6,8 @@ import Account from '../components/account'; import { followAccount, unfollowAccount, + subscribeAccount, + unsubscribeAccount, blockAccount, unblockAccount, muteAccount, @@ -13,10 +15,11 @@ import { } from '../actions/accounts'; import { openModal } from '../actions/modal'; import { initMuteModal } from '../actions/mutes'; -import { unfollowModal } from '../initial_state'; +import { unfollowModal, unsubscribeModal } from '../initial_state'; const messages = defineMessages({ unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, + unsubscribeConfirm: { id: 'confirmations.unsubscribe.confirm', defaultMessage: 'Unsubscribe' }, }); const makeMapStateToProps = () => { @@ -47,6 +50,22 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, + onSubscribe (account) { + if (account.getIn(['relationship', 'subscribing'])) { + if (unsubscribeModal) { + dispatch(openModal('CONFIRM', { + message: @{account.get('acct')} }} />, + confirm: intl.formatMessage(messages.unsubscribeConfirm), + onConfirm: () => dispatch(unsubscribeAccount(account.get('id'))), + })); + } else { + dispatch(unsubscribeAccount(account.get('id'))); + } + } else { + dispatch(subscribeAccount(account.get('id'))); + } + }, + onBlock (account) { if (account.getIn(['relationship', 'blocking'])) { dispatch(unblockAccount(account.get('id'))); diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index 20641121f..825f36214 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -18,6 +18,8 @@ import AccountNoteContainer from '../containers/account_note_container'; const messages = defineMessages({ unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' }, + unsubscribe: { id: 'account.unsubscribe', defaultMessage: 'Unsubscribe' }, + subscribe: { id: 'account.subscribe', defaultMessage: 'Subscribe' }, cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Cancel follow request' }, requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, @@ -68,6 +70,7 @@ class Header extends ImmutablePureComponent { account: ImmutablePropTypes.map, identity_props: ImmutablePropTypes.list, onFollow: PropTypes.func.isRequired, + onSubscribe: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, onDirect: PropTypes.func.isRequired, @@ -260,6 +263,22 @@ class Header extends ImmutablePureComponent { badge = null; } + const following = account.getIn(['relationship', 'following']); + const subscribing = account.getIn(['relationship', 'subscribing']); + const blockd_by = account.getIn(['relationship', 'blocked_by']); + let buttons; + + if(me !== account.get('id') && !blockd_by) { + let following_buttons, subscribing_buttons; + if(!account.get('moved') || subscribing) { + subscribing_buttons = ; + } + if(!account.get('moved') || following) { + following_buttons = ; + } + buttons = {subscribing_buttons}{following_buttons} + } + return (
@@ -293,6 +312,9 @@ class Header extends ImmutablePureComponent { {badge} @{acct} {lockedIcon} +
+ {buttons} +
@@ -352,6 +374,12 @@ class Header extends ImmutablePureComponent { renderer={counterRenderer('followers')} /> + + { (me === account.get('id')) && ( + + {shortNumberFormat(account.get('subscribing_count'))} + + )}
)}
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js index 6b52defe4..44f8361f3 100644 --- a/app/javascript/mastodon/features/account_timeline/components/header.js +++ b/app/javascript/mastodon/features/account_timeline/components/header.js @@ -13,6 +13,7 @@ export default class Header extends ImmutablePureComponent { account: ImmutablePropTypes.map, identity_proofs: ImmutablePropTypes.list, onFollow: PropTypes.func.isRequired, + onSubscribe: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired, onDirect: PropTypes.func.isRequired, @@ -35,6 +36,10 @@ export default class Header extends ImmutablePureComponent { this.props.onFollow(this.props.account); } + handleSubscribe = () => { + this.props.onSubscribe(this.props.account); + } + handleBlock = () => { this.props.onBlock(this.props.account); } @@ -106,6 +111,7 @@ export default class Header extends ImmutablePureComponent { account={account} identity_proofs={identity_proofs} onFollow={this.handleFollow} + onSubscribe={this.handleSubscribe} onBlock={this.handleBlock} onMention={this.handleMention} onDirect={this.handleDirect} 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 e12019547..d6b96eec3 100644 --- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js +++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js @@ -5,6 +5,8 @@ import Header from '../components/header'; import { followAccount, unfollowAccount, + subscribeAccount, + unsubscribeAccount, unblockAccount, unmuteAccount, pinAccount, @@ -20,11 +22,12 @@ import { initReport } from '../../../actions/reports'; import { openModal } from '../../../actions/modal'; import { blockDomain, unblockDomain } from '../../../actions/domain_blocks'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; -import { unfollowModal } from '../../../initial_state'; +import { unfollowModal, unsubscribeModal } from '../../../initial_state'; import { List as ImmutableList } from 'immutable'; const messages = defineMessages({ unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, + unsubscribeConfirm: { id: 'confirmations.unsubscribe.confirm', defaultMessage: 'Unsubscribe' }, blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Hide entire domain' }, }); @@ -58,6 +61,22 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, + onSubscribe (account) { + if (account.getIn(['relationship', 'subscribing'])) { + if (unsubscribeModal) { + dispatch(openModal('CONFIRM', { + message: @{account.get('acct')} }} />, + confirm: intl.formatMessage(messages.unsubscribeConfirm), + onConfirm: () => dispatch(unsubscribeAccount(account.get('id'))), + })); + } else { + dispatch(unsubscribeAccount(account.get('id'))); + } + } else { + dispatch(subscribeAccount(account.get('id'))); + } + }, + onBlock (account) { if (account.getIn(['relationship', 'blocking'])) { dispatch(unblockAccount(account.get('id'))); diff --git a/app/javascript/mastodon/features/directory/components/account_card.js b/app/javascript/mastodon/features/directory/components/account_card.js index 8f0e8db4b..4c4145ae4 100644 --- a/app/javascript/mastodon/features/directory/components/account_card.js +++ b/app/javascript/mastodon/features/directory/components/account_card.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { Fragment } from 'react'; import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; @@ -10,14 +10,16 @@ import Permalink from 'mastodon/components/permalink'; import RelativeTimestamp from 'mastodon/components/relative_timestamp'; import IconButton from 'mastodon/components/icon_button'; import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; -import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state'; +import { autoPlayGif, me, unfollowModal, unsubscribeModal } from 'mastodon/initial_state'; import ShortNumber from 'mastodon/components/short_number'; import { followAccount, unfollowAccount, + subscribeAccount, + unsubscribeAccount, blockAccount, unblockAccount, - unmuteAccount, + unmuteAccount } from 'mastodon/actions/accounts'; import { openModal } from 'mastodon/actions/modal'; import { initMuteModal } from 'mastodon/actions/mutes'; @@ -25,6 +27,8 @@ import { initMuteModal } from 'mastodon/actions/mutes'; const messages = defineMessages({ follow: { id: 'account.follow', defaultMessage: 'Follow' }, unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, + unsubscribe: { id: 'account.unsubscribe', defaultMessage: 'Unsubscribe' }, + subscribe: { id: 'account.subscribe', defaultMessage: 'Subscribe' }, requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, @@ -72,6 +76,22 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ } }, + onSubscribe(account) { + if (account.getIn(['relationship', 'subscribing'])) { + if (unsubscribeModal) { + dispatch(openModal('CONFIRM', { + message: @{account.get('acct')} }} />, + confirm: intl.formatMessage(messages.unsubscribeConfirm), + onConfirm: () => dispatch(unsubscribeAccount(account.get('id'))), + })); + } else { + dispatch(unsubscribeAccount(account.get('id'))); + } + } else { + dispatch(subscribeAccount(account.get('id'))); + } + }, + onBlock(account) { if (account.getIn(['relationship', 'blocking'])) { dispatch(unblockAccount(account.get('id'))); @@ -98,6 +118,7 @@ class AccountCard extends ImmutablePureComponent { account: ImmutablePropTypes.map.isRequired, intl: PropTypes.object.isRequired, onFollow: PropTypes.func.isRequired, + onSubscribe: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired, }; @@ -132,6 +153,10 @@ class AccountCard extends ImmutablePureComponent { this.props.onFollow(this.props.account); }; + handleSubscribe = () => { + this.props.onSubscribe(this.props.account); + } + handleBlock = () => { this.props.onBlock(this.props.account); }; @@ -150,6 +175,7 @@ class AccountCard extends ImmutablePureComponent { account.get('relationship', null) !== null ) { const following = account.getIn(['relationship', 'following']); + const subscribing = account.getIn(['relationship', 'subscribing']); const requested = account.getIn(['relationship', 'requested']); const blocking = account.getIn(['relationship', 'blocking']); const muting = account.getIn(['relationship', 'muting']); @@ -179,22 +205,38 @@ class AccountCard extends ImmutablePureComponent { active icon='volume-up' title={intl.formatMessage(messages.unmute, { - name: account.get('username'), + name: account.get('username') })} onClick={this.handleMute} /> ); - } else if (!account.get('moved') || following) { - buttons = ( - - ); + } else { + let following_buttons, subscribing_buttons; + if(!account.get('moved') || subscribing) { + subscribing_buttons = ( + + ); + } + if(!account.get('moved') || following) { + following_buttons = ( + + ); + } + buttons = {subscribing_buttons}{following_buttons} } } diff --git a/app/javascript/mastodon/features/subscribing/index.js b/app/javascript/mastodon/features/subscribing/index.js new file mode 100644 index 000000000..cdc6ae620 --- /dev/null +++ b/app/javascript/mastodon/features/subscribing/index.js @@ -0,0 +1,103 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import PropTypes from 'prop-types'; +import ImmutablePropTypes from 'react-immutable-proptypes'; +import { debounce } from 'lodash'; +import LoadingIndicator from '../../components/loading_indicator'; +import { + fetchAccount, + fetchSubscribing, + expandSubscribing, +} from '../../actions/accounts'; +import { FormattedMessage } from 'react-intl'; +import AccountContainer from '../../containers/account_container'; +import Column from '../ui/components/column'; +import HeaderContainer from '../account_timeline/containers/header_container'; +import ColumnBackButton from '../../components/column_back_button'; +import ScrollableList from '../../components/scrollable_list'; +import MissingIndicator from 'mastodon/components/missing_indicator'; + +const mapStateToProps = (state, props) => ({ + isAccount: !!state.getIn(['accounts', props.params.accountId]), + accountIds: state.getIn(['user_lists', 'subscribing', props.params.accountId, 'items']), + hasMore: !!state.getIn(['user_lists', 'subscribing', props.params.accountId, 'next']), + isLoading: state.getIn(['user_lists', 'subscribing', props.params.accountId, 'isLoading'], true), + blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false), +}); + +export default @connect(mapStateToProps) +class Subscribing extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + hasMore: PropTypes.bool, + blockedBy: PropTypes.bool, + isAccount: PropTypes.bool, + multiColumn: PropTypes.bool, + }; + + componentWillMount () { + if (!this.props.accountIds) { + this.props.dispatch(fetchAccount(this.props.params.accountId)); + this.props.dispatch(fetchSubscribing(this.props.params.accountId)); + } + } + + componentWillReceiveProps (nextProps) { + if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) { + this.props.dispatch(fetchAccount(nextProps.params.accountId)); + this.props.dispatch(fetchSubscribing(nextProps.params.accountId)); + } + } + + handleLoadMore = debounce(() => { + this.props.dispatch(expandSubscribing()); + }, 300, { leading: true }); + + render () { + const { accountIds, hasMore, blockedBy, isAccount, multiColumn, isLoading } = this.props; + + if (!isAccount) { + return ( + + + + ); + } + + if (!accountIds) { + return ( + + + + ); + } + + const emptyMessage = blockedBy ? : ; + + return ( + + + + } + alwaysPrepend + emptyMessage={emptyMessage} + bindToDocument={!multiColumn} + > + {blockedBy ? [] : accountIds.map(id => + + )} + + + ); + } + +} diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 44a4839f9..c33f3e757 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -35,6 +35,7 @@ import { HomeTimeline, Followers, Following, + Subscribing, Reblogs, Favourites, DirectTimeline, @@ -179,6 +180,7 @@ class SwitchingColumnsArea extends React.PureComponent { + diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 280ca3e57..17e31d1f6 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -74,6 +74,10 @@ export function Following () { return import(/* webpackChunkName: "features/following" */'../../following'); } +export function Subscribing () { + return import(/* webpackChunkName: "features/subscribing" */'../../subscribing'); +} + export function Reblogs () { return import(/* webpackChunkName: "features/reblogs" */'../../reblogs'); } diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js index 1389a3c3d..c68942118 100644 --- a/app/javascript/mastodon/initial_state.js +++ b/app/javascript/mastodon/initial_state.js @@ -8,6 +8,7 @@ export const autoPlayGif = getMeta('auto_play_gif'); export const displayMedia = getMeta('display_media'); export const expandSpoilers = getMeta('expand_spoilers'); export const unfollowModal = getMeta('unfollow_modal'); +export const unsubscribeModal = getMeta('unsubscribe_modal'); export const boostModal = getMeta('boost_modal'); export const deleteModal = getMeta('delete_modal'); export const me = getMeta('me'); diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index e5557160c..5305b236d 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -41,10 +41,14 @@ "account.share": "Share @{name}'s profile", "account.show_reblogs": "Show boosts from @{name}", "account.statuses_counter": "{count, plural, one {{counter} Post} other {{counter} Posts}}", + "account.subscribe": "Subscribe", + "account.subscribes": "Subscribes", + "account.subscribes.empty": "This user doesn't subscribe anyone yet.", "account.unblock": "Unblock @{name}", "account.unblock_domain": "Unblock domain {domain}", "account.unendorse": "Don't feature on profile", "account.unfollow": "Unfollow", + "account.unsubscribe": "Unsubscribe", "account.unmute": "Unmute @{name}", "account.unmute_notifications": "Unmute notifications from @{name}", "account_note.placeholder": "Click to add note", @@ -129,6 +133,8 @@ "confirmations.reply.message": "Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?", "confirmations.unfollow.confirm": "Unfollow", "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?", + "confirmations.unsubscribe.confirm": "Unsubscribe", + "confirmations.unsubscribe.message": "Are you sure you want to unsubscribe {name}?", "conversation.delete": "Delete conversation", "conversation.mark_as_read": "Mark as read", "conversation.open": "View conversation", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 7aba44396..b571c5f95 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -41,10 +41,14 @@ "account.share": "@{name}さんのプロフィールを共有する", "account.show_reblogs": "@{name}さんからのブーストを表示", "account.statuses_counter": "{counter} 投稿", + "account.subscribe": "購読", + "account.subscribes": "購読", + "account.subscribes.empty": "まだ誰も購読していません。", "account.unblock": "@{name}さんのブロックを解除", "account.unblock_domain": "{domain}のブロックを解除", "account.unendorse": "プロフィールから外す", "account.unfollow": "フォロー解除", + "account.unsubscribe": "購読解除", "account.unmute": "@{name}さんのミュートを解除", "account.unmute_notifications": "@{name}さんからの通知を受け取るようにする", "account_note.placeholder": "クリックしてメモを追加", @@ -129,6 +133,8 @@ "confirmations.reply.message": "今返信すると現在作成中のメッセージが上書きされます。本当に実行しますか?", "confirmations.unfollow.confirm": "フォロー解除", "confirmations.unfollow.message": "本当に{name}さんのフォローを解除しますか?", + "confirmations.unsubscribe.confirm": "購読解除", + "confirmations.unsubscribe.message": "本当に{name}さんの購読を解除しますか?", "conversation.delete": "会話を削除", "conversation.mark_as_read": "既読にする", "conversation.open": "会話を表示", diff --git a/app/javascript/mastodon/reducers/accounts.js b/app/javascript/mastodon/reducers/accounts.js index 530ed8e60..a5853b789 100644 --- a/app/javascript/mastodon/reducers/accounts.js +++ b/app/javascript/mastodon/reducers/accounts.js @@ -8,6 +8,7 @@ const normalizeAccount = (state, account) => { delete account.followers_count; delete account.following_count; + delete account.subscribing_count; delete account.statuses_count; return state.set(account.id, fromJS(account)); diff --git a/app/javascript/mastodon/reducers/accounts_counters.js b/app/javascript/mastodon/reducers/accounts_counters.js index 9ebf72af9..9a89544ef 100644 --- a/app/javascript/mastodon/reducers/accounts_counters.js +++ b/app/javascript/mastodon/reducers/accounts_counters.js @@ -8,6 +8,7 @@ import { Map as ImmutableMap, fromJS } from 'immutable'; const normalizeAccount = (state, account) => state.set(account.id, fromJS({ followers_count: account.followers_count, following_count: account.following_count, + subscribing_count: account.subscribing_count, statuses_count: account.statuses_count, })); diff --git a/app/javascript/mastodon/reducers/relationships.js b/app/javascript/mastodon/reducers/relationships.js index 53949258a..710770e73 100644 --- a/app/javascript/mastodon/reducers/relationships.js +++ b/app/javascript/mastodon/reducers/relationships.js @@ -5,6 +5,12 @@ import { ACCOUNT_UNFOLLOW_SUCCESS, ACCOUNT_UNFOLLOW_REQUEST, ACCOUNT_UNFOLLOW_FAIL, + ACCOUNT_SUBSCRIBE_SUCCESS, + ACCOUNT_SUBSCRIBE_REQUEST, + ACCOUNT_SUBSCRIBE_FAIL, + ACCOUNT_UNSUBSCRIBE_SUCCESS, + ACCOUNT_UNSUBSCRIBE_REQUEST, + ACCOUNT_UNSUBSCRIBE_FAIL, ACCOUNT_BLOCK_SUCCESS, ACCOUNT_UNBLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS, @@ -52,8 +58,18 @@ export default function relationships(state = initialState, action) { return state.setIn([action.id, 'following'], false); case ACCOUNT_UNFOLLOW_FAIL: return state.setIn([action.id, 'following'], true); + case ACCOUNT_SUBSCRIBE_REQUEST: + return state.setIn([action.id, 'subscribing'], true); + case ACCOUNT_SUBSCRIBE_FAIL: + return state.setIn([action.id, 'subscribing'], false); + case ACCOUNT_UNSUBSCRIBE_REQUEST: + return state.setIn([action.id, 'subscribing'], false); + case ACCOUNT_UNSUBSCRIBE_FAIL: + return state.setIn([action.id, 'subscribing'], true); case ACCOUNT_FOLLOW_SUCCESS: case ACCOUNT_UNFOLLOW_SUCCESS: + case ACCOUNT_SUBSCRIBE_SUCCESS: + case ACCOUNT_UNSUBSCRIBE_SUCCESS: case ACCOUNT_BLOCK_SUCCESS: case ACCOUNT_UNBLOCK_SUCCESS: case ACCOUNT_MUTE_SUCCESS: diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js index 9249227aa..ac77bc5a1 100644 --- a/app/javascript/mastodon/reducers/timelines.js +++ b/app/javascript/mastodon/reducers/timelines.js @@ -15,6 +15,7 @@ import { ACCOUNT_BLOCK_SUCCESS, ACCOUNT_MUTE_SUCCESS, ACCOUNT_UNFOLLOW_SUCCESS, + ACCOUNT_UNSUBSCRIBE_SUCCESS, } from '../actions/accounts'; import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; import compareId from '../compare_id'; @@ -158,6 +159,7 @@ export default function timelines(state = initialState, action) { case ACCOUNT_MUTE_SUCCESS: return filterTimelines(state, action.relationship, action.statuses); case ACCOUNT_UNFOLLOW_SUCCESS: + case ACCOUNT_UNSUBSCRIBE_SUCCESS: return filterTimeline('home', state, action.relationship, action.statuses); case TIMELINE_SCROLL_TOP: return updateTop(state, action.timeline, action.top); diff --git a/app/javascript/mastodon/reducers/user_lists.js b/app/javascript/mastodon/reducers/user_lists.js index 10aaa2d68..c737dd24a 100644 --- a/app/javascript/mastodon/reducers/user_lists.js +++ b/app/javascript/mastodon/reducers/user_lists.js @@ -15,6 +15,12 @@ import { FOLLOWING_EXPAND_SUCCESS, FOLLOWING_EXPAND_FAIL, FOLLOW_REQUESTS_FETCH_REQUEST, + SUBSCRIBING_FETCH_REQUEST, + SUBSCRIBING_FETCH_SUCCESS, + SUBSCRIBING_FETCH_FAIL, + SUBSCRIBING_EXPAND_REQUEST, + SUBSCRIBING_EXPAND_SUCCESS, + SUBSCRIBING_EXPAND_FAIL, FOLLOW_REQUESTS_FETCH_SUCCESS, FOLLOW_REQUESTS_FETCH_FAIL, FOLLOW_REQUESTS_EXPAND_REQUEST, @@ -62,6 +68,7 @@ const initialListState = ImmutableMap({ const initialState = ImmutableMap({ followers: initialListState, following: initialListState, + subscribing: initialListState, reblogged_by: initialListState, favourited_by: initialListState, follow_requests: initialListState, @@ -111,6 +118,16 @@ export default function userLists(state = initialState, action) { case FOLLOWING_FETCH_FAIL: case FOLLOWING_EXPAND_FAIL: return state.setIn(['following', action.id, 'isLoading'], false); + case SUBSCRIBING_FETCH_SUCCESS: + return normalizeList(state, 'subscribing', action.id, action.accounts, action.next); + case SUBSCRIBING_EXPAND_SUCCESS: + return appendToList(state, 'subscribing', action.id, action.accounts, action.next); + case SUBSCRIBING_FETCH_REQUEST: + case SUBSCRIBING_EXPAND_REQUEST: + return state.setIn(['subscribing', action.id, 'isLoading'], true); + case SUBSCRIBING_FETCH_FAIL: + case SUBSCRIBING_EXPAND_FAIL: + return state.setIn(['subscribing', action.id, 'isLoading'], false); case REBLOGS_FETCH_SUCCESS: return state.setIn(['reblogged_by', action.id], ImmutableList(action.accounts.map(item => item.id))); case FAVOURITES_FETCH_SUCCESS: diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 53fdd1025..d5175134e 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -5991,7 +5991,7 @@ a.status-card.compact:hover { } &__relationship { - width: 23px; + width: 46px; min-height: 1px; flex: 0 0 auto; } @@ -6761,6 +6761,7 @@ noscript { &__name { padding: 5px 10px; + display: flex; .account-role { vertical-align: top; @@ -6779,6 +6780,7 @@ noscript { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; + flex: 1 1 auto; small { display: block; @@ -6789,6 +6791,9 @@ noscript { text-overflow: ellipsis; } } + &__relationship { + flex: 0 0 auto; + } } .spacer { diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb index e37bc6d9f..6eb32c862 100644 --- a/app/lib/user_settings_decorator.rb +++ b/app/lib/user_settings_decorator.rb @@ -21,6 +21,7 @@ class UserSettingsDecorator user.settings['default_sensitive'] = default_sensitive_preference if change?('setting_default_sensitive') user.settings['default_language'] = default_language_preference if change?('setting_default_language') user.settings['unfollow_modal'] = unfollow_modal_preference if change?('setting_unfollow_modal') + user.settings['unsubscribe_modal'] = unsubscribe_modal_preference if change?('setting_unsubscribe_modal') user.settings['boost_modal'] = boost_modal_preference if change?('setting_boost_modal') user.settings['delete_modal'] = delete_modal_preference if change?('setting_delete_modal') user.settings['auto_play_gif'] = auto_play_gif_preference if change?('setting_auto_play_gif') @@ -61,6 +62,10 @@ class UserSettingsDecorator boolean_cast_setting 'setting_unfollow_modal' end + def unsubscribe_modal_preference + boolean_cast_setting 'setting_unsubscribe_modal' + end + def boost_modal_preference boolean_cast_setting 'setting_boost_modal' end diff --git a/app/models/account_stat.rb b/app/models/account_stat.rb index e702fa4a4..11e6318fe 100644 --- a/app/models/account_stat.rb +++ b/app/models/account_stat.rb @@ -8,6 +8,7 @@ # statuses_count :bigint(8) default(0), not null # following_count :bigint(8) default(0), not null # followers_count :bigint(8) default(0), not null +# subscribing_count :bigint(8) default(0), not null # created_at :datetime not null # updated_at :datetime not null # last_status_at :datetime diff --git a/app/models/account_subscribe.rb b/app/models/account_subscribe.rb index 097758786..e6663c61c 100644 --- a/app/models/account_subscribe.rb +++ b/app/models/account_subscribe.rb @@ -11,6 +11,9 @@ # class AccountSubscribe < ApplicationRecord + include Paginable + include RelationshipCacheable + belongs_to :account belongs_to :target_account, class_name: 'Account' belongs_to :list, optional: true @@ -20,4 +23,17 @@ class AccountSubscribe < ApplicationRecord scope :recent, -> { reorder(id: :desc) } scope :subscribed_lists, ->(account) { AccountSubscribe.where(target_account_id: account.id).where.not(list_id: nil).select(:list_id).uniq } + after_create :increment_cache_counters + after_destroy :decrement_cache_counters + + private + + def increment_cache_counters + account&.increment_count!(:subscribing_count) + end + + def decrement_cache_counters + account&.decrement_count!(:subscribing_count) + end + end diff --git a/app/models/concerns/account_counters.rb b/app/models/concerns/account_counters.rb index cf47ce9ca..355c9b98c 100644 --- a/app/models/concerns/account_counters.rb +++ b/app/models/concerns/account_counters.rb @@ -16,6 +16,8 @@ module AccountCounters :following_count=, :followers_count, :followers_count=, + :subscribing_count, + :subscribing_count=, :last_status_at, to: :account_stat diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index e30c7be44..fcbc69641 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -17,6 +17,10 @@ module AccountInteractions follow_mapping(Follow.where(account_id: target_account_ids, target_account_id: account_id), :account_id) end + def subscribing_map(target_account_ids, account_id) + follow_mapping(AccountSubscribe.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) + end + def blocking_map(target_account_ids, account_id) follow_mapping(Block.where(target_account_id: target_account_ids, account_id: account_id), :target_account_id) end @@ -191,7 +195,11 @@ module AccountInteractions end def subscribe!(other_account) - active_subscribes.find_or_create_by!(target_account: other_account) + rel = active_subscribes.find_or_create_by!(target_account: other_account) + + remove_potential_friendship(other_account) + + rel end def following?(other_account) diff --git a/app/models/concerns/status_threading_concern.rb b/app/models/concerns/status_threading_concern.rb index 5c04108e4..008dc1b60 100644 --- a/app/models/concerns/status_threading_concern.rb +++ b/app/models/concerns/status_threading_concern.rb @@ -122,6 +122,7 @@ module StatusThreadingConcern blocked_by: Account.blocked_by_map(account_ids, account.id), muting: Account.muting_map(account_ids, account.id), following: Account.following_map(account_ids, account.id), + subscribing: Account.subscribing_map(account_ids, account.id), domain_blocking_by_domain: Account.domain_blocking_map_by_domain(domains, account.id), } end diff --git a/app/models/export.rb b/app/models/export.rb index 5216eed5e..1abcfa144 100644 --- a/app/models/export.rb +++ b/app/models/export.rb @@ -71,6 +71,10 @@ class Export account.following_count end + def total_subscribes + account.subscribing_count + end + def total_lists account.owned_lists.count end diff --git a/app/models/user.rb b/app/models/user.rb index 4059c96b5..164631d16 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -121,7 +121,7 @@ class User < ApplicationRecord has_many :session_activations, dependent: :destroy - delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :delete_modal, + delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :unsubscribe_modal, :boost_modal, :delete_modal, :reduce_motion, :system_font_ui, :noindex, :theme, :display_media, :hide_network, :expand_spoilers, :default_language, :aggregate_reblogs, :show_application, :advanced_layout, :use_blurhash, :use_pending_items, :trends, :crop_images, diff --git a/app/presenters/account_relationships_presenter.rb b/app/presenters/account_relationships_presenter.rb index d662380f6..0423f1409 100644 --- a/app/presenters/account_relationships_presenter.rb +++ b/app/presenters/account_relationships_presenter.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class AccountRelationshipsPresenter - attr_reader :following, :followed_by, :blocking, :blocked_by, + attr_reader :following, :followed_by, :subscribing, :blocking, :blocked_by, :muting, :requested, :domain_blocking, :endorsed, :account_note @@ -11,6 +11,7 @@ class AccountRelationshipsPresenter @following = cached[:following].merge(Account.following_map(@uncached_account_ids, @current_account_id)) @followed_by = cached[:followed_by].merge(Account.followed_by_map(@uncached_account_ids, @current_account_id)) + @subscribing = cached[:subscribing].merge(Account.subscribing_map(@uncached_account_ids, @current_account_id)) @blocking = cached[:blocking].merge(Account.blocking_map(@uncached_account_ids, @current_account_id)) @blocked_by = cached[:blocked_by].merge(Account.blocked_by_map(@uncached_account_ids, @current_account_id)) @muting = cached[:muting].merge(Account.muting_map(@uncached_account_ids, @current_account_id)) @@ -23,6 +24,7 @@ class AccountRelationshipsPresenter @following.merge!(options[:following_map] || {}) @followed_by.merge!(options[:followed_by_map] || {}) + @subscribing.merge!(options[:subscribing_map] || {}) @blocking.merge!(options[:blocking_map] || {}) @blocked_by.merge!(options[:blocked_by_map] || {}) @muting.merge!(options[:muting_map] || {}) @@ -40,6 +42,7 @@ class AccountRelationshipsPresenter @cached = { following: {}, followed_by: {}, + subscribing: {}, blocking: {}, blocked_by: {}, muting: {}, @@ -69,6 +72,7 @@ class AccountRelationshipsPresenter maps_for_account = { following: { account_id => following[account_id] }, followed_by: { account_id => followed_by[account_id] }, + subscribing: { account_id => subscribing[account_id] }, blocking: { account_id => blocking[account_id] }, blocked_by: { account_id => blocked_by[account_id] }, muting: { account_id => muting[account_id] }, diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index b3b913946..aceca1dcc 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -28,6 +28,7 @@ class InitialStateSerializer < ActiveModel::Serializer if object.current_account store[:me] = object.current_account.id.to_s store[:unfollow_modal] = object.current_account.user.setting_unfollow_modal + store[:unsubscribe_modal] = object.current_account.user.setting_unsubscribe_modal store[:boost_modal] = object.current_account.user.setting_boost_modal store[:delete_modal] = object.current_account.user.setting_delete_modal store[:auto_play_gif] = object.current_account.user.setting_auto_play_gif diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb index a78ec4507..61417f03c 100644 --- a/app/serializers/rest/account_serializer.rb +++ b/app/serializers/rest/account_serializer.rb @@ -5,7 +5,7 @@ class REST::AccountSerializer < ActiveModel::Serializer attributes :id, :username, :acct, :display_name, :locked, :bot, :discoverable, :group, :created_at, :note, :url, :avatar, :avatar_static, :header, :header_static, - :followers_count, :following_count, :statuses_count, :last_status_at + :followers_count, :following_count, :subscribing_count, :statuses_count, :last_status_at has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested? diff --git a/app/serializers/rest/relationship_serializer.rb b/app/serializers/rest/relationship_serializer.rb index afd4cddf9..0584f4a6f 100644 --- a/app/serializers/rest/relationship_serializer.rb +++ b/app/serializers/rest/relationship_serializer.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class REST::RelationshipSerializer < ActiveModel::Serializer - attributes :id, :following, :showing_reblogs, :notifying, :followed_by, + attributes :id, :following, :showing_reblogs, :notifying, :followed_by, :subscribing, :blocking, :blocked_by, :muting, :muting_notifications, :requested, :domain_blocking, :endorsed, :note @@ -29,6 +29,10 @@ class REST::RelationshipSerializer < ActiveModel::Serializer instance_options[:relationships].followed_by[object.id] || false end + def subscribing + instance_options[:relationships].subscribing[object.id] ? true : false + end + def blocking instance_options[:relationships].blocking[object.id] || false end diff --git a/app/services/account_subscribe_service.rb b/app/services/account_subscribe_service.rb index 8e9b0adf3..05e10953e 100644 --- a/app/services/account_subscribe_service.rb +++ b/app/services/account_subscribe_service.rb @@ -6,7 +6,8 @@ class AccountSubscribeService < BaseService # @param [String, Account] uri User URI to subscribe in the form of username@domain (or account record) def call(source_account, target_account) begin - target_account = ResolveAccountService.new.call(target_account, skip_webfinger: false) + target_account = ResolveAccountService.new.call(target_account, skip_webfinger: true) + target_account ||= ResolveAccountService.new.call(target_account, skip_webfinger: false) rescue target_account = nil end @@ -14,9 +15,7 @@ class AccountSubscribeService < BaseService 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) || (!target_account.local? && target_account.ostatus?) || source_account.domain_blocking?(target_account.domain) - if source_account.following?(target_account) - return - elsif source_account.subscribing?(target_account) + if source_account.subscribing?(target_account) return end diff --git a/app/services/delete_account_service.rb b/app/services/delete_account_service.rb index d8270498a..1bf4ce437 100644 --- a/app/services/delete_account_service.rb +++ b/app/services/delete_account_service.rb @@ -234,6 +234,7 @@ class DeleteAccountService < BaseService @account.statuses_count = 0 @account.followers_count = 0 @account.following_count = 0 + @account.subscribing_count = 0 @account.moved_to_account = nil @account.also_known_as = [] @account.trust_level = :untrusted diff --git a/app/services/follow_service.rb b/app/services/follow_service.rb index ee5460383..329262cca 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -79,7 +79,6 @@ class FollowService < BaseService def direct_follow! follow = @source_account.follow!(@target_account, reblogs: @options[:reblogs], notify: @options[:notify], rate_limit: @options[:with_rate_limit], bypass_limit: @options[:bypass_limit]) - UnsubscribeAccountService.new.call(@source_account, @target_account) if @source_account.subscribing?(@target_account) LocalNotificationWorker.perform_async(@target_account.id, follow.id, follow.class.name, :follow) MergeWorker.perform_async(@target_account.id, @source_account.id) diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 1a76cbb38..132fd36fc 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -119,6 +119,7 @@ class SearchService < BaseService blocked_by: Account.blocked_by_map(account_ids, account.id), muting: Account.muting_map(account_ids, account.id), following: Account.following_map(account_ids, account.id), + subscribing: Account.subscribing_map(account_ids, account.id), domain_blocking_by_domain: Account.domain_blocking_map_by_domain(domains, account.id), } end diff --git a/app/views/settings/preferences/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml index 14941d5fd..0ee7aa482 100644 --- a/app/views/settings/preferences/appearance/show.html.haml +++ b/app/views/settings/preferences/appearance/show.html.haml @@ -47,6 +47,7 @@ .fields-group = f.input :setting_unfollow_modal, as: :boolean, wrapper: :with_label + = f.input :setting_unsubscribe_modal, as: :boolean, wrapper: :with_label = f.input :setting_boost_modal, as: :boolean, wrapper: :with_label = f.input :setting_delete_modal, as: :boolean, wrapper: :with_label diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index c31a06969..7993a79fc 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -178,6 +178,7 @@ en: setting_theme: Site theme setting_trends: Show today's trends setting_unfollow_modal: Show confirmation dialog before unfollowing someone + setting_unsubscribe_modal: Show confirmation dialog before unsubscribing someone setting_use_blurhash: Show colorful gradients for hidden media setting_use_pending_items: Slow mode severity: Severity diff --git a/config/locales/simple_form.ja.yml b/config/locales/simple_form.ja.yml index af1dcf195..9c642aa24 100644 --- a/config/locales/simple_form.ja.yml +++ b/config/locales/simple_form.ja.yml @@ -178,6 +178,7 @@ ja: setting_theme: サイトテーマ setting_trends: 本日のトレンドタグを表示する setting_unfollow_modal: フォローを解除する前に確認ダイアログを表示する + setting_unsubscribe_modal: 購読を解除する前に確認ダイアログを表示する setting_use_blurhash: 非表示のメディアを色付きのぼかしで表示する setting_use_pending_items: 手動更新モード severity: 重大性 diff --git a/config/routes.rb b/config/routes.rb index a790077a0..ff86fda5c 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -451,6 +451,7 @@ Rails.application.routes.draw do resource :search, only: :show, controller: :search resource :lookup, only: :show, controller: :lookup resources :relationships, only: :index + resources :subscribing, only: :index, controller: 'subscribing_accounts' end resources :accounts, only: [:create, :show] do @@ -464,6 +465,8 @@ Rails.application.routes.draw do member do post :follow post :unfollow + post :subscribe + post :unsubscribe post :block post :unblock post :mute @@ -486,7 +489,6 @@ Rails.application.routes.draw do resources :featured_tags, only: [:index, :create, :destroy] resources :favourite_tags, only: [:index, :create, :show, :update, :destroy] resources :follow_tags, only: [:index, :create, :show, :update, :destroy] - resources :account_subscribes, only: [:index, :create, :show, :update, :destroy] resources :domain_subscribes, only: [:index, :create, :show, :update, :destroy] resources :keyword_subscribes, only: [:index, :create, :show, :update, :destroy] diff --git a/config/settings.yml b/config/settings.yml index 06cee2532..7d47d4e9d 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -19,6 +19,7 @@ defaults: &defaults default_sensitive: false hide_network: false unfollow_modal: false + unsubscribe_modal: false boost_modal: false delete_modal: true auto_play_gif: false diff --git a/db/migrate/20191214025518_add_subscribing_count_to_account_stat.rb b/db/migrate/20191214025518_add_subscribing_count_to_account_stat.rb new file mode 100644 index 000000000..f5135f3ca --- /dev/null +++ b/db/migrate/20191214025518_add_subscribing_count_to_account_stat.rb @@ -0,0 +1,5 @@ +class AddSubscribingCountToAccountStat < ActiveRecord::Migration[5.2] + def change + add_column :account_stats, :subscribing_count, :bigint, null: false, default: 0 + end +end diff --git a/db/schema.rb b/db/schema.rb index 02b5a8eca..9a875f232 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -111,6 +111,7 @@ ActiveRecord::Schema.define(version: 2021_08_08_071221) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.datetime "last_status_at" + t.bigint "subscribing_count", default: 0, null: false t.index ["account_id"], name: "index_account_stats_on_account_id", unique: true end diff --git a/lib/mastodon/cache_cli.rb b/lib/mastodon/cache_cli.rb index 803404c34..3613bb151 100644 --- a/lib/mastodon/cache_cli.rb +++ b/lib/mastodon/cache_cli.rb @@ -32,10 +32,11 @@ module Mastodon case type when 'accounts' processed, = parallelize_with_progress(Account.local.includes(:account_stat)) do |account| - account_stat = account.account_stat - account_stat.following_count = account.active_relationships.count - account_stat.followers_count = account.passive_relationships.count - account_stat.statuses_count = account.statuses.where.not(visibility: :direct).count + account_stat = account.account_stat + account_stat.following_count = account.active_relationships.count + account_stat.followers_count = account.passive_relationships.count + account_stat.subscribing_count = account.active_subscribes.count + account_stat.statuses_count = account.statuses.where.not(visibility: :direct).count account_stat.save if account_stat.changed? end diff --git a/spec/lib/settings/scoped_settings_spec.rb b/spec/lib/settings/scoped_settings_spec.rb index 7566685b4..7c6fa688b 100644 --- a/spec/lib/settings/scoped_settings_spec.rb +++ b/spec/lib/settings/scoped_settings_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Settings::ScopedSettings do let(:object) { Fabricate(:user) } let(:scoped_setting) { described_class.new(object) } let(:val) { 'whatever' } - let(:methods) { %i(auto_play_gif default_sensitive unfollow_modal boost_modal delete_modal reduce_motion system_font_ui noindex theme) } + let(:methods) { %i(auto_play_gif default_sensitive unfollow_modal unsubscribe_modal boost_modal delete_modal reduce_motion system_font_ui noindex theme) } describe '.initialize' do it 'sets @object' do diff --git a/spec/lib/user_settings_decorator_spec.rb b/spec/lib/user_settings_decorator_spec.rb index 462c5b124..af5ca29f1 100644 --- a/spec/lib/user_settings_decorator_spec.rb +++ b/spec/lib/user_settings_decorator_spec.rb @@ -42,6 +42,13 @@ describe UserSettingsDecorator do expect(user.settings['unfollow_modal']).to eq false end + it 'updates the user settings value for unsubscribe modal' do + values = { 'setting_unsubscribe_modal' => '0' } + + settings.update(values) + expect(user.settings['unsubscribe_modal']).to eq false + end + it 'updates the user settings value for boost modal' do values = { 'setting_boost_modal' => '1' }