From 4b3f4be472b6e9980e11e49c68181cf26c84666f Mon Sep 17 00:00:00 2001 From: noellabo Date: Mon, 12 Sep 2022 02:42:05 +0900 Subject: [PATCH] Add account conversations column --- .../v1/accounts/conversations_controller.rb | 80 ++++++++++ app/javascript/mastodon/actions/timelines.js | 1 + .../features/account/components/header.js | 8 + .../features/account_conversations/index.js | 139 ++++++++++++++++++ .../account_timeline/components/header.js | 6 + .../containers/header_container.js | 4 + app/javascript/mastodon/features/ui/index.js | 2 + .../features/ui/util/async-components.js | 4 + app/javascript/mastodon/locales/en.json | 2 + app/javascript/mastodon/locales/ja.json | 2 + app/models/account.rb | 8 + app/serializers/rest/instance_serializer.rb | 1 + config/routes.rb | 1 + 13 files changed, 258 insertions(+) create mode 100644 app/controllers/api/v1/accounts/conversations_controller.rb create mode 100644 app/javascript/mastodon/features/account_conversations/index.js 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