diff --git a/app/controllers/api/v1/statuses/mentioned_accounts_controller.rb b/app/controllers/api/v1/statuses/mentioned_accounts_controller.rb new file mode 100644 index 00000000000000..4d905ef1a6f95a --- /dev/null +++ b/app/controllers/api/v1/statuses/mentioned_accounts_controller.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +class Api::V1::Statuses::MentionedAccountsController < Api::BaseController + include Authorization + + before_action -> { authorize_if_got_token! :read, :'read:accounts' } + before_action :set_status + after_action :insert_pagination_headers + + def index + cache_if_unauthenticated! + @accounts = load_accounts + render json: @accounts, each_serializer: REST::AccountSerializer + end + + private + + def load_accounts + scope = default_accounts + scope = scope.where.not(id: current_account.excluded_from_timeline_account_ids) unless current_account.nil? + scope.merge(paginated_mentioned_users).to_a + end + + def default_accounts + Account + .without_suspended + .includes(:mentions, :account_stat) + .references(:mentions) + .where(mentions: { status_id: @status.id }) + end + + def paginated_mentioned_users + Mention.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 + api_v1_status_mentioned_by_index_url pagination_params(max_id: pagination_max_id) if records_continue? + end + + def prev_path + api_v1_status_mentioned_by_index_url pagination_params(since_id: pagination_since_id) unless @accounts.empty? + end + + def pagination_max_id + @accounts.last.mentions.last.id + end + + def pagination_since_id + @accounts.first.mentions.first.id + end + + def records_continue? + @accounts.size == limit_param(DEFAULT_ACCOUNTS_LIMIT) + end + + def set_status + @status = Status.find(params[:status_id]) + authorize @status, :show_mentioned_users? + rescue Mastodon::NotPermittedError + not_found + end + + def pagination_params(core_params) + params.slice(:limit).permit(:limit).merge(core_params) + end +end diff --git a/app/javascript/mastodon/actions/interactions.js b/app/javascript/mastodon/actions/interactions.js index b36180930950da..640c5c312884d2 100644 --- a/app/javascript/mastodon/actions/interactions.js +++ b/app/javascript/mastodon/actions/interactions.js @@ -71,6 +71,14 @@ export const UNBOOKMARK_REQUEST = 'UNBOOKMARKED_REQUEST'; export const UNBOOKMARK_SUCCESS = 'UNBOOKMARKED_SUCCESS'; export const UNBOOKMARK_FAIL = 'UNBOOKMARKED_FAIL'; +export const MENTIONED_USERS_FETCH_REQUEST = 'MENTIONED_USERS_FETCH_REQUEST'; +export const MENTIONED_USERS_FETCH_SUCCESS = 'MENTIONED_USERS_FETCH_SUCCESS'; +export const MENTIONED_USERS_FETCH_FAIL = 'MENTIONED_USERS_FETCH_FAIL'; + +export const MENTIONED_USERS_EXPAND_REQUEST = 'MENTIONED_USERS_EXPAND_REQUEST'; +export const MENTIONED_USERS_EXPAND_SUCCESS = 'MENTIONED_USERS_EXPAND_SUCCESS'; +export const MENTIONED_USERS_EXPAND_FAIL = 'MENTIONED_USERS_EXPAND_FAIL'; + export function reblog(status, visibility) { return function (dispatch, getState) { dispatch(reblogRequest(status)); @@ -735,3 +743,85 @@ export function unpinFail(status, error) { skipLoading: true, }; } + +export function fetchMentionedUsers(id) { + return (dispatch, getState) => { + dispatch(fetchMentionedUsersRequest(id)); + + api(getState).get(`/api/v1/statuses/${id}/mentioned_by`).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + dispatch(importFetchedAccounts(response.data)); + dispatch(fetchMentionedUsersSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => { + dispatch(fetchMentionedUsersFail(id, error)); + }); + }; +} + +export function fetchMentionedUsersRequest(id) { + return { + type: MENTIONED_USERS_FETCH_REQUEST, + id, + }; +} + +export function fetchMentionedUsersSuccess(id, accounts, next) { + return { + type: MENTIONED_USERS_FETCH_SUCCESS, + id, + accounts, + next, + }; +} + +export function fetchMentionedUsersFail(id, error) { + return { + type: MENTIONED_USERS_FETCH_FAIL, + id, + error, + }; +} + +export function expandMentionedUsers(id) { + return (dispatch, getState) => { + const url = getState().getIn(['user_lists', 'mentioned_users', id, 'next']); + if (url === null) { + return; + } + + dispatch(expandMentionedUsersRequest(id)); + + api(getState).get(url).then(response => { + const next = getLinks(response).refs.find(link => link.rel === 'next'); + + dispatch(importFetchedAccounts(response.data)); + dispatch(expandMentionedUsersSuccess(id, response.data, next ? next.uri : null)); + dispatch(fetchRelationships(response.data.map(item => item.id))); + }).catch(error => dispatch(expandMentionedUsersFail(id, error))); + }; +} + +export function expandMentionedUsersRequest(id) { + return { + type: MENTIONED_USERS_EXPAND_REQUEST, + id, + }; +} + +export function expandMentionedUsersSuccess(id, accounts, next) { + return { + type: MENTIONED_USERS_EXPAND_SUCCESS, + id, + accounts, + next, + }; +} + +export function expandMentionedUsersFail(id, error) { + return { + type: MENTIONED_USERS_EXPAND_FAIL, + id, + error, + }; +} diff --git a/app/javascript/mastodon/components/status_action_bar.jsx b/app/javascript/mastodon/components/status_action_bar.jsx index 02a42a92ddda49..a6f5e870376501 100644 --- a/app/javascript/mastodon/components/status_action_bar.jsx +++ b/app/javascript/mastodon/components/status_action_bar.jsx @@ -24,6 +24,7 @@ const messages = defineMessages({ edit: { id: 'status.edit', defaultMessage: 'Edit' }, direct: { id: 'status.direct', defaultMessage: 'Privately mention @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, + mentions: { id: 'status.mentions', defaultMessage: 'Mentioned users' }, mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, block: { id: 'account.block', defaultMessage: 'Block @{name}' }, reply: { id: 'status.reply', defaultMessage: 'Reply' }, @@ -249,6 +250,10 @@ class StatusActionBar extends ImmutablePureComponent { this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}`); }; + handleOpenMentions = () => { + this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}/mentioned_users`); + }; + handleEmbed = () => { this.props.onEmbed(this.props.status); }; @@ -315,7 +320,11 @@ class StatusActionBar extends ImmutablePureComponent { } if (signedIn) { - if (!simpleTimelineMenu) { + if (writtenByMe) { + menu.push({ text: intl.formatMessage(messages.mentions), action: this.handleOpenMentions }); + } + + if (!simpleTimelineMenu || writtenByMe) { menu.push(null); } diff --git a/app/javascript/mastodon/features/mentioned_users/index.jsx b/app/javascript/mastodon/features/mentioned_users/index.jsx new file mode 100644 index 00000000000000..f32e38820e225c --- /dev/null +++ b/app/javascript/mastodon/features/mentioned_users/index.jsx @@ -0,0 +1,90 @@ +import PropTypes from 'prop-types'; + +import { injectIntl, FormattedMessage } from 'react-intl'; + +import { Helmet } from 'react-helmet'; + +import ImmutablePropTypes from 'react-immutable-proptypes'; +import ImmutablePureComponent from 'react-immutable-pure-component'; +import { connect } from 'react-redux'; + +import { debounce } from 'lodash'; + +import { fetchMentionedUsers, expandMentionedUsers } from 'mastodon/actions/interactions'; +import ColumnHeader from 'mastodon/components/column_header'; +import { LoadingIndicator } from 'mastodon/components/loading_indicator'; +import ScrollableList from 'mastodon/components/scrollable_list'; +import AccountContainer from 'mastodon/containers/account_container'; +import Column from 'mastodon/features/ui/components/column'; + +const mapStateToProps = (state, props) => ({ + accountIds: state.getIn(['user_lists', 'mentioned_users', props.params.statusId, 'items']), + hasMore: !!state.getIn(['user_lists', 'mentioned_users', props.params.statusId, 'next']), + isLoading: state.getIn(['user_lists', 'mentioned_users', props.params.statusId, 'isLoading'], true), +}); + +class MentionedUsers extends ImmutablePureComponent { + + static propTypes = { + params: PropTypes.object.isRequired, + dispatch: PropTypes.func.isRequired, + accountIds: ImmutablePropTypes.list, + hasMore: PropTypes.bool, + isLoading: PropTypes.bool, + multiColumn: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + UNSAFE_componentWillMount () { + if (!this.props.accountIds) { + this.props.dispatch(fetchMentionedUsers(this.props.params.statusId)); + } + } + + handleLoadMore = debounce(() => { + this.props.dispatch(expandMentionedUsers(this.props.params.statusId)); + }, 300, { leading: true }); + + render () { + const { accountIds, hasMore, isLoading, multiColumn } = this.props; + + if (!accountIds) { + return ( + + + + ); + } + + const emptyMessage = ; + + return ( + + + + + {accountIds.map(id => + , + )} + + + + + + + ); + } + +} + +export default connect(mapStateToProps)(injectIntl(MentionedUsers)); diff --git a/app/javascript/mastodon/features/status/components/action_bar.jsx b/app/javascript/mastodon/features/status/components/action_bar.jsx index 56086075fc8780..2e5df723dea114 100644 --- a/app/javascript/mastodon/features/status/components/action_bar.jsx +++ b/app/javascript/mastodon/features/status/components/action_bar.jsx @@ -22,6 +22,7 @@ const messages = defineMessages({ edit: { id: 'status.edit', defaultMessage: 'Edit' }, direct: { id: 'status.direct', defaultMessage: 'Privately mention @{name}' }, mention: { id: 'status.mention', defaultMessage: 'Mention @{name}' }, + mentions: { id: 'status.mentions', defaultMessage: 'Mentioned users' }, reply: { id: 'status.reply', defaultMessage: 'Reply' }, reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, cancel_reblog: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, @@ -95,6 +96,10 @@ class ActionBar extends PureComponent { intl: PropTypes.object.isRequired, }; + handleOpenMentions = () => { + this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}/mentioned_users`); + }; + handleReplyClick = () => { this.props.onReply(this.props.status); }; @@ -264,6 +269,7 @@ class ActionBar extends PureComponent { menu.push(null); } + menu.push({ text: intl.formatMessage(messages.mentions), action: this.handleOpenMentions }); menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick }); menu.push(null); menu.push({ text: intl.formatMessage(messages.edit), action: this.handleEditClick }); diff --git a/app/javascript/mastodon/features/ui/index.jsx b/app/javascript/mastodon/features/ui/index.jsx index feef2a7559887b..ee51220df67d2b 100644 --- a/app/javascript/mastodon/features/ui/index.jsx +++ b/app/javascript/mastodon/features/ui/index.jsx @@ -46,6 +46,7 @@ import { Favourites, EmojiReactions, StatusReferences, + MentionedUsers, DirectTimeline, HashtagTimeline, AntennaTimeline, @@ -243,6 +244,7 @@ class SwitchingColumnsArea extends PureComponent { + {/* Legacy routes, cannot be easily factored with other routes because they share a param name */} diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 10daf553574923..2857fcec381b63 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -106,6 +106,10 @@ export function StatusReferences () { return import(/* webpackChunkName: "features/status_references" */'../../status_references'); } +export function MentionedUsers () { + return import(/* webpackChunkName: "features/mentioned_users" */'../../mentioned_users'); +} + export function FollowRequests () { return import(/* webpackChunkName: "features/follow_requests" */'../../follow_requests'); } diff --git a/app/javascript/mastodon/reducers/circles.js b/app/javascript/mastodon/reducers/circles.js index b9a6e6c37a4317..cadbb000e03c89 100644 --- a/app/javascript/mastodon/reducers/circles.js +++ b/app/javascript/mastodon/reducers/circles.js @@ -23,6 +23,7 @@ const initialState = ImmutableList(); const initialStatusesState = ImmutableMap({ items: ImmutableList(), isLoading: false, + loaded: true, next: null, }); diff --git a/app/javascript/mastodon/reducers/user_lists.js b/app/javascript/mastodon/reducers/user_lists.js index 2cb41cd03d5b20..ecf08dfbc3bd11 100644 --- a/app/javascript/mastodon/reducers/user_lists.js +++ b/app/javascript/mastodon/reducers/user_lists.js @@ -64,6 +64,12 @@ import { EMOJI_REACTIONS_EXPAND_SUCCESS, EMOJI_REACTIONS_EXPAND_FAIL, STATUS_REFERENCES_FETCH_SUCCESS, + MENTIONED_USERS_FETCH_REQUEST, + MENTIONED_USERS_FETCH_SUCCESS, + MENTIONED_USERS_FETCH_FAIL, + MENTIONED_USERS_EXPAND_REQUEST, + MENTIONED_USERS_EXPAND_SUCCESS, + MENTIONED_USERS_EXPAND_FAIL, } from '../actions/interactions'; import { MUTES_FETCH_REQUEST, @@ -92,6 +98,7 @@ const initialState = ImmutableMap({ favourited_by: initialListState, emoji_reactioned_by: initialListState, referred_by: initialListState, + mentioned_users: initialListState, follow_requests: initialListState, blocks: initialListState, mutes: initialListState, @@ -205,6 +212,16 @@ export default function userLists(state = initialState, action) { return appendToEmojiReactionList(state, ['emoji_reactioned_by', action.id], action.accounts, action.next); case STATUS_REFERENCES_FETCH_SUCCESS: return state.setIn(['referred_by', action.id], ImmutableList(action.statuses.map(item => item.id))); + case MENTIONED_USERS_FETCH_SUCCESS: + return normalizeList(state, ['mentioned_users', action.id], action.accounts, action.next); + case MENTIONED_USERS_EXPAND_SUCCESS: + return appendToList(state, ['mentioned_users', action.id], action.accounts, action.next); + case MENTIONED_USERS_FETCH_REQUEST: + case MENTIONED_USERS_EXPAND_REQUEST: + return state.setIn(['mentioned_users', action.id, 'isLoading'], true); + case MENTIONED_USERS_FETCH_FAIL: + case MENTIONED_USERS_EXPAND_FAIL: + return state.setIn(['mentioned_users', action.id, 'isLoading'], false); case NOTIFICATIONS_UPDATE: return action.notification.type === 'follow_request' ? normalizeFollowRequest(state, action.notification) : state; case FOLLOW_REQUESTS_FETCH_SUCCESS: diff --git a/app/models/mention.rb b/app/models/mention.rb index 2348b2905c0e06..5addfcc583c472 100644 --- a/app/models/mention.rb +++ b/app/models/mention.rb @@ -13,6 +13,8 @@ # class Mention < ApplicationRecord + include Paginable + belongs_to :account, inverse_of: :mentions belongs_to :status diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index 335abe9e9224b3..301ec4fdc48213 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -24,6 +24,10 @@ def show? end end + def show_mentioned_users? + owned? + end + def reblog? !requires_mention? && (!private? || owned?) && show? && !blocking_author? end diff --git a/config/routes/api.rb b/config/routes/api.rb index 961cd43ad35198..005d8f68390c4e 100644 --- a/config/routes/api.rb +++ b/config/routes/api.rb @@ -12,6 +12,7 @@ resources :favourited_by, controller: :favourited_by_accounts, only: :index resources :emoji_reactioned_by, controller: :emoji_reactioned_by_accounts, only: :index resources :referred_by, controller: :referred_by_statuses, only: :index + resources :mentioned_by, controller: :mentioned_accounts, only: :index resources :bookmark_categories, only: :index resource :reblog, only: :create post :unreblog, to: 'reblogs#destroy' diff --git a/spec/controllers/api/v1/statuses/mentioned_accounts_controller_spec.rb b/spec/controllers/api/v1/statuses/mentioned_accounts_controller_spec.rb new file mode 100644 index 00000000000000..22997913445a2f --- /dev/null +++ b/spec/controllers/api/v1/statuses/mentioned_accounts_controller_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Api::V1::Statuses::MentionedAccountsController do + render_views + + let(:user) { Fabricate(:user) } + let(:app) { Fabricate(:application, name: 'Test app', website: 'http://testapp.com') } + let(:token) { Fabricate(:accessible_access_token, resource_owner_id: user.id, application: app, scopes: 'read:accounts') } + let(:alice) { Fabricate(:account) } + let(:bob) { Fabricate(:account) } + let(:ohagi) { Fabricate(:account) } + + context 'with an oauth token' do + before do + allow(controller).to receive(:doorkeeper_token) { token } + end + + describe 'GET #index' do + let(:status) { Fabricate(:status, account: user.account) } + + before do + Mention.create!(account: bob, status: status) + Mention.create!(account: ohagi, status: status) + end + + it 'returns http success' do + get :index, params: { status_id: status.id, limit: 2 } + expect(response).to have_http_status(200) + expect(response.headers['Link'].links.size).to eq(2) + end + + it 'returns accounts who favorited the status' do + get :index, params: { status_id: status.id, limit: 2 } + expect(body_as_json.size).to eq 2 + expect([body_as_json[0][:id], body_as_json[1][:id]]).to contain_exactly(bob.id.to_s, ohagi.id.to_s) + end + + it 'does not return blocked users' do + user.account.block!(ohagi) + get :index, params: { status_id: status.id, limit: 2 } + expect(body_as_json.size).to eq 1 + expect(body_as_json[0][:id]).to eq bob.id.to_s + end + + context 'when other accounts status' do + let(:status) { Fabricate(:status, account: alice) } + + it 'returns http unauthorized' do + get :index, params: { status_id: status.id } + expect(response).to have_http_status(404) + end + end + end + end + + context 'without an oauth token' do + before do + allow(controller).to receive(:doorkeeper_token).and_return(nil) + end + + context 'with a public status' do + let(:status) { Fabricate(:status, account: user.account, visibility: :public) } + + describe 'GET #index' do + before do + Mention.create!(account: bob, status: status) + end + + it 'returns http unauthorized' do + get :index, params: { status_id: status.id } + expect(response).to have_http_status(404) + end + end + end + end +end