Add group timeline

This commit is contained in:
noellabo 2020-07-12 22:00:23 +09:00
parent 08d250d633
commit 110421c8a0
42 changed files with 1047 additions and 22 deletions

View file

@ -0,0 +1,88 @@
# frozen_string_literal: true
class Api::V1::Timelines::GroupController < Api::BaseController
before_action :load_group
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
def show
@statuses = load_statuses
render json: @statuses, each_serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id)
end
private
def load_group
@group = Account.groups.find(params[:id])
end
def load_statuses
cached_group_statuses
end
def cached_group_statuses
cache_collection group_statuses, Status
end
def group_statuses
if @group.nil?
[]
else
statuses = group_timeline_statuses.to_a_paginated_by_id(
limit_param(DEFAULT_STATUSES_LIMIT),
params_slice(:max_id, :since_id, :min_id)
)
statuses.merge!(no_replies_scope) if truthy_param?(:exclude_replies)
statuses.merge!(hashtag_scope) if params[:tagged].present?
if truthy_param?(:only_media)
# `SELECT DISTINCT id, updated_at` is too slow, so pluck ids at first, and then select id, updated_at with ids.
status_ids = statuses.joins(:media_attachments).distinct(:id).pluck(:id)
statuses.where(id: status_ids)
else
statuses
end
end
end
def group_timeline_statuses
@group.permitted_group_statuses(current_account)
end
def no_replies_scope
Status.without_replies
end
def hashtag_scope
tag = Tag.find_normalized(params[:tagged])
if tag
Status.tagged_with(tag.id)
else
Status.none
end
end
def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end
def pagination_params(core_params)
params.slice(:limit, :only_media, :exclude_replies).permit(:limit, :only_media, :exclude_replies).merge(core_params)
end
def next_path
api_v1_timelines_group_url params[:id], pagination_params(max_id: pagination_max_id)
end
def prev_path
api_v1_timelines_group_url params[:id], pagination_params(min_id: pagination_since_id)
end
def pagination_max_id
@statuses.last.id
end
def pagination_since_id
@statuses.first.id
end
end

View file

@ -126,6 +126,16 @@ export const connectUserStream = () =>
export const connectDomainStream = (domain, { onlyMedia } = {}) =>
connectTimelineStream(`domain${onlyMedia ? ':media' : ''}:${domain}`, `public:domain${onlyMedia ? ':media' : ''}`, { domain: domain });
/**
* @param {string} id
* @param {Object} options
* @param {boolean} [options.onlyMedia]
* @param {string} [options.tagged]
* @return {function(): void}
*/
export const connectGroupStream = (id, { onlyMedia, tagged } = {}) =>
connectTimelineStream(`group:${id}${onlyMedia ? ':media' : ''}${tagged ? `:${tagged}` : ''}`, `group${onlyMedia ? ':media' : ''}`, { id: id, tagged: tagged });
/**
* @param {Object} options
* @param {boolean} [options.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 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 expandGroupTimeline = (id, { maxId, onlyMedia, tagged } = {}, done = noOp) => expandTimeline(`group:${id}${onlyMedia ? ':media' : ''}${tagged ? `:${tagged}` : ''}`, `/api/v1/timelines/group/${id}`, { max_id: maxId, only_media: !!onlyMedia, 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 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

@ -146,7 +146,7 @@ class Account extends ImmutablePureComponent {
return (
<div className='account'>
<div className='account__wrapper'>
<Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}>
<Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`${(account.get('group', false)) ? '/timelines/groups/' : '/accounts/'}${account.get('id')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
{mute_expires_at}
<DisplayName account={account} />

View file

@ -5,7 +5,7 @@ import { FormattedMessage } from 'react-intl';
/**
* Returns custom renderer for one of the common counter types
*
* @param {"statuses" | "following" | "followers"} counterType
* @param {"statuses" | "following" | "followers" | "members" | "subscribers"} counterType
* Type of the counter
* @param {boolean} isBold Whether display number must be displayed in bold
* @returns {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
@ -57,6 +57,18 @@ export function counterRenderer(counterType, isBold = true) {
/>
);
}
case 'members': {
return (displayNumber, pluralReady) => (
<FormattedMessage
id='account.members_counter'
defaultMessage='{count, plural, one {{counter} Follower} other {{counter} Members}}'
values={{
count: pluralReady,
counter: renderCounter(displayNumber),
}}
/>
);
}
case 'subscribers': {
return (displayNumber, pluralReady) => (
<FormattedMessage

View file

@ -222,8 +222,13 @@ class Status extends ImmutablePureComponent {
handleAccountClick = (e) => {
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
const id = e.currentTarget.getAttribute('data-id');
const group = e.currentTarget.getAttribute('data-group') !== 'false';
e.preventDefault();
this.context.router.history.push(`/accounts/${id}`);
if (group) {
this.context.router.history.push(`/timelines/groups/${id}`);
} else {
this.context.router.history.push(`/accounts/${id}`);
}
}
}
@ -422,7 +427,7 @@ class Status extends ImmutablePureComponent {
prepend = (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><Icon id='retweet' className='status__prepend-icon' fixedWidth /></div>
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} data-group={status.getIn(['account', 'group'])} href={status.getIn(['account', 'url'])} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
</div>
);
@ -680,7 +685,7 @@ class Status extends ImmutablePureComponent {
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
<a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
<a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} data-group={status.getIn(['account', 'group'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
<div className='status__avatar'>
{statusAvatar}
</div>

View file

@ -50,7 +50,11 @@ export default class StatusContent extends React.PureComponent {
let mention = this.props.status.get('mentions').find(item => link.href === item.get('url'));
if (mention) {
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
if (mention.get('group', false)) {
link.addEventListener('click', this.onGroupMentionClick.bind(this, mention), false);
} else {
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
}
link.setAttribute('title', mention.get('acct'));
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
@ -117,6 +121,13 @@ export default class StatusContent extends React.PureComponent {
}
}
onGroupMentionClick = (mention, e) => {
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.context.router.history.push(`/timelines/groups/${mention.get('id')}`);
}
}
onHashtagClick = (hashtag, e) => {
hashtag = hashtag.replace(/^#/, '');
@ -219,7 +230,7 @@ export default class StatusContent extends React.PureComponent {
let mentionsPlaceholder = '';
const mentionLinks = status.get('mentions').map(item => (
<Permalink to={`/accounts/${item.get('id')}`} href={item.get('url')} key={item.get('id')} className='mention'>
<Permalink to={`${(item.get('group', false)) ? '/timelines/groups/' : '/accounts/'}${item.get('id')}`} href={item.get('url')} key={item.get('id')} className='mention'>
@<span>{item.get('username')}</span>
</Permalink>
)).reduce((aggregate, item) => [...aggregate, item, ' '], []);

View file

@ -20,8 +20,13 @@ export default class MovedNote extends ImmutablePureComponent {
handleAccountClick = e => {
if (e.button === 0) {
const { to } = this.props;
e.preventDefault();
this.context.router.history.push(`/accounts/${this.props.to.get('id')}`);
if (to.get('group')) {
this.context.router.history.push(`/timelines/groups/${to.get('id')}`);
} else {
this.context.router.history.push(`/accounts/${to.get('id')}`);
}
}
e.stopPropagation();

View file

@ -19,13 +19,13 @@ export default class NavigationBar extends ImmutablePureComponent {
render () {
return (
<div className='navigation-bar'>
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
<Permalink href={this.props.account.get('url')} to={`${(this.props.account.get('group', false)) ? '/timelines/groups/' : '/accounts/'}${this.props.account.get('id')}`}>
<span style={{ display: 'none' }}>{this.props.account.get('acct')}</span>
<Avatar account={this.props.account} size={48} />
</Permalink>
<div className='navigation-bar__profile'>
<Permalink href={this.props.account.get('url')} to={`/accounts/${this.props.account.get('id')}`}>
<Permalink href={this.props.account.get('url')} to={`${(this.props.account.get('group', false)) ? '/timelines/groups/' : '/accounts/'}${this.props.account.get('id')}`}>
<strong className='navigation-bar__profile-account'>@{this.props.account.get('acct')}</strong>
</Permalink>

View file

@ -31,8 +31,13 @@ class ReplyIndicator extends ImmutablePureComponent {
handleAccountClick = (e) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
const { status } = this.props;
e.preventDefault();
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
if (status.getIn(['account', 'group'], false)) {
this.context.router.history.push(`/timelines/groups/${status.getIn(['account', 'id'])}`);
} else {
this.context.router.history.push(`/accounts/${status.getIn(['account', 'id'])}`);
}
}
}

View file

@ -133,7 +133,7 @@ class Conversation extends ImmutablePureComponent {
menu.push({ text: intl.formatMessage(messages.delete), action: this.handleDelete });
const names = accounts.map(a => <Permalink to={`/accounts/${a.get('id')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Permalink>).reduce((prev, cur) => [prev, ', ', cur]);
const names = accounts.map(a => <Permalink to={`${(a.get('group', false)) ? '/timelines/groups/' : '/accounts/'}${a.get('id')}`} href={a.get('url')} key={a.get('id')} title={a.get('acct')}><bdi><strong className='display-name__html' dangerouslySetInnerHTML={{ __html: a.get('display_name_html') }} /></bdi></Permalink>).reduce((prev, cur) => [prev, ', ', cur]);
const handlers = {
reply: this.handleReply,

View file

@ -281,7 +281,7 @@ class AccountCard extends ImmutablePureComponent {
<Permalink
className='directory__card__bar__name'
href={account.get('url')}
to={`/accounts/${account.get('id')}`}
to={`${(account.get('group', false)) ? '/timelines/groups/' : '/accounts/'}${account.get('id')}`}
>
<Avatar account={account} size={48} />
<DisplayName account={account} />

View file

@ -30,7 +30,7 @@ class AccountAuthorize extends ImmutablePureComponent {
return (
<div className='account-authorize__wrapper'>
<div className='account-authorize'>
<Permalink href={account.get('url')} to={`/accounts/${account.get('id')}`} className='detailed-status__display-name'>
<Permalink href={account.get('url')} to={`${(account.get('group', false)) ? '/timelines/groups/' : '/accounts/'}${account.get('id')}`} className='detailed-status__display-name'>
<div className='account-authorize__avatar'><Avatar account={account} size={48} /></div>
<DisplayName account={account} />
</Permalink>

View file

@ -66,7 +66,11 @@ class Content extends ImmutablePureComponent {
let mention = this.props.announcement.get('mentions').find(item => link.href === item.get('url'));
if (mention) {
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
if (mention.get('group', false)) {
link.addEventListener('click', this.onGroupMentionClick.bind(this, mention), false);
} else {
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
}
link.setAttribute('title', mention.get('acct'));
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
@ -91,6 +95,13 @@ class Content extends ImmutablePureComponent {
}
}
onGroupMentionClick = (mention, e) => {
if (this.context.router && e.button === 0 && !(e.ctrlKey || e.metaKey)) {
e.preventDefault();
this.context.router.history.push(`/timelines/groups/${mention.get('id')}`);
}
}
onHashtagClick = (hashtag, e) => {
hashtag = hashtag.replace(/^#/, '');

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='group.column_settings.media_only' defaultMessage='Media only' />} />
</div>
</div>
);
}
}

View file

@ -0,0 +1,264 @@
import React from 'react';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { counterRenderer } from 'mastodon/components/common_counter';
import { makeGetAccount } from 'mastodon/selectors';
import Avatar from 'mastodon/components/avatar';
import DisplayName from 'mastodon/components/display_name';
import IconButton from 'mastodon/components/icon_button';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import { autoPlayGif, me, unfollowModal } from 'mastodon/initial_state';
import ShortNumber from 'mastodon/components/short_number';
import {
followAccount,
unfollowAccount,
blockAccount,
unblockAccount,
unmuteAccount,
} from 'mastodon/actions/accounts';
import { openModal } from 'mastodon/actions/modal';
import { initMuteModal } from 'mastodon/actions/mutes';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
unfollowConfirm: {
id: 'confirmations.unfollow.confirm',
defaultMessage: 'Unfollow',
},
});
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { id }) => ({
account: getAccount(state, id),
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { intl }) => ({
onFollow(account) {
if (
account.getIn(['relationship', 'following']) ||
account.getIn(['relationship', 'requested'])
) {
if (unfollowModal) {
dispatch(
openModal('CONFIRM', {
message: (
<FormattedMessage
id='confirmations.unfollow.message'
defaultMessage='Are you sure you want to unfollow {name}?'
values={{ name: <strong>@{account.get('acct')}</strong> }}
/>
),
confirm: intl.formatMessage(messages.unfollowConfirm),
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
}),
);
} else {
dispatch(unfollowAccount(account.get('id')));
}
} else {
dispatch(followAccount(account.get('id')));
}
},
onBlock(account) {
if (account.getIn(['relationship', 'blocking'])) {
dispatch(unblockAccount(account.get('id')));
} else {
dispatch(blockAccount(account.get('id')));
}
},
onMute(account) {
if (account.getIn(['relationship', 'muting'])) {
dispatch(unmuteAccount(account.get('id')));
} else {
dispatch(initMuteModal(account));
}
},
});
export default
@injectIntl
@connect(makeMapStateToProps, mapDispatchToProps)
class GroupDetail extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
};
_updateEmojis() {
const node = this.node;
if (!node || autoPlayGif) {
return;
}
const emojis = node.querySelectorAll('.custom-emoji');
for (var i = 0; i < emojis.length; i++) {
let emoji = emojis[i];
if (emoji.classList.contains('status-emoji')) {
continue;
}
emoji.classList.add('status-emoji');
emoji.addEventListener('mouseenter', this.handleEmojiMouseEnter, false);
emoji.addEventListener('mouseleave', this.handleEmojiMouseLeave, false);
}
}
componentDidMount() {
this._updateEmojis();
}
componentDidUpdate() {
this._updateEmojis();
}
handleEmojiMouseEnter = ({ target }) => {
target.src = target.getAttribute('data-original');
};
handleEmojiMouseLeave = ({ target }) => {
target.src = target.getAttribute('data-static');
};
handleFollow = () => {
this.props.onFollow(this.props.account);
};
handleBlock = () => {
this.props.onBlock(this.props.account);
};
handleMute = () => {
this.props.onMute(this.props.account);
};
setRef = (c) => {
this.node = c;
};
render() {
const { account, intl } = this.props;
let buttons;
if (
account.get('id') !== me &&
account.get('relationship', null) !== null
) {
const following = account.getIn(['relationship', 'following']);
const requested = account.getIn(['relationship', 'requested']);
const blocking = account.getIn(['relationship', 'blocking']);
const muting = account.getIn(['relationship', 'muting']);
if (requested) {
buttons = (
<IconButton
disabled
icon='hourglass'
title={intl.formatMessage(messages.requested)}
/>
);
} else if (blocking) {
buttons = (
<IconButton
active
icon='unlock'
title={intl.formatMessage(messages.unblock, {
name: account.get('username'),
})}
onClick={this.handleBlock}
/>
);
} else if (muting) {
buttons = (
<IconButton
active
icon='volume-up'
title={intl.formatMessage(messages.unmute, {
name: account.get('username'),
})}
onClick={this.handleMute}
/>
);
} else if (!account.get('moved') || following) {
buttons = (
<IconButton
icon={following ? 'user-times' : 'user-plus'}
title={intl.formatMessage(
following ? messages.unfollow : messages.follow,
)}
onClick={this.handleFollow}
active={following}
/>
);
}
}
return (
<div className='group__detail'>
<div className='group__detail__img'>
<img
src={
autoPlayGif ? account.get('header') : account.get('header_static')
}
alt=''
/>
</div>
<div className='group__detail__bar'>
<a target='_blank' href={account.get('url')} className={'group__detail__bar__name'}>
<Avatar account={account} size={48} />
<DisplayName account={account} />
</a>
<div className='group__detail__bar__relationship account__relationship'>
{buttons}
</div>
</div>
<div className='group__detail__extra' ref={this.setRef}>
<div
className='group__header__content'
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
/>
</div>
<div className='group__detail__extra'>
<div className='group__header__links'>
<a title={intl.formatNumber(account.get('statuses_count'))}>
<ShortNumber
value={account.get('statuses_count')}
renderer={counterRenderer('statuses')}
/>
</a>
<a title={intl.formatNumber(account.get('followers_count'))}>
<ShortNumber
value={account.get('followers_count')}
renderer={counterRenderer('members')}
/>
</a>
</div>
</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', 'group']),
};
};
const mapDispatchToProps = (dispatch, { columnId }) => {
return {
onChange (key, checked) {
if (columnId) {
dispatch(changeColumnParams(columnId, key, checked));
} else {
dispatch(changeSetting(['group', ...key], checked));
}
},
};
};
export default connect(mapStateToProps, mapDispatchToProps)(ColumnSettings);

View file

@ -0,0 +1,206 @@
import React from 'react';
import { connect } from 'react-redux';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import classNames from 'classnames';
import StatusListContainer from '../ui/containers/status_list_container';
import Column from '../../components/column';
import ColumnHeader from '../../components/column_header';
import Icon from '../../components/icon';
import { fetchAccount } from '../../actions/accounts';
import { makeGetAccount } from 'mastodon/selectors';
import { expandGroupTimeline } from '../../actions/timelines';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import ColumnSettingsContainer from './containers/column_settings_container';
import GroupDetail from './components/group_detail';
import { connectGroupStream } from '../../actions/streaming';
const messages = defineMessages({
title: { id: 'column.group', defaultMessage: 'Group timeline' },
show_group_detail: { id: 'home.show_group_detail', defaultMessage: 'Show group detail' },
hide_group_detail: { id: 'home.hide_group_detail', defaultMessage: 'Hide group detail' },
});
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { columnId, params: { id, tagged } }) => {
const uuid = columnId;
const columns = state.getIn(['settings', 'columns']);
const index = columns.findIndex(c => c.get('uuid') === uuid);
const onlyMedia = (columnId && index >= 0) ? columns.get(index).getIn(['params', 'other', 'onlyMedia']) : state.getIn(['settings', 'group', 'other', 'onlyMedia']);
const timelineState = state.getIn(['timelines', `group:${id}${onlyMedia ? ':media' : ''}${tagged ? `:${tagged}` : ''}`]);
const account = getAccount(state, id);
return {
hasUnread: !!timelineState && timelineState.get('unread') > 0,
onlyMedia,
account,
};
};
return mapStateToProps;
};
export default @connect(makeMapStateToProps)
@injectIntl
class GroupTimeline extends React.PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static defaultProps = {
onlyMedia: false,
};
static propTypes = {
account: ImmutablePropTypes.map.isRequired,
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
columnId: PropTypes.string,
intl: PropTypes.object.isRequired,
hasUnread: PropTypes.bool,
multiColumn: PropTypes.bool,
onlyMedia: PropTypes.bool,
};
state = {
collapsed: true,
animating: false,
};
handlePin = () => {
const { columnId, dispatch, onlyMedia, params: { id, tagged } } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('GROUP', { id: id, other: { onlyMedia, tagged } }));
}
}
handleMove = (dir) => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
}
handleHeaderClick = () => {
this.column.scrollTop();
}
componentDidMount () {
const { dispatch, onlyMedia, params: { id, tagged } } = this.props;
dispatch(fetchAccount(id));
dispatch(expandGroupTimeline(id, { onlyMedia, tagged }));
this.disconnect = dispatch(connectGroupStream(id, { onlyMedia, tagged }));
}
componentDidUpdate (prevProps) {
const { dispatch, onlyMedia, params: { id, tagged } } = this.props;
if (prevProps.params.id !== id || prevProps.onlyMedia !== onlyMedia || prevProps.tagged !== tagged) {
this.disconnect();
dispatch(expandGroupTimeline(id, { onlyMedia, tagged }));
this.disconnect = dispatch(connectGroupStream(id, { onlyMedia, tagged }));
}
}
componentWillUnmount () {
if (this.disconnect) {
this.disconnect();
this.disconnect = null;
}
}
setRef = c => {
this.column = c;
}
handleLoadMore = maxId => {
const { dispatch, onlyMedia, params: { id, tagged } } = this.props;
dispatch(expandGroupTimeline(id, { maxId, onlyMedia, tagged }));
}
handleToggleClick = (e) => {
e.stopPropagation();
this.setState({ collapsed: !this.state.collapsed, animating: true });
}
handleTransitionEnd = () => {
this.setState({ animating: false });
}
render () {
const { intl, hasUnread, columnId, multiColumn, onlyMedia, params: { id, tagged }, account } = this.props;
const pinned = !!columnId;
const { collapsed, animating } = this.state;
if (!account) {
return <div />;
}
const collapsibleClassName = classNames('column-header__collapsible', {
'collapsed': collapsed,
'animating': animating,
});
const collapsibleButtonClassName = classNames('column-header__button', {
'active': !collapsed,
});
const groupDetailButton = (
<button
className={collapsibleButtonClassName}
title={intl.formatMessage(collapsed ? messages.hide_group_detail : messages.show_group_detail)}
aria-label={intl.formatMessage(collapsed ? messages.hide_group_detail : messages.show_group_detail)}
aria-pressed={collapsed ? 'false' : 'true'}
onClick={this.handleToggleClick}
>
<Icon id='info-circle' />
</button>
);
const title = account.get('username', intl.formatMessage(messages.title))
const groupDetail = (
<div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}>
{(!collapsed || animating) && <><GroupDetail id={id} /></>}
</div>
);
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={title}>
<ColumnHeader
icon='users'
active={hasUnread}
title={title}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
extraButton={groupDetailButton}
>
<ColumnSettingsContainer columnId={columnId} />
</ColumnHeader>
{groupDetail}
<StatusListContainer
trackScroll={!pinned}
scrollKey={`group_timeline-${columnId}`}
timelineId={`group:${id}${onlyMedia ? ':media' : ''}${tagged ? `:${tagged}` : ''}`}
onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.group' defaultMessage='The group timeline is empty. When members of this group post new toots, they will appear here.' />}
bindToDocument={!multiColumn}
/>
</Column>
);
}
}

View file

@ -42,7 +42,7 @@ class FollowRequest extends ImmutablePureComponent {
return (
<div className='account'>
<div className='account__wrapper'>
<Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}>
<Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`${(account.get('group', false)) ? '/timelines/groups/' : '/accounts/'}${account.get('id')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
<DisplayName account={account} />
</Permalink>

View file

@ -315,7 +315,7 @@ class Notification extends ImmutablePureComponent {
const { notification } = this.props;
const account = notification.get('account');
const displayNameHtml = { __html: account.get('display_name_html') };
const link = <bdi><Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`/accounts/${account.get('id')}`} dangerouslySetInnerHTML={displayNameHtml} /></bdi>;
const link = <bdi><Permalink className='notification__display-name' href={account.get('url')} title={account.get('acct')} to={`${(account.get('group', false)) ? '/timelines/groups/' : '/accounts/'}${account.get('id')}`} dangerouslySetInnerHTML={displayNameHtml} /></bdi>;
switch(notification.get('type')) {
case 'follow':

View file

@ -94,9 +94,15 @@ class DetailedStatus extends ImmutablePureComponent {
handleAccountClick = (e) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey) && this.context.router) {
const { status } = this.props;
const id = e.currentTarget.getAttribute('data-id');
e.preventDefault();
this.context.router.history.push(`/accounts/${id}`);
if (status.getIn(['account', 'group'], false)) {
this.context.router.history.push(`/timelines/groups/${id}`);
} else {
this.context.router.history.push(`/accounts/${id}`);
}
}
e.stopPropagation();

View file

@ -66,9 +66,14 @@ class BoostModal extends ImmutablePureComponent {
handleAccountClick = (e) => {
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
const { status } = this.props;
e.preventDefault();
this.props.onClose();
this.context.router.history.push(`/accounts/${this.props.status.getIn(['account', 'id'])}`);
if (status.getIn(['account', 'group'], false)) {
this.context.router.history.push(`/timelines/groups/${status.getIn(['account', 'id'])}`);
} else {
this.context.router.history.push(`/accounts/${status.getIn(['account', 'id'])}`);
}
}
}

View file

@ -18,6 +18,7 @@ import {
Compose,
Notifications,
HomeTimeline,
GroupTimeline,
PublicTimeline,
DomainTimeline,
HashtagTimeline,
@ -42,6 +43,7 @@ const componentMap = {
'PUBLIC': PublicTimeline,
'REMOTE': PublicTimeline,
'DOMAIN': DomainTimeline,
'GROUP': GroupTimeline,
'HASHTAG': HashtagTimeline,
'DIRECT': DirectTimeline,
'FAVOURITES': FavouritedStatuses,

View file

@ -29,6 +29,7 @@ import {
KeyboardShortcuts,
PublicTimeline,
DomainTimeline,
GroupTimeline,
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/domain/:domain' exact component={DomainTimeline} content={children} />
<WrappedRoute path='/timelines/groups/:id/:tagged?' exact component={GroupTimeline} 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 DomainTimeline () {
return import(/* webpackChunkName: "features/domain_timeline" */'../../domain_timeline');
}
export function GroupTimeline () {
return import(/* webpackChunkName: "features/group_timeline" */'../../group_timeline');
}
export function HashtagTimeline () {
return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline');
}

View file

@ -1866,6 +1866,69 @@
],
"path": "app/javascript/mastodon/features/getting_started/index.json"
},
{
"descriptors": [
{
"defaultMessage": "Follow",
"id": "account.follow"
},
{
"defaultMessage": "Unfollow",
"id": "account.unfollow"
},
{
"defaultMessage": "Awaiting approval",
"id": "account.requested"
},
{
"defaultMessage": "Unblock @{name}",
"id": "account.unblock"
},
{
"defaultMessage": "Unmute @{name}",
"id": "account.unmute"
},
{
"defaultMessage": "Unfollow",
"id": "confirmations.unfollow.confirm"
},
{
"defaultMessage": "Are you sure you want to unfollow {name}?",
"id": "confirmations.unfollow.message"
}
],
"path": "app/javascript/mastodon/features/group_timeline/components/account_card.json"
},
{
"descriptors": [
{
"defaultMessage": "Media only",
"id": "group.column_settings.media_only"
}
],
"path": "app/javascript/mastodon/features/group_timeline/components/column_settings.json"
},
{
"descriptors": [
{
"defaultMessage": "Group timeline",
"id": "column.group"
},
{
"defaultMessage": "Show group detail",
"id": "home.show_group_detail"
},
{
"defaultMessage": "Hide group detail",
"id": "home.hide_group_detail"
},
{
"defaultMessage": "The group timeline is empty. When members of this group post new toots, they will appear here.",
"id": "empty_column.group"
}
],
"path": "app/javascript/mastodon/features/group_timeline/index.json"
},
{
"descriptors": [
{

View file

@ -28,6 +28,7 @@
"account.link_verified_on": "Ownership of this link was checked on {date}",
"account.locked_info": "This account privacy status is set to locked. The owner manually reviews who can follow them.",
"account.media": "Media",
"account.members_counter": "{count, plural, one {{counter} Follower} other {{counter} Members}}",
"account.mention": "Mention @{name}",
"account.moved_to": "{name} has moved to:",
"account.mute": "Mute @{name}",
@ -75,6 +76,7 @@
"column.domain_blocks": "Blocked domains",
"column.favourites": "Favourites",
"column.follow_requests": "Follow requests",
"column.group": "Group timeline",
"column.home": "Home",
"column.lists": "Lists",
"column.mutes": "Muted users",
@ -173,6 +175,7 @@
"empty_column.favourites": "No one has favourited this post yet. When someone does, they will show up here.",
"empty_column.follow_recommendations": "Looks like no suggestions could be generated for you. You can try using search to look for people you might know or explore trending hashtags.",
"empty_column.follow_requests": "You don't have any follow requests yet. When you receive one, it will show up here.",
"empty_column.group": "The group timeline is empty. When members of this group post new toots, they will appear here.",
"empty_column.hashtag": "There is nothing in this hashtag yet.",
"empty_column.home": "Your home timeline is empty! Follow more people to fill it up. {suggestions}",
"empty_column.home.suggestions": "See some suggestions",
@ -202,6 +205,7 @@
"getting_started.open_source_notice": "Mastodon is open source software. You can contribute or report issues on GitHub at {github}.",
"getting_started.security": "Account settings",
"getting_started.terms": "Terms of service",
"group.column_settings.media_only": "Media only",
"hashtag.column_header.tag_mode.all": "and {additional}",
"hashtag.column_header.tag_mode.any": "or {additional}",
"hashtag.column_header.tag_mode.none": "without {additional}",
@ -217,7 +221,9 @@
"home.column_settings.show_reblogs": "Show boosts",
"home.column_settings.show_replies": "Show replies",
"home.hide_announcements": "Hide announcements",
"home.hide_group_detail": "Hide group detail",
"home.show_announcements": "Show announcements",
"home.show_group_detail": "Show group detail",
"intervals.full.days": "{number, plural, one {# day} other {# days}}",
"intervals.full.hours": "{number, plural, one {# hour} other {# hours}}",
"intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}",

View file

@ -28,6 +28,7 @@
"account.link_verified_on": "このリンクの所有権は{date}に確認されました",
"account.locked_info": "このアカウントは承認制アカウントです。相手が承認するまでフォローは完了しません。",
"account.media": "メディア",
"account.members_counter": "{counter} 人の参加者",
"account.mention": "@{name}さんに投稿",
"account.moved_to": "{name}さんは引っ越しました:",
"account.mute": "@{name}さんをミュート",
@ -75,6 +76,7 @@
"column.domain_blocks": "ブロックしたドメイン",
"column.favourites": "お気に入り",
"column.follow_requests": "フォローリクエスト",
"column.group": "グループタイムライン",
"column.home": "ホーム",
"column.lists": "リスト",
"column.mutes": "ミュートしたユーザー",
@ -173,6 +175,7 @@
"empty_column.favourites": "まだ誰もお気に入り登録していません。お気に入り登録されるとここに表示されます。",
"empty_column.follow_recommendations": "おすすめを生成できませんでした。検索を使って知り合いを探したり、トレンドハッシュタグを見てみましょう。",
"empty_column.follow_requests": "まだフォローリクエストを受けていません。フォローリクエストを受けるとここに表示されます。",
"empty_column.group": "グループタイムラインはまだ使われていません。このグループのメンバーが新しいトゥートをするとここに表示されます。",
"empty_column.hashtag": "このハッシュタグはまだ使われていません。",
"empty_column.home": "ホームタイムラインはまだ空っぽです。誰かフォローして埋めてみましょう。 {suggestions}",
"empty_column.home.suggestions": "おすすめを見る",
@ -202,6 +205,7 @@
"getting_started.open_source_notice": "Mastodonはオープンソースソフトウェアです。誰でもGitHub ( {github} ) から開発に参加したり、問題を報告したりできます。",
"getting_started.security": "アカウント設定",
"getting_started.terms": "プライバシーポリシー",
"group.column_settings.media_only": "メディアのみ表示",
"hashtag.column_header.tag_mode.all": "と {additional}",
"hashtag.column_header.tag_mode.any": "か {additional}",
"hashtag.column_header.tag_mode.none": "({additional} を除く)",
@ -217,7 +221,9 @@
"home.column_settings.show_reblogs": "ブースト表示",
"home.column_settings.show_replies": "返信表示",
"home.hide_announcements": "お知らせを隠す",
"home.hide_group_detail": "グループ詳細を隠す",
"home.show_announcements": "お知らせを表示",
"home.show_group_detail": "グループ詳細を表示",
"intervals.full.days": "{number}日",
"intervals.full.hours": "{number}時間",
"intervals.full.minutes": "{number}分",

View file

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

View file

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

View file

@ -6130,6 +6130,127 @@ a.status-card.compact:hover {
}
}
.group {
&__detail {
box-sizing: border-box;
margin-bottom: 6px;
&__img {
height: 125px;
position: relative;
background: darken($ui-base-color, 12%);
overflow: hidden;
img {
display: block;
width: 100%;
height: 100%;
margin: 0;
object-fit: cover;
}
}
&__bar {
display: flex;
align-items: center;
background: lighten($ui-base-color, 4%);
padding: 10px;
&__name {
flex: 1 1 auto;
display: flex;
align-items: center;
text-decoration: none;
overflow: hidden;
}
&__relationship {
width: 23px;
min-height: 1px;
flex: 0 0 auto;
}
.avatar {
flex: 0 0 auto;
width: 48px;
height: 48px;
padding-top: 2px;
img {
width: 100%;
height: 100%;
display: block;
margin: 0;
border-radius: 4px;
background: darken($ui-base-color, 8%);
object-fit: cover;
}
}
.display-name {
margin-left: 15px;
text-align: left;
strong {
font-size: 15px;
color: $primary-text-color;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
}
span {
display: block;
font-size: 14px;
color: $darker-text-color;
font-weight: 400;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
&__extra {
background: $ui-base-color;
align-items: center;
justify-content: center;
.group__header__links {
font-size: 14px;
color: $darker-text-color;
padding: 10px 0;
a {
display: inline-block;
color: $darker-text-color;
text-decoration: none;
padding: 5px 10px;
font-weight: 500;
strong {
font-weight: 700;
color: $primary-text-color;
}
}
}
.group__header__content {
box-sizing: border-box;
padding: 15px 10px;
width: 100%;
min-height: 18px + 30px;
margin-top: 0;
margin-bottom: 0;
border-bottom: 1px solid lighten($ui-base-color, 12%);
p + p {
margin-top: 1em;
}
}
}
}
}
.account-gallery__container {
display: flex;
flex-wrap: wrap;

View file

@ -55,7 +55,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
end
def announceable?(status)
status.account_id == @account.id || status.distributable?
status.account_id == @account.id || status.distributable? || @account.group? && (status.mentioning?(@account) || status.account.mutual?(@account))
end
def related_to_local_activity?

View file

@ -305,6 +305,6 @@ class Formatter
end
def mention_html(account, with_domain: false)
"<span class=\"h-card\"><a href=\"#{encode(ActivityPub::TagManager.instance.url_for(account))}\" class=\"u-url mention\">@<span>#{encode(with_domain ? account.pretty_acct : account.username)}</span></a></span>"
"<span class=\"h-card\"><a href=\"#{encode(ActivityPub::TagManager.instance.url_for(account))}\" class=\"u-url mention#{account.group? ? ' group' : ''}\">@<span>#{encode(with_domain ? account.pretty_acct : account.username)}</span></a></span>"
end
end

View file

@ -81,4 +81,19 @@ module AccountAssociations
# Account statuses cleanup policy
has_one :statuses_cleanup_policy, class_name: 'AccountStatusesCleanupPolicy', inverse_of: :account, dependent: :destroy
end
def permitted_group_statuses(account)
return Status.none if !group? || !account.nil? && (blocking?(account) || (account.domain.present? && domain_blocking?(account.domain)))
visibility = [:public, :unlisted]
visibility.push(:private) if account&.following?(self)
scope = Status.where(id:
Status.where(account_id: id)
.where(visibility: visibility)
.select(:reblog_of_id)
)
scope = scope.where.not(account_id: account.excluded_from_timeline_account_ids) unless account.nil?
scope
end
end

View file

@ -237,6 +237,10 @@ module AccountInteractions
active_relationships.where(target_account: other_account, delivery: true).exists?
end
def mutual?(other_account)
following?(other_account) && other_account.following?(self)
end
def blocking?(other_account)
block_relationships.where(target_account: other_account).exists?
end

View file

@ -131,6 +131,7 @@ class Status < ApplicationRecord
scope :with_public_visibility, -> { where(visibility: :public) }
scope :tagged_with, ->(tag_ids) { joins(:statuses_tags).where(statuses_tags: { tag_id: tag_ids }) }
scope :in_chosen_languages, ->(account) { where(language: nil).or where(language: account.chosen_languages) }
scope :mentioned_with, ->(account) { joins(:mentions).where(mentions: { account_id: account }) }
scope :excluding_silenced_accounts, -> { left_outer_joins(:account).where(accounts: { silenced_at: nil }) }
scope :including_silenced_accounts, -> { left_outer_joins(:account).where.not(accounts: { silenced_at: nil }) }
scope :not_excluded_by_account, ->(account) { where.not(account_id: account.excluded_from_timeline_account_ids) }
@ -217,6 +218,12 @@ class Status < ApplicationRecord
quote&.visibility
end
def mentioning?(source_account_id)
source_account_id = source_account_id.id if source_account_id.is_a?(Account)
mentioned_with(source_account_id).exists?
end
def within_realtime_window?
created_at >= REAL_TIME_WINDOW.ago
end

View file

@ -146,7 +146,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
end
class MentionSerializer < ActiveModel::Serializer
attributes :id, :username, :url, :acct
attributes :id, :username, :url, :acct, :group
def id
object.account_id.to_s
@ -163,6 +163,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
def acct
object.account.pretty_acct
end
def group
object.account.group?
end
end
class TagSerializer < ActiveModel::Serializer

View file

@ -49,6 +49,7 @@ class BatchedRemoveStatusService < BaseService
@status_id_cutoff = Mastodon::Snowflake.id_at(2.weeks.ago)
redis.pipelined do
statuses.each do |status|
unpush_from_group_timelines(status)
unpush_from_public_timelines(status)
end
end
@ -72,6 +73,26 @@ class BatchedRemoveStatusService < BaseService
end
end
def unpush_from_group_timelines(status)
return unless status.account.group?
payload = Oj.dump(event: :delete, payload: status.reblog.id.to_s)
redis.publish("timeline:group:#{status.account.id}", payload)
@tags[status.id].each do |hashtag|
redis.publish("timeline:group:#{status.account.id}:#{hashtag.mb_chars.downcase}", payload)
end
if status.media_attachments.any?
redis.publish("timeline:group:media:#{status.account.id}", payload)
@tags[status.id].each do |hashtag|
redis.publish("timeline:group:media:#{status.account.id}:#{hashtag.mb_chars.downcase}", payload)
end
end
end
def unpush_from_public_timelines(status)
return unless status.public_visibility? && status.id > @status_id_cutoff

View file

@ -18,6 +18,12 @@ class FanOutOnWriteService < BaseService
deliver_to_lists(status)
end
if status.account.group? && status.reblog?
render_anonymous_reblog_payload(status)
deliver_to_group(status)
end
return if status.account.silenced? || !status.public_visibility?
if !status.reblog? && (!status.reply? || status.in_reply_to_account_id == status.account_id)
@ -156,6 +162,11 @@ class FanOutOnWriteService < BaseService
@payload = Oj.dump(event: :update, payload: @payload)
end
def render_anonymous_reblog_payload(status)
@reblog_payload = InlineRenderer.render(status.reblog, nil, :status)
@reblog_payload = Oj.dump(event: :update, payload: @reblog_payload)
end
def deliver_to_hashtags(status)
Rails.logger.debug "Delivering status #{status.id} to hashtags"
@ -183,6 +194,24 @@ class FanOutOnWriteService < BaseService
end
end
def deliver_to_group(status)
Rails.logger.debug "Delivering status #{status.reblog.id} to group timeline"
Redis.current.publish("timeline:group:#{status.account.id}", @reblog_payload)
status.tags.pluck(:name).each do |hashtag|
Redis.current.publish("timeline:group:#{status.account.id}:#{hashtag.mb_chars.downcase}", @reblog_payload)
end
if status.media_attachments.any?
Redis.current.publish("timeline:group:media:#{status.account.id}", @reblog_payload)
status.tags.pluck(:name).each do |hashtag|
Redis.current.publish("timeline:group:media:#{status.account.id}:#{hashtag.mb_chars.downcase}", @reblog_payload)
end
end
end
def deliver_to_public(status)
Rails.logger.debug "Delivering status #{status.id} to public timeline"

View file

@ -12,6 +12,7 @@ class RemoveStatusService < BaseService
# @option [Boolean] :original_removed
def call(status, **options)
@payload = Oj.dump(event: :delete, payload: status.id.to_s)
@reblog_payload = Oj.dump(event: :delete, payload: status.reblog.id.to_s)
@status = status
@account = status.account
@options = options
@ -38,6 +39,7 @@ class RemoveStatusService < BaseService
remove_from_mentions
remove_reblogs
remove_from_hashtags
remove_from_group if status.account.group?
remove_from_public
remove_from_media if @status.media_attachments.any?
remove_media
@ -119,6 +121,22 @@ class RemoveStatusService < BaseService
end
end
def remove_from_group
redis.publish("timeline:group:#{@status.account.id}", @reblog_payload)
@status.tags.map(&:name).each do |hashtag|
redis.publish("timeline:group:#{@status.account.id}:#{hashtag.mb_chars.downcase}", @reblog_payload)
end
if @status.media_attachments.any?
redis.publish("timeline:group:media:#{@status.account.id}", @reblog_payload)
@status.tags.map(&:name).each do |hashtag|
redis.publish("timeline:group:media:#{@status.account.id}:#{hashtag.mb_chars.downcase}", @reblog_payload)
end
end
end
def remove_from_public
return unless @status.public_visibility?

View file

@ -358,6 +358,7 @@ Rails.application.routes.draw do
resource :public, only: :show, controller: :public
resources :tag, only: :show
resources :list, only: :show
resources :group, only: :show
end
resources :streaming, only: [:index]

View file

@ -393,6 +393,8 @@ const startWorker = (workerId) => {
'public:remote:media',
'public:domain',
'public:domain:media',
'group',
'group:media',
'hashtag',
];
@ -777,6 +779,8 @@ const startWorker = (workerId) => {
* @property {string} [list]
* @property {string} [domain]
* @property {string} [only_media]
* @property {string} [id]
* @property {string} [tagged]
*/
/**
@ -836,6 +840,17 @@ const startWorker = (workerId) => {
});
}
break;
case 'group':
if (!params.id || params.id.length === 0) {
reject('No group id for stream provided');
} else {
resolve({
channelIds: [`timeline:group:${params.id}${!!params.tagged && params.tagged.length !== 0 ? `:${params.tagged.toLowerCase()}` : ''}`],
options: { needsFiltering: true, notificationOnly: false },
});
}
break;
case 'public:media':
resolve({
@ -872,6 +887,17 @@ const startWorker = (workerId) => {
});
}
break;
case 'group:media':
if (!params.id || params.id.length === 0) {
reject('No group id for stream provided');
} else {
resolve({
channelIds: [`timeline:group:media:${params.id}${!!params.tagged && params.tagged.length !== 0 ? `:${params.tagged.toLowerCase()}` : ''}`],
options: { needsFiltering: true, notificationOnly: false },
});
}
break;
case 'direct':
resolve({
@ -919,6 +945,8 @@ const startWorker = (workerId) => {
return [channelName, params.tag];
} else if (['public:domain', 'public:domain:media'].includes(channelName)) {
return [channelName, params.domain];
} else if (['group', 'group:media'].includes(channelName)) {
return [channelName, params.id, params.tagged];
} else {
return [channelName];
}