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 { fetchTrends } from 'mastodon/actions/trends'; |
||||
import { fetchTrendingHashtags } from 'mastodon/actions/trends'; |
||||
import Trends from '../components/trends'; |
||||
|
||||
const mapStateToProps = state => ({ |
||||
trends: state.getIn(['trends', 'items']), |
||||
trends: state.getIn(['trends', 'tags', 'items']), |
||||
}); |
||||
|
||||
const mapDispatchToProps = dispatch => ({ |
||||
fetchTrends: () => dispatch(fetchTrends()), |
||||
fetchTrends: () => dispatch(fetchTrendingHashtags()), |
||||
}); |
||||
|
||||
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 |
||||
|
||||
class PreviewCardProviderFilter |
||||
class Trends::PreviewCardProviderFilter |
||||
KEYS = %i( |
||||
status |
||||
).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