Add trending statuses (#17431)
* Add trending statuses * Fix dangling items with stale scores in localized sets * Various fixes and improvements - Change approve_all/reject_all to approve_accounts/reject_accounts - Change Trends::Query methods to not mutate the original query - Change Trends::Query#skip to offset - Change follow recommendations to be refreshed in a transaction * Add tests for trending statuses filtering behaviour * Fix not applying filtering scope in controllermain
parent
a29a982eaa
commit
27965ce5ed
@ -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
|
@ -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? && !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
|
@ -0,0 +1,30 @@
|
|||||||
|
.batch-table__row{ class: [status.account.requires_review? && 'batch-table__row--attention', !status.account.requires_review? && !status.trendable? && 'batch-table__row--muted'] }
|
||||||
|
%label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
|
||||||
|
= f.check_box :status_ids, { multiple: true, include_hidden: false }, status.id
|
||||||
|
|
||||||
|
.batch-table__row__content.pending-account__header
|
||||||
|
.one-liner
|
||||||
|
= admin_account_link_to status.account
|
||||||
|
|
||||||
|
= link_to ActivityPub::TagManager.instance.url_for(status), target: '_blank', class: 'emojify', rel: 'noopener noreferrer' do
|
||||||
|
= one_line_preview(status)
|
||||||
|
|
||||||
|
- status.media_attachments.each do |media_attachment|
|
||||||
|
%abbr{ title: media_attachment.description }
|
||||||
|
= fa_icon 'link'
|
||||||
|
= media_attachment.file_file_name
|
||||||
|
|
||||||
|
= t('admin.trends.statuses.shared_by', count: status.reblogs_count + status.favourites_count, friendly_count: friendly_number_to_human(status.reblogs_count + status.favourites_count))
|
||||||
|
|
||||||
|
- if status.account.domain.present?
|
||||||
|
•
|
||||||
|
= status.account.domain
|
||||||
|
- if status.language.present?
|
||||||
|
•
|
||||||
|
= standard_locale_name(status.language)
|
||||||
|
- if status.trendable? && (rank = Trends.statuses.rank(status.id))
|
||||||
|
•
|
||||||
|
%abbr{ title: t('admin.trends.tags.current_score', score: Trends.statuses.score(status.id)) }= t('admin.trends.tags.trending_rank', rank: rank + 1)
|
||||||
|
- elsif status.account.requires_review?
|
||||||
|
•
|
||||||
|
= t('admin.trends.pending_review')
|
@ -0,0 +1,43 @@
|
|||||||
|
- content_for :page_title do
|
||||||
|
= t('admin.trends.statuses.title')
|
||||||
|
|
||||||
|
- content_for :header_tags do
|
||||||
|
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
|
||||||
|
|
||||||
|
= form_tag admin_trends_statuses_path, method: 'GET', class: 'simple_form' do
|
||||||
|
- Trends::StatusFilter::KEYS.each do |key|
|
||||||
|
= hidden_field_tag key, params[key] if params[key].present?
|
||||||
|
|
||||||
|
.filters
|
||||||
|
.filter-subset.filter-subset--with-select
|
||||||
|
%strong= t('admin.follow_recommendations.language')
|
||||||
|
.input.select.optional
|
||||||
|
= select_tag :locale, options_for_select(Trends.available_locales.map { |key| [standard_locale_name(key), key]}, params[:locale]), include_blank: true
|
||||||
|
.filter-subset
|
||||||
|
%strong= t('admin.trends.trending')
|
||||||
|
%ul
|
||||||
|
%li= filter_link_to t('generic.all'), trending: nil
|
||||||
|
%li= filter_link_to t('admin.trends.only_allowed'), trending: 'allowed'
|
||||||
|
|
||||||
|
= form_for(@form, url: batch_admin_trends_statuses_path) do |f|
|
||||||
|
= hidden_field_tag :page, params[:page] || 1
|
||||||
|
|
||||||
|
- Trends::StatusFilter::KEYS.each do |key|
|
||||||
|
= hidden_field_tag key, params[key] if params[key].present?
|
||||||
|
|
||||||
|
.batch-table
|
||||||
|
.batch-table__toolbar
|
||||||
|
%label.batch-table__toolbar__select.batch-checkbox-all
|
||||||
|
= check_box_tag :batch_checkbox_all, nil, false
|
||||||
|
.batch-table__toolbar__actions
|
||||||
|
= f.button safe_join([fa_icon('check'), t('admin.trends.statuses.allow')]), name: :approve, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
|
||||||
|
= f.button safe_join([fa_icon('check'), t('admin.trends.statuses.allow_account')]), name: :approve_accounts, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
|
||||||
|
= f.button safe_join([fa_icon('times'), t('admin.trends.statuses.disallow')]), name: :reject, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
|
||||||
|
= f.button safe_join([fa_icon('times'), t('admin.trends.statuses.disallow_account')]), name: :reject_accounts, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
|
||||||
|
.batch-table__body
|
||||||
|
- if @statuses.empty?
|
||||||
|
= nothing_here 'nothing-here--under-tabs'
|
||||||
|
- else
|
||||||
|
= render partial: 'status', collection: @statuses, locals: { f: f }
|
||||||
|
|
||||||
|
= paginate @statuses
|
@ -0,0 +1,14 @@
|
|||||||
|
<%= raw t('admin_mailer.new_trends.new_trending_links.title') %>
|
||||||
|
|
||||||
|
<% @links.each do |link| %>
|
||||||
|
- <%= link.title %> • <%= link.url %>
|
||||||
|
<%= raw t('admin.trends.links.usage_comparison', today: link.history.get(Time.now.utc).accounts, yesterday: link.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: Trends.links.score(link.id).round(2)) %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @lowest_trending_link %>
|
||||||
|
<%= raw t('admin_mailer.new_trends.new_trending_links.requirements', lowest_link_title: @lowest_trending_link.title, lowest_link_score: Trends.links.score(@lowest_trending_link.id).round(2), rank: Trends.links.options[:review_threshold]) %>
|
||||||
|
<% else %>
|
||||||
|
<%= raw t('admin_mailer.new_trends.new_trending_links.no_approved_links') %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= raw t('application_mailer.view')%> <%= admin_trends_links_url %>
|
@ -0,0 +1,14 @@
|
|||||||
|
<%= raw t('admin_mailer.new_trends.new_trending_statuses.title') %>
|
||||||
|
|
||||||
|
<% @statuses.each do |status| %>
|
||||||
|
- <%= ActivityPub::TagManager.instance.url_for(status) %>
|
||||||
|
<%= raw t('admin.trends.tags.current_score', score: Trends.statuses.score(status.id).round(2)) %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @lowest_trending_status %>
|
||||||
|
<%= raw t('admin_mailer.new_trends.new_trending_statuses.requirements', lowest_status_url: ActivityPub::TagManager.instance.url_for(@lowest_trending_status), lowest_status_score: Trends.statuses.score(@lowest_trending_status.id).round(2), rank: Trends.statuses.options[:review_threshold]) %>
|
||||||
|
<% else %>
|
||||||
|
<%= raw t('admin_mailer.new_trends.new_trending_statuses.no_approved_statuses') %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= raw t('application_mailer.view')%> <%= admin_trends_statuses_url %>
|
@ -0,0 +1,14 @@
|
|||||||
|
<%= raw t('admin_mailer.new_trends.new_trending_tags.title') %>
|
||||||
|
|
||||||
|
<% @tags.each do |tag| %>
|
||||||
|
- #<%= tag.name %>
|
||||||
|
<%= raw t('admin.trends.tags.usage_comparison', today: tag.history.get(Time.now.utc).accounts, yesterday: tag.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id).round(2)) %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @lowest_trending_tag %>
|
||||||
|
<%= raw t('admin_mailer.new_trends.new_trending_tags.requirements', lowest_tag_name: @lowest_trending_tag.name, lowest_tag_score: Trends.tags.score(@lowest_trending_tag.id).round(2), rank: Trends.tags.options[:review_threshold]) %>
|
||||||
|
<% else %>
|
||||||
|
<%= raw t('admin_mailer.new_trends.new_trending_tags.no_approved_tags') %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= raw t('application_mailer.view')%> <%= admin_trends_tags_url(pending_review: '1') %>
|
@ -1,16 +0,0 @@
|
|||||||
<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
|
|
||||||
|
|
||||||
<%= raw t('admin_mailer.new_trending_links.body') %>
|
|
||||||
|
|
||||||
<% @links.each do |link| %>
|
|
||||||
- <%= link.title %> • <%= link.url %>
|
|
||||||
<%= t('admin.trends.links.usage_comparison', today: link.history.get(Time.now.utc).accounts, yesterday: link.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: Trends.links.score(link.id).round(2)) %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<% if @lowest_trending_link %>
|
|
||||||
<%= t('admin_mailer.new_trending_links.requirements', lowest_link_title: @lowest_trending_link.title, lowest_link_score: Trends.links.score(@lowest_trending_link.id).round(2)) %>
|
|
||||||
<% else %>
|
|
||||||
<%= t('admin_mailer.new_trending_links.no_approved_links') %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= raw t('application_mailer.view')%> <%= admin_trends_links_url %>
|
|
@ -1,16 +0,0 @@
|
|||||||
<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
|
|
||||||
|
|
||||||
<%= raw t('admin_mailer.new_trending_tags.body') %>
|
|
||||||
|
|
||||||
<% @tags.each do |tag| %>
|
|
||||||
- #<%= tag.name %>
|
|
||||||
<%= t('admin.trends.tags.usage_comparison', today: tag.history.get(Time.now.utc).accounts, yesterday: tag.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id).round(2)) %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<% if @lowest_trending_tag %>
|
|
||||||
<%= t('admin_mailer.new_trending_tags.requirements', lowest_tag_name: @lowest_trending_tag.name, lowest_tag_score: Trends.tags.score(@lowest_trending_tag.id).round(2)) %>
|
|
||||||
<% else %>
|
|
||||||
<%= t('admin_mailer.new_trending_tags.no_approved_tags') %>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= raw t('application_mailer.view')%> <%= admin_trends_tags_url(status: 'pending_review') %>
|
|
@ -0,0 +1,13 @@
|
|||||||
|
<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
|
||||||
|
|
||||||
|
<%= raw t('admin_mailer.new_trends.body') %>
|
||||||
|
|
||||||
|
<% unless @links.empty? %>
|
||||||
|
<%= render 'new_trending_links' %>
|
||||||
|
<% end %>
|
||||||
|
<% unless @tags.empty? %>
|
||||||
|
<%= render 'new_trending_tags' unless @tags.empty? %>
|
||||||
|
<% end %>
|
||||||
|
<% unless @statuses.empty? %>
|
||||||
|
<%= render 'new_trending_statuses' unless @statuses.empty? %>
|
||||||
|
<% end %>
|
@ -0,0 +1,7 @@
|
|||||||
|
class AddTrendableToAccounts < ActiveRecord::Migration[6.1]
|
||||||
|
def change
|
||||||
|
add_column :accounts, :trendable, :boolean
|
||||||
|
add_column :accounts, :reviewed_at, :datetime
|
||||||
|
add_column :accounts, :requested_review_at, :datetime
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,5 @@
|
|||||||
|
class AddTrendableToStatuses < ActiveRecord::Migration[6.1]
|
||||||
|
def change
|
||||||
|
add_column :statuses, :trendable, :boolean
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,9 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class RemoveTrustLevelFromAccounts < ActiveRecord::Migration[5.2]
|
||||||
|
disable_ddl_transaction!
|
||||||
|
|
||||||
|
def change
|
||||||
|
safety_assured { remove_column :accounts, :trust_level, :integer }
|
||||||
|
end
|
||||||
|
end
|
@ -0,0 +1,110 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Trends::Statuses do
|
||||||
|
subject! { described_class.new(threshold: 5, review_threshold: 10, score_halflife: 8.hours) }
|
||||||
|
|
||||||
|
let!(:at_time) { DateTime.new(2021, 11, 14, 10, 15, 0) }
|
||||||
|
|
||||||
|
describe 'Trends::Statuses::Query' do
|
||||||
|
let!(:query) { subject.query }
|
||||||
|
let!(:today) { at_time }
|
||||||
|
|
||||||
|
let!(:status1) { Fabricate(:status, text: 'Foo', trendable: true, created_at: today) }
|
||||||
|
let!(:status2) { Fabricate(:status, text: 'Bar', trendable: true, created_at: today) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
15.times { reblog(status1, today) }
|
||||||
|
12.times { reblog(status2, today) }
|
||||||
|
|
||||||
|
subject.refresh(today)
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#filtered_for' do
|
||||||
|
let(:account) { Fabricate(:account) }
|
||||||
|
|
||||||
|
it 'returns a composable query scope' do
|
||||||
|
expect(query.filtered_for(account)).to be_a Trends::Query
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'filters out blocked accounts' do
|
||||||
|
account.block!(status1.account)
|
||||||
|
expect(query.filtered_for(account).to_a).to eq [status2]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'filters out muted accounts' do
|
||||||
|
account.mute!(status2.account)
|
||||||
|
expect(query.filtered_for(account).to_a).to eq [status1]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'filters out blocked-by accounts' do
|
||||||
|
status1.account.block!(account)
|
||||||
|
expect(query.filtered_for(account).to_a).to eq [status2]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#add' do
|
||||||
|
let(:status) { Fabricate(:status) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
subject.add(status, 1, at_time)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'records use' do
|
||||||
|
expect(subject.send(:recently_used_ids, at_time)).to eq [status.id]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#query' do
|
||||||
|
it 'returns a composable query scope' do
|
||||||
|
expect(subject.query).to be_a Trends::Query
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'responds to filtered_for' do
|
||||||
|
expect(subject.query).to respond_to(:filtered_for)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#refresh' do
|
||||||
|
let!(:today) { at_time }
|
||||||
|
let!(:yesterday) { today - 1.day }
|
||||||
|
|
||||||
|
let!(:status1) { Fabricate(:status, text: 'Foo', trendable: true, created_at: yesterday) }
|
||||||
|
let!(:status2) { Fabricate(:status, text: 'Bar', trendable: true, created_at: today) }
|
||||||
|
let!(:status3) { Fabricate(:status, text: 'Baz', trendable: true, created_at: today) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
13.times { reblog(status1, today) }
|
||||||
|
13.times { reblog(status2, today) }
|
||||||
|
4.times { reblog(status3, today) }
|
||||||
|
end
|
||||||
|
|
||||||
|
context do
|
||||||
|
before do
|
||||||
|
subject.refresh(today)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'calculates and re-calculates scores' do
|
||||||
|
expect(subject.query.limit(10).to_a).to eq [status2, status1]
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'omits statuses below threshold' do
|
||||||
|
expect(subject.query.limit(10).to_a).to_not include(status3)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'decays scores' do
|
||||||
|
subject.refresh(today)
|
||||||
|
original_score = subject.score(status2.id)
|
||||||
|
expect(original_score).to be_a Float
|
||||||
|
subject.refresh(today + subject.options[:score_halflife])
|
||||||
|
decayed_score = subject.score(status2.id)
|
||||||
|
expect(decayed_score).to be <= original_score / 2
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def reblog(status, at_time)
|
||||||
|
reblog = Fabricate(:status, reblog: status, created_at: at_time)
|
||||||
|
subject.add(status, reblog.account_id, at_time)
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in new issue