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}' },
hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide 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
@ -54,17 +62,29 @@ export default class ActionBar extends React.PureComponent {
let menu = [];
let extraInfo = '';
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 });
if (account.get('id') !== me) {
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) {
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) {
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 {
if (account.getIn(['relationship', 'following'])) {
if (account.getIn(['relationship', 'showing_reblogs'])) {

View File

@ -14,6 +14,7 @@ const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
});
class Avatar extends ImmutablePureComponent {
@ -74,6 +75,10 @@ export default class Header extends ImmutablePureComponent {
intl: PropTypes.object.isRequired,
};
openEditProfile = () => {
window.open('/settings/profile', '_blank');
}
render () {
const { account, intl } = this.props;
@ -118,6 +123,12 @@ export default class Header extends ImmutablePureComponent {
</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'])) {

View File

@ -1,42 +1,11 @@
import React from 'react';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { FormattedMessage, FormattedNumber } from 'react-intl';
import { FormattedMessage } from 'react-intl';
import AccountContainer from '../../../containers/account_container';
import StatusContainer from '../../../containers/status_container';
import { Link } from 'react-router-dom';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { Sparklines, SparklinesCurve } from 'react-sparklines';
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>
);
import Hashtag from '../../../components/hashtag';
export default class SearchResults extends ImmutablePureComponent {
@ -66,7 +35,7 @@ export default class SearchResults extends ImmutablePureComponent {
<FormattedMessage id='trends.header' defaultMessage='Trending now' />
</div>
{trends && trends.map(hashtag => renderHashtag(hashtag))}
{trends && trends.map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
</div>
</div>
);
@ -100,7 +69,7 @@ export default class SearchResults extends ImmutablePureComponent {
<div className='search-results__section'>
<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>
);
}

View File

@ -75,7 +75,7 @@ export default class Compose extends React.PureComponent {
const { columns } = this.props;
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') && (
<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 = {
params: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
domains: ImmutablePropTypes.list,
domains: ImmutablePropTypes.orderedSet,
intl: PropTypes.object.isRequired,
};

View File

@ -10,38 +10,41 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from '../../initial_state';
import { fetchFollowRequests } from '../../actions/accounts';
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({
heading: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
home_timeline: { id: 'tabs_bar.home', defaultMessage: 'Home' },
notifications: { id: 'tabs_bar.notifications', defaultMessage: 'Notifications' },
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' },
community_timeline: { id: 'navigation_bar.community_timeline', defaultMessage: 'Local timeline' },
direct: { id: 'navigation_bar.direct', defaultMessage: 'Direct messages' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
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' },
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' },
info: { id: 'navigation_bar.info', defaultMessage: 'Extended information' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
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 => ({
myAccount: state.getIn(['accounts', me]),
columns: state.getIn(['settings', 'columns']),
unreadFollowRequests: state.getIn(['user_lists', 'follow_requests', 'items'], ImmutableList()).size,
unreadNotifications: state.getIn(['notifications', 'unread']),
trends: state.get('trends'),
});
const mapDispatchToProps = dispatch => ({
fetchFollowRequests: () => dispatch(fetchFollowRequests()),
fetchTrends: () => dispatch(fetchTrends()),
});
const badgeDisplay = (number, limit) => {
@ -66,6 +69,7 @@ export default class GettingStarted extends ImmutablePureComponent {
fetchFollowRequests: PropTypes.func.isRequired,
unreadFollowRequests: PropTypes.number,
unreadNotifications: PropTypes.number,
trends: ImmutablePropTypes.list,
};
componentDidMount () {
@ -74,36 +78,26 @@ export default class GettingStarted extends ImmutablePureComponent {
if (myAccount.get('locked')) {
fetchFollowRequests();
}
setTimeout(() => this.props.fetchTrends(), 5000);
}
render () {
const { intl, myAccount, columns, multiColumn, unreadFollowRequests, unreadNotifications } = this.props;
const { intl, myAccount, multiColumn, unreadFollowRequests, trends } = this.props;
const navItems = [];
if (multiColumn) {
if (!columns.find(item => item.get('id') === 'HOME')) {
navItems.push(<ColumnLink key='0' icon='home' text={intl.formatMessage(messages.home_timeline)} to='/timelines/home' />);
}
if (!columns.find(item => item.get('id') === 'NOTIFICATIONS')) {
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(
<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' />,
<ColumnSubheading key='8' text={intl.formatMessage(messages.personal)} />
);
}
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='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' />);
}
if (multiColumn) {
navItems.push(<ColumnLink key='8' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' />);
if (!multiColumn) {
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 (
<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'>
<ColumnSubheading text={intl.formatMessage(messages.navigation_subheading)} />
{!multiColumn && <NavigationBar account={myAccount} />}
{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 className='static-content getting-started'>
<p>
<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>
</p>
{multiColumn && trends && <div className='getting-started__trends'>
<div className='column-header__wrapper'>
<h1 className='column-header'>
<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>
<FormattedMessage
id='getting_started.open_source_notice'

View File

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

View File

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

View File

@ -5,6 +5,7 @@ import {
import { CONTEXT_FETCH_SUCCESS } from '../actions/statuses';
import { TIMELINE_DELETE, TIMELINE_UPDATE } from '../actions/timelines';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import compareId from '../compare_id';
const initialState = ImmutableMap({
inReplyTos: ImmutableMap(),
@ -15,27 +16,27 @@ const normalizeContext = (immutableState, id, ancestors, descendants) => immutab
state.update('inReplyTos', immutableAncestors => immutableAncestors.withMutations(inReplyTos => {
state.update('replies', immutableDescendants => immutableDescendants.withMutations(replies => {
function addReply({ id, in_reply_to_id }) {
if (in_reply_to_id) {
const siblings = replies.get(in_reply_to_id, ImmutableList());
if (in_reply_to_id && !inReplyTos.has(id)) {
if (!siblings.includes(id)) {
const index = siblings.findLastIndex(sibling => sibling.id < id);
replies.set(in_reply_to_id, siblings.insert(index + 1, id));
}
replies.update(in_reply_to_id, ImmutableList(), siblings => {
const index = siblings.findLastIndex(sibling => compareId(sibling, id) < 0);
return siblings.insert(index + 1, 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]) {
addReply({ id, in_reply_to_id: ancestors[0].id });
addReply({ id, in_reply_to_id: ancestors[ancestors.length - 1].id });
}
if (descendants[0]) {
addReply({ id: descendants[0].id, in_reply_to_id: id });
}
[ancestors, descendants].forEach(statuses => statuses.forEach(addReply));
descendants.forEach(addReply);
}));
}));
});
@ -80,7 +81,7 @@ const updateContext = (state, status) => {
mutable.setIn(['inReplyTos', status.id], status.in_reply_to_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';
const initialState = ImmutableMap({
blocks: ImmutableMap(),
blocks: ImmutableMap({
items: ImmutableOrderedSet(),
}),
});
export default function domainLists(state = initialState, action) {

View File

@ -1663,24 +1663,6 @@ a.account__display-name {
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 {
display: flex;
flex: 1 1 auto;
@ -1772,6 +1754,8 @@ a.account__display-name {
margin-bottom: 0;
}
.getting-started__wrapper,
.getting-started__trends,
.search {
margin-bottom: 10px;
}
@ -2175,7 +2159,8 @@ a.account__display-name {
}
.getting-started__wrapper,
.getting_started {
.getting-started,
.flex-spacer {
background: $ui-base-color;
}
@ -2184,16 +2169,58 @@ a.account__display-name {
overflow-y: auto;
}
.flex-spacer {
flex: 1 1 auto;
}
.getting-started {
background: $ui-base-color;
flex: 1 0 auto;
color: $dark-text-color;
p {
color: $secondary-text-color;
color: $dark-text-color;
font-size: 13px;
margin-bottom: 20px;
a {
color: $dark-text-color;
text-decoration: underline;
}
}
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)
status_pins.where(status: status).exists?
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

View File

@ -16,7 +16,7 @@ class Favourite < ApplicationRecord
update_index('statuses#status', :status) if Chewy.enabled?
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
@ -25,4 +25,27 @@ class Favourite < ApplicationRecord
before_validation do
self.status = status.reblog if status&.reblog?
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

View File

@ -43,12 +43,12 @@ class Status < ApplicationRecord
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 :conversation, 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 :bookmarks, inverse_of: :status, dependent: :destroy
@ -172,6 +172,17 @@ class Status < ApplicationRecord
@emojis ||= CustomEmoji.from_text([spoiler_text, text].join(' '), account.domain)
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 :update_statistics, if: :local?
@ -414,4 +425,40 @@ class Status < ApplicationRecord
return unless public_visibility? || unlisted_visibility?
ActivityTracker.increment('activity:statuses:local')
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

View File

@ -21,7 +21,10 @@ class BatchedRemoveStatusService < BaseService
@activity_xml = {}
# 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
statuses.group_by(&:account_id).each_value do |account_statuses|
@ -53,7 +56,7 @@ class BatchedRemoveStatusService < BaseService
end
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?
@ -65,7 +68,7 @@ class BatchedRemoveStatusService < BaseService
end
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|
FeedManager.instance.unpush_from_list(list, status)
end

View File

@ -38,7 +38,7 @@ class FanOutOnWriteService < BaseService
def deliver_to_followers(status)
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|
[status.id, follower.id, :home]
end
@ -48,7 +48,7 @@ class FanOutOnWriteService < BaseService
def deliver_to_lists(status)
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|
[status.id, list.id, :list]
end

View File

@ -43,13 +43,13 @@ class RemoveStatusService < BaseService
end
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)
end
end
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)
end
end

View File

@ -41,9 +41,10 @@ class SuspendAccountService < BaseService
end
def purge_profile!
@account.suspended = true
@account.display_name = ''
@account.note = ''
@account.suspended = true
@account.display_name = ''
@account.note = ''
@account.statuses_count = 0
@account.avatar.destroy
@account.header.destroy
@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.
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
enable_extension "plpgsql"
@ -77,10 +77,9 @@ ActiveRecord::Schema.define(version: 2018_05_14_140000) do
t.jsonb "fields"
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 "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 ["url"], name: "index_accounts_on_url"
t.index ["username", "domain"], name: "index_accounts_on_username_and_domain", unique: true
end
create_table "admin_action_logs", force: :cascade do |t|

View File

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

View File

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

View File

@ -525,6 +525,37 @@ RSpec.describe Account, type: :model do
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
it 'returns an hash' do
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
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
describe '#favourites_count' do
@ -184,6 +191,13 @@ RSpec.describe Status, type: :model do
expect(subject.favourites_count).to eq 2
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
describe '#proper' do