Add a domain timeline

This commit is contained in:
noellabo 2019-11-14 07:42:56 +09:00
parent 92a9a23eb6
commit 3d6eaf638d
20 changed files with 319 additions and 12 deletions

View file

@ -39,6 +39,7 @@ class Api::V1::Timelines::PublicController < Api::BaseController
current_account,
local: truthy_param?(:local),
remote: truthy_param?(:remote),
domain: params[:domain],
only_media: truthy_param?(:only_media)
)
end
@ -48,7 +49,7 @@ class Api::V1::Timelines::PublicController < Api::BaseController
end
def pagination_params(core_params)
params.slice(:local, :remote, :limit, :only_media).permit(:local, :remote, :limit, :only_media).merge(core_params)
params.slice(:local, :remote, :domain, :limit, :only_media).permit(:local, :remote, :domain, :limit, :only_media).merge(core_params)
end
def next_path

View file

@ -126,9 +126,18 @@ export const connectCommunityStream = ({ onlyMedia } = {}) =>
connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`);
/**
* @param {string} domain
* @param {Object} options
* @param {boolean} [options.onlyMedia]
* @return {function(): void}
*/
export const connectDomainStream = (domain, { onlyMedia } = {}) =>
connectTimelineStream(`domain${onlyMedia ? ':media' : ''}:${domain}`, `public:domain${onlyMedia ? ':media' : ''}`, { domain: domain });
/**
* @param {Object} options
* @param {boolean} [options.onlyRemote]
* @param {boolean} [options.onlyMedia]
* @return {function(): void}
*/
export const connectPublicStream = ({ onlyMedia, onlyRemote } = {}) =>

View file

@ -131,6 +131,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {
export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done);
export const expandPublicTimeline = ({ maxId, onlyMedia, onlyRemote } = {}, done = noOp) => expandTimeline(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { remote: !!onlyRemote, max_id: maxId, only_media: !!onlyMedia }, done);
export const expandCommunityTimeline = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
export const expandDomainTimeline = (domain, { maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`domain${onlyMedia ? ':media' : ''}:${domain}`, '/api/v1/timelines/public', { local: false, domain: domain, max_id: maxId, only_media: !!onlyMedia }, 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 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 });

View file

@ -41,6 +41,7 @@ const messages = defineMessages({
copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
openDomainTimeline: { id: 'account.open_domain_timeline', defaultMessage: 'Open {domain} timeline' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
});
@ -192,6 +193,13 @@ class StatusActionBar extends ImmutablePureComponent {
onUnblockDomain(account.get('acct').split('@')[1]);
}
handleOpenDomainTimeline = () => {
const { status } = this.props;
const account = status.get('account');
this.context.router.history.push(`/timelines/public/domain/${account.get('acct').split('@')[1]}`);
}
handleOpen = () => {
this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
}
@ -292,6 +300,7 @@ class StatusActionBar extends ImmutablePureComponent {
} else {
menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.handleBlockDomain });
}
menu.push({ text: intl.formatMessage(messages.openDomainTimeline, { domain }), action: this.handleOpenDomainTimeline });
}
if (isStaff) {

View file

@ -0,0 +1,29 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { injectIntl, FormattedMessage } from 'react-intl';
import SettingToggle from '../../notifications/components/setting_toggle';
export default @injectIntl
class ColumnSettings extends React.PureComponent {
static propTypes = {
settings: ImmutablePropTypes.map.isRequired,
onChange: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
columnId: PropTypes.string,
};
render () {
const { settings, onChange } = this.props;
return (
<div>
<div className='column-settings__row'>
<SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} />
</div>
</div>
);
}
}

View file

@ -0,0 +1,28 @@
import { connect } from 'react-redux';
import ColumnSettings from '../components/column_settings';
import { changeSetting } from '../../../actions/settings';
import { changeColumnParams } from '../../../actions/columns';
const mapStateToProps = (state, { columnId }) => {
const uuid = columnId;
const columns = state.getIn(['settings', 'columns']);
const index = columns.findIndex(c => c.get('uuid') === uuid);
return {
settings: (uuid && index >= 0) ? columns.get(index).get('params') : state.getIn(['settings', 'domain']),
};
};
const mapDispatchToProps = (dispatch, { columnId }) => {
return {
onChange (key, checked) {
if (columnId) {
dispatch(changeColumnParams(columnId, key, checked));
} else {
dispatch(changeSetting(['domain', ...key], checked));
}
},
};
};
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);

View file

@ -0,0 +1,136 @@
import React from 'react';
import { connect } from 'react-redux';
import { injectIntl, FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
import StatusListContainer from '../ui/containers/status_list_container';
import Column from '../../components/column';
import ColumnHeader from '../../components/column_header';
import { expandDomainTimeline } from '../../actions/timelines';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import ColumnSettingsContainer from './containers/column_settings_container';
import { connectDomainStream } from '../../actions/streaming';
const mapStateToProps = (state, props) => {
const uuid = props.columnId;
const columns = state.getIn(['settings', 'columns']);
const index = columns.findIndex(c => c.get('uuid') === uuid);
const onlyMedia = (props.columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'domain', 'other', 'onlyMedia']);
const timelineState = state.getIn(['timelines', `domain${onlyMedia ? ':media' : ''}:${domain}`]);
const domain = props.params.domain;
return {
hasUnread: !!timelineState && timelineState.get('unread') > 0,
onlyMedia,
domain: domain,
};
};
export default @connect(mapStateToProps)
@injectIntl
class DomainTimeline extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static defaultProps = {
onlyMedia: false,
};
static propTypes = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
columnId: PropTypes.string,
intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool,
multiColumn: PropTypes.bool,
onlyMedia: PropTypes.bool,
domain: PropTypes.string,
};
handlePin = () => {
const { columnId, dispatch, onlyMedia, domain } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('DOMAIN', { domain, other: { onlyMedia } }));
}
}
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
}
handleHeaderClick = () => {
this.column.scrollTop();
}
componentDidMount () {
const { dispatch, onlyMedia, domain } = this.props;
dispatch(expandDomainTimeline(domain, { onlyMedia }));
this.disconnect = dispatch(connectDomainStream(domain, { onlyMedia }));
}
componentDidUpdate (prevProps) {
if (prevProps.onlyMedia !== this.props.onlyMedia || prevProps.domain !== this.props.domain) {
const { dispatch, onlyMedia, domain } = this.props;
this.disconnect();
dispatch(expandDomainTimeline(domain, { onlyMedia }));
this.disconnect = dispatch(connectDomainStream(domain, { onlyMedia }));
}
}
componentWillUnmount () {
if (this.disconnect) {
this.disconnect();
this.disconnect = null;
}
}
setRef = c => {
this.column = c;
}
handleLoadMore = maxId => {
const { dispatch, onlyMedia, domain } = this.props;
dispatch(expandDomainTimeline(domain, { maxId, onlyMedia }));
}
render () {
const { hasUnread, columnId, multiColumn, onlyMedia, domain } = this.props;
const pinned = !!columnId;
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={domain}>
<ColumnHeader
icon='users'
active={hasUnread}
title={domain}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
showBackButton
>
<ColumnSettingsContainer columnId={columnId} />
</ColumnHeader>
<StatusListContainer
trackScroll={!pinned}
scrollKey={`domain_timeline-${columnId}`}
timelineId={`domain${onlyMedia ? ':media' : ''}:${domain}`}
onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.domain' defaultMessage='There is nothing here! Manually follow users from other servers to fill it up' />}
bindToDocument={!multiColumn}
/>
</Column>
);
}
}

View file

@ -37,6 +37,7 @@ const messages = defineMessages({
copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
blockDomain: { id: 'account.block_domain', defaultMessage: 'Block domain {domain}' },
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
openDomainTimeline: { id: 'account.open_domain_timeline', defaultMessage: 'Open {domain} timeline' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
});
@ -149,6 +150,13 @@ class ActionBar extends React.PureComponent {
onUnblockDomain(account.get('acct').split('@')[1]);
}
handleOpenDomainTimeline = () => {
const { status } = this.props;
const account = status.get('account');
this.context.router.history.push(`/timelines/public/domain/${account.get('acct').split('@')[1]}`);
}
handleConversationMuteClick = () => {
this.props.onMuteConversation(this.props.status);
}
@ -246,6 +254,7 @@ class ActionBar extends React.PureComponent {
} else {
menu.push({ text: intl.formatMessage(messages.blockDomain, { domain }), action: this.handleBlockDomain });
}
menu.push({ text: intl.formatMessage(messages.openDomainTimeline, { domain }), action: this.handleOpenDomainTimeline });
}
if (isStaff) {

View file

@ -20,6 +20,7 @@ import {
HomeTimeline,
CommunityTimeline,
PublicTimeline,
DomainTimeline,
HashtagTimeline,
DirectTimeline,
FavouritedStatuses,
@ -41,6 +42,7 @@ const componentMap = {
'PUBLIC': PublicTimeline,
'REMOTE': PublicTimeline,
'COMMUNITY': CommunityTimeline,
'DOMAIN': DomainTimeline,
'HASHTAG': HashtagTimeline,
'DIRECT': DirectTimeline,
'FAVOURITES': FavouritedStatuses,

View file

@ -29,6 +29,7 @@ import {
KeyboardShortcuts,
PublicTimeline,
CommunityTimeline,
DomainTimeline,
AccountTimeline,
AccountGallery,
HomeTimeline,
@ -155,6 +156,7 @@ class SwitchingColumnsArea extends React.PureComponent {
<WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />
<WrappedRoute path='/timelines/public' exact component={PublicTimeline} content={children} />
<WrappedRoute path='/timelines/public/local' exact component={CommunityTimeline} content={children} />
<WrappedRoute path='/timelines/public/domain/:domain' exact component={DomainTimeline} content={children} />
<WrappedRoute path='/timelines/direct' component={DirectTimeline} content={children} />
<WrappedRoute path='/timelines/tag/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/timelines/list/:id' component={ListTimeline} content={children} />

View file

@ -22,6 +22,10 @@ export function CommunityTimeline () {
return import(/* webpackChunkName: "features/community_timeline" */'../../community_timeline');
}
export function DomainTimeline () {
return import(/* webpackChunkName: "features/domain_timeline" */'../../domain_timeline');
}
export function HashtagTimeline () {
return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline');
}

View file

@ -33,6 +33,7 @@
"account.mute_notifications": "Mute notifications from @{name}",
"account.muted": "Muted",
"account.never_active": "Never",
"account.open_domain_timeline": "Open {domain} timeline",
"account.posts": "Posts",
"account.posts_with_replies": "Posts and replies",
"account.report": "Report @{name}",

View file

@ -33,6 +33,7 @@
"account.mute_notifications": "@{name}さんからの通知を受け取らない",
"account.muted": "ミュート済み",
"account.never_active": "活動なし",
"account.open_domain_timeline": "{domain}タイムラインを表示",
"account.posts": "投稿",
"account.posts_with_replies": "投稿と返信",
"account.report": "@{name}さんを通報",

View file

@ -74,6 +74,12 @@ const initialState = ImmutableMap({
}),
}),
domain: ImmutableMap({
regex: ImmutableMap({
body: '',
}),
}),
public: ImmutableMap({
regex: ImmutableMap({
body: '',

View file

@ -103,6 +103,8 @@ const sharedCallbacks = {
return channelName === streamChannelName && params.tag === streamIdentifier;
} else if (channelName === 'list') {
return channelName === streamChannelName && params.list === streamIdentifier;
} else if (['public:domain', 'public:domain:media'].includes(channelName)) {
return channelName === streamChannelName && params.domain === streamIdentifier;
}
return false;

View file

@ -25,6 +25,7 @@ class PublicFeed
scope.merge!(without_reblogs_scope) unless with_reblogs?
scope.merge!(local_only_scope) if local_only?
scope.merge!(remote_only_scope) if remote_only?
scope.merge!(domain_only_scope) if domain_only?
scope.merge!(account_filters_scope) if account?
scope.merge!(media_only_scope) if media_only?
@ -51,6 +52,10 @@ class PublicFeed
options[:remote]
end
def domain_only?
@options[:domain].present?
end
def account?
account.present?
end
@ -59,6 +64,10 @@ class PublicFeed
options[:only_media]
end
def domain
@options[:domain]
end
def public_scope
Status.with_public_visibility.joins(:account).merge(Account.without_suspended.without_silenced)
end
@ -71,6 +80,10 @@ class PublicFeed
Status.remote
end
def domain_only_scope
Status.joins(:account).merge(Account.where(domain: domain))
end
def without_replies_scope
Status.without_replies
end

View file

@ -76,18 +76,31 @@ class BatchedRemoveStatusService < BaseService
return unless status.public_visibility? && status.id > @status_id_cutoff
payload = Oj.dump(event: :delete, payload: status.id.to_s)
domain = status.account.domain.mb_chars.downcase
redis.publish('timeline:public', payload)
redis.publish(status.local? ? 'timeline:public:local' : 'timeline:public:remote', payload)
redis.pipelined do
redis.publish('timeline:public', payload)
if status.local?
redis.publish('timeline:public:local', payload)
else
redis.publish('timeline:public:remote', payload)
redis.publish("timeline:public:domain:#{domain}", payload)
end
if status.media_attachments.any?
redis.publish('timeline:public:media', payload)
redis.publish(status.local? ? 'timeline:public:local:media' : 'timeline:public:remote:media', payload)
end
if status.media_attachments.any?
redis.publish('timeline:public:media', payload)
if status.local?
redis.publish('timeline:public:local:media', payload)
else
redis.publish('timeline:public:remote:media', payload)
redis.publish("timeline:public:domain:media:#{domain}", payload)
end
end
status.tags.map { |tag| tag.name.mb_chars.downcase }.each do |hashtag|
redis.publish("timeline:hashtag:#{hashtag}", payload)
redis.publish("timeline:hashtag:#{hashtag}:local", payload) if status.local?
status.tags.map { |tag| tag.name.mb_chars.downcase }.each do |hashtag|
redis.publish("timeline:hashtag:#{hashtag}", payload)
redis.publish("timeline:hashtag:#{hashtag}:local", payload) if status.local?
end
end
end
end

View file

@ -170,6 +170,7 @@ class FanOutOnWriteService < BaseService
Redis.current.publish('timeline:public:local', @payload)
else
Redis.current.publish('timeline:public:remote', @payload)
Redis.current.publish("timeline:public:domain:#{status.account.domain.mb_chars.downcase}", @payload)
end
end
@ -181,6 +182,7 @@ class FanOutOnWriteService < BaseService
Redis.current.publish('timeline:public:local:media', @payload)
else
Redis.current.publish('timeline:public:remote:media', @payload)
Redis.current.publish("timeline:public:domain:media:#{status.account.domain.mb_chars.downcase}", @payload)
end
end

View file

@ -124,14 +124,24 @@ class RemoveStatusService < BaseService
return unless @status.public_visibility?
redis.publish('timeline:public', @payload)
redis.publish(@status.local? ? 'timeline:public:local' : 'timeline:public:remote', @payload)
if @status.local?
redis.publish('timeline:public:local', @payload)
else
redis.publish('timeline:public:remote', @payload)
redis.publish("timeline:public:domain:#{@account.domain.mb_chars.downcase}", @payload)
end
end
def remove_from_media
return unless @status.public_visibility?
redis.publish('timeline:public:media', @payload)
redis.publish(@status.local? ? 'timeline:public:local:media' : 'timeline:public:remote:media', @payload)
if @status.local?
redis.publish('timeline:public:local:media', @payload)
else
redis.publish('timeline:public:remote:media', @payload)
redis.publish("timeline:public:domain:media:#{@account.domain.mb_chars.downcase}", @payload)
end
end
def remove_media

View file

@ -369,6 +369,8 @@ const startWorker = (workerId) => {
return onlyMedia ? 'public:local:media' : 'public:local';
case '/api/v1/streaming/public/remote':
return onlyMedia ? 'public:remote:media' : 'public:remote';
case '/api/v1/streaming/public/domain':
return onlyMedia ? 'public:domain:media' : 'public:domain';
case '/api/v1/streaming/hashtag':
return 'hashtag';
case '/api/v1/streaming/hashtag/local':
@ -389,6 +391,8 @@ const startWorker = (workerId) => {
'public:local:media',
'public:remote',
'public:remote:media',
'public:domain',
'public:domain:media',
'hashtag',
'hashtag:local',
];
@ -772,6 +776,7 @@ const startWorker = (workerId) => {
* @typedef StreamParams
* @property {string} [tag]
* @property {string} [list]
* @property {string} [domain]
* @property {string} [only_media]
*/
@ -817,6 +822,17 @@ const startWorker = (workerId) => {
options: { needsFiltering: true, notificationOnly: false },
});
break;
case 'public:domain':
if (!params.domain || params.domain.length === 0) {
reject('No domain for stream provided');
} else {
resolve({
channelIds: [`timeline:public:domain:${params.domain.toLowerCase()}`],
options: { needsFiltering: true, notificationOnly: false },
});
}
break;
case 'public:media':
resolve({
@ -838,6 +854,17 @@ const startWorker = (workerId) => {
options: { needsFiltering: true, notificationOnly: false },
});
break;
case 'public:domain:media':
if (!params.domain || params.domain.length === 0) {
reject('No domain for stream provided');
} else {
resolve({
channelIds: [`timeline:public:domain:media:${params.domain.toLowerCase()}`],
options: { needsFiltering: true, notificationOnly: false },
});
}
break;
case 'direct':
resolve({
@ -894,6 +921,8 @@ const startWorker = (workerId) => {
return [channelName, params.list];
} else if (['hashtag', 'hashtag:local'].includes(channelName)) {
return [channelName, params.tag];
} else if (['public:domain', 'public:domain:media'].includes(channelName)) {
return [channelName, params.domain];
} else {
return [channelName];
}