forked from fedi/mastodon
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 controller
This commit is contained in:
parent
a29a982eaa
commit
27965ce5ed
|
@ -32,10 +32,11 @@ Layout/EmptyLineAfterGuardClause:
|
||||||
Layout/EmptyLinesAroundAttributeAccessor:
|
Layout/EmptyLinesAroundAttributeAccessor:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
|
|
||||||
|
Layout/FirstHashElementIndentation:
|
||||||
|
EnforcedStyle: consistent
|
||||||
|
|
||||||
Layout/HashAlignment:
|
Layout/HashAlignment:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
# EnforcedHashRocketStyle: table
|
|
||||||
# EnforcedColonStyle: table
|
|
||||||
|
|
||||||
Layout/SpaceAroundMethodCallOperator:
|
Layout/SpaceAroundMethodCallOperator:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
|
|
|
@ -5,11 +5,11 @@ class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseControll
|
||||||
authorize :preview_card_provider, :index?
|
authorize :preview_card_provider, :index?
|
||||||
|
|
||||||
@preview_card_providers = filtered_preview_card_providers.page(params[:page])
|
@preview_card_providers = filtered_preview_card_providers.page(params[:page])
|
||||||
@form = Form::PreviewCardProviderBatch.new
|
@form = Trends::PreviewCardProviderBatch.new
|
||||||
end
|
end
|
||||||
|
|
||||||
def batch
|
def batch
|
||||||
@form = Form::PreviewCardProviderBatch.new(form_preview_card_provider_batch_params.merge(current_account: current_account, action: action_from_button))
|
@form = Trends::PreviewCardProviderBatch.new(trends_preview_card_provider_batch_params.merge(current_account: current_account, action: action_from_button))
|
||||||
@form.save
|
@form.save
|
||||||
rescue ActionController::ParameterMissing
|
rescue ActionController::ParameterMissing
|
||||||
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
||||||
|
@ -20,15 +20,15 @@ class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseControll
|
||||||
private
|
private
|
||||||
|
|
||||||
def filtered_preview_card_providers
|
def filtered_preview_card_providers
|
||||||
PreviewCardProviderFilter.new(filter_params).results
|
Trends::PreviewCardProviderFilter.new(filter_params).results
|
||||||
end
|
end
|
||||||
|
|
||||||
def filter_params
|
def filter_params
|
||||||
params.slice(:page, *PreviewCardProviderFilter::KEYS).permit(:page, *PreviewCardProviderFilter::KEYS)
|
params.slice(:page, *Trends::PreviewCardProviderFilter::KEYS).permit(:page, *Trends::PreviewCardProviderFilter::KEYS)
|
||||||
end
|
end
|
||||||
|
|
||||||
def form_preview_card_provider_batch_params
|
def trends_preview_card_provider_batch_params
|
||||||
params.require(:form_preview_card_provider_batch).permit(:action, preview_card_provider_ids: [])
|
params.require(:trends_preview_card_provider_batch).permit(:action, preview_card_provider_ids: [])
|
||||||
end
|
end
|
||||||
|
|
||||||
def action_from_button
|
def action_from_button
|
||||||
|
|
|
@ -5,11 +5,11 @@ class Admin::Trends::LinksController < Admin::BaseController
|
||||||
authorize :preview_card, :index?
|
authorize :preview_card, :index?
|
||||||
|
|
||||||
@preview_cards = filtered_preview_cards.page(params[:page])
|
@preview_cards = filtered_preview_cards.page(params[:page])
|
||||||
@form = Form::PreviewCardBatch.new
|
@form = Trends::PreviewCardBatch.new
|
||||||
end
|
end
|
||||||
|
|
||||||
def batch
|
def batch
|
||||||
@form = Form::PreviewCardBatch.new(form_preview_card_batch_params.merge(current_account: current_account, action: action_from_button))
|
@form = Trends::PreviewCardBatch.new(trends_preview_card_batch_params.merge(current_account: current_account, action: action_from_button))
|
||||||
@form.save
|
@form.save
|
||||||
rescue ActionController::ParameterMissing
|
rescue ActionController::ParameterMissing
|
||||||
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
||||||
|
@ -20,26 +20,26 @@ class Admin::Trends::LinksController < Admin::BaseController
|
||||||
private
|
private
|
||||||
|
|
||||||
def filtered_preview_cards
|
def filtered_preview_cards
|
||||||
PreviewCardFilter.new(filter_params.with_defaults(trending: 'all')).results
|
Trends::PreviewCardFilter.new(filter_params.with_defaults(trending: 'all')).results
|
||||||
end
|
end
|
||||||
|
|
||||||
def filter_params
|
def filter_params
|
||||||
params.slice(:page, *PreviewCardFilter::KEYS).permit(:page, *PreviewCardFilter::KEYS)
|
params.slice(:page, *Trends::PreviewCardFilter::KEYS).permit(:page, *Trends::PreviewCardFilter::KEYS)
|
||||||
end
|
end
|
||||||
|
|
||||||
def form_preview_card_batch_params
|
def trends_preview_card_batch_params
|
||||||
params.require(:form_preview_card_batch).permit(:action, preview_card_ids: [])
|
params.require(:trends_preview_card_batch).permit(:action, preview_card_ids: [])
|
||||||
end
|
end
|
||||||
|
|
||||||
def action_from_button
|
def action_from_button
|
||||||
if params[:approve]
|
if params[:approve]
|
||||||
'approve'
|
'approve'
|
||||||
elsif params[:approve_all]
|
elsif params[:approve_providers]
|
||||||
'approve_all'
|
'approve_providers'
|
||||||
elsif params[:reject]
|
elsif params[:reject]
|
||||||
'reject'
|
'reject'
|
||||||
elsif params[:reject_all]
|
elsif params[:reject_providers]
|
||||||
'reject_all'
|
'reject_providers'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
45
app/controllers/admin/trends/statuses_controller.rb
Normal file
45
app/controllers/admin/trends/statuses_controller.rb
Normal file
|
@ -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
|
|
@ -5,11 +5,11 @@ class Admin::Trends::TagsController < Admin::BaseController
|
||||||
authorize :tag, :index?
|
authorize :tag, :index?
|
||||||
|
|
||||||
@tags = filtered_tags.page(params[:page])
|
@tags = filtered_tags.page(params[:page])
|
||||||
@form = Form::TagBatch.new
|
@form = Trends::TagBatch.new
|
||||||
end
|
end
|
||||||
|
|
||||||
def batch
|
def batch
|
||||||
@form = Form::TagBatch.new(form_tag_batch_params.merge(current_account: current_account, action: action_from_button))
|
@form = Trends::TagBatch.new(trends_tag_batch_params.merge(current_account: current_account, action: action_from_button))
|
||||||
@form.save
|
@form.save
|
||||||
rescue ActionController::ParameterMissing
|
rescue ActionController::ParameterMissing
|
||||||
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
||||||
|
@ -20,15 +20,15 @@ class Admin::Trends::TagsController < Admin::BaseController
|
||||||
private
|
private
|
||||||
|
|
||||||
def filtered_tags
|
def filtered_tags
|
||||||
TagFilter.new(filter_params).results
|
Trends::TagFilter.new(filter_params).results
|
||||||
end
|
end
|
||||||
|
|
||||||
def filter_params
|
def filter_params
|
||||||
params.slice(:page, *TagFilter::KEYS).permit(:page, *TagFilter::KEYS)
|
params.slice(:page, *Trends::TagFilter::KEYS).permit(:page, *Trends::TagFilter::KEYS)
|
||||||
end
|
end
|
||||||
|
|
||||||
def form_tag_batch_params
|
def trends_tag_batch_params
|
||||||
params.require(:form_tag_batch).permit(:action, tag_ids: [])
|
params.require(:trends_tag_batch).permit(:action, tag_ids: [])
|
||||||
end
|
end
|
||||||
|
|
||||||
def action_from_button
|
def action_from_button
|
||||||
|
|
19
app/controllers/api/v1/admin/trends/links_controller.rb
Normal file
19
app/controllers/api/v1/admin/trends/links_controller.rb
Normal file
|
@ -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
|
19
app/controllers/api/v1/admin/trends/statuses_controller.rb
Normal file
19
app/controllers/api/v1/admin/trends/statuses_controller.rb
Normal file
|
@ -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
|
|
@ -14,6 +14,6 @@ class Api::V1::Admin::Trends::TagsController < Api::BaseController
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_tags
|
def set_tags
|
||||||
@tags = Trends.tags.get(false, limit_param(10))
|
@tags = Trends.tags.query.limit(limit_param(10))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -12,10 +12,14 @@ class Api::V1::Trends::LinksController < Api::BaseController
|
||||||
def set_links
|
def set_links
|
||||||
@links = begin
|
@links = begin
|
||||||
if Setting.trends
|
if Setting.trends
|
||||||
Trends.links.get(true, limit_param(10))
|
links_from_trends
|
||||||
else
|
else
|
||||||
[]
|
[]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def links_from_trends
|
||||||
|
Trends.links.query.allowed.in_locale(content_locale).limit(limit_param(10))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
27
app/controllers/api/v1/trends/statuses_controller.rb
Normal file
27
app/controllers/api/v1/trends/statuses_controller.rb
Normal file
|
@ -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
|
|
@ -12,7 +12,7 @@ class Api::V1::Trends::TagsController < Api::BaseController
|
||||||
def set_tags
|
def set_tags
|
||||||
@tags = begin
|
@tags = begin
|
||||||
if Setting.trends
|
if Setting.trends
|
||||||
Trends.tags.get(true, limit_param(10))
|
Trends.tags.query.allowed.limit(limit_param(10))
|
||||||
else
|
else
|
||||||
[]
|
[]
|
||||||
end
|
end
|
||||||
|
|
|
@ -27,4 +27,8 @@ module Localized
|
||||||
def available_locale_or_nil(locale_name)
|
def available_locale_or_nil(locale_name)
|
||||||
locale_name.to_sym if locale_name.present? && I18n.available_locales.include?(locale_name.to_sym)
|
locale_name.to_sym if locale_name.present? && I18n.available_locales.include?(locale_name.to_sym)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def content_locale
|
||||||
|
@content_locale ||= I18n.locale.to_s.split(/[_-]/).first
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,9 +5,10 @@ module Admin::FilterHelper
|
||||||
AccountFilter::KEYS,
|
AccountFilter::KEYS,
|
||||||
CustomEmojiFilter::KEYS,
|
CustomEmojiFilter::KEYS,
|
||||||
ReportFilter::KEYS,
|
ReportFilter::KEYS,
|
||||||
TagFilter::KEYS,
|
Trends::TagFilter::KEYS,
|
||||||
PreviewCardProviderFilter::KEYS,
|
Trends::PreviewCardProviderFilter::KEYS,
|
||||||
PreviewCardFilter::KEYS,
|
Trends::PreviewCardFilter::KEYS,
|
||||||
|
Trends::StatusFilter::KEYS,
|
||||||
InstanceFilter::KEYS,
|
InstanceFilter::KEYS,
|
||||||
InviteFilter::KEYS,
|
InviteFilter::KEYS,
|
||||||
RelationshipFilter::KEYS,
|
RelationshipFilter::KEYS,
|
||||||
|
|
|
@ -242,6 +242,6 @@ module LanguagesHelper
|
||||||
end
|
end
|
||||||
|
|
||||||
def valid_locale?(locale)
|
def valid_locale?(locale)
|
||||||
SUPPORTED_LOCALES.key?(locale.to_sym)
|
locale.present? && SUPPORTED_LOCALES.key?(locale.to_sym)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -331,7 +331,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.batch-table__row--muted .pending-account__header,
|
.batch-table__row--muted .pending-account__header,
|
||||||
.batch-table__row--muted .accounts-table {
|
.batch-table__row--muted .accounts-table,
|
||||||
|
.batch-table__row--muted .name-tag {
|
||||||
&,
|
&,
|
||||||
a,
|
a,
|
||||||
strong {
|
strong {
|
||||||
|
@ -339,6 +340,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.batch-table__row--muted .name-tag .avatar {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
.batch-table__row--muted .accounts-table {
|
.batch-table__row--muted .accounts-table {
|
||||||
tbody td.accounts-table__extra,
|
tbody td.accounts-table__extra,
|
||||||
&__count,
|
&__count,
|
||||||
|
@ -352,7 +357,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.batch-table__row--attention .pending-account__header,
|
.batch-table__row--attention .pending-account__header,
|
||||||
.batch-table__row--attention .accounts-table {
|
.batch-table__row--attention .accounts-table,
|
||||||
|
.batch-table__row--attention .name-tag {
|
||||||
&,
|
&,
|
||||||
a,
|
a,
|
||||||
strong {
|
strong {
|
||||||
|
|
|
@ -210,6 +210,7 @@ a.table-action-link {
|
||||||
&__content {
|
&__content {
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
&--unpadded {
|
&--unpadded {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
@ -296,3 +297,9 @@ a.table-action-link {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.one-liner {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
|
@ -23,8 +23,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
|
||||||
visibility: visibility_from_audience
|
visibility: visibility_from_audience
|
||||||
)
|
)
|
||||||
|
|
||||||
Trends.tags.register(@status)
|
Trends.register!(@status)
|
||||||
Trends.links.register(@status)
|
|
||||||
|
|
||||||
distribute
|
distribute
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,6 +7,8 @@ class ActivityPub::Activity::Like < ActivityPub::Activity
|
||||||
return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status)
|
return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status)
|
||||||
|
|
||||||
favourite = original_status.favourites.create!(account: @account)
|
favourite = original_status.favourites.create!(account: @account)
|
||||||
|
|
||||||
NotifyService.new.call(original_status.account, :favourite, favourite)
|
NotifyService.new.call(original_status.account, :favourite, favourite)
|
||||||
|
Trends.statuses.register(original_status)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -35,25 +35,18 @@ class AdminMailer < ApplicationMailer
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def new_trending_tags(recipient, tags)
|
def new_trends(recipient, links, tags, statuses)
|
||||||
@tags = tags
|
@links = links
|
||||||
@me = recipient
|
@lowest_trending_link = Trends.links.query.allowed.limit(Trends.links.options[:review_threshold]).last
|
||||||
@instance = Rails.configuration.x.local_domain
|
@tags = tags
|
||||||
@lowest_trending_tag = Trends.tags.get(true, Trends.tags.options[:review_threshold]).last
|
@lowest_trending_tag = Trends.tags.query.allowed.limit(Trends.tags.options[:review_threshold]).last
|
||||||
|
@statuses = statuses
|
||||||
|
@lowest_trending_status = Trends.statuses.query.allowed.limit(Trends.statuses.options[:review_threshold]).last
|
||||||
|
@me = recipient
|
||||||
|
@instance = Rails.configuration.x.local_domain
|
||||||
|
|
||||||
locale_for_account(@me) do
|
locale_for_account(@me) do
|
||||||
mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trending_tags.subject', instance: @instance)
|
mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trends.subject', instance: @instance)
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def new_trending_links(recipient, links)
|
|
||||||
@links = links
|
|
||||||
@me = recipient
|
|
||||||
@instance = Rails.configuration.x.local_domain
|
|
||||||
@lowest_trending_link = Trends.links.get(true, Trends.links.options[:review_threshold]).last
|
|
||||||
|
|
||||||
locale_for_account(@me) do
|
|
||||||
mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trending_links.subject', instance: @instance)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -40,13 +40,15 @@
|
||||||
# also_known_as :string is an Array
|
# also_known_as :string is an Array
|
||||||
# silenced_at :datetime
|
# silenced_at :datetime
|
||||||
# suspended_at :datetime
|
# suspended_at :datetime
|
||||||
# trust_level :integer
|
|
||||||
# hide_collections :boolean
|
# hide_collections :boolean
|
||||||
# avatar_storage_schema_version :integer
|
# avatar_storage_schema_version :integer
|
||||||
# header_storage_schema_version :integer
|
# header_storage_schema_version :integer
|
||||||
# devices_url :string
|
# devices_url :string
|
||||||
# suspension_origin :integer
|
# suspension_origin :integer
|
||||||
# sensitized_at :datetime
|
# sensitized_at :datetime
|
||||||
|
# trendable :boolean
|
||||||
|
# reviewed_at :datetime
|
||||||
|
# requested_review_at :datetime
|
||||||
#
|
#
|
||||||
|
|
||||||
class Account < ApplicationRecord
|
class Account < ApplicationRecord
|
||||||
|
@ -56,6 +58,7 @@ class Account < ApplicationRecord
|
||||||
remote_url
|
remote_url
|
||||||
salmon_url
|
salmon_url
|
||||||
hub_url
|
hub_url
|
||||||
|
trust_level
|
||||||
)
|
)
|
||||||
|
|
||||||
USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i
|
USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i
|
||||||
|
@ -74,11 +77,6 @@ class Account < ApplicationRecord
|
||||||
include DomainMaterializable
|
include DomainMaterializable
|
||||||
include AccountMerging
|
include AccountMerging
|
||||||
|
|
||||||
TRUST_LEVELS = {
|
|
||||||
untrusted: 0,
|
|
||||||
trusted: 1,
|
|
||||||
}.freeze
|
|
||||||
|
|
||||||
enum protocol: [:ostatus, :activitypub]
|
enum protocol: [:ostatus, :activitypub]
|
||||||
enum suspension_origin: [:local, :remote], _prefix: true
|
enum suspension_origin: [:local, :remote], _prefix: true
|
||||||
|
|
||||||
|
@ -202,10 +200,6 @@ class Account < ApplicationRecord
|
||||||
last_webfingered_at.nil? || last_webfingered_at <= 1.day.ago
|
last_webfingered_at.nil? || last_webfingered_at <= 1.day.ago
|
||||||
end
|
end
|
||||||
|
|
||||||
def trust_level
|
|
||||||
self[:trust_level] || 0
|
|
||||||
end
|
|
||||||
|
|
||||||
def refresh!
|
def refresh!
|
||||||
ResolveAccountService.new.call(acct) unless local?
|
ResolveAccountService.new.call(acct) unless local?
|
||||||
end
|
end
|
||||||
|
@ -388,6 +382,22 @@ class Account < ApplicationRecord
|
||||||
@synchronization_uri_prefix ||= "#{uri[URL_PREFIX_RE]}/"
|
@synchronization_uri_prefix ||= "#{uri[URL_PREFIX_RE]}/"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def requires_review?
|
||||||
|
reviewed_at.nil?
|
||||||
|
end
|
||||||
|
|
||||||
|
def reviewed?
|
||||||
|
reviewed_at.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def requested_review?
|
||||||
|
requested_review_at.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def requires_review_notification?
|
||||||
|
requires_review? && !requested_review?
|
||||||
|
end
|
||||||
|
|
||||||
class Field < ActiveModelSerializers::Model
|
class Field < ActiveModelSerializers::Model
|
||||||
attributes :name, :value, :verified_at, :account
|
attributes :name, :value, :verified_at, :account
|
||||||
|
|
||||||
|
|
|
@ -268,6 +268,18 @@ class Status < ApplicationRecord
|
||||||
update_status_stat!(key => [public_send(key) - 1, 0].max)
|
update_status_stat!(key => [public_send(key) - 1, 0].max)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def trendable?
|
||||||
|
if attributes['trendable'].nil?
|
||||||
|
account.trendable?
|
||||||
|
else
|
||||||
|
attributes['trendable']
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def requires_review_notification?
|
||||||
|
attributes['trendable'].nil? && account.requires_review_notification?
|
||||||
|
end
|
||||||
|
|
||||||
after_create_commit :increment_counter_caches
|
after_create_commit :increment_counter_caches
|
||||||
after_destroy_commit :decrement_counter_caches
|
after_destroy_commit :decrement_counter_caches
|
||||||
|
|
||||||
|
|
|
@ -13,15 +13,37 @@ module Trends
|
||||||
@tags ||= Trends::Tags.new
|
@tags ||= Trends::Tags.new
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.statuses
|
||||||
|
@statuses ||= Trends::Statuses.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.register!(status)
|
||||||
|
[links, tags, statuses].each { |trend_type| trend_type.register(status) }
|
||||||
|
end
|
||||||
|
|
||||||
def self.refresh!
|
def self.refresh!
|
||||||
[links, tags].each(&:refresh)
|
[links, tags, statuses].each(&:refresh)
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.request_review!
|
def self.request_review!
|
||||||
[links, tags].each(&:request_review) if enabled?
|
return unless enabled?
|
||||||
|
|
||||||
|
links_requiring_review = links.request_review
|
||||||
|
tags_requiring_review = tags.request_review
|
||||||
|
statuses_requiring_review = statuses.request_review
|
||||||
|
|
||||||
|
return if links_requiring_review.empty? && tags_requiring_review.empty? && statuses_requiring_review.empty?
|
||||||
|
|
||||||
|
User.staff.includes(:account).find_each do |user|
|
||||||
|
AdminMailer.new_trends(user.account, links_requiring_review, tags_requiring_review, statuses_requiring_review).deliver_later! if user.allows_trends_review_emails?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.enabled?
|
def self.enabled?
|
||||||
Setting.trends
|
Setting.trends
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.available_locales
|
||||||
|
@available_locales ||= I18n.available_locales.map { |locale| locale.to_s.split(/[_-]/).first }.uniq
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
class Trends::Base
|
class Trends::Base
|
||||||
include Redisable
|
include Redisable
|
||||||
|
include LanguagesHelper
|
||||||
|
|
||||||
class_attribute :default_options
|
class_attribute :default_options
|
||||||
|
|
||||||
|
@ -32,8 +33,8 @@ class Trends::Base
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
end
|
end
|
||||||
|
|
||||||
def get(*)
|
def query
|
||||||
raise NotImplementedError
|
Trends::Query.new(key_prefix, klass)
|
||||||
end
|
end
|
||||||
|
|
||||||
def score(id)
|
def score(id)
|
||||||
|
@ -72,6 +73,21 @@ class Trends::Base
|
||||||
redis.zrevrange("#{key_prefix}:allowed", 0, rank, with_scores: true).last&.last || 0
|
redis.zrevrange("#{key_prefix}:allowed", 0, rank, with_scores: true).last&.last || 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @param [Integer] id
|
||||||
|
# @param [Float] score
|
||||||
|
# @param [Hash<String, Boolean>] subsets
|
||||||
|
def add_to_and_remove_from_subsets(id, score, subsets = {})
|
||||||
|
subsets.each_key do |subset|
|
||||||
|
key = [key_prefix, subset].compact.join(':')
|
||||||
|
|
||||||
|
if score.positive? && subsets[subset]
|
||||||
|
redis.zadd(key, score, id)
|
||||||
|
else
|
||||||
|
redis.zrem(key, id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def used_key(at_time)
|
def used_key(at_time)
|
||||||
|
|
|
@ -4,8 +4,8 @@ class Trends::Links < Trends::Base
|
||||||
PREFIX = 'trending_links'
|
PREFIX = 'trending_links'
|
||||||
|
|
||||||
self.default_options = {
|
self.default_options = {
|
||||||
threshold: 15,
|
threshold: 5,
|
||||||
review_threshold: 10,
|
review_threshold: 3,
|
||||||
max_score_cooldown: 2.days.freeze,
|
max_score_cooldown: 2.days.freeze,
|
||||||
max_score_halflife: 8.hours.freeze,
|
max_score_halflife: 8.hours.freeze,
|
||||||
}
|
}
|
||||||
|
@ -27,12 +27,6 @@ class Trends::Links < Trends::Base
|
||||||
record_used_id(preview_card.id, at_time)
|
record_used_id(preview_card.id, at_time)
|
||||||
end
|
end
|
||||||
|
|
||||||
def get(allowed, limit)
|
|
||||||
preview_card_ids = currently_trending_ids(allowed, limit)
|
|
||||||
preview_cards = PreviewCard.where(id: preview_card_ids).index_by(&:id)
|
|
||||||
preview_card_ids.map { |id| preview_cards[id] }.compact
|
|
||||||
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) + currently_trending_ids(false, -1)).uniq)
|
||||||
calculate_scores(preview_cards, at_time)
|
calculate_scores(preview_cards, at_time)
|
||||||
|
@ -42,7 +36,7 @@ class Trends::Links < Trends::Base
|
||||||
def request_review
|
def request_review
|
||||||
preview_cards = PreviewCard.where(id: currently_trending_ids(false, -1))
|
preview_cards = PreviewCard.where(id: currently_trending_ids(false, -1))
|
||||||
|
|
||||||
preview_cards_requiring_review = preview_cards.filter_map do |preview_card|
|
preview_cards.filter_map do |preview_card|
|
||||||
next unless would_be_trending?(preview_card.id) && !preview_card.trendable? && preview_card.requires_review_notification?
|
next unless would_be_trending?(preview_card.id) && !preview_card.trendable? && preview_card.requires_review_notification?
|
||||||
|
|
||||||
if preview_card.provider.nil?
|
if preview_card.provider.nil?
|
||||||
|
@ -53,12 +47,6 @@ class Trends::Links < Trends::Base
|
||||||
|
|
||||||
preview_card
|
preview_card
|
||||||
end
|
end
|
||||||
|
|
||||||
return if preview_cards_requiring_review.empty?
|
|
||||||
|
|
||||||
User.staff.includes(:account).find_each do |user|
|
|
||||||
AdminMailer.new_trending_links(user.account, preview_cards_requiring_review).deliver_later! if user.allows_trending_tag_emails?
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
protected
|
protected
|
||||||
|
@ -67,6 +55,10 @@ class Trends::Links < Trends::Base
|
||||||
PREFIX
|
PREFIX
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def klass
|
||||||
|
PreviewCard
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def calculate_scores(preview_cards, at_time)
|
def calculate_scores(preview_cards, at_time)
|
||||||
|
@ -96,17 +88,27 @@ class Trends::Links < Trends::Base
|
||||||
|
|
||||||
decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / options[:max_score_halflife].to_f))
|
decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / options[:max_score_halflife].to_f))
|
||||||
|
|
||||||
if decaying_score.zero?
|
add_to_and_remove_from_subsets(preview_card.id, decaying_score, {
|
||||||
redis.zrem("#{PREFIX}:all", preview_card.id)
|
all: true,
|
||||||
redis.zrem("#{PREFIX}:allowed", preview_card.id)
|
allowed: preview_card.trendable?,
|
||||||
else
|
})
|
||||||
redis.zadd("#{PREFIX}:all", decaying_score, preview_card.id)
|
|
||||||
|
|
||||||
if preview_card.trendable?
|
next unless valid_locale?(preview_card.language)
|
||||||
redis.zadd("#{PREFIX}:allowed", decaying_score, preview_card.id)
|
|
||||||
else
|
add_to_and_remove_from_subsets(preview_card.id, decaying_score, {
|
||||||
redis.zrem("#{PREFIX}:allowed", preview_card.id)
|
"all:#{preview_card.language}" => true,
|
||||||
end
|
"allowed:#{preview_card.language}" => preview_card.trendable?,
|
||||||
|
})
|
||||||
|
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
|
||||||
|
|
||||||
|
redis.pipelined do
|
||||||
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Form::PreviewCardBatch
|
class Trends::PreviewCardBatch
|
||||||
include ActiveModel::Model
|
include ActiveModel::Model
|
||||||
include Authorization
|
include Authorization
|
||||||
|
|
||||||
|
@ -10,12 +10,12 @@ class Form::PreviewCardBatch
|
||||||
case action
|
case action
|
||||||
when 'approve'
|
when 'approve'
|
||||||
approve!
|
approve!
|
||||||
when 'approve_all'
|
when 'approve_providers'
|
||||||
approve_all!
|
approve_providers!
|
||||||
when 'reject'
|
when 'reject'
|
||||||
reject!
|
reject!
|
||||||
when 'reject_all'
|
when 'reject_providers'
|
||||||
reject_all!
|
reject_providers!
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -30,13 +30,13 @@ class Form::PreviewCardBatch
|
||||||
end
|
end
|
||||||
|
|
||||||
def approve!
|
def approve!
|
||||||
preview_cards.each { |preview_card| authorize(preview_card, :update?) }
|
preview_cards.each { |preview_card| authorize(preview_card, :review?) }
|
||||||
preview_cards.update_all(trendable: true)
|
preview_cards.update_all(trendable: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
def approve_all!
|
def approve_providers!
|
||||||
preview_card_providers.each do |provider|
|
preview_card_providers.each do |provider|
|
||||||
authorize(provider, :update?)
|
authorize(provider, :review?)
|
||||||
provider.update(trendable: true, reviewed_at: action_time)
|
provider.update(trendable: true, reviewed_at: action_time)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -45,13 +45,13 @@ class Form::PreviewCardBatch
|
||||||
end
|
end
|
||||||
|
|
||||||
def reject!
|
def reject!
|
||||||
preview_cards.each { |preview_card| authorize(preview_card, :update?) }
|
preview_cards.each { |preview_card| authorize(preview_card, :review?) }
|
||||||
preview_cards.update_all(trendable: false)
|
preview_cards.update_all(trendable: false)
|
||||||
end
|
end
|
||||||
|
|
||||||
def reject_all!
|
def reject_providers!
|
||||||
preview_card_providers.each do |provider|
|
preview_card_providers.each do |provider|
|
||||||
authorize(provider, :update?)
|
authorize(provider, :review?)
|
||||||
provider.update(trendable: false, reviewed_at: action_time)
|
provider.update(trendable: false, reviewed_at: action_time)
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class PreviewCardFilter
|
class Trends::PreviewCardFilter
|
||||||
KEYS = %i(
|
KEYS = %i(
|
||||||
trending
|
trending
|
||||||
|
locale
|
||||||
).freeze
|
).freeze
|
||||||
|
|
||||||
attr_reader :params
|
attr_reader :params
|
||||||
|
@ -15,7 +16,7 @@ class PreviewCardFilter
|
||||||
scope = PreviewCard.unscoped
|
scope = PreviewCard.unscoped
|
||||||
|
|
||||||
params.each do |key, value|
|
params.each do |key, value|
|
||||||
next if key.to_s == 'page'
|
next if %w(page locale).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
|
||||||
|
@ -35,19 +36,11 @@ class PreviewCardFilter
|
||||||
end
|
end
|
||||||
|
|
||||||
def trending_scope(value)
|
def trending_scope(value)
|
||||||
ids = begin
|
scope = Trends.links.query
|
||||||
case value.to_s
|
|
||||||
when 'allowed'
|
|
||||||
Trends.links.currently_trending_ids(true, -1)
|
|
||||||
else
|
|
||||||
Trends.links.currently_trending_ids(false, -1)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if ids.empty?
|
scope = scope.in_locale(@params[:locale].to_s) if @params[:locale].present?
|
||||||
PreviewCard.none
|
scope = scope.allowed if value == 'allowed'
|
||||||
else
|
|
||||||
PreviewCard.joins("join unnest(array[#{ids.map(&:to_i).join(',')}]::integer[]) with ordinality as x (id, ordering) on preview_cards.id = x.id").order('x.ordering')
|
scope.to_arel
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
|
@ -1,6 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Form::PreviewCardProviderBatch
|
class Trends::PreviewCardProviderBatch
|
||||||
include ActiveModel::Model
|
include ActiveModel::Model
|
||||||
include Authorization
|
include Authorization
|
||||||
|
|
||||||
|
@ -22,12 +22,12 @@ class Form::PreviewCardProviderBatch
|
||||||
end
|
end
|
||||||
|
|
||||||
def approve!
|
def approve!
|
||||||
preview_card_providers.each { |provider| authorize(provider, :update?) }
|
preview_card_providers.each { |provider| authorize(provider, :review?) }
|
||||||
preview_card_providers.update_all(trendable: true, reviewed_at: Time.now.utc)
|
preview_card_providers.update_all(trendable: true, reviewed_at: Time.now.utc)
|
||||||
end
|
end
|
||||||
|
|
||||||
def reject!
|
def reject!
|
||||||
preview_card_providers.each { |provider| authorize(provider, :update?) }
|
preview_card_providers.each { |provider| authorize(provider, :review?) }
|
||||||
preview_card_providers.update_all(trendable: false, reviewed_at: Time.now.utc)
|
preview_card_providers.update_all(trendable: false, reviewed_at: Time.now.utc)
|
||||||
end
|
end
|
||||||
end
|
end
|
|
@ -1,6 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class PreviewCardProviderFilter
|
class Trends::PreviewCardProviderFilter
|
||||||
KEYS = %i(
|
KEYS = %i(
|
||||||
status
|
status
|
||||||
).freeze
|
).freeze
|
106
app/models/trends/query.rb
Normal file
106
app/models/trends/query.rb
Normal file
|
@ -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
|
65
app/models/trends/status_batch.rb
Normal file
65
app/models/trends/status_batch.rb
Normal file
|
@ -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
|
46
app/models/trends/status_filter.rb
Normal file
46
app/models/trends/status_filter.rb
Normal file
|
@ -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
|
142
app/models/trends/statuses.rb
Normal file
142
app/models/trends/statuses.rb
Normal file
|
@ -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
|
|
@ -1,6 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Form::TagBatch
|
class Trends::TagBatch
|
||||||
include ActiveModel::Model
|
include ActiveModel::Model
|
||||||
include Authorization
|
include Authorization
|
||||||
|
|
||||||
|
@ -22,12 +22,12 @@ class Form::TagBatch
|
||||||
end
|
end
|
||||||
|
|
||||||
def approve!
|
def approve!
|
||||||
tags.each { |tag| authorize(tag, :update?) }
|
tags.each { |tag| authorize(tag, :review?) }
|
||||||
tags.update_all(trendable: true, reviewed_at: action_time)
|
tags.update_all(trendable: true, reviewed_at: action_time)
|
||||||
end
|
end
|
||||||
|
|
||||||
def reject!
|
def reject!
|
||||||
tags.each { |tag| authorize(tag, :update?) }
|
tags.each { |tag| authorize(tag, :review?) }
|
||||||
tags.update_all(trendable: false, reviewed_at: action_time)
|
tags.update_all(trendable: false, reviewed_at: action_time)
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class TagFilter
|
class Trends::TagFilter
|
||||||
KEYS = %i(
|
KEYS = %i(
|
||||||
trending
|
trending
|
||||||
status
|
status
|
||||||
|
@ -42,13 +42,7 @@ class TagFilter
|
||||||
end
|
end
|
||||||
|
|
||||||
def trending_scope
|
def trending_scope
|
||||||
ids = Trends.tags.currently_trending_ids(false, -1)
|
Trends.tags.query.to_arel
|
||||||
|
|
||||||
if ids.empty?
|
|
||||||
Tag.none
|
|
||||||
else
|
|
||||||
Tag.joins("join unnest(array[#{ids.map(&:to_i).join(',')}]::integer[]) with ordinality as x (id, ordering) on tags.id = x.id").order('x.ordering')
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def status_scope(value)
|
def status_scope(value)
|
|
@ -5,7 +5,7 @@ class Trends::Tags < Trends::Base
|
||||||
|
|
||||||
self.default_options = {
|
self.default_options = {
|
||||||
threshold: 5,
|
threshold: 5,
|
||||||
review_threshold: 10,
|
review_threshold: 3,
|
||||||
max_score_cooldown: 2.days.freeze,
|
max_score_cooldown: 2.days.freeze,
|
||||||
max_score_halflife: 4.hours.freeze,
|
max_score_halflife: 4.hours.freeze,
|
||||||
}
|
}
|
||||||
|
@ -29,27 +29,15 @@ class Trends::Tags < Trends::Base
|
||||||
trim_older_items
|
trim_older_items
|
||||||
end
|
end
|
||||||
|
|
||||||
def get(allowed, limit)
|
|
||||||
tag_ids = currently_trending_ids(allowed, limit)
|
|
||||||
tags = Tag.where(id: tag_ids).index_by(&:id)
|
|
||||||
tag_ids.map { |id| tags[id] }.compact
|
|
||||||
end
|
|
||||||
|
|
||||||
def request_review
|
def request_review
|
||||||
tags = Tag.where(id: currently_trending_ids(false, -1))
|
tags = Tag.where(id: currently_trending_ids(false, -1))
|
||||||
|
|
||||||
tags_requiring_review = tags.filter_map do |tag|
|
tags.filter_map do |tag|
|
||||||
next unless would_be_trending?(tag.id) && !tag.trendable? && tag.requires_review_notification?
|
next unless would_be_trending?(tag.id) && !tag.trendable? && tag.requires_review_notification?
|
||||||
|
|
||||||
tag.touch(:requested_review_at)
|
tag.touch(:requested_review_at)
|
||||||
tag
|
tag
|
||||||
end
|
end
|
||||||
|
|
||||||
return if tags_requiring_review.empty?
|
|
||||||
|
|
||||||
User.staff.includes(:account).find_each do |user|
|
|
||||||
AdminMailer.new_trending_tags(user.account, tags_requiring_review).deliver_later! if user.allows_trending_tag_emails?
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
protected
|
protected
|
||||||
|
@ -58,6 +46,10 @@ class Trends::Tags < Trends::Base
|
||||||
PREFIX
|
PREFIX
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def klass
|
||||||
|
Tag
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def calculate_scores(tags, at_time)
|
def calculate_scores(tags, at_time)
|
||||||
|
@ -87,18 +79,10 @@ class Trends::Tags < Trends::Base
|
||||||
|
|
||||||
decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / options[:max_score_halflife].to_f))
|
decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / options[:max_score_halflife].to_f))
|
||||||
|
|
||||||
if decaying_score.zero?
|
add_to_and_remove_from_subsets(tag.id, decaying_score, {
|
||||||
redis.zrem("#{PREFIX}:all", tag.id)
|
all: true,
|
||||||
redis.zrem("#{PREFIX}:allowed", tag.id)
|
allowed: tag.trendable?,
|
||||||
else
|
})
|
||||||
redis.zadd("#{PREFIX}:all", decaying_score, tag.id)
|
|
||||||
|
|
||||||
if tag.trendable?
|
|
||||||
redis.zadd("#{PREFIX}:allowed", decaying_score, tag.id)
|
|
||||||
else
|
|
||||||
redis.zrem("#{PREFIX}:allowed", tag.id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -269,7 +269,7 @@ class User < ApplicationRecord
|
||||||
settings.notification_emails['appeal']
|
settings.notification_emails['appeal']
|
||||||
end
|
end
|
||||||
|
|
||||||
def allows_trending_tag_emails?
|
def allows_trends_review_emails?
|
||||||
settings.notification_emails['trending_tag']
|
settings.notification_emails['trending_tag']
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -68,4 +68,8 @@ class AccountPolicy < ApplicationPolicy
|
||||||
def unblock_email?
|
def unblock_email?
|
||||||
staff?
|
staff?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def review?
|
||||||
|
staff?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,7 +5,7 @@ class PreviewCardPolicy < ApplicationPolicy
|
||||||
staff?
|
staff?
|
||||||
end
|
end
|
||||||
|
|
||||||
def update?
|
def review?
|
||||||
staff?
|
staff?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,7 +5,7 @@ class PreviewCardProviderPolicy < ApplicationPolicy
|
||||||
staff?
|
staff?
|
||||||
end
|
end
|
||||||
|
|
||||||
def update?
|
def review?
|
||||||
staff?
|
staff?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -41,6 +41,10 @@ class StatusPolicy < ApplicationPolicy
|
||||||
staff? || owned?
|
staff? || owned?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def review?
|
||||||
|
staff?
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def requires_mention?
|
def requires_mention?
|
||||||
|
|
|
@ -12,4 +12,8 @@ class TagPolicy < ApplicationPolicy
|
||||||
def update?
|
def update?
|
||||||
staff?
|
staff?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def review?
|
||||||
|
staff?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -220,21 +220,23 @@ class DeleteAccountService < BaseService
|
||||||
|
|
||||||
return unless keep_account_record?
|
return unless keep_account_record?
|
||||||
|
|
||||||
@account.silenced_at = nil
|
@account.silenced_at = nil
|
||||||
@account.suspended_at = @options[:suspended_at] || Time.now.utc
|
@account.suspended_at = @options[:suspended_at] || Time.now.utc
|
||||||
@account.suspension_origin = :local
|
@account.suspension_origin = :local
|
||||||
@account.locked = false
|
@account.locked = false
|
||||||
@account.memorial = false
|
@account.memorial = false
|
||||||
@account.discoverable = false
|
@account.discoverable = false
|
||||||
@account.display_name = ''
|
@account.trendable = false
|
||||||
@account.note = ''
|
@account.display_name = ''
|
||||||
@account.fields = []
|
@account.note = ''
|
||||||
@account.statuses_count = 0
|
@account.fields = []
|
||||||
@account.followers_count = 0
|
@account.statuses_count = 0
|
||||||
@account.following_count = 0
|
@account.followers_count = 0
|
||||||
@account.moved_to_account = nil
|
@account.following_count = 0
|
||||||
@account.also_known_as = []
|
@account.moved_to_account = nil
|
||||||
@account.trust_level = :untrusted
|
@account.reviewed_at = nil
|
||||||
|
@account.requested_review_at = nil
|
||||||
|
@account.also_known_as = []
|
||||||
@account.avatar.destroy
|
@account.avatar.destroy
|
||||||
@account.header.destroy
|
@account.header.destroy
|
||||||
@account.save!
|
@account.save!
|
||||||
|
|
|
@ -17,6 +17,8 @@ class FavouriteService < BaseService
|
||||||
|
|
||||||
favourite = Favourite.create!(account: account, status: status)
|
favourite = Favourite.create!(account: account, status: status)
|
||||||
|
|
||||||
|
Trends.statuses.register(status)
|
||||||
|
|
||||||
create_notification(favourite)
|
create_notification(favourite)
|
||||||
bump_potential_friendship(account, status)
|
bump_potential_friendship(account, status)
|
||||||
|
|
||||||
|
|
|
@ -30,8 +30,7 @@ class ReblogService < BaseService
|
||||||
|
|
||||||
reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility, rate_limit: options[:with_rate_limit])
|
reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility, rate_limit: options[:with_rate_limit])
|
||||||
|
|
||||||
Trends.tags.register(reblog)
|
Trends.register!(reblog)
|
||||||
Trends.links.register(reblog)
|
|
||||||
DistributionWorker.perform_async(reblog.id)
|
DistributionWorker.perform_async(reblog.id)
|
||||||
ActivityPub::DistributionWorker.perform_async(reblog.id)
|
ActivityPub::DistributionWorker.perform_async(reblog.id)
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
= f.check_box :custom_emoji_ids, { multiple: true, include_hidden: false }, custom_emoji.id
|
= f.check_box :custom_emoji_ids, { multiple: true, include_hidden: false }, custom_emoji.id
|
||||||
.batch-table__row__content.batch-table__row__content--with-image
|
.batch-table__row__content.batch-table__row__content--with-image
|
||||||
.batch-table__row__content__image
|
.batch-table__row__content__image
|
||||||
= custom_emoji_tag(custom_emoji, animate = current_account&.user&.setting_auto_play_gif)
|
= custom_emoji_tag(custom_emoji, current_account&.user&.setting_auto_play_gif)
|
||||||
|
|
||||||
.batch-table__row__content__text
|
.batch-table__row__content__text
|
||||||
%samp= ":#{custom_emoji.shortcode}:"
|
%samp= ":#{custom_emoji.shortcode}:"
|
||||||
|
|
|
@ -9,12 +9,14 @@
|
||||||
%hr.spacer/
|
%hr.spacer/
|
||||||
|
|
||||||
= form_tag admin_follow_recommendations_path, method: 'GET', class: 'simple_form' do
|
= form_tag admin_follow_recommendations_path, method: 'GET', class: 'simple_form' do
|
||||||
|
- RelationshipFilter::KEYS.each do |key|
|
||||||
|
= hidden_field_tag key, params[key] if params[key].present?
|
||||||
|
|
||||||
.filters
|
.filters
|
||||||
.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 :language, options_for_select(I18n.available_locales.map { |key| key.to_s.split(/[_-]/).first.to_sym }.uniq.map { |key| [standard_locale_name(key), key]}, @language)
|
= select_tag :language, options_for_select(Trends.available_locales.map { |key| [standard_locale_name(key), key] }, @language)
|
||||||
|
|
||||||
.filter-subset
|
.filter-subset
|
||||||
%strong= t('admin.follow_recommendations.status')
|
%strong= t('admin.follow_recommendations.status')
|
||||||
%ul
|
%ul
|
||||||
|
|
|
@ -4,23 +4,29 @@
|
||||||
- content_for :header_tags do
|
- content_for :header_tags do
|
||||||
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
|
= javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
|
||||||
|
|
||||||
.filters
|
= form_tag admin_trends_links_path, method: 'GET', class: 'simple_form' do
|
||||||
.filter-subset
|
- Trends::PreviewCardFilter::KEYS.each do |key|
|
||||||
%strong= t('admin.trends.trending')
|
= hidden_field_tag key, params[key] if params[key].present?
|
||||||
%ul
|
|
||||||
%li= filter_link_to t('generic.all'), trending: nil
|
|
||||||
%li= filter_link_to t('admin.trends.only_allowed'), trending: 'allowed'
|
|
||||||
.back-link
|
|
||||||
= link_to admin_trends_links_preview_card_providers_path do
|
|
||||||
= t('admin.trends.preview_card_providers.title')
|
|
||||||
= fa_icon 'chevron-right fw'
|
|
||||||
|
|
||||||
%hr.spacer/
|
.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'
|
||||||
|
.back-link
|
||||||
|
= link_to admin_trends_links_preview_card_providers_path do
|
||||||
|
= t('admin.trends.preview_card_providers.title')
|
||||||
|
= fa_icon 'chevron-right fw'
|
||||||
|
|
||||||
= form_for(@form, url: batch_admin_trends_links_path) do |f|
|
= form_for(@form, url: batch_admin_trends_links_path) do |f|
|
||||||
= hidden_field_tag :page, params[:page] || 1
|
= hidden_field_tag :page, params[:page] || 1
|
||||||
|
|
||||||
- PreviewCardFilter::KEYS.each do |key|
|
- Trends::PreviewCardFilter::KEYS.each do |key|
|
||||||
= hidden_field_tag key, params[key] if params[key].present?
|
= hidden_field_tag key, params[key] if params[key].present?
|
||||||
|
|
||||||
.batch-table
|
.batch-table
|
||||||
|
@ -29,9 +35,9 @@
|
||||||
= check_box_tag :batch_checkbox_all, nil, false
|
= check_box_tag :batch_checkbox_all, nil, false
|
||||||
.batch-table__toolbar__actions
|
.batch-table__toolbar__actions
|
||||||
= f.button safe_join([fa_icon('check'), t('admin.trends.links.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.links.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.links.allow_provider')]), name: :approve_all, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
|
= f.button safe_join([fa_icon('check'), t('admin.trends.links.allow_provider')]), name: :approve_providers, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
|
||||||
= f.button safe_join([fa_icon('times'), t('admin.trends.links.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.links.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.links.disallow_provider')]), name: :reject_all, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
|
= f.button safe_join([fa_icon('times'), t('admin.trends.links.disallow_provider')]), name: :reject_providers, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
|
||||||
.batch-table__body
|
.batch-table__body
|
||||||
- if @preview_cards.empty?
|
- if @preview_cards.empty?
|
||||||
= nothing_here 'nothing-here--under-tabs'
|
= nothing_here 'nothing-here--under-tabs'
|
||||||
|
|
|
@ -23,7 +23,7 @@
|
||||||
= form_for(@form, url: batch_admin_trends_links_preview_card_providers_path) do |f|
|
= form_for(@form, url: batch_admin_trends_links_preview_card_providers_path) do |f|
|
||||||
= hidden_field_tag :page, params[:page] || 1
|
= hidden_field_tag :page, params[:page] || 1
|
||||||
|
|
||||||
- PreviewCardProviderFilter::KEYS.each do |key|
|
- Trends::PreviewCardProviderFilter::KEYS.each do |key|
|
||||||
= hidden_field_tag key, params[key] if params[key].present?
|
= hidden_field_tag key, params[key] if params[key].present?
|
||||||
|
|
||||||
.batch-table.optional
|
.batch-table.optional
|
||||||
|
|
30
app/views/admin/trends/statuses/_status.html.haml
Normal file
30
app/views/admin/trends/statuses/_status.html.haml
Normal file
|
@ -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')
|
43
app/views/admin/trends/statuses/index.html.haml
Normal file
43
app/views/admin/trends/statuses/index.html.haml
Normal file
|
@ -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
|
|
@ -13,12 +13,10 @@
|
||||||
%li= filter_link_to t('admin.trends.rejected'), status: 'rejected'
|
%li= filter_link_to t('admin.trends.rejected'), status: 'rejected'
|
||||||
%li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{Tag.pending_review.count})"], ' '), status: 'pending_review'
|
%li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{Tag.pending_review.count})"], ' '), status: 'pending_review'
|
||||||
|
|
||||||
%hr.spacer/
|
|
||||||
|
|
||||||
= form_for(@form, url: batch_admin_trends_tags_path) do |f|
|
= form_for(@form, url: batch_admin_trends_tags_path) do |f|
|
||||||
= hidden_field_tag :page, params[:page] || 1
|
= hidden_field_tag :page, params[:page] || 1
|
||||||
|
|
||||||
- TagFilter::KEYS.each do |key|
|
- Trends::TagFilter::KEYS.each do |key|
|
||||||
= hidden_field_tag key, params[key] if params[key].present?
|
= hidden_field_tag key, params[key] if params[key].present?
|
||||||
|
|
||||||
.batch-table.optional
|
.batch-table.optional
|
||||||
|
|
14
app/views/admin_mailer/_new_trending_links.text.erb
Normal file
14
app/views/admin_mailer/_new_trending_links.text.erb
Normal file
|
@ -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 %>
|
14
app/views/admin_mailer/_new_trending_statuses.text.erb
Normal file
14
app/views/admin_mailer/_new_trending_statuses.text.erb
Normal file
|
@ -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 %>
|
14
app/views/admin_mailer/_new_trending_tags.text.erb
Normal file
14
app/views/admin_mailer/_new_trending_tags.text.erb
Normal file
|
@ -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') %>
|
|
13
app/views/admin_mailer/new_trends.text.erb
Normal file
13
app/views/admin_mailer/new_trends.text.erb
Normal file
|
@ -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 %>
|
|
@ -6,7 +6,7 @@
|
||||||
%p= @instance_presenter.site_short_description.html_safe.presence || t('about.about_mastodon_html')
|
%p= @instance_presenter.site_short_description.html_safe.presence || t('about.about_mastodon_html')
|
||||||
|
|
||||||
- if Setting.trends && !(user_signed_in? && !current_user.setting_trends)
|
- if Setting.trends && !(user_signed_in? && !current_user.setting_trends)
|
||||||
- trends = Trends.tags.get(true, 3)
|
- trends = Trends.tags.query.allowed.limit(3)
|
||||||
|
|
||||||
- unless trends.empty?
|
- unless trends.empty?
|
||||||
.endorsements-widget.trends-widget
|
.endorsements-widget.trends-widget
|
||||||
|
|
|
@ -18,7 +18,7 @@ class Scheduler::FollowRecommendationsScheduler
|
||||||
|
|
||||||
fallback_recommendations = FollowRecommendation.order(rank: :desc).limit(SET_SIZE)
|
fallback_recommendations = FollowRecommendation.order(rank: :desc).limit(SET_SIZE)
|
||||||
|
|
||||||
I18n.available_locales.map { |locale| locale.to_s.split(/[_-]/).first }.uniq.each do |locale|
|
Trends.available_locales.each do |locale|
|
||||||
recommendations = begin
|
recommendations = begin
|
||||||
if AccountSummary.safe.filtered.localized(locale).exists? # We can skip the work if no accounts with that language exist
|
if AccountSummary.safe.filtered.localized(locale).exists? # We can skip the work if no accounts with that language exist
|
||||||
FollowRecommendation.localized(locale).order(rank: :desc).limit(SET_SIZE).map { |recommendation| [recommendation.account_id, recommendation.rank] }
|
FollowRecommendation.localized(locale).order(rank: :desc).limit(SET_SIZE).map { |recommendation| [recommendation.account_id, recommendation.rank] }
|
||||||
|
@ -49,11 +49,11 @@ class Scheduler::FollowRecommendationsScheduler
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
redis.pipelined do
|
redis.multi do |multi|
|
||||||
redis.del(key(locale))
|
multi.del(key(locale))
|
||||||
|
|
||||||
recommendations.each do |(account_id, rank)|
|
recommendations.each do |(account_id, rank)|
|
||||||
redis.zadd(key(locale), rank, account_id)
|
multi.zadd(key(locale), rank, account_id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
"check_name": "SQL",
|
"check_name": "SQL",
|
||||||
"message": "Possible SQL injection",
|
"message": "Possible SQL injection",
|
||||||
"file": "app/models/status.rb",
|
"file": "app/models/status.rb",
|
||||||
"line": 104,
|
"line": 105,
|
||||||
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
||||||
"code": "result.joins(\"INNER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}\")",
|
"code": "result.joins(\"INNER JOIN statuses_tags t#{id} ON t#{id}.status_id = statuses.id AND t#{id}.tag_id = #{id}\")",
|
||||||
"render_path": null,
|
"render_path": null,
|
||||||
|
@ -20,6 +20,26 @@
|
||||||
"confidence": "Weak",
|
"confidence": "Weak",
|
||||||
"note": ""
|
"note": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"warning_type": "SQL Injection",
|
||||||
|
"warning_code": 0,
|
||||||
|
"fingerprint": "30dfe36e87fe1b8f239df9a33d576e44a9863f73b680198d4713be6540ae61d3",
|
||||||
|
"check_name": "SQL",
|
||||||
|
"message": "Possible SQL injection",
|
||||||
|
"file": "app/models/trends/query.rb",
|
||||||
|
"line": 60,
|
||||||
|
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
||||||
|
"code": "klass.joins(\"join unnest(array[#{ids.join(\",\")}]) with ordinality as x (id, ordering) on #{klass.table_name}.id = x.id\")",
|
||||||
|
"render_path": null,
|
||||||
|
"location": {
|
||||||
|
"type": "method",
|
||||||
|
"class": "Trends::Query",
|
||||||
|
"method": "to_arel"
|
||||||
|
},
|
||||||
|
"user_input": "ids.join(\",\")",
|
||||||
|
"confidence": "Weak",
|
||||||
|
"note": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"warning_type": "Redirect",
|
"warning_type": "Redirect",
|
||||||
"warning_code": 18,
|
"warning_code": 18,
|
||||||
|
@ -100,26 +120,6 @@
|
||||||
"confidence": "High",
|
"confidence": "High",
|
||||||
"note": ""
|
"note": ""
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"warning_type": "SQL Injection",
|
|
||||||
"warning_code": 0,
|
|
||||||
"fingerprint": "8c1d8c4b76c1cd3960e90dff999f854a6ff742fcfd8de6c7184ac5a1b1a4d7dd",
|
|
||||||
"check_name": "SQL",
|
|
||||||
"message": "Possible SQL injection",
|
|
||||||
"file": "app/models/preview_card_filter.rb",
|
|
||||||
"line": 50,
|
|
||||||
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
|
||||||
"code": "PreviewCard.joins(\"join unnest(array[#{(Trends.links.currently_trending_ids(true, -1) or Trends.links.currently_trending_ids(false, -1)).map(&:to_i).join(\",\")}]::integer[]) with ordinality as x (id, ordering) on preview_cards.id = x.id\")",
|
|
||||||
"render_path": null,
|
|
||||||
"location": {
|
|
||||||
"type": "method",
|
|
||||||
"class": "PreviewCardFilter",
|
|
||||||
"method": "trending_scope"
|
|
||||||
},
|
|
||||||
"user_input": "(Trends.links.currently_trending_ids(true, -1) or Trends.links.currently_trending_ids(false, -1)).map(&:to_i).join(\",\")",
|
|
||||||
"confidence": "Medium",
|
|
||||||
"note": ""
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"warning_type": "Cross-Site Scripting",
|
"warning_type": "Cross-Site Scripting",
|
||||||
"warning_code": 2,
|
"warning_code": 2,
|
||||||
|
@ -134,7 +134,7 @@
|
||||||
{
|
{
|
||||||
"type": "template",
|
"type": "template",
|
||||||
"name": "admin/disputes/appeals/index",
|
"name": "admin/disputes/appeals/index",
|
||||||
"line": 16,
|
"line": 20,
|
||||||
"file": "app/views/admin/disputes/appeals/index.html.haml",
|
"file": "app/views/admin/disputes/appeals/index.html.haml",
|
||||||
"rendered": {
|
"rendered": {
|
||||||
"name": "admin/disputes/appeals/_appeal",
|
"name": "admin/disputes/appeals/_appeal",
|
||||||
|
@ -170,26 +170,6 @@
|
||||||
"confidence": "High",
|
"confidence": "High",
|
||||||
"note": ""
|
"note": ""
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"warning_type": "SQL Injection",
|
|
||||||
"warning_code": 0,
|
|
||||||
"fingerprint": "c32a484ccd9da46abd3bc93d08b72029d7dbc0576ccf4e878a9627e9a83cad2e",
|
|
||||||
"check_name": "SQL",
|
|
||||||
"message": "Possible SQL injection",
|
|
||||||
"file": "app/models/tag_filter.rb",
|
|
||||||
"line": 50,
|
|
||||||
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
|
||||||
"code": "Tag.joins(\"join unnest(array[#{Trends.tags.currently_trending_ids(false, -1).map(&:to_i).join(\",\")}]::integer[]) with ordinality as x (id, ordering) on tags.id = x.id\")",
|
|
||||||
"render_path": null,
|
|
||||||
"location": {
|
|
||||||
"type": "method",
|
|
||||||
"class": "TagFilter",
|
|
||||||
"method": "trending_scope"
|
|
||||||
},
|
|
||||||
"user_input": "Trends.tags.currently_trending_ids(false, -1).map(&:to_i).join(\",\")",
|
|
||||||
"confidence": "Medium",
|
|
||||||
"note": ""
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"warning_type": "Cross-Site Scripting",
|
"warning_type": "Cross-Site Scripting",
|
||||||
"warning_code": 4,
|
"warning_code": 4,
|
||||||
|
@ -204,7 +184,7 @@
|
||||||
{
|
{
|
||||||
"type": "template",
|
"type": "template",
|
||||||
"name": "admin/trends/links/index",
|
"name": "admin/trends/links/index",
|
||||||
"line": 39,
|
"line": 45,
|
||||||
"file": "app/views/admin/trends/links/index.html.haml",
|
"file": "app/views/admin/trends/links/index.html.haml",
|
||||||
"rendered": {
|
"rendered": {
|
||||||
"name": "admin/trends/links/_preview_card",
|
"name": "admin/trends/links/_preview_card",
|
||||||
|
@ -241,6 +221,6 @@
|
||||||
"note": ""
|
"note": ""
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"updated": "2022-02-13 02:24:12 +0100",
|
"updated": "2022-02-15 03:48:53 +0100",
|
||||||
"brakeman_version": "5.2.1"
|
"brakeman_version": "5.2.1"
|
||||||
}
|
}
|
||||||
|
|
|
@ -787,6 +787,15 @@ en:
|
||||||
rejected: Links from this publisher won't trend
|
rejected: Links from this publisher won't trend
|
||||||
title: Publishers
|
title: Publishers
|
||||||
rejected: Rejected
|
rejected: Rejected
|
||||||
|
statuses:
|
||||||
|
allow: Allow post
|
||||||
|
allow_account: Allow author
|
||||||
|
disallow: Disallow post
|
||||||
|
disallow_account: Disallow author
|
||||||
|
shared_by:
|
||||||
|
one: Shared or favourited one time
|
||||||
|
other: Shared and favourited %{friendly_count} times
|
||||||
|
title: Trending posts
|
||||||
tags:
|
tags:
|
||||||
current_score: Current score %{score}
|
current_score: Current score %{score}
|
||||||
dashboard:
|
dashboard:
|
||||||
|
@ -835,16 +844,21 @@ en:
|
||||||
body: "%{reporter} has reported %{target}"
|
body: "%{reporter} has reported %{target}"
|
||||||
body_remote: Someone from %{domain} has reported %{target}
|
body_remote: Someone from %{domain} has reported %{target}
|
||||||
subject: New report for %{instance} (#%{id})
|
subject: New report for %{instance} (#%{id})
|
||||||
new_trending_links:
|
new_trends:
|
||||||
body: The following links are trending today, but their publishers have not been previously reviewed. They will not be displayed publicly unless you approve them. Further notifications from the same publishers will not be generated.
|
body: 'The following items need a review before they can be displayed publicly:'
|
||||||
no_approved_links: There are currently no approved trending links.
|
new_trending_links:
|
||||||
requirements: The lowest approved trending link is currently "%{lowest_link_title}" with a score of %{lowest_link_score}.
|
no_approved_links: There are currently no approved trending links.
|
||||||
subject: New trending links up for review on %{instance}
|
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}.'
|
||||||
new_trending_tags:
|
title: Trending links
|
||||||
body: 'The following hashtags are trending today, but they have not been previously reviewed. They will not be displayed publicly unless you approve them:'
|
new_trending_statuses:
|
||||||
no_approved_tags: There are currently no approved trending hashtags.
|
no_approved_statuses: There are currently no approved trending posts.
|
||||||
requirements: 'The lowest approved trending hashtag is currently #%{lowest_tag_name} with a score of %{lowest_tag_score}.'
|
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}.'
|
||||||
subject: New trending hashtags up for review on %{instance}
|
title: Trending posts
|
||||||
|
new_trending_tags:
|
||||||
|
no_approved_tags: There are currently no approved trending hashtags.
|
||||||
|
requirements: 'Any of these candidates could surpass the #%{rank} approved trending hashtag, which is currently #%{lowest_tag_name} with a score of %{lowest_tag_score}.'
|
||||||
|
title: Trending hashtags
|
||||||
|
subject: New trends up for review on %{instance}
|
||||||
aliases:
|
aliases:
|
||||||
add_new: Create alias
|
add_new: Create alias
|
||||||
created_msg: Successfully created a new alias. You can now initiate the move from the old account.
|
created_msg: Successfully created a new alias. You can now initiate the move from the old account.
|
||||||
|
|
|
@ -34,6 +34,7 @@ SimpleNavigation::Configuration.run do |navigation|
|
||||||
n.item :development, safe_join([fa_icon('code fw'), t('settings.development')]), settings_applications_url, if: -> { current_user.functional? }
|
n.item :development, safe_join([fa_icon('code fw'), t('settings.development')]), settings_applications_url, if: -> { current_user.functional? }
|
||||||
|
|
||||||
n.item :trends, safe_join([fa_icon('fire fw'), t('admin.trends.title')]), admin_trends_tags_path, if: proc { current_user.staff? } do |s|
|
n.item :trends, safe_join([fa_icon('fire fw'), t('admin.trends.title')]), admin_trends_tags_path, if: proc { current_user.staff? } do |s|
|
||||||
|
s.item :statuses, safe_join([fa_icon('comments-o fw'), t('admin.trends.statuses.title')]), admin_trends_statuses_path, highlights_on: %r{/admin/trends/statuses}
|
||||||
s.item :tags, safe_join([fa_icon('hashtag fw'), t('admin.trends.tags.title')]), admin_trends_tags_path, highlights_on: %r{/admin/tags|/admin/trends/tags}
|
s.item :tags, safe_join([fa_icon('hashtag fw'), t('admin.trends.tags.title')]), admin_trends_tags_path, highlights_on: %r{/admin/tags|/admin/trends/tags}
|
||||||
s.item :links, safe_join([fa_icon('newspaper-o fw'), t('admin.trends.links.title')]), admin_trends_links_path, highlights_on: %r{/admin/trends/links}
|
s.item :links, safe_join([fa_icon('newspaper-o fw'), t('admin.trends.links.title')]), admin_trends_links_path, highlights_on: %r{/admin/trends/links}
|
||||||
end
|
end
|
||||||
|
|
|
@ -327,6 +327,12 @@ Rails.application.routes.draw do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
resources :statuses, only: [:index] do
|
||||||
|
collection do
|
||||||
|
post :batch
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
namespace :links do
|
namespace :links do
|
||||||
resources :preview_card_providers, only: [:index], path: :publishers do
|
resources :preview_card_providers, only: [:index], path: :publishers do
|
||||||
collection do
|
collection do
|
||||||
|
@ -448,6 +454,7 @@ Rails.application.routes.draw do
|
||||||
namespace :trends do
|
namespace :trends do
|
||||||
resources :links, only: [:index]
|
resources :links, only: [:index]
|
||||||
resources :tags, only: [:index]
|
resources :tags, only: [:index]
|
||||||
|
resources :statuses, only: [:index]
|
||||||
end
|
end
|
||||||
|
|
||||||
namespace :emails do
|
namespace :emails do
|
||||||
|
@ -554,6 +561,8 @@ Rails.application.routes.draw do
|
||||||
|
|
||||||
namespace :trends do
|
namespace :trends do
|
||||||
resources :tags, only: [:index]
|
resources :tags, only: [:index]
|
||||||
|
resources :links, only: [:index]
|
||||||
|
resources :statuses, only: [:index]
|
||||||
end
|
end
|
||||||
|
|
||||||
post :measures, to: 'measures#create'
|
post :measures, to: 'measures#create'
|
||||||
|
|
7
db/migrate/20220202200743_add_trendable_to_accounts.rb
Normal file
7
db/migrate/20220202200743_add_trendable_to_accounts.rb
Normal file
|
@ -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
|
5
db/migrate/20220202200926_add_trendable_to_statuses.rb
Normal file
5
db/migrate/20220202200926_add_trendable_to_statuses.rb
Normal file
|
@ -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
|
|
@ -177,13 +177,15 @@ ActiveRecord::Schema.define(version: 2022_02_24_010024) do
|
||||||
t.string "also_known_as", array: true
|
t.string "also_known_as", array: true
|
||||||
t.datetime "silenced_at"
|
t.datetime "silenced_at"
|
||||||
t.datetime "suspended_at"
|
t.datetime "suspended_at"
|
||||||
t.integer "trust_level"
|
|
||||||
t.boolean "hide_collections"
|
t.boolean "hide_collections"
|
||||||
t.integer "avatar_storage_schema_version"
|
t.integer "avatar_storage_schema_version"
|
||||||
t.integer "header_storage_schema_version"
|
t.integer "header_storage_schema_version"
|
||||||
t.string "devices_url"
|
t.string "devices_url"
|
||||||
t.integer "suspension_origin"
|
t.integer "suspension_origin"
|
||||||
t.datetime "sensitized_at"
|
t.datetime "sensitized_at"
|
||||||
|
t.boolean "trendable"
|
||||||
|
t.datetime "reviewed_at"
|
||||||
|
t.datetime "requested_review_at"
|
||||||
t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin
|
t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin
|
||||||
t.index "lower((username)::text), COALESCE(lower((domain)::text), ''::text)", name: "index_accounts_on_username_and_domain_lower", unique: true
|
t.index "lower((username)::text), COALESCE(lower((domain)::text), ''::text)", name: "index_accounts_on_username_and_domain_lower", unique: true
|
||||||
t.index ["moved_to_account_id"], name: "index_accounts_on_moved_to_account_id"
|
t.index ["moved_to_account_id"], name: "index_accounts_on_moved_to_account_id"
|
||||||
|
@ -887,6 +889,7 @@ ActiveRecord::Schema.define(version: 2022_02_24_010024) do
|
||||||
t.bigint "poll_id"
|
t.bigint "poll_id"
|
||||||
t.datetime "deleted_at"
|
t.datetime "deleted_at"
|
||||||
t.datetime "edited_at"
|
t.datetime "edited_at"
|
||||||
|
t.boolean "trendable"
|
||||||
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
|
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
|
||||||
t.index ["deleted_at"], name: "index_statuses_on_deleted_at", where: "(deleted_at IS NOT NULL)"
|
t.index ["deleted_at"], name: "index_statuses_on_deleted_at", where: "(deleted_at IS NOT NULL)"
|
||||||
t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
|
t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
|
||||||
|
@ -1228,5 +1231,4 @@ ActiveRecord::Schema.define(version: 2022_02_24_010024) do
|
||||||
ORDER BY (sum(t0.rank)) DESC;
|
ORDER BY (sum(t0.rank)) DESC;
|
||||||
SQL
|
SQL
|
||||||
add_index "follow_recommendations", ["account_id"], name: "index_follow_recommendations_on_account_id", unique: true
|
add_index "follow_recommendations", ["account_id"], name: "index_follow_recommendations_on_account_id", unique: true
|
||||||
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,10 +7,9 @@ RSpec.describe Api::V1::Trends::TagsController, type: :controller do
|
||||||
|
|
||||||
describe 'GET #index' do
|
describe 'GET #index' do
|
||||||
before do
|
before do
|
||||||
trending_tags = double()
|
Fabricate.times(10, :tag).each do |tag|
|
||||||
|
10.times { |i| Trends.tags.add(tag, i) }
|
||||||
allow(trending_tags).to receive(:get).and_return(Fabricate.times(10, :tag))
|
end
|
||||||
allow(Trends).to receive(:tags).and_return(trending_tags)
|
|
||||||
|
|
||||||
get :index
|
get :index
|
||||||
end
|
end
|
||||||
|
|
|
@ -6,14 +6,9 @@ class AdminMailerPreview < ActionMailer::Preview
|
||||||
AdminMailer.new_pending_account(Account.first, User.pending.first)
|
AdminMailer.new_pending_account(Account.first, User.pending.first)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_trending_tags
|
# Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_trends
|
||||||
def new_trending_tags
|
def new_trends
|
||||||
AdminMailer.new_trending_tags(Account.first, Tag.limit(3))
|
AdminMailer.new_trends(Account.first, PreviewCard.limit(3), Tag.limit(3), Status.where(reblog_of_id: nil).limit(3))
|
||||||
end
|
|
||||||
|
|
||||||
# Preview this email at http://localhost:3000/rails/mailers/admin_mailer/new_trending_links
|
|
||||||
def new_trending_links
|
|
||||||
AdminMailer.new_trending_links(Account.first, PreviewCard.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
|
||||||
|
|
110
spec/models/trends/statuses_spec.rb
Normal file
110
spec/models/trends/statuses_spec.rb
Normal file
|
@ -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
|
|
@ -21,7 +21,7 @@ RSpec.describe Trends::Tags do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#get' do
|
describe '#query' do
|
||||||
pending
|
pending
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -47,11 +47,11 @@ RSpec.describe Trends::Tags do
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'calculates and re-calculates scores' do
|
it 'calculates and re-calculates scores' do
|
||||||
expect(subject.get(false, 10)).to eq [tag1, tag3]
|
expect(subject.query.limit(10).to_a).to eq [tag1, tag3]
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'omits hashtags below threshold' do
|
it 'omits hashtags below threshold' do
|
||||||
expect(subject.get(false, 10)).to_not include(tag2)
|
expect(subject.query.limit(10).to_a).to_not include(tag2)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue