forked from berserker/microblog
Merge branch 'main' of https://github.com/glitch-soc/mastodon into main
commit
7f3114e4cb
147 changed files with 2443 additions and 650 deletions
@ -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,65 @@ |
|||||||
|
# frozen_string_literal: true |
||||||
|
|
||||||
|
class ActivityPub::Forwarder |
||||||
|
def initialize(account, original_json, status) |
||||||
|
@json = original_json |
||||||
|
@account = account |
||||||
|
@status = status |
||||||
|
end |
||||||
|
|
||||||
|
def forwardable? |
||||||
|
@json['signature'].present? && @status.distributable? |
||||||
|
end |
||||||
|
|
||||||
|
def forward! |
||||||
|
ActivityPub::LowPriorityDeliveryWorker.push_bulk(inboxes) do |inbox_url| |
||||||
|
[payload, signature_account_id, inbox_url] |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
private |
||||||
|
|
||||||
|
def payload |
||||||
|
@payload ||= Oj.dump(@json) |
||||||
|
end |
||||||
|
|
||||||
|
def reblogged_by_account_ids |
||||||
|
@reblogged_by_account_ids ||= @status.reblogs.includes(:account).references(:account).merge(Account.local).pluck(:account_id) |
||||||
|
end |
||||||
|
|
||||||
|
def signature_account_id |
||||||
|
@signature_account_id ||= begin |
||||||
|
if in_reply_to_local? |
||||||
|
in_reply_to.account_id |
||||||
|
else |
||||||
|
reblogged_by_account_ids.first |
||||||
|
end |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
def inboxes |
||||||
|
@inboxes ||= begin |
||||||
|
arr = inboxes_for_followers_of_reblogged_by_accounts |
||||||
|
arr += inboxes_for_followers_of_replied_to_account if in_reply_to_local? |
||||||
|
arr -= [@account.preferred_inbox_url] |
||||||
|
arr.uniq! |
||||||
|
arr |
||||||
|
end |
||||||
|
end |
||||||
|
|
||||||
|
def inboxes_for_followers_of_reblogged_by_accounts |
||||||
|
Account.where(id: ::Follow.where(target_account_id: reblogged_by_account_ids).select(:account_id)).inboxes |
||||||
|
end |
||||||
|
|
||||||
|
def inboxes_for_followers_of_replied_to_account |
||||||
|
in_reply_to.account.followers.inboxes |
||||||
|
end |
||||||
|
|
||||||
|
def in_reply_to |
||||||
|
@status.thread |
||||||
|
end |
||||||
|
|
||||||
|
def in_reply_to_local? |
||||||
|
@status.thread&.account&.local? |
||||||
|
end |
||||||
|
end |
@ -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 |
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue