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:
Eugen Rochko 2019-08-05 19:54:29 +02:00 committed by GitHub
parent 6201bfdfba
commit 115dab78f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 258 additions and 173 deletions

View File

@ -27,7 +27,7 @@ module Admin
@saml_enabled = ENV['SAML_ENABLED'] == 'true' @saml_enabled = ENV['SAML_ENABLED'] == 'true'
@pam_enabled = ENV['PAM_ENABLED'] == 'true' @pam_enabled = ENV['PAM_ENABLED'] == 'true'
@hidden_service = ENV['ALLOW_ACCESS_TO_HIDDEN_SERVICE'] == '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 @profile_directory = Setting.profile_directory
@timeline_preview = Setting.timeline_preview @timeline_preview = Setting.timeline_preview
@spam_check_enabled = Setting.spam_check_enabled @spam_check_enabled = Setting.spam_check_enabled

View File

@ -4,41 +4,49 @@ module Admin
class TagsController < BaseController class TagsController < BaseController
before_action :set_tags, only: :index before_action :set_tags, only: :index
before_action :set_tag, except: :index before_action :set_tag, except: :index
before_action :set_filter_params
def index def index
authorize :tag, :index? authorize :tag, :index?
end end
def hide def show
authorize @tag, :hide? authorize @tag, :show?
@tag.account_tag_stat.update!(hidden: true)
redirect_to admin_tags_path(@filter_params)
end end
def unhide def update
authorize @tag, :unhide? authorize @tag, :update?
@tag.account_tag_stat.update!(hidden: false)
redirect_to admin_tags_path(@filter_params) if @tag.update(tag_params.merge(reviewed_at: Time.now.utc))
redirect_to admin_tag_path(@tag.id)
else
render :show
end
end end
private private
def set_tags def set_tags
@tags = Tag.discoverable @tags = filtered_tags.page(params[:page])
@tags.merge!(Tag.hidden) if filter_params[:hidden]
end end
def set_tag def set_tag
@tag = Tag.find(params[:id]) @tag = Tag.find(params[:id])
end end
def set_filter_params def filtered_tags
@filter_params = filter_params.to_hash.symbolize_keys 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 end
def filter_params 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 end
end end

View 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

View File

@ -56,7 +56,7 @@ class Settings::PreferencesController < Settings::BaseController
:setting_advanced_layout, :setting_advanced_layout,
:setting_use_blurhash, :setting_use_blurhash,
:setting_use_pending_items, :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) interactions: %i(must_be_follower must_be_following must_be_following_dm)
) )
end end

View File

@ -5,15 +5,16 @@ module Admin::FilterHelper
REPORT_FILTERS = %i(resolved account_id target_account_id).freeze REPORT_FILTERS = %i(resolved account_id target_account_id).freeze
INVITE_FILTER = %i(available expired).freeze INVITE_FILTER = %i(available expired).freeze
CUSTOM_EMOJI_FILTERS = %i(local remote by_domain shortcode).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 INSTANCES_FILTERS = %i(limited by_domain).freeze
FOLLOWERS_FILTERS = %i(relationship status by_domain activity order).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 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) 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) new_class = filtered_url_for(link_class_params)
link_to text, new_url, class: filter_link_class(new_class) link_to text, new_url, class: filter_link_class(new_class)
end end

View File

@ -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) mail to: @me.user_email, subject: I18n.t('admin_mailer.new_pending_account.subject', instance: @instance, username: @account.username)
end end
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 end

View File

@ -2,5 +2,16 @@
class ApplicationRecord < ActiveRecord::Base class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true self.abstract_class = true
include Remotable include Remotable
def boolean_with_default(key, default_value)
value = attributes[key]
if value.nil?
default_value
else
value
end
end
end end

View File

@ -3,11 +3,16 @@
# #
# Table name: tags # Table name: tags
# #
# id :bigint(8) not null, primary key # id :bigint(8) not null, primary key
# name :string default(""), not null # name :string default(""), not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# score :integer # score :integer
# usable :boolean
# trendable :boolean
# listable :boolean
# reviewed_at :datetime
# requested_review_at :datetime
# #
class Tag < ApplicationRecord class Tag < ApplicationRecord
@ -22,16 +27,17 @@ class Tag < ApplicationRecord
HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i
validates :name, presence: true, format: { with: /\A(#{HASHTAG_NAME_RE})\z/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 :reviewed, -> { where.not(reviewed_at: nil) }
scope :hidden, -> { where(account_tag_stats: { hidden: true }) } 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')) } scope :most_used, ->(account) { joins(:statuses).where(statuses: { account: account }).group(:id).order(Arel.sql('count(*) desc')) }
delegate :accounts_count, delegate :accounts_count,
:accounts_count=, :accounts_count=,
:increment_count!, :increment_count!,
:decrement_count!, :decrement_count!,
:hidden?,
to: :account_tag_stat to: :account_tag_stat
after_save :save_account_tag_stat after_save :save_account_tag_stat
@ -48,6 +54,40 @@ class Tag < ApplicationRecord
name name
end 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 def history
days = [] days = []
@ -117,4 +157,8 @@ class Tag < ApplicationRecord
return unless account_tag_stat&.changed? return unless account_tag_stat&.changed?
account_tag_stat.save account_tag_stat.save
end 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 end

View File

@ -10,20 +10,28 @@ class TrendingTags
include Redisable include Redisable
def record_use!(tag, account, at_time = Time.now.utc) 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_historical_use!(tag.id, at_time)
increment_unique_use!(tag.id, account.id, at_time) increment_unique_use!(tag.id, account.id, at_time)
increment_vote!(tag.id, at_time) increment_vote!(tag, at_time)
end end
def get(limit) def get(limit, filtered: true)
key = "#{KEY}:#{Time.now.utc.beginning_of_day.to_i}" tag_ids = redis.zrevrange("#{KEY}:#{Time.now.utc.beginning_of_day.to_i}", 0, limit - 1).map(&: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 } 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 tag_ids.map { |tag_id| tags[tag_id] }.compact
end end
def trending?(tag)
rank = redis.zrevrank("#{KEY}:#{Time.now.utc.beginning_of_day.to_i}", tag.id)
rank.present? && rank <= 10
end
private private
def increment_historical_use!(tag_id, at_time) def increment_historical_use!(tag_id, at_time)
@ -38,33 +46,27 @@ class TrendingTags
redis.expire(key, EXPIRE_HISTORY_AFTER) redis.expire(key, EXPIRE_HISTORY_AFTER)
end end
def increment_vote!(tag_id, at_time) def increment_vote!(tag, at_time)
key = "#{KEY}:#{at_time.beginning_of_day.to_i}" 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? 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 if expected > observed || observed < THRESHOLD
redis.zrem(key, tag_id.to_s) redis.zrem(key, tag.id)
else else
score = ((observed - expected)**2) / expected score = ((observed - expected)**2) / expected
added = redis.zadd(key, score, tag_id.to_s) old_rank = redis.zrevrank(key, tag.id)
bump_tag_score!(tag_id) if added
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 end
redis.expire(key, EXPIRE_TRENDS_AFTER) redis.expire(key, EXPIRE_TRENDS_AFTER)
end end
def bump_tag_score!(tag_id) def request_review!(tag)
Tag.where(id: tag_id).update_all('score = COALESCE(score, 0) + 1') User.staff.includes(:account).find_each { |u| AdminMailer.new_trending_tag(u.account, tag).deliver_later! if u.allows_trending_tag_emails? }
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)
end end
end end
end end

View File

@ -207,6 +207,10 @@ class User < ApplicationRecord
settings.notification_emails['pending_account'] settings.notification_emails['pending_account']
end end
def allows_trending_tag_emails?
settings.notification_emails['trending_tag']
end
def hides_network? def hides_network?
@hides_network ||= settings.hide_network @hides_network ||= settings.hide_network
end end

View File

@ -5,11 +5,11 @@ class TagPolicy < ApplicationPolicy
staff? staff?
end end
def hide? def show?
staff? staff?
end end
def unhide? def update?
staff? staff?
end end
end end

View File

@ -4,24 +4,7 @@ class DisallowedHashtagsValidator < ActiveModel::Validator
def validate(status) def validate(status)
return unless status.local? && !status.reblog? return unless status.local? && !status.reblog?
@status = status disallowed_hashtags = Tag.matching_name(Extractor.extract_hashtags(status.text)).reject(&:usable?)
tags = select_tags status.errors.add(:text, I18n.t('statuses.disallowed_hashtags', tags: disallowed_hashtags.map(&:name).join(', '), count: disallowed_hashtags.size)) unless disallowed_hashtags.empty?
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)
end end
end end

View File

@ -107,5 +107,5 @@
%ul %ul
- @trending_hashtags.each do |tag| - @trending_hashtags.each do |tag|
%li %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) %span.pull-right= number_with_delimiter(tag.history[0][:accounts].to_i)

View File

@ -1,12 +1,16 @@
%tr .directory__tag
%td = link_to admin_tag_path(tag.id) do
= link_to explore_hashtag_path(tag) do %h4
= fa_icon 'hashtag' = fa_icon 'hashtag'
= tag.name = tag.name
%td
= t('directories.people', count: tag.accounts_count) %small
%td = t('admin.tags.in_directory', count: tag.accounts_count)
- if tag.hidden? &bull;
= table_link_to 'eye', t('admin.tags.unhide'), unhide_admin_tag_path(tag.id, **@filter_params), method: :post = t('admin.tags.unique_uses_today', count: tag.history.first[:accounts])
- else
= table_link_to 'eye-slash', t('admin.tags.hide'), hide_admin_tag_path(tag.id, **@filter_params), method: :post - 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

View File

@ -3,17 +3,19 @@
.filters .filters
.filter-subset .filter-subset
%strong= t('admin.reports.status') %strong= t('admin.tags.context')
%ul %ul
%li= filter_link_to t('admin.tags.visible'), hidden: nil %li= filter_link_to t('generic.all'), context: nil
%li= filter_link_to t('admin.tags.hidden'), hidden: '1' %li= filter_link_to t('admin.tags.directory'), context: 'directory'
.table-wrapper .filter-subset
%table.table %strong= t('admin.tags.review')
%thead %ul
%tr %li= filter_link_to t('generic.all'), review: nil
%th= t('admin.tags.name') %li= filter_link_to t('admin.tags.reviewed'), review: 'reviewed'
%th= t('admin.tags.accounts') %li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{Tag.pending_review.count})"], ' '), review: 'pending_review'
%th
%tbody %hr.spacer/
= render @tags
= render @tags
= paginate @tags

View 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

View 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') %>

View File

@ -15,6 +15,7 @@
- if current_user.staff? - if current_user.staff?
= ff.input :report, as: :boolean, wrapper: :with_label = ff.input :report, as: :boolean, wrapper: :with_label
= ff.input :pending_account, 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 .fields-group
= f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff| = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff|

View File

@ -483,13 +483,14 @@ en:
title: Account statuses title: Account statuses
with_media: With media with_media: With media
tags: tags:
accounts: Accounts context: Context
hidden: Hidden directory: In directory
hide: Hide from directory in_directory: "%{count} in directory"
name: Hashtag review: Review status
reviewed: Reviewed
title: Hashtags title: Hashtags
unhide: Show in directory trending_right_now: Trending right now
visible: Visible unique_uses_today: "%{count} posting today"
title: Administration title: Administration
warning_presets: warning_presets:
add_new: Add new add_new: Add new
@ -505,6 +506,9 @@ 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_tag:
body: 'The hashtag #%{name} is trending today, but has not been previously reviewed. It will not be displayed publicly unless you allow it to, or just save the form as it is to never hear about it again.'
subject: New hashtag up for review on %{instance} (#%{name})
appearance: appearance:
advanced_web_interface: Advanced web interface advanced_web_interface: Advanced web interface
advanced_web_interface_hint: 'If you want to make use of your entire screen width, the advanced web interface allows you to configure many different columns to see as much information at the same time as you want: Home, notifications, federated timeline, any number of lists and hashtags.' advanced_web_interface_hint: 'If you want to make use of your entire screen width, the advanced web interface allows you to configure many different columns to see as much information at the same time as you want: Home, notifications, federated timeline, any number of lists and hashtags.'
@ -939,6 +943,8 @@ en:
pinned: Pinned toot pinned: Pinned toot
reblogged: boosted reblogged: boosted
sensitive_content: Sensitive content sensitive_content: Sensitive content
tags:
does_not_match_previous_name: does not match the previous name
terms: terms:
body_html: | body_html: |
<h2>Privacy Policy</h2> <h2>Privacy Policy</h2>

View File

@ -48,6 +48,8 @@ en:
text: This will help us review your application text: This will help us review your application
sessions: sessions:
otp: 'Enter the two-factor code generated by your phone app or use one of your recovery codes:' otp: 'Enter the two-factor code generated by your phone app or use one of your recovery codes:'
tag:
name: You can only change the casing of the letters, for example, to make it more readable
user: user:
chosen_languages: When checked, only toots in selected languages will be displayed in public timelines chosen_languages: When checked, only toots in selected languages will be displayed in public timelines
labels: labels:
@ -137,6 +139,11 @@ en:
pending_account: Send e-mail when a new account needs review pending_account: Send e-mail when a new account needs review
reblog: Send e-mail when someone boosts your status reblog: Send e-mail when someone boosts your status
report: Send e-mail when a new report is submitted report: Send e-mail when a new report is submitted
trending_tag: Send e-mail when an unreviewed hashtag is trending
tag:
listable: Allow this hashtag to appear on the profile directory
trendable: Allow this hashtag to appear under trends
usable: Allow toots to use this hashtag
'no': 'No' 'no': 'No'
recommended: Recommended recommended: Recommended
required: required:

View File

@ -38,7 +38,7 @@ SimpleNavigation::Configuration.run do |navigation|
s.item :reports, safe_join([fa_icon('flag fw'), t('admin.reports.title')]), admin_reports_url, highlights_on: %r{/admin/reports} s.item :reports, safe_join([fa_icon('flag fw'), t('admin.reports.title')]), admin_reports_url, highlights_on: %r{/admin/reports}
s.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_url, highlights_on: %r{/admin/accounts|/admin/pending_accounts} s.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_url, highlights_on: %r{/admin/accounts|/admin/pending_accounts}
s.item :invites, safe_join([fa_icon('user-plus fw'), t('admin.invites.title')]), admin_invites_path s.item :invites, safe_join([fa_icon('user-plus fw'), t('admin.invites.title')]), admin_invites_path
s.item :tags, safe_join([fa_icon('tag fw'), t('admin.tags.title')]), admin_tags_path s.item :tags, safe_join([fa_icon('hashtag fw'), t('admin.tags.title')]), admin_tags_path, highlights_on: %r{/admin/tags}
s.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url(limited: whitelist_mode? ? nil : '1'), highlights_on: %r{/admin/instances|/admin/domain_blocks|/admin/domain_allows}, if: -> { current_user.admin? } s.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url(limited: whitelist_mode? ? nil : '1'), highlights_on: %r{/admin/instances|/admin/domain_blocks|/admin/domain_allows}, if: -> { current_user.admin? }
s.item :email_domain_blocks, safe_join([fa_icon('envelope fw'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_url, highlights_on: %r{/admin/email_domain_blocks}, if: -> { current_user.admin? } s.item :email_domain_blocks, safe_join([fa_icon('envelope fw'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_url, highlights_on: %r{/admin/email_domain_blocks}, if: -> { current_user.admin? }
end end

View File

@ -243,13 +243,7 @@ Rails.application.routes.draw do
end end
resources :account_moderation_notes, only: [:create, :destroy] resources :account_moderation_notes, only: [:create, :destroy]
resources :tags, only: [:index, :show, :update]
resources :tags, only: [:index] do
member do
post :hide
post :unhide
end
end
end end
get '/admin', to: redirect('/admin/dashboard', status: 302) get '/admin', to: redirect('/admin/dashboard', status: 302)
@ -311,6 +305,7 @@ Rails.application.routes.draw do
resources :mutes, only: [:index] resources :mutes, only: [:index]
resources :favourites, only: [:index] resources :favourites, only: [:index]
resources :reports, only: [:create] resources :reports, only: [:create]
resources :trends, only: [:index]
resources :filters, only: [:index, :create, :show, :update, :destroy] resources :filters, only: [:index, :create, :show, :update, :destroy]
resources :endorsements, only: [:index] resources :endorsements, only: [:index]

View File

@ -43,6 +43,7 @@ defaults: &defaults
digest: true digest: true
report: true report: true
pending_account: true pending_account: true
trending_tag: true
interactions: interactions:
must_be_follower: false must_be_follower: false
must_be_following: false must_be_following: false

View File

@ -0,0 +1,9 @@
class AddCapabilitiesToTags < ActiveRecord::Migration[5.2]
def change
add_column :tags, :usable, :boolean
add_column :tags, :trendable, :boolean
add_column :tags, :listable, :boolean
add_column :tags, :reviewed_at, :datetime
add_column :tags, :requested_review_at, :datetime
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2019_07_29_185330) do ActiveRecord::Schema.define(version: 2019_08_05_123746) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -660,6 +660,11 @@ ActiveRecord::Schema.define(version: 2019_07_29_185330) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "score" t.integer "score"
t.boolean "usable"
t.boolean "trendable"
t.boolean "listable"
t.datetime "reviewed_at"
t.datetime "requested_review_at"
t.index "lower((name)::text)", name: "index_tags_on_name_lower", unique: true t.index "lower((name)::text)", name: "index_tags_on_name_lower", unique: true
end end

View File

@ -10,62 +10,14 @@ RSpec.describe Admin::TagsController, type: :controller do
end end
describe 'GET #index' do describe 'GET #index' do
before do let!(:tag) { Fabricate(:tag) }
account_tag_stat = Fabricate(:tag).account_tag_stat
account_tag_stat.update(hidden: hidden, accounts_count: 1)
get :index, params: { hidden: hidden }
end
context 'with hidden tags' do
let(:hidden) { true }
it 'returns status 200' do
expect(response).to have_http_status(200)
end
end
context 'without hidden tags' do
let(:hidden) { false }
it 'returns status 200' do
expect(response).to have_http_status(200)
end
end
end
describe 'POST #hide' do
let(:tag) { Fabricate(:tag) }
before do before do
tag.account_tag_stat.update(hidden: false) get :index
post :hide, params: { id: tag.id }
end end
it 'hides tag' do it 'returns status 200' do
tag.reload expect(response).to have_http_status(200)
expect(tag).to be_hidden
end
it 'redirects to admin_tags_path' do
expect(response).to redirect_to(admin_tags_path(controller.instance_variable_get(:@filter_params)))
end
end
describe 'POST #unhide' do
let(:tag) { Fabricate(:tag) }
before do
tag.account_tag_stat.update(hidden: true)
post :unhide, params: { id: tag.id }
end
it 'unhides tag' do
tag.reload
expect(tag).not_to be_hidden
end
it 'redirects to admin_tags_path' do
expect(response).to redirect_to(admin_tags_path(controller.instance_variable_get(:@filter_params)))
end end
end end
end end

View File

@ -8,7 +8,7 @@ RSpec.describe TagPolicy do
let(:admin) { Fabricate(:user, admin: true).account } let(:admin) { Fabricate(:user, admin: true).account }
let(:john) { Fabricate(:user).account } let(:john) { Fabricate(:user).account }
permissions :index?, :hide?, :unhide? do permissions :index?, :show?, :update? do
context 'staff?' do context 'staff?' do
it 'permits' do it 'permits' do
expect(subject).to permit(admin, Tag) expect(subject).to permit(admin, Tag)

View File

@ -3,42 +3,44 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe DisallowedHashtagsValidator, type: :validator do RSpec.describe DisallowedHashtagsValidator, type: :validator do
let(:disallowed_tags) { [] }
describe '#validate' do describe '#validate' do
before do before do
allow_any_instance_of(described_class).to receive(:select_tags) { tags } disallowed_tags.each { |name| Fabricate(:tag, name: name, usable: false) }
described_class.new.validate(status) described_class.new.validate(status)
end end
let(:status) { double(errors: errors, local?: local, reblog?: reblog, text: '') } let(:status) { double(errors: errors, local?: local, reblog?: reblog, text: disallowed_tags.map { |x| '#' + x }.join(' ')) }
let(:errors) { double(add: nil) } let(:errors) { double(add: nil) }
context 'unless status.local? && !status.reblog?' do context 'for a remote reblog' do
let(:local) { false } let(:local) { false }
let(:reblog) { true } let(:reblog) { true }
it 'not calls errors.add' do it 'does not add errors' do
expect(errors).not_to have_received(:add).with(:text, any_args) expect(errors).not_to have_received(:add).with(:text, any_args)
end end
end end
context 'status.local? && !status.reblog?' do context 'for a local original status' do
let(:local) { true } let(:local) { true }
let(:reblog) { false } let(:reblog) { false }
context 'tags.empty?' do context 'when does not contain any disallowed hashtags' do
let(:tags) { [] } let(:disallowed_tags) { [] }
it 'not calls errors.add' do it 'does not add errors' do
expect(errors).not_to have_received(:add).with(:text, any_args) expect(errors).not_to have_received(:add).with(:text, any_args)
end end
end end
context '!tags.empty?' do context 'when contains disallowed hashtags' do
let(:tags) { %w(a b c) } let(:disallowed_tags) { %w(a b c) }
it 'calls errors.add' do it 'adds an error' do
expect(errors).to have_received(:add) expect(errors).to have_received(:add)
.with(:text, I18n.t('statuses.disallowed_hashtags', tags: tags.join(', '), count: tags.size)) .with(:text, I18n.t('statuses.disallowed_hashtags', tags: disallowed_tags.join(', '), count: disallowed_tags.size))
end end
end end
end end