Merge remote-tracking branch 'tootsuite/master' into glitchsoc/master

lolsob-rspec
Jenkins 2018-05-31 01:17:25 +00:00
commit a76f40890e
28 changed files with 508 additions and 157 deletions

View File

@ -0,0 +1,41 @@
import React from 'react';
import { Sparklines, SparklinesCurve } from 'react-sparklines';
import { Link } from 'react-router-dom';
import { FormattedMessage, FormattedNumber } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes';
const shortNumberFormat = number => {
if (number < 1000) {
return <FormattedNumber value={number} />;
} else {
return <React.Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={1} />K</React.Fragment>;
}
};
const Hashtag = ({ hashtag }) => (
<div className='trends__item'>
<div className='trends__item__name'>
<Link to={`/timelines/tag/${hashtag.get('name')}`}>
#<span>{hashtag.get('name')}</span>
</Link>
<FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']), count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']))}</strong> }} />
</div>
<div className='trends__item__current'>
{shortNumberFormat(hashtag.getIn(['history', 0, 'uses']))}
</div>
<div className='trends__item__sparkline'>
<Sparklines width={50} height={28} data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()}>
<SparklinesCurve style={{ fill: 'none' }} />
</Sparklines>
</div>
</div>
);
Hashtag.propTypes = {
hashtag: ImmutablePropTypes.map.isRequired,
};
export default Hashtag;

View File

@ -23,6 +23,14 @@ const messages = defineMessages({
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' }, unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unhide {domain}' },
hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' }, hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' },
showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' }, showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
}); });
@injectIntl @injectIntl
@ -54,17 +62,29 @@ export default class ActionBar extends React.PureComponent {
let menu = []; let menu = [];
let extraInfo = ''; let extraInfo = '';
menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention }); if (account.get('id') !== me) {
menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.props.onDirect }); menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.props.onDirect });
menu.push(null);
}
if ('share' in navigator) { if ('share' in navigator) {
menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare }); menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare });
menu.push(null);
} }
menu.push(null);
if (account.get('id') === me) { if (account.get('id') === me) {
menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' }); menu.push({ text: intl.formatMessage(messages.edit_profile), href: '/settings/profile' });
menu.push({ text: intl.formatMessage(messages.preferences), href: '/settings/preferences' });
menu.push({ text: intl.formatMessage(messages.pins), to: '/pinned' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.follow_requests), to: '/follow_requests' });
menu.push({ text: intl.formatMessage(messages.favourites), to: '/favourites' });
menu.push({ text: intl.formatMessage(messages.lists), to: '/lists' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.mutes), to: '/mutes' });
menu.push({ text: intl.formatMessage(messages.blocks), to: '/blocks' });
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
} else { } else {
if (account.getIn(['relationship', 'following'])) { if (account.getIn(['relationship', 'following'])) {
if (account.getIn(['relationship', 'showing_reblogs'])) { if (account.getIn(['relationship', 'showing_reblogs'])) {

View File

@ -14,6 +14,7 @@ const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' }, requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
}); });
class Avatar extends ImmutablePureComponent { class Avatar extends ImmutablePureComponent {
@ -74,6 +75,10 @@ export default class Header extends ImmutablePureComponent {
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
openEditProfile = () => {
window.open('/settings/profile', '_blank');
}
render () { render () {
const { account, intl } = this.props; const { account, intl } = this.props;
@ -118,6 +123,12 @@ export default class Header extends ImmutablePureComponent {
</div> </div>
); );
} }
} else {
actionBtn = (
<div className='account--action-button'>
<IconButton size={26} icon='pencil' title={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />
</div>
);
} }
if (account.get('moved') && !account.getIn(['relationship', 'following'])) { if (account.get('moved') && !account.getIn(['relationship', 'following'])) {

View File

@ -1,42 +1,11 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage, FormattedNumber } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import AccountContainer from '../../../containers/account_container'; import AccountContainer from '../../../containers/account_container';
import StatusContainer from '../../../containers/status_container'; import StatusContainer from '../../../containers/status_container';
import { Link } from 'react-router-dom';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { Sparklines, SparklinesCurve } from 'react-sparklines'; import Hashtag from '../../../components/hashtag';
const shortNumberFormat = number => {
if (number < 1000) {
return <FormattedNumber value={number} />;
} else {
return <React.Fragment><FormattedNumber value={number / 1000} maximumFractionDigits={1} />K</React.Fragment>;
}
};
const renderHashtag = hashtag => (
<div className='trends__item' key={hashtag.get('name')}>
<div className='trends__item__name'>
<Link to={`/timelines/tag/${hashtag.get('name')}`}>
#<span>{hashtag.get('name')}</span>
</Link>
<FormattedMessage id='trends.count_by_accounts' defaultMessage='{count} {rawCount, plural, one {person} other {people}} talking' values={{ rawCount: hashtag.getIn(['history', 0, 'accounts']), count: <strong>{shortNumberFormat(hashtag.getIn(['history', 0, 'accounts']))}</strong> }} />
</div>
<div className='trends__item__current'>
{shortNumberFormat(hashtag.getIn(['history', 0, 'uses']))}
</div>
<div className='trends__item__sparkline'>
<Sparklines width={50} height={28} data={hashtag.get('history').reverse().map(day => day.get('uses')).toArray()}>
<SparklinesCurve style={{ fill: 'none' }} />
</Sparklines>
</div>
</div>
);
export default class SearchResults extends ImmutablePureComponent { export default class SearchResults extends ImmutablePureComponent {
@ -66,7 +35,7 @@ export default class SearchResults extends ImmutablePureComponent {
<FormattedMessage id='trends.header' defaultMessage='Trending now' /> <FormattedMessage id='trends.header' defaultMessage='Trending now' />
</div> </div>
{trends && trends.map(hashtag => renderHashtag(hashtag))} {trends && trends.map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
</div> </div>
</div> </div>
); );
@ -100,7 +69,7 @@ export default class SearchResults extends ImmutablePureComponent {
<div className='search-results__section'> <div className='search-results__section'>
<h5><i className='fa fa-fw fa-hashtag' /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5> <h5><i className='fa fa-fw fa-hashtag' /><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></h5>
{results.get('hashtags').map(hashtag => renderHashtag(hashtag))} {results.get('hashtags').map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
</div> </div>
); );
} }

View File

@ -75,7 +75,7 @@ export default class Compose extends React.PureComponent {
const { columns } = this.props; const { columns } = this.props;
header = ( header = (
<nav className='drawer__header'> <nav className='drawer__header'>
<Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(messages.start)}><i role='img' className='fa fa-fw fa-asterisk' /></Link> <Link to='/getting-started' className='drawer__tab' title={intl.formatMessage(messages.start)} aria-label={intl.formatMessage(messages.start)}><i role='img' className='fa fa-fw fa-bars' /></Link>
{!columns.some(column => column.get('id') === 'HOME') && ( {!columns.some(column => column.get('id') === 'HOME') && (
<Link to='/timelines/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(messages.home_timeline)}><i role='img' className='fa fa-fw fa-home' /></Link> <Link to='/timelines/home' className='drawer__tab' title={intl.formatMessage(messages.home_timeline)} aria-label={intl.formatMessage(messages.home_timeline)}><i role='img' className='fa fa-fw fa-home' /></Link>
)} )}

View File

@ -28,7 +28,7 @@ export default class Blocks extends ImmutablePureComponent {
static propTypes = { static propTypes = {
params: PropTypes.object.isRequired, params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired, dispatch: PropTypes.func.isRequired,
domains: ImmutablePropTypes.list, domains: ImmutablePropTypes.orderedSet,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };

View File

@ -10,38 +10,41 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from '../../initial_state'; import { me } from '../../initial_state';
import { fetchFollowRequests } from '../../actions/accounts'; import { fetchFollowRequests } from '../../actions/accounts';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import { Link } from 'react-router-dom';
import { fetchTrends } from '../../actions/trends';
import Hashtag from '../../components/hashtag';
import NavigationBar from '../compose/components/navigation_bar';
const messages = defineMessages({ const messages = defineMessages({
heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' }, home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' }, notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' }, public_timeline: { id: 'navigation_bar.public_timeline', defaultMessage: 'Federated timeline' },
navigation_subheading: { id: 'column_subheading.navigation', defaultMessage: 'Navigation' },
settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' }, settings_subheading: { id: 'column_subheading.settings', defaultMessage: 'Settings' },
community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' }, community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' }, direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' }, favourites: { id: 'navigation_bar.favourites', defaultMessage: 'Favourites' },
blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' }, blocks: { id: 'navigation_bar.blocks', defaultMessage: 'Blocked users' },
domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' }, domain_blocks: { id: 'navigation_bar.domain_blocks', defaultMessage: 'Hidden domains' },
mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' }, mutes: { id: 'navigation_bar.mutes', defaultMessage: 'Muted users' },
info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' }, pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' }, refresh_trends: { id: 'trends.refresh', defaultMessage: 'Refresh' },
discover: { id: 'navigation_bar.discover', defaultMessage: 'Discover' },
personal: { id: 'navigation_bar.personal', defaultMessage: 'Personal' },
security: { id: 'navigation_bar.security', defaultMessage: 'Security' },
}); });
const mapStateToProps = state => ({ const mapStateToProps = state => ({
myAccount: state.getIn(['accounts', me]), myAccount: state.getIn(['accounts', me]),
columns: state.getIn(['settings', 'columns']),
unreadFollowRequests: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size, unreadFollowRequests: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
unreadNotifications: state.getIn(['notifications', 'unread']), trends: state.get('trends'),
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({
fetchFollowRequests: () => dispatch(fetchFollowRequests()), fetchFollowRequests: () => dispatch(fetchFollowRequests()),
fetchTrends: () => dispatch(fetchTrends()),
}); });
const badgeDisplay = (number, limit) => { const badgeDisplay = (number, limit) => {
@ -66,6 +69,7 @@ export default class GettingStarted extends ImmutablePureComponent {
fetchFollowRequests: PropTypes.func.isRequired, fetchFollowRequests: PropTypes.func.isRequired,
unreadFollowRequests: PropTypes.number, unreadFollowRequests: PropTypes.number,
unreadNotifications: PropTypes.number, unreadNotifications: PropTypes.number,
trends: ImmutablePropTypes.list,
}; };
componentDidMount () { componentDidMount () {
@ -74,36 +78,26 @@ export default class GettingStarted extends ImmutablePureComponent {
if (myAccount.get('locked')) { if (myAccount.get('locked')) {
fetchFollowRequests(); fetchFollowRequests();
} }
setTimeout(() => this.props.fetchTrends(), 5000);
} }
render () { render () {
const { intl, myAccount, columns, multiColumn, unreadFollowRequests, unreadNotifications } = this.props; const { intl, myAccount, multiColumn, unreadFollowRequests, trends } = this.props;
const navItems = []; const navItems = [];
if (multiColumn) { if (multiColumn) {
if (!columns.find(item => item.get('id') === 'HOME')) { navItems.push(
navItems.push(<ColumnLink key='0' icon='home' text={intl.formatMessage(messages.home_timeline)} to='/timelines/home' />); <ColumnSubheading key='1' text={intl.formatMessage(messages.discover)} />,
} <ColumnLink key='2' icon='users' text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' />,
<ColumnLink key='3' icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />,
if (!columns.find(item => item.get('id') === 'NOTIFICATIONS')) { <ColumnSubheading key='8' text={intl.formatMessage(messages.personal)} />
navItems.push(<ColumnLink key='1' icon='bell' text={intl.formatMessage(messages.notifications)} badge={badgeDisplay(unreadNotifications)} to='/notifications' />); );
}
if (!columns.find(item => item.get('id') === 'COMMUNITY')) {
navItems.push(<ColumnLink key='2' icon='users' text={intl.formatMessage(messages.community_timeline)} to='/timelines/public/local' />);
}
if (!columns.find(item => item.get('id') === 'PUBLIC')) {
navItems.push(<ColumnLink key='3' icon='globe' text={intl.formatMessage(messages.public_timeline)} to='/timelines/public' />);
}
}
if (!multiColumn || !columns.find(item => item.get('id') === 'DIRECT')) {
navItems.push(<ColumnLink key='4' icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />);
} }
navItems.push( navItems.push(
<ColumnLink key='4' icon='envelope' text={intl.formatMessage(messages.direct)} to='/timelines/direct' />,
<ColumnLink key='5' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />, <ColumnLink key='5' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
<ColumnLink key='6' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' /> <ColumnLink key='6' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />
); );
@ -112,30 +106,57 @@ export default class GettingStarted extends ImmutablePureComponent {
navItems.push(<ColumnLink key='7' icon='users' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />); navItems.push(<ColumnLink key='7' icon='users' text={intl.formatMessage(messages.follow_requests)} badge={badgeDisplay(unreadFollowRequests, 40)} to='/follow_requests' />);
} }
if (multiColumn) { if (!multiColumn) {
navItems.push(<ColumnLink key='8' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' />); navItems.push(
<ColumnSubheading key='9' text={intl.formatMessage(messages.settings_subheading)} />,
<ColumnLink key='6' icon='gears' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />,
<ColumnLink key='6' icon='lock' text={intl.formatMessage(messages.security)} href='/auth/edit' />
);
} }
navItems.push(<ColumnLink key='9' icon='book' text={intl.formatMessage(messages.info)} href='/about/more' />);
return ( return (
<Column icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile> <Column>
{multiColumn && <div className='column-header__wrapper'>
<h1 className='column-header'>
<button>
<i className='fa fa-bars fa-fw column-header__icon' />
<FormattedMessage id='getting_started.heading' defaultMessage='Getting started' />
</button>
</h1>
</div>}
<div className='getting-started__wrapper'> <div className='getting-started__wrapper'>
<ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} /> {!multiColumn && <NavigationBar account={myAccount} />}
{navItems} {navItems}
<ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} />
<ColumnLink icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />
<ColumnLink icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />
<ColumnLink icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />
<ColumnLink icon='minus-circle' text={intl.formatMessage(messages.domain_blocks)} to='/domain_blocks' />
<ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href='/settings/preferences' />
<ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href='/auth/sign_out' method='delete' />
</div> </div>
<div className='static-content getting-started'> {multiColumn && trends && <div className='getting-started__trends'>
<p> <div className='column-header__wrapper'>
<a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/FAQ.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.faq' defaultMessage='FAQ' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/User-guide.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.userguide' defaultMessage='User Guide' /></a> • <a href='https://github.com/tootsuite/documentation/blob/master/Using-Mastodon/Apps.md' rel='noopener' target='_blank'><FormattedMessage id='getting_started.appsshort' defaultMessage='Apps' /></a> <h1 className='column-header'>
</p> <button>
<i className='fa fa-fire fa-fw' />
<FormattedMessage id='trends.header' defaultMessage='Trending now' />
</button>
<div className='column-header__buttons'>
<button className='column-header__button' title={intl.formatMessage(messages.refresh_trends)} aria-label={intl.formatMessage(messages.refresh_trends)}><i className='fa fa-refresh' /></button>
</div>
</h1>
</div>
<div className='getting-started__scrollable'>{trends.take(3).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}</div>
</div>}
{!multiColumn && <div className='flex-spacer' />}
<div className='getting-started getting-started__footer'>
<ul>
{multiColumn && <li><Link to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link> · </li>}
<li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this instance' /></a> · </li>
<li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li>
<li><a href='https://github.com/tootsuite/documentation#documentation' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li>
<li><a href='/auth/sign_out' data-method='delete'><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
</ul>
<p> <p>
<FormattedMessage <FormattedMessage
id='getting_started.open_source_notice' id='getting_started.open_source_notice'

View File

@ -132,11 +132,12 @@ class SwitchingColumnsArea extends React.PureComponent {
render () { render () {
const { children } = this.props; const { children } = this.props;
const { mobile } = this.state; const { mobile } = this.state;
const redirect = mobile ? <Redirect from='/' to='/timelines/home' exact /> : <Redirect from='/' to='/getting-started' exact />;
return ( return (
<ColumnsAreaContainer ref={this.setRef} singleColumn={mobile}> <ColumnsAreaContainer ref={this.setRef} singleColumn={mobile}>
<WrappedSwitch> <WrappedSwitch>
<Redirect from='/' to='/getting-started' exact /> {redirect}
<WrappedRoute path='/getting-started' component={GettingStarted} content={children} /> <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
<WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} /> <WrappedRoute path='/keyboard-shortcuts' component={KeyboardShortcuts} content={children} />
<WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} /> <WrappedRoute path='/timelines/home' component={HomeTimeline} content={children} />

View File

@ -168,7 +168,7 @@
"navigation_bar.favourites": "Favourites", "navigation_bar.favourites": "Favourites",
"navigation_bar.follow_requests": "Follow requests", "navigation_bar.follow_requests": "Follow requests",
"navigation_bar.info": "About this instance", "navigation_bar.info": "About this instance",
"navigation_bar.keyboard_shortcuts": "Keyboard shortcuts", "navigation_bar.keyboard_shortcuts": "Hotkeys",
"navigation_bar.lists": "Lists", "navigation_bar.lists": "Lists",
"navigation_bar.misc": "Misc", "navigation_bar.misc": "Misc",
"navigation_bar.logout": "Logout", "navigation_bar.logout": "Logout",

View File

@ -5,6 +5,7 @@ import {
import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses'; import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses';
import { TIMELINE_DELETE, TIMELINE_UPDATE } from '../actions/timelines'; import { TIMELINE_DELETE, TIMELINE_UPDATE } from '../actions/timelines';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import compareId from '../compare_id';
const initialState = ImmutableMap({ const initialState = ImmutableMap({
inReplyTos: ImmutableMap(), inReplyTos: ImmutableMap(),
@ -15,27 +16,27 @@ const normalizeContext = (immutableState, id, ancestors, descendants) => immutab
state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => { state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => {
state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => { state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => {
function addReply({ id, in_reply_to_id }) { function addReply({ id, in_reply_to_id }) {
if (in_reply_to_id) { if (in_reply_to_id && !inReplyTos.has(id)) {
const siblings = replies.get(in_reply_to_id, ImmutableList());
if (!siblings.includes(id)) { replies.update(in_reply_to_id, ImmutableList(), siblings => {
const index = siblings.findLastIndex(sibling => sibling.id < id); const index = siblings.findLastIndex(sibling => compareId(sibling, id) < 0);
replies.set(in_reply_to_id, siblings.insert(index + 1, id)); return siblings.insert(index + 1, id);
} });
inReplyTos.set(id, in_reply_to_id); inReplyTos.set(id, in_reply_to_id);
} }
} }
// We know in_reply_to_id of statuses but `id` itself.
// So we assume that the status of the id replies to last ancestors.
ancestors.forEach(addReply);
if (ancestors[0]) { if (ancestors[0]) {
addReply({ id, in_reply_to_id: ancestors[0].id }); addReply({ id, in_reply_to_id: ancestors[ancestors.length - 1].id });
} }
if (descendants[0]) { descendants.forEach(addReply);
addReply({ id: descendants[0].id, in_reply_to_id: id });
}
[ancestors, descendants].forEach(statuses => statuses.forEach(addReply));
})); }));
})); }));
}); });
@ -80,7 +81,7 @@ const updateContext = (state, status) => {
mutable.setIn(['inReplyTos', status.id], status.in_reply_to_id); mutable.setIn(['inReplyTos', status.id], status.in_reply_to_id);
if (!replies.includes(status.id)) { if (!replies.includes(status.id)) {
mutable.setIn(['replies', status.id], replies.push(status.id)); mutable.setIn(['replies', status.in_reply_to_id], replies.push(status.id));
} }
}); });
} }

View File

@ -6,7 +6,9 @@ import {
import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable'; import { Map as ImmutableMap, OrderedSet as ImmutableOrderedSet } from 'immutable';
const initialState = ImmutableMap({ const initialState = ImmutableMap({
blocks: ImmutableMap(), blocks: ImmutableMap({
items: ImmutableOrderedSet(),
}),
}); });
export default function domainLists(state = initialState, action) { export default function domainLists(state = initialState, action) {

View File

@ -1663,24 +1663,6 @@ a.account__display-name {
vertical-align: middle; vertical-align: middle;
} }
.static-content {
padding: 10px;
padding-top: 20px;
color: $dark-text-color;
h1 {
font-size: 16px;
font-weight: 500;
margin-bottom: 40px;
text-align: center;
}
p {
font-size: 13px;
margin-bottom: 20px;
}
}
.columns-area { .columns-area {
display: flex; display: flex;
flex: 1 1 auto; flex: 1 1 auto;
@ -1772,6 +1754,8 @@ a.account__display-name {
margin-bottom: 0; margin-bottom: 0;
} }
.getting-started__wrapper,
.getting-started__trends,
.search { .search {
margin-bottom: 10px; margin-bottom: 10px;
} }
@ -2175,7 +2159,8 @@ a.account__display-name {
} }
.getting-started__wrapper, .getting-started__wrapper,
.getting_started { .getting-started,
.flex-spacer {
background: $ui-base-color; background: $ui-base-color;
} }
@ -2184,16 +2169,58 @@ a.account__display-name {
overflow-y: auto; overflow-y: auto;
} }
.flex-spacer {
flex: 1 1 auto;
}
.getting-started { .getting-started {
background: $ui-base-color;
flex: 1 0 auto; flex: 1 0 auto;
color: $dark-text-color;
p { p {
color: $secondary-text-color; color: $dark-text-color;
font-size: 13px;
margin-bottom: 20px;
a {
color: $dark-text-color;
text-decoration: underline;
}
} }
a { a {
color: $dark-text-color; text-decoration: none;
color: $darker-text-color;
&:hover,
&:focus,
&:active {
text-decoration: underline;
}
}
&__footer {
flex: 0 0 auto;
padding: 10px;
padding-top: 20px;
ul {
margin-bottom: 10px;
}
ul li {
display: inline;
}
}
&__trends {
background: $ui-base-color;
flex: 1 1 auto;
}
&__scrollable {
max-height: 100%;
overflow-y: auto;
} }
} }

View File

@ -187,4 +187,15 @@ module AccountInteractions
def pinned?(status) def pinned?(status)
status_pins.where(status: status).exists? status_pins.where(status: status).exists?
end end
def followers_for_local_distribution
followers.local
.joins(:user)
.where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago)
end
def lists_for_local_distribution
lists.joins(account: :user)
.where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago)
end
end end

View File

@ -16,7 +16,7 @@ class Favourite < ApplicationRecord
update_index('statuses#status', :status) if Chewy.enabled? update_index('statuses#status', :status) if Chewy.enabled?
belongs_to :account, inverse_of: :favourites belongs_to :account, inverse_of: :favourites
belongs_to :status, inverse_of: :favourites, counter_cache: true belongs_to :status, inverse_of: :favourites
has_one :notification, as: :activity, dependent: :destroy has_one :notification, as: :activity, dependent: :destroy
@ -25,4 +25,27 @@ class Favourite < ApplicationRecord
before_validation do before_validation do
self.status = status.reblog if status&.reblog? self.status = status.reblog if status&.reblog?
end end
after_create :increment_cache_counters
after_destroy :decrement_cache_counters
private
def increment_cache_counters
if association(:status).loaded?
status.update_attribute(:favourites_count, status.favourites_count + 1)
else
Status.where(id: status_id).update_all('favourites_count = COALESCE(favourites_count, 0) + 1')
end
end
def decrement_cache_counters
return if association(:status).loaded? && (status.marked_for_destruction? || status.marked_for_mass_destruction?)
if association(:status).loaded?
status.update_attribute(:favourites_count, [status.favourites_count - 1, 0].max)
else
Status.where(id: status_id).update_all('favourites_count = GREATEST(COALESCE(favourites_count, 0) - 1, 0)')
end
end
end end

View File

@ -43,12 +43,12 @@ class Status < ApplicationRecord
belongs_to :application, class_name: 'Doorkeeper::Application', optional: true belongs_to :application, class_name: 'Doorkeeper::Application', optional: true
belongs_to :account, inverse_of: :statuses, counter_cache: true belongs_to :account, inverse_of: :statuses
belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account', optional: true belongs_to :in_reply_to_account, foreign_key: 'in_reply_to_account_id', class_name: 'Account', optional: true
belongs_to :conversation, optional: true belongs_to :conversation, optional: true
belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true belongs_to :thread, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :replies, optional: true
belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, counter_cache: :reblogs_count, optional: true belongs_to :reblog, foreign_key: 'reblog_of_id', class_name: 'Status', inverse_of: :reblogs, optional: true
has_many :favourites, inverse_of: :status, dependent: :destroy has_many :favourites, inverse_of: :status, dependent: :destroy
has_many :bookmarks, inverse_of: :status, dependent: :destroy has_many :bookmarks, inverse_of: :status, dependent: :destroy
@ -172,6 +172,17 @@ class Status < ApplicationRecord
@emojis ||= CustomEmoji.from_text([spoiler_text, text].join(' '), account.domain) @emojis ||= CustomEmoji.from_text([spoiler_text, text].join(' '), account.domain)
end end
def mark_for_mass_destruction!
@marked_for_mass_destruction = true
end
def marked_for_mass_destruction?
@marked_for_mass_destruction
end
after_create :increment_counter_caches
after_destroy :decrement_counter_caches
after_create_commit :store_uri, if: :local? after_create_commit :store_uri, if: :local?
after_create_commit :update_statistics, if: :local? after_create_commit :update_statistics, if: :local?
@ -414,4 +425,40 @@ class Status < ApplicationRecord
return unless public_visibility? || unlisted_visibility? return unless public_visibility? || unlisted_visibility?
ActivityTracker.increment('activity:statuses:local') ActivityTracker.increment('activity:statuses:local')
end end
def increment_counter_caches
return if direct_visibility?
if association(:account).loaded?
account.update_attribute(:statuses_count, account.statuses_count + 1)
else
Account.where(id: account_id).update_all('statuses_count = COALESCE(statuses_count, 0) + 1')
end
return unless reblog?
if association(:reblog).loaded?
reblog.update_attribute(:reblogs_count, reblog.reblogs_count + 1)
else
Status.where(id: reblog_of_id).update_all('reblogs_count = COALESCE(reblogs_count, 0) + 1')
end
end
def decrement_counter_caches
return if direct_visibility? || marked_for_mass_destruction?
if association(:account).loaded?
account.update_attribute(:statuses_count, [account.statuses_count - 1, 0].max)
else
Account.where(id: account_id).update_all('statuses_count = GREATEST(COALESCE(statuses_count, 0) - 1, 0)')
end
return unless reblog?
if association(:reblog).loaded?
reblog.update_attribute(:reblogs_count, [reblog.reblogs_count - 1, 0].max)
else
Status.where(id: reblog_of_id).update_all('reblogs_count = GREATEST(COALESCE(reblogs_count, 0) - 1, 0)')
end
end
end end

View File

@ -21,7 +21,10 @@ class BatchedRemoveStatusService < BaseService
@activity_xml = {} @activity_xml = {}
# Ensure that rendered XML reflects destroyed state # Ensure that rendered XML reflects destroyed state
statuses.each(&:destroy) statuses.each do |status|
status.mark_for_mass_destruction!
status.destroy
end
# Batch by source account # Batch by source account
statuses.group_by(&:account_id).each_value do |account_statuses| statuses.group_by(&:account_id).each_value do |account_statuses|
@ -53,7 +56,7 @@ class BatchedRemoveStatusService < BaseService
end end
def unpush_from_home_timelines(account, statuses) def unpush_from_home_timelines(account, statuses)
recipients = account.followers.local.to_a recipients = account.followers_for_local_distribution.to_a
recipients << account if account.local? recipients << account if account.local?
@ -65,7 +68,7 @@ class BatchedRemoveStatusService < BaseService
end end
def unpush_from_list_timelines(account, statuses) def unpush_from_list_timelines(account, statuses)
account.lists.select(:id, :account_id).each do |list| account.lists_for_local_distribution.select(:id, :account_id).each do |list|
statuses.each do |status| statuses.each do |status|
FeedManager.instance.unpush_from_list(list, status) FeedManager.instance.unpush_from_list(list, status)
end end

View File

@ -38,7 +38,7 @@ class FanOutOnWriteService < BaseService
def deliver_to_followers(status) def deliver_to_followers(status)
Rails.logger.debug "Delivering status #{status.id} to followers" Rails.logger.debug "Delivering status #{status.id} to followers"
status.account.followers.where(domain: nil).joins(:user).where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago).select(:id).reorder(nil).find_in_batches do |followers| status.account.followers_for_local_distribution.select(:id).reorder(nil).find_in_batches do |followers|
FeedInsertWorker.push_bulk(followers) do |follower| FeedInsertWorker.push_bulk(followers) do |follower|
[status.id, follower.id, :home] [status.id, follower.id, :home]
end end
@ -48,7 +48,7 @@ class FanOutOnWriteService < BaseService
def deliver_to_lists(status) def deliver_to_lists(status)
Rails.logger.debug "Delivering status #{status.id} to lists" Rails.logger.debug "Delivering status #{status.id} to lists"
status.account.lists.joins(account: :user).where('users.current_sign_in_at > ?', User::ACTIVE_DURATION.ago).select(:id).reorder(nil).find_in_batches do |lists| status.account.lists_for_local_distribution.select(:id).reorder(nil).find_in_batches do |lists|
FeedInsertWorker.push_bulk(lists) do |list| FeedInsertWorker.push_bulk(lists) do |list|
[status.id, list.id, :list] [status.id, list.id, :list]
end end

View File

@ -43,13 +43,13 @@ class RemoveStatusService < BaseService
end end
def remove_from_followers def remove_from_followers
@account.followers.local.find_each do |follower| @account.followers_for_local_distribution.find_each do |follower|
FeedManager.instance.unpush_from_home(follower, @status) FeedManager.instance.unpush_from_home(follower, @status)
end end
end end
def remove_from_lists def remove_from_lists
@account.lists.select(:id, :account_id).find_each do |list| @account.lists_for_local_distribution.select(:id, :account_id).find_each do |list|
FeedManager.instance.unpush_from_list(list, @status) FeedManager.instance.unpush_from_list(list, @status)
end end
end end

View File

@ -41,9 +41,10 @@ class SuspendAccountService < BaseService
end end
def purge_profile! def purge_profile!
@account.suspended = true @account.suspended = true
@account.display_name = '' @account.display_name = ''
@account.note = '' @account.note = ''
@account.statuses_count = 0
@account.avatar.destroy @account.avatar.destroy
@account.header.destroy @account.header.destroy
@account.save! @account.save!

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
class Maintenance::DestroyMediaWorker
include Sidekiq::Worker
sidekiq_options queue: 'pull'
def perform(media_attachment_id)
media = MediaAttachment.find(media_attachment_id)
media.destroy
rescue ActiveRecord::RecordNotFound
true
end
end

View File

@ -0,0 +1,16 @@
# frozen_string_literal: true
class Maintenance::RedownloadAccountMediaWorker
include Sidekiq::Worker
sidekiq_options queue: 'pull', retry: false
def perform(account_id)
account = Account.find(account_id)
account.reset_avatar!
account.reset_header!
account.save
rescue ActiveRecord::RecordNotFound
true
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class Maintenance::UncacheMediaWorker
include Sidekiq::Worker
sidekiq_options queue: 'pull'
def perform(media_attachment_id)
media = MediaAttachment.find(media_attachment_id)
return unless media.file.exists?
media.file.destroy
media.save
rescue ActiveRecord::RecordNotFound
true
end
end

View File

@ -0,0 +1,88 @@
class FixAccountsUniqueIndex < ActiveRecord::Migration[5.2]
disable_ddl_transaction!
def up
say ''
say 'WARNING: This migration may take a *long* time for large instances'
say 'It will *not* lock tables for any significant time, but it may run'
say 'for a very long time. We will pause for 10 seconds to allow you to'
say 'interrupt this migration if you are not ready.'
say ''
say 'This migration will irreversibly delete user accounts with duplicate'
say 'usernames. You may use the `rake mastodon:maintenance:find_duplicate_usernames`'
say 'task to manually deal with such accounts before running this migration.'
10.downto(1) do |i|
say "Continuing in #{i} second#{i == 1 ? '' : 's'}...", true
sleep 1
end
duplicates = Account.connection.select_all('SELECT string_agg(id::text, \',\') AS ids FROM accounts GROUP BY lower(username), lower(domain) HAVING count(*) > 1').to_hash
duplicates.each do |row|
deduplicate_account!(row['ids'].split(','))
end
remove_index :accounts, name: 'index_accounts_on_username_and_domain_lower' if index_name_exists?(:accounts, 'index_accounts_on_username_and_domain_lower')
safety_assured { execute 'CREATE UNIQUE INDEX CONCURRENTLY index_accounts_on_username_and_domain_lower ON accounts (lower(username), lower(domain))' }
remove_index :accounts, name: 'index_accounts_on_username_and_domain' if index_name_exists?(:accounts, 'index_accounts_on_username_and_domain')
end
def down
raise ActiveRecord::IrreversibleMigration
end
private
def deduplicate_account!(account_ids)
accounts = Account.where(id: account_ids).to_a
accounts = accounts.first.local? ? accounts.sort_by(&:created_at) : accounts.sort_by(&:updated_at).reverse
reference_account = accounts.shift
accounts.each do |other_account|
if other_account.public_key == reference_account.public_key
# The accounts definitely point to the same resource, so
# it's safe to re-attribute content and relationships
merge_accounts!(reference_account, other_account)
elsif other_account.local?
# Since domain is in the GROUP BY clause, both accounts
# are always either going to be local or not local, so only
# one check is needed. Since we cannot support two users with
# the same username locally, one has to go. 😢
other_account.user&.destroy
end
other_account.destroy
end
end
def merge_accounts!(main_account, duplicate_account)
[Status, Favourite, Mention, StatusPin, StreamEntry].each do |klass|
klass.where(account_id: duplicate_account.id).update_all(account_id: main_account.id)
end
# Since it's the same remote resource, the remote resource likely
# already believes we are following/blocking, so it's safe to
# re-attribute the relationships too. However, during the presence
# of the index bug users could have *also* followed the reference
# account already, therefore mass update will not work and we need
# to check for (and skip past) uniqueness errors
[Follow, FollowRequest, Block, Mute].each do |klass|
klass.where(account_id: duplicate_account.id).find_each do |record|
begin
record.update(account_id: main_account.id)
rescue ActiveRecord::RecordNotUnique
next
end
end
klass.where(target_account_id: duplicate_account.id).find_each do |record|
begin
record.update(target_account_id: main_account.id)
rescue ActiveRecord::RecordNotUnique
next
end
end
end
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2018_05_14_140000) do ActiveRecord::Schema.define(version: 2018_05_28_141303) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -77,10 +77,9 @@ ActiveRecord::Schema.define(version: 2018_05_14_140000) do
t.jsonb "fields" t.jsonb "fields"
t.string "actor_type" t.string "actor_type"
t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin
t.index "lower((username)::text), lower((domain)::text)", name: "index_accounts_on_username_and_domain_lower" t.index "lower((username)::text), lower((domain)::text)", name: "index_accounts_on_username_and_domain_lower", unique: true
t.index ["uri"], name: "index_accounts_on_uri" t.index ["uri"], name: "index_accounts_on_uri"
t.index ["url"], name: "index_accounts_on_url" t.index ["url"], name: "index_accounts_on_url"
t.index ["username", "domain"], name: "index_accounts_on_username_and_domain", unique: true
end end
create_table "admin_action_logs", force: :cascade do |t| create_table "admin_action_logs", force: :cascade do |t|

View File

@ -13,7 +13,7 @@ module Mastodon
end end
def patch def patch
0 1
end end
def pre def pre
@ -21,7 +21,7 @@ module Mastodon
end end
def flags def flags
'' 'rc1'
end end
def to_a def to_a

View File

@ -502,18 +502,17 @@ namespace :mastodon do
desc 'Remove media attachments attributed to silenced accounts' desc 'Remove media attachments attributed to silenced accounts'
task remove_silenced: :environment do task remove_silenced: :environment do
MediaAttachment.where(account: Account.silenced).find_each(&:destroy) MediaAttachment.where(account: Account.silenced).select(:id).find_in_batches do |media_attachments|
Maintenance::DestroyMediaWorker.push_bulk(media_attachments.map(&:id))
end
end end
desc 'Remove cached remote media attachments that are older than NUM_DAYS. By default 7 (week)' desc 'Remove cached remote media attachments that are older than NUM_DAYS. By default 7 (week)'
task remove_remote: :environment do task remove_remote: :environment do
time_ago = ENV.fetch('NUM_DAYS') { 7 }.to_i.days.ago time_ago = ENV.fetch('NUM_DAYS') { 7 }.to_i.days.ago
MediaAttachment.where.not(remote_url: '').where.not(file_file_name: nil).where('created_at < ?', time_ago).find_each do |media| MediaAttachment.where.not(remote_url: '').where.not(file_file_name: nil).where('created_at < ?', time_ago).select(:id).find_in_batches do |media_attachments|
next unless media.file.exists? Maintenance::UncacheMediaWorker.push_bulk(media_attachments.map(&:id))
media.file.destroy
media.save
end end
end end
@ -529,14 +528,8 @@ namespace :mastodon do
accounts = Account.remote accounts = Account.remote
accounts = accounts.where(domain: ENV['DOMAIN']) if ENV['DOMAIN'].present? accounts = accounts.where(domain: ENV['DOMAIN']) if ENV['DOMAIN'].present?
accounts.find_each do |account| accounts.select(:id).find_in_batches do |accounts_batch|
begin Maintenance::RedownloadAccountMediaWorker.push_bulk(accounts_batch.map(&:id))
account.reset_avatar!
account.reset_header!
account.save
rescue Paperclip::Error
puts "Error resetting avatar and header for account #{username}@#{domain}"
end
end end
end end
end end
@ -568,8 +561,8 @@ namespace :mastodon do
desc 'Generates home timelines for users who logged in in the past two weeks' desc 'Generates home timelines for users who logged in in the past two weeks'
task build: :environment do task build: :environment do
User.active.includes(:account).find_each do |u| User.active.select(:account_id).find_in_batches do |users|
PrecomputeFeedService.new.call(u.account) RegenerationWorker.push_bulk(users.map(&:account_id))
end end
end end
end end

View File

@ -525,6 +525,37 @@ RSpec.describe Account, type: :model do
end end
end end
describe '#statuses_count' do
subject { Fabricate(:account) }
it 'counts statuses' do
Fabricate(:status, account: subject)
Fabricate(:status, account: subject)
expect(subject.statuses_count).to eq 2
end
it 'does not count direct statuses' do
Fabricate(:status, account: subject, visibility: :direct)
expect(subject.statuses_count).to eq 0
end
it 'is decremented when status is removed' do
status = Fabricate(:status, account: subject)
expect(subject.statuses_count).to eq 1
status.destroy
expect(subject.statuses_count).to eq 0
end
it 'is decremented when status is removed when account is not preloaded' do
status = Fabricate(:status, account: subject)
expect(subject.reload.statuses_count).to eq 1
clean_status = Status.find(status.id)
expect(clean_status.association(:account).loaded?).to be false
clean_status.destroy
expect(subject.reload.statuses_count).to eq 0
end
end
describe '.following_map' do describe '.following_map' do
it 'returns an hash' do it 'returns an hash' do
expect(Account.following_map([], 1)).to be_a Hash expect(Account.following_map([], 1)).to be_a Hash

View File

@ -175,6 +175,13 @@ RSpec.describe Status, type: :model do
expect(subject.reblogs_count).to eq 2 expect(subject.reblogs_count).to eq 2
end end
it 'is decremented when reblog is removed' do
reblog = Fabricate(:status, account: bob, reblog: subject)
expect(subject.reblogs_count).to eq 1
reblog.destroy
expect(subject.reblogs_count).to eq 0
end
end end
describe '#favourites_count' do describe '#favourites_count' do
@ -184,6 +191,13 @@ RSpec.describe Status, type: :model do
expect(subject.favourites_count).to eq 2 expect(subject.favourites_count).to eq 2
end end
it 'is decremented when favourite is removed' do
favourite = Fabricate(:favourite, account: bob, status: subject)
expect(subject.favourites_count).to eq 1
favourite.destroy
expect(subject.favourites_count).to eq 0
end
end end
describe '#proper' do describe '#proper' do