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`gh/stable
parent
6201bfdfba
commit
115dab78f1
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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_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
|
||||||
|
|
|
@ -5,7 +5,7 @@ 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
|
||||||
|
|
||||||
|
@ -14,6 +14,7 @@ module Admin::FilterHelper
|
||||||
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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -8,6 +8,11 @@
|
||||||
# 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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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?
|
•
|
||||||
= 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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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?
|
- 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|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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]
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
before do
|
||||||
let(:hidden) { true }
|
get :index
|
||||||
|
end
|
||||||
|
|
||||||
it 'returns status 200' do
|
it 'returns status 200' do
|
||||||
expect(response).to have_http_status(200)
|
expect(response).to have_http_status(200)
|
||||||
end
|
end
|
||||||
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
|
|
||||||
tag.account_tag_stat.update(hidden: false)
|
|
||||||
post :hide, params: { id: tag.id }
|
|
||||||
end
|
|
||||||
|
|
||||||
it 'hides tag' do
|
|
||||||
tag.reload
|
|
||||||
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
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
Reference in New Issue