From 115dab78f1cc5357281dcb593f04ac8b2629cec6 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Mon, 5 Aug 2019 19:54:29 +0200 Subject: [PATCH 01/13] 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` --- app/controllers/admin/dashboard_controller.rb | 2 +- app/controllers/admin/tags_controller.rb | 36 ++++++----- app/controllers/api/v1/trends_controller.rb | 17 ++++++ .../settings/preferences_controller.rb | 2 +- app/helpers/admin/filter_helper.rb | 5 +- app/mailers/admin_mailer.rb | 10 ++++ app/models/application_record.rb | 11 ++++ app/models/tag.rb | 60 ++++++++++++++++--- app/models/trending_tags.rb | 48 ++++++++------- app/models/user.rb | 4 ++ app/policies/tag_policy.rb | 4 +- .../disallowed_hashtags_validator.rb | 21 +------ app/views/admin/dashboard/index.html.haml | 2 +- app/views/admin/tags/_tag.html.haml | 24 ++++---- app/views/admin/tags/index.html.haml | 26 ++++---- app/views/admin/tags/show.html.haml | 16 +++++ .../admin_mailer/new_trending_tag.text.erb | 5 ++ .../preferences/notifications/show.html.haml | 1 + config/locales/en.yml | 18 ++++-- config/locales/simple_form.en.yml | 7 +++ config/navigation.rb | 2 +- config/routes.rb | 9 +-- config/settings.yml | 1 + ...20190805123746_add_capabilities_to_tags.rb | 9 +++ db/schema.rb | 7 ++- .../controllers/admin/tags_controller_spec.rb | 56 ++--------------- spec/policies/tag_policy_spec.rb | 2 +- .../disallowed_hashtags_validator_spec.rb | 26 ++++---- 28 files changed, 258 insertions(+), 173 deletions(-) create mode 100644 app/controllers/api/v1/trends_controller.rb create mode 100644 app/views/admin/tags/show.html.haml create mode 100644 app/views/admin_mailer/new_trending_tag.text.erb create mode 100644 db/migrate/20190805123746_add_capabilities_to_tags.rb diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index e74e4755f85..70afdedd766 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -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 diff --git a/app/controllers/admin/tags_controller.rb b/app/controllers/admin/tags_controller.rb index e9f4f2cfa3b..0e9dda30221 100644 --- a/app/controllers/admin/tags_controller.rb +++ b/app/controllers/admin/tags_controller.rb @@ -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 diff --git a/app/controllers/api/v1/trends_controller.rb b/app/controllers/api/v1/trends_controller.rb new file mode 100644 index 00000000000..bcea9857e8f --- /dev/null +++ b/app/controllers/api/v1/trends_controller.rb @@ -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 diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index 742c97cdb61..d548072a801 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -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 diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb index 0bda25974f0..506429e10bc 100644 --- a/app/helpers/admin/filter_helper.rb +++ b/app/helpers/admin/filter_helper.rb @@ -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 diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb index 9ab3e2bbdc6..8abce5f055d 100644 --- a/app/mailers/admin_mailer.rb +++ b/app/mailers/admin_mailer.rb @@ -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 diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 83134d41a47..c1b873da6ac 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -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 diff --git a/app/models/tag.rb b/app/models/tag.rb index c7f0af86df1..6a02581fad3 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -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 diff --git a/app/models/trending_tags.rb b/app/models/trending_tags.rb index 211c8f1dc16..e9b9b25e31f 100644 --- a/app/models/trending_tags.rb +++ b/app/models/trending_tags.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 6806c036242..b83e26af3f0 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/policies/tag_policy.rb b/app/policies/tag_policy.rb index c63de01dbe9..aaf70fcabc4 100644 --- a/app/policies/tag_policy.rb +++ b/app/policies/tag_policy.rb @@ -5,11 +5,11 @@ class TagPolicy < ApplicationPolicy staff? end - def hide? + def show? staff? end - def unhide? + def update? staff? end end diff --git a/app/validators/disallowed_hashtags_validator.rb b/app/validators/disallowed_hashtags_validator.rb index ee06b20f6b4..d745b767f6d 100644 --- a/app/validators/disallowed_hashtags_validator.rb +++ b/app/validators/disallowed_hashtags_validator.rb @@ -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 diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 77cc1a2a008..910896075cf 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -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) diff --git a/app/views/admin/tags/_tag.html.haml b/app/views/admin/tags/_tag.html.haml index 961b83f93c2..91af8e4927c 100644 --- a/app/views/admin/tags/_tag.html.haml +++ b/app/views/admin/tags/_tag.html.haml @@ -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 diff --git a/app/views/admin/tags/index.html.haml b/app/views/admin/tags/index.html.haml index 4ba3958605d..5e4ee21f50e 100644 --- a/app/views/admin/tags/index.html.haml +++ b/app/views/admin/tags/index.html.haml @@ -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 diff --git a/app/views/admin/tags/show.html.haml b/app/views/admin/tags/show.html.haml new file mode 100644 index 00000000000..27c8dc92b75 --- /dev/null +++ b/app/views/admin/tags/show.html.haml @@ -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 diff --git a/app/views/admin_mailer/new_trending_tag.text.erb b/app/views/admin_mailer/new_trending_tag.text.erb new file mode 100644 index 00000000000..f3087df3753 --- /dev/null +++ b/app/views/admin_mailer/new_trending_tag.text.erb @@ -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') %> diff --git a/app/views/settings/preferences/notifications/show.html.haml b/app/views/settings/preferences/notifications/show.html.haml index acc646fc348..f666ae4ff6b 100644 --- a/app/views/settings/preferences/notifications/show.html.haml +++ b/app/views/settings/preferences/notifications/show.html.haml @@ -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| diff --git a/config/locales/en.yml b/config/locales/en.yml index 6c1a3430015..9b62aac3af3 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -483,13 +483,14 @@ en: title: Account statuses with_media: With media tags: - accounts: Accounts - hidden: Hidden - hide: Hide from directory - name: Hashtag + context: Context + directory: In directory + in_directory: "%{count} in directory" + review: Review status + reviewed: Reviewed title: Hashtags - unhide: Show in directory - visible: Visible + trending_right_now: Trending right now + unique_uses_today: "%{count} posting today" title: Administration warning_presets: add_new: Add new @@ -505,6 +506,9 @@ en: body: "%{reporter} has reported %{target}" body_remote: Someone from %{domain} has reported %{target} 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: 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.' @@ -939,6 +943,8 @@ en: pinned: Pinned toot reblogged: boosted sensitive_content: Sensitive content + tags: + does_not_match_previous_name: does not match the previous name terms: body_html: |

Privacy Policy

diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 10b30e627f2..6fdfc9d7bce 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -48,6 +48,8 @@ en: text: This will help us review your application sessions: 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: chosen_languages: When checked, only toots in selected languages will be displayed in public timelines labels: @@ -137,6 +139,11 @@ en: pending_account: Send e-mail when a new account needs review reblog: Send e-mail when someone boosts your status 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' recommended: Recommended required: diff --git a/config/navigation.rb b/config/navigation.rb index 9b46da60322..38668bbf74d 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -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 :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 :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 :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 diff --git a/config/routes.rb b/config/routes.rb index 04424bbbd1f..60f7d2e053a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -243,13 +243,7 @@ Rails.application.routes.draw do end resources :account_moderation_notes, only: [:create, :destroy] - - resources :tags, only: [:index] do - member do - post :hide - post :unhide - end - end + resources :tags, only: [:index, :show, :update] end get '/admin', to: redirect('/admin/dashboard', status: 302) @@ -311,6 +305,7 @@ Rails.application.routes.draw do resources :mutes, only: [:index] resources :favourites, only: [:index] resources :reports, only: [:create] + resources :trends, only: [:index] resources :filters, only: [:index, :create, :show, :update, :destroy] resources :endorsements, only: [:index] diff --git a/config/settings.yml b/config/settings.yml index ad2970bb7ec..10180201f32 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -43,6 +43,7 @@ defaults: &defaults digest: true report: true pending_account: true + trending_tag: true interactions: must_be_follower: false must_be_following: false diff --git a/db/migrate/20190805123746_add_capabilities_to_tags.rb b/db/migrate/20190805123746_add_capabilities_to_tags.rb new file mode 100644 index 00000000000..43c7763b1bf --- /dev/null +++ b/db/migrate/20190805123746_add_capabilities_to_tags.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index e3af9c31aa5..d1b6825b4b6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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 enable_extension "plpgsql" @@ -660,6 +660,11 @@ ActiveRecord::Schema.define(version: 2019_07_29_185330) do t.datetime "created_at", null: false t.datetime "updated_at", null: false 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 end diff --git a/spec/controllers/admin/tags_controller_spec.rb b/spec/controllers/admin/tags_controller_spec.rb index 3af994071ce..5c1944fc770 100644 --- a/spec/controllers/admin/tags_controller_spec.rb +++ b/spec/controllers/admin/tags_controller_spec.rb @@ -10,62 +10,14 @@ RSpec.describe Admin::TagsController, type: :controller do end describe 'GET #index' do - before do - 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) } + let!(:tag) { Fabricate(:tag) } before do - tag.account_tag_stat.update(hidden: false) - post :hide, params: { id: tag.id } + get :index 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))) + it 'returns status 200' do + expect(response).to have_http_status(200) end end end diff --git a/spec/policies/tag_policy_spec.rb b/spec/policies/tag_policy_spec.rb index c7afaa7c93d..c63875dc088 100644 --- a/spec/policies/tag_policy_spec.rb +++ b/spec/policies/tag_policy_spec.rb @@ -8,7 +8,7 @@ RSpec.describe TagPolicy do let(:admin) { Fabricate(:user, admin: true).account } let(:john) { Fabricate(:user).account } - permissions :index?, :hide?, :unhide? do + permissions :index?, :show?, :update? do context 'staff?' do it 'permits' do expect(subject).to permit(admin, Tag) diff --git a/spec/validators/disallowed_hashtags_validator_spec.rb b/spec/validators/disallowed_hashtags_validator_spec.rb index 8ec1302ab9f..9deec0bb90e 100644 --- a/spec/validators/disallowed_hashtags_validator_spec.rb +++ b/spec/validators/disallowed_hashtags_validator_spec.rb @@ -3,42 +3,44 @@ require 'rails_helper' RSpec.describe DisallowedHashtagsValidator, type: :validator do + let(:disallowed_tags) { [] } + describe '#validate' 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) 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) } - context 'unless status.local? && !status.reblog?' do + context 'for a remote reblog' do let(:local) { false } 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) end end - context 'status.local? && !status.reblog?' do + context 'for a local original status' do let(:local) { true } let(:reblog) { false } - context 'tags.empty?' do - let(:tags) { [] } + context 'when does not contain any disallowed hashtags' do + 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) end end - context '!tags.empty?' do - let(:tags) { %w(a b c) } + context 'when contains disallowed hashtags' do + let(:disallowed_tags) { %w(a b c) } - it 'calls errors.add' do + it 'adds an error' do 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 From 792d0dd47078842fa96a2784a484247c3ff62f0c Mon Sep 17 00:00:00 2001 From: ThibG Date: Tue, 6 Aug 2019 11:59:14 +0200 Subject: [PATCH 02/13] Fix privacy dropdown active state when dropdown is placed on top of it (#11495) --- .../mastodon/features/compose/components/privacy_dropdown.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js index d02a55be00e..9db098544af 100644 --- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js @@ -229,7 +229,7 @@ class PrivacyDropdown extends React.PureComponent { return (
-
+
Date: Tue, 6 Aug 2019 11:59:28 +0200 Subject: [PATCH 03/13] Trap tab in modals (#11493) --- .../mastodon/components/modal_root.js | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/app/javascript/mastodon/components/modal_root.js b/app/javascript/mastodon/components/modal_root.js index ef1156571f1..5d4f4bbe138 100644 --- a/app/javascript/mastodon/components/modal_root.js +++ b/app/javascript/mastodon/components/modal_root.js @@ -21,8 +21,30 @@ export default class ModalRoot extends React.PureComponent { } } + handleKeyDown = (e) => { + if (e.key === 'Tab') { + const focusable = Array.from(this.node.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((x) => window.getComputedStyle(x).display !== 'none'); + const index = focusable.indexOf(e.target); + + let element; + + if (e.shiftKey) { + element = focusable[index - 1] || focusable[focusable.length - 1]; + } else { + element = focusable[index + 1] || focusable[0]; + } + + if (element) { + element.focus(); + e.stopPropagation(); + e.preventDefault(); + } + } + } + componentDidMount () { window.addEventListener('keyup', this.handleKeyUp, false); + window.addEventListener('keydown', this.handleKeyDown, false); } componentWillReceiveProps (nextProps) { @@ -52,6 +74,7 @@ export default class ModalRoot extends React.PureComponent { componentWillUnmount () { window.removeEventListener('keyup', this.handleKeyUp); + window.removeEventListener('keydown', this.handleKeyDown); } getSiblings = () => { From a12f1a0baf3d31ecc9779c25b4bf4a0c9bd95543 Mon Sep 17 00:00:00 2001 From: ThibG Date: Tue, 6 Aug 2019 11:59:46 +0200 Subject: [PATCH 04/13] Improve dropdown menu keyboard navigation (#11491) * Allow selecting menu items with the space bar in status dropdown menus * Fix modals opened by keyboard navigation being immediately closed * Fix menu items triggering modal actions * Add Tab trapping inside dropdown menu * Give focus back to last focused element when status dropdown menu closes --- app/javascript/mastodon/actions/modal.js | 3 +- .../mastodon/components/dropdown_menu.js | 44 +++++++++++-------- .../containers/dropdown_menu_container.js | 2 +- app/javascript/mastodon/reducers/modal.js | 2 +- 4 files changed, 30 insertions(+), 21 deletions(-) diff --git a/app/javascript/mastodon/actions/modal.js b/app/javascript/mastodon/actions/modal.js index 80e15c28ec1..3d0299db58a 100644 --- a/app/javascript/mastodon/actions/modal.js +++ b/app/javascript/mastodon/actions/modal.js @@ -9,8 +9,9 @@ export function openModal(type, props) { }; }; -export function closeModal() { +export function closeModal(type) { return { type: MODAL_CLOSE, + modalType: type, }; }; diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js index e122515c49d..9937d0f886c 100644 --- a/app/javascript/mastodon/components/dropdown_menu.js +++ b/app/javascript/mastodon/components/dropdown_menu.js @@ -45,7 +45,10 @@ class DropdownMenu extends React.PureComponent { document.addEventListener('click', this.handleDocumentClick, false); document.addEventListener('keydown', this.handleKeyDown, false); document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); - if (this.focusedItem && this.props.openedViaKeyboard) this.focusedItem.focus(); + this.activeElement = document.activeElement; + if (this.focusedItem && this.props.openedViaKeyboard) { + this.focusedItem.focus(); + } this.setState({ mounted: true }); } @@ -53,6 +56,9 @@ class DropdownMenu extends React.PureComponent { document.removeEventListener('click', this.handleDocumentClick, false); document.removeEventListener('keydown', this.handleKeyDown, false); document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); + if (this.activeElement) { + this.activeElement.focus(); + } } setRef = c => { @@ -81,6 +87,18 @@ class DropdownMenu extends React.PureComponent { element.focus(); } break; + case 'Tab': + if (e.shiftKey) { + element = items[index-1] || items[items.length-1]; + } else { + element = items[index+1] || items[0]; + } + if (element) { + element.focus(); + e.preventDefault(); + e.stopPropagation(); + } + break; case 'Home': element = items[0]; if (element) { @@ -93,11 +111,14 @@ class DropdownMenu extends React.PureComponent { element.focus(); } break; + case 'Escape': + this.props.onClose(); + break; } } - handleItemKeyDown = e => { - if (e.key === 'Enter') { + handleItemKeyUp = e => { + if (e.key === 'Enter' || e.key === ' ') { this.handleClick(e); } } @@ -126,7 +147,7 @@ class DropdownMenu extends React.PureComponent { return (
  • - + {text}
  • @@ -202,19 +223,6 @@ export default class Dropdown extends React.PureComponent { this.props.onClose(this.state.id); } - handleKeyDown = e => { - switch(e.key) { - case ' ': - case 'Enter': - this.handleClick(e); - e.preventDefault(); - break; - case 'Escape': - this.handleClose(); - break; - } - } - handleItemClick = e => { const i = Number(e.currentTarget.getAttribute('data-index')); const { action, to } = this.props.items[i]; @@ -249,7 +257,7 @@ export default class Dropdown extends React.PureComponent { const open = this.state.id === openDropdownId; return ( -
    +
    ({ }) : openDropdownMenu(id, dropdownPlacement, keyboard)); }, onClose(id) { - dispatch(closeModal()); + dispatch(closeModal('ACTIONS')); dispatch(closeDropdownMenu(id)); }, }); diff --git a/app/javascript/mastodon/reducers/modal.js b/app/javascript/mastodon/reducers/modal.js index 599a2443e0d..a30da2db1b4 100644 --- a/app/javascript/mastodon/reducers/modal.js +++ b/app/javascript/mastodon/reducers/modal.js @@ -10,7 +10,7 @@ export default function modal(state = initialState, action) { case MODAL_OPEN: return { modalType: action.modalType, modalProps: action.modalProps }; case MODAL_CLOSE: - return initialState; + return (action.modalType === undefined || action.modalType === state.modalType) ? initialState : state; default: return state; } From 27a0d02d0d960163e98595b05412c0d03a4875d0 Mon Sep 17 00:00:00 2001 From: ThibG Date: Tue, 6 Aug 2019 11:59:58 +0200 Subject: [PATCH 05/13] Improve keyboard navigation in privacy dropdown (#11492) * Trap tab in privacy dropdown * Give focus back to last focused element when privacy dropdown menu closes * Actually give back focus to the element that had it before clicking the dropdown --- .../mastodon/components/icon_button.js | 18 ++++++++++ .../compose/components/privacy_dropdown.js | 36 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js index 9d8a8d06b73..a727359e948 100644 --- a/app/javascript/mastodon/components/icon_button.js +++ b/app/javascript/mastodon/components/icon_button.js @@ -12,6 +12,8 @@ export default class IconButton extends React.PureComponent { title: PropTypes.string.isRequired, icon: PropTypes.string.isRequired, onClick: PropTypes.func, + onMouseDown: PropTypes.func, + onKeyDown: PropTypes.func, size: PropTypes.number, active: PropTypes.bool, pressed: PropTypes.bool, @@ -42,6 +44,18 @@ export default class IconButton extends React.PureComponent { } } + handleMouseDown = (e) => { + if (!this.props.disabled && this.props.onMouseDown) { + this.props.onMouseDown(e); + } + } + + handleKeyDown = (e) => { + if (!this.props.disabled && this.props.onKeyDown) { + this.props.onKeyDown(e); + } + } + render () { const style = { fontSize: `${this.props.size}px`, @@ -84,6 +98,8 @@ export default class IconButton extends React.PureComponent { title={title} className={classes} onClick={this.handleClick} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleKeyDown} style={style} tabIndex={tabIndex} disabled={disabled} @@ -103,6 +119,8 @@ export default class IconButton extends React.PureComponent { title={title} className={classes} onClick={this.handleClick} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleKeyDown} style={style} tabIndex={tabIndex} disabled={disabled} diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js index 9db098544af..7cbfe463add 100644 --- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js @@ -73,6 +73,19 @@ class PrivacyDropdownMenu extends React.PureComponent { this.props.onChange(element.getAttribute('data-index')); } break; + case 'Tab': + if (e.shiftKey) { + element = this.node.childNodes[index - 1] || this.node.lastChild; + } else { + element = this.node.childNodes[index + 1] || this.node.firstChild; + } + if (element) { + element.focus(); + this.props.onChange(element.getAttribute('data-index')); + e.preventDefault(); + e.stopPropagation(); + } + break; case 'Home': element = this.node.firstChild; if (element) { @@ -180,6 +193,9 @@ class PrivacyDropdown extends React.PureComponent { } } else { const { top } = target.getBoundingClientRect(); + if (this.state.open && this.activeElement) { + this.activeElement.focus(); + } this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); this.setState({ open: !this.state.open }); } @@ -202,7 +218,25 @@ class PrivacyDropdown extends React.PureComponent { } } + handleMouseDown = () => { + if (!this.state.open) { + this.activeElement = document.activeElement; + } + } + + handleButtonKeyDown = (e) => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleMouseDown(); + break; + } + } + handleClose = () => { + if (this.state.open && this.activeElement) { + this.activeElement.focus(); + } this.setState({ open: false }); } @@ -239,6 +273,8 @@ class PrivacyDropdown extends React.PureComponent { active={open} inverted onClick={this.handleToggle} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleButtonKeyDown} style={{ height: null, lineHeight: '27px' }} />
    From 111a0628fc161df4d76967d7dc7116b8a43fe8e2 Mon Sep 17 00:00:00 2001 From: ThibG Date: Tue, 6 Aug 2019 12:08:19 +0200 Subject: [PATCH 06/13] Fix image uploads being perfectly white when canvas read access is blocked (#11499) Fixes #11496 --- app/javascript/mastodon/utils/resize_image.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/javascript/mastodon/utils/resize_image.js b/app/javascript/mastodon/utils/resize_image.js index bbdbc865e9e..a8ec5f3fa76 100644 --- a/app/javascript/mastodon/utils/resize_image.js +++ b/app/javascript/mastodon/utils/resize_image.js @@ -67,6 +67,14 @@ const processImage = (img, { width, height, orientation, type = 'image/png' }) = context.drawImage(img, 0, 0, width, height); + // The Tor Browser and maybe other browsers may prevent reading from canvas + // and return an all-white image instead. Assume reading failed if the resized + // image is perfectly white. + const imageData = context.getImageData(0, 0, width, height); + if (imageData.every(value => value === 255)) { + throw 'Failed to read from canvas'; + } + canvas.toBlob(resolve, type); }); From 1d8d553d8b5cb23cc67bd530ca9eb70a6683ab2a Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 6 Aug 2019 12:09:28 +0200 Subject: [PATCH 07/13] Revert "Change links in webUI to rewrite misleading links (#11426)" (#11500) This reverts commit b31b232edfcc7f04acf828bf6829ab716b290692. --- .../mastodon/components/status_content.js | 90 ------------------- .../features/status/components/card.js | 11 ++- app/javascript/mastodon/utils/idna.js | 10 --- 3 files changed, 10 insertions(+), 101 deletions(-) delete mode 100644 app/javascript/mastodon/utils/idna.js diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index e717934fa70..549de95fc13 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -8,71 +8,9 @@ import classnames from 'classnames'; import PollContainer from 'mastodon/containers/poll_container'; import Icon from 'mastodon/components/icon'; import { autoPlayGif } from 'mastodon/initial_state'; -import { decode as decodeIDNA } from 'mastodon/utils/idna'; const MAX_HEIGHT = 642; // 20px * 32 (+ 2px padding at the top) -// Regex matching what "looks like a link", that is, something that starts with -// an optional "http://" or "https://" scheme and then what could look like a -// domain main, that is, at least two sequences of characters not including spaces -// and separated by "." or an homoglyph. The idea is not to match valid URLs or -// domain names, but what could be confused for a valid URL or domain name, -// especially to the untrained eye. - -const h_confusables = 'h\u13c2\u1d58d\u1d4f1\u1d691\u0068\uff48\u1d525\u210e\u1d489\u1d629\u0570\u1d4bd\u1d65d\u1d421\u1d5c1\u1d5f5\u04bb\u1d559'; -const t_confusables = 't\u1d42d\u1d5cd\u1d531\u1d565\u1d4c9\u1d669\u1d4fd\u1d69d\u0074\u1d461\u1d601\u1d495\u1d635\u1d599'; -const p_confusables = 'p\u0440\u03c1\u1d52d\u1d631\u1d665\u1d429\uff50\u1d6e0\u1d45d\u1d561\u1d595\u1d71a\u1d699\u1d78e\u2ca3\u1d754\u1d6d2\u1d491\u1d7c8\u1d746\u1d4c5\u1d70c\u1d5c9\u0070\u1d780\u03f1\u1d5fd\u2374\u1d7ba\u1d4f9'; -const s_confusables = 's\u1d530\u118c1\u1d494\u1d634\u1d4c8\u1d668\uabaa\u1d42c\u1d5cc\u1d460\u1d600\ua731\u0073\uff53\u1d564\u0455\u1d598\u1d4fc\u1d69c\u10448\u01bd'; -const column_confusables = ':\u0903\u0a83\u0703\u1803\u05c3\u0704\u0589\u1809\ua789\u16ec\ufe30\u02d0\u2236\u02f8\u003a\uff1a\u205a\ua4fd'; -const slash_confusables = '/\u2041\u2f03\u2044\u2cc6\u27cb\u30ce\u002f\u2571\u31d3\u3033\u1735\u2215\u29f8\u1d23a\u4e3f'; -const dot_confusables = '.\u002e\u0660\u06f0\u0701\u0702\u2024\ua4f8\ua60e\u10a50\u1d16d'; - -const linkRegex = new RegExp(`^\\s*(([${h_confusables}][${t_confusables}][${t_confusables}][${p_confusables}][${s_confusables}]?[${column_confusables}][${slash_confusables}][${slash_confusables}]))?[^:/\\n ]+([${dot_confusables}][^:/\\n ]+)+`); - -const isLinkMisleading = (link) => { - let linkTextParts = []; - - // Reconstruct visible text, as we do not have much control over how links - // from remote software look, and we can't rely on `innerText` because the - // `invisible` class does not set `display` to `none`. - - const walk = (node) => { - switch (node.nodeType) { - case Node.TEXT_NODE: - linkTextParts.push(node.textContent); - break; - case Node.ELEMENT_NODE: - if (node.classList.contains('invisible')) return; - const children = node.childNodes; - for (let i = 0; i < children.length; i++) { - walk(children[i]); - } - break; - } - }; - - walk(link); - - const linkText = linkTextParts.join(''); - const targetURL = new URL(link.href); - - // The following may not work with international domain names - if (linkText === targetURL.origin || linkText === targetURL.host || 'www.' + linkText === targetURL.host || linkText.startsWith(targetURL.origin + '/') || linkText.startsWith(targetURL.host + '/')) { - return false; - } - - // The link hasn't been recognized, maybe it features an international domain name - const hostname = decodeIDNA(targetURL.hostname); - const host = targetURL.host.replace(targetURL.hostname, hostname); - const origin = targetURL.origin.replace(targetURL.host, host); - if (linkText === origin || linkText === host || linkText.startsWith(origin + '/') || linkText.startsWith(host + '/')) { - return false; - } - - // If the link text looks like an URL or auto-generated link, it is misleading - return linkRegex.test(linkText); -}; - export default class StatusContent extends React.PureComponent { static contextTypes = { @@ -118,34 +56,6 @@ export default class StatusContent extends React.PureComponent { } else { link.setAttribute('title', link.href); link.classList.add('unhandled-link'); - - if (isLinkMisleading(link)) { - while (link.firstChild) { - link.removeChild(link.firstChild); - } - - const prefix = (link.href.match(/https?:\/\/(www\.)?/) || [''])[0]; - const text = link.href.substr(prefix.length, 30); - const suffix = link.href.substr(prefix.length + 30); - const cutoff = !!suffix; - - const prefixTag = document.createElement('span'); - prefixTag.classList.add('invisible'); - prefixTag.textContent = prefix; - link.appendChild(prefixTag); - - const textTag = document.createElement('span'); - if (cutoff) { - textTag.classList.add('ellipsis'); - } - textTag.textContent = text; - link.appendChild(textTag); - - const suffixTag = document.createElement('span'); - suffixTag.classList.add('invisible'); - suffixTag.textContent = suffix; - link.appendChild(suffixTag); - } } link.setAttribute('target', '_blank'); diff --git a/app/javascript/mastodon/features/status/components/card.js b/app/javascript/mastodon/features/status/components/card.js index 0125428436e..0eff544119f 100644 --- a/app/javascript/mastodon/features/status/components/card.js +++ b/app/javascript/mastodon/features/status/components/card.js @@ -2,9 +2,18 @@ import React from 'react'; import PropTypes from 'prop-types'; import Immutable from 'immutable'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import punycode from 'punycode'; import classnames from 'classnames'; import Icon from 'mastodon/components/icon'; -import { decode as decodeIDNA } from 'mastodon/utils/idna'; + +const IDNA_PREFIX = 'xn--'; + +const decodeIDNA = domain => { + return domain + .split('.') + .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part) + .join('.'); +}; const getHostname = url => { const parser = document.createElement('a'); diff --git a/app/javascript/mastodon/utils/idna.js b/app/javascript/mastodon/utils/idna.js deleted file mode 100644 index efab5bacf77..00000000000 --- a/app/javascript/mastodon/utils/idna.js +++ /dev/null @@ -1,10 +0,0 @@ -import punycode from 'punycode'; - -const IDNA_PREFIX = 'xn--'; - -export const decode = domain => { - return domain - .split('.') - .map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part) - .join('.'); -}; From 6afdb6c2b698737e63f781aba7fae71ab28106d6 Mon Sep 17 00:00:00 2001 From: ThibG Date: Tue, 6 Aug 2019 11:59:28 +0200 Subject: [PATCH 08/13] [Glitch] Trap tab in modals Port 5c73746b695e5bc540b41f5ae1406eac6220886e to glitch-soc Signed-off-by: Thibaut Girka --- .../flavours/glitch/components/modal_root.js | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/app/javascript/flavours/glitch/components/modal_root.js b/app/javascript/flavours/glitch/components/modal_root.js index 4e8648b497a..fd0af9f6ee8 100644 --- a/app/javascript/flavours/glitch/components/modal_root.js +++ b/app/javascript/flavours/glitch/components/modal_root.js @@ -26,8 +26,30 @@ export default class ModalRoot extends React.PureComponent { } } + handleKeyDown = (e) => { + if (e.key === 'Tab') { + const focusable = Array.from(this.node.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((x) => window.getComputedStyle(x).display !== 'none'); + const index = focusable.indexOf(e.target); + + let element; + + if (e.shiftKey) { + element = focusable[index - 1] || focusable[focusable.length - 1]; + } else { + element = focusable[index + 1] || focusable[0]; + } + + if (element) { + element.focus(); + e.stopPropagation(); + e.preventDefault(); + } + } + } + componentDidMount () { window.addEventListener('keyup', this.handleKeyUp, false); + window.addEventListener('keydown', this.handleKeyDown, false); this.history = this.context.router ? this.context.router.history : createHistory(); } @@ -60,6 +82,7 @@ export default class ModalRoot extends React.PureComponent { componentWillUnmount () { window.removeEventListener('keyup', this.handleKeyUp); + window.removeEventListener('keydown', this.handleKeyDown); } handleModalClose () { From fe1de4e49b2ee6b74139d8ac7811104095c7477b Mon Sep 17 00:00:00 2001 From: ThibG Date: Tue, 6 Aug 2019 11:59:46 +0200 Subject: [PATCH 09/13] [Glitch] Improve dropdown menu keyboard navigation Port a12f1a0baf3d31ecc9779c25b4bf4a0c9bd95543 to glitch-soc Signed-off-by: Thibaut Girka --- .../flavours/glitch/actions/modal.js | 3 +- .../glitch/components/dropdown_menu.js | 44 +++++++++++-------- .../containers/dropdown_menu_container.js | 2 +- .../flavours/glitch/reducers/modal.js | 2 +- 4 files changed, 30 insertions(+), 21 deletions(-) diff --git a/app/javascript/flavours/glitch/actions/modal.js b/app/javascript/flavours/glitch/actions/modal.js index 80e15c28ec1..3d0299db58a 100644 --- a/app/javascript/flavours/glitch/actions/modal.js +++ b/app/javascript/flavours/glitch/actions/modal.js @@ -9,8 +9,9 @@ export function openModal(type, props) { }; }; -export function closeModal() { +export function closeModal(type) { return { type: MODAL_CLOSE, + modalType: type, }; }; diff --git a/app/javascript/flavours/glitch/components/dropdown_menu.js b/app/javascript/flavours/glitch/components/dropdown_menu.js index 05611c13510..f29b824d5e4 100644 --- a/app/javascript/flavours/glitch/components/dropdown_menu.js +++ b/app/javascript/flavours/glitch/components/dropdown_menu.js @@ -45,7 +45,10 @@ class DropdownMenu extends React.PureComponent { document.addEventListener('click', this.handleDocumentClick, false); document.addEventListener('keydown', this.handleKeyDown, false); document.addEventListener('touchend', this.handleDocumentClick, listenerOptions); - if (this.focusedItem && this.props.openedViaKeyboard) this.focusedItem.focus(); + this.activeElement = document.activeElement; + if (this.focusedItem && this.props.openedViaKeyboard) { + this.focusedItem.focus(); + } this.setState({ mounted: true }); } @@ -53,6 +56,9 @@ class DropdownMenu extends React.PureComponent { document.removeEventListener('click', this.handleDocumentClick, false); document.removeEventListener('keydown', this.handleKeyDown, false); document.removeEventListener('touchend', this.handleDocumentClick, listenerOptions); + if (this.activeElement) { + this.activeElement.focus(); + } } setRef = c => { @@ -81,6 +87,18 @@ class DropdownMenu extends React.PureComponent { element.focus(); } break; + case 'Tab': + if (e.shiftKey) { + element = items[index-1] || items[items.length-1]; + } else { + element = items[index+1] || items[0]; + } + if (element) { + element.focus(); + e.preventDefault(); + e.stopPropagation(); + } + break; case 'Home': element = items[0]; if (element) { @@ -93,11 +111,14 @@ class DropdownMenu extends React.PureComponent { element.focus(); } break; + case 'Escape': + this.props.onClose(); + break; } } - handleItemKeyDown = e => { - if (e.key === 'Enter') { + handleItemKeyUp = e => { + if (e.key === 'Enter' || e.key === ' ') { this.handleClick(e); } } @@ -126,7 +147,7 @@ class DropdownMenu extends React.PureComponent { return (
  • - + {text}
  • @@ -202,19 +223,6 @@ export default class Dropdown extends React.PureComponent { this.props.onClose(this.state.id); } - handleKeyDown = e => { - switch(e.key) { - case ' ': - case 'Enter': - this.handleClick(e); - e.preventDefault(); - break; - case 'Escape': - this.handleClose(); - break; - } - } - handleItemClick = (i, e) => { const { action, to } = this.props.items[i]; @@ -248,7 +256,7 @@ export default class Dropdown extends React.PureComponent { const open = this.state.id === openDropdownId; return ( -
    +
    ({ }) : openDropdownMenu(id, dropdownPlacement, keyboard)); }, onClose(id) { - dispatch(closeModal()); + dispatch(closeModal('ACTIONS')); dispatch(closeDropdownMenu(id)); }, }); diff --git a/app/javascript/flavours/glitch/reducers/modal.js b/app/javascript/flavours/glitch/reducers/modal.js index 80bc11dda40..7bd9d4b326b 100644 --- a/app/javascript/flavours/glitch/reducers/modal.js +++ b/app/javascript/flavours/glitch/reducers/modal.js @@ -10,7 +10,7 @@ export default function modal(state = initialState, action) { case MODAL_OPEN: return { modalType: action.modalType, modalProps: action.modalProps }; case MODAL_CLOSE: - return initialState; + return (action.modalType === undefined || action.modalType === state.modalType) ? initialState : state; default: return state; } From a4b15e2cf063666262e1caab7d213d7ec9d2b67b Mon Sep 17 00:00:00 2001 From: ThibG Date: Tue, 6 Aug 2019 11:59:58 +0200 Subject: [PATCH 10/13] [Glitch] Port changes to IconButton Port changes to IconButton from 27a0d02d0d960163e98595b05412c0d03a4875d0 to glitch-soc Signed-off-by: Thibaut Girka --- .../flavours/glitch/components/icon_button.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/app/javascript/flavours/glitch/components/icon_button.js b/app/javascript/flavours/glitch/components/icon_button.js index 6a25794d372..c1e2f664c17 100644 --- a/app/javascript/flavours/glitch/components/icon_button.js +++ b/app/javascript/flavours/glitch/components/icon_button.js @@ -11,6 +11,8 @@ export default class IconButton extends React.PureComponent { title: PropTypes.string.isRequired, icon: PropTypes.string.isRequired, onClick: PropTypes.func, + onMouseDown: PropTypes.func, + onKeyDown: PropTypes.func, size: PropTypes.number, active: PropTypes.bool, pressed: PropTypes.bool, @@ -43,6 +45,18 @@ export default class IconButton extends React.PureComponent { } } + handleMouseDown = (e) => { + if (!this.props.disabled && this.props.onMouseDown) { + this.props.onMouseDown(e); + } + } + + handleKeyDown = (e) => { + if (!this.props.disabled && this.props.onKeyDown) { + this.props.onKeyDown(e); + } + } + render () { let style = { fontSize: `${this.props.size}px`, @@ -105,6 +119,8 @@ export default class IconButton extends React.PureComponent { title={title} className={classes} onClick={this.handleClick} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleKeyDown} style={style} tabIndex={tabIndex} disabled={disabled} @@ -124,6 +140,8 @@ export default class IconButton extends React.PureComponent { title={title} className={classes} onClick={this.handleClick} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleKeyDown} style={style} tabIndex={tabIndex} disabled={disabled} From 381dbb6569b07554a2543082cbb2e736fb425e2a Mon Sep 17 00:00:00 2001 From: ThibG Date: Tue, 6 Aug 2019 12:08:19 +0200 Subject: [PATCH 11/13] [Glitch] Fix image uploads being perfectly white when canvas read access is blocked Port 111a0628fc161df4d76967d7dc7116b8a43fe8e2 to glitch-soc Signed-off-by: Thibaut Girka --- app/javascript/flavours/glitch/util/resize_image.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/javascript/flavours/glitch/util/resize_image.js b/app/javascript/flavours/glitch/util/resize_image.js index bbdbc865e9e..a8ec5f3fa76 100644 --- a/app/javascript/flavours/glitch/util/resize_image.js +++ b/app/javascript/flavours/glitch/util/resize_image.js @@ -67,6 +67,14 @@ const processImage = (img, { width, height, orientation, type = 'image/png' }) = context.drawImage(img, 0, 0, width, height); + // The Tor Browser and maybe other browsers may prevent reading from canvas + // and return an all-white image instead. Assume reading failed if the resized + // image is perfectly white. + const imageData = context.getImageData(0, 0, width, height); + if (imageData.every(value => value === 255)) { + throw 'Failed to read from canvas'; + } + canvas.toBlob(resolve, type); }); From 6d2b0fa3f0f32c874863e7fe035270dc31cdcc58 Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Tue, 6 Aug 2019 13:57:45 +0200 Subject: [PATCH 12/13] Refactor composer Dropdown's component a bit to make it closer to upstream --- .../features/compose/components/dropdown.js | 155 +++++++----------- 1 file changed, 63 insertions(+), 92 deletions(-) diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown.js b/app/javascript/flavours/glitch/features/compose/components/dropdown.js index 764dcea6965..6a5f4575e40 100644 --- a/app/javascript/flavours/glitch/features/compose/components/dropdown.js +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.js @@ -12,33 +12,71 @@ import DropdownMenu from './dropdown_menu'; import { isUserTouching } from 'flavours/glitch/util/is_mobile'; import { assignHandlers } from 'flavours/glitch/util/react_helpers'; -// Handlers. -const handlers = { +// The component. +export default class ComposerOptionsDropdown extends React.PureComponent { - // Closes the dropdown. - handleClose () { - this.setState({ open: false }); - }, + static propTypes = { + active: PropTypes.bool, + disabled: PropTypes.bool, + icon: PropTypes.string, + items: PropTypes.arrayOf(PropTypes.shape({ + icon: PropTypes.string, + meta: PropTypes.node, + name: PropTypes.string.isRequired, + on: PropTypes.bool, + text: PropTypes.node, + })).isRequired, + onModalOpen: PropTypes.func, + onModalClose: PropTypes.func, + title: PropTypes.string, + value: PropTypes.string, + onChange: PropTypes.func, + }; - // The enter key toggles the dropdown's open state, and the escape - // key closes it. - handleKeyDown ({ key }) { - const { - handleClose, - handleToggle, - } = this.handlers; - switch (key) { + state = { + needsModalUpdate: false, + open: false, + placement: 'bottom', + }; + + // Toggles opening and closing the dropdown. + handleToggle = ({ target }) => { + const { onModalOpen } = this.props; + const { open } = this.state; + + if (isUserTouching()) { + if (this.state.open) { + this.props.onModalClose(); + } else { + const modal = this.handleMakeModal(); + if (modal && onModalOpen) { + onModalOpen(modal); + } + } + } else { + const { top } = target.getBoundingClientRect(); + this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); + this.setState({ open: !this.state.open }); + } + } + + handleKeyDown = (e) => { + switch (e.key) { case 'Enter': - handleToggle(key); + this.handleToggle(key); break; case 'Escape': - handleClose(); + this.handleClose(); break; } - }, + } + + handleClose = () => { + this.setState({ open: false }); + } // Creates an action modal object. - handleMakeModal () { + handleMakeModal = () => { const component = this; const { items, @@ -76,85 +114,37 @@ const handlers = { }) ), }; - }, - - // Toggles opening and closing the dropdown. - handleToggle ({ target }) { - const { handleMakeModal } = this.handlers; - const { onModalOpen } = this.props; - const { open } = this.state; - - // If this is a touch device, we open a modal instead of the - // dropdown. - if (isUserTouching()) { - - // This gets the modal to open. - const modal = handleMakeModal(); - - // If we can, we then open the modal. - if (modal && onModalOpen) { - onModalOpen(modal); - return; - } - } - - const { top } = target.getBoundingClientRect(); - this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); - // Otherwise, we just set our state to open. - this.setState({ open: !open }); - }, + } // If our modal is open and our props update, we need to also update // the modal. - handleUpdate () { - const { handleMakeModal } = this.handlers; + handleUpdate = () => { const { onModalOpen } = this.props; const { needsModalUpdate } = this.state; // Gets our modal object. - const modal = handleMakeModal(); + const modal = this.handleMakeModal(); // Reopens the modal with the new object. if (needsModalUpdate && modal && onModalOpen) { onModalOpen(modal); } - }, -}; - -// The component. -export default class ComposerOptionsDropdown extends React.PureComponent { - - // Constructor. - constructor (props) { - super(props); - assignHandlers(this, handlers); - this.state = { - needsModalUpdate: false, - open: false, - placement: 'bottom', - }; } // Updates our modal as necessary. componentDidUpdate (prevProps) { - const { handleUpdate } = this.handlers; const { items } = this.props; const { needsModalUpdate } = this.state; if (needsModalUpdate && items.find( (item, i) => item.on !== prevProps.items[i].on )) { - handleUpdate(); + this.handleUpdate(); this.setState({ needsModalUpdate: false }); } } // Rendering. render () { - const { - handleClose, - handleKeyDown, - handleToggle, - } = this.handlers; const { active, disabled, @@ -175,7 +165,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent { return (
    @@ -209,22 +199,3 @@ export default class ComposerOptionsDropdown extends React.PureComponent { } } - -// Props. -ComposerOptionsDropdown.propTypes = { - active: PropTypes.bool, - disabled: PropTypes.bool, - icon: PropTypes.string, - items: PropTypes.arrayOf(PropTypes.shape({ - icon: PropTypes.string, - meta: PropTypes.node, - name: PropTypes.string.isRequired, - on: PropTypes.bool, - text: PropTypes.node, - })).isRequired, - onChange: PropTypes.func, - onModalClose: PropTypes.func, - onModalOpen: PropTypes.func, - title: PropTypes.string, - value: PropTypes.string, -}; From d10f6036cfeebec5b2c160db8659d2c19d29fe9c Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Tue, 6 Aug 2019 14:18:09 +0200 Subject: [PATCH 13/13] Implement keyboard navigation in glitch-soc composer --- .../flavours/glitch/components/icon_button.js | 9 + .../features/compose/components/dropdown.js | 44 ++- .../compose/components/dropdown_menu.js | 254 ++++++++++-------- 3 files changed, 194 insertions(+), 113 deletions(-) diff --git a/app/javascript/flavours/glitch/components/icon_button.js b/app/javascript/flavours/glitch/components/icon_button.js index c1e2f664c17..5213532385e 100644 --- a/app/javascript/flavours/glitch/components/icon_button.js +++ b/app/javascript/flavours/glitch/components/icon_button.js @@ -13,6 +13,7 @@ export default class IconButton extends React.PureComponent { onClick: PropTypes.func, onMouseDown: PropTypes.func, onKeyDown: PropTypes.func, + onKeyPress: PropTypes.func, size: PropTypes.number, active: PropTypes.bool, pressed: PropTypes.bool, @@ -45,6 +46,12 @@ export default class IconButton extends React.PureComponent { } } + handleKeyPress = (e) => { + if (this.props.onKeyPress && !this.props.disabled) { + this.props.onKeyPress(e); + } + } + handleMouseDown = (e) => { if (!this.props.disabled && this.props.onMouseDown) { this.props.onMouseDown(e); @@ -121,6 +128,7 @@ export default class IconButton extends React.PureComponent { onClick={this.handleClick} onMouseDown={this.handleMouseDown} onKeyDown={this.handleKeyDown} + onKeyPress={this.handleKeyPress} style={style} tabIndex={tabIndex} disabled={disabled} @@ -142,6 +150,7 @@ export default class IconButton extends React.PureComponent { onClick={this.handleClick} onMouseDown={this.handleMouseDown} onKeyDown={this.handleKeyDown} + onKeyPress={this.handleKeyPress} style={style} tabIndex={tabIndex} disabled={disabled} diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown.js b/app/javascript/flavours/glitch/features/compose/components/dropdown.js index 6a5f4575e40..60035b70577 100644 --- a/app/javascript/flavours/glitch/features/compose/components/dropdown.js +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown.js @@ -36,11 +36,12 @@ export default class ComposerOptionsDropdown extends React.PureComponent { state = { needsModalUpdate: false, open: false, + openedViaKeyboard: undefined, placement: 'bottom', }; // Toggles opening and closing the dropdown. - handleToggle = ({ target }) => { + handleToggle = ({ target, type }) => { const { onModalOpen } = this.props; const { open } = this.state; @@ -55,23 +56,52 @@ export default class ComposerOptionsDropdown extends React.PureComponent { } } else { const { top } = target.getBoundingClientRect(); + if (this.state.open && this.activeElement) { + this.activeElement.focus(); + } this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' }); - this.setState({ open: !this.state.open }); + this.setState({ open: !this.state.open, openedViaKeyboard: type !== 'click' }); } } handleKeyDown = (e) => { switch (e.key) { - case 'Enter': - this.handleToggle(key); - break; case 'Escape': this.handleClose(); break; } } + handleMouseDown = () => { + if (!this.state.open) { + this.activeElement = document.activeElement; + } + } + + handleButtonKeyDown = (e) => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleMouseDown(); + break; + } + } + + handleKeyPress = (e) => { + switch(e.key) { + case ' ': + case 'Enter': + this.handleToggle(e); + e.stopPropagation(); + e.preventDefault(); + break; + } + } + handleClose = () => { + if (this.state.open && this.activeElement) { + this.activeElement.focus(); + } this.setState({ open: false }); } @@ -174,6 +204,9 @@ export default class ComposerOptionsDropdown extends React.PureComponent { icon={icon} inverted onClick={this.handleToggle} + onMouseDown={this.handleMouseDown} + onKeyDown={this.handleButtonKeyDown} + onKeyPress={this.handleKeyPress} size={18} style={{ height: null, @@ -192,6 +225,7 @@ export default class ComposerOptionsDropdown extends React.PureComponent { onChange={onChange} onClose={this.handleClose} value={value} + openedViaKeyboard={this.state.openedViaKeyboard} />
    diff --git a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js index 19d35a8f41b..f812be7a9db 100644 --- a/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js +++ b/app/javascript/flavours/glitch/features/compose/components/dropdown_menu.js @@ -14,91 +14,6 @@ import { withPassive } from 'flavours/glitch/util/dom_helpers'; import Motion from 'flavours/glitch/util/optional_motion'; import { assignHandlers } from 'flavours/glitch/util/react_helpers'; -class ComposerOptionsDropdownContentItem extends ImmutablePureComponent { - - static propTypes = { - active: PropTypes.bool, - name: PropTypes.string, - onChange: PropTypes.func, - onClose: PropTypes.func, - options: PropTypes.shape({ - icon: PropTypes.string, - meta: PropTypes.node, - on: PropTypes.bool, - text: PropTypes.node, - }), - }; - - handleActivate = (e) => { - const { - name, - onChange, - onClose, - options: { on }, - } = this.props; - - // If the escape key was pressed, we close the dropdown. - if (e.key === 'Escape' && onClose) { - onClose(); - - // Otherwise, we both close the dropdown and change the value. - } else if (onChange && (!e.key || e.key === 'Enter')) { - e.preventDefault(); // Prevents change in focus on click - if ((on === null || typeof on === 'undefined') && onClose) { - onClose(); - } - onChange(name); - } - } - - // Rendering. - render () { - const { - active, - options: { - icon, - meta, - on, - text, - }, - } = this.props; - const computedClass = classNames('composer--options--dropdown--content--item', { - active, - lengthy: meta, - 'toggled-off': !on && on !== null && typeof on !== 'undefined', - 'toggled-on': on, - 'with-icon': icon, - }); - - let prefix = null; - - if (on !== null && typeof on !== 'undefined') { - prefix = ; - } else if (icon) { - prefix = - } - - // The result. - return ( -
    - {prefix} - -
    - {text} - {meta} -
    -
    - ); - } - -}; - // The spring to use with our motion. const springMotion = spring(1, { damping: 35, @@ -116,10 +31,11 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent on: PropTypes.bool, text: PropTypes.node, })), - onChange: PropTypes.func, - onClose: PropTypes.func, + onChange: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, style: PropTypes.object, value: PropTypes.string, + openedViaKeyboard: PropTypes.bool, }; static defaultProps = { @@ -128,14 +44,13 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent state = { mounted: false, + value: this.props.openedViaKeyboard ? this.props.items[0].name : undefined, }; // When the document is clicked elsewhere, we close the dropdown. - handleDocumentClick = ({ target }) => { - const { node } = this; - const { onClose } = this.props; - if (onClose && node && !node.contains(target)) { - onClose(); + handleDocumentClick = (e) => { + if (this.node && !this.node.contains(e.target)) { + this.props.onClose(); } } @@ -148,6 +63,11 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent componentDidMount () { document.addEventListener('click', this.handleDocumentClick, false); document.addEventListener('touchend', this.handleDocumentClick, withPassive); + if (this.focusedItem) { + this.focusedItem.focus(); + } else { + this.node.firstChild.focus(); + } this.setState({ mounted: true }); } @@ -157,6 +77,138 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent document.removeEventListener('touchend', this.handleDocumentClick, withPassive); } + handleClick = (e) => { + const name = e.currentTarget.getAttribute('data-index'); + + const { + onChange, + onClose, + items, + } = this.props; + + const { on } = this.props.items.find(item => item.name === name); + e.preventDefault(); // Prevents change in focus on click + if ((on === null || typeof on === 'undefined')) { + onClose(); + } + onChange(name); + } + + // Handle changes differently whether the dropdown is a list of options or actions + handleChange = (name) => { + if (this.props.value) { + this.props.onChange(name); + } else { + this.setState({ value: name }); + } + } + + handleKeyDown = e => { + const { items } = this.props; + const name = e.currentTarget.getAttribute('data-index'); + const index = items.findIndex(item => { + return (item.name === name); + }); + let element; + + switch(e.key) { + case 'Escape': + this.props.onClose(); + break; + case 'Enter': + case ' ': + this.handleClick(e); + break; + case 'ArrowDown': + element = this.node.childNodes[index + 1]; + if (element) { + element.focus(); + this.handleChange(element.getAttribute('data-index')); + } + break; + case 'ArrowUp': + element = this.node.childNodes[index - 1]; + if (element) { + element.focus(); + this.handleChange(element.getAttribute('data-index')); + } + break; + case 'Tab': + if (e.shiftKey) { + element = this.node.childNodes[index - 1] || this.node.lastChild; + } else { + element = this.node.childNodes[index + 1] || this.node.firstChild; + } + if (element) { + element.focus(); + this.handleChange(element.getAttribute('data-index')); + e.preventDefault(); + e.stopPropagation(); + } + break; + case 'Home': + element = this.node.firstChild; + if (element) { + element.focus(); + this.handleChange(element.getAttribute('data-index')); + } + break; + case 'End': + element = this.node.lastChild; + if (element) { + element.focus(); + this.handleChange(element.getAttribute('data-index')); + } + break; + } + } + + setFocusRef = c => { + this.focusedItem = c; + } + + renderItem = (item) => { + const { name, icon, meta, on, text } = item; + + const active = (name === (this.props.value || this.state.value)); + + const computedClass = classNames('composer--options--dropdown--content--item', { + active, + lengthy: meta, + 'toggled-off': !on && on !== null && typeof on !== 'undefined', + 'toggled-on': on, + 'with-icon': icon, + }); + + let prefix = null; + + if (on !== null && typeof on !== 'undefined') { + prefix = ; + } else if (icon) { + prefix = + } + + return ( +
    + {prefix} + +
    + {text} + {meta} +
    +
    + ); + } + // Rendering. render () { const { mounted } = this.state; @@ -165,7 +217,6 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent onChange, onClose, style, - value, } = this.props; // The result. @@ -189,27 +240,14 @@ export default class ComposerOptionsDropdownContent extends React.PureComponent
    - {items ? items.map( - ({ - name, - ...rest - }) => ( - - ) - ) : null} + {!!items && items.map(item => this.renderItem(item))}
    )}