Change admin UI for hashtags and add back whitelisted trends (#11490)
Fix #271 Add back the `GET /api/v1/trends` API with the caveat that it does not return tags that have not been allowed to trend by the staff. When a hashtag begins to trend (internally) and that hashtag has not been previously reviewed by the staff, the staff is notified. The new admin UI for hashtags allows filtering hashtags by where they are used (e.g. in the profile directory), whether they have been reviewed or are pending reviewal, they show by how many people the hashtag is used in the directory, how many people used it today, how many statuses with it have been created today, and it allows fixing the name of the hashtag to make it more readable. The disallowed hashtags feature has been reworked. It is now controlled from the admin UI for hashtags instead of from the file `config/settings.yml`
This commit is contained in:
@ -27,7 +27,7 @@ module Admin
|
||||
@saml_enabled = ENV['SAML_ENABLED'] == 'true'
|
||||
@pam_enabled = ENV['PAM_ENABLED'] == 'true'
|
||||
@hidden_service = ENV['ALLOW_ACCESS_TO_HIDDEN_SERVICE'] == 'true'
|
||||
@trending_hashtags = TrendingTags.get(7)
|
||||
@trending_hashtags = TrendingTags.get(10, filtered: false)
|
||||
@profile_directory = Setting.profile_directory
|
||||
@timeline_preview = Setting.timeline_preview
|
||||
@spam_check_enabled = Setting.spam_check_enabled
|
||||
|
@ -4,41 +4,49 @@ module Admin
|
||||
class TagsController < BaseController
|
||||
before_action :set_tags, only: :index
|
||||
before_action :set_tag, except: :index
|
||||
before_action :set_filter_params
|
||||
|
||||
def index
|
||||
authorize :tag, :index?
|
||||
end
|
||||
|
||||
def hide
|
||||
authorize @tag, :hide?
|
||||
@tag.account_tag_stat.update!(hidden: true)
|
||||
redirect_to admin_tags_path(@filter_params)
|
||||
def show
|
||||
authorize @tag, :show?
|
||||
end
|
||||
|
||||
def unhide
|
||||
authorize @tag, :unhide?
|
||||
@tag.account_tag_stat.update!(hidden: false)
|
||||
redirect_to admin_tags_path(@filter_params)
|
||||
def update
|
||||
authorize @tag, :update?
|
||||
|
||||
if @tag.update(tag_params.merge(reviewed_at: Time.now.utc))
|
||||
redirect_to admin_tag_path(@tag.id)
|
||||
else
|
||||
render :show
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_tags
|
||||
@tags = Tag.discoverable
|
||||
@tags.merge!(Tag.hidden) if filter_params[:hidden]
|
||||
@tags = filtered_tags.page(params[:page])
|
||||
end
|
||||
|
||||
def set_tag
|
||||
@tag = Tag.find(params[:id])
|
||||
end
|
||||
|
||||
def set_filter_params
|
||||
@filter_params = filter_params.to_hash.symbolize_keys
|
||||
def filtered_tags
|
||||
scope = Tag
|
||||
scope = scope.discoverable if filter_params[:context] == 'directory'
|
||||
scope = scope.reviewed if filter_params[:review] == 'reviewed'
|
||||
scope = scope.pending_review if filter_params[:review] == 'pending_review'
|
||||
scope.reorder(score: :desc)
|
||||
end
|
||||
|
||||
def filter_params
|
||||
params.permit(:hidden)
|
||||
params.slice(:context, :review).permit(:context, :review)
|
||||
end
|
||||
|
||||
def tag_params
|
||||
params.require(:tag).permit(:name, :trendable, :usable, :listable)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
17
app/controllers/api/v1/trends_controller.rb
Normal file
17
app/controllers/api/v1/trends_controller.rb
Normal file
@ -0,0 +1,17 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::TrendsController < Api::BaseController
|
||||
before_action :set_tags
|
||||
|
||||
respond_to :json
|
||||
|
||||
def index
|
||||
render json: @tags, each_serializer: REST::TagSerializer
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_tags
|
||||
@tags = TrendingTags.get(limit_param(10))
|
||||
end
|
||||
end
|
@ -56,7 +56,7 @@ class Settings::PreferencesController < Settings::BaseController
|
||||
:setting_advanced_layout,
|
||||
:setting_use_blurhash,
|
||||
:setting_use_pending_items,
|
||||
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account),
|
||||
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account trending_tag),
|
||||
interactions: %i(must_be_follower must_be_following must_be_following_dm)
|
||||
)
|
||||
end
|
||||
|
@ -5,15 +5,16 @@ module Admin::FilterHelper
|
||||
REPORT_FILTERS = %i(resolved account_id target_account_id).freeze
|
||||
INVITE_FILTER = %i(available expired).freeze
|
||||
CUSTOM_EMOJI_FILTERS = %i(local remote by_domain shortcode).freeze
|
||||
TAGS_FILTERS = %i(hidden).freeze
|
||||
TAGS_FILTERS = %i(context review).freeze
|
||||
INSTANCES_FILTERS = %i(limited by_domain).freeze
|
||||
FOLLOWERS_FILTERS = %i(relationship status by_domain activity order).freeze
|
||||
|
||||
FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS + INVITE_FILTER + CUSTOM_EMOJI_FILTERS + TAGS_FILTERS + INSTANCES_FILTERS + FOLLOWERS_FILTERS
|
||||
|
||||
def filter_link_to(text, link_to_params, link_class_params = link_to_params)
|
||||
new_url = filtered_url_for(link_to_params)
|
||||
new_url = filtered_url_for(link_to_params)
|
||||
new_class = filtered_url_for(link_class_params)
|
||||
|
||||
link_to text, new_url, class: filter_link_class(new_class)
|
||||
end
|
||||
|
||||
|
@ -24,4 +24,14 @@ class AdminMailer < ApplicationMailer
|
||||
mail to: @me.user_email, subject: I18n.t('admin_mailer.new_pending_account.subject', instance: @instance, username: @account.username)
|
||||
end
|
||||
end
|
||||
|
||||
def new_trending_tag(recipient, tag)
|
||||
@tag = tag
|
||||
@me = recipient
|
||||
@instance = Rails.configuration.x.local_domain
|
||||
|
||||
locale_for_account(@me) do
|
||||
mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trending_tag.subject', instance: @instance, name: @tag.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -2,5 +2,16 @@
|
||||
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
self.abstract_class = true
|
||||
|
||||
include Remotable
|
||||
|
||||
def boolean_with_default(key, default_value)
|
||||
value = attributes[key]
|
||||
|
||||
if value.nil?
|
||||
default_value
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -3,11 +3,16 @@
|
||||
#
|
||||
# Table name: tags
|
||||
#
|
||||
# id :bigint(8) not null, primary key
|
||||
# name :string default(""), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# score :integer
|
||||
# id :bigint(8) not null, primary key
|
||||
# name :string default(""), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# score :integer
|
||||
# usable :boolean
|
||||
# trendable :boolean
|
||||
# listable :boolean
|
||||
# reviewed_at :datetime
|
||||
# requested_review_at :datetime
|
||||
#
|
||||
|
||||
class Tag < ApplicationRecord
|
||||
@ -22,16 +27,17 @@ class Tag < ApplicationRecord
|
||||
HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i
|
||||
|
||||
validates :name, presence: true, format: { with: /\A(#{HASHTAG_NAME_RE})\z/i }
|
||||
validate :validate_name_change, if: -> { !new_record? && name_changed? }
|
||||
|
||||
scope :discoverable, -> { joins(:account_tag_stat).where(AccountTagStat.arel_table[:accounts_count].gt(0)).where(account_tag_stats: { hidden: false }).order(Arel.sql('account_tag_stats.accounts_count desc')) }
|
||||
scope :hidden, -> { where(account_tag_stats: { hidden: true }) }
|
||||
scope :reviewed, -> { where.not(reviewed_at: nil) }
|
||||
scope :pending_review, -> { where(reviewed_at: nil).where.not(requested_review_at: nil) }
|
||||
scope :discoverable, -> { where.not(listable: false).joins(:account_tag_stat).where(AccountTagStat.arel_table[:accounts_count].gt(0)).order(Arel.sql('account_tag_stats.accounts_count desc')) }
|
||||
scope :most_used, ->(account) { joins(:statuses).where(statuses: { account: account }).group(:id).order(Arel.sql('count(*) desc')) }
|
||||
|
||||
delegate :accounts_count,
|
||||
:accounts_count=,
|
||||
:increment_count!,
|
||||
:decrement_count!,
|
||||
:hidden?,
|
||||
to: :account_tag_stat
|
||||
|
||||
after_save :save_account_tag_stat
|
||||
@ -48,6 +54,40 @@ class Tag < ApplicationRecord
|
||||
name
|
||||
end
|
||||
|
||||
def usable
|
||||
boolean_with_default('usable', true)
|
||||
end
|
||||
|
||||
alias usable? usable
|
||||
|
||||
def listable
|
||||
boolean_with_default('listable', true)
|
||||
end
|
||||
|
||||
alias listable? listable
|
||||
|
||||
def trendable
|
||||
boolean_with_default('trendable', false)
|
||||
end
|
||||
|
||||
alias trendable? trendable
|
||||
|
||||
def requires_review?
|
||||
reviewed_at.nil?
|
||||
end
|
||||
|
||||
def reviewed?
|
||||
reviewed_at.present?
|
||||
end
|
||||
|
||||
def requested_review?
|
||||
requested_review_at.present?
|
||||
end
|
||||
|
||||
def trending?
|
||||
TrendingTags.trending?(self)
|
||||
end
|
||||
|
||||
def history
|
||||
days = []
|
||||
|
||||
@ -117,4 +157,8 @@ class Tag < ApplicationRecord
|
||||
return unless account_tag_stat&.changed?
|
||||
account_tag_stat.save
|
||||
end
|
||||
|
||||
def validate_name_change
|
||||
errors.add(:name, I18n.t('tags.does_not_match_previous_name')) unless name_was.mb_chars.casecmp(name.mb_chars).zero?
|
||||
end
|
||||
end
|
||||
|
@ -10,20 +10,28 @@ class TrendingTags
|
||||
include Redisable
|
||||
|
||||
def record_use!(tag, account, at_time = Time.now.utc)
|
||||
return if disallowed_hashtags.include?(tag.name) || account.silenced? || account.bot?
|
||||
return if account.silenced? || account.bot? || !tag.usable? || !(tag.trendable? || tag.requires_review?)
|
||||
|
||||
increment_historical_use!(tag.id, at_time)
|
||||
increment_unique_use!(tag.id, account.id, at_time)
|
||||
increment_vote!(tag.id, at_time)
|
||||
increment_vote!(tag, at_time)
|
||||
end
|
||||
|
||||
def get(limit)
|
||||
key = "#{KEY}:#{Time.now.utc.beginning_of_day.to_i}"
|
||||
tag_ids = redis.zrevrange(key, 0, limit - 1).map(&:to_i)
|
||||
tags = Tag.where(id: tag_ids).to_a.each_with_object({}) { |tag, h| h[tag.id] = tag }
|
||||
def get(limit, filtered: true)
|
||||
tag_ids = redis.zrevrange("#{KEY}:#{Time.now.utc.beginning_of_day.to_i}", 0, limit - 1).map(&:to_i)
|
||||
|
||||
tags = Tag.where(id: tag_ids)
|
||||
tags = tags.where(trendable: true) if filtered
|
||||
tags = tags.each_with_object({}) { |tag, h| h[tag.id] = tag }
|
||||
|
||||
tag_ids.map { |tag_id| tags[tag_id] }.compact
|
||||
end
|
||||
|
||||
def trending?(tag)
|
||||
rank = redis.zrevrank("#{KEY}:#{Time.now.utc.beginning_of_day.to_i}", tag.id)
|
||||
rank.present? && rank <= 10
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def increment_historical_use!(tag_id, at_time)
|
||||
@ -38,33 +46,27 @@ class TrendingTags
|
||||
redis.expire(key, EXPIRE_HISTORY_AFTER)
|
||||
end
|
||||
|
||||
def increment_vote!(tag_id, at_time)
|
||||
def increment_vote!(tag, at_time)
|
||||
key = "#{KEY}:#{at_time.beginning_of_day.to_i}"
|
||||
expected = redis.pfcount("activity:tags:#{tag_id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts").to_f
|
||||
expected = redis.pfcount("activity:tags:#{tag.id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts").to_f
|
||||
expected = 1.0 if expected.zero?
|
||||
observed = redis.pfcount("activity:tags:#{tag_id}:#{at_time.beginning_of_day.to_i}:accounts").to_f
|
||||
observed = redis.pfcount("activity:tags:#{tag.id}:#{at_time.beginning_of_day.to_i}:accounts").to_f
|
||||
|
||||
if expected > observed || observed < THRESHOLD
|
||||
redis.zrem(key, tag_id.to_s)
|
||||
redis.zrem(key, tag.id)
|
||||
else
|
||||
score = ((observed - expected)**2) / expected
|
||||
added = redis.zadd(key, score, tag_id.to_s)
|
||||
bump_tag_score!(tag_id) if added
|
||||
score = ((observed - expected)**2) / expected
|
||||
old_rank = redis.zrevrank(key, tag.id)
|
||||
|
||||
redis.zadd(key, score, tag.id)
|
||||
request_review!(tag) if (old_rank.nil? || old_rank > 10) && redis.zrevrank(key, tag.id) <= 10 && !tag.trendable? && tag.requires_review? && !tag.requested_review?
|
||||
end
|
||||
|
||||
redis.expire(key, EXPIRE_TRENDS_AFTER)
|
||||
end
|
||||
|
||||
def bump_tag_score!(tag_id)
|
||||
Tag.where(id: tag_id).update_all('score = COALESCE(score, 0) + 1')
|
||||
end
|
||||
|
||||
def disallowed_hashtags
|
||||
return @disallowed_hashtags if defined?(@disallowed_hashtags)
|
||||
|
||||
@disallowed_hashtags = Setting.disallowed_hashtags.nil? ? [] : Setting.disallowed_hashtags
|
||||
@disallowed_hashtags = @disallowed_hashtags.split(' ') if @disallowed_hashtags.is_a? String
|
||||
@disallowed_hashtags = @disallowed_hashtags.map(&:downcase)
|
||||
def request_review!(tag)
|
||||
User.staff.includes(:account).find_each { |u| AdminMailer.new_trending_tag(u.account, tag).deliver_later! if u.allows_trending_tag_emails? }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -207,6 +207,10 @@ class User < ApplicationRecord
|
||||
settings.notification_emails['pending_account']
|
||||
end
|
||||
|
||||
def allows_trending_tag_emails?
|
||||
settings.notification_emails['trending_tag']
|
||||
end
|
||||
|
||||
def hides_network?
|
||||
@hides_network ||= settings.hide_network
|
||||
end
|
||||
|
@ -5,11 +5,11 @@ class TagPolicy < ApplicationPolicy
|
||||
staff?
|
||||
end
|
||||
|
||||
def hide?
|
||||
def show?
|
||||
staff?
|
||||
end
|
||||
|
||||
def unhide?
|
||||
def update?
|
||||
staff?
|
||||
end
|
||||
end
|
||||
|
@ -4,24 +4,7 @@ class DisallowedHashtagsValidator < ActiveModel::Validator
|
||||
def validate(status)
|
||||
return unless status.local? && !status.reblog?
|
||||
|
||||
@status = status
|
||||
tags = select_tags
|
||||
|
||||
status.errors.add(:text, I18n.t('statuses.disallowed_hashtags', tags: tags.join(', '), count: tags.size)) unless tags.empty?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def select_tags
|
||||
tags = Extractor.extract_hashtags(@status.text)
|
||||
tags.keep_if { |tag| disallowed_hashtags.include? tag.downcase }
|
||||
end
|
||||
|
||||
def disallowed_hashtags
|
||||
return @disallowed_hashtags if @disallowed_hashtags
|
||||
|
||||
@disallowed_hashtags = Setting.disallowed_hashtags.nil? ? [] : Setting.disallowed_hashtags
|
||||
@disallowed_hashtags = @disallowed_hashtags.split(' ') if @disallowed_hashtags.is_a? String
|
||||
@disallowed_hashtags = @disallowed_hashtags.map(&:downcase)
|
||||
disallowed_hashtags = Tag.matching_name(Extractor.extract_hashtags(status.text)).reject(&:usable?)
|
||||
status.errors.add(:text, I18n.t('statuses.disallowed_hashtags', tags: disallowed_hashtags.map(&:name).join(', '), count: disallowed_hashtags.size)) unless disallowed_hashtags.empty?
|
||||
end
|
||||
end
|
||||
|
@ -107,5 +107,5 @@
|
||||
%ul
|
||||
- @trending_hashtags.each do |tag|
|
||||
%li
|
||||
= link_to "##{tag.name}", web_url("timelines/tag/#{tag.name}")
|
||||
= link_to content_tag(:span, "##{tag.name}", class: !tag.trendable? && !tag.reviewed? ? 'warning-hint' : (!tag.trendable? ? 'negative-hint' : nil)), admin_tag_path(tag.id)
|
||||
%span.pull-right= number_with_delimiter(tag.history[0][:accounts].to_i)
|
||||
|
@ -1,12 +1,16 @@
|
||||
%tr
|
||||
%td
|
||||
= link_to explore_hashtag_path(tag) do
|
||||
.directory__tag
|
||||
= link_to admin_tag_path(tag.id) do
|
||||
%h4
|
||||
= fa_icon 'hashtag'
|
||||
= tag.name
|
||||
%td
|
||||
= t('directories.people', count: tag.accounts_count)
|
||||
%td
|
||||
- if tag.hidden?
|
||||
= table_link_to 'eye', t('admin.tags.unhide'), unhide_admin_tag_path(tag.id, **@filter_params), method: :post
|
||||
- else
|
||||
= table_link_to 'eye-slash', t('admin.tags.hide'), hide_admin_tag_path(tag.id, **@filter_params), method: :post
|
||||
|
||||
%small
|
||||
= t('admin.tags.in_directory', count: tag.accounts_count)
|
||||
•
|
||||
= t('admin.tags.unique_uses_today', count: tag.history.first[:accounts])
|
||||
|
||||
- if tag.trending?
|
||||
= fa_icon 'fire fw'
|
||||
= t('admin.tags.trending_right_now')
|
||||
|
||||
.trends__item__current= number_to_human tag.history.first[:uses], strip_insignificant_zeros: true
|
||||
|
@ -3,17 +3,19 @@
|
||||
|
||||
.filters
|
||||
.filter-subset
|
||||
%strong= t('admin.reports.status')
|
||||
%strong= t('admin.tags.context')
|
||||
%ul
|
||||
%li= filter_link_to t('admin.tags.visible'), hidden: nil
|
||||
%li= filter_link_to t('admin.tags.hidden'), hidden: '1'
|
||||
%li= filter_link_to t('generic.all'), context: nil
|
||||
%li= filter_link_to t('admin.tags.directory'), context: 'directory'
|
||||
|
||||
.table-wrapper
|
||||
%table.table
|
||||
%thead
|
||||
%tr
|
||||
%th= t('admin.tags.name')
|
||||
%th= t('admin.tags.accounts')
|
||||
%th
|
||||
%tbody
|
||||
= render @tags
|
||||
.filter-subset
|
||||
%strong= t('admin.tags.review')
|
||||
%ul
|
||||
%li= filter_link_to t('generic.all'), review: nil
|
||||
%li= filter_link_to t('admin.tags.reviewed'), review: 'reviewed'
|
||||
%li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{Tag.pending_review.count})"], ' '), review: 'pending_review'
|
||||
|
||||
%hr.spacer/
|
||||
|
||||
= render @tags
|
||||
= paginate @tags
|
||||
|
16
app/views/admin/tags/show.html.haml
Normal file
16
app/views/admin/tags/show.html.haml
Normal file
@ -0,0 +1,16 @@
|
||||
- content_for :page_title do
|
||||
= "##{@tag.name}"
|
||||
|
||||
= simple_form_for @tag, url: admin_tag_path(@tag.id) do |f|
|
||||
= render 'shared/error_messages', object: @tag
|
||||
|
||||
.fields-group
|
||||
= f.input :name, wrapper: :with_block_label
|
||||
|
||||
.fields-group
|
||||
= f.input :usable, as: :boolean, wrapper: :with_label
|
||||
= f.input :trendable, as: :boolean, wrapper: :with_label
|
||||
= f.input :listable, as: :boolean, wrapper: :with_label
|
||||
|
||||
.actions
|
||||
= f.button :button, t('generic.save_changes'), type: :submit
|
5
app/views/admin_mailer/new_trending_tag.text.erb
Normal file
5
app/views/admin_mailer/new_trending_tag.text.erb
Normal file
@ -0,0 +1,5 @@
|
||||
<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
|
||||
|
||||
<%= raw t('admin_mailer.new_trending_tag.body', name: @tag.name) %>
|
||||
|
||||
<%= raw t('application_mailer.view')%> <%= admin_tags_url(review: 'pending_review') %>
|
@ -15,6 +15,7 @@
|
||||
- if current_user.staff?
|
||||
= ff.input :report, as: :boolean, wrapper: :with_label
|
||||
= ff.input :pending_account, as: :boolean, wrapper: :with_label
|
||||
= ff.input :trending_tag, as: :boolean, wrapper: :with_label
|
||||
|
||||
.fields-group
|
||||
= f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff|
|
||||
|
Reference in New Issue
Block a user