Merge pull request #1704 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes
This commit is contained in:
commit
551820cbd9
|
@ -32,10 +32,11 @@ Layout/EmptyLineAfterGuardClause:
|
||||||
Layout/EmptyLinesAroundAttributeAccessor:
|
Layout/EmptyLinesAroundAttributeAccessor:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
|
|
||||||
|
Layout/FirstHashElementIndentation:
|
||||||
|
EnforcedStyle: consistent
|
||||||
|
|
||||||
Layout/HashAlignment:
|
Layout/HashAlignment:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
# EnforcedHashRocketStyle: table
|
|
||||||
# EnforcedColonStyle: table
|
|
||||||
|
|
||||||
Layout/SpaceAroundMethodCallOperator:
|
Layout/SpaceAroundMethodCallOperator:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
|
|
2
Gemfile
2
Gemfile
|
@ -18,7 +18,7 @@ gem 'makara', '~> 0.5'
|
||||||
gem 'pghero', '~> 2.8'
|
gem 'pghero', '~> 2.8'
|
||||||
gem 'dotenv-rails', '~> 2.7'
|
gem 'dotenv-rails', '~> 2.7'
|
||||||
|
|
||||||
gem 'aws-sdk-s3', '~> 1.112', require: false
|
gem 'aws-sdk-s3', '~> 1.113', require: false
|
||||||
gem 'fog-core', '<= 2.1.0'
|
gem 'fog-core', '<= 2.1.0'
|
||||||
gem 'fog-openstack', '~> 0.3', require: false
|
gem 'fog-openstack', '~> 0.3', require: false
|
||||||
gem 'kt-paperclip', '~> 7.1'
|
gem 'kt-paperclip', '~> 7.1'
|
||||||
|
|
24
Gemfile.lock
24
Gemfile.lock
|
@ -79,17 +79,17 @@ GEM
|
||||||
encryptor (~> 3.0.0)
|
encryptor (~> 3.0.0)
|
||||||
awrence (1.1.1)
|
awrence (1.1.1)
|
||||||
aws-eventstream (1.2.0)
|
aws-eventstream (1.2.0)
|
||||||
aws-partitions (1.553.0)
|
aws-partitions (1.558.0)
|
||||||
aws-sdk-core (3.126.0)
|
aws-sdk-core (3.127.0)
|
||||||
aws-eventstream (~> 1, >= 1.0.2)
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
aws-partitions (~> 1, >= 1.525.0)
|
aws-partitions (~> 1, >= 1.525.0)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sigv4 (~> 1.1)
|
||||||
jmespath (~> 1.0)
|
jmespath (~> 1.0)
|
||||||
aws-sdk-kms (1.54.0)
|
aws-sdk-kms (1.55.0)
|
||||||
aws-sdk-core (~> 3, >= 3.126.0)
|
aws-sdk-core (~> 3, >= 3.127.0)
|
||||||
aws-sigv4 (~> 1.1)
|
aws-sigv4 (~> 1.1)
|
||||||
aws-sdk-s3 (1.112.0)
|
aws-sdk-s3 (1.113.0)
|
||||||
aws-sdk-core (~> 3, >= 3.126.0)
|
aws-sdk-core (~> 3, >= 3.127.0)
|
||||||
aws-sdk-kms (~> 1)
|
aws-sdk-kms (~> 1)
|
||||||
aws-sigv4 (~> 1.4)
|
aws-sigv4 (~> 1.4)
|
||||||
aws-sigv4 (1.4.0)
|
aws-sigv4 (1.4.0)
|
||||||
|
@ -305,7 +305,7 @@ GEM
|
||||||
terminal-table (>= 1.5.1)
|
terminal-table (>= 1.5.1)
|
||||||
idn-ruby (0.1.4)
|
idn-ruby (0.1.4)
|
||||||
ipaddress (0.8.3)
|
ipaddress (0.8.3)
|
||||||
jmespath (1.5.0)
|
jmespath (1.6.0)
|
||||||
json (2.5.1)
|
json (2.5.1)
|
||||||
json-canonicalization (0.3.0)
|
json-canonicalization (0.3.0)
|
||||||
json-ld (3.2.0)
|
json-ld (3.2.0)
|
||||||
|
@ -376,7 +376,7 @@ GEM
|
||||||
mime-types-data (~> 3.2015)
|
mime-types-data (~> 3.2015)
|
||||||
mime-types-data (3.2022.0105)
|
mime-types-data (3.2022.0105)
|
||||||
mini_mime (1.1.2)
|
mini_mime (1.1.2)
|
||||||
mini_portile2 (2.7.1)
|
mini_portile2 (2.8.0)
|
||||||
minitest (5.15.0)
|
minitest (5.15.0)
|
||||||
msgpack (1.4.4)
|
msgpack (1.4.4)
|
||||||
multi_json (1.15.0)
|
multi_json (1.15.0)
|
||||||
|
@ -386,8 +386,8 @@ GEM
|
||||||
net-ssh (>= 2.6.5, < 7.0.0)
|
net-ssh (>= 2.6.5, < 7.0.0)
|
||||||
net-ssh (6.1.0)
|
net-ssh (6.1.0)
|
||||||
nio4r (2.5.8)
|
nio4r (2.5.8)
|
||||||
nokogiri (1.13.1)
|
nokogiri (1.13.3)
|
||||||
mini_portile2 (~> 2.7.0)
|
mini_portile2 (~> 2.8.0)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nsa (0.2.8)
|
nsa (0.2.8)
|
||||||
activesupport (>= 4.2, < 7)
|
activesupport (>= 4.2, < 7)
|
||||||
|
@ -418,7 +418,7 @@ GEM
|
||||||
parslet (2.0.0)
|
parslet (2.0.0)
|
||||||
pastel (0.8.0)
|
pastel (0.8.0)
|
||||||
tty-color (~> 0.5)
|
tty-color (~> 0.5)
|
||||||
pg (1.3.2)
|
pg (1.3.3)
|
||||||
pghero (2.8.2)
|
pghero (2.8.2)
|
||||||
activerecord (>= 5)
|
activerecord (>= 5)
|
||||||
pkg-config (1.4.7)
|
pkg-config (1.4.7)
|
||||||
|
@ -686,7 +686,7 @@ DEPENDENCIES
|
||||||
active_record_query_trace (~> 1.8)
|
active_record_query_trace (~> 1.8)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
annotate (~> 3.2)
|
annotate (~> 3.2)
|
||||||
aws-sdk-s3 (~> 1.112)
|
aws-sdk-s3 (~> 1.113)
|
||||||
better_errors (~> 2.9)
|
better_errors (~> 2.9)
|
||||||
binding_of_caller (~> 1.0)
|
binding_of_caller (~> 1.0)
|
||||||
blurhash (~> 0.1)
|
blurhash (~> 0.1)
|
||||||
|
|
|
@ -6,7 +6,20 @@ module Admin
|
||||||
|
|
||||||
def index
|
def index
|
||||||
authorize :email_domain_block, :index?
|
authorize :email_domain_block, :index?
|
||||||
|
|
||||||
@email_domain_blocks = EmailDomainBlock.where(parent_id: nil).includes(:children).order(id: :desc).page(params[:page])
|
@email_domain_blocks = EmailDomainBlock.where(parent_id: nil).includes(:children).order(id: :desc).page(params[:page])
|
||||||
|
@form = Form::EmailDomainBlockBatch.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def batch
|
||||||
|
@form = Form::EmailDomainBlockBatch.new(form_email_domain_block_batch_params.merge(current_account: current_account, action: action_from_button))
|
||||||
|
@form.save
|
||||||
|
rescue ActionController::ParameterMissing
|
||||||
|
flash[:alert] = I18n.t('admin.email_domain_blocks.no_email_domain_block_selected')
|
||||||
|
rescue Mastodon::NotPermittedError
|
||||||
|
flash[:alert] = I18n.t('admin.custom_emojis.not_permitted')
|
||||||
|
ensure
|
||||||
|
redirect_to admin_email_domain_blocks_path
|
||||||
end
|
end
|
||||||
|
|
||||||
def new
|
def new
|
||||||
|
@ -19,41 +32,27 @@ module Admin
|
||||||
|
|
||||||
@email_domain_block = EmailDomainBlock.new(resource_params)
|
@email_domain_block = EmailDomainBlock.new(resource_params)
|
||||||
|
|
||||||
if @email_domain_block.save
|
if action_from_button == 'save'
|
||||||
log_action :create, @email_domain_block
|
EmailDomainBlock.transaction do
|
||||||
|
@email_domain_block.save!
|
||||||
|
log_action :create, @email_domain_block
|
||||||
|
|
||||||
if @email_domain_block.with_dns_records?
|
(@email_domain_block.other_domains || []).uniq.each do |domain|
|
||||||
hostnames = []
|
next if EmailDomainBlock.where(domain: domain).exists?
|
||||||
ips = []
|
|
||||||
|
|
||||||
Resolv::DNS.open do |dns|
|
other_email_domain_block = EmailDomainBlock.create!(domain: domain, parent: @email_domain_block)
|
||||||
dns.timeouts = 5
|
log_action :create, other_email_domain_block
|
||||||
|
|
||||||
hostnames = dns.getresources(@email_domain_block.domain, Resolv::DNS::Resource::IN::MX).to_a.map { |e| e.exchange.to_s }
|
|
||||||
|
|
||||||
([@email_domain_block.domain] + hostnames).uniq.each do |hostname|
|
|
||||||
ips.concat(dns.getresources(hostname, Resolv::DNS::Resource::IN::A).to_a.map { |e| e.address.to_s })
|
|
||||||
ips.concat(dns.getresources(hostname, Resolv::DNS::Resource::IN::AAAA).to_a.map { |e| e.address.to_s })
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
(hostnames + ips).each do |hostname|
|
|
||||||
another_email_domain_block = EmailDomainBlock.new(domain: hostname, parent: @email_domain_block)
|
|
||||||
log_action :create, another_email_domain_block if another_email_domain_block.save
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.created_msg')
|
redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.created_msg')
|
||||||
else
|
else
|
||||||
|
set_resolved_records
|
||||||
render :new
|
render :new
|
||||||
end
|
end
|
||||||
end
|
rescue ActiveRecord::RecordInvalid
|
||||||
|
set_resolved_records
|
||||||
def destroy
|
render :new
|
||||||
authorize @email_domain_block, :destroy?
|
|
||||||
@email_domain_block.destroy!
|
|
||||||
log_action :destroy, @email_domain_block
|
|
||||||
redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.destroyed_msg')
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@ -62,8 +61,27 @@ module Admin
|
||||||
@email_domain_block = EmailDomainBlock.find(params[:id])
|
@email_domain_block = EmailDomainBlock.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_resolved_records
|
||||||
|
Resolv::DNS.open do |dns|
|
||||||
|
dns.timeouts = 5
|
||||||
|
@resolved_records = dns.getresources(@email_domain_block.domain, Resolv::DNS::Resource::IN::MX).to_a
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def resource_params
|
def resource_params
|
||||||
params.require(:email_domain_block).permit(:domain, :with_dns_records)
|
params.require(:email_domain_block).permit(:domain, other_domains: [])
|
||||||
|
end
|
||||||
|
|
||||||
|
def form_email_domain_block_batch_params
|
||||||
|
params.require(:form_email_domain_block_batch).permit(email_domain_block_ids: [])
|
||||||
|
end
|
||||||
|
|
||||||
|
def action_from_button
|
||||||
|
if params[:delete]
|
||||||
|
'delete'
|
||||||
|
elsif params[:save]
|
||||||
|
'save'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,11 +5,11 @@ class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseControll
|
||||||
authorize :preview_card_provider, :index?
|
authorize :preview_card_provider, :index?
|
||||||
|
|
||||||
@preview_card_providers = filtered_preview_card_providers.page(params[:page])
|
@preview_card_providers = filtered_preview_card_providers.page(params[:page])
|
||||||
@form = Form::PreviewCardProviderBatch.new
|
@form = Trends::PreviewCardProviderBatch.new
|
||||||
end
|
end
|
||||||
|
|
||||||
def batch
|
def batch
|
||||||
@form = Form::PreviewCardProviderBatch.new(form_preview_card_provider_batch_params.merge(current_account: current_account, action: action_from_button))
|
@form = Trends::PreviewCardProviderBatch.new(trends_preview_card_provider_batch_params.merge(current_account: current_account, action: action_from_button))
|
||||||
@form.save
|
@form.save
|
||||||
rescue ActionController::ParameterMissing
|
rescue ActionController::ParameterMissing
|
||||||
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
||||||
|
@ -20,15 +20,15 @@ class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseControll
|
||||||
private
|
private
|
||||||
|
|
||||||
def filtered_preview_card_providers
|
def filtered_preview_card_providers
|
||||||
PreviewCardProviderFilter.new(filter_params).results
|
Trends::PreviewCardProviderFilter.new(filter_params).results
|
||||||
end
|
end
|
||||||
|
|
||||||
def filter_params
|
def filter_params
|
||||||
params.slice(:page, *PreviewCardProviderFilter::KEYS).permit(:page, *PreviewCardProviderFilter::KEYS)
|
params.slice(:page, *Trends::PreviewCardProviderFilter::KEYS).permit(:page, *Trends::PreviewCardProviderFilter::KEYS)
|
||||||
end
|
end
|
||||||
|
|
||||||
def form_preview_card_provider_batch_params
|
def trends_preview_card_provider_batch_params
|
||||||
params.require(:form_preview_card_provider_batch).permit(:action, preview_card_provider_ids: [])
|
params.require(:trends_preview_card_provider_batch).permit(:action, preview_card_provider_ids: [])
|
||||||
end
|
end
|
||||||
|
|
||||||
def action_from_button
|
def action_from_button
|
||||||
|
|
|
@ -5,11 +5,11 @@ class Admin::Trends::LinksController < Admin::BaseController
|
||||||
authorize :preview_card, :index?
|
authorize :preview_card, :index?
|
||||||
|
|
||||||
@preview_cards = filtered_preview_cards.page(params[:page])
|
@preview_cards = filtered_preview_cards.page(params[:page])
|
||||||
@form = Form::PreviewCardBatch.new
|
@form = Trends::PreviewCardBatch.new
|
||||||
end
|
end
|
||||||
|
|
||||||
def batch
|
def batch
|
||||||
@form = Form::PreviewCardBatch.new(form_preview_card_batch_params.merge(current_account: current_account, action: action_from_button))
|
@form = Trends::PreviewCardBatch.new(trends_preview_card_batch_params.merge(current_account: current_account, action: action_from_button))
|
||||||
@form.save
|
@form.save
|
||||||
rescue ActionController::ParameterMissing
|
rescue ActionController::ParameterMissing
|
||||||
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
||||||
|
@ -20,26 +20,26 @@ class Admin::Trends::LinksController < Admin::BaseController
|
||||||
private
|
private
|
||||||
|
|
||||||
def filtered_preview_cards
|
def filtered_preview_cards
|
||||||
PreviewCardFilter.new(filter_params.with_defaults(trending: 'all')).results
|
Trends::PreviewCardFilter.new(filter_params.with_defaults(trending: 'all')).results
|
||||||
end
|
end
|
||||||
|
|
||||||
def filter_params
|
def filter_params
|
||||||
params.slice(:page, *PreviewCardFilter::KEYS).permit(:page, *PreviewCardFilter::KEYS)
|
params.slice(:page, *Trends::PreviewCardFilter::KEYS).permit(:page, *Trends::PreviewCardFilter::KEYS)
|
||||||
end
|
end
|
||||||
|
|
||||||
def form_preview_card_batch_params
|
def trends_preview_card_batch_params
|
||||||
params.require(:form_preview_card_batch).permit(:action, preview_card_ids: [])
|
params.require(:trends_preview_card_batch).permit(:action, preview_card_ids: [])
|
||||||
end
|
end
|
||||||
|
|
||||||
def action_from_button
|
def action_from_button
|
||||||
if params[:approve]
|
if params[:approve]
|
||||||
'approve'
|
'approve'
|
||||||
elsif params[:approve_all]
|
elsif params[:approve_providers]
|
||||||
'approve_all'
|
'approve_providers'
|
||||||
elsif params[:reject]
|
elsif params[:reject]
|
||||||
'reject'
|
'reject'
|
||||||
elsif params[:reject_all]
|
elsif params[:reject_providers]
|
||||||
'reject_all'
|
'reject_providers'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Admin::Trends::StatusesController < Admin::BaseController
|
||||||
|
def index
|
||||||
|
authorize :status, :index?
|
||||||
|
|
||||||
|
@statuses = filtered_statuses.page(params[:page])
|
||||||
|
@form = Trends::StatusBatch.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def batch
|
||||||
|
@form = Trends::StatusBatch.new(trends_status_batch_params.merge(current_account: current_account, action: action_from_button))
|
||||||
|
@form.save
|
||||||
|
rescue ActionController::ParameterMissing
|
||||||
|
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
||||||
|
ensure
|
||||||
|
redirect_to admin_trends_statuses_path(filter_params)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def filtered_statuses
|
||||||
|
Trends::StatusFilter.new(filter_params.with_defaults(trending: 'all')).results.includes(:account, :media_attachments, :active_mentions)
|
||||||
|
end
|
||||||
|
|
||||||
|
def filter_params
|
||||||
|
params.slice(:page, *Trends::StatusFilter::KEYS).permit(:page, *Trends::StatusFilter::KEYS)
|
||||||
|
end
|
||||||
|
|
||||||
|
def trends_status_batch_params
|
||||||
|
params.require(:trends_status_batch).permit(:action, status_ids: [])
|
||||||
|
end
|
||||||
|
|
||||||
|
def action_from_button
|
||||||
|
if params[:approve]
|
||||||
|
'approve'
|
||||||
|
elsif params[:approve_accounts]
|
||||||
|
'approve_accounts'
|
||||||
|
elsif params[:reject]
|
||||||
|
'reject'
|
||||||
|
elsif params[:reject_accounts]
|
||||||
|
'reject_accounts'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -5,11 +5,11 @@ class Admin::Trends::TagsController < Admin::BaseController
|
||||||
authorize :tag, :index?
|
authorize :tag, :index?
|
||||||
|
|
||||||
@tags = filtered_tags.page(params[:page])
|
@tags = filtered_tags.page(params[:page])
|
||||||
@form = Form::TagBatch.new
|
@form = Trends::TagBatch.new
|
||||||
end
|
end
|
||||||
|
|
||||||
def batch
|
def batch
|
||||||
@form = Form::TagBatch.new(form_tag_batch_params.merge(current_account: current_account, action: action_from_button))
|
@form = Trends::TagBatch.new(trends_tag_batch_params.merge(current_account: current_account, action: action_from_button))
|
||||||
@form.save
|
@form.save
|
||||||
rescue ActionController::ParameterMissing
|
rescue ActionController::ParameterMissing
|
||||||
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
|
||||||
|
@ -20,15 +20,15 @@ class Admin::Trends::TagsController < Admin::BaseController
|
||||||
private
|
private
|
||||||
|
|
||||||
def filtered_tags
|
def filtered_tags
|
||||||
TagFilter.new(filter_params).results
|
Trends::TagFilter.new(filter_params).results
|
||||||
end
|
end
|
||||||
|
|
||||||
def filter_params
|
def filter_params
|
||||||
params.slice(:page, *TagFilter::KEYS).permit(:page, *TagFilter::KEYS)
|
params.slice(:page, *Trends::TagFilter::KEYS).permit(:page, *Trends::TagFilter::KEYS)
|
||||||
end
|
end
|
||||||
|
|
||||||
def form_tag_batch_params
|
def trends_tag_batch_params
|
||||||
params.require(:form_tag_batch).permit(:action, tag_ids: [])
|
params.require(:trends_tag_batch).permit(:action, tag_ids: [])
|
||||||
end
|
end
|
||||||
|
|
||||||
def action_from_button
|
def action_from_button
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::Admin::Trends::LinksController < Api::BaseController
|
||||||
|
protect_from_forgery with: :exception
|
||||||
|
|
||||||
|
before_action -> { authorize_if_got_token! :'admin:read' }
|
||||||
|
before_action :require_staff!
|
||||||
|
before_action :set_links
|
||||||
|
|
||||||
|
def index
|
||||||
|
render json: @links, each_serializer: REST::Trends::LinkSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_links
|
||||||
|
@links = Trends.links.query.limit(limit_param(10))
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,19 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::Admin::Trends::StatusesController < Api::BaseController
|
||||||
|
protect_from_forgery with: :exception
|
||||||
|
|
||||||
|
before_action -> { authorize_if_got_token! :'admin:read' }
|
||||||
|
before_action :require_staff!
|
||||||
|
before_action :set_statuses
|
||||||
|
|
||||||
|
def index
|
||||||
|
render json: @statuses, each_serializer: REST::StatusSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_statuses
|
||||||
|
@statuses = cache_collection(Trends.statuses.query.limit(limit_param(DEFAULT_STATUSES_LIMIT)), Status)
|
||||||
|
end
|
||||||
|
end
|
|
@ -14,6 +14,6 @@ class Api::V1::Admin::Trends::TagsController < Api::BaseController
|
||||||
private
|
private
|
||||||
|
|
||||||
def set_tags
|
def set_tags
|
||||||
@tags = Trends.tags.get(false, limit_param(10))
|
@tags = Trends.tags.query.limit(limit_param(10))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -12,10 +12,14 @@ class Api::V1::Trends::LinksController < Api::BaseController
|
||||||
def set_links
|
def set_links
|
||||||
@links = begin
|
@links = begin
|
||||||
if Setting.trends
|
if Setting.trends
|
||||||
Trends.links.get(true, limit_param(10))
|
links_from_trends
|
||||||
else
|
else
|
||||||
[]
|
[]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def links_from_trends
|
||||||
|
Trends.links.query.allowed.in_locale(content_locale).limit(limit_param(10))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Api::V1::Trends::StatusesController < Api::BaseController
|
||||||
|
before_action :set_statuses
|
||||||
|
|
||||||
|
def index
|
||||||
|
render json: @statuses, each_serializer: REST::StatusSerializer
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_statuses
|
||||||
|
@statuses = begin
|
||||||
|
if Setting.trends
|
||||||
|
cache_collection(statuses_from_trends, Status)
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def statuses_from_trends
|
||||||
|
scope = Trends.statuses.query.allowed.in_locale(content_locale)
|
||||||
|
scope = scope.filtered_for(current_account) if user_signed_in?
|
||||||
|
scope.limit(limit_param(DEFAULT_STATUSES_LIMIT))
|
||||||
|
end
|
||||||
|
end
|
|
@ -12,7 +12,7 @@ class Api::V1::Trends::TagsController < Api::BaseController
|
||||||
def set_tags
|
def set_tags
|
||||||
@tags = begin
|
@tags = begin
|
||||||
if Setting.trends
|
if Setting.trends
|
||||||
Trends.tags.get(true, limit_param(10))
|
Trends.tags.query.allowed.limit(limit_param(10))
|
||||||
else
|
else
|
||||||
[]
|
[]
|
||||||
end
|
end
|
||||||
|
|
|
@ -27,4 +27,8 @@ module Localized
|
||||||
def available_locale_or_nil(locale_name)
|
def available_locale_or_nil(locale_name)
|
||||||
locale_name.to_sym if locale_name.present? && I18n.available_locales.include?(locale_name.to_sym)
|
locale_name.to_sym if locale_name.present? && I18n.available_locales.include?(locale_name.to_sym)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def content_locale
|
||||||
|
@content_locale ||= I18n.locale.to_s.split(/[_-]/).first
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -58,7 +58,7 @@ class Settings::PreferencesController < Settings::BaseController
|
||||||
:setting_use_pending_items,
|
:setting_use_pending_items,
|
||||||
:setting_trends,
|
:setting_trends,
|
||||||
:setting_crop_images,
|
:setting_crop_images,
|
||||||
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account trending_tag),
|
notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account trending_tag trending_link trending_status),
|
||||||
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,9 +5,10 @@ module Admin::FilterHelper
|
||||||
AccountFilter::KEYS,
|
AccountFilter::KEYS,
|
||||||
CustomEmojiFilter::KEYS,
|
CustomEmojiFilter::KEYS,
|
||||||
ReportFilter::KEYS,
|
ReportFilter::KEYS,
|
||||||
TagFilter::KEYS,
|
Trends::TagFilter::KEYS,
|
||||||
PreviewCardProviderFilter::KEYS,
|
Trends::PreviewCardProviderFilter::KEYS,
|
||||||
PreviewCardFilter::KEYS,
|
Trends::PreviewCardFilter::KEYS,
|
||||||
|
Trends::StatusFilter::KEYS,
|
||||||
InstanceFilter::KEYS,
|
InstanceFilter::KEYS,
|
||||||
InviteFilter::KEYS,
|
InviteFilter::KEYS,
|
||||||
RelationshipFilter::KEYS,
|
RelationshipFilter::KEYS,
|
||||||
|
|
|
@ -242,6 +242,6 @@ module LanguagesHelper
|
||||||
end
|
end
|
||||||
|
|
||||||
def valid_locale?(locale)
|
def valid_locale?(locale)
|
||||||
SUPPORTED_LOCALES.key?(locale.to_sym)
|
locale.present? && SUPPORTED_LOCALES.key?(locale.to_sym)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -9,9 +9,10 @@ export function openModal(type, props) {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function closeModal(type) {
|
export function closeModal(type, options = { ignoreFocus: false }) {
|
||||||
return {
|
return {
|
||||||
type: MODAL_CLOSE,
|
type: MODAL_CLOSE,
|
||||||
modalType: type,
|
modalType: type,
|
||||||
|
ignoreFocus: options.ignoreFocus,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -30,6 +30,7 @@ export default class IconButton extends React.PureComponent {
|
||||||
label: PropTypes.string,
|
label: PropTypes.string,
|
||||||
counter: PropTypes.number,
|
counter: PropTypes.number,
|
||||||
obfuscateCount: PropTypes.bool,
|
obfuscateCount: PropTypes.bool,
|
||||||
|
href: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
|
@ -109,6 +110,7 @@ export default class IconButton extends React.PureComponent {
|
||||||
title,
|
title,
|
||||||
counter,
|
counter,
|
||||||
obfuscateCount,
|
obfuscateCount,
|
||||||
|
href,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
@ -130,6 +132,21 @@ export default class IconButton extends React.PureComponent {
|
||||||
style.width = 'auto';
|
style.width = 'auto';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let contents = (
|
||||||
|
<React.Fragment>
|
||||||
|
<Icon id={icon} fixedWidth aria-hidden='true' /> {typeof counter !== 'undefined' && <span className='icon-button__counter'><AnimatedNumber value={counter} obfuscate={obfuscateCount} /></span>}
|
||||||
|
{this.props.label}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
contents = (
|
||||||
|
<a href={href} target='_blank' rel='noopener noreferrer'>
|
||||||
|
{contents}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
|
@ -145,8 +162,7 @@ export default class IconButton extends React.PureComponent {
|
||||||
tabIndex={tabIndex}
|
tabIndex={tabIndex}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<Icon id={icon} fixedWidth aria-hidden='true' /> {typeof counter !== 'undefined' && <span className='icon-button__counter'><AnimatedNumber value={counter} obfuscate={obfuscateCount} /></span>}
|
{contents}
|
||||||
{this.props.label}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@ export default class ModalRoot extends React.PureComponent {
|
||||||
b: PropTypes.number,
|
b: PropTypes.number,
|
||||||
}),
|
}),
|
||||||
noEsc: PropTypes.bool,
|
noEsc: PropTypes.bool,
|
||||||
|
ignoreFocus: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
activeElement = this.props.children ? document.activeElement : null;
|
activeElement = this.props.children ? document.activeElement : null;
|
||||||
|
@ -72,7 +73,9 @@ export default class ModalRoot extends React.PureComponent {
|
||||||
// immediately selectable, we have to wait for observers to run, as
|
// immediately selectable, we have to wait for observers to run, as
|
||||||
// described in https://github.com/WICG/inert#performance-and-gotchas
|
// described in https://github.com/WICG/inert#performance-and-gotchas
|
||||||
Promise.resolve().then(() => {
|
Promise.resolve().then(() => {
|
||||||
this.activeElement.focus({ preventScroll: true });
|
if (!this.props.ignoreFocus) {
|
||||||
|
this.activeElement.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
this.activeElement = null;
|
this.activeElement = null;
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
|
|
||||||
|
|
|
@ -246,9 +246,14 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
selectionStart = selectionEnd = text.length;
|
selectionStart = selectionEnd = text.length;
|
||||||
}
|
}
|
||||||
if (textarea) {
|
if (textarea) {
|
||||||
textarea.setSelectionRange(selectionStart, selectionEnd);
|
// Because of the wicg-inert polyfill, the activeElement may not be
|
||||||
textarea.focus();
|
// immediately selectable, we have to wait for observers to run, as
|
||||||
if (!singleColumn) textarea.scrollIntoView();
|
// described in https://github.com/WICG/inert#performance-and-gotchas
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
textarea.setSelectionRange(selectionStart, selectionEnd);
|
||||||
|
textarea.focus();
|
||||||
|
if (!singleColumn) textarea.scrollIntoView();
|
||||||
|
}).catch(console.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refocuses the textarea after submitting.
|
// Refocuses the textarea after submitting.
|
||||||
|
|
|
@ -62,7 +62,7 @@ class Footer extends ImmutablePureComponent {
|
||||||
const { router } = this.context;
|
const { router } = this.context;
|
||||||
|
|
||||||
if (onClose) {
|
if (onClose) {
|
||||||
onClose();
|
onClose(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(replyCompose(status, router.history));
|
dispatch(replyCompose(status, router.history));
|
||||||
|
@ -181,7 +181,7 @@ class Footer extends ImmutablePureComponent {
|
||||||
{replyButton}
|
{replyButton}
|
||||||
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
|
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
|
||||||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
|
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
|
||||||
{withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' onClick={this.handleOpenClick} />}
|
{withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' onClick={this.handleOpenClick} href={status.get('url')} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -55,6 +55,7 @@ export default class ModalRoot extends React.PureComponent {
|
||||||
type: PropTypes.string,
|
type: PropTypes.string,
|
||||||
props: PropTypes.object,
|
props: PropTypes.object,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
|
ignoreFocus: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
|
@ -85,7 +86,7 @@ export default class ModalRoot extends React.PureComponent {
|
||||||
return <BundleModalError {...props} onClose={onClose} />;
|
return <BundleModalError {...props} onClose={onClose} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleClose = () => {
|
handleClose = (ignoreFocus = false) => {
|
||||||
const { onClose } = this.props;
|
const { onClose } = this.props;
|
||||||
let message = null;
|
let message = null;
|
||||||
try {
|
try {
|
||||||
|
@ -95,7 +96,7 @@ export default class ModalRoot extends React.PureComponent {
|
||||||
// isn't set.
|
// isn't set.
|
||||||
// This would be much smoother with react-intl 3+ and `forwardRef`.
|
// This would be much smoother with react-intl 3+ and `forwardRef`.
|
||||||
}
|
}
|
||||||
onClose(message);
|
onClose(message, ignoreFocus);
|
||||||
}
|
}
|
||||||
|
|
||||||
setModalRef = (c) => {
|
setModalRef = (c) => {
|
||||||
|
@ -103,12 +104,12 @@ export default class ModalRoot extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { type, props } = this.props;
|
const { type, props, ignoreFocus } = this.props;
|
||||||
const { backgroundColor } = this.state;
|
const { backgroundColor } = this.state;
|
||||||
const visible = !!type;
|
const visible = !!type;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Base backgroundColor={backgroundColor} onClose={this.handleClose} noEsc={props ? props.noEsc : false}>
|
<Base backgroundColor={backgroundColor} onClose={this.handleClose} noEsc={props ? props.noEsc : false} ignoreFocus={ignoreFocus}>
|
||||||
{visible && (
|
{visible && (
|
||||||
<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
|
<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
|
||||||
{(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={this.setModalRef} />}
|
{(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={this.setModalRef} />}
|
||||||
|
|
|
@ -3,22 +3,23 @@ import { openModal, closeModal } from 'flavours/glitch/actions/modal';
|
||||||
import ModalRoot from '../components/modal_root';
|
import ModalRoot from '../components/modal_root';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
type: state.getIn(['modal', 0, 'modalType'], null),
|
ignoreFocus: state.getIn(['modal', 'ignoreFocus']),
|
||||||
props: state.getIn(['modal', 0, 'modalProps'], {}),
|
type: state.getIn(['modal', 'stack', 0, 'modalType'], null),
|
||||||
|
props: state.getIn(['modal', 'stack', 0, 'modalProps'], {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
const mapDispatchToProps = dispatch => ({
|
||||||
onClose (confirmationMessage) {
|
onClose (confirmationMessage, ignoreFocus = false) {
|
||||||
if (confirmationMessage) {
|
if (confirmationMessage) {
|
||||||
dispatch(
|
dispatch(
|
||||||
openModal('CONFIRM', {
|
openModal('CONFIRM', {
|
||||||
message: confirmationMessage.message,
|
message: confirmationMessage.message,
|
||||||
confirm: confirmationMessage.confirm,
|
confirm: confirmationMessage.confirm,
|
||||||
onConfirm: () => dispatch(closeModal()),
|
onConfirm: () => dispatch(closeModal(undefined, { ignoreFocus })),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
dispatch(closeModal());
|
dispatch(closeModal(undefined, { ignoreFocus }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -3,16 +3,36 @@ import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines';
|
||||||
import { COMPOSE_UPLOAD_CHANGE_SUCCESS } from 'flavours/glitch/actions/compose';
|
import { COMPOSE_UPLOAD_CHANGE_SUCCESS } from 'flavours/glitch/actions/compose';
|
||||||
import { Stack as ImmutableStack, Map as ImmutableMap } from 'immutable';
|
import { Stack as ImmutableStack, Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
export default function modal(state = ImmutableStack(), action) {
|
const initialState = ImmutableMap({
|
||||||
|
ignoreFocus: false,
|
||||||
|
stack: ImmutableStack(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const popModal = (state, { modalType, ignoreFocus }) => {
|
||||||
|
if (modalType === undefined || modalType === state.getIn(['stack', 0, 'modalType'])) {
|
||||||
|
return state.set('ignoreFocus', !!ignoreFocus).update('stack', stack => stack.shift());
|
||||||
|
} else {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushModal = (state, modalType, modalProps) => {
|
||||||
|
return state.withMutations(map => {
|
||||||
|
map.set('ignoreFocus', false);
|
||||||
|
map.update('stack', stack => stack.unshift(ImmutableMap({ modalType, modalProps })));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function modal(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case MODAL_OPEN:
|
case MODAL_OPEN:
|
||||||
return state.unshift(ImmutableMap({ modalType: action.modalType, modalProps: action.modalProps }));
|
return pushModal(state, action.modalType, action.modalProps);
|
||||||
case MODAL_CLOSE:
|
case MODAL_CLOSE:
|
||||||
return (action.modalType === undefined || action.modalType === state.getIn([0, 'modalType'])) ? state.shift() : state;
|
return popModal(state, action);
|
||||||
case COMPOSE_UPLOAD_CHANGE_SUCCESS:
|
case COMPOSE_UPLOAD_CHANGE_SUCCESS:
|
||||||
return state.getIn([0, 'modalType']) === 'FOCAL_POINT' ? state.shift() : state;
|
return popModal(state, { modalType: 'FOCAL_POINT', ignoreFocus: false });
|
||||||
case TIMELINE_DELETE:
|
case TIMELINE_DELETE:
|
||||||
return state.filterNot((modal) => modal.get('modalProps').statusId === action.id);
|
return state.update('stack', stack => stack.filterNot((modal) => modal.get('modalProps').statusId === action.id));
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -333,7 +333,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.batch-table__row--muted .pending-account__header,
|
.batch-table__row--muted .pending-account__header,
|
||||||
.batch-table__row--muted .accounts-table {
|
.batch-table__row--muted .accounts-table,
|
||||||
|
.batch-table__row--muted .name-tag {
|
||||||
&,
|
&,
|
||||||
a,
|
a,
|
||||||
strong {
|
strong {
|
||||||
|
@ -341,6 +342,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.batch-table__row--muted .name-tag .avatar {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
.batch-table__row--muted .accounts-table {
|
.batch-table__row--muted .accounts-table {
|
||||||
tbody td.accounts-table__extra,
|
tbody td.accounts-table__extra,
|
||||||
&__count,
|
&__count,
|
||||||
|
@ -354,7 +359,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.batch-table__row--attention .pending-account__header,
|
.batch-table__row--attention .pending-account__header,
|
||||||
.batch-table__row--attention .accounts-table {
|
.batch-table__row--attention .accounts-table,
|
||||||
|
.batch-table__row--attention .name-tag {
|
||||||
&,
|
&,
|
||||||
a,
|
a,
|
||||||
strong {
|
strong {
|
||||||
|
|
|
@ -146,6 +146,11 @@
|
||||||
transition-property: background-color, color;
|
transition-property: background-color, color;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:active,
|
&:active,
|
||||||
&:focus {
|
&:focus {
|
||||||
|
|
|
@ -210,6 +210,7 @@ a.table-action-link {
|
||||||
&__content {
|
&__content {
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
&--unpadded {
|
&--unpadded {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
@ -292,3 +293,9 @@ a.table-action-link {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.one-liner {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
|
@ -9,9 +9,10 @@ export function openModal(type, props) {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function closeModal(type) {
|
export function closeModal(type, options = { ignoreFocus: false }) {
|
||||||
return {
|
return {
|
||||||
type: MODAL_CLOSE,
|
type: MODAL_CLOSE,
|
||||||
modalType: type,
|
modalType: type,
|
||||||
|
ignoreFocus: options.ignoreFocus,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,31 +1,94 @@
|
||||||
import api from '../api';
|
import api from '../api';
|
||||||
|
import { importFetchedStatuses } from './importer';
|
||||||
|
|
||||||
export const TRENDS_FETCH_REQUEST = 'TRENDS_FETCH_REQUEST';
|
export const TRENDS_TAGS_FETCH_REQUEST = 'TRENDS_TAGS_FETCH_REQUEST';
|
||||||
export const TRENDS_FETCH_SUCCESS = 'TRENDS_FETCH_SUCCESS';
|
export const TRENDS_TAGS_FETCH_SUCCESS = 'TRENDS_TAGS_FETCH_SUCCESS';
|
||||||
export const TRENDS_FETCH_FAIL = 'TRENDS_FETCH_FAIL';
|
export const TRENDS_TAGS_FETCH_FAIL = 'TRENDS_TAGS_FETCH_FAIL';
|
||||||
|
|
||||||
export const fetchTrends = () => (dispatch, getState) => {
|
export const TRENDS_LINKS_FETCH_REQUEST = 'TRENDS_LINKS_FETCH_REQUEST';
|
||||||
dispatch(fetchTrendsRequest());
|
export const TRENDS_LINKS_FETCH_SUCCESS = 'TRENDS_LINKS_FETCH_SUCCESS';
|
||||||
|
export const TRENDS_LINKS_FETCH_FAIL = 'TRENDS_LINKS_FETCH_FAIL';
|
||||||
|
|
||||||
|
export const TRENDS_STATUSES_FETCH_REQUEST = 'TRENDS_STATUSES_FETCH_REQUEST';
|
||||||
|
export const TRENDS_STATUSES_FETCH_SUCCESS = 'TRENDS_STATUSES_FETCH_SUCCESS';
|
||||||
|
export const TRENDS_STATUSES_FETCH_FAIL = 'TRENDS_STATUSES_FETCH_FAIL';
|
||||||
|
|
||||||
|
export const fetchTrendingHashtags = () => (dispatch, getState) => {
|
||||||
|
dispatch(fetchTrendingHashtagsRequest());
|
||||||
|
|
||||||
api(getState)
|
api(getState)
|
||||||
.get('/api/v1/trends')
|
.get('/api/v1/trends/tags')
|
||||||
.then(({ data }) => dispatch(fetchTrendsSuccess(data)))
|
.then(({ data }) => dispatch(fetchTrendingHashtagsSuccess(data)))
|
||||||
.catch(err => dispatch(fetchTrendsFail(err)));
|
.catch(err => dispatch(fetchTrendingHashtagsFail(err)));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchTrendsRequest = () => ({
|
export const fetchTrendingHashtagsRequest = () => ({
|
||||||
type: TRENDS_FETCH_REQUEST,
|
type: TRENDS_TAGS_FETCH_REQUEST,
|
||||||
skipLoading: true,
|
skipLoading: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const fetchTrendsSuccess = trends => ({
|
export const fetchTrendingHashtagsSuccess = trends => ({
|
||||||
type: TRENDS_FETCH_SUCCESS,
|
type: TRENDS_TAGS_FETCH_SUCCESS,
|
||||||
trends,
|
trends,
|
||||||
skipLoading: true,
|
skipLoading: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const fetchTrendsFail = error => ({
|
export const fetchTrendingHashtagsFail = error => ({
|
||||||
type: TRENDS_FETCH_FAIL,
|
type: TRENDS_TAGS_FETCH_FAIL,
|
||||||
|
error,
|
||||||
|
skipLoading: true,
|
||||||
|
skipAlert: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchTrendingLinks = () => (dispatch, getState) => {
|
||||||
|
dispatch(fetchTrendingLinksRequest());
|
||||||
|
|
||||||
|
api(getState)
|
||||||
|
.get('/api/v1/trends/links')
|
||||||
|
.then(({ data }) => dispatch(fetchTrendingLinksSuccess(data)))
|
||||||
|
.catch(err => dispatch(fetchTrendingLinksFail(err)));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchTrendingLinksRequest = () => ({
|
||||||
|
type: TRENDS_LINKS_FETCH_REQUEST,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchTrendingLinksSuccess = trends => ({
|
||||||
|
type: TRENDS_LINKS_FETCH_SUCCESS,
|
||||||
|
trends,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchTrendingLinksFail = error => ({
|
||||||
|
type: TRENDS_LINKS_FETCH_FAIL,
|
||||||
|
error,
|
||||||
|
skipLoading: true,
|
||||||
|
skipAlert: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchTrendingStatuses = () => (dispatch, getState) => {
|
||||||
|
dispatch(fetchTrendingStatusesRequest());
|
||||||
|
|
||||||
|
api(getState).get('/api/v1/trends/statuses').then(({ data }) => {
|
||||||
|
dispatch(importFetchedStatuses(data));
|
||||||
|
dispatch(fetchTrendingStatusesSuccess(data));
|
||||||
|
}).catch(err => dispatch(fetchTrendingStatusesFail(err)));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchTrendingStatusesRequest = () => ({
|
||||||
|
type: TRENDS_STATUSES_FETCH_REQUEST,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchTrendingStatusesSuccess = statuses => ({
|
||||||
|
type: TRENDS_STATUSES_FETCH_SUCCESS,
|
||||||
|
statuses,
|
||||||
|
skipLoading: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchTrendingStatusesFail = error => ({
|
||||||
|
type: TRENDS_STATUSES_FETCH_FAIL,
|
||||||
error,
|
error,
|
||||||
skipLoading: true,
|
skipLoading: true,
|
||||||
skipAlert: true,
|
skipAlert: true,
|
||||||
|
|
|
@ -38,7 +38,7 @@ class SilentErrorBoundary extends React.Component {
|
||||||
*
|
*
|
||||||
* @type {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
|
* @type {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element}
|
||||||
*/
|
*/
|
||||||
const accountsCountRenderer = (displayNumber, pluralReady) => (
|
export const accountsCountRenderer = (displayNumber, pluralReady) => (
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='trends.counter_by_accounts'
|
id='trends.counter_by_accounts'
|
||||||
defaultMessage='{count, plural, one {{counter} person} other {{counter} people}} talking'
|
defaultMessage='{count, plural, one {{counter} person} other {{counter} people}} talking'
|
||||||
|
|
|
@ -27,6 +27,7 @@ export default class IconButton extends React.PureComponent {
|
||||||
tabIndex: PropTypes.string,
|
tabIndex: PropTypes.string,
|
||||||
counter: PropTypes.number,
|
counter: PropTypes.number,
|
||||||
obfuscateCount: PropTypes.bool,
|
obfuscateCount: PropTypes.bool,
|
||||||
|
href: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
static defaultProps = {
|
static defaultProps = {
|
||||||
|
@ -102,6 +103,7 @@ export default class IconButton extends React.PureComponent {
|
||||||
title,
|
title,
|
||||||
counter,
|
counter,
|
||||||
obfuscateCount,
|
obfuscateCount,
|
||||||
|
href,
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
@ -123,6 +125,20 @@ export default class IconButton extends React.PureComponent {
|
||||||
style.width = 'auto';
|
style.width = 'auto';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let contents = (
|
||||||
|
<React.Fragment>
|
||||||
|
<Icon id={icon} fixedWidth aria-hidden='true' /> {typeof counter !== 'undefined' && <span className='icon-button__counter'><AnimatedNumber value={counter} obfuscate={obfuscateCount} /></span>}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (href) {
|
||||||
|
contents = (
|
||||||
|
<a href={href} target='_blank' rel='noopener noreferrer'>
|
||||||
|
{contents}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
aria-label={title}
|
aria-label={title}
|
||||||
|
@ -138,7 +154,7 @@ export default class IconButton extends React.PureComponent {
|
||||||
tabIndex={tabIndex}
|
tabIndex={tabIndex}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<Icon id={icon} fixedWidth aria-hidden='true' /> {typeof counter !== 'undefined' && <span className='icon-button__counter'><AnimatedNumber value={counter} obfuscate={obfuscateCount} /></span>}
|
{contents}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@ export default class ModalRoot extends React.PureComponent {
|
||||||
g: PropTypes.number,
|
g: PropTypes.number,
|
||||||
b: PropTypes.number,
|
b: PropTypes.number,
|
||||||
}),
|
}),
|
||||||
|
ignoreFocus: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
activeElement = this.props.children ? document.activeElement : null;
|
activeElement = this.props.children ? document.activeElement : null;
|
||||||
|
@ -72,7 +73,9 @@ export default class ModalRoot extends React.PureComponent {
|
||||||
// immediately selectable, we have to wait for observers to run, as
|
// immediately selectable, we have to wait for observers to run, as
|
||||||
// described in https://github.com/WICG/inert#performance-and-gotchas
|
// described in https://github.com/WICG/inert#performance-and-gotchas
|
||||||
Promise.resolve().then(() => {
|
Promise.resolve().then(() => {
|
||||||
this.activeElement.focus({ preventScroll: true });
|
if (!this.props.ignoreFocus) {
|
||||||
|
this.activeElement.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
this.activeElement = null;
|
this.activeElement = null;
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
|
|
||||||
|
|
|
@ -77,6 +77,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
onPin: PropTypes.func,
|
onPin: PropTypes.func,
|
||||||
onBookmark: PropTypes.func,
|
onBookmark: PropTypes.func,
|
||||||
withDismiss: PropTypes.bool,
|
withDismiss: PropTypes.bool,
|
||||||
|
withCounters: PropTypes.bool,
|
||||||
scrollKey: PropTypes.string,
|
scrollKey: PropTypes.string,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
};
|
};
|
||||||
|
@ -226,7 +227,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { status, relationship, intl, withDismiss, scrollKey } = this.props;
|
const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props;
|
||||||
|
|
||||||
const anonymousAccess = !me;
|
const anonymousAccess = !me;
|
||||||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
||||||
|
@ -331,8 +332,8 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
return (
|
return (
|
||||||
<div className='status__action-bar'>
|
<div className='status__action-bar'>
|
||||||
<IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
|
<IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
|
||||||
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
|
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={withCounters ? status.get('reblogs_count') : undefined} />
|
||||||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
|
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={withCounters ? status.get('favourites_count') : undefined} />
|
||||||
|
|
||||||
{shareButton}
|
{shareButton}
|
||||||
|
|
||||||
|
|
|
@ -24,6 +24,7 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
prepend: PropTypes.node,
|
prepend: PropTypes.node,
|
||||||
emptyMessage: PropTypes.node,
|
emptyMessage: PropTypes.node,
|
||||||
alwaysPrepend: PropTypes.bool,
|
alwaysPrepend: PropTypes.bool,
|
||||||
|
withCounters: PropTypes.bool,
|
||||||
timelineId: PropTypes.string,
|
timelineId: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -100,6 +101,7 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
contextType={timelineId}
|
contextType={timelineId}
|
||||||
scrollKey={this.props.scrollKey}
|
scrollKey={this.props.scrollKey}
|
||||||
showThread
|
showThread
|
||||||
|
withCounters={this.props.withCounters}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
) : null;
|
) : null;
|
||||||
|
@ -114,6 +116,7 @@ export default class StatusList extends ImmutablePureComponent {
|
||||||
onMoveDown={this.handleMoveDown}
|
onMoveDown={this.handleMoveDown}
|
||||||
contextType={timelineId}
|
contextType={timelineId}
|
||||||
showThread
|
showThread
|
||||||
|
withCounters={this.props.withCounters}
|
||||||
/>
|
/>
|
||||||
)).concat(scrollableContent);
|
)).concat(scrollableContent);
|
||||||
}
|
}
|
||||||
|
|
|
@ -164,8 +164,13 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
selectionStart = selectionEnd;
|
selectionStart = selectionEnd;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
|
// Because of the wicg-inert polyfill, the activeElement may not be
|
||||||
this.autosuggestTextarea.textarea.focus();
|
// immediately selectable, we have to wait for observers to run, as
|
||||||
|
// described in https://github.com/WICG/inert#performance-and-gotchas
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd);
|
||||||
|
this.autosuggestTextarea.textarea.focus();
|
||||||
|
}).catch(console.error);
|
||||||
} else if(prevProps.isSubmitting && !this.props.isSubmitting) {
|
} else if(prevProps.isSubmitting && !this.props.isSubmitting) {
|
||||||
this.autosuggestTextarea.textarea.focus();
|
this.autosuggestTextarea.textarea.focus();
|
||||||
} else if (this.props.spoiler !== prevProps.spoiler) {
|
} else if (this.props.spoiler !== prevProps.spoiler) {
|
||||||
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import Blurhash from 'mastodon/components/blurhash';
|
||||||
|
import { accountsCountRenderer } from 'mastodon/components/hashtag';
|
||||||
|
import ShortNumber from 'mastodon/components/short_number';
|
||||||
|
import Skeleton from 'mastodon/components/skeleton';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
export default class Story extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
url: PropTypes.string,
|
||||||
|
title: PropTypes.string,
|
||||||
|
publisher: PropTypes.string,
|
||||||
|
sharedTimes: PropTypes.number,
|
||||||
|
thumbnail: PropTypes.string,
|
||||||
|
blurhash: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
thumbnailLoaded: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleImageLoad = () => this.setState({ thumbnailLoaded: true });
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { url, title, publisher, sharedTimes, thumbnail, blurhash } = this.props;
|
||||||
|
|
||||||
|
const { thumbnailLoaded } = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a className='story' href={url} target='blank' rel='noopener'>
|
||||||
|
<div className='story__details'>
|
||||||
|
<div className='story__details__publisher'>{publisher ? publisher : <Skeleton width={50} />}</div>
|
||||||
|
<div className='story__details__title'>{title ? title : <Skeleton />}</div>
|
||||||
|
<div className='story__details__shared'>{typeof sharedTimes === 'number' ? <ShortNumber value={sharedTimes} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='story__thumbnail'>
|
||||||
|
{thumbnail ? (
|
||||||
|
<React.Fragment>
|
||||||
|
<div className={classNames('story__thumbnail__preview', { 'story__thumbnail__preview--hidden': thumbnailLoaded })}><Blurhash hash={blurhash} /></div>
|
||||||
|
<img src={thumbnail} onLoad={this.handleImageLoad} alt='' role='presentation' />
|
||||||
|
</React.Fragment>
|
||||||
|
) : <Skeleton />}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,91 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import Column from 'mastodon/components/column';
|
||||||
|
import ColumnHeader from 'mastodon/components/column_header';
|
||||||
|
import { NavLink, Switch, Route } from 'react-router-dom';
|
||||||
|
import Links from './links';
|
||||||
|
import Tags from './tags';
|
||||||
|
import Statuses from './statuses';
|
||||||
|
import Suggestions from './suggestions';
|
||||||
|
import Search from 'mastodon/features/compose/containers/search_container';
|
||||||
|
import SearchResults from './results';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: { id: 'explore.title', defaultMessage: 'Explore' },
|
||||||
|
searchResults: { id: 'explore.search_results', defaultMessage: 'Search results' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
layout: state.getIn(['meta', 'layout']),
|
||||||
|
isSearching: state.getIn(['search', 'submitted']),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default @connect(mapStateToProps)
|
||||||
|
@injectIntl
|
||||||
|
class Explore extends React.PureComponent {
|
||||||
|
|
||||||
|
static contextTypes = {
|
||||||
|
router: PropTypes.object,
|
||||||
|
};
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
intl: PropTypes.object.isRequired,
|
||||||
|
multiColumn: PropTypes.bool,
|
||||||
|
isSearching: PropTypes.bool,
|
||||||
|
layout: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
|
handleHeaderClick = () => {
|
||||||
|
this.column.scrollTop();
|
||||||
|
}
|
||||||
|
|
||||||
|
setRef = c => {
|
||||||
|
this.column = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { intl, multiColumn, isSearching, layout } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
|
||||||
|
{layout === 'mobile' ? (
|
||||||
|
<div className='explore__search-header'>
|
||||||
|
<Search />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ColumnHeader
|
||||||
|
icon={isSearching ? 'search' : 'globe'}
|
||||||
|
title={intl.formatMessage(isSearching ? messages.searchResults : messages.title)}
|
||||||
|
onClick={this.handleHeaderClick}
|
||||||
|
multiColumn={multiColumn}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='scrollable scrollable--flex'>
|
||||||
|
{isSearching ? (
|
||||||
|
<SearchResults />
|
||||||
|
) : (
|
||||||
|
<React.Fragment>
|
||||||
|
<div className='account__section-headline'>
|
||||||
|
<NavLink exact to='/explore'><FormattedMessage id='explore.trending_statuses' defaultMessage='Posts' /></NavLink>
|
||||||
|
<NavLink exact to='/explore/tags'><FormattedMessage id='explore.trending_tags' defaultMessage='Hashtags' /></NavLink>
|
||||||
|
<NavLink exact to='/explore/links'><FormattedMessage id='explore.trending_links' defaultMessage='News' /></NavLink>
|
||||||
|
<NavLink exact to='/explore/suggestions'><FormattedMessage id='explore.suggested_follows' defaultMessage='For you' /></NavLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Switch>
|
||||||
|
<Route path='/explore/tags' component={Tags} />
|
||||||
|
<Route path='/explore/links' component={Links} />
|
||||||
|
<Route path='/explore/suggestions' component={Suggestions} />
|
||||||
|
<Route exact path={['/explore', '/explore/posts', '/search']} component={Statuses} componentParams={{ multiColumn }} />
|
||||||
|
</Switch>
|
||||||
|
</React.Fragment>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Column>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import Story from './components/story';
|
||||||
|
import LoadingIndicator from 'mastodon/components/loading_indicator';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { fetchTrendingLinks } from 'mastodon/actions/trends';
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
links: state.getIn(['trends', 'links', 'items']),
|
||||||
|
isLoading: state.getIn(['trends', 'links', 'isLoading']),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default @connect(mapStateToProps)
|
||||||
|
class Links extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
links: ImmutablePropTypes.list,
|
||||||
|
isLoading: PropTypes.bool,
|
||||||
|
dispatch: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
dispatch(fetchTrendingLinks());
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { isLoading, links } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='explore__links'>
|
||||||
|
{isLoading ? (<LoadingIndicator />) : links.map(link => (
|
||||||
|
<Story
|
||||||
|
key={link.get('id')}
|
||||||
|
url={link.get('url')}
|
||||||
|
title={link.get('title')}
|
||||||
|
publisher={link.get('provider_name')}
|
||||||
|
sharedTimes={link.getIn(['history', 0, 'accounts']) * 1 + link.getIn(['history', 1, 'accounts']) * 1}
|
||||||
|
thumbnail={link.get('image')}
|
||||||
|
blurhash={link.get('blurhash')}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,113 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { expandSearch } from 'mastodon/actions/search';
|
||||||
|
import Account from 'mastodon/containers/account_container';
|
||||||
|
import Status from 'mastodon/containers/status_container';
|
||||||
|
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
|
||||||
|
import { List as ImmutableList } from 'immutable';
|
||||||
|
import LoadMore from 'mastodon/components/load_more';
|
||||||
|
import LoadingIndicator from 'mastodon/components/loading_indicator';
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
isLoading: state.getIn(['search', 'isLoading']),
|
||||||
|
results: state.getIn(['search', 'results']),
|
||||||
|
});
|
||||||
|
|
||||||
|
const appendLoadMore = (id, list, onLoadMore) => {
|
||||||
|
if (list.size >= 5) {
|
||||||
|
return list.push(<LoadMore key={`${id}-load-more`} visible onClick={onLoadMore} />);
|
||||||
|
} else {
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderAccounts = (results, onLoadMore) => appendLoadMore('accounts', results.get('accounts').map(item => (
|
||||||
|
<Account key={`account-${item}`} id={item} />
|
||||||
|
)), onLoadMore);
|
||||||
|
|
||||||
|
const renderHashtags = (results, onLoadMore) => appendLoadMore('hashtags', results.get('hashtags').map(item => (
|
||||||
|
<Hashtag key={`tag-${item.get('name')}`} hashtag={item} />
|
||||||
|
)), onLoadMore);
|
||||||
|
|
||||||
|
const renderStatuses = (results, onLoadMore) => appendLoadMore('statuses', results.get('statuses').map(item => (
|
||||||
|
<Status key={`status-${item}`} id={item} />
|
||||||
|
)), onLoadMore);
|
||||||
|
|
||||||
|
export default @connect(mapStateToProps)
|
||||||
|
class Results extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
results: ImmutablePropTypes.map,
|
||||||
|
isLoading: PropTypes.bool,
|
||||||
|
multiColumn: PropTypes.bool,
|
||||||
|
dispatch: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
state = {
|
||||||
|
type: 'all',
|
||||||
|
};
|
||||||
|
|
||||||
|
handleSelectAll = () => this.setState({ type: 'all' });
|
||||||
|
handleSelectAccounts = () => this.setState({ type: 'accounts' });
|
||||||
|
handleSelectHashtags = () => this.setState({ type: 'hashtags' });
|
||||||
|
handleSelectStatuses = () => this.setState({ type: 'statuses' });
|
||||||
|
handleLoadMoreAccounts = () => this.loadMore('accounts');
|
||||||
|
handleLoadMoreStatuses = () => this.loadMore('statuses');
|
||||||
|
handleLoadMoreHashtags = () => this.loadMore('hashtags');
|
||||||
|
|
||||||
|
loadMore (type) {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
dispatch(expandSearch(type));
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { isLoading, results } = this.props;
|
||||||
|
const { type } = this.state;
|
||||||
|
|
||||||
|
let filteredResults = ImmutableList();
|
||||||
|
|
||||||
|
if (!isLoading) {
|
||||||
|
switch(type) {
|
||||||
|
case 'all':
|
||||||
|
filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts), renderHashtags(results, this.handleLoadMoreHashtags), renderStatuses(results, this.handleLoadMoreStatuses));
|
||||||
|
break;
|
||||||
|
case 'accounts':
|
||||||
|
filteredResults = filteredResults.concat(renderAccounts(results, this.handleLoadMoreAccounts));
|
||||||
|
break;
|
||||||
|
case 'hashtags':
|
||||||
|
filteredResults = filteredResults.concat(renderHashtags(results, this.handleLoadMoreHashtags));
|
||||||
|
break;
|
||||||
|
case 'statuses':
|
||||||
|
filteredResults = filteredResults.concat(renderStatuses(results, this.handleLoadMoreStatuses));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredResults.size === 0) {
|
||||||
|
filteredResults = (
|
||||||
|
<div className='empty-column-indicator'>
|
||||||
|
<FormattedMessage id='search_results.nothing_found' defaultMessage='Could not find anything for these search terms' />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<div className='account__section-headline'>
|
||||||
|
<button onClick={this.handleSelectAll} className={type === 'all' && 'active'}><FormattedMessage id='search_results.all' defaultMessage='All' /></button>
|
||||||
|
<button onClick={this.handleSelectAccounts} className={type === 'accounts' && 'active'}><FormattedMessage id='search_results.accounts' defaultMessage='People' /></button>
|
||||||
|
<button onClick={this.handleSelectHashtags} className={type === 'hashtags' && 'active'}><FormattedMessage id='search_results.hashtags' defaultMessage='Hashtags' /></button>
|
||||||
|
<button onClick={this.handleSelectStatuses} className={type === 'statuses' && 'active'}><FormattedMessage id='search_results.statuses' defaultMessage='Toots' /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='explore__search-results'>
|
||||||
|
{isLoading ? (<LoadingIndicator />) : filteredResults}
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import StatusList from 'mastodon/components/status_list';
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { fetchTrendingStatuses } from 'mastodon/actions/trends';
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
statusIds: state.getIn(['status_lists', 'trending', 'items']),
|
||||||
|
isLoading: state.getIn(['status_lists', 'trending', 'isLoading'], true),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default @connect(mapStateToProps)
|
||||||
|
class Statuses extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
statusIds: ImmutablePropTypes.list,
|
||||||
|
isLoading: PropTypes.bool,
|
||||||
|
multiColumn: PropTypes.bool,
|
||||||
|
dispatch: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
dispatch(fetchTrendingStatuses());
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { isLoading, statusIds, multiColumn } = this.props;
|
||||||
|
|
||||||
|
const emptyMessage = <FormattedMessage id='empty_column.explore_statuses' defaultMessage='Nothing is trending right now. Check back later!' />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatusList
|
||||||
|
trackScroll
|
||||||
|
statusIds={statusIds}
|
||||||
|
scrollKey='explore-statuses'
|
||||||
|
hasMore={false}
|
||||||
|
isLoading={isLoading}
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
bindToDocument={!multiColumn}
|
||||||
|
withCounters
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import Account from 'mastodon/containers/account_container';
|
||||||
|
import LoadingIndicator from 'mastodon/components/loading_indicator';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { fetchSuggestions } from 'mastodon/actions/suggestions';
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
suggestions: state.getIn(['suggestions', 'items']),
|
||||||
|
isLoading: state.getIn(['suggestions', 'isLoading']),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default @connect(mapStateToProps)
|
||||||
|
class Suggestions extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
isLoading: PropTypes.bool,
|
||||||
|
suggestions: ImmutablePropTypes.list,
|
||||||
|
dispatch: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
dispatch(fetchSuggestions(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { isLoading, suggestions } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='explore__links'>
|
||||||
|
{isLoading ? (<LoadingIndicator />) : suggestions.map(suggestion => (
|
||||||
|
<Account key={suggestion.get('account')} id={suggestion.get('account')} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
|
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
|
||||||
|
import LoadingIndicator from 'mastodon/components/loading_indicator';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { fetchTrendingHashtags } from 'mastodon/actions/trends';
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
hashtags: state.getIn(['trends', 'tags', 'items']),
|
||||||
|
isLoadingHashtags: state.getIn(['trends', 'tags', 'isLoading']),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default @connect(mapStateToProps)
|
||||||
|
class Tags extends React.PureComponent {
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
hashtags: ImmutablePropTypes.list,
|
||||||
|
isLoading: PropTypes.bool,
|
||||||
|
dispatch: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
componentDidMount () {
|
||||||
|
const { dispatch } = this.props;
|
||||||
|
dispatch(fetchTrendingHashtags());
|
||||||
|
}
|
||||||
|
|
||||||
|
render () {
|
||||||
|
const { isLoading, hashtags } = this.props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='explore__links'>
|
||||||
|
{isLoading ? (<LoadingIndicator />) : hashtags.map(hashtag => (
|
||||||
|
<Hashtag key={hashtag.get('name')} hashtag={hashtag} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,13 +1,13 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { fetchTrends } from 'mastodon/actions/trends';
|
import { fetchTrendingHashtags } from 'mastodon/actions/trends';
|
||||||
import Trends from '../components/trends';
|
import Trends from '../components/trends';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
trends: state.getIn(['trends', 'items']),
|
trends: state.getIn(['trends', 'tags', 'items']),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
const mapDispatchToProps = dispatch => ({
|
||||||
fetchTrends: () => dispatch(fetchTrends()),
|
fetchTrends: () => dispatch(fetchTrendingHashtags()),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(mapStateToProps, mapDispatchToProps)(Trends);
|
export default connect(mapStateToProps, mapDispatchToProps)(Trends);
|
||||||
|
|
|
@ -60,7 +60,7 @@ class Footer extends ImmutablePureComponent {
|
||||||
const { router } = this.context;
|
const { router } = this.context;
|
||||||
|
|
||||||
if (onClose) {
|
if (onClose) {
|
||||||
onClose();
|
onClose(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(replyCompose(status, router.history));
|
dispatch(replyCompose(status, router.history));
|
||||||
|
@ -156,7 +156,7 @@ class Footer extends ImmutablePureComponent {
|
||||||
<IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
|
<IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
|
||||||
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
|
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
|
||||||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
|
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
|
||||||
{withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' onClick={this.handleOpenClick} />}
|
{withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' onClick={this.handleOpenClick} href={status.get('url')} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,17 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import SearchContainer from 'mastodon/features/compose/containers/search_container';
|
|
||||||
import SearchResultsContainer from 'mastodon/features/compose/containers/search_results_container';
|
|
||||||
|
|
||||||
const Search = () => (
|
|
||||||
<div className='column search-page'>
|
|
||||||
<SearchContainer />
|
|
||||||
|
|
||||||
<div className='drawer__pager'>
|
|
||||||
<div className='drawer__inner darker'>
|
|
||||||
<SearchResultsContainer />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default Search;
|
|
|
@ -53,7 +53,7 @@ const messages = defineMessages({
|
||||||
publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
|
publish: { id: 'compose_form.publish', defaultMessage: 'Toot' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const shouldHideFAB = path => path.match(/^\/statuses\/|^\/@[^/]+\/\d+|^\/publish|^\/search|^\/getting-started|^\/start/);
|
const shouldHideFAB = path => path.match(/^\/statuses\/|^\/@[^/]+\/\d+|^\/publish|^\/explore|^\/getting-started|^\/start/);
|
||||||
|
|
||||||
export default @(component => injectIntl(component, { withRef: true }))
|
export default @(component => injectIntl(component, { withRef: true }))
|
||||||
class ColumnsArea extends ImmutablePureComponent {
|
class ColumnsArea extends ImmutablePureComponent {
|
||||||
|
|
|
@ -45,6 +45,7 @@ export default class ModalRoot extends React.PureComponent {
|
||||||
type: PropTypes.string,
|
type: PropTypes.string,
|
||||||
props: PropTypes.object,
|
props: PropTypes.object,
|
||||||
onClose: PropTypes.func.isRequired,
|
onClose: PropTypes.func.isRequired,
|
||||||
|
ignoreFocus: PropTypes.bool,
|
||||||
};
|
};
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
|
@ -79,7 +80,7 @@ export default class ModalRoot extends React.PureComponent {
|
||||||
return <BundleModalError {...props} onClose={onClose} />;
|
return <BundleModalError {...props} onClose={onClose} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleClose = () => {
|
handleClose = (ignoreFocus = false) => {
|
||||||
const { onClose } = this.props;
|
const { onClose } = this.props;
|
||||||
let message = null;
|
let message = null;
|
||||||
try {
|
try {
|
||||||
|
@ -89,7 +90,7 @@ export default class ModalRoot extends React.PureComponent {
|
||||||
// isn't set.
|
// isn't set.
|
||||||
// This would be much smoother with react-intl 3+ and `forwardRef`.
|
// This would be much smoother with react-intl 3+ and `forwardRef`.
|
||||||
}
|
}
|
||||||
onClose(message);
|
onClose(message, ignoreFocus);
|
||||||
}
|
}
|
||||||
|
|
||||||
setModalRef = (c) => {
|
setModalRef = (c) => {
|
||||||
|
@ -97,12 +98,12 @@ export default class ModalRoot extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { type, props } = this.props;
|
const { type, props, ignoreFocus } = this.props;
|
||||||
const { backgroundColor } = this.state;
|
const { backgroundColor } = this.state;
|
||||||
const visible = !!type;
|
const visible = !!type;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Base backgroundColor={backgroundColor} onClose={this.handleClose}>
|
<Base backgroundColor={backgroundColor} onClose={this.handleClose} ignoreFocus={ignoreFocus}>
|
||||||
{visible && (
|
{visible && (
|
||||||
<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
|
<BundleContainer fetchComponent={MODAL_COMPONENTS[type]} loading={this.renderLoading(type)} error={this.renderError} renderDelay={200}>
|
||||||
{(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={this.setModalRef} />}
|
{(SpecificComponent) => <SpecificComponent {...props} onChangeBackgroundColor={this.setBackgroundColor} onClose={this.handleClose} ref={this.setModalRef} />}
|
||||||
|
|
|
@ -13,6 +13,7 @@ const NavigationPanel = () => (
|
||||||
<NavLink className='column-link column-link--transparent' to='/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon className='column-link__icon' id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>
|
<NavLink className='column-link column-link--transparent' to='/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon className='column-link__icon' id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>
|
||||||
<NavLink className='column-link column-link--transparent' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon className='column-link__icon' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>
|
<NavLink className='column-link column-link--transparent' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon className='column-link__icon' /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>
|
||||||
<FollowRequestsNavLink />
|
<FollowRequestsNavLink />
|
||||||
|
<NavLink className='column-link column-link--transparent' to='/explore' data-preview-title-id='explore.title' data-preview-icon='globe'><Icon className='column-link__icon' id='globe' fixedWidth /><FormattedMessage id='explore.title' defaultMessage='Explore' /></NavLink>
|
||||||
<NavLink className='column-link column-link--transparent' to='/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>
|
<NavLink className='column-link column-link--transparent' to='/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon className='column-link__icon' id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>
|
||||||
<NavLink className='column-link column-link--transparent' exact to='/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>
|
<NavLink className='column-link column-link--transparent' exact to='/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon className='column-link__icon' id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>
|
||||||
<NavLink className='column-link column-link--transparent' to='/conversations'><Icon className='column-link__icon' id='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink>
|
<NavLink className='column-link column-link--transparent' to='/conversations'><Icon className='column-link__icon' id='envelope' fixedWidth /><FormattedMessage id='navigation_bar.direct' defaultMessage='Direct messages' /></NavLink>
|
||||||
|
|
|
@ -10,9 +10,9 @@ import NotificationsCounterIcon from './notifications_counter_icon';
|
||||||
export const links = [
|
export const links = [
|
||||||
<NavLink className='tabs-bar__link' to='/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>,
|
<NavLink className='tabs-bar__link' to='/home' data-preview-title-id='column.home' data-preview-icon='home' ><Icon id='home' fixedWidth /><FormattedMessage id='tabs_bar.home' defaultMessage='Home' /></NavLink>,
|
||||||
<NavLink className='tabs-bar__link' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>,
|
<NavLink className='tabs-bar__link' to='/notifications' data-preview-title-id='column.notifications' data-preview-icon='bell' ><NotificationsCounterIcon /><FormattedMessage id='tabs_bar.notifications' defaultMessage='Notifications' /></NavLink>,
|
||||||
<NavLink className='tabs-bar__link' to='/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>,
|
<NavLink className='tabs-bar__link optional' to='/public/local' data-preview-title-id='column.community' data-preview-icon='users' ><Icon id='users' fixedWidth /><FormattedMessage id='tabs_bar.local_timeline' defaultMessage='Local' /></NavLink>,
|
||||||
<NavLink className='tabs-bar__link' exact to='/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>,
|
<NavLink className='tabs-bar__link optional' exact to='/public' data-preview-title-id='column.public' data-preview-icon='globe' ><Icon id='globe' fixedWidth /><FormattedMessage id='tabs_bar.federated_timeline' defaultMessage='Federated' /></NavLink>,
|
||||||
<NavLink className='tabs-bar__link optional' to='/search' data-preview-title-id='tabs_bar.search' data-preview-icon='bell' ><Icon id='search' fixedWidth /><FormattedMessage id='tabs_bar.search' defaultMessage='Search' /></NavLink>,
|
<NavLink className='tabs-bar__link' to='/explore' data-preview-title-id='tabs_bar.search' data-preview-icon='search' ><Icon id='search' fixedWidth /><FormattedMessage id='tabs_bar.search' defaultMessage='Search' /></NavLink>,
|
||||||
<NavLink className='tabs-bar__link' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started' data-preview-title-id='getting_started.heading' data-preview-icon='bars' ><Icon id='bars' fixedWidth /></NavLink>,
|
<NavLink className='tabs-bar__link' style={{ flexGrow: '0', flexBasis: '30px' }} to='/getting-started' data-preview-title-id='getting_started.heading' data-preview-icon='bars' ><Icon id='bars' fixedWidth /></NavLink>,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
@ -3,22 +3,23 @@ import { openModal, closeModal } from '../../../actions/modal';
|
||||||
import ModalRoot from '../components/modal_root';
|
import ModalRoot from '../components/modal_root';
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
type: state.getIn(['modal', 0, 'modalType'], null),
|
ignoreFocus: state.getIn(['modal', 'ignoreFocus']),
|
||||||
props: state.getIn(['modal', 0, 'modalProps'], {}),
|
type: state.getIn(['modal', 'stack', 0, 'modalType'], null),
|
||||||
|
props: state.getIn(['modal', 'stack', 0, 'modalProps'], {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
const mapDispatchToProps = dispatch => ({
|
||||||
onClose (confirmationMessage) {
|
onClose (confirmationMessage, ignoreFocus = false) {
|
||||||
if (confirmationMessage) {
|
if (confirmationMessage) {
|
||||||
dispatch(
|
dispatch(
|
||||||
openModal('CONFIRM', {
|
openModal('CONFIRM', {
|
||||||
message: confirmationMessage.message,
|
message: confirmationMessage.message,
|
||||||
confirm: confirmationMessage.confirm,
|
confirm: confirmationMessage.confirm,
|
||||||
onConfirm: () => dispatch(closeModal()),
|
onConfirm: () => dispatch(closeModal(undefined, { ignoreFocus })),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
dispatch(closeModal());
|
dispatch(closeModal(undefined, { ignoreFocus }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -49,8 +49,8 @@ import {
|
||||||
Mutes,
|
Mutes,
|
||||||
PinnedStatuses,
|
PinnedStatuses,
|
||||||
Lists,
|
Lists,
|
||||||
Search,
|
|
||||||
Directory,
|
Directory,
|
||||||
|
Explore,
|
||||||
FollowRecommendations,
|
FollowRecommendations,
|
||||||
} from './util/async-components';
|
} from './util/async-components';
|
||||||
import { me } from '../../initial_state';
|
import { me } from '../../initial_state';
|
||||||
|
@ -167,8 +167,8 @@ class SwitchingColumnsArea extends React.PureComponent {
|
||||||
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
|
<WrappedRoute path='/pinned' component={PinnedStatuses} content={children} />
|
||||||
|
|
||||||
<WrappedRoute path='/start' component={FollowRecommendations} content={children} />
|
<WrappedRoute path='/start' component={FollowRecommendations} content={children} />
|
||||||
<WrappedRoute path='/search' component={Search} content={children} />
|
|
||||||
<WrappedRoute path='/directory' component={Directory} content={children} />
|
<WrappedRoute path='/directory' component={Directory} content={children} />
|
||||||
|
<WrappedRoute path={['/explore', '/search']} component={Explore} content={children} />
|
||||||
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
|
<WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} />
|
||||||
|
|
||||||
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
|
<WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} />
|
||||||
|
|
|
@ -138,10 +138,6 @@ export function ListAdder () {
|
||||||
return import(/*webpackChunkName: "features/list_adder" */'../../list_adder');
|
return import(/*webpackChunkName: "features/list_adder" */'../../list_adder');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Search () {
|
|
||||||
return import(/*webpackChunkName: "features/search" */'../../search');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Tesseract () {
|
export function Tesseract () {
|
||||||
return import(/*webpackChunkName: "tesseract" */'tesseract.js');
|
return import(/*webpackChunkName: "tesseract" */'tesseract.js');
|
||||||
}
|
}
|
||||||
|
@ -161,3 +157,7 @@ export function FollowRecommendations () {
|
||||||
export function CompareHistoryModal () {
|
export function CompareHistoryModal () {
|
||||||
return import(/*webpackChunkName: "modals/compare_history_modal" */'../components/compare_history_modal');
|
return import(/*webpackChunkName: "modals/compare_history_modal" */'../components/compare_history_modal');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function Explore () {
|
||||||
|
return import(/* webpackChunkName: "features/explore" */'../../explore');
|
||||||
|
}
|
||||||
|
|
|
@ -3,16 +3,36 @@ import { TIMELINE_DELETE } from '../actions/timelines';
|
||||||
import { COMPOSE_UPLOAD_CHANGE_SUCCESS } from '../actions/compose';
|
import { COMPOSE_UPLOAD_CHANGE_SUCCESS } from '../actions/compose';
|
||||||
import { Stack as ImmutableStack, Map as ImmutableMap } from 'immutable';
|
import { Stack as ImmutableStack, Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
export default function modal(state = ImmutableStack(), action) {
|
const initialState = ImmutableMap({
|
||||||
|
ignoreFocus: false,
|
||||||
|
stack: ImmutableStack(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const popModal = (state, { modalType, ignoreFocus }) => {
|
||||||
|
if (modalType === undefined || modalType === state.getIn(['stack', 0, 'modalType'])) {
|
||||||
|
return state.set('ignoreFocus', !!ignoreFocus).update('stack', stack => stack.shift());
|
||||||
|
} else {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushModal = (state, modalType, modalProps) => {
|
||||||
|
return state.withMutations(map => {
|
||||||
|
map.set('ignoreFocus', false);
|
||||||
|
map.update('stack', stack => stack.unshift(ImmutableMap({ modalType, modalProps })));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function modal(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case MODAL_OPEN:
|
case MODAL_OPEN:
|
||||||
return state.unshift(ImmutableMap({ modalType: action.modalType, modalProps: action.modalProps }));
|
return pushModal(state, action.modalType, action.modalProps);
|
||||||
case MODAL_CLOSE:
|
case MODAL_CLOSE:
|
||||||
return (action.modalType === undefined || action.modalType === state.getIn([0, 'modalType'])) ? state.shift() : state;
|
return popModal(state, action);
|
||||||
case COMPOSE_UPLOAD_CHANGE_SUCCESS:
|
case COMPOSE_UPLOAD_CHANGE_SUCCESS:
|
||||||
return state.getIn([0, 'modalType']) === 'FOCAL_POINT' ? state.shift() : state;
|
return popModal(state, { modalType: 'FOCAL_POINT', ignoreFocus: false });
|
||||||
case TIMELINE_DELETE:
|
case TIMELINE_DELETE:
|
||||||
return state.filterNot((modal) => modal.get('modalProps').statusId === action.id);
|
return state.update('stack', stack => stack.filterNot((modal) => modal.get('modalProps').statusId === action.id));
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import {
|
import {
|
||||||
SEARCH_CHANGE,
|
SEARCH_CHANGE,
|
||||||
SEARCH_CLEAR,
|
SEARCH_CLEAR,
|
||||||
|
SEARCH_FETCH_REQUEST,
|
||||||
|
SEARCH_FETCH_FAIL,
|
||||||
SEARCH_FETCH_SUCCESS,
|
SEARCH_FETCH_SUCCESS,
|
||||||
SEARCH_SHOW,
|
SEARCH_SHOW,
|
||||||
SEARCH_EXPAND_SUCCESS,
|
SEARCH_EXPAND_SUCCESS,
|
||||||
|
@ -17,6 +19,7 @@ const initialState = ImmutableMap({
|
||||||
submitted: false,
|
submitted: false,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
results: ImmutableMap(),
|
results: ImmutableMap(),
|
||||||
|
isLoading: false,
|
||||||
searchTerm: '',
|
searchTerm: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -37,12 +40,22 @@ export default function search(state = initialState, action) {
|
||||||
case COMPOSE_MENTION:
|
case COMPOSE_MENTION:
|
||||||
case COMPOSE_DIRECT:
|
case COMPOSE_DIRECT:
|
||||||
return state.set('hidden', true);
|
return state.set('hidden', true);
|
||||||
|
case SEARCH_FETCH_REQUEST:
|
||||||
|
return state.set('isLoading', true);
|
||||||
|
case SEARCH_FETCH_FAIL:
|
||||||
|
return state.set('isLoading', false);
|
||||||
case SEARCH_FETCH_SUCCESS:
|
case SEARCH_FETCH_SUCCESS:
|
||||||
return state.set('results', ImmutableMap({
|
return state.withMutations(map => {
|
||||||
accounts: ImmutableList(action.results.accounts.map(item => item.id)),
|
map.set('results', ImmutableMap({
|
||||||
statuses: ImmutableList(action.results.statuses.map(item => item.id)),
|
accounts: ImmutableList(action.results.accounts.map(item => item.id)),
|
||||||
hashtags: fromJS(action.results.hashtags),
|
statuses: ImmutableList(action.results.statuses.map(item => item.id)),
|
||||||
})).set('submitted', true).set('searchTerm', action.searchTerm);
|
hashtags: fromJS(action.results.hashtags),
|
||||||
|
}));
|
||||||
|
|
||||||
|
map.set('submitted', true);
|
||||||
|
map.set('searchTerm', action.searchTerm);
|
||||||
|
map.set('isLoading', false);
|
||||||
|
});
|
||||||
case SEARCH_EXPAND_SUCCESS:
|
case SEARCH_EXPAND_SUCCESS:
|
||||||
const results = action.searchType === 'hashtags' ? fromJS(action.results.hashtags) : action.results[action.searchType].map(item => item.id);
|
const results = action.searchType === 'hashtags' ? fromJS(action.results.hashtags) : action.results[action.searchType].map(item => item.id);
|
||||||
return state.updateIn(['results', action.searchType], list => list.concat(results));
|
return state.updateIn(['results', action.searchType], list => list.concat(results));
|
||||||
|
|
|
@ -17,6 +17,11 @@ import {
|
||||||
import {
|
import {
|
||||||
PINNED_STATUSES_FETCH_SUCCESS,
|
PINNED_STATUSES_FETCH_SUCCESS,
|
||||||
} from '../actions/pin_statuses';
|
} from '../actions/pin_statuses';
|
||||||
|
import {
|
||||||
|
TRENDS_STATUSES_FETCH_REQUEST,
|
||||||
|
TRENDS_STATUSES_FETCH_SUCCESS,
|
||||||
|
TRENDS_STATUSES_FETCH_FAIL,
|
||||||
|
} from '../actions/trends';
|
||||||
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
|
||||||
import {
|
import {
|
||||||
FAVOURITE_SUCCESS,
|
FAVOURITE_SUCCESS,
|
||||||
|
@ -26,6 +31,10 @@ import {
|
||||||
PIN_SUCCESS,
|
PIN_SUCCESS,
|
||||||
UNPIN_SUCCESS,
|
UNPIN_SUCCESS,
|
||||||
} from '../actions/interactions';
|
} from '../actions/interactions';
|
||||||
|
import {
|
||||||
|
ACCOUNT_BLOCK_SUCCESS,
|
||||||
|
ACCOUNT_MUTE_SUCCESS,
|
||||||
|
} from '../actions/accounts';
|
||||||
|
|
||||||
const initialState = ImmutableMap({
|
const initialState = ImmutableMap({
|
||||||
favourites: ImmutableMap({
|
favourites: ImmutableMap({
|
||||||
|
@ -43,6 +52,11 @@ const initialState = ImmutableMap({
|
||||||
loaded: false,
|
loaded: false,
|
||||||
items: ImmutableList(),
|
items: ImmutableList(),
|
||||||
}),
|
}),
|
||||||
|
trending: ImmutableMap({
|
||||||
|
next: null,
|
||||||
|
loaded: false,
|
||||||
|
items: ImmutableList(),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const normalizeList = (state, listType, statuses, next) => {
|
const normalizeList = (state, listType, statuses, next) => {
|
||||||
|
@ -96,6 +110,12 @@ export default function statusLists(state = initialState, action) {
|
||||||
return normalizeList(state, 'bookmarks', action.statuses, action.next);
|
return normalizeList(state, 'bookmarks', action.statuses, action.next);
|
||||||
case BOOKMARKED_STATUSES_EXPAND_SUCCESS:
|
case BOOKMARKED_STATUSES_EXPAND_SUCCESS:
|
||||||
return appendToList(state, 'bookmarks', action.statuses, action.next);
|
return appendToList(state, 'bookmarks', action.statuses, action.next);
|
||||||
|
case TRENDS_STATUSES_FETCH_REQUEST:
|
||||||
|
return state.setIn(['trending', 'isLoading'], true);
|
||||||
|
case TRENDS_STATUSES_FETCH_FAIL:
|
||||||
|
return state.setIn(['trending', 'isLoading'], false);
|
||||||
|
case TRENDS_STATUSES_FETCH_SUCCESS:
|
||||||
|
return normalizeList(state, 'trending', action.statuses, action.next);
|
||||||
case FAVOURITE_SUCCESS:
|
case FAVOURITE_SUCCESS:
|
||||||
return prependOneToList(state, 'favourites', action.status);
|
return prependOneToList(state, 'favourites', action.status);
|
||||||
case UNFAVOURITE_SUCCESS:
|
case UNFAVOURITE_SUCCESS:
|
||||||
|
@ -110,6 +130,9 @@ export default function statusLists(state = initialState, action) {
|
||||||
return prependOneToList(state, 'pins', action.status);
|
return prependOneToList(state, 'pins', action.status);
|
||||||
case UNPIN_SUCCESS:
|
case UNPIN_SUCCESS:
|
||||||
return removeOneFromList(state, 'pins', action.status);
|
return removeOneFromList(state, 'pins', action.status);
|
||||||
|
case ACCOUNT_BLOCK_SUCCESS:
|
||||||
|
case ACCOUNT_MUTE_SUCCESS:
|
||||||
|
return state.updateIn(['trending', 'items'], ImmutableList(), list => list.filterNot(statusId => action.statuses.getIn([statusId, 'account']) === action.relationship.id));
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,22 +1,45 @@
|
||||||
import { TRENDS_FETCH_REQUEST, TRENDS_FETCH_SUCCESS, TRENDS_FETCH_FAIL } from '../actions/trends';
|
import {
|
||||||
|
TRENDS_TAGS_FETCH_REQUEST,
|
||||||
|
TRENDS_TAGS_FETCH_SUCCESS,
|
||||||
|
TRENDS_TAGS_FETCH_FAIL,
|
||||||
|
TRENDS_LINKS_FETCH_REQUEST,
|
||||||
|
TRENDS_LINKS_FETCH_SUCCESS,
|
||||||
|
TRENDS_LINKS_FETCH_FAIL,
|
||||||
|
} from 'mastodon/actions/trends';
|
||||||
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable';
|
||||||
|
|
||||||
const initialState = ImmutableMap({
|
const initialState = ImmutableMap({
|
||||||
items: ImmutableList(),
|
tags: ImmutableMap({
|
||||||
isLoading: false,
|
items: ImmutableList(),
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
links: ImmutableMap({
|
||||||
|
items: ImmutableList(),
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function trendsReducer(state = initialState, action) {
|
export default function trendsReducer(state = initialState, action) {
|
||||||
switch(action.type) {
|
switch(action.type) {
|
||||||
case TRENDS_FETCH_REQUEST:
|
case TRENDS_TAGS_FETCH_REQUEST:
|
||||||
return state.set('isLoading', true);
|
return state.setIn(['tags', 'isLoading'], true);
|
||||||
case TRENDS_FETCH_SUCCESS:
|
case TRENDS_TAGS_FETCH_SUCCESS:
|
||||||
return state.withMutations(map => {
|
return state.withMutations(map => {
|
||||||
map.set('items', fromJS(action.trends));
|
map.setIn(['tags', 'items'], fromJS(action.trends));
|
||||||
map.set('isLoading', false);
|
map.setIn(['tags', 'isLoading'], false);
|
||||||
});
|
});
|
||||||
case TRENDS_FETCH_FAIL:
|
case TRENDS_TAGS_FETCH_FAIL:
|
||||||
return state.set('isLoading', false);
|
return state.setIn(['tags', 'isLoading'], false);
|
||||||
|
case TRENDS_LINKS_FETCH_REQUEST:
|
||||||
|
return state.setIn(['links', 'isLoading'], true);
|
||||||
|
case TRENDS_LINKS_FETCH_SUCCESS:
|
||||||
|
return state.withMutations(map => {
|
||||||
|
map.setIn(['links', 'items'], fromJS(action.trends));
|
||||||
|
map.setIn(['links', 'isLoading'], false);
|
||||||
|
});
|
||||||
|
case TRENDS_LINKS_FETCH_FAIL:
|
||||||
|
return state.setIn(['links', 'isLoading'], false);
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -331,7 +331,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.batch-table__row--muted .pending-account__header,
|
.batch-table__row--muted .pending-account__header,
|
||||||
.batch-table__row--muted .accounts-table {
|
.batch-table__row--muted .accounts-table,
|
||||||
|
.batch-table__row--muted .name-tag {
|
||||||
&,
|
&,
|
||||||
a,
|
a,
|
||||||
strong {
|
strong {
|
||||||
|
@ -339,6 +340,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.batch-table__row--muted .name-tag .avatar {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
.batch-table__row--muted .accounts-table {
|
.batch-table__row--muted .accounts-table {
|
||||||
tbody td.accounts-table__extra,
|
tbody td.accounts-table__extra,
|
||||||
&__count,
|
&__count,
|
||||||
|
@ -352,7 +357,8 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.batch-table__row--attention .pending-account__header,
|
.batch-table__row--attention .pending-account__header,
|
||||||
.batch-table__row--attention .accounts-table {
|
.batch-table__row--attention .accounts-table,
|
||||||
|
.batch-table__row--attention .name-tag {
|
||||||
&,
|
&,
|
||||||
a,
|
a,
|
||||||
strong {
|
strong {
|
||||||
|
|
|
@ -166,6 +166,11 @@
|
||||||
transition-property: background-color, color;
|
transition-property: background-color, color;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:active,
|
&:active,
|
||||||
&:focus {
|
&:focus {
|
||||||
|
@ -2797,6 +2802,10 @@ a.account__display-name {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 120px;
|
min-height: 120px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scrollable {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.scrollable.fullscreen {
|
.scrollable.fullscreen {
|
||||||
|
@ -7724,3 +7733,122 @@ noscript {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.explore__search-header {
|
||||||
|
background: $ui-base-color;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 15px;
|
||||||
|
|
||||||
|
.search {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search__input {
|
||||||
|
border-radius: 4px;
|
||||||
|
color: $inverted-text-color;
|
||||||
|
background: $simple-background-color;
|
||||||
|
padding: 10px;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: $dark-text-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search .fa {
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
color: $dark-text-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search .fa-times-circle {
|
||||||
|
top: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.explore__search-results {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.story {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: $primary-text-color;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 15px 0;
|
||||||
|
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:active,
|
||||||
|
&:focus {
|
||||||
|
background-color: lighten($ui-base-color, 4%);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__details {
|
||||||
|
padding: 0 15px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
|
||||||
|
&__publisher {
|
||||||
|
color: $darker-text-color;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
font-size: 19px;
|
||||||
|
line-height: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__shared {
|
||||||
|
color: $darker-text-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__thumbnail {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 0 15px;
|
||||||
|
position: relative;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
border-radius: 4px;
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__preview {
|
||||||
|
border-radius: 4px;
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: fill;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 0;
|
||||||
|
|
||||||
|
&--hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -210,6 +210,7 @@ a.table-action-link {
|
||||||
&__content {
|
&__content {
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
padding-bottom: 16px;
|
padding-bottom: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
&--unpadded {
|
&--unpadded {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
@ -296,3 +297,9 @@ a.table-action-link {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.one-liner {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
|
@ -23,8 +23,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity
|
||||||
visibility: visibility_from_audience
|
visibility: visibility_from_audience
|
||||||
)
|
)
|
||||||
|
|
||||||
Trends.tags.register(@status)
|
Trends.register!(@status)
|
||||||
Trends.links.register(@status)
|
|
||||||
|
|
||||||
distribute
|
distribute
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,6 +7,8 @@ class ActivityPub::Activity::Like < ActivityPub::Activity
|
||||||
return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status)
|
return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status)
|
||||||
|
|
||||||
favourite = original_status.favourites.create!(account: @account)
|
favourite = original_status.favourites.create!(account: @account)
|
||||||
|
|
||||||
NotifyService.new.call(original_status.account, :favourite, favourite)
|
NotifyService.new.call(original_status.account, :favourite, favourite)
|
||||||
|
Trends.statuses.register(original_status)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -35,25 +35,18 @@ class AdminMailer < ApplicationMailer
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def new_trending_tags(recipient, tags)
|
def new_trends(recipient, links, tags, statuses)
|
||||||
@tags = tags
|
@links = links
|
||||||
@me = recipient
|
@lowest_trending_link = Trends.links.query.allowed.limit(Trends.links.options[:review_threshold]).last
|
||||||
@instance = Rails.configuration.x.local_domain
|
@tags = tags
|
||||||
@lowest_trending_tag = Trends.tags.get(true, Trends.tags.options[:review_threshold]).last
|
@lowest_trending_tag = Trends.tags.query.allowed.limit(Trends.tags.options[:review_threshold]).last
|
||||||
|
@statuses = statuses
|
||||||
|
@lowest_trending_status = Trends.statuses.query.allowed.limit(Trends.statuses.options[:review_threshold]).last
|
||||||
|
@me = recipient
|
||||||
|
@instance = Rails.configuration.x.local_domain
|
||||||
|
|
||||||
locale_for_account(@me) do
|
locale_for_account(@me) do
|
||||||
mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trending_tags.subject', instance: @instance)
|
mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trends.subject', instance: @instance)
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def new_trending_links(recipient, links)
|
|
||||||
@links = links
|
|
||||||
@me = recipient
|
|
||||||
@instance = Rails.configuration.x.local_domain
|
|
||||||
@lowest_trending_link = Trends.links.get(true, Trends.links.options[:review_threshold]).last
|
|
||||||
|
|
||||||
locale_for_account(@me) do
|
|
||||||
mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trending_links.subject', instance: @instance)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -40,13 +40,15 @@
|
||||||
# also_known_as :string is an Array
|
# also_known_as :string is an Array
|
||||||
# silenced_at :datetime
|
# silenced_at :datetime
|
||||||
# suspended_at :datetime
|
# suspended_at :datetime
|
||||||
# trust_level :integer
|
|
||||||
# hide_collections :boolean
|
# hide_collections :boolean
|
||||||
# avatar_storage_schema_version :integer
|
# avatar_storage_schema_version :integer
|
||||||
# header_storage_schema_version :integer
|
# header_storage_schema_version :integer
|
||||||
# devices_url :string
|
# devices_url :string
|
||||||
# suspension_origin :integer
|
# suspension_origin :integer
|
||||||
# sensitized_at :datetime
|
# sensitized_at :datetime
|
||||||
|
# trendable :boolean
|
||||||
|
# reviewed_at :datetime
|
||||||
|
# requested_review_at :datetime
|
||||||
#
|
#
|
||||||
|
|
||||||
class Account < ApplicationRecord
|
class Account < ApplicationRecord
|
||||||
|
@ -56,6 +58,7 @@ class Account < ApplicationRecord
|
||||||
remote_url
|
remote_url
|
||||||
salmon_url
|
salmon_url
|
||||||
hub_url
|
hub_url
|
||||||
|
trust_level
|
||||||
)
|
)
|
||||||
|
|
||||||
USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i
|
USERNAME_RE = /[a-z0-9_]+([a-z0-9_\.-]+[a-z0-9_]+)?/i
|
||||||
|
@ -78,11 +81,6 @@ class Account < ApplicationRecord
|
||||||
MAX_NOTE_LENGTH = (ENV['MAX_BIO_CHARS'] || 500).to_i
|
MAX_NOTE_LENGTH = (ENV['MAX_BIO_CHARS'] || 500).to_i
|
||||||
MAX_FIELDS = (ENV['MAX_PROFILE_FIELDS'] || 4).to_i
|
MAX_FIELDS = (ENV['MAX_PROFILE_FIELDS'] || 4).to_i
|
||||||
|
|
||||||
TRUST_LEVELS = {
|
|
||||||
untrusted: 0,
|
|
||||||
trusted: 1,
|
|
||||||
}.freeze
|
|
||||||
|
|
||||||
enum protocol: [:ostatus, :activitypub]
|
enum protocol: [:ostatus, :activitypub]
|
||||||
enum suspension_origin: [:local, :remote], _prefix: true
|
enum suspension_origin: [:local, :remote], _prefix: true
|
||||||
|
|
||||||
|
@ -206,10 +204,6 @@ class Account < ApplicationRecord
|
||||||
last_webfingered_at.nil? || last_webfingered_at <= 1.day.ago
|
last_webfingered_at.nil? || last_webfingered_at <= 1.day.ago
|
||||||
end
|
end
|
||||||
|
|
||||||
def trust_level
|
|
||||||
self[:trust_level] || 0
|
|
||||||
end
|
|
||||||
|
|
||||||
def refresh!
|
def refresh!
|
||||||
ResolveAccountService.new.call(acct) unless local?
|
ResolveAccountService.new.call(acct) unless local?
|
||||||
end
|
end
|
||||||
|
@ -390,6 +384,22 @@ class Account < ApplicationRecord
|
||||||
@synchronization_uri_prefix ||= "#{uri[URL_PREFIX_RE]}/"
|
@synchronization_uri_prefix ||= "#{uri[URL_PREFIX_RE]}/"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def requires_review?
|
||||||
|
reviewed_at.nil?
|
||||||
|
end
|
||||||
|
|
||||||
|
def reviewed?
|
||||||
|
reviewed_at.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def requested_review?
|
||||||
|
requested_review_at.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def requires_review_notification?
|
||||||
|
requires_review? && !requested_review?
|
||||||
|
end
|
||||||
|
|
||||||
class Field < ActiveModelSerializers::Model
|
class Field < ActiveModelSerializers::Model
|
||||||
attributes :name, :value, :verified_at, :account
|
attributes :name, :value, :verified_at, :account
|
||||||
|
|
||||||
|
|
|
@ -3,11 +3,13 @@
|
||||||
#
|
#
|
||||||
# Table name: email_domain_blocks
|
# Table name: email_domain_blocks
|
||||||
#
|
#
|
||||||
# id :bigint(8) not null, primary key
|
# id :bigint(8) not null, primary key
|
||||||
# domain :string default(""), not null
|
# domain :string default(""), not null
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
# parent_id :bigint(8)
|
# parent_id :bigint(8)
|
||||||
|
# ips :inet is an Array
|
||||||
|
# last_refresh_at :datetime
|
||||||
#
|
#
|
||||||
|
|
||||||
class EmailDomainBlock < ApplicationRecord
|
class EmailDomainBlock < ApplicationRecord
|
||||||
|
@ -18,27 +20,42 @@ class EmailDomainBlock < ApplicationRecord
|
||||||
|
|
||||||
validates :domain, presence: true, uniqueness: true, domain: true
|
validates :domain, presence: true, uniqueness: true, domain: true
|
||||||
|
|
||||||
def with_dns_records=(val)
|
# Used for adding multiple blocks at once
|
||||||
@with_dns_records = ActiveModel::Type::Boolean.new.cast(val)
|
attr_accessor :other_domains
|
||||||
|
|
||||||
|
def history
|
||||||
|
@history ||= Trends::History.new('email_domain_blocks', id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def with_dns_records?
|
def self.block?(domain_or_domains, ips: [], attempt_ip: nil)
|
||||||
@with_dns_records
|
domains = Array(domain_or_domains).map do |str|
|
||||||
end
|
domain = begin
|
||||||
|
if str.include?('@')
|
||||||
|
str.split('@', 2).last
|
||||||
|
else
|
||||||
|
str
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
alias with_dns_records with_dns_records?
|
TagManager.instance.normalize_domain(domain) if domain.present?
|
||||||
|
|
||||||
def self.block?(email)
|
|
||||||
_, domain = email.split('@', 2)
|
|
||||||
|
|
||||||
return true if domain.nil?
|
|
||||||
|
|
||||||
begin
|
|
||||||
domain = TagManager.instance.normalize_domain(domain)
|
|
||||||
rescue Addressable::URI::InvalidURIError
|
rescue Addressable::URI::InvalidURIError
|
||||||
return true
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
where(domain: domain).exists?
|
# If some of the inputs passed in are invalid, we definitely want to
|
||||||
|
# block the attempt, but we also want to register hits against any
|
||||||
|
# other valid matches
|
||||||
|
|
||||||
|
blocked = domains.any?(&:nil?)
|
||||||
|
|
||||||
|
scope = where(domain: domains)
|
||||||
|
scope = scope.or(where('ips && ARRAY[?]::inet[]', ips)) if ips.any?
|
||||||
|
|
||||||
|
scope.find_each do |block|
|
||||||
|
blocked = true
|
||||||
|
block.history.add(attempt_ip) if attempt_ip.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
blocked
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -35,6 +35,7 @@ class Form::AdminSettings
|
||||||
show_replies_in_public_timelines
|
show_replies_in_public_timelines
|
||||||
trends
|
trends
|
||||||
trendable_by_default
|
trendable_by_default
|
||||||
|
trending_status_cw
|
||||||
show_domain_blocks
|
show_domain_blocks
|
||||||
show_domain_blocks_rationale
|
show_domain_blocks_rationale
|
||||||
noindex
|
noindex
|
||||||
|
@ -57,6 +58,7 @@ class Form::AdminSettings
|
||||||
show_replies_in_public_timelines
|
show_replies_in_public_timelines
|
||||||
trends
|
trends
|
||||||
trendable_by_default
|
trendable_by_default
|
||||||
|
trending_status_cw
|
||||||
noindex
|
noindex
|
||||||
require_invite_text
|
require_invite_text
|
||||||
captcha_enabled
|
captcha_enabled
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Form::EmailDomainBlockBatch
|
||||||
|
include ActiveModel::Model
|
||||||
|
include Authorization
|
||||||
|
include AccountableConcern
|
||||||
|
|
||||||
|
attr_accessor :email_domain_block_ids, :action, :current_account
|
||||||
|
|
||||||
|
def save
|
||||||
|
case action
|
||||||
|
when 'delete'
|
||||||
|
delete!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def email_domain_blocks
|
||||||
|
@email_domain_blocks ||= EmailDomainBlock.where(id: email_domain_block_ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete!
|
||||||
|
email_domain_blocks.each do |email_domain_block|
|
||||||
|
authorize(email_domain_block, :destroy?)
|
||||||
|
email_domain_block.destroy!
|
||||||
|
log_action :destroy, email_domain_block
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -27,6 +27,7 @@
|
||||||
# content_type :string
|
# content_type :string
|
||||||
# deleted_at :datetime
|
# deleted_at :datetime
|
||||||
# edited_at :datetime
|
# edited_at :datetime
|
||||||
|
# trendable :boolean
|
||||||
#
|
#
|
||||||
|
|
||||||
class Status < ApplicationRecord
|
class Status < ApplicationRecord
|
||||||
|
@ -274,6 +275,18 @@ class Status < ApplicationRecord
|
||||||
update_status_stat!(key => [public_send(key) - 1, 0].max)
|
update_status_stat!(key => [public_send(key) - 1, 0].max)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def trendable?
|
||||||
|
if attributes['trendable'].nil?
|
||||||
|
account.trendable?
|
||||||
|
else
|
||||||
|
attributes['trendable']
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def requires_review_notification?
|
||||||
|
attributes['trendable'].nil? && account.requires_review_notification?
|
||||||
|
end
|
||||||
|
|
||||||
after_create_commit :increment_counter_caches
|
after_create_commit :increment_counter_caches
|
||||||
after_destroy_commit :decrement_counter_caches
|
after_destroy_commit :decrement_counter_caches
|
||||||
|
|
||||||
|
|
|
@ -13,15 +13,40 @@ module Trends
|
||||||
@tags ||= Trends::Tags.new
|
@tags ||= Trends::Tags.new
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.statuses
|
||||||
|
@statuses ||= Trends::Statuses.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.register!(status)
|
||||||
|
[links, tags, statuses].each { |trend_type| trend_type.register(status) }
|
||||||
|
end
|
||||||
|
|
||||||
def self.refresh!
|
def self.refresh!
|
||||||
[links, tags].each(&:refresh)
|
[links, tags, statuses].each(&:refresh)
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.request_review!
|
def self.request_review!
|
||||||
[tags].each(&:request_review) if enabled?
|
return unless enabled?
|
||||||
|
|
||||||
|
links_requiring_review = links.request_review
|
||||||
|
tags_requiring_review = tags.request_review
|
||||||
|
statuses_requiring_review = statuses.request_review
|
||||||
|
|
||||||
|
User.staff.includes(:account).find_each do |user|
|
||||||
|
links = user.allows_trending_tags_review_emails? ? links_requiring_review : []
|
||||||
|
tags = user.allows_trending_links_review_emails? ? tags_requiring_review : []
|
||||||
|
statuses = user.allows_trending_statuses_review_emails? ? statuses_requiring_review : []
|
||||||
|
next if links.empty? && tags.empty? && statuses.empty?
|
||||||
|
|
||||||
|
AdminMailer.new_trends(user.account, links, tags, statuses).deliver_later!
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.enabled?
|
def self.enabled?
|
||||||
Setting.trends
|
Setting.trends
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.available_locales
|
||||||
|
@available_locales ||= I18n.available_locales.map { |locale| locale.to_s.split(/[_-]/).first }.uniq
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
class Trends::Base
|
class Trends::Base
|
||||||
include Redisable
|
include Redisable
|
||||||
|
include LanguagesHelper
|
||||||
|
|
||||||
class_attribute :default_options
|
class_attribute :default_options
|
||||||
|
|
||||||
|
@ -32,8 +33,8 @@ class Trends::Base
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
end
|
end
|
||||||
|
|
||||||
def get(*)
|
def query
|
||||||
raise NotImplementedError
|
Trends::Query.new(key_prefix, klass)
|
||||||
end
|
end
|
||||||
|
|
||||||
def score(id)
|
def score(id)
|
||||||
|
@ -72,6 +73,21 @@ class Trends::Base
|
||||||
redis.zrevrange("#{key_prefix}:allowed", 0, rank, with_scores: true).last&.last || 0
|
redis.zrevrange("#{key_prefix}:allowed", 0, rank, with_scores: true).last&.last || 0
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @param [Integer] id
|
||||||
|
# @param [Float] score
|
||||||
|
# @param [Hash<String, Boolean>] subsets
|
||||||
|
def add_to_and_remove_from_subsets(id, score, subsets = {})
|
||||||
|
subsets.each_key do |subset|
|
||||||
|
key = [key_prefix, subset].compact.join(':')
|
||||||
|
|
||||||
|
if score.positive? && subsets[subset]
|
||||||
|
redis.zadd(key, score, id)
|
||||||
|
else
|
||||||
|
redis.zrem(key, id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def used_key(at_time)
|
def used_key(at_time)
|
||||||
|
|
|
@ -4,8 +4,8 @@ class Trends::Links < Trends::Base
|
||||||
PREFIX = 'trending_links'
|
PREFIX = 'trending_links'
|
||||||
|
|
||||||
self.default_options = {
|
self.default_options = {
|
||||||
threshold: 15,
|
threshold: 5,
|
||||||
review_threshold: 10,
|
review_threshold: 3,
|
||||||
max_score_cooldown: 2.days.freeze,
|
max_score_cooldown: 2.days.freeze,
|
||||||
max_score_halflife: 8.hours.freeze,
|
max_score_halflife: 8.hours.freeze,
|
||||||
}
|
}
|
||||||
|
@ -27,12 +27,6 @@ class Trends::Links < Trends::Base
|
||||||
record_used_id(preview_card.id, at_time)
|
record_used_id(preview_card.id, at_time)
|
||||||
end
|
end
|
||||||
|
|
||||||
def get(allowed, limit)
|
|
||||||
preview_card_ids = currently_trending_ids(allowed, limit)
|
|
||||||
preview_cards = PreviewCard.where(id: preview_card_ids).index_by(&:id)
|
|
||||||
preview_card_ids.map { |id| preview_cards[id] }.compact
|
|
||||||
end
|
|
||||||
|
|
||||||
def refresh(at_time = Time.now.utc)
|
def refresh(at_time = Time.now.utc)
|
||||||
preview_cards = PreviewCard.where(id: (recently_used_ids(at_time) + currently_trending_ids(false, -1)).uniq)
|
preview_cards = PreviewCard.where(id: (recently_used_ids(at_time) + currently_trending_ids(false, -1)).uniq)
|
||||||
calculate_scores(preview_cards, at_time)
|
calculate_scores(preview_cards, at_time)
|
||||||
|
@ -42,7 +36,7 @@ class Trends::Links < Trends::Base
|
||||||
def request_review
|
def request_review
|
||||||
preview_cards = PreviewCard.where(id: currently_trending_ids(false, -1))
|
preview_cards = PreviewCard.where(id: currently_trending_ids(false, -1))
|
||||||
|
|
||||||
preview_cards_requiring_review = preview_cards.filter_map do |preview_card|
|
preview_cards.filter_map do |preview_card|
|
||||||
next unless would_be_trending?(preview_card.id) && !preview_card.trendable? && preview_card.requires_review_notification?
|
next unless would_be_trending?(preview_card.id) && !preview_card.trendable? && preview_card.requires_review_notification?
|
||||||
|
|
||||||
if preview_card.provider.nil?
|
if preview_card.provider.nil?
|
||||||
|
@ -53,12 +47,6 @@ class Trends::Links < Trends::Base
|
||||||
|
|
||||||
preview_card
|
preview_card
|
||||||
end
|
end
|
||||||
|
|
||||||
return if preview_cards_requiring_review.empty?
|
|
||||||
|
|
||||||
User.staff.includes(:account).find_each do |user|
|
|
||||||
AdminMailer.new_trending_links(user.account, preview_cards_requiring_review).deliver_later! if user.allows_trending_tag_emails?
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
protected
|
protected
|
||||||
|
@ -67,6 +55,10 @@ class Trends::Links < Trends::Base
|
||||||
PREFIX
|
PREFIX
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def klass
|
||||||
|
PreviewCard
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def calculate_scores(preview_cards, at_time)
|
def calculate_scores(preview_cards, at_time)
|
||||||
|
@ -96,17 +88,27 @@ class Trends::Links < Trends::Base
|
||||||
|
|
||||||
decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / options[:max_score_halflife].to_f))
|
decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / options[:max_score_halflife].to_f))
|
||||||
|
|
||||||
if decaying_score.zero?
|
add_to_and_remove_from_subsets(preview_card.id, decaying_score, {
|
||||||
redis.zrem("#{PREFIX}:all", preview_card.id)
|
all: true,
|
||||||
redis.zrem("#{PREFIX}:allowed", preview_card.id)
|
allowed: preview_card.trendable?,
|
||||||
else
|
})
|
||||||
redis.zadd("#{PREFIX}:all", decaying_score, preview_card.id)
|
|
||||||
|
|
||||||
if preview_card.trendable?
|
next unless valid_locale?(preview_card.language)
|
||||||
redis.zadd("#{PREFIX}:allowed", decaying_score, preview_card.id)
|
|
||||||
else
|
add_to_and_remove_from_subsets(preview_card.id, decaying_score, {
|
||||||
redis.zrem("#{PREFIX}:allowed", preview_card.id)
|
"all:#{preview_card.language}" => true,
|
||||||
end
|
"allowed:#{preview_card.language}" => preview_card.trendable?,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
# Clean up localized sets by calculating the intersection with the main
|
||||||
|
# set. We do this instead of just deleting the localized sets to avoid
|
||||||
|
# having moments where the API returns empty results
|
||||||
|
|
||||||
|
redis.pipelined do
|
||||||
|
Trends.available_locales.each do |locale|
|
||||||
|
redis.zinterstore("#{key_prefix}:all:#{locale}", ["#{key_prefix}:all:#{locale}", "#{key_prefix}:all"], aggregate: 'max')
|
||||||
|
redis.zinterstore("#{key_prefix}:allowed:#{locale}", ["#{key_prefix}:allowed:#{locale}", "#{key_prefix}:all"], aggregate: 'max')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Form::PreviewCardBatch
|
class Trends::PreviewCardBatch
|
||||||
include ActiveModel::Model
|
include ActiveModel::Model
|
||||||
include Authorization
|
include Authorization
|
||||||
|
|
||||||
|
@ -10,12 +10,12 @@ class Form::PreviewCardBatch
|
||||||
case action
|
case action
|
||||||
when 'approve'
|
when 'approve'
|
||||||
approve!
|
approve!
|
||||||
when 'approve_all'
|
when 'approve_providers'
|
||||||
approve_all!
|
approve_providers!
|
||||||
when 'reject'
|
when 'reject'
|
||||||
reject!
|
reject!
|
||||||
when 'reject_all'
|
when 'reject_providers'
|
||||||
reject_all!
|
reject_providers!
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -30,13 +30,13 @@ class Form::PreviewCardBatch
|
||||||
end
|
end
|
||||||
|
|
||||||
def approve!
|
def approve!
|
||||||
preview_cards.each { |preview_card| authorize(preview_card, :update?) }
|
preview_cards.each { |preview_card| authorize(preview_card, :review?) }
|
||||||
preview_cards.update_all(trendable: true)
|
preview_cards.update_all(trendable: true)
|
||||||
end
|
end
|
||||||
|
|
||||||
def approve_all!
|
def approve_providers!
|
||||||
preview_card_providers.each do |provider|
|
preview_card_providers.each do |provider|
|
||||||
authorize(provider, :update?)
|
authorize(provider, :review?)
|
||||||
provider.update(trendable: true, reviewed_at: action_time)
|
provider.update(trendable: true, reviewed_at: action_time)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -45,13 +45,13 @@ class Form::PreviewCardBatch
|
||||||
end
|
end
|
||||||
|
|
||||||
def reject!
|
def reject!
|
||||||
preview_cards.each { |preview_card| authorize(preview_card, :update?) }
|
preview_cards.each { |preview_card| authorize(preview_card, :review?) }
|
||||||
preview_cards.update_all(trendable: false)
|
preview_cards.update_all(trendable: false)
|
||||||
end
|
end
|
||||||
|
|
||||||
def reject_all!
|
def reject_providers!
|
||||||
preview_card_providers.each do |provider|
|
preview_card_providers.each do |provider|
|
||||||
authorize(provider, :update?)
|
authorize(provider, :review?)
|
||||||
provider.update(trendable: false, reviewed_at: action_time)
|
provider.update(trendable: false, reviewed_at: action_time)
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class PreviewCardFilter
|
class Trends::PreviewCardFilter
|
||||||
KEYS = %i(
|
KEYS = %i(
|
||||||
trending
|
trending
|
||||||
|
locale
|
||||||
).freeze
|
).freeze
|
||||||
|
|
||||||
attr_reader :params
|
attr_reader :params
|
||||||
|
@ -15,7 +16,7 @@ class PreviewCardFilter
|
||||||
scope = PreviewCard.unscoped
|
scope = PreviewCard.unscoped
|
||||||
|
|
||||||
params.each do |key, value|
|
params.each do |key, value|
|
||||||
next if key.to_s == 'page'
|
next if %w(page locale).include?(key.to_s)
|
||||||
|
|
||||||
scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
|
scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
|
||||||
end
|
end
|
||||||
|
@ -35,19 +36,11 @@ class PreviewCardFilter
|
||||||
end
|
end
|
||||||
|
|
||||||
def trending_scope(value)
|
def trending_scope(value)
|
||||||
ids = begin
|
scope = Trends.links.query
|
||||||
case value.to_s
|
|
||||||
when 'allowed'
|
|
||||||
Trends.links.currently_trending_ids(true, -1)
|
|
||||||
else
|
|
||||||
Trends.links.currently_trending_ids(false, -1)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if ids.empty?
|
scope = scope.in_locale(@params[:locale].to_s) if @params[:locale].present?
|
||||||
PreviewCard.none
|
scope = scope.allowed if value == 'allowed'
|
||||||
else
|
|
||||||
PreviewCard.joins("join unnest(array[#{ids.map(&:to_i).join(',')}]::integer[]) with ordinality as x (id, ordering) on preview_cards.id = x.id").order('x.ordering')
|
scope.to_arel
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
|
@ -1,6 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Form::PreviewCardProviderBatch
|
class Trends::PreviewCardProviderBatch
|
||||||
include ActiveModel::Model
|
include ActiveModel::Model
|
||||||
include Authorization
|
include Authorization
|
||||||
|
|
||||||
|
@ -22,12 +22,12 @@ class Form::PreviewCardProviderBatch
|
||||||
end
|
end
|
||||||
|
|
||||||
def approve!
|
def approve!
|
||||||
preview_card_providers.each { |provider| authorize(provider, :update?) }
|
preview_card_providers.each { |provider| authorize(provider, :review?) }
|
||||||
preview_card_providers.update_all(trendable: true, reviewed_at: Time.now.utc)
|
preview_card_providers.update_all(trendable: true, reviewed_at: Time.now.utc)
|
||||||
end
|
end
|
||||||
|
|
||||||
def reject!
|
def reject!
|
||||||
preview_card_providers.each { |provider| authorize(provider, :update?) }
|
preview_card_providers.each { |provider| authorize(provider, :review?) }
|
||||||
preview_card_providers.update_all(trendable: false, reviewed_at: Time.now.utc)
|
preview_card_providers.update_all(trendable: false, reviewed_at: Time.now.utc)
|
||||||
end
|
end
|
||||||
end
|
end
|
|
@ -1,6 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class PreviewCardProviderFilter
|
class Trends::PreviewCardProviderFilter
|
||||||
KEYS = %i(
|
KEYS = %i(
|
||||||
status
|
status
|
||||||
).freeze
|
).freeze
|
|
@ -0,0 +1,106 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Trends::Query
|
||||||
|
include Redisable
|
||||||
|
include Enumerable
|
||||||
|
|
||||||
|
attr_reader :prefix, :klass, :loaded
|
||||||
|
|
||||||
|
alias loaded? loaded
|
||||||
|
|
||||||
|
def initialize(prefix, klass)
|
||||||
|
@prefix = prefix
|
||||||
|
@klass = klass
|
||||||
|
@records = []
|
||||||
|
@loaded = false
|
||||||
|
@allowed = false
|
||||||
|
@limit = -1
|
||||||
|
@offset = 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def allowed!
|
||||||
|
@allowed = true
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
|
def allowed
|
||||||
|
clone.allowed!
|
||||||
|
end
|
||||||
|
|
||||||
|
def in_locale!(value)
|
||||||
|
@locale = value
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
|
def in_locale(value)
|
||||||
|
clone.in_locale!(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
def offset!(value)
|
||||||
|
@offset = value
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
|
def offset(value)
|
||||||
|
clone.offset!(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
def limit!(value)
|
||||||
|
@limit = value
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
|
def limit(value)
|
||||||
|
clone.limit!(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
def records
|
||||||
|
load
|
||||||
|
@records
|
||||||
|
end
|
||||||
|
|
||||||
|
delegate :each, :empty?, :first, :last, to: :records
|
||||||
|
|
||||||
|
def to_ary
|
||||||
|
records.dup
|
||||||
|
end
|
||||||
|
|
||||||
|
alias to_a to_ary
|
||||||
|
|
||||||
|
def to_arel
|
||||||
|
tmp_ids = ids
|
||||||
|
|
||||||
|
if tmp_ids.empty?
|
||||||
|
klass.none
|
||||||
|
else
|
||||||
|
klass.joins("join unnest(array[#{tmp_ids.join(',')}]) with ordinality as x (id, ordering) on #{klass.table_name}.id = x.id").reorder('x.ordering')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def key
|
||||||
|
[@prefix, @allowed ? 'allowed' : 'all', @locale].compact.join(':')
|
||||||
|
end
|
||||||
|
|
||||||
|
def load
|
||||||
|
unless loaded?
|
||||||
|
@records = perform_queries
|
||||||
|
@loaded = true
|
||||||
|
end
|
||||||
|
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
|
def ids
|
||||||
|
redis.zrevrange(key, @offset, @limit.positive? ? @limit - 1 : @limit).map(&:to_i)
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_queries
|
||||||
|
apply_scopes(to_arel).to_a
|
||||||
|
end
|
||||||
|
|
||||||
|
def apply_scopes(scope)
|
||||||
|
scope
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,65 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Trends::StatusBatch
|
||||||
|
include ActiveModel::Model
|
||||||
|
include Authorization
|
||||||
|
|
||||||
|
attr_accessor :status_ids, :action, :current_account
|
||||||
|
|
||||||
|
def save
|
||||||
|
case action
|
||||||
|
when 'approve'
|
||||||
|
approve!
|
||||||
|
when 'approve_accounts'
|
||||||
|
approve_accounts!
|
||||||
|
when 'reject'
|
||||||
|
reject!
|
||||||
|
when 'reject_accounts'
|
||||||
|
reject_accounts!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def statuses
|
||||||
|
@statuses ||= Status.where(id: status_ids)
|
||||||
|
end
|
||||||
|
|
||||||
|
def status_accounts
|
||||||
|
@status_accounts ||= Account.where(id: statuses.map(&:account_id).uniq)
|
||||||
|
end
|
||||||
|
|
||||||
|
def approve!
|
||||||
|
statuses.each { |status| authorize(status, :review?) }
|
||||||
|
statuses.update_all(trendable: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def approve_accounts!
|
||||||
|
status_accounts.each do |account|
|
||||||
|
authorize(account, :review?)
|
||||||
|
account.update(trendable: true, reviewed_at: action_time)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Reset any individual overrides
|
||||||
|
statuses.update_all(trendable: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def reject!
|
||||||
|
statuses.each { |status| authorize(status, :review?) }
|
||||||
|
statuses.update_all(trendable: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
def reject_accounts!
|
||||||
|
status_accounts.each do |account|
|
||||||
|
authorize(account, :review?)
|
||||||
|
account.update(trendable: false, reviewed_at: action_time)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Reset any individual overrides
|
||||||
|
statuses.update_all(trendable: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def action_time
|
||||||
|
@action_time ||= Time.now.utc
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,46 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Trends::StatusFilter
|
||||||
|
KEYS = %i(
|
||||||
|
trending
|
||||||
|
locale
|
||||||
|
).freeze
|
||||||
|
|
||||||
|
attr_reader :params
|
||||||
|
|
||||||
|
def initialize(params)
|
||||||
|
@params = params
|
||||||
|
end
|
||||||
|
|
||||||
|
def results
|
||||||
|
scope = Status.unscoped.kept
|
||||||
|
|
||||||
|
params.each do |key, value|
|
||||||
|
next if %w(page locale).include?(key.to_s)
|
||||||
|
|
||||||
|
scope.merge!(scope_for(key, value.to_s.strip)) if value.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
scope
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def scope_for(key, value)
|
||||||
|
case key.to_s
|
||||||
|
when 'trending'
|
||||||
|
trending_scope(value)
|
||||||
|
else
|
||||||
|
raise "Unknown filter: #{key}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def trending_scope(value)
|
||||||
|
scope = Trends.statuses.query
|
||||||
|
|
||||||
|
scope = scope.in_locale(@params[:locale].to_s) if @params[:locale].present?
|
||||||
|
scope = scope.allowed if value == 'allowed'
|
||||||
|
|
||||||
|
scope.to_arel
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,142 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class Trends::Statuses < Trends::Base
|
||||||
|
PREFIX = 'trending_statuses'
|
||||||
|
|
||||||
|
self.default_options = {
|
||||||
|
threshold: 5,
|
||||||
|
review_threshold: 3,
|
||||||
|
score_halflife: 2.hours.freeze,
|
||||||
|
}
|
||||||
|
|
||||||
|
class Query < Trends::Query
|
||||||
|
def filtered_for!(account)
|
||||||
|
@account = account
|
||||||
|
self
|
||||||
|
end
|
||||||
|
|
||||||
|
def filtered_for(account)
|
||||||
|
clone.filtered_for!(account)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def apply_scopes(scope)
|
||||||
|
scope.includes(:account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform_queries
|
||||||
|
return super if @account.nil?
|
||||||
|
|
||||||
|
statuses = super
|
||||||
|
account_ids = statuses.map(&:account_id)
|
||||||
|
account_domains = statuses.map(&:account_domain)
|
||||||
|
|
||||||
|
preloaded_relations = {
|
||||||
|
blocking: Account.blocking_map(account_ids, @account.id),
|
||||||
|
blocked_by: Account.blocked_by_map(account_ids, @account.id),
|
||||||
|
muting: Account.muting_map(account_ids, @account.id),
|
||||||
|
following: Account.following_map(account_ids, @account.id),
|
||||||
|
domain_blocking_by_domain: Account.domain_blocking_map_by_domain(account_domains, @account.id),
|
||||||
|
}
|
||||||
|
|
||||||
|
statuses.reject { |status| StatusFilter.new(status, @account, preloaded_relations).filtered? }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def register(status, at_time = Time.now.utc)
|
||||||
|
add(status.proper, status.account_id, at_time) if eligible?(status)
|
||||||
|
end
|
||||||
|
|
||||||
|
def add(status, _account_id, at_time = Time.now.utc)
|
||||||
|
# We rely on the total reblogs and favourites count, so we
|
||||||
|
# don't record which account did the what and when here
|
||||||
|
|
||||||
|
record_used_id(status.id, at_time)
|
||||||
|
end
|
||||||
|
|
||||||
|
def query
|
||||||
|
Query.new(key_prefix, klass)
|
||||||
|
end
|
||||||
|
|
||||||
|
def refresh(at_time = Time.now.utc)
|
||||||
|
statuses = Status.where(id: (recently_used_ids(at_time) + currently_trending_ids(false, -1)).uniq).includes(:account, :media_attachments)
|
||||||
|
calculate_scores(statuses, at_time)
|
||||||
|
trim_older_items
|
||||||
|
end
|
||||||
|
|
||||||
|
def request_review
|
||||||
|
statuses = Status.where(id: currently_trending_ids(false, -1)).includes(:account)
|
||||||
|
|
||||||
|
statuses.filter_map do |status|
|
||||||
|
next unless would_be_trending?(status.id) && !status.trendable? && status.requires_review_notification?
|
||||||
|
|
||||||
|
status.account.touch(:requested_review_at)
|
||||||
|
status
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
protected
|
||||||
|
|
||||||
|
def key_prefix
|
||||||
|
PREFIX
|
||||||
|
end
|
||||||
|
|
||||||
|
def klass
|
||||||
|
Status
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def eligible?(status)
|
||||||
|
original_status = status.proper
|
||||||
|
|
||||||
|
original_status.public_visibility? &&
|
||||||
|
original_status.account.discoverable? && !original_status.account.silenced? &&
|
||||||
|
(original_status.spoiler_text.blank? || Setting.trending_status_cw) && !original_status.sensitive? && !original_status.reply?
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate_scores(statuses, at_time)
|
||||||
|
redis.pipelined do
|
||||||
|
statuses.each do |status|
|
||||||
|
expected = 1.0
|
||||||
|
observed = (status.reblogs_count + status.favourites_count).to_f
|
||||||
|
|
||||||
|
score = begin
|
||||||
|
if expected > observed || observed < options[:threshold]
|
||||||
|
0
|
||||||
|
else
|
||||||
|
((observed - expected)**2) / expected
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
decaying_score = score * (0.5**((at_time.to_f - status.created_at.to_f) / options[:score_halflife].to_f))
|
||||||
|
|
||||||
|
add_to_and_remove_from_subsets(status.id, decaying_score, {
|
||||||
|
all: true,
|
||||||
|
allowed: status.trendable? && status.account.discoverable?,
|
||||||
|
})
|
||||||
|
|
||||||
|
next unless valid_locale?(status.language)
|
||||||
|
|
||||||
|
add_to_and_remove_from_subsets(status.id, decaying_score, {
|
||||||
|
"all:#{status.language}" => true,
|
||||||
|
"allowed:#{status.language}" => status.trendable? && status.account.discoverable?,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
# Clean up localized sets by calculating the intersection with the main
|
||||||
|
# set. We do this instead of just deleting the localized sets to avoid
|
||||||
|
# having moments where the API returns empty results
|
||||||
|
|
||||||
|
Trends.available_locales.each do |locale|
|
||||||
|
redis.zinterstore("#{key_prefix}:all:#{locale}", ["#{key_prefix}:all:#{locale}", "#{key_prefix}:all"], aggregate: 'max')
|
||||||
|
redis.zinterstore("#{key_prefix}:allowed:#{locale}", ["#{key_prefix}:allowed:#{locale}", "#{key_prefix}:all"], aggregate: 'max')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def would_be_trending?(id)
|
||||||
|
score(id) > score_at_rank(options[:review_threshold] - 1)
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,6 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Form::TagBatch
|
class Trends::TagBatch
|
||||||
include ActiveModel::Model
|
include ActiveModel::Model
|
||||||
include Authorization
|
include Authorization
|
||||||
|
|
||||||
|
@ -22,12 +22,12 @@ class Form::TagBatch
|
||||||
end
|
end
|
||||||
|
|
||||||
def approve!
|
def approve!
|
||||||
tags.each { |tag| authorize(tag, :update?) }
|
tags.each { |tag| authorize(tag, :review?) }
|
||||||
tags.update_all(trendable: true, reviewed_at: action_time)
|
tags.update_all(trendable: true, reviewed_at: action_time)
|
||||||
end
|
end
|
||||||
|
|
||||||
def reject!
|
def reject!
|
||||||
tags.each { |tag| authorize(tag, :update?) }
|
tags.each { |tag| authorize(tag, :review?) }
|
||||||
tags.update_all(trendable: false, reviewed_at: action_time)
|
tags.update_all(trendable: false, reviewed_at: action_time)
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class TagFilter
|
class Trends::TagFilter
|
||||||
KEYS = %i(
|
KEYS = %i(
|
||||||
trending
|
trending
|
||||||
status
|
status
|
||||||
|
@ -42,13 +42,7 @@ class TagFilter
|
||||||
end
|
end
|
||||||
|
|
||||||
def trending_scope
|
def trending_scope
|
||||||
ids = Trends.tags.currently_trending_ids(false, -1)
|
Trends.tags.query.to_arel
|
||||||
|
|
||||||
if ids.empty?
|
|
||||||
Tag.none
|
|
||||||
else
|
|
||||||
Tag.joins("join unnest(array[#{ids.map(&:to_i).join(',')}]::integer[]) with ordinality as x (id, ordering) on tags.id = x.id").order('x.ordering')
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def status_scope(value)
|
def status_scope(value)
|
|
@ -5,7 +5,7 @@ class Trends::Tags < Trends::Base
|
||||||
|
|
||||||
self.default_options = {
|
self.default_options = {
|
||||||
threshold: 5,
|
threshold: 5,
|
||||||
review_threshold: 10,
|
review_threshold: 3,
|
||||||
max_score_cooldown: 2.days.freeze,
|
max_score_cooldown: 2.days.freeze,
|
||||||
max_score_halflife: 4.hours.freeze,
|
max_score_halflife: 4.hours.freeze,
|
||||||
}
|
}
|
||||||
|
@ -29,27 +29,15 @@ class Trends::Tags < Trends::Base
|
||||||
trim_older_items
|
trim_older_items
|
||||||
end
|
end
|
||||||
|
|
||||||
def get(allowed, limit)
|
|
||||||
tag_ids = currently_trending_ids(allowed, limit)
|
|
||||||
tags = Tag.where(id: tag_ids).index_by(&:id)
|
|
||||||
tag_ids.map { |id| tags[id] }.compact
|
|
||||||
end
|
|
||||||
|
|
||||||
def request_review
|
def request_review
|
||||||
tags = Tag.where(id: currently_trending_ids(false, -1))
|
tags = Tag.where(id: currently_trending_ids(false, -1))
|
||||||
|
|
||||||
tags_requiring_review = tags.filter_map do |tag|
|
tags.filter_map do |tag|
|
||||||
next unless would_be_trending?(tag.id) && !tag.trendable? && tag.requires_review_notification?
|
next unless would_be_trending?(tag.id) && !tag.trendable? && tag.requires_review_notification?
|
||||||
|
|
||||||
tag.touch(:requested_review_at)
|
tag.touch(:requested_review_at)
|
||||||
tag
|
tag
|
||||||
end
|
end
|
||||||
|
|
||||||
return if tags_requiring_review.empty?
|
|
||||||
|
|
||||||
User.staff.includes(:account).find_each do |user|
|
|
||||||
AdminMailer.new_trending_tags(user.account, tags_requiring_review).deliver_later! if user.allows_trending_tag_emails?
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
protected
|
protected
|
||||||
|
@ -58,6 +46,10 @@ class Trends::Tags < Trends::Base
|
||||||
PREFIX
|
PREFIX
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def klass
|
||||||
|
Tag
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def calculate_scores(tags, at_time)
|
def calculate_scores(tags, at_time)
|
||||||
|
@ -87,18 +79,10 @@ class Trends::Tags < Trends::Base
|
||||||
|
|
||||||
decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / options[:max_score_halflife].to_f))
|
decaying_score = max_score * (0.5**((at_time.to_f - max_time.to_f) / options[:max_score_halflife].to_f))
|
||||||
|
|
||||||
if decaying_score.zero?
|
add_to_and_remove_from_subsets(tag.id, decaying_score, {
|
||||||
redis.zrem("#{PREFIX}:all", tag.id)
|
all: true,
|
||||||
redis.zrem("#{PREFIX}:allowed", tag.id)
|
allowed: tag.trendable?,
|
||||||
else
|
})
|
||||||
redis.zadd("#{PREFIX}:all", decaying_score, tag.id)
|
|
||||||
|
|
||||||
if tag.trendable?
|
|
||||||
redis.zadd("#{PREFIX}:allowed", decaying_score, tag.id)
|
|
||||||
else
|
|
||||||
redis.zrem("#{PREFIX}:allowed", tag.id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -269,10 +269,18 @@ class User < ApplicationRecord
|
||||||
settings.notification_emails['appeal']
|
settings.notification_emails['appeal']
|
||||||
end
|
end
|
||||||
|
|
||||||
def allows_trending_tag_emails?
|
def allows_trending_tags_review_emails?
|
||||||
settings.notification_emails['trending_tag']
|
settings.notification_emails['trending_tag']
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def allows_trending_links_review_emails?
|
||||||
|
settings.notification_emails['trending_link']
|
||||||
|
end
|
||||||
|
|
||||||
|
def allows_trending_statuses_review_emails?
|
||||||
|
settings.notification_emails['trending_status']
|
||||||
|
end
|
||||||
|
|
||||||
def hides_network?
|
def hides_network?
|
||||||
@hides_network ||= settings.hide_network
|
@hides_network ||= settings.hide_network
|
||||||
end
|
end
|
||||||
|
|
|
@ -68,4 +68,8 @@ class AccountPolicy < ApplicationPolicy
|
||||||
def unblock_email?
|
def unblock_email?
|
||||||
staff?
|
staff?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def review?
|
||||||
|
staff?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,7 +5,7 @@ class PreviewCardPolicy < ApplicationPolicy
|
||||||
staff?
|
staff?
|
||||||
end
|
end
|
||||||
|
|
||||||
def update?
|
def review?
|
||||||
staff?
|
staff?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,7 +5,7 @@ class PreviewCardProviderPolicy < ApplicationPolicy
|
||||||
staff?
|
staff?
|
||||||
end
|
end
|
||||||
|
|
||||||
def update?
|
def review?
|
||||||
staff?
|
staff?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -42,6 +42,10 @@ class StatusPolicy < ApplicationPolicy
|
||||||
staff? || owned?
|
staff? || owned?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def review?
|
||||||
|
staff?
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def requires_mention?
|
def requires_mention?
|
||||||
|
|
|
@ -12,4 +12,8 @@ class TagPolicy < ApplicationPolicy
|
||||||
def update?
|
def update?
|
||||||
staff?
|
staff?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def review?
|
||||||
|
staff?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -15,7 +15,7 @@ class StatusRelationshipsPresenter
|
||||||
statuses = statuses.compact
|
statuses = statuses.compact
|
||||||
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact
|
status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact
|
||||||
conversation_ids = statuses.filter_map(&:conversation_id).uniq
|
conversation_ids = statuses.filter_map(&:conversation_id).uniq
|
||||||
pinnable_status_ids = statuses.map(&:proper).filter_map { |s| s.id if s.account_id == current_account_id && %w(public unlisted).include?(s.visibility) }
|
pinnable_status_ids = statuses.map(&:proper).filter_map { |s| s.id if s.account_id == current_account_id && %w(public unlisted private).include?(s.visibility) }
|
||||||
|
|
||||||
@reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {})
|
@reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {})
|
||||||
@favourites_map = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {})
|
@favourites_map = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {})
|
||||||
|
|
|
@ -220,21 +220,23 @@ class DeleteAccountService < BaseService
|
||||||
|
|
||||||
return unless keep_account_record?
|
return unless keep_account_record?
|
||||||
|
|
||||||
@account.silenced_at = nil
|
@account.silenced_at = nil
|
||||||
@account.suspended_at = @options[:suspended_at] || Time.now.utc
|
@account.suspended_at = @options[:suspended_at] || Time.now.utc
|
||||||
@account.suspension_origin = :local
|
@account.suspension_origin = :local
|
||||||
@account.locked = false
|
@account.locked = false
|
||||||
@account.memorial = false
|
@account.memorial = false
|
||||||
@account.discoverable = false
|
@account.discoverable = false
|
||||||
@account.display_name = ''
|
@account.trendable = false
|
||||||
@account.note = ''
|
@account.display_name = ''
|
||||||
@account.fields = []
|
@account.note = ''
|
||||||
@account.statuses_count = 0
|
@account.fields = []
|
||||||
@account.followers_count = 0
|
@account.statuses_count = 0
|
||||||
@account.following_count = 0
|
@account.followers_count = 0
|
||||||
@account.moved_to_account = nil
|
@account.following_count = 0
|
||||||
@account.also_known_as = []
|
@account.moved_to_account = nil
|
||||||
@account.trust_level = :untrusted
|
@account.reviewed_at = nil
|
||||||
|
@account.requested_review_at = nil
|
||||||
|
@account.also_known_as = []
|
||||||
@account.avatar.destroy
|
@account.avatar.destroy
|
||||||
@account.header.destroy
|
@account.header.destroy
|
||||||
@account.save!
|
@account.save!
|
||||||
|
|
|
@ -17,6 +17,8 @@ class FavouriteService < BaseService
|
||||||
|
|
||||||
favourite = Favourite.create!(account: account, status: status)
|
favourite = Favourite.create!(account: account, status: status)
|
||||||
|
|
||||||
|
Trends.statuses.register(status)
|
||||||
|
|
||||||
create_notification(favourite)
|
create_notification(favourite)
|
||||||
bump_potential_friendship(account, status)
|
bump_potential_friendship(account, status)
|
||||||
|
|
||||||
|
|
|
@ -30,8 +30,7 @@ class ReblogService < BaseService
|
||||||
|
|
||||||
reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility, rate_limit: options[:with_rate_limit])
|
reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility, rate_limit: options[:with_rate_limit])
|
||||||
|
|
||||||
Trends.tags.register(reblog)
|
Trends.register!(reblog)
|
||||||
Trends.links.register(reblog)
|
|
||||||
DistributionWorker.perform_async(reblog.id)
|
DistributionWorker.perform_async(reblog.id)
|
||||||
ActivityPub::DistributionWorker.perform_async(reblog.id) unless reblogged_status.local_only?
|
ActivityPub::DistributionWorker.perform_async(reblog.id) unless reblogged_status.local_only?
|
||||||
|
|
||||||
|
|
|
@ -4,41 +4,39 @@ class BlacklistedEmailValidator < ActiveModel::Validator
|
||||||
def validate(user)
|
def validate(user)
|
||||||
return if user.valid_invitation? || user.email.blank?
|
return if user.valid_invitation? || user.email.blank?
|
||||||
|
|
||||||
@email = user.email
|
user.errors.add(:email, :blocked) if blocked_email_provider?(user.email, user.sign_up_ip)
|
||||||
|
user.errors.add(:email, :taken) if blocked_canonical_email?(user.email)
|
||||||
user.errors.add(:email, :blocked) if blocked_email_provider?
|
|
||||||
user.errors.add(:email, :taken) if blocked_canonical_email?
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def blocked_email_provider?
|
def blocked_email_provider?(email, ip)
|
||||||
disallowed_through_email_domain_block? || disallowed_through_configuration? || not_allowed_through_configuration?
|
disallowed_through_email_domain_block?(email, ip) || disallowed_through_configuration?(email) || not_allowed_through_configuration?(email)
|
||||||
end
|
end
|
||||||
|
|
||||||
def blocked_canonical_email?
|
def blocked_canonical_email?(email)
|
||||||
CanonicalEmailBlock.block?(@email)
|
CanonicalEmailBlock.block?(email)
|
||||||
end
|
end
|
||||||
|
|
||||||
def disallowed_through_email_domain_block?
|
def disallowed_through_email_domain_block?(email, ip)
|
||||||
EmailDomainBlock.block?(@email)
|
EmailDomainBlock.block?(email, attempt_ip: ip)
|
||||||
end
|
end
|
||||||
|
|
||||||
def not_allowed_through_configuration?
|
def not_allowed_through_configuration?(email)
|
||||||
return false if Rails.configuration.x.email_domains_whitelist.blank?
|
return false if Rails.configuration.x.email_domains_whitelist.blank?
|
||||||
|
|
||||||
domains = Rails.configuration.x.email_domains_whitelist.gsub('.', '\.')
|
domains = Rails.configuration.x.email_domains_whitelist.gsub('.', '\.')
|
||||||
regexp = Regexp.new("@(.+\\.)?(#{domains})$", true)
|
regexp = Regexp.new("@(.+\\.)?(#{domains})$", true)
|
||||||
|
|
||||||
@email !~ regexp
|
email !~ regexp
|
||||||
end
|
end
|
||||||
|
|
||||||
def disallowed_through_configuration?
|
def disallowed_through_configuration?(email)
|
||||||
return false if Rails.configuration.x.email_domains_blacklist.blank?
|
return false if Rails.configuration.x.email_domains_blacklist.blank?
|
||||||
|
|
||||||
domains = Rails.configuration.x.email_domains_blacklist.gsub('.', '\.')
|
domains = Rails.configuration.x.email_domains_blacklist.gsub('.', '\.')
|
||||||
regexp = Regexp.new("@(.+\\.)?(#{domains})", true)
|
regexp = Regexp.new("@(.+\\.)?(#{domains})", true)
|
||||||
|
|
||||||
regexp.match?(@email)
|
regexp.match?(email)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -11,11 +11,11 @@ class EmailMxValidator < ActiveModel::Validator
|
||||||
if domain.blank?
|
if domain.blank?
|
||||||
user.errors.add(:email, :invalid)
|
user.errors.add(:email, :invalid)
|
||||||
elsif !on_allowlist?(domain)
|
elsif !on_allowlist?(domain)
|
||||||
ips, hostnames = resolve_mx(domain)
|
resolved_ips, resolved_domains = resolve_mx(domain)
|
||||||
|
|
||||||
if ips.empty?
|
if resolved_ips.empty?
|
||||||
user.errors.add(:email, :unreachable)
|
user.errors.add(:email, :unreachable)
|
||||||
elsif on_blacklist?(hostnames + ips)
|
elsif on_blacklist?(resolved_domains, resolved_ips, user.sign_up_ip)
|
||||||
user.errors.add(:email, :blocked)
|
user.errors.add(:email, :blocked)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -40,24 +40,24 @@ class EmailMxValidator < ActiveModel::Validator
|
||||||
end
|
end
|
||||||
|
|
||||||
def resolve_mx(domain)
|
def resolve_mx(domain)
|
||||||
hostnames = []
|
records = []
|
||||||
ips = []
|
ips = []
|
||||||
|
|
||||||
Resolv::DNS.open do |dns|
|
Resolv::DNS.open do |dns|
|
||||||
dns.timeouts = 5
|
dns.timeouts = 5
|
||||||
|
|
||||||
hostnames = dns.getresources(domain, Resolv::DNS::Resource::IN::MX).to_a.map { |e| e.exchange.to_s }
|
records = dns.getresources(domain, Resolv::DNS::Resource::IN::MX).to_a.map { |e| e.exchange.to_s }
|
||||||
|
|
||||||
([domain] + hostnames).uniq.each do |hostname|
|
([domain] + records).uniq.each do |hostname|
|
||||||
ips.concat(dns.getresources(hostname, Resolv::DNS::Resource::IN::A).to_a.map { |e| e.address.to_s })
|
ips.concat(dns.getresources(hostname, Resolv::DNS::Resource::IN::A).to_a.map { |e| e.address.to_s })
|
||||||
ips.concat(dns.getresources(hostname, Resolv::DNS::Resource::IN::AAAA).to_a.map { |e| e.address.to_s })
|
ips.concat(dns.getresources(hostname, Resolv::DNS::Resource::IN::AAAA).to_a.map { |e| e.address.to_s })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
[ips, hostnames]
|
[ips, records]
|
||||||
end
|
end
|
||||||
|
|
||||||
def on_blacklist?(values)
|
def on_blacklist?(domains, resolved_ips, attempt_ip)
|
||||||
EmailDomainBlock.where(domain: values.uniq).any?
|
EmailDomainBlock.block?(domains, ips: resolved_ips, attempt_ip: attempt_ip)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
= f.check_box :custom_emoji_ids, { multiple: true, include_hidden: false }, custom_emoji.id
|
= f.check_box :custom_emoji_ids, { multiple: true, include_hidden: false }, custom_emoji.id
|
||||||
.batch-table__row__content.batch-table__row__content--with-image
|
.batch-table__row__content.batch-table__row__content--with-image
|
||||||
.batch-table__row__content__image
|
.batch-table__row__content__image
|
||||||
= custom_emoji_tag(custom_emoji, animate = current_account&.user&.setting_auto_play_gif)
|
= custom_emoji_tag(custom_emoji, current_account&.user&.setting_auto_play_gif)
|
||||||
|
|
||||||
.batch-table__row__content__text
|
.batch-table__row__content__text
|
||||||
%samp= ":#{custom_emoji.shortcode}:"
|
%samp= ":#{custom_emoji.shortcode}:"
|
||||||
|
|
|
@ -1,15 +1,14 @@
|
||||||
%tr
|
.batch-table__row
|
||||||
%td
|
%label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
|
||||||
%samp= email_domain_block.domain
|
= f.check_box :email_domain_block_ids, { multiple: true, include_hidden: false }, email_domain_block.id
|
||||||
%td
|
.batch-table__row__content.pending-account
|
||||||
= table_link_to 'trash', t('admin.email_domain_blocks.delete'), admin_email_domain_block_path(email_domain_block), method: :delete
|
.pending-account__header
|
||||||
|
%samp= link_to email_domain_block.domain, admin_accounts_path(email: "%@#{email_domain_block.domain}")
|
||||||
|
|
||||||
- email_domain_block.children.each do |child_email_domain_block|
|
%br/
|
||||||
%tr
|
|
||||||
%td
|
- if email_domain_block.parent.present?
|
||||||
%samp= child_email_domain_block.domain
|
= t('admin.email_domain_blocks.resolved_through_html', domain: content_tag(:samp, email_domain_block.parent.domain))
|
||||||
%span.muted-hint
|
•
|
||||||
= surround '(', ')' do
|
|
||||||
= t('admin.email_domain_blocks.from_html', domain: content_tag(:samp, email_domain_block.domain))
|
= t('admin.email_domain_blocks.attempts_over_week', count: email_domain_block.history.reduce(0) { |sum, day| sum + day.accounts })
|
||||||
%td
|
|
||||||
= table_link_to 'trash', t('admin.email_domain_blocks.delete'), admin_email_domain_block_path(child_email_domain_block), method: :delete
|
|
||||||
|
|
|
@ -4,16 +4,19 @@
|
||||||
- content_for :heading_actions do
|
- content_for :heading_actions do
|
||||||
= link_to t('admin.email_domain_blocks.add_new'), new_admin_email_domain_block_path, class: 'button'
|
= link_to t('admin.email_domain_blocks.add_new'), new_admin_email_domain_block_path, class: 'button'
|
||||||
|
|
||||||
- if @email_domain_blocks.empty?
|
= form_for(@form, url: batch_admin_email_domain_blocks_path) do |f|
|
||||||
%div.muted-hint.center-text=t 'admin.email_domain_blocks.empty'
|
= hidden_field_tag :page, params[:page] || 1
|
||||||
- else
|
|
||||||
.table-wrapper
|
.batch-table
|
||||||
%table.table
|
.batch-table__toolbar
|
||||||
%thead
|
%label.batch-table__toolbar__select.batch-checkbox-all
|
||||||
%tr
|
= check_box_tag :batch_checkbox_all, nil, false
|
||||||
%th= t('admin.email_domain_blocks.domain')
|
.batch-table__toolbar__actions
|
||||||
%th
|
= f.button safe_join([fa_icon('times'), t('admin.email_domain_blocks.delete')]), name: :delete, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
|
||||||
%tbody
|
.batch-table__body
|
||||||
= render partial: 'email_domain_block', collection: @email_domain_blocks
|
- if @email_domain_blocks.empty?
|
||||||
|
= nothing_here 'nothing-here--under-tabs'
|
||||||
|
- else
|
||||||
|
= render partial: 'email_domain_block', collection: @email_domain_blocks.flat_map { |x| [x, x.children.to_a].flatten }, locals: { f: f }
|
||||||
|
|
||||||
= paginate @email_domain_blocks
|
= paginate @email_domain_blocks
|
||||||
|
|
|
@ -5,10 +5,31 @@
|
||||||
= render 'shared/error_messages', object: @email_domain_block
|
= render 'shared/error_messages', object: @email_domain_block
|
||||||
|
|
||||||
.fields-group
|
.fields-group
|
||||||
= f.input :domain, wrapper: :with_block_label, label: t('admin.email_domain_blocks.domain')
|
= f.input :domain, wrapper: :with_block_label, label: t('admin.email_domain_blocks.domain'), input_html: { readonly: defined?(@resolved_records) }
|
||||||
|
|
||||||
.fields-group
|
- if defined?(@resolved_records)
|
||||||
= f.input :with_dns_records, as: :boolean, wrapper: :with_label
|
%p.hint= t('admin.email_domain_blocks.resolved_dns_records_hint_html')
|
||||||
|
|
||||||
|
.batch-table
|
||||||
|
.batch-table__toolbar
|
||||||
|
%label.batch-table__toolbar__select.batch-checkbox-all
|
||||||
|
= check_box_tag :batch_checkbox_all, nil, false
|
||||||
|
.batch-table__toolbar__actions
|
||||||
|
.batch-table__body
|
||||||
|
- @resolved_records.each do |record|
|
||||||
|
.batch-table__row
|
||||||
|
%label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
|
||||||
|
= f.input_field :other_domains, as: :boolean, checked_value: record.exchange.to_s, include_hidden: false, multiple: true
|
||||||
|
.batch-table__row__content.pending-account
|
||||||
|
.pending-account__header
|
||||||
|
%samp= record.exchange.to_s
|
||||||
|
%br
|
||||||
|
= t('admin.email_domain_blocks.dns.types.mx')
|
||||||
|
|
||||||
|
%hr.spacer/
|
||||||
|
|
||||||
.actions
|
.actions
|
||||||
= f.button :button, t('.create'), type: :submit
|
- if defined?(@resolved_records)
|
||||||
|
= f.button :button, t('.create'), type: :submit, name: :save
|
||||||
|
- else
|
||||||
|
= f.button :button, t('.resolve'), type: :submit, name: :resolve
|
||||||
|
|
|
@ -6,12 +6,14 @@
|
||||||
%hr.spacer/
|
%hr.spacer/
|
||||||
|
|
||||||
= form_tag admin_follow_recommendations_path, method: 'GET', class: 'simple_form' do
|
= form_tag admin_follow_recommendations_path, method: 'GET', class: 'simple_form' do
|
||||||
|
- RelationshipFilter::KEYS.each do |key|
|
||||||
|
= hidden_field_tag key, params[key] if params[key].present?
|
||||||
|
|
||||||
.filters
|
.filters
|
||||||
.filter-subset.filter-subset--with-select
|
.filter-subset.filter-subset--with-select
|
||||||
%strong= t('admin.follow_recommendations.language')
|
%strong= t('admin.follow_recommendations.language')
|
||||||
.input.select.optional
|
.input.select.optional
|
||||||
= select_tag :language, options_for_select(I18n.available_locales.map { |key| key.to_s.split(/[_-]/).first.to_sym }.uniq.map { |key| [standard_locale_name(key), key]}, @language)
|
= select_tag :language, options_for_select(Trends.available_locales.map { |key| [standard_locale_name(key), key] }, @language)
|
||||||
|
|
||||||
.filter-subset
|
.filter-subset
|
||||||
%strong= t('admin.follow_recommendations.status')
|
%strong= t('admin.follow_recommendations.status')
|
||||||
%ul
|
%ul
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue