diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb
new file mode 100644
index 000000000..894f620f3
--- /dev/null
+++ b/app/controllers/api/v1/accounts/conversations_controller.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+class Api::V1::Accounts::ConversationsController < Api::BaseController
+ before_action -> { doorkeeper_authorize! :read, :'read:statuses' }
+ before_action :require_user!
+ before_action :set_account
+
+ after_action :insert_pagination_headers
+
+ def index
+ @statuses = load_statuses
+
+ if compact?
+ render json: CompactStatusesPresenter.new(statuses: @statuses), serializer: REST::CompactStatusesSerializer
+ else
+ account_ids = @statuses.filter(&:quote?).map { |status| status.quote.account_id }.uniq
+
+ render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), account_relationships: AccountRelationshipsPresenter.new(account_ids, current_user&.account_id)
+ end
+ end
+
+ private
+
+ def set_account
+ @account = Account.find(params[:account_id])
+ end
+
+ def load_statuses
+ @account.suspended? ? [] : cached_account_statuses
+ end
+
+ def cached_account_statuses
+ cache_collection_paginated_by_id(
+ conversation_account_statuses,
+ Status,
+ limit_param(DEFAULT_STATUSES_LIMIT),
+ params_slice(:max_id, :since_id, :min_id)
+ )
+ end
+
+ def conversation_account_statuses
+ @account.conversation_statuses(current_account)
+ end
+
+ def compact?
+ truthy_param?(:compact)
+ end
+
+ def pagination_params(core_params)
+ params.slice(:limit, :compact).permit(:limit, :compact).merge(core_params)
+ end
+
+ def insert_pagination_headers
+ set_pagination_headers(next_path, prev_path)
+ end
+
+ def next_path
+ if records_continue?
+ api_v1_account_statuses_url pagination_params(max_id: pagination_max_id)
+ end
+ end
+
+ def prev_path
+ unless @statuses.empty?
+ api_v1_account_statuses_url pagination_params(min_id: pagination_since_id)
+ end
+ end
+
+ def records_continue?
+ @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
+ end
+
+ def pagination_max_id
+ @statuses.last.id
+ end
+
+ def pagination_since_id
+ @statuses.first.id
+ end
+end
diff --git a/app/javascript/mastodon/actions/timelines.js b/app/javascript/mastodon/actions/timelines.js
index 67540350b..62dff9e01 100644
--- a/app/javascript/mastodon/actions/timelines.js
+++ b/app/javascript/mastodon/actions/timelines.js
@@ -183,6 +183,7 @@ export const expandPublicTimeline = ({ maxId, onlyMedia, withoutMedia,
export const expandDomainTimeline = (domain, { maxId, onlyMedia, withoutMedia, withoutBot } = {}, done = noOp) => expandTimeline(`domain${withoutBot ? ':nobot' : ':bot'}${withoutMedia ? ':nomedia' : ''}${onlyMedia ? ':media' : ''}:${domain}`, '/api/v1/timelines/public', { local: false, domain: domain, max_id: maxId, only_media: !!onlyMedia, without_media: !!withoutMedia, without_bot: !!withoutBot }, done);
export const expandGroupTimeline = (id, { maxId, onlyMedia, withoutMedia, tagged } = {}, done = noOp) => expandTimeline(`group:${id}${withoutMedia ? ':nomedia' : ''}${onlyMedia ? ':media' : ''}${tagged ? `:${tagged}` : ''}`, `/api/v1/timelines/group/${id}`, { max_id: maxId, only_media: !!onlyMedia, without_media: !!withoutMedia, tagged: tagged }, done);
export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
+export const expandAccountCoversations = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:conversations`, `/api/v1/accounts/${accountId}/conversations`, { max_id: maxId });
export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index e9c980dc5..38317296e 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -27,6 +27,8 @@ const messages = defineMessages({
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
linkVerifiedOn: { id: 'account.link_verified_on', defaultMessage: 'Ownership of this link was checked on {date}' },
account_locked: { id: 'account.locked_info', defaultMessage: 'This account privacy status is set to locked. The owner manually reviews who can follow them.' },
+ conversations: { id: 'account.conversations', defaultMessage: 'Show conversations with @{name}' },
+ conversations_all: { id: 'account.conversations_all', defaultMessage: 'Show all conversations' },
mention: { id: 'account.mention', defaultMessage: 'Mention @{name}' },
direct: { id: 'account.direct', defaultMessage: 'Direct message @{name}' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
@@ -79,6 +81,7 @@ class Header extends ImmutablePureComponent {
onBlock: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired,
onDirect: PropTypes.func.isRequired,
+ onConversations: PropTypes.func.isRequired,
onReblogToggle: PropTypes.func.isRequired,
onNotifyToggle: PropTypes.func.isRequired,
onReport: PropTypes.func.isRequired,
@@ -207,7 +210,12 @@ class Header extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.props.onDirect });
menu.push(null);
+
+ menu.push({ text: intl.formatMessage(messages.conversations, { name: account.get('username') }), action: this.props.onConversations });
+ } else {
+ menu.push({ text: intl.formatMessage(messages.conversations_all), action: this.props.onConversations });
}
+ menu.push(null);
if ('share' in navigator) {
menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare });
diff --git a/app/javascript/mastodon/features/account_conversations/index.js b/app/javascript/mastodon/features/account_conversations/index.js
new file mode 100644
index 000000000..4d137352d
--- /dev/null
+++ b/app/javascript/mastodon/features/account_conversations/index.js
@@ -0,0 +1,139 @@
+import React from 'react';
+import { connect } from 'react-redux';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { fetchAccount } from '../../actions/accounts';
+import { expandAccountCoversations } from '../../actions/timelines';
+import StatusList from '../../components/status_list';
+import LoadingIndicator from '../../components/loading_indicator';
+import Column from '../ui/components/column';
+import HeaderContainer from '../account_timeline/containers/header_container';
+import ColumnBackButton from '../../components/column_back_button';
+import { List as ImmutableList } from 'immutable';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { FormattedMessage } from 'react-intl';
+import { fetchAccountIdentityProofs } from '../../actions/identity_proofs';
+import MissingIndicator from 'mastodon/components/missing_indicator';
+import TimelineHint from 'mastodon/components/timeline_hint';
+
+const emptyList = ImmutableList();
+
+const mapStateToProps = (state, { params: { accountId } }) => {
+ return {
+ remote: !!(state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username'])),
+ remoteUrl: state.getIn(['accounts', accountId, 'url']),
+ isAccount: !!state.getIn(['accounts', accountId]),
+ statusIds: state.getIn(['timelines', `account:${accountId}:conversations`, 'items'], emptyList),
+ isLoading: state.getIn(['timelines', `account:${accountId}:conversations`, 'isLoading']),
+ hasMore: state.getIn(['timelines', `account:${accountId}:conversations`, 'hasMore']),
+ suspended: state.getIn(['accounts', accountId, 'suspended'], false),
+ blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
+ };
+};
+
+const RemoteHint = ({ url }) => (
+ } />
+);
+
+RemoteHint.propTypes = {
+ url: PropTypes.string.isRequired,
+};
+
+export default @connect(mapStateToProps)
+class AccountConversations extends ImmutablePureComponent {
+
+ static propTypes = {
+ params: PropTypes.object.isRequired,
+ dispatch: PropTypes.func.isRequired,
+ statusIds: ImmutablePropTypes.list,
+ isLoading: PropTypes.bool,
+ hasMore: PropTypes.bool,
+ withReplies: PropTypes.bool,
+ blockedBy: PropTypes.bool,
+ isAccount: PropTypes.bool,
+ suspended: PropTypes.bool,
+ remote: PropTypes.bool,
+ remoteUrl: PropTypes.string,
+ multiColumn: PropTypes.bool,
+ };
+
+ componentWillMount () {
+ const { params: { accountId }, dispatch } = this.props;
+
+ dispatch(fetchAccount(accountId));
+ dispatch(fetchAccountIdentityProofs(accountId));
+
+ dispatch(expandAccountCoversations(accountId));
+ }
+
+ componentWillReceiveProps (nextProps) {
+ const { dispatch } = this.props;
+
+ if (nextProps.params.accountId !== this.props.params.accountId && nextProps.params.accountId) {
+ dispatch(fetchAccount(nextProps.params.accountId));
+ dispatch(fetchAccountIdentityProofs(nextProps.params.accountId));
+
+ dispatch(expandAccountCoversations(nextProps.params.accountId));
+ }
+ }
+
+ handleLoadMore = maxId => {
+ this.props.dispatch(expandAccountCoversations(this.props.params.accountId, { maxId }));
+ }
+
+ render () {
+ const { statusIds, isLoading, hasMore, blockedBy, suspended, isAccount, multiColumn, remote, remoteUrl } = this.props;
+
+ if (!isAccount) {
+ return (
+
+
+
+
+ );
+ }
+
+ if (!statusIds && isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ let emptyMessage;
+
+ if (suspended) {
+ emptyMessage = ;
+ } else if (blockedBy) {
+ emptyMessage = ;
+ } else if (remote && statusIds.isEmpty()) {
+ emptyMessage = ;
+ } else {
+ emptyMessage = ;
+ }
+
+ const remoteMessage = remote ? : null;
+
+ return (
+
+
+
+ }
+ alwaysPrepend
+ append={remoteMessage}
+ scrollKey='account_conversations'
+ statusIds={(suspended || blockedBy) ? emptyList : statusIds}
+ isLoading={isLoading}
+ hasMore={hasMore}
+ onLoadMore={this.handleLoadMore}
+ emptyMessage={emptyMessage}
+ bindToDocument={!multiColumn}
+ timelineId='account_conversations'
+ />
+
+ );
+ }
+
+}
diff --git a/app/javascript/mastodon/features/account_timeline/components/header.js b/app/javascript/mastodon/features/account_timeline/components/header.js
index 61ecef566..846103311 100644
--- a/app/javascript/mastodon/features/account_timeline/components/header.js
+++ b/app/javascript/mastodon/features/account_timeline/components/header.js
@@ -19,6 +19,7 @@ export default class Header extends ImmutablePureComponent {
onBlock: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired,
onDirect: PropTypes.func.isRequired,
+ onConversations: PropTypes.func.isRequired,
onReblogToggle: PropTypes.func.isRequired,
onReport: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
@@ -63,6 +64,10 @@ export default class Header extends ImmutablePureComponent {
this.props.onDirect(this.props.account, this.context.router.history);
}
+ handleConversations = () => {
+ this.props.onConversations(this.props.account, this.context.router.history);
+ }
+
handleReport = () => {
this.props.onReport(this.props.account);
}
@@ -130,6 +135,7 @@ export default class Header extends ImmutablePureComponent {
onBlock={this.handleBlock}
onMention={this.handleMention}
onDirect={this.handleDirect}
+ onConversations={this.handleConversations}
onReblogToggle={this.handleReblogToggle}
onNotifyToggle={this.handleNotifyToggle}
onReport={this.handleReport}
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 67ca57828..c8c479193 100644
--- a/app/javascript/mastodon/features/account_timeline/containers/header_container.js
+++ b/app/javascript/mastodon/features/account_timeline/containers/header_container.js
@@ -99,6 +99,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(directCompose(account, router));
},
+ onConversations (account, router) {
+ router.push(`/accounts/${account.get('id')}/conversations`);
+ },
+
onReblogToggle (account) {
if (account.getIn(['relationship', 'showing_reblogs'])) {
dispatch(followAccount(account.get('id'), { reblogs: false }));
diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js
index 0683fc660..57a138742 100644
--- a/app/javascript/mastodon/features/ui/index.js
+++ b/app/javascript/mastodon/features/ui/index.js
@@ -34,6 +34,7 @@ import {
GroupTimeline,
AccountTimeline,
AccountGallery,
+ AccountConversations,
HomeTimeline,
Followers,
Following,
@@ -204,6 +205,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 6b5549b9e..16b89a713 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 AccountGallery () {
return import(/* webpackChunkName: "features/account_gallery" */'../../account_gallery');
}
+export function AccountConversations () {
+ return import(/* webpackChunkName: "features/account_conversations" */'../../account_conversations');
+}
+
export function Followers () {
return import(/* webpackChunkName: "features/followers" */'../../followers');
}
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index d478375c1..6901c922e 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -11,6 +11,8 @@
"account.blocked": "Blocked",
"account.browse_more_on_origin_server": "Browse more on the original profile",
"account.cancel_follow_request": "Cancel follow request",
+ "account.conversations": "Show conversations with @{name}",
+ "account.conversations_all": "Show all conversations",
"account.direct": "Direct message @{name}",
"account.disable_notifications": "Stop notifying me when @{name} posts",
"account.domain_blocked": "Domain blocked",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index 848ab6a93..73fa1271f 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -11,6 +11,8 @@
"account.blocked": "ブロック済み",
"account.browse_more_on_origin_server": "リモートで表示",
"account.cancel_follow_request": "フォローリクエストを取り消す",
+ "account.conversations": "@{name}さんとの会話を表示",
+ "account.conversations_all": "すべての会話を表示",
"account.direct": "@{name}さんにダイレクトメッセージ",
"account.disable_notifications": "@{name} の投稿時の通知を停止",
"account.domain_blocked": "ドメインブロック中",
diff --git a/app/models/account.rb b/app/models/account.rb
index fd5ce9eb8..3091b5e46 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -425,6 +425,14 @@ class Account < ApplicationRecord
end.permitted_for(self, account)
end
+ def conversation_statuses(account)
+ if account.nil? || account.class.name == 'Account' && account.id == id
+ Status.unscoped.recent.union_all(Status.include_expired.joins(:mentions).where(account_id: id, mentions: {silent: false})).union_all(Status.include_expired.joins(:mentions).where(mentions: {account_id: id, silent: false}))
+ else
+ Status.unscoped.recent.union_all(Status.include_expired.joins(:mentions).where(account_id: id, mentions: {account_id: account.id, silent: false})).union_all(Status.include_expired.joins(:mentions).where(account_id: account.id, mentions: {account_id: id, silent: false}))
+ end
+ end
+
def index_text
ActionController::Base.helpers.strip_tags(note)
end
diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb
index ec3abefbc..a952ba079 100644
--- a/app/serializers/rest/instance_serializer.rb
+++ b/app/serializers/rest/instance_serializer.rb
@@ -136,6 +136,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
:status_reference,
:searchability,
:status_compact_mode,
+ :account_conversations,
]
capabilities << :profile_search unless Chewy.enabled?
diff --git a/config/routes.rb b/config/routes.rb
index e639697fa..adee50d42 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -479,6 +479,7 @@ Rails.application.routes.draw do
resources :circles, only: :index, controller: 'accounts/circles'
resources :identity_proofs, only: :index, controller: 'accounts/identity_proofs'
resources :featured_tags, only: :index, controller: 'accounts/featured_tags'
+ resources :conversations, only: :index, controller: 'accounts/conversations'
member do
post :follow