Add group timeline
This commit is contained in:
parent
08d250d633
commit
110421c8a0
42 changed files with 1047 additions and 22 deletions
88
app/controllers/api/v1/timelines/group_controller.rb
Normal file
88
app/controllers/api/v1/timelines/group_controller.rb
Normal 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
|
|
@ -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]
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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, ' '], []);
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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'])}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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(/^#/, '');
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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);
|
206
app/javascript/mastodon/features/group_timeline/index.js
Normal file
206
app/javascript/mastodon/features/group_timeline/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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':
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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'])}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
@ -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}}",
|
||||
|
|
|
@ -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}分",
|
||||
|
|
|
@ -74,6 +74,12 @@ const initialState = ImmutableMap({
|
|||
}),
|
||||
}),
|
||||
|
||||
group: ImmutableMap({
|
||||
regex: ImmutableMap({
|
||||
body: '',
|
||||
}),
|
||||
}),
|
||||
|
||||
public: ImmutableMap({
|
||||
regex: ImmutableMap({
|
||||
body: '',
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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?
|
||||
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue