Add support for language preferences for trending statuses and links (#18288)
parent
678fc4d292
commit
45ebdb72ca
|
@ -4,6 +4,7 @@ class Admin::Trends::LinksController < Admin::BaseController
|
||||||
def index
|
def index
|
||||||
authorize :preview_card, :review?
|
authorize :preview_card, :review?
|
||||||
|
|
||||||
|
@locales = PreviewCardTrend.pluck('distinct language')
|
||||||
@preview_cards = filtered_preview_cards.page(params[:page])
|
@preview_cards = filtered_preview_cards.page(params[:page])
|
||||||
@form = Trends::PreviewCardBatch.new
|
@form = Trends::PreviewCardBatch.new
|
||||||
end
|
end
|
||||||
|
|
|
@ -4,6 +4,7 @@ class Admin::Trends::StatusesController < Admin::BaseController
|
||||||
def index
|
def index
|
||||||
authorize :status, :review?
|
authorize :status, :review?
|
||||||
|
|
||||||
|
@locales = StatusTrend.pluck('distinct language')
|
||||||
@statuses = filtered_statuses.page(params[:page])
|
@statuses = filtered_statuses.page(params[:page])
|
||||||
@form = Trends::StatusBatch.new
|
@form = Trends::StatusBatch.new
|
||||||
end
|
end
|
||||||
|
|
|
@ -28,7 +28,9 @@ class Api::V1::Trends::LinksController < Api::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def links_from_trends
|
def links_from_trends
|
||||||
Trends.links.query.allowed.in_locale(content_locale)
|
scope = Trends.links.query.allowed.in_locale(content_locale)
|
||||||
|
scope = scope.filtered_for(current_account) if user_signed_in?
|
||||||
|
scope
|
||||||
end
|
end
|
||||||
|
|
||||||
def insert_pagination_headers
|
def insert_pagination_headers
|
||||||
|
|
|
@ -4,6 +4,7 @@ class AdminMailer < ApplicationMailer
|
||||||
layout 'plain_mailer'
|
layout 'plain_mailer'
|
||||||
|
|
||||||
helper :accounts
|
helper :accounts
|
||||||
|
helper :languages
|
||||||
|
|
||||||
def new_report(recipient, report)
|
def new_report(recipient, report)
|
||||||
@report = report
|
@report = report
|
||||||
|
@ -37,11 +38,8 @@ class AdminMailer < ApplicationMailer
|
||||||
|
|
||||||
def new_trends(recipient, links, tags, statuses)
|
def new_trends(recipient, links, tags, statuses)
|
||||||
@links = links
|
@links = links
|
||||||
@lowest_trending_link = Trends.links.query.allowed.limit(Trends.links.options[:review_threshold]).last
|
|
||||||
@tags = tags
|
@tags = tags
|
||||||
@lowest_trending_tag = Trends.tags.query.allowed.limit(Trends.tags.options[:review_threshold]).last
|
|
||||||
@statuses = statuses
|
@statuses = statuses
|
||||||
@lowest_trending_status = Trends.statuses.query.allowed.limit(Trends.statuses.options[:review_threshold]).last
|
|
||||||
@me = recipient
|
@me = recipient
|
||||||
@instance = Rails.configuration.x.local_domain
|
@instance = Rails.configuration.x.local_domain
|
||||||
|
|
||||||
|
|
|
@ -48,6 +48,7 @@ class PreviewCard < ApplicationRecord
|
||||||
enum link_type: [:unknown, :article]
|
enum link_type: [:unknown, :article]
|
||||||
|
|
||||||
has_and_belongs_to_many :statuses
|
has_and_belongs_to_many :statuses
|
||||||
|
has_one :trend, class_name: 'PreviewCardTrend', inverse_of: :preview_card, dependent: :destroy
|
||||||
|
|
||||||
has_attached_file :image, processors: [:thumbnail, :blurhash_transcoder], styles: ->(f) { image_styles(f) }, convert_options: { all: '-quality 80 -strip' }, validate_media_type: false
|
has_attached_file :image, processors: [:thumbnail, :blurhash_transcoder], styles: ->(f) { image_styles(f) }, convert_options: { all: '-quality 80 -strip' }, validate_media_type: false
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: preview_card_trends
|
||||||
|
#
|
||||||
|
# id :bigint(8) not null, primary key
|
||||||
|
# preview_card_id :bigint(8) not null
|
||||||
|
# score :float default(0.0), not null
|
||||||
|
# rank :integer default(0), not null
|
||||||
|
# allowed :boolean default(FALSE), not null
|
||||||
|
# language :string
|
||||||
|
#
|
||||||
|
class PreviewCardTrend < ApplicationRecord
|
||||||
|
belongs_to :preview_card
|
||||||
|
scope :allowed, -> { where(allowed: true) }
|
||||||
|
end
|
|
@ -75,6 +75,7 @@ class Status < ApplicationRecord
|
||||||
has_one :notification, as: :activity, dependent: :destroy
|
has_one :notification, as: :activity, dependent: :destroy
|
||||||
has_one :status_stat, inverse_of: :status
|
has_one :status_stat, inverse_of: :status
|
||||||
has_one :poll, inverse_of: :status, dependent: :destroy
|
has_one :poll, inverse_of: :status, dependent: :destroy
|
||||||
|
has_one :trend, class_name: 'StatusTrend', inverse_of: :status
|
||||||
|
|
||||||
validates :uri, uniqueness: true, presence: true, unless: :local?
|
validates :uri, uniqueness: true, presence: true, unless: :local?
|
||||||
validates :text, presence: true, unless: -> { with_media? || reblog? }
|
validates :text, presence: true, unless: -> { with_media? || reblog? }
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: status_trends
|
||||||
|
#
|
||||||
|
# id :bigint(8) not null, primary key
|
||||||
|
# status_id :bigint(8) not null
|
||||||
|
# account_id :bigint(8) not null
|
||||||
|
# score :float default(0.0), not null
|
||||||
|
# rank :integer default(0), not null
|
||||||
|
# allowed :boolean default(FALSE), not null
|
||||||
|
# language :string
|
||||||
|
#
|
||||||
|
|
||||||
|
class StatusTrend < ApplicationRecord
|
||||||
|
belongs_to :status
|
||||||
|
belongs_to :account
|
||||||
|
|
||||||
|
scope :allowed, -> { where(allowed: true) }
|
||||||
|
end
|
|
@ -26,7 +26,7 @@ module Trends
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.request_review!
|
def self.request_review!
|
||||||
return unless enabled?
|
return if skip_review? || !enabled?
|
||||||
|
|
||||||
links_requiring_review = links.request_review
|
links_requiring_review = links.request_review
|
||||||
tags_requiring_review = tags.request_review
|
tags_requiring_review = tags.request_review
|
||||||
|
@ -43,6 +43,10 @@ module Trends
|
||||||
Setting.trends
|
Setting.trends
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def skip_review?
|
||||||
|
Setting.trendable_by_default
|
||||||
|
end
|
||||||
|
|
||||||
def self.available_locales
|
def self.available_locales
|
||||||
@available_locales ||= I18n.available_locales.map { |locale| locale.to_s.split(/[_-]/).first }.uniq
|
@available_locales ||= I18n.available_locales.map { |locale| locale.to_s.split(/[_-]/).first }.uniq
|
||||||
end
|
end
|
||||||
|
|
|
@ -98,4 +98,8 @@ class Trends::Base
|
||||||
pipeline.rename(from_key, to_key)
|
pipeline.rename(from_key, to_key)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def skip_review?
|
||||||
|
Setting.trendable_by_default
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -11,6 +11,40 @@ class Trends::Links < Trends::Base
|
||||||
decay_threshold: 1,
|
decay_threshold: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class Query < Trends::Query
|
||||||
|
def filtered_for!(account)
|
||||||
|
@account = account
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
|
def filtered_for(account)
|
||||||
|
clone.filtered_for!(account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_arel
|
||||||
|
scope = PreviewCard.joins(:trend).reorder(score: :desc)
|
||||||
|
scope = scope.reorder(language_order_clause.desc, score: :desc) if preferred_languages.present?
|
||||||
|
scope = scope.merge(PreviewCardTrend.allowed) if @allowed
|
||||||
|
scope = scope.offset(@offset) if @offset.present?
|
||||||
|
scope = scope.limit(@limit) if @limit.present?
|
||||||
|
scope
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def language_order_clause
|
||||||
|
Arel::Nodes::Case.new.when(PreviewCardTrend.arel_table[:language].in(preferred_languages)).then(1).else(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
def preferred_languages
|
||||||
|
if @account&.chosen_languages.present?
|
||||||
|
@account.chosen_languages
|
||||||
|
else
|
||||||
|
@locale
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def register(status, at_time = Time.now.utc)
|
def register(status, at_time = Time.now.utc)
|
||||||
original_status = status.proper
|
original_status = status.proper
|
||||||
|
|
||||||
|
@ -28,16 +62,24 @@ class Trends::Links < Trends::Base
|
||||||
record_used_id(preview_card.id, at_time)
|
record_used_id(preview_card.id, at_time)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def query
|
||||||
|
Query.new(key_prefix, klass)
|
||||||
|
end
|
||||||
|
|
||||||
def refresh(at_time = Time.now.utc)
|
def refresh(at_time = Time.now.utc)
|
||||||
preview_cards = PreviewCard.where(id: (recently_used_ids(at_time) + currently_trending_ids(false, -1)).uniq)
|
preview_cards = PreviewCard.where(id: (recently_used_ids(at_time) + PreviewCardTrend.pluck(:preview_card_id)).uniq)
|
||||||
calculate_scores(preview_cards, at_time)
|
calculate_scores(preview_cards, at_time)
|
||||||
end
|
end
|
||||||
|
|
||||||
def request_review
|
def request_review
|
||||||
preview_cards = PreviewCard.where(id: currently_trending_ids(false, -1))
|
PreviewCardTrend.pluck('distinct language').flat_map do |language|
|
||||||
|
score_at_threshold = PreviewCardTrend.where(language: language, allowed: true).order(rank: :desc).where('rank <= ?', options[:review_threshold]).first&.score || 0
|
||||||
|
preview_card_trends = PreviewCardTrend.where(language: language, allowed: false).joins(:preview_card)
|
||||||
|
|
||||||
preview_cards.filter_map do |preview_card|
|
preview_card_trends.filter_map do |trend|
|
||||||
next unless would_be_trending?(preview_card.id) && !preview_card.trendable? && preview_card.requires_review_notification?
|
preview_card = trend.preview_card
|
||||||
|
|
||||||
|
next unless trend.score > score_at_threshold && !preview_card.trendable? && preview_card.requires_review_notification?
|
||||||
|
|
||||||
if preview_card.provider.nil?
|
if preview_card.provider.nil?
|
||||||
preview_card.provider = PreviewCardProvider.create(domain: preview_card.domain, requested_review_at: Time.now.utc)
|
preview_card.provider = PreviewCardProvider.create(domain: preview_card.domain, requested_review_at: Time.now.utc)
|
||||||
|
@ -48,6 +90,7 @@ class Trends::Links < Trends::Base
|
||||||
preview_card
|
preview_card
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
protected
|
protected
|
||||||
|
|
||||||
|
@ -62,10 +105,7 @@ class Trends::Links < Trends::Base
|
||||||
private
|
private
|
||||||
|
|
||||||
def calculate_scores(preview_cards, at_time)
|
def calculate_scores(preview_cards, at_time)
|
||||||
global_items = []
|
items = preview_cards.map do |preview_card|
|
||||||
locale_items = Hash.new { |h, key| h[key] = [] }
|
|
||||||
|
|
||||||
preview_cards.each do |preview_card|
|
|
||||||
expected = preview_card.history.get(at_time - 1.day).accounts.to_f
|
expected = preview_card.history.get(at_time - 1.day).accounts.to_f
|
||||||
expected = 1.0 if expected.zero?
|
expected = 1.0 if expected.zero?
|
||||||
observed = preview_card.history.get(at_time).accounts.to_f
|
observed = preview_card.history.get(at_time).accounts.to_f
|
||||||
|
@ -89,26 +129,24 @@ class Trends::Links < Trends::Base
|
||||||
preview_card.update_columns(max_score: max_score, max_score_at: max_time)
|
preview_card.update_columns(max_score: max_score, max_score_at: max_time)
|
||||||
end
|
end
|
||||||
|
|
||||||
decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / options[:max_score_halflife].to_f))
|
decaying_score = begin
|
||||||
|
if max_score.zero? || !valid_locale?(preview_card.language)
|
||||||
next unless decaying_score >= options[:decay_threshold]
|
0
|
||||||
|
else
|
||||||
global_items << { score: decaying_score, item: preview_card }
|
max_score * (0.5**((at_time.to_f - max_time.to_f) / options[:max_score_halflife].to_f))
|
||||||
locale_items[preview_card.language] << { score: decaying_score, item: preview_card } if valid_locale?(preview_card.language)
|
|
||||||
end
|
|
||||||
|
|
||||||
replace_items('', global_items)
|
|
||||||
|
|
||||||
Trends.available_locales.each do |locale|
|
|
||||||
replace_items(":#{locale}", locale_items[locale])
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def filter_for_allowed_items(items)
|
[decaying_score, preview_card]
|
||||||
items.select { |item| item[:item].trendable? }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def would_be_trending?(id)
|
to_insert = items.filter { |(score, _)| score >= options[:decay_threshold] }
|
||||||
score(id) > score_at_rank(options[:review_threshold] - 1)
|
to_delete = items.filter { |(score, _)| score < options[:decay_threshold] }
|
||||||
|
|
||||||
|
PreviewCardTrend.transaction do
|
||||||
|
PreviewCardTrend.upsert_all(to_insert.map { |(score, preview_card)| { preview_card_id: preview_card.id, score: score, language: preview_card.language, allowed: preview_card.trendable? || false } }, unique_by: :preview_card_id) if to_insert.any?
|
||||||
|
PreviewCardTrend.where(preview_card_id: to_delete.map { |(_, preview_card)| preview_card.id }).delete_all if to_delete.any?
|
||||||
|
PreviewCardTrend.connection.exec_update('UPDATE preview_card_trends SET rank = t0.calculated_rank FROM (SELECT id, row_number() OVER w AS calculated_rank FROM preview_card_trends WINDOW w AS (PARTITION BY language ORDER BY score DESC)) t0 WHERE preview_card_trends.id = t0.id')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -13,10 +13,10 @@ class Trends::PreviewCardFilter
|
||||||
end
|
end
|
||||||
|
|
||||||
def results
|
def results
|
||||||
scope = PreviewCard.unscoped
|
scope = initial_scope
|
||||||
|
|
||||||
params.each do |key, value|
|
params.each do |key, value|
|
||||||
next if %w(page locale).include?(key.to_s)
|
next if %w(page).include?(key.to_s)
|
||||||
|
|
||||||
scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
|
scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
|
||||||
end
|
end
|
||||||
|
@ -26,21 +26,30 @@ class Trends::PreviewCardFilter
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def initial_scope
|
||||||
|
PreviewCard.select(PreviewCard.arel_table[Arel.star])
|
||||||
|
.joins(:trend)
|
||||||
|
.eager_load(:trend)
|
||||||
|
.reorder(score: :desc)
|
||||||
|
end
|
||||||
|
|
||||||
def scope_for(key, value)
|
def scope_for(key, value)
|
||||||
case key.to_s
|
case key.to_s
|
||||||
when 'trending'
|
when 'trending'
|
||||||
trending_scope(value)
|
trending_scope(value)
|
||||||
|
when 'locale'
|
||||||
|
PreviewCardTrend.where(language: value)
|
||||||
else
|
else
|
||||||
raise "Unknown filter: #{key}"
|
raise "Unknown filter: #{key}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def trending_scope(value)
|
def trending_scope(value)
|
||||||
scope = Trends.links.query
|
case value
|
||||||
|
when 'allowed'
|
||||||
scope = scope.in_locale(@params[:locale].to_s) if @params[:locale].present?
|
PreviewCardTrend.allowed
|
||||||
scope = scope.allowed if value == 'allowed'
|
else
|
||||||
|
PreviewCardTrend.all
|
||||||
scope.to_arel
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -13,10 +13,10 @@ class Trends::StatusFilter
|
||||||
end
|
end
|
||||||
|
|
||||||
def results
|
def results
|
||||||
scope = Status.unscoped.kept
|
scope = initial_scope
|
||||||
|
|
||||||
params.each do |key, value|
|
params.each do |key, value|
|
||||||
next if %w(page locale).include?(key.to_s)
|
next if %w(page).include?(key.to_s)
|
||||||
|
|
||||||
scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
|
scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
|
||||||
end
|
end
|
||||||
|
@ -26,21 +26,30 @@ class Trends::StatusFilter
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def initial_scope
|
||||||
|
Status.select(Status.arel_table[Arel.star])
|
||||||
|
.joins(:trend)
|
||||||
|
.eager_load(:trend)
|
||||||
|
.reorder(score: :desc)
|
||||||
|
end
|
||||||
|
|
||||||
def scope_for(key, value)
|
def scope_for(key, value)
|
||||||
case key.to_s
|
case key.to_s
|
||||||
when 'trending'
|
when 'trending'
|
||||||
trending_scope(value)
|
trending_scope(value)
|
||||||
|
when 'locale'
|
||||||
|
StatusTrend.where(language: value)
|
||||||
else
|
else
|
||||||
raise "Unknown filter: #{key}"
|
raise "Unknown filter: #{key}"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def trending_scope(value)
|
def trending_scope(value)
|
||||||
scope = Trends.statuses.query
|
case value
|
||||||
|
when 'allowed'
|
||||||
scope = scope.in_locale(@params[:locale].to_s) if @params[:locale].present?
|
StatusTrend.allowed
|
||||||
scope = scope.allowed if value == 'allowed'
|
else
|
||||||
|
StatusTrend.all
|
||||||
scope.to_arel
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -20,13 +20,27 @@ class Trends::Statuses < Trends::Base
|
||||||
clone.filtered_for!(account)
|
clone.filtered_for!(account)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def to_arel
|
||||||
|
scope = Status.joins(:trend).reorder(score: :desc)
|
||||||
|
scope = scope.reorder(language_order_clause.desc, score: :desc) if preferred_languages.present?
|
||||||
|
scope = scope.merge(StatusTrend.allowed) if @allowed
|
||||||
|
scope = scope.not_excluded_by_account(@account).not_domain_blocked_by_account(@account) if @account.present?
|
||||||
|
scope = scope.offset(@offset) if @offset.present?
|
||||||
|
scope = scope.limit(@limit) if @limit.present?
|
||||||
|
scope
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def apply_scopes(scope)
|
def language_order_clause
|
||||||
if @account.nil?
|
Arel::Nodes::Case.new.when(StatusTrend.arel_table[:language].in(preferred_languages)).then(1).else(0)
|
||||||
scope
|
end
|
||||||
|
|
||||||
|
def preferred_languages
|
||||||
|
if @account&.chosen_languages.present?
|
||||||
|
@account.chosen_languages
|
||||||
else
|
else
|
||||||
scope.not_excluded_by_account(@account).not_domain_blocked_by_account(@account)
|
@locale
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -36,9 +50,6 @@ class Trends::Statuses < Trends::Base
|
||||||
end
|
end
|
||||||
|
|
||||||
def add(status, _account_id, at_time = Time.now.utc)
|
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)
|
record_used_id(status.id, at_time)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -47,20 +58,25 @@ class Trends::Statuses < Trends::Base
|
||||||
end
|
end
|
||||||
|
|
||||||
def refresh(at_time = Time.now.utc)
|
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)
|
statuses = Status.where(id: (recently_used_ids(at_time) + StatusTrend.pluck(:status_id)).uniq).includes(:status_stat, :account)
|
||||||
calculate_scores(statuses, at_time)
|
calculate_scores(statuses, at_time)
|
||||||
end
|
end
|
||||||
|
|
||||||
def request_review
|
def request_review
|
||||||
statuses = Status.where(id: currently_trending_ids(false, -1)).includes(:account)
|
StatusTrend.pluck('distinct language').flat_map do |language|
|
||||||
|
score_at_threshold = StatusTrend.where(language: language, allowed: true).order(rank: :desc).where('rank <= ?', options[:review_threshold]).first&.score || 0
|
||||||
|
status_trends = StatusTrend.where(language: language, allowed: false).joins(:status).includes(status: :account)
|
||||||
|
|
||||||
statuses.filter_map do |status|
|
status_trends.filter_map do |trend|
|
||||||
next unless would_be_trending?(status.id) && !status.trendable? && status.requires_review_notification?
|
status = trend.status
|
||||||
|
|
||||||
|
if trend.score > score_at_threshold && !status.trendable? && status.requires_review_notification?
|
||||||
status.account.touch(:requested_review_at)
|
status.account.touch(:requested_review_at)
|
||||||
status
|
status
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
protected
|
protected
|
||||||
|
|
||||||
|
@ -75,14 +91,11 @@ class Trends::Statuses < Trends::Base
|
||||||
private
|
private
|
||||||
|
|
||||||
def eligible?(status)
|
def eligible?(status)
|
||||||
status.public_visibility? && status.account.discoverable? && !status.account.silenced? && status.spoiler_text.blank? && !status.sensitive? && !status.reply?
|
status.public_visibility? && status.account.discoverable? && !status.account.silenced? && status.spoiler_text.blank? && !status.sensitive? && !status.reply? && valid_locale?(status.language)
|
||||||
end
|
end
|
||||||
|
|
||||||
def calculate_scores(statuses, at_time)
|
def calculate_scores(statuses, at_time)
|
||||||
global_items = []
|
items = statuses.map do |status|
|
||||||
locale_items = Hash.new { |h, key| h[key] = [] }
|
|
||||||
|
|
||||||
statuses.each do |status|
|
|
||||||
expected = 1.0
|
expected = 1.0
|
||||||
observed = (status.reblogs_count + status.favourites_count).to_f
|
observed = (status.reblogs_count + status.favourites_count).to_f
|
||||||
|
|
||||||
|
@ -94,29 +107,24 @@ class Trends::Statuses < Trends::Base
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
decaying_score = score * (0.5**((at_time.to_f - status.created_at.to_f) / options[:score_halflife].to_f))
|
decaying_score = begin
|
||||||
|
if score.zero? || !eligible?(status)
|
||||||
next unless decaying_score >= options[:decay_threshold]
|
0
|
||||||
|
else
|
||||||
global_items << { score: decaying_score, item: status }
|
score * (0.5**((at_time.to_f - status.created_at.to_f) / options[:score_halflife].to_f))
|
||||||
locale_items[status.language] << { account_id: status.account_id, score: decaying_score, item: status } if valid_locale?(status.language)
|
|
||||||
end
|
|
||||||
|
|
||||||
replace_items('', global_items)
|
|
||||||
|
|
||||||
Trends.available_locales.each do |locale|
|
|
||||||
replace_items(":#{locale}", locale_items[locale])
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def filter_for_allowed_items(items)
|
[decaying_score, status]
|
||||||
# Show only one status per account, pick the one with the highest score
|
|
||||||
# that's also eligible to trend
|
|
||||||
|
|
||||||
items.group_by { |item| item[:account_id] }.values.filter_map { |account_items| account_items.select { |item| item[:item].trendable? && item[:item].account.discoverable? }.max_by { |item| item[:score] } }
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def would_be_trending?(id)
|
to_insert = items.filter { |(score, _)| score >= options[:decay_threshold] }
|
||||||
score(id) > score_at_rank(options[:review_threshold] - 1)
|
to_delete = items.filter { |(score, _)| score < options[:decay_threshold] }
|
||||||
|
|
||||||
|
StatusTrend.transaction do
|
||||||
|
StatusTrend.upsert_all(to_insert.map { |(score, status)| { status_id: status.id, account_id: status.account_id, score: score, language: status.language, allowed: status.trendable? || false } }, unique_by: :status_id) if to_insert.any?
|
||||||
|
StatusTrend.where(status_id: to_delete.map { |(_, status)| status.id }).delete_all if to_delete.any?
|
||||||
|
StatusTrend.connection.exec_update('UPDATE status_trends SET rank = t0.calculated_rank FROM (SELECT id, row_number() OVER w AS calculated_rank FROM status_trends WINDOW w AS (PARTITION BY language ORDER BY score DESC)) t0 WHERE status_trends.id = t0.id')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -18,9 +18,9 @@
|
||||||
|
|
||||||
= t('admin.trends.links.shared_by_over_week', count: preview_card.history.reduce(0) { |sum, day| sum + day.accounts })
|
= t('admin.trends.links.shared_by_over_week', count: preview_card.history.reduce(0) { |sum, day| sum + day.accounts })
|
||||||
|
|
||||||
- if preview_card.trendable? && (rank = Trends.links.rank(preview_card.id, locale: params[:locale].presence))
|
- if preview_card.trend.allowed?
|
||||||
•
|
•
|
||||||
%abbr{ title: t('admin.trends.tags.current_score', score: Trends.links.score(preview_card.id, locale: params[:locale].presence)) }= t('admin.trends.tags.trending_rank', rank: rank + 1)
|
%abbr{ title: t('admin.trends.tags.current_score', score: preview_card.trend.score) }= t('admin.trends.tags.trending_rank', rank: preview_card.trend.rank)
|
||||||
|
|
||||||
- if preview_card.decaying?
|
- if preview_card.decaying?
|
||||||
•
|
•
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
.filter-subset.filter-subset--with-select
|
.filter-subset.filter-subset--with-select
|
||||||
%strong= t('admin.follow_recommendations.language')
|
%strong= t('admin.follow_recommendations.language')
|
||||||
.input.select.optional
|
.input.select.optional
|
||||||
= select_tag :locale, options_for_select(Trends.available_locales.map { |key| [standard_locale_name(key), key] }, params[:locale]), include_blank: true
|
= select_tag :locale, options_for_select(@locales.map { |key| [standard_locale_name(key), key] }, params[:locale]), include_blank: true
|
||||||
.filter-subset
|
.filter-subset
|
||||||
%strong= t('admin.trends.trending')
|
%strong= t('admin.trends.trending')
|
||||||
%ul
|
%ul
|
||||||
|
|
|
@ -25,9 +25,9 @@
|
||||||
- if status.trendable? && !status.account.discoverable?
|
- if status.trendable? && !status.account.discoverable?
|
||||||
•
|
•
|
||||||
= t('admin.trends.statuses.not_discoverable')
|
= t('admin.trends.statuses.not_discoverable')
|
||||||
- if status.trendable? && (rank = Trends.statuses.rank(status.id, locale: params[:locale].presence))
|
- if status.trend.allowed?
|
||||||
•
|
•
|
||||||
%abbr{ title: t('admin.trends.tags.current_score', score: Trends.statuses.score(status.id, locale: params[:locale].presence)) }= t('admin.trends.tags.trending_rank', rank: rank + 1)
|
%abbr{ title: t('admin.trends.tags.current_score', score: status.trend.score) }= t('admin.trends.tags.trending_rank', rank: status.trend.rank)
|
||||||
- elsif status.requires_review?
|
- elsif status.requires_review?
|
||||||
•
|
•
|
||||||
= t('admin.trends.pending_review')
|
= t('admin.trends.pending_review')
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
.filter-subset.filter-subset--with-select
|
.filter-subset.filter-subset--with-select
|
||||||
%strong= t('admin.follow_recommendations.language')
|
%strong= t('admin.follow_recommendations.language')
|
||||||
.input.select.optional
|
.input.select.optional
|
||||||
= select_tag :locale, options_for_select(Trends.available_locales.map { |key| [standard_locale_name(key), key]}, params[:locale]), include_blank: true
|
= select_tag :locale, options_for_select(@locales.map { |key| [standard_locale_name(key), key] }, params[:locale]), include_blank: true
|
||||||
.filter-subset
|
.filter-subset
|
||||||
%strong= t('admin.trends.trending')
|
%strong= t('admin.trends.trending')
|
||||||
%ul
|
%ul
|
||||||
|
|
|
@ -2,13 +2,7 @@
|
||||||
|
|
||||||
<% @links.each do |link| %>
|
<% @links.each do |link| %>
|
||||||
- <%= link.title %> • <%= link.url %>
|
- <%= 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)) %>
|
<%= standard_locale_name(link.language) %> • <%= 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: link.trend.score.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 %>
|
<% end %>
|
||||||
|
|
||||||
<%= raw t('application_mailer.view')%> <%= admin_trends_links_url %>
|
<%= raw t('application_mailer.view')%> <%= admin_trends_links_url %>
|
||||||
|
|
|
@ -2,13 +2,7 @@
|
||||||
|
|
||||||
<% @statuses.each do |status| %>
|
<% @statuses.each do |status| %>
|
||||||
- <%= ActivityPub::TagManager.instance.url_for(status) %>
|
- <%= ActivityPub::TagManager.instance.url_for(status) %>
|
||||||
<%= raw t('admin.trends.tags.current_score', score: Trends.statuses.score(status.id).round(2)) %>
|
<%= standard_locale_name(status.language) %> • <%= raw t('admin.trends.tags.current_score', score: status.trend.score.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 %>
|
<% end %>
|
||||||
|
|
||||||
<%= raw t('application_mailer.view')%> <%= admin_trends_statuses_url %>
|
<%= raw t('application_mailer.view')%> <%= admin_trends_statuses_url %>
|
||||||
|
|
|
@ -939,12 +939,8 @@ en:
|
||||||
new_trends:
|
new_trends:
|
||||||
body: 'The following items need a review before they can be displayed publicly:'
|
body: 'The following items need a review before they can be displayed publicly:'
|
||||||
new_trending_links:
|
new_trending_links:
|
||||||
no_approved_links: There are currently no approved trending links.
|
|
||||||
requirements: 'Any of these candidates could surpass the #%{rank} approved trending link, which is currently "%{lowest_link_title}" with a score of %{lowest_link_score}.'
|
|
||||||
title: Trending links
|
title: Trending links
|
||||||
new_trending_statuses:
|
new_trending_statuses:
|
||||||
no_approved_statuses: There are currently no approved trending posts.
|
|
||||||
requirements: 'Any of these candidates could surpass the #%{rank} approved trending post, which is currently %{lowest_status_url} with a score of %{lowest_status_score}.'
|
|
||||||
title: Trending posts
|
title: Trending posts
|
||||||
new_trending_tags:
|
new_trending_tags:
|
||||||
no_approved_tags: There are currently no approved trending hashtags.
|
no_approved_tags: There are currently no approved trending hashtags.
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
class CreateStatusTrends < ActiveRecord::Migration[6.1]
|
||||||
|
def change
|
||||||
|
create_table :status_trends do |t|
|
||||||
|
t.references :status, null: false, foreign_key: { on_delete: :cascade }, index: { unique: true }
|
||||||
|
t.references :account, null: false, foreign_key: { on_delete: :cascade }
|
||||||
|
t.float :score, null: false, default: 0
|
||||||
|
t.integer :rank, null: false, default: 0
|
||||||
|
t.boolean :allowed, null: false, default: false
|
||||||
|
t.string :language
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,11 @@
|
||||||
|
class CreatePreviewCardTrends < ActiveRecord::Migration[6.1]
|
||||||
|
def change
|
||||||
|
create_table :preview_card_trends do |t|
|
||||||
|
t.references :preview_card, null: false, foreign_key: { on_delete: :cascade }, index: { unique: true }
|
||||||
|
t.float :score, null: false, default: 0
|
||||||
|
t.integer :rank, null: false, default: 0
|
||||||
|
t.boolean :allowed, null: false, default: false
|
||||||
|
t.string :language
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
25
db/schema.rb
25
db/schema.rb
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema.define(version: 2022_08_29_192658) do
|
ActiveRecord::Schema.define(version: 2022_10_06_061337) do
|
||||||
|
|
||||||
# These are extensions that must be enabled in order to support this database
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -735,6 +735,15 @@ ActiveRecord::Schema.define(version: 2022_08_29_192658) do
|
||||||
t.index ["domain"], name: "index_preview_card_providers_on_domain", unique: true
|
t.index ["domain"], name: "index_preview_card_providers_on_domain", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "preview_card_trends", force: :cascade do |t|
|
||||||
|
t.bigint "preview_card_id", null: false
|
||||||
|
t.float "score", default: 0.0, null: false
|
||||||
|
t.integer "rank", default: 0, null: false
|
||||||
|
t.boolean "allowed", default: false, null: false
|
||||||
|
t.string "language"
|
||||||
|
t.index ["preview_card_id"], name: "index_preview_card_trends_on_preview_card_id", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
create_table "preview_cards", force: :cascade do |t|
|
create_table "preview_cards", force: :cascade do |t|
|
||||||
t.string "url", default: "", null: false
|
t.string "url", default: "", null: false
|
||||||
t.string "title", default: "", null: false
|
t.string "title", default: "", null: false
|
||||||
|
@ -894,6 +903,17 @@ ActiveRecord::Schema.define(version: 2022_08_29_192658) do
|
||||||
t.index ["status_id"], name: "index_status_stats_on_status_id", unique: true
|
t.index ["status_id"], name: "index_status_stats_on_status_id", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "status_trends", force: :cascade do |t|
|
||||||
|
t.bigint "status_id", null: false
|
||||||
|
t.bigint "account_id", null: false
|
||||||
|
t.float "score", default: 0.0, null: false
|
||||||
|
t.integer "rank", default: 0, null: false
|
||||||
|
t.boolean "allowed", default: false, null: false
|
||||||
|
t.string "language"
|
||||||
|
t.index ["account_id"], name: "index_status_trends_on_account_id"
|
||||||
|
t.index ["status_id"], name: "index_status_trends_on_status_id", unique: true
|
||||||
|
end
|
||||||
|
|
||||||
create_table "statuses", id: :bigint, default: -> { "timestamp_id('statuses'::text)" }, force: :cascade do |t|
|
create_table "statuses", id: :bigint, default: -> { "timestamp_id('statuses'::text)" }, force: :cascade do |t|
|
||||||
t.string "uri"
|
t.string "uri"
|
||||||
t.text "text", default: "", null: false
|
t.text "text", default: "", null: false
|
||||||
|
@ -1171,6 +1191,7 @@ ActiveRecord::Schema.define(version: 2022_08_29_192658) do
|
||||||
add_foreign_key "poll_votes", "polls", on_delete: :cascade
|
add_foreign_key "poll_votes", "polls", on_delete: :cascade
|
||||||
add_foreign_key "polls", "accounts", on_delete: :cascade
|
add_foreign_key "polls", "accounts", on_delete: :cascade
|
||||||
add_foreign_key "polls", "statuses", on_delete: :cascade
|
add_foreign_key "polls", "statuses", on_delete: :cascade
|
||||||
|
add_foreign_key "preview_card_trends", "preview_cards", on_delete: :cascade
|
||||||
add_foreign_key "report_notes", "accounts", on_delete: :cascade
|
add_foreign_key "report_notes", "accounts", on_delete: :cascade
|
||||||
add_foreign_key "report_notes", "reports", on_delete: :cascade
|
add_foreign_key "report_notes", "reports", on_delete: :cascade
|
||||||
add_foreign_key "reports", "accounts", column: "action_taken_by_account_id", name: "fk_bca45b75fd", on_delete: :nullify
|
add_foreign_key "reports", "accounts", column: "action_taken_by_account_id", name: "fk_bca45b75fd", on_delete: :nullify
|
||||||
|
@ -1185,6 +1206,8 @@ ActiveRecord::Schema.define(version: 2022_08_29_192658) do
|
||||||
add_foreign_key "status_pins", "accounts", name: "fk_d4cb435b62", on_delete: :cascade
|
add_foreign_key "status_pins", "accounts", name: "fk_d4cb435b62", on_delete: :cascade
|
||||||
add_foreign_key "status_pins", "statuses", on_delete: :cascade
|
add_foreign_key "status_pins", "statuses", on_delete: :cascade
|
||||||
add_foreign_key "status_stats", "statuses", on_delete: :cascade
|
add_foreign_key "status_stats", "statuses", on_delete: :cascade
|
||||||
|
add_foreign_key "status_trends", "accounts", on_delete: :cascade
|
||||||
|
add_foreign_key "status_trends", "statuses", on_delete: :cascade
|
||||||
add_foreign_key "statuses", "accounts", column: "in_reply_to_account_id", name: "fk_c7fa917661", on_delete: :nullify
|
add_foreign_key "statuses", "accounts", column: "in_reply_to_account_id", name: "fk_c7fa917661", on_delete: :nullify
|
||||||
add_foreign_key "statuses", "accounts", name: "fk_9bda1543f7", on_delete: :cascade
|
add_foreign_key "statuses", "accounts", name: "fk_9bda1543f7", on_delete: :cascade
|
||||||
add_foreign_key "statuses", "statuses", column: "in_reply_to_id", on_delete: :nullify
|
add_foreign_key "statuses", "statuses", column: "in_reply_to_id", on_delete: :nullify
|
||||||
|
|
|
@ -11,4 +11,5 @@ Fabricator(:account) do
|
||||||
suspended_at { |attrs| attrs[:suspended] ? Time.now.utc : nil }
|
suspended_at { |attrs| attrs[:suspended] ? Time.now.utc : nil }
|
||||||
silenced_at { |attrs| attrs[:silenced] ? Time.now.utc : nil }
|
silenced_at { |attrs| attrs[:silenced] ? Time.now.utc : nil }
|
||||||
user { |attrs| attrs[:domain].nil? ? Fabricate.build(:user, account: nil) : nil }
|
user { |attrs| attrs[:domain].nil? ? Fabricate.build(:user, account: nil) : nil }
|
||||||
|
discoverable true
|
||||||
end
|
end
|
||||||
|
|
|
@ -8,7 +8,7 @@ class AdminMailerPreview < ActionMailer::Preview
|
||||||
|
|
||||||
# Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_trends
|
# Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_trends
|
||||||
def new_trends
|
def new_trends
|
||||||
AdminMailer.new_trends(Account.first, PreviewCard.limit(3), Tag.limit(3), Status.where(reblog_of_id: nil).limit(3))
|
AdminMailer.new_trends(Account.first, PreviewCard.joins(:trend).limit(3), Tag.limit(3), Status.joins(:trend).where(reblog_of_id: nil).limit(3))
|
||||||
end
|
end
|
||||||
|
|
||||||
# Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_appeal
|
# Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_appeal
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe PreviewCardTrend, type: :model do
|
||||||
|
end
|
|
@ -0,0 +1,4 @@
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe StatusTrend, type: :model do
|
||||||
|
end
|
|
@ -9,8 +9,8 @@ RSpec.describe Trends::Statuses do
|
||||||
let!(:query) { subject.query }
|
let!(:query) { subject.query }
|
||||||
let!(:today) { at_time }
|
let!(:today) { at_time }
|
||||||
|
|
||||||
let!(:status1) { Fabricate(:status, text: 'Foo', trendable: true, created_at: today) }
|
let!(:status1) { Fabricate(:status, text: 'Foo', language: 'en', trendable: true, created_at: today) }
|
||||||
let!(:status2) { Fabricate(:status, text: 'Bar', trendable: true, created_at: today) }
|
let!(:status2) { Fabricate(:status, text: 'Bar', language: 'en', trendable: true, created_at: today) }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
15.times { reblog(status1, today) }
|
15.times { reblog(status1, today) }
|
||||||
|
@ -69,9 +69,9 @@ RSpec.describe Trends::Statuses do
|
||||||
let!(:today) { at_time }
|
let!(:today) { at_time }
|
||||||
let!(:yesterday) { today - 1.day }
|
let!(:yesterday) { today - 1.day }
|
||||||
|
|
||||||
let!(:status1) { Fabricate(:status, text: 'Foo', trendable: true, created_at: yesterday) }
|
let!(:status1) { Fabricate(:status, text: 'Foo', language: 'en', trendable: true, created_at: yesterday) }
|
||||||
let!(:status2) { Fabricate(:status, text: 'Bar', trendable: true, created_at: today) }
|
let!(:status2) { Fabricate(:status, text: 'Bar', language: 'en', trendable: true, created_at: today) }
|
||||||
let!(:status3) { Fabricate(:status, text: 'Baz', trendable: true, created_at: today) }
|
let!(:status3) { Fabricate(:status, text: 'Baz', language: 'en', trendable: true, created_at: today) }
|
||||||
|
|
||||||
before do
|
before do
|
||||||
13.times { reblog(status1, today) }
|
13.times { reblog(status1, today) }
|
||||||
|
@ -95,10 +95,10 @@ RSpec.describe Trends::Statuses do
|
||||||
|
|
||||||
it 'decays scores' do
|
it 'decays scores' do
|
||||||
subject.refresh(today)
|
subject.refresh(today)
|
||||||
original_score = subject.score(status2.id)
|
original_score = status2.trend.score
|
||||||
expect(original_score).to be_a Float
|
expect(original_score).to be_a Float
|
||||||
subject.refresh(today + subject.options[:score_halflife])
|
subject.refresh(today + subject.options[:score_halflife])
|
||||||
decayed_score = subject.score(status2.id)
|
decayed_score = status2.trend.reload.score
|
||||||
expect(decayed_score).to be <= original_score / 2
|
expect(decayed_score).to be <= original_score / 2
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
Reference in New Issue