Skip to content

Commit 995b366

Browse files
Gargronhiyuki2578
authored andcommitted
Change admin UI for hashtags and add back whitelisted trends (mastodon#11490)
Fix mastodon#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`
1 parent 06eae7f commit 995b366

28 files changed

Lines changed: 258 additions & 173 deletions

app/controllers/admin/dashboard_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def index
2727
@saml_enabled = ENV['SAML_ENABLED'] == 'true'
2828
@pam_enabled = ENV['PAM_ENABLED'] == 'true'
2929
@hidden_service = ENV['ALLOW_ACCESS_TO_HIDDEN_SERVICE'] == 'true'
30-
@trending_hashtags = TrendingTags.get(7)
30+
@trending_hashtags = TrendingTags.get(10, filtered: false)
3131
@profile_directory = Setting.profile_directory
3232
@timeline_preview = Setting.timeline_preview
3333
@spam_check_enabled = Setting.spam_check_enabled

app/controllers/admin/tags_controller.rb

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,41 +4,49 @@ module Admin
44
class TagsController < BaseController
55
before_action :set_tags, only: :index
66
before_action :set_tag, except: :index
7-
before_action :set_filter_params
87

98
def index
109
authorize :tag, :index?
1110
end
1211

13-
def hide
14-
authorize @tag, :hide?
15-
@tag.account_tag_stat.update!(hidden: true)
16-
redirect_to admin_tags_path(@filter_params)
12+
def show
13+
authorize @tag, :show?
1714
end
1815

19-
def unhide
20-
authorize @tag, :unhide?
21-
@tag.account_tag_stat.update!(hidden: false)
22-
redirect_to admin_tags_path(@filter_params)
16+
def update
17+
authorize @tag, :update?
18+
19+
if @tag.update(tag_params.merge(reviewed_at: Time.now.utc))
20+
redirect_to admin_tag_path(@tag.id)
21+
else
22+
render :show
23+
end
2324
end
2425

2526
private
2627

2728
def set_tags
28-
@tags = Tag.discoverable
29-
@tags.merge!(Tag.hidden) if filter_params[:hidden]
29+
@tags = filtered_tags.page(params[:page])
3030
end
3131

3232
def set_tag
3333
@tag = Tag.find(params[:id])
3434
end
3535

36-
def set_filter_params
37-
@filter_params = filter_params.to_hash.symbolize_keys
36+
def filtered_tags
37+
scope = Tag
38+
scope = scope.discoverable if filter_params[:context] == 'directory'
39+
scope = scope.reviewed if filter_params[:review] == 'reviewed'
40+
scope = scope.pending_review if filter_params[:review] == 'pending_review'
41+
scope.reorder(score: :desc)
3842
end
3943

4044
def filter_params
41-
params.permit(:hidden)
45+
params.slice(:context, :review).permit(:context, :review)
46+
end
47+
48+
def tag_params
49+
params.require(:tag).permit(:name, :trendable, :usable, :listable)
4250
end
4351
end
4452
end
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# frozen_string_literal: true
2+
3+
class Api::V1::TrendsController < Api::BaseController
4+
before_action :set_tags
5+
6+
respond_to :json
7+
8+
def index
9+
render json: @tags, each_serializer: REST::TagSerializer
10+
end
11+
12+
private
13+
14+
def set_tags
15+
@tags = TrendingTags.get(limit_param(10))
16+
end
17+
end

app/controllers/settings/preferences_controller.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def user_settings_params
5656
:setting_advanced_layout,
5757
:setting_use_blurhash,
5858
:setting_use_pending_items,
59-
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account),
59+
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account trending_tag),
6060
interactions: %i(must_be_follower must_be_following must_be_following_dm)
6161
)
6262
end

app/helpers/admin/filter_helper.rb

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@ module Admin::FilterHelper
55
REPORT_FILTERS = %i(resolved account_id target_account_id).freeze
66
INVITE_FILTER = %i(available expired).freeze
77
CUSTOM_EMOJI_FILTERS = %i(local remote by_domain shortcode).freeze
8-
TAGS_FILTERS = %i(hidden).freeze
8+
TAGS_FILTERS = %i(context review).freeze
99
INSTANCES_FILTERS = %i(limited by_domain).freeze
1010
FOLLOWERS_FILTERS = %i(relationship status by_domain activity order).freeze
1111

1212
FILTERS = ACCOUNT_FILTERS + REPORT_FILTERS + INVITE_FILTER + CUSTOM_EMOJI_FILTERS + TAGS_FILTERS + INSTANCES_FILTERS + FOLLOWERS_FILTERS
1313

1414
def filter_link_to(text, link_to_params, link_class_params = link_to_params)
15-
new_url = filtered_url_for(link_to_params)
15+
new_url = filtered_url_for(link_to_params)
1616
new_class = filtered_url_for(link_class_params)
17+
1718
link_to text, new_url, class: filter_link_class(new_class)
1819
end
1920

app/mailers/admin_mailer.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,4 +24,14 @@ def new_pending_account(recipient, user)
2424
mail to: @me.user_email, subject: I18n.t('admin_mailer.new_pending_account.subject', instance: @instance, username: @account.username)
2525
end
2626
end
27+
28+
def new_trending_tag(recipient, tag)
29+
@tag = tag
30+
@me = recipient
31+
@instance = Rails.configuration.x.local_domain
32+
33+
locale_for_account(@me) do
34+
mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trending_tag.subject', instance: @instance, name: @tag.name)
35+
end
36+
end
2737
end

app/models/application_record.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,16 @@
22

33
class ApplicationRecord < ActiveRecord::Base
44
self.abstract_class = true
5+
56
include Remotable
7+
8+
def boolean_with_default(key, default_value)
9+
value = attributes[key]
10+
11+
if value.nil?
12+
default_value
13+
else
14+
value
15+
end
16+
end
617
end

app/models/tag.rb

Lines changed: 52 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,16 @@
33
#
44
# Table name: tags
55
#
6-
# id :bigint(8) not null, primary key
7-
# name :string default(""), not null
8-
# created_at :datetime not null
9-
# updated_at :datetime not null
10-
# score :integer
6+
# id :bigint(8) not null, primary key
7+
# name :string default(""), not null
8+
# created_at :datetime not null
9+
# updated_at :datetime not null
10+
# score :integer
11+
# usable :boolean
12+
# trendable :boolean
13+
# listable :boolean
14+
# reviewed_at :datetime
15+
# requested_review_at :datetime
1116
#
1217

1318
class Tag < ApplicationRecord
@@ -22,16 +27,17 @@ class Tag < ApplicationRecord
2227
HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i
2328

2429
validates :name, presence: true, format: { with: /\A(#{HASHTAG_NAME_RE})\z/i }
30+
validate :validate_name_change, if: -> { !new_record? && name_changed? }
2531

26-
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')) }
27-
scope :hidden, -> { where(account_tag_stats: { hidden: true }) }
32+
scope :reviewed, -> { where.not(reviewed_at: nil) }
33+
scope :pending_review, -> { where(reviewed_at: nil).where.not(requested_review_at: nil) }
34+
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')) }
2835
scope :most_used, ->(account) { joins(:statuses).where(statuses: { account: account }).group(:id).order(Arel.sql('count(*) desc')) }
2936

3037
delegate :accounts_count,
3138
:accounts_count=,
3239
:increment_count!,
3340
:decrement_count!,
34-
:hidden?,
3541
to: :account_tag_stat
3642

3743
after_save :save_account_tag_stat
@@ -48,6 +54,40 @@ def to_param
4854
name
4955
end
5056

57+
def usable
58+
boolean_with_default('usable', true)
59+
end
60+
61+
alias usable? usable
62+
63+
def listable
64+
boolean_with_default('listable', true)
65+
end
66+
67+
alias listable? listable
68+
69+
def trendable
70+
boolean_with_default('trendable', false)
71+
end
72+
73+
alias trendable? trendable
74+
75+
def requires_review?
76+
reviewed_at.nil?
77+
end
78+
79+
def reviewed?
80+
reviewed_at.present?
81+
end
82+
83+
def requested_review?
84+
requested_review_at.present?
85+
end
86+
87+
def trending?
88+
TrendingTags.trending?(self)
89+
end
90+
5191
def history
5292
days = []
5393

@@ -117,4 +157,8 @@ def save_account_tag_stat
117157
return unless account_tag_stat&.changed?
118158
account_tag_stat.save
119159
end
160+
161+
def validate_name_change
162+
errors.add(:name, I18n.t('tags.does_not_match_previous_name')) unless name_was.mb_chars.casecmp(name.mb_chars).zero?
163+
end
120164
end

app/models/trending_tags.rb

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,28 @@ class << self
1010
include Redisable
1111

1212
def record_use!(tag, account, at_time = Time.now.utc)
13-
return if disallowed_hashtags.include?(tag.name) || account.silenced? || account.bot?
13+
return if account.silenced? || account.bot? || !tag.usable? || !(tag.trendable? || tag.requires_review?)
1414

1515
increment_historical_use!(tag.id, at_time)
1616
increment_unique_use!(tag.id, account.id, at_time)
17-
increment_vote!(tag.id, at_time)
17+
increment_vote!(tag, at_time)
1818
end
1919

20-
def get(limit)
21-
key = "#{KEY}:#{Time.now.utc.beginning_of_day.to_i}"
22-
tag_ids = redis.zrevrange(key, 0, limit - 1).map(&:to_i)
23-
tags = Tag.where(id: tag_ids).to_a.each_with_object({}) { |tag, h| h[tag.id] = tag }
20+
def get(limit, filtered: true)
21+
tag_ids = redis.zrevrange("#{KEY}:#{Time.now.utc.beginning_of_day.to_i}", 0, limit - 1).map(&:to_i)
22+
23+
tags = Tag.where(id: tag_ids)
24+
tags = tags.where(trendable: true) if filtered
25+
tags = tags.each_with_object({}) { |tag, h| h[tag.id] = tag }
26+
2427
tag_ids.map { |tag_id| tags[tag_id] }.compact
2528
end
2629

30+
def trending?(tag)
31+
rank = redis.zrevrank("#{KEY}:#{Time.now.utc.beginning_of_day.to_i}", tag.id)
32+
rank.present? && rank <= 10
33+
end
34+
2735
private
2836

2937
def increment_historical_use!(tag_id, at_time)
@@ -38,33 +46,27 @@ def increment_unique_use!(tag_id, account_id, at_time)
3846
redis.expire(key, EXPIRE_HISTORY_AFTER)
3947
end
4048

41-
def increment_vote!(tag_id, at_time)
49+
def increment_vote!(tag, at_time)
4250
key = "#{KEY}:#{at_time.beginning_of_day.to_i}"
43-
expected = redis.pfcount("activity:tags:#{tag_id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts").to_f
51+
expected = redis.pfcount("activity:tags:#{tag.id}:#{(at_time - 1.day).beginning_of_day.to_i}:accounts").to_f
4452
expected = 1.0 if expected.zero?
45-
observed = redis.pfcount("activity:tags:#{tag_id}:#{at_time.beginning_of_day.to_i}:accounts").to_f
53+
observed = redis.pfcount("activity:tags:#{tag.id}:#{at_time.beginning_of_day.to_i}:accounts").to_f
4654

4755
if expected > observed || observed < THRESHOLD
48-
redis.zrem(key, tag_id.to_s)
56+
redis.zrem(key, tag.id)
4957
else
50-
score = ((observed - expected)**2) / expected
51-
added = redis.zadd(key, score, tag_id.to_s)
52-
bump_tag_score!(tag_id) if added
58+
score = ((observed - expected)**2) / expected
59+
old_rank = redis.zrevrank(key, tag.id)
60+
61+
redis.zadd(key, score, tag.id)
62+
request_review!(tag) if (old_rank.nil? || old_rank > 10) && redis.zrevrank(key, tag.id) <= 10 && !tag.trendable? && tag.requires_review? && !tag.requested_review?
5363
end
5464

5565
redis.expire(key, EXPIRE_TRENDS_AFTER)
5666
end
5767

58-
def bump_tag_score!(tag_id)
59-
Tag.where(id: tag_id).update_all('score = COALESCE(score, 0) + 1')
60-
end
61-
62-
def disallowed_hashtags
63-
return @disallowed_hashtags if defined?(@disallowed_hashtags)
64-
65-
@disallowed_hashtags = Setting.disallowed_hashtags.nil? ? [] : Setting.disallowed_hashtags
66-
@disallowed_hashtags = @disallowed_hashtags.split(' ') if @disallowed_hashtags.is_a? String
67-
@disallowed_hashtags = @disallowed_hashtags.map(&:downcase)
68+
def request_review!(tag)
69+
User.staff.includes(:account).find_each { |u| AdminMailer.new_trending_tag(u.account, tag).deliver_later! if u.allows_trending_tag_emails? }
6870
end
6971
end
7072
end

app/models/user.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,10 @@ def allows_pending_account_emails?
207207
settings.notification_emails['pending_account']
208208
end
209209

210+
def allows_trending_tag_emails?
211+
settings.notification_emails['trending_tag']
212+
end
213+
210214
def hides_network?
211215
@hides_network ||= settings.hide_network
212216
end

0 commit comments

Comments
 (0)