Add account conversations column

This commit is contained in:
noellabo 2022-09-12 02:42:05 +09:00
parent ccafeaf32f
commit 4b3f4be472
13 changed files with 258 additions and 0 deletions

View file

@ -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

View file

@ -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);

View file

@ -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 });

View file

@ -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 }) => (
<TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.statuses' defaultMessage='Older toots' />} />
);
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 (
<Column>
<ColumnBackButton multiColumn={multiColumn} />
<MissingIndicator />
</Column>
);
}
if (!statusIds && isLoading) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
let emptyMessage;
if (suspended) {
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
} else if (blockedBy) {
emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
} else if (remote && statusIds.isEmpty()) {
emptyMessage = <RemoteHint url={remoteUrl} />;
} else {
emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />;
}
const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;
return (
<Column>
<ColumnBackButton multiColumn={multiColumn} />
<StatusList
prepend={<HeaderContainer accountId={this.props.params.accountId} />}
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'
/>
</Column>
);
}
}

View file

@ -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}

View file

@ -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 }));

View file

@ -34,6 +34,7 @@ import {
GroupTimeline,
AccountTimeline,
AccountGallery,
AccountConversations,
HomeTimeline,
Followers,
Following,
@ -204,6 +205,7 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path='/accounts/:accountId/following' component={Following} content={children} />
<WrappedRoute path='/accounts/:accountId/subscribing' component={Subscribing} content={children} />
<WrappedRoute path='/accounts/:accountId/media' component={AccountGallery} content={children} />
<WrappedRoute path='/accounts/:accountId/conversations' component={AccountConversations} content={children} />
<WrappedRoute path='/follow_requests' component={FollowRequests} content={children} />
<WrappedRoute path='/blocks' component={Blocks} content={children} />

View file

@ -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');
}

View file

@ -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",

View file

@ -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": "ドメインブロック中",

View file

@ -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

View file

@ -136,6 +136,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
:status_reference,
:searchability,
:status_compact_mode,
:account_conversations,
]
capabilities << :profile_search unless Chewy.enabled?

View file

@ -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