Merge pull request #1704 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changesth-downstream
commit
a3ead64052
@ -0,0 +1,45 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::Trends::StatusesController < Admin::BaseController
|
||||||
|
def index
|
||||||
|
authorize :status, :index?
|
||||||
|
|
||||||
|
@statuses = filtered_statuses.page(params[:page])
|
||||||
|
@form = Trends::StatusBatch.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def batch
|
||||||
|
@form = Trends::StatusBatch.new(trends_status_batch_params.merge(current_account: current_account, action: action_from_button))
|
||||||
|
@form.save
|
||||||
|
rescue ActionController::ParameterMissing
|
||||||
|
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
||||||
|
ensure
|
||||||
|
redirect_to admin_trends_statuses_path(filter_params)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def filtered_statuses
|
||||||
|
Trends::StatusFilter.new(filter_params.with_defaults(trending: 'all')).results.includes(:account, :media_attachments, :active_mentions)
|
||||||
|
end
|
||||||
|
|
||||||
|
def filter_params
|
||||||
|
params.slice(:page, *Trends::StatusFilter::KEYS).permit(:page, *Trends::StatusFilter::KEYS)
|
||||||
|
end
|
||||||
|
|
||||||
|
def trends_status_batch_params
|
||||||
|
params.require(:trends_status_batch).permit(:action, status_ids: [])
|
||||||
|
end
|
||||||
|
|
||||||
|
def action_from_button
|
||||||
|
if params[:approve]
|
||||||
|
'approve'
|
||||||
|
elsif params[:approve_accounts]
|
||||||
|
'approve_accounts'
|
||||||
|
elsif params[:reject]
|
||||||
|
'reject'
|
||||||
|
elsif params[:reject_accounts]
|
||||||
|
'reject_accounts'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,19 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::Admin::Trends::LinksController < Api::BaseController
|
||||||
|
protect_from_forgery with: :exception
|
||||||
|
|
||||||
|
before_action -> { authorize_if_got_token! :'admin:read' }
|
||||||
|
before_action :require_staff!
|
||||||
|
before_action :set_links
|
||||||
|
|
||||||
|
def index
|
||||||
|
render json: @links, each_serializer: REST::Trends::LinkSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_links
|
||||||
|
@links = Trends.links.query.limit(limit_param(10))
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,19 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::Admin::Trends::StatusesController < Api::BaseController
|
||||||
|
protect_from_forgery with: :exception
|
||||||
|
|
||||||
|
before_action -> { authorize_if_got_token! :'admin:read' }
|
||||||
|
before_action :require_staff!
|
||||||
|
before_action :set_statuses
|
||||||
|
|
||||||
|
def index
|
||||||
|
render json: @statuses, each_serializer: REST::StatusSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_statuses
|
||||||
|
@statuses = cache_collection(Trends.statuses.query.limit(limit_param(DEFAULT_STATUSES_LIMIT)), Status)
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,27 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::Trends::StatusesController < Api::BaseController
|
||||||
|
before_action :set_statuses
|
||||||
|
|
||||||
|
def index
|
||||||
|
render json: @statuses, each_serializer: REST::StatusSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_statuses
|
||||||
|
@statuses = begin
|
||||||
|
if Setting.trends
|
||||||
|
cache_collection(statuses_from_trends, Status)
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def statuses_from_trends
|
||||||
|
scope = Trends.statuses.query.allowed.in_locale(content_locale)
|
||||||
|
scope = scope.filtered_for(current_account) if user_signed_in?
|
||||||
|
scope.limit(limit_param(DEFAULT_STATUSES_LIMIT))
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import Blurhash from 'mastodon/components/blurhash';
|
||||||
|
import { accountsCountRenderer } from 'mastodon/components/hashtag';
|
||||||
|
import ShortNumber from 'mastodon/components/short_number';
|
||||||
|
import Skeleton from 'mastodon/components/skeleton';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
export default class Story extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
url: PropTypes.string,
|
||||||
|
title: PropTypes.string,
|
||||||
|
publisher: PropTypes.string,
|
||||||
|
sharedTimes: PropTypes.number,
|
||||||
|
thumbnail: PropTypes.string,
|
||||||
|
blurhash: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
thumbnailLoaded: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleImageLoad = () => this.setState({ thumbnailLoaded: true });
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { url, title, publisher, sharedTimes, thumbnail, blurhash } = this.props;
|
||||||
|
|
||||||
|
const { thumbnailLoaded } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a className='story' href={url} target='blank' rel='noopener'>
|
||||||
|
<div className='story__details'>
|
||||||
|
<div className='story__details__publisher'>{publisher ? publisher : <Skeleton width={50} />}</div>
|
||||||
|
<div className='story__details__title'>{title ? title : <Skeleton />}</div>
|
||||||
|
<div className='story__details__shared'>{typeof sharedTimes === 'number' ? <ShortNumber value={sharedTimes} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='story__thumbnail'>
|
||||||
|
{thumbnail ? (
|
||||||
|
<React.Fragment>
|
||||||
|
<div className={classNames('story__thumbnail__preview', { 'story__thumbnail__preview--hidden': thumbnailLoaded })}><Blurhash hash={blurhash} /></div>
|
||||||
|
<img src={thumbnail} onLoad={this.handleImageLoad} alt='' role='presentation' />
|
||||||
|
</React.Fragment>
|
||||||
|
) : <Skeleton />}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,91 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import Column from 'mastodon/components/column';
|
||||||
|
import ColumnHeader from 'mastodon/components/column_header';
|
||||||
|
import { NavLink, Switch, Route } from 'react-router-dom';
|
||||||
|
import Links from './links';
|
||||||
|
import Tags from './tags';
|
||||||
|
import Statuses from './statuses';
|
||||||
|
import Suggestions from './suggestions';
|
||||||
|
import Search from 'mastodon/features/compose/containers/search_container';
|
||||||
|
import SearchResults from './results';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: { id: 'explore.title', defaultMessage: 'Explore' },
|
||||||
|
searchResults: { id: 'explore.search_results', defaultMessage: 'Search results' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
layout: state.getIn(['meta', 'layout']),
|
||||||
|
isSearching: state.getIn(['search', 'submitted']),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default @connect(mapStateToProps)
|
||||||
|
@injectIntl
|
||||||
|
class Explore extends React.PureComponent {
|
||||||
|
|
||||||
|
static contextTypes = {
|
||||||
|
router: PropTypes.object,
|
||||||
|
};
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
intl: PropTypes.object.isRequired,
|
||||||
|
multiColumn: PropTypes.bool,
|
||||||
|
isSearching: PropTypes.bool,
|
||||||
|
layout: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleHeaderClick = () => {
|
||||||
|
this.column.scrollTop();
|
||||||
|
}
|
||||||
|
|
||||||
|
setRef = c => {
|
||||||
|
this.column = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { intl, multiColumn, isSearching, layout } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||||
|
{layout === 'mobile' ? (
|
||||||
|
<div className='explore__search-header'>
|
||||||
|
<Search />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ColumnHeader
|
||||||
|
icon={isSearching ? 'search' : 'globe'}
|
||||||
|
title={intl.formatMessage(isSearching ? messages.searchResults : messages.title)}
|
||||||
|
onClick={this.handleHeaderClick}
|
||||||
|
multiColumn={multiColumn}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='scrollable scrollable--flex'>
|
||||||
|
{isSearching ? (
|
||||||
|
<SearchResults />
|
||||||
|
) : (
|
||||||
|
<React.Fragment>
|
||||||
|
<div className='account__section-headline'>
|
||||||
|
<NavLink exact to='/explore'><FormattedMessage id='explore.trending_statuses' defaultMessage='Posts' /></NavLink>
|
||||||
|
<NavLink exact to='/explore/tags'><FormattedMessage id='explore.trending_tags' defaultMessage='Hashtags' /></NavLink>
|
||||||
|
<NavLink exact to='/explore/links'><FormattedMessage id='explore.trending_links' defaultMessage='News' /></NavLink>
|
||||||
|
<NavLink exact to='/explore/suggestions'><FormattedMessage id='explore.suggested_follows' defaultMessage='For you' /></NavLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Switch>
|
||||||
|
<Route path='/explore/tags' component={Tags} />
|
||||||
|
<Route path='/explore/links' component={Links} />
|
||||||
|
<Route path='/explore/suggestions' component={Suggestions} />
|
||||||
|
<Route exact path={['/explore', '/explore/posts', '/search']} component={Statuses} componentParams={{ multiColumn }} />
|
||||||
|
</Switch>
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import Story from './components/story';
|
||||||
|
import LoadingIndicator from 'mastodon/components/loading_indicator';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { fetchTrendingLinks } from 'mastodon/actions/trends';
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
links: state.getIn(['trends', 'links', 'items']),
|
||||||
|
isLoading: state.getIn(['trends', 'links', 'isLoading']),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default @connect(mapStateToProps)
|
||||||
|
class Links extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
links: ImmutablePropTypes.list,
|
||||||
|
isLoading: PropTypes.bool,
|
||||||
|
dispatch: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
dispatch(fetchTrendingLinks());
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { isLoading, links } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='explore__links'>
|
||||||
|
{isLoading ? (<LoadingIndicator />) : links.map(link => (
|
||||||
|
<Story
|
||||||
|
key={link.get('id')}
|
||||||
|
url={link.get('url')}
|
||||||
|
title={link.get('title')}
|
||||||
|
publisher={link.get('provider_name')}
|
||||||
|
sharedTimes={link.getIn(['history', 0, 'accounts']) * 1 + link.getIn(['history', 1, 'accounts']) * 1}
|
||||||
|
thumbnail={link.get('image')}
|
||||||
|
blurhash={link.get('blurhash')}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,113 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { expandSearch } from 'mastodon/actions/search';
|
||||||
|
import Account from 'mastodon/containers/account_container';
|
||||||
|
import Status from 'mastodon/containers/status_container';
|
||||||
|
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
|
||||||
|
import { List as ImmutableList } from 'immutable';
|
||||||
|
import LoadMore from 'mastodon/components/load_more';
|
||||||
|
import LoadingIndicator from 'mastodon/components/loading_indicator';
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
isLoading: state.getIn(['search', 'isLoading']),
|
||||||
|
results: state.getIn(['search', 'results']),
|
||||||
|
});
|
||||||
|
|
||||||
|
const appendLoadMore = (id, list, onLoadMore) => {
|
||||||
|
if (list.size >= 5) {
|
||||||
|
return list.push(<LoadMore key={`${id}-load-more`} visible onClick={onLoadMore} />);
|
||||||
|
} else {
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAccounts = (results, onLoadMore) => appendLoadMore('accounts', results.get('accounts').map(item => (
|
||||||
|
<Account key={`account-${item}`} id={item} />
|
||||||
|
)), onLoadMore);
|
||||||
|
|
||||||
|
const renderHashtags = (results, onLoadMore) => appendLoadMore('hashtags', results.get('hashtags').map(item => (
|
||||||
|
<Hashtag key={`tag-${item.get('name')}`} hashtag={item} />
|
||||||
|
)), onLoadMore);
|
||||||
|
|
||||||
|
const renderStatuses = (results, onLoadMore) => appendLoadMore('statuses', results.get('statuses').map(item => (
|
||||||
|
<Status key={`status-${item}`} id={item} />
|
||||||
|
)), onLoadMore);
|
||||||
|
|
||||||
|
export default @connect(mapStateToProps)
|
||||||
|
class Results extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
results: ImmutablePropTypes.map,
|
||||||
|
isLoading: PropTypes.bool,
|
||||||
|
multiColumn: PropTypes.bool,
|
||||||
|
dispatch: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
type: 'all',
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSelectAll = () => this.setState({ type: 'all' });
|
||||||
|
handleSelectAccounts = () => this.setState({ type: 'accounts' });
|
||||||
|
handleSelectHashtags = () => this.setState({ type: 'hashtags' });
|
||||||
|
handleSelectStatuses = () => this.setState({ type: 'statuses' });
|
||||||
|
handleLoadMoreAccounts = () => this.loadMore('accounts');
|
||||||
|
handleLoadMoreStatuses = () => this.loadMore('statuses');
|
||||||
|
handleLoadMoreHashtags = () => this.loadMore('hashtags');
|
||||||
|
|
||||||
|
loadMore (type) {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
dispatch(expandSearch(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { isLoading, results } = this.props;
|
||||||
|
const { type } = this.state;
|
||||||
|
|
||||||
|
let filteredResults = ImmutableList();
|
||||||
|
|
||||||
|
if (!isLoading) {
|
||||||
|
switch(type) {
|
||||||
|
case 'all':
|
||||||
|
filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts), renderHashtags(results, this.handleLoadMoreHashtags), renderStatuses(results, this.handleLoadMoreStatuses));
|
||||||
|
break;
|
||||||
|
case 'accounts':
|
||||||
|
filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts));
|
||||||
|
break;
|
||||||
|
case 'hashtags':
|
||||||
|
filteredResults = filteredResults.concat(renderHashtags(results, this.handleLoadMoreHashtags));
|
||||||
|
break;
|
||||||
|
case 'statuses':
|
||||||
|
filteredResults = filteredResults.concat(renderStatuses(results, this.handleLoadMoreStatuses));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredResults.size === 0) {
|
||||||
|
filteredResults = (
|
||||||
|
<div className='empty-column-indicator'>
|
||||||
|
<FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<div className='account__section-headline'>
|
||||||
|
<button onClick={this.handleSelectAll} className={type === 'all' && 'active'}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
|
||||||
|
<button onClick={this.handleSelectAccounts} className={type === 'accounts' && 'active'}><FormattedMessage id='search_results.accounts' defaultMessage='People' /></button>
|
||||||
|
<button onClick={this.handleSelectHashtags} className={type === 'hashtags' && 'active'}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
|
||||||
|
<button onClick={this.handleSelectStatuses} className={type === 'statuses' && 'active'}><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='explore__search-results'>
|
||||||
|
{isLoading ? (<LoadingIndicator />) : filteredResults}
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import StatusList from 'mastodon/components/status_list';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { fetchTrendingStatuses } from 'mastodon/actions/trends';
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
statusIds: state.getIn(['status_lists', 'trending', 'items']),
|
||||||
|
isLoading: state.getIn(['status_lists', 'trending', 'isLoading'], true),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default @connect(mapStateToProps)
|
||||||
|
class Statuses extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
statusIds: ImmutablePropTypes.list,
|
||||||
|
isLoading: PropTypes.bool,
|
||||||
|
multiColumn: PropTypes.bool,
|
||||||
|
dispatch: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
dispatch(fetchTrendingStatuses());
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { isLoading, statusIds, multiColumn } = this.props;
|
||||||
|
|
||||||
|
const emptyMessage = <FormattedMessage id='empty_column.explore_statuses' defaultMessage='Nothing is trending right now. Check back later!' />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatusList
|
||||||
|
trackScroll
|
||||||
|
statusIds={statusIds}
|
||||||
|
scrollKey='explore-statuses'
|
||||||
|
hasMore={false}
|
||||||
|
isLoading={isLoading}
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
bindToDocument={!multiColumn}
|
||||||
|
withCounters
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import Account from 'mastodon/containers/account_container';
|
||||||
|
import LoadingIndicator from 'mastodon/components/loading_indicator';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { fetchSuggestions } from 'mastodon/actions/suggestions';
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
suggestions: state.getIn(['suggestions', 'items']),
|
||||||
|
isLoading: state.getIn(['suggestions', 'isLoading']),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default @connect(mapStateToProps)
|
||||||
|
class Suggestions extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
isLoading: PropTypes.bool,
|
||||||
|
suggestions: ImmutablePropTypes.list,
|
||||||
|
dispatch: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
dispatch(fetchSuggestions(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { isLoading, suggestions } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='explore__links'>
|
||||||
|
{isLoading ? (<LoadingIndicator />) : suggestions.map(suggestion => (
|
||||||
|
<Account key={suggestion.get('account')} id={suggestion.get('account')} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
|
||||||
|
import LoadingIndicator from 'mastodon/components/loading_indicator';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { fetchTrendingHashtags } from 'mastodon/actions/trends';
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
hashtags: state.getIn(['trends', 'tags', 'items']),
|
||||||
|
isLoadingHashtags: state.getIn(['trends', 'tags', 'isLoading']),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default @connect(mapStateToProps)
|
||||||
|
class Tags extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
hashtags: ImmutablePropTypes.list,
|
||||||
|
isLoading: PropTypes.bool,
|
||||||
|
dispatch: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
dispatch(fetchTrendingHashtags());
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { isLoading, hashtags } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='explore__links'>
|
||||||
|
{isLoading ? (<LoadingIndicator />) : hashtags.map(hashtag => (
|
||||||
|
<Hashtag key={hashtag.get('name')} hashtag={hashtag} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -1,13 +1,13 @@
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { fetchTrends } from 'mastodon/actions/trends';
|
import { fetchTrendingHashtags } from 'mastodon/actions/trends';
|
||||||
import Trends from '../components/trends';
|
import Trends from '../components/trends';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
trends: state.getIn(['trends', 'items']),
|
trends: state.getIn(['trends', 'tags', 'items']),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
const mapDispatchToProps = dispatch => ({
|
||||||
fetchTrends: () => dispatch(fetchTrends()),
|
fetchTrends: () => dispatch(fetchTrendingHashtags()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(Trends);
|
export default connect(mapStateToProps, mapDispatchToProps)(Trends);
|
||||||
|
@ -1,17 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import SearchContainer from 'mastodon/features/compose/containers/search_container';
|
|
||||||
import SearchResultsContainer from 'mastodon/features/compose/containers/search_results_container';
|
|
||||||
|
|
||||||
const Search = () => (
|
|
||||||
<div className='column search-page'>
|
|
||||||
<SearchContainer />
|
|
||||||
|
|
||||||
<div className='drawer__pager'>
|
|
||||||
<div className='drawer__inner darker'>
|
|
||||||
<SearchResultsContainer />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default Search;
|
|
@ -0,0 +1,30 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Form::EmailDomainBlockBatch
|
||||||
|
include ActiveModel::Model
|
||||||
|
include Authorization
|
||||||
|
include AccountableConcern
|
||||||
|
|
||||||
|
attr_accessor :email_domain_block_ids, :action, :current_account
|
||||||
|
|
||||||
|
def save
|
||||||
|
case action
|
||||||
|
when 'delete'
|
||||||
|
delete!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def email_domain_blocks
|
||||||
|
@email_domain_blocks ||= EmailDomainBlock.where(id: email_domain_block_ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete!
|
||||||
|
email_domain_blocks.each do |email_domain_block|
|
||||||
|
authorize(email_domain_block, :destroy?)
|
||||||
|
email_domain_block.destroy!
|
||||||
|
log_action :destroy, email_domain_block
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
@ -1,6 +1,6 @@
|
|||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class PreviewCardProviderFilter
|
class Trends::PreviewCardProviderFilter
|
||||||
KEYS = %i(
|
KEYS = %i(
|
||||||
status
|
status
|
||||||
).freeze
|
).freeze
|
@ -0,0 +1,106 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Trends::Query
|
||||||
|
include Redisable
|
||||||
|
include Enumerable
|
||||||
|
|
||||||
|
attr_reader :prefix, :klass, :loaded
|
||||||
|
|
||||||
|
alias loaded? loaded
|
||||||
|
|
||||||
|
def initialize(prefix, klass)
|
||||||
|
@prefix = prefix
|
||||||
|
@klass = klass
|
||||||
|
@records = []
|
||||||
|
@loaded = false
|
||||||
|
@allowed = false
|
||||||
|
@limit = -1
|
||||||
|
@offset = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def allowed!
|
||||||
|
@allowed = true
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
|
def allowed
|
||||||
|
clone.allowed!
|
||||||
|
end
|
||||||
|
|
||||||
|
def in_locale!(value)
|
||||||
|
@locale = value
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
|
def in_locale(value)
|
||||||
|
clone.in_locale!(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
def offset!(value)
|
||||||
|
@offset = value
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
|
def offset(value)
|
||||||
|
clone.offset!(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
def limit!(value)
|
||||||
|
@limit = value
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
|
def limit(value)
|
||||||
|
clone.limit!(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
def records
|
||||||
|
load
|
||||||
|
@records
|
||||||
|
end
|
||||||
|
|
||||||
|
delegate :each, :empty?, :first, :last, to: :records
|
||||||
|
|
||||||
|
def to_ary
|
||||||
|
records.dup
|
||||||
|
end
|
||||||
|
|
||||||
|
alias to_a to_ary
|
||||||
|
|
||||||
|
def to_arel
|
||||||
|
tmp_ids = ids
|
||||||
|
|
||||||
|
if tmp_ids.empty?
|
||||||
|
klass.none
|
||||||
|
else
|
||||||
|
klass.joins("join unnest(array[#{tmp_ids.join(',')}]) with ordinality as x (id, ordering) on #{klass.table_name}.id = x.id").reorder('x.ordering')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def key
|
||||||
|
[@prefix, @allowed ? 'allowed' : 'all', @locale].compact.join(':')
|
||||||
|
end
|
||||||
|
|
||||||
|
def load
|
||||||
|
unless loaded?
|
||||||
|
@records = perform_queries
|
||||||
|
@loaded = true
|
||||||
|
end
|
||||||
|
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
|
def ids
|
||||||
|
redis.zrevrange(key, @offset, @limit.positive? ? @limit - 1 : @limit).map(&:to_i)
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_queries
|
||||||
|
apply_scopes(to_arel).to_a
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply_scopes(scope)
|
||||||
|
scope
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,65 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Trends::StatusBatch
|
||||||
|
include ActiveModel::Model
|
||||||
|
include Authorization
|
||||||
|
|
||||||
|
attr_accessor :status_ids, :action, :current_account
|
||||||
|
|
||||||
|
def save
|
||||||
|
case action
|
||||||
|
when 'approve'
|
||||||
|
approve!
|
||||||
|
when 'approve_accounts'
|
||||||
|
approve_accounts!
|
||||||
|
when 'reject'
|
||||||
|
reject!
|
||||||
|
when 'reject_accounts'
|
||||||
|
reject_accounts!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def statuses
|
||||||
|
@statuses ||= Status.where(id: status_ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
def status_accounts
|
||||||
|
@status_accounts ||= Account.where(id: statuses.map(&:account_id).uniq)
|
||||||
|
end
|
||||||
|
|
||||||
|
def approve!
|
||||||
|
statuses.each { |status| authorize(status, :review?) }
|
||||||
|
statuses.update_all(trendable: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def approve_accounts!
|
||||||
|
status_accounts.each do |account|
|
||||||
|
authorize(account, :review?)
|
||||||
|
account.update(trendable: true, reviewed_at: action_time)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Reset any individual overrides
|
||||||
|
statuses.update_all(trendable: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def reject!
|
||||||
|
statuses.each { |status| authorize(status, :review?) }
|
||||||
|
statuses.update_all(trendable: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
def reject_accounts!
|
||||||
|
status_accounts.each do |account|
|
||||||
|
authorize(account, :review?)
|
||||||
|
account.update(trendable: false, reviewed_at: action_time)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Reset any individual overrides
|
||||||
|
statuses.update_all(trendable: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def action_time
|
||||||
|
@action_time ||= Time.now.utc
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,46 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Trends::StatusFilter
|
||||||
|
KEYS = %i(
|
||||||
|
trending
|
||||||
|
locale
|
||||||
|
).freeze
|
||||||
|
|
||||||
|
attr_reader :params
|
||||||
|
|
||||||
|
def initialize(params)
|
||||||
|
@params = params
|
||||||
|
end
|
||||||
|
|
||||||
|
def results
|
||||||
|
scope = Status.unscoped.kept
|
||||||
|
|
||||||
|
params.each do |key, value|
|
||||||
|
next if %w(page locale).include?(key.to_s)
|
||||||
|
|
||||||
|
scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
scope
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def scope_for(key, value)
|
||||||
|
case key.to_s
|
||||||
|
when 'trending'
|
||||||
|
trending_scope(value)
|
||||||
|
else
|
||||||
|
raise "Unknown filter: #{key}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def trending_scope(value)
|
||||||
|
scope = Trends.statuses.query
|
||||||
|
|
||||||
|
scope = scope.in_locale(@params[:locale].to_s) if @params[:locale].present?
|
||||||
|
scope = scope.allowed if value == 'allowed'
|
||||||
|
|
||||||
|
scope.to_arel
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,142 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Trends::Statuses < Trends::Base
|
||||||
|
PREFIX = 'trending_statuses'
|
||||||
|
|
||||||
|
self.default_options = {
|
||||||
|
threshold: 5,
|
||||||
|
review_threshold: 3,
|
||||||
|
score_halflife: 2.hours.freeze,
|
||||||
|
}
|
||||||
|
|
||||||
|
class Query < Trends::Query
|
||||||
|
def filtered_for!(account)
|
||||||
|
@account = account
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
|
def filtered_for(account)
|
||||||
|
clone.filtered_for!(account)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def apply_scopes(scope)
|
||||||
|
scope.includes(:account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_queries
|
||||||
|
return super if @account.nil?
|
||||||
|
|
||||||
|
statuses = super
|
||||||
|
account_ids = statuses.map(&:account_id)
|
||||||
|
account_domains = statuses.map(&:account_domain)
|
||||||
|
|
||||||
|
preloaded_relations = {
|
||||||
|
blocking: Account.blocking_map(account_ids, @account.id),
|
||||||
|
blocked_by: Account.blocked_by_map(account_ids, @account.id),
|
||||||
|
muting: Account.muting_map(account_ids, @account.id),
|
||||||
|
following: Account.following_map(account_ids, @account.id),
|
||||||
|
domain_blocking_by_domain: Account.domain_blocking_map_by_domain(account_domains, @account.id),
|
||||||
|
}
|
||||||
|
|
||||||
|
statuses.reject { |status| StatusFilter.new(status, @account, preloaded_relations).filtered? }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def register(status, at_time = Time.now.utc)
|
||||||
|
add(status.proper, status.account_id, at_time) if eligible?(status)
|
||||||
|
end
|
||||||
|
|
||||||
|
def add(status, _account_id, at_time = Time.now.utc)
|
||||||
|
# We rely on the total reblogs and favourites count, so we
|
||||||
|
# don't record which account did the what and when here
|
||||||
|
|
||||||
|
record_used_id(status.id, at_time)
|
||||||
|
end
|
||||||
|
|
||||||
|
def query
|
||||||
|
Query.new(key_prefix, klass)
|
||||||
|
end
|
||||||
|
|
||||||
|
def refresh(at_time = Time.now.utc)
|
||||||
|
statuses = Status.where(id: (recently_used_ids(at_time) + currently_trending_ids(false, -1)).uniq).includes(:account, :media_attachments)
|
||||||
|
calculate_scores(statuses, at_time)
|
||||||
|
trim_older_items
|
||||||
|
end
|
||||||
|
|
||||||
|
def request_review
|
||||||
|
statuses = Status.where(id: currently_trending_ids(false, -1)).includes(:account)
|
||||||
|
|
||||||
|
statuses.filter_map do |status|
|
||||||
|
next unless would_be_trending?(status.id) && !status.trendable? && status.requires_review_notification?
|
||||||
|
|
||||||
|
status.account.touch(:requested_review_at)
|
||||||
|
status
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
protected
|
||||||
|
|
||||||
|
def key_prefix
|
||||||
|
PREFIX
|
||||||
|
end
|
||||||
|
|
||||||
|
def klass
|
||||||
|
Status
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def eligible?(status)
|
||||||
|
original_status = status.proper
|
||||||
|
|
||||||
|
original_status.public_visibility? &&
|
||||||
|
original_status.account.discoverable? && !original_status.account.silenced? &&
|
||||||
|
(original_status.spoiler_text.blank? || Setting.trending_status_cw) && !original_status.sensitive? && !original_status.reply?
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate_scores(statuses, at_time)
|
||||||
|
redis.pipelined do
|
||||||
|
statuses.each do |status|
|
||||||
|
expected = 1.0
|
||||||
|
observed = (status.reblogs_count + status.favourites_count).to_f
|
||||||
|
|
||||||
|
score = begin
|
||||||
|
if expected > observed || observed < options[:threshold]
|
||||||
|
0
|
||||||
|
else
|
||||||
|
((observed - expected)**2) / expected
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
decaying_score = score * (0.5**((at_time.to_f - status.created_at.to_f) / options[:score_halflife].to_f))
|
||||||
|
|
||||||
|
add_to_and_remove_from_subsets(status.id, decaying_score, {
|
||||||
|
all: true,
|
||||||
|
allowed: status.trendable? && status.account.discoverable?,
|
||||||
|
})
|
||||||
|
|
||||||
|
next unless valid_locale?(status.language)
|
||||||
|
|
||||||
|
add_to_and_remove_from_subsets(status.id, decaying_score, {
|
||||||
|
"all:#{status.language}" => true,
|
||||||
|
"allowed:#{status.language}" => status.trendable? && status.account.discoverable?,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
# Clean up localized sets by calculating the intersection with the main
|
||||||
|
# set. We do this instead of just deleting the localized sets to avoid
|
||||||
|
# having moments where the API returns empty results
|
||||||
|
|
||||||
|
Trends.available_locales.each do |locale|
|
||||||
|
redis.zinterstore("#{key_prefix}:all:#{locale}", ["#{key_prefix}:all:#{locale}", "#{key_prefix}:all"], aggregate: 'max')
|
||||||
|
redis.zinterstore("#{key_prefix}:allowed:#{locale}", ["#{key_prefix}:allowed:#{locale}", "#{key_prefix}:all"], aggregate: 'max')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def would_be_trending?(id)
|
||||||
|
score(id) > score_at_rank(options[:review_threshold] - 1)
|
||||||
|
end
|
||||||
|
end
|
@ -1,15 +1,14 @@
|
|||||||
%tr
|
.batch-table__row
|
||||||
%td
|
%label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
|
||||||
%samp= email_domain_block.domain
|
= f.check_box :email_domain_block_ids, { multiple: true, include_hidden: false }, email_domain_block.id
|
||||||
%td
|
.batch-table__row__content.pending-account
|
||||||
= table_link_to 'trash', t('admin.email_domain_blocks.delete'), admin_email_domain_block_path(email_domain_block), method: :delete
|
.pending-account__header
|
||||||
|
%samp= link_to email_domain_block.domain, admin_accounts_path(email: "%@#{email_domain_block.domain}")
|
||||||
|
|
||||||
- email_domain_block.children.each do |child_email_domain_block|
|
%br/
|
||||||
%tr
|
|
||||||
%td
|
- if email_domain_block.parent.present?
|
||||||
%samp= child_email_domain_block.domain
|
= t('admin.email_domain_blocks.resolved_through_html', domain: content_tag(:samp, email_domain_block.parent.domain))
|
||||||
%span.muted-hint
|
•
|
||||||
= surround '(', ')' do
|
|
||||||
= t('admin.email_domain_blocks.from_html', domain: content_tag(:samp, email_domain_block.domain))
|
= t('admin.email_domain_blocks.attempts_over_week', count: email_domain_block.history.reduce(0) { |sum, day| sum + day.accounts })
|
||||||
%td
|
|
||||||
= table_link_to 'trash', t('admin.email_domain_blocks.delete'), admin_email_domain_block_path(child_email_domain_block), method: :delete
|
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue