From 7646adf8b259b20497c0ad94119debf4df7c03c3 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 | 29 +++ .../account_timeline/components/header.js | 6 + .../containers/header_container.js | 21 +- .../directory/components/account_card.js | 55 +++++- .../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 | 7 + .../styles/mastodon/components.scss | 7 +- app/lib/user_settings_decorator.rb | 5 + app/models/account_stat.rb | 19 +- 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/follow_service.rb | 1 - app/services/search_service.rb | 1 + app/services/suspend_account_service.rb | 28 +-- .../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, 662 insertions(+), 100 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 b814e009e5..4ef383c12c 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 5da428bbd9..0000000000 --- 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 0000000000..3fbfcc70d5 --- /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 d68d2715f7..235deef34b 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 @@ def follow 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 @@ def unfollow 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 bac9b329d4..ec00f0c066 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -41,6 +41,7 @@ def user_settings_params :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 d4a824e2c9..f0caefbf4c 100644 --- a/app/javascript/mastodon/actions/accounts.js +++ b/app/javascript/mastodon/actions/accounts.js @@ -14,6 +14,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'; @@ -54,6 +62,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'; @@ -221,6 +237,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)); @@ -531,6 +626,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 2705a60013..3f78263540 100644 --- a/app/javascript/mastodon/components/account.js +++ b/app/javascript/mastodon/components/account.js @@ -12,6 +12,8 @@ import { me } from '../initial_state'; 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}' }, @@ -25,6 +27,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, @@ -39,6 +42,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); } @@ -80,10 +87,11 @@ class Account extends ImmutablePureComponent { if (onActionClick && actionIcon) { 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 = ; @@ -102,8 +110,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 5a5136dd18..ac69b91501 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 8bd7f2db5f..6241f65edb 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -3,6 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes'; import PropTypes from 'prop-types'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import Button from 'mastodon/components/button'; +import IconButton from 'mastodon/components/icon_button'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { autoPlayGif, me, isStaff } from 'mastodon/initial_state'; import classNames from 'classnames'; @@ -15,6 +16,8 @@ import DropdownMenuContainer from 'mastodon/containers/dropdown_menu_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}' }, @@ -63,6 +66,7 @@ class Header extends ImmutablePureComponent { account: ImmutablePropTypes.map, identity_props: ImmutablePropTypes.list, onFollow: PropTypes.func.isRequired, + onSubscribe: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, domain: PropTypes.string.isRequired, @@ -250,6 +254,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 (
@@ -280,6 +300,9 @@ class Header extends ImmutablePureComponent { {badge} @{acct} {lockedIcon} +
+ {buttons} +
@@ -325,6 +348,12 @@ class Header extends ImmutablePureComponent { {shortNumberFormat(account.get('followers_count'))} + + { (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 844b8a236a..1af0b10e98 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); } @@ -98,6 +103,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 8728b48068..189e05e127 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 cb47d9db4b..ee72d796ab 100644 --- a/app/javascript/mastodon/features/directory/components/account_card.js +++ b/app/javascript/mastodon/features/directory/components/account_card.js @@ -10,15 +10,25 @@ 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 { shortNumberFormat } from 'mastodon/utils/numbers'; -import { followAccount, unfollowAccount, blockAccount, unblockAccount, unmuteAccount } from 'mastodon/actions/accounts'; +import { + followAccount, + unfollowAccount, + subscribeAccount, + unsubscribeAccount, + blockAccount, + unblockAccount, + unmuteAccount +} from 'mastodon/actions/accounts'; import { openModal } from 'mastodon/actions/modal'; 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}' }, @@ -53,6 +63,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'))); @@ -79,6 +105,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, }; @@ -124,6 +151,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); } @@ -142,10 +173,11 @@ class AccountCard extends ImmutablePureComponent { let buttons; 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 = ; @@ -153,8 +185,15 @@ class AccountCard extends ImmutablePureComponent { buttons = ; } else if (muting) { buttons = ; - } 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 0000000000..6067e88e49 --- /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']), + 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, + shouldUpdateScroll: PropTypes.func, + 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 { shouldUpdateScroll, accountIds, hasMore, blockedBy, isAccount, multiColumn } = 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 659477b451..0bb95b96bb 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -34,6 +34,7 @@ import { HomeTimeline, Followers, Following, + Subscribing, Reblogs, Favourites, DirectTimeline, @@ -209,6 +210,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 bf9e87e174..06504dc553 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 1134c55db4..e724b79592 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 68a6fb25e4..8b05bad7c2 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -33,10 +33,14 @@ "account.requested": "Awaiting approval. Click to cancel follow request", "account.share": "Share @{name}'s profile", "account.show_reblogs": "Show boosts from @{name}", + "account.subscribe": "Subscribe", + "account.subscribes": "Subscribes", + "account.subscribes.empty": "This user doesn't subscribe anyone yet.", "account.unblock": "Unblock @{name}", "account.unblock_domain": "Unhide {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}", "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.", @@ -120,6 +124,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 15ebe01299..647ef4504f 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -33,10 +33,14 @@ "account.requested": "フォロー承認待ちです。クリックしてキャンセル", "account.share": "@{name}さんのプロフィールを共有する", "account.show_reblogs": "@{name}さんからのブーストを表示", + "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}さんからの通知を受け取るようにする", "alert.rate_limited.message": "{retry_time, time, medium} 以降に再度実行してください。", @@ -120,6 +124,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 530ed8e607..a5853b7893 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 9ebf72af9b..9a89544ef6 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 8322780de5..3a4ed2034f 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, @@ -49,8 +55,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 0d7222e10a..8f712020ae 100644 --- a/app/javascript/mastodon/reducers/timelines.js +++ b/app/javascript/mastodon/reducers/timelines.js @@ -14,6 +14,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'; @@ -157,6 +158,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 a7853452f0..31602bc333 100644 --- a/app/javascript/mastodon/reducers/user_lists.js +++ b/app/javascript/mastodon/reducers/user_lists.js @@ -6,6 +6,8 @@ import { FOLLOWERS_EXPAND_SUCCESS, FOLLOWING_FETCH_SUCCESS, FOLLOWING_EXPAND_SUCCESS, + SUBSCRIBING_FETCH_SUCCESS, + SUBSCRIBING_EXPAND_SUCCESS, FOLLOW_REQUESTS_FETCH_SUCCESS, FOLLOW_REQUESTS_EXPAND_SUCCESS, FOLLOW_REQUEST_AUTHORIZE_SUCCESS, @@ -36,6 +38,7 @@ import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; const initialState = ImmutableMap({ followers: ImmutableMap(), following: ImmutableMap(), + subscribing: ImmutableMap(), reblogged_by: ImmutableMap(), favourited_by: ImmutableMap(), follow_requests: ImmutableMap(), @@ -72,6 +75,10 @@ export default function userLists(state = initialState, action) { return normalizeList(state, 'following', action.id, action.accounts, action.next); case FOLLOWING_EXPAND_SUCCESS: return appendToList(state, 'following', action.id, action.accounts, action.next); + 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 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 5dc1ed74b2..15088b0394 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -5709,7 +5709,7 @@ a.status-card.compact:hover { } &__relationship { - width: 23px; + width: 46px; min-height: 1px; flex: 0 0 auto; } @@ -6471,6 +6471,7 @@ noscript { &__name { padding: 5px; + display: flex; .account-role { vertical-align: top; @@ -6489,6 +6490,7 @@ noscript { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; + flex: 1 1 auto; small { display: block; @@ -6499,6 +6501,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 fa8255faab..68b388c8e8 100644 --- a/app/lib/user_settings_decorator.rb +++ b/app/lib/user_settings_decorator.rb @@ -21,6 +21,7 @@ def process_update 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') @@ -60,6 +61,10 @@ def unfollow_modal_preference 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 c84e4217c8..373fb36297 100644 --- a/app/models/account_stat.rb +++ b/app/models/account_stat.rb @@ -3,15 +3,16 @@ # # Table name: account_stats # -# id :bigint(8) not null, primary key -# account_id :bigint(8) not null -# statuses_count :bigint(8) default(0), not null -# following_count :bigint(8) default(0), not null -# followers_count :bigint(8) default(0), not null -# created_at :datetime not null -# updated_at :datetime not null -# last_status_at :datetime -# lock_version :integer default(0), not null +# id :bigint(8) not null, primary key +# account_id :bigint(8) not null +# 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 +# lock_version :integer default(0), not null # class AccountStat < ApplicationRecord diff --git a/app/models/account_subscribe.rb b/app/models/account_subscribe.rb index c663ef9d3a..5bc05a5451 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' @@ -19,4 +22,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 6e25e1905e..1ba9f5b64c 100644 --- a/app/models/concerns/account_counters.rb +++ b/app/models/concerns/account_counters.rb @@ -14,6 +14,8 @@ module AccountCounters :following_count=, :followers_count, :followers_count=, + :subscribing_count, + :subscribing_count=, :increment_count!, :decrement_count!, :last_status_at, diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb index 62c433fddb..b2fbdd4ad6 100644 --- a/app/models/concerns/account_interactions.rb +++ b/app/models/concerns/account_interactions.rb @@ -16,6 +16,10 @@ def followed_by_map(target_account_ids, account_id) 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 @@ -159,7 +163,11 @@ def unblock_domain!(other_domain) 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 a0ead1995a..a19da2c2c3 100644 --- a/app/models/concerns/status_threading_concern.rb +++ b/app/models/concerns/status_threading_concern.rb @@ -122,6 +122,7 @@ def relations_map_for_account(account, account_ids, domains) 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 cab01f11ad..ffda4c26d1 100644 --- a/app/models/export.rb +++ b/app/models/export.rb @@ -59,6 +59,10 @@ def total_follows 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 85ee5cd064..5f76375958 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -106,7 +106,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 08614b67c2..917bbac198 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 @@ -11,6 +11,7 @@ def initialize(account_ids, current_account_id, **options) @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)) @@ -22,6 +23,7 @@ def initialize(account_ids, current_account_id, **options) @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] || {}) @@ -38,6 +40,7 @@ def cached @cached = { following: {}, followed_by: {}, + subscribing: {}, blocking: {}, blocked_by: {}, muting: {}, @@ -66,6 +69,7 @@ def cache_uncached! 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 392fc891af..dde0971617 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -27,6 +27,7 @@ def meta 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 0db1916b07..05bf66307a 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? has_many :emojis, serializer: REST::CustomEmojiSerializer diff --git a/app/serializers/rest/relationship_serializer.rb b/app/serializers/rest/relationship_serializer.rb index 1a3fd915cb..5b05e60b17 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, :followed_by, :blocking, :blocked_by, + attributes :id, :following, :showing_reblogs, :followed_by, :subscribing, :blocking, :blocked_by, :muting, :muting_notifications, :requested, :domain_blocking, :endorsed @@ -23,6 +23,10 @@ def followed_by 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 8e9b0adf32..05e10953e9 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 @@ def call(source_account, target_account) 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/follow_service.rb b/app/services/follow_service.rb index da35f1d328..4d19002c40 100644 --- a/app/services/follow_service.rb +++ b/app/services/follow_service.rb @@ -53,7 +53,6 @@ def request_follow(source_account, target_account, reblogs: true) def direct_follow(source_account, target_account, reblogs: true) follow = source_account.follow!(target_account, reblogs: reblogs) - UnsubscribeAccountService.new.call(source_account, target_account) if source_account.subscribing?(target_account) LocalNotificationWorker.perform_async(target_account.id, follow.id, follow.class.name) MergeWorker.perform_async(target_account.id, source_account.id) diff --git a/app/services/search_service.rb b/app/services/search_service.rb index 090fd409b6..bde6f4f5b5 100644 --- a/app/services/search_service.rb +++ b/app/services/search_service.rb @@ -119,6 +119,7 @@ def relations_map_for_account(account, account_ids, domains) 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/services/suspend_account_service.rb b/app/services/suspend_account_service.rb index ecc893931d..4ff9111264 100644 --- a/app/services/suspend_account_service.rb +++ b/app/services/suspend_account_service.rb @@ -20,6 +20,7 @@ class SuspendAccountService < BaseService notifications owned_lists passive_relationships + passive_subscribes report_notes scheduled_statuses status_pins @@ -113,19 +114,20 @@ def purge_profile! return unless @options[:reserve_username] - @account.silenced_at = nil - @account.suspended_at = @options[:suspended_at] || Time.now.utc - @account.locked = false - @account.memorial = false - @account.discoverable = false - @account.display_name = '' - @account.note = '' - @account.fields = [] - @account.statuses_count = 0 - @account.followers_count = 0 - @account.following_count = 0 - @account.moved_to_account = nil - @account.trust_level = :untrusted + @account.silenced_at = nil + @account.suspended_at = @options[:suspended_at] || Time.now.utc + @account.locked = false + @account.memorial = false + @account.discoverable = false + @account.display_name = '' + @account.note = '' + @account.fields = [] + @account.statuses_count = 0 + @account.followers_count = 0 + @account.following_count = 0 + @account.subscribing_count = 0 + @account.moved_to_account = nil + @account.trust_level = :untrusted @account.avatar.destroy @account.header.destroy @account.save! diff --git a/app/views/settings/preferences/appearance/show.html.haml b/app/views/settings/preferences/appearance/show.html.haml index d2b05513e3..a5ebf908dc 100644 --- a/app/views/settings/preferences/appearance/show.html.haml +++ b/app/views/settings/preferences/appearance/show.html.haml @@ -43,6 +43,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 427ec68b1d..9c93b6b82f 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -153,6 +153,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 5fab73c2e6..dadd68bd71 100644 --- a/config/locales/simple_form.ja.yml +++ b/config/locales/simple_form.ja.yml @@ -153,6 +153,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 afb624dc07..41096f7760 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -395,6 +395,7 @@ patch :update_credentials, to: 'credentials#update' resource :search, only: :show, controller: :search resources :relationships, only: :index + resources :subscribing, only: :index, controller: 'subscribing_accounts' end resources :accounts, only: [:create, :show] do @@ -408,6 +409,8 @@ member do post :follow post :unfollow + post :subscribe + post :unsubscribe post :block post :unblock post :mute @@ -429,7 +432,6 @@ 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 0024736435..f31e7c90cb 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 0000000000..f5135f3ca2 --- /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 34c64a378c..64289b1de4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -98,6 +98,7 @@ t.datetime "updated_at", null: false t.datetime "last_status_at" t.integer "lock_version", default: 0, null: false + 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 803404c34f..3613bb1511 100644 --- a/lib/mastodon/cache_cli.rb +++ b/lib/mastodon/cache_cli.rb @@ -32,10 +32,11 @@ def recount(type) 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 7566685b4a..7c6fa688b7 100644 --- a/spec/lib/settings/scoped_settings_spec.rb +++ b/spec/lib/settings/scoped_settings_spec.rb @@ -6,7 +6,7 @@ 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 462c5b1249..af5ca29f19 100644 --- a/spec/lib/user_settings_decorator_spec.rb +++ b/spec/lib/user_settings_decorator_spec.rb @@ -42,6 +42,13 @@ 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' }