Merge pull request #1704 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes
th-downstream
Claire 3 years ago committed by GitHub
commit a3ead64052

@ -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

@ -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'

@ -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'
EmailDomainBlock.transaction do
@email_domain_block.save!
log_action :create, @email_domain_block 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|
dns.timeouts = 5
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| other_email_domain_block = EmailDomainBlock.create!(domain: domain, parent: @email_domain_block)
another_email_domain_block = EmailDomainBlock.new(domain: hostname, parent: @email_domain_block) log_action :create, other_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(() => {
if (!this.props.ignoreFocus) {
this.activeElement.focus({ preventScroll: true }); 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) {
// Because of the wicg-inert polyfill, the activeElement may not be
// immediately selectable, we have to wait for observers to run, as
// described in https://github.com/WICG/inert#performance-and-gotchas
Promise.resolve().then(() => {
textarea.setSelectionRange(selectionStart, selectionEnd); textarea.setSelectionRange(selectionStart, selectionEnd);
textarea.focus(); textarea.focus();
if (!singleColumn) textarea.scrollIntoView(); 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)
.get('/api/v1/trends/tags')
.then(({ data }) => dispatch(fetchTrendingHashtagsSuccess(data)))
.catch(err => dispatch(fetchTrendingHashtagsFail(err)));
};
export const fetchTrendingHashtagsRequest = () => ({
type: TRENDS_TAGS_FETCH_REQUEST,
skipLoading: true,
});
export const fetchTrendingHashtagsSuccess = trends => ({
type: TRENDS_TAGS_FETCH_SUCCESS,
trends,
skipLoading: true,
});
export const fetchTrendingHashtagsFail = error => ({
type: TRENDS_TAGS_FETCH_FAIL,
error,
skipLoading: true,
skipAlert: true,
});
export const fetchTrendingLinks = () => (dispatch, getState) => {
dispatch(fetchTrendingLinksRequest());
api(getState) api(getState)
.get('/api/v1/trends') .get('/api/v1/trends/links')
.then(({ data }) => dispatch(fetchTrendsSuccess(data))) .then(({ data }) => dispatch(fetchTrendingLinksSuccess(data)))
.catch(err => dispatch(fetchTrendsFail(err))); .catch(err => dispatch(fetchTrendingLinksFail(err)));
}; };
export const fetchTrendsRequest = () => ({ export const fetchTrendingLinksRequest = () => ({
type: TRENDS_FETCH_REQUEST, type: TRENDS_LINKS_FETCH_REQUEST,
skipLoading: true, skipLoading: true,
}); });
export const fetchTrendsSuccess = trends => ({ export const fetchTrendingLinksSuccess = trends => ({
type: TRENDS_FETCH_SUCCESS, type: TRENDS_LINKS_FETCH_SUCCESS,
trends, trends,
skipLoading: true, skipLoading: true,
}); });
export const fetchTrendsFail = error => ({ export const fetchTrendingLinksFail = error => ({
type: TRENDS_FETCH_FAIL, 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(() => {
if (!this.props.ignoreFocus) {
this.activeElement.focus({ preventScroll: true }); 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;
} }
// Because of the wicg-inert polyfill, the activeElement may not be
// 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.setSelectionRange(selectionStart, selectionEnd);
this.autosuggestTextarea.textarea.focus(); 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 => {
map.set('results', ImmutableMap({
accounts: ImmutableList(action.results.accounts.map(item => item.id)), accounts: ImmutableList(action.results.accounts.map(item => item.id)),
statuses: ImmutableList(action.results.statuses.map(item => item.id)), statuses: ImmutableList(action.results.statuses.map(item => item.id)),
hashtags: fromJS(action.results.hashtags), hashtags: fromJS(action.results.hashtags),
})).set('submitted', true).set('searchTerm', action.searchTerm); }));
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({
tags: ImmutableMap({
items: ImmutableList(), items: ImmutableList(),
isLoading: false, 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 => {
map.setIn(['tags', 'items'], fromJS(action.trends));
map.setIn(['tags', 'isLoading'], false);
});
case TRENDS_TAGS_FETCH_FAIL:
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 => { return state.withMutations(map => {
map.set('items', fromJS(action.trends)); map.setIn(['links', 'items'], fromJS(action.trends));
map.set('isLoading', false); map.setIn(['links', 'isLoading'], false);
}); });
case TRENDS_FETCH_FAIL: case TRENDS_LINKS_FETCH_FAIL:
return state.set('isLoading', false); 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
@me = recipient
@instance = Rails.configuration.x.local_domain
@lowest_trending_tag = Trends.tags.get(true, Trends.tags.options[:review_threshold]).last
locale_for_account(@me) do
mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trending_tags.subject', instance: @instance)
end
end
def new_trending_links(recipient, links)
@links = links @links = links
@lowest_trending_link = Trends.links.query.allowed.limit(Trends.links.options[:review_threshold]).last
@tags = tags
@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 @me = recipient
@instance = Rails.configuration.x.local_domain @instance = Rails.configuration.x.local_domain
@lowest_trending_link = Trends.links.get(true, Trends.links.options[:review_threshold]).last
locale_for_account(@me) do locale_for_account(@me) do
mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trending_links.subject', instance: @instance) mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trends.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

@ -8,6 +8,8 @@
# 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
def self.block?(domain_or_domains, ips: [], attempt_ip: nil)
domains = Array(domain_or_domains).map do |str|
domain = begin
if str.include?('@')
str.split('@', 2).last
else
str
end
end end
def with_dns_records? TagManager.instance.normalize_domain(domain) if domain.present?
@with_dns_records rescue Addressable::URI::InvalidURIError
nil
end end
alias with_dns_records with_dns_records? # 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
def self.block?(email) blocked = domains.any?(&:nil?)
_, domain = email.split('@', 2)
return true if domain.nil? scope = where(domain: domains)
scope = scope.or(where('ips && ARRAY[?]::inet[]', ips)) if ips.any?
begin scope.find_each do |block|
domain = TagManager.instance.normalize_domain(domain) blocked = true
rescue Addressable::URI::InvalidURIError block.history.add(attempt_ip) if attempt_ip.present?
return true
end end
where(domain: domain).exists? 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,
"allowed:#{preview_card.language}" => preview_card.trendable?,
})
end 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] || {})

@ -226,6 +226,7 @@ class DeleteAccountService < BaseService
@account.locked = false @account.locked = false
@account.memorial = false @account.memorial = false
@account.discoverable = false @account.discoverable = false
@account.trendable = false
@account.display_name = '' @account.display_name = ''
@account.note = '' @account.note = ''
@account.fields = [] @account.fields = []
@ -233,8 +234,9 @@ class DeleteAccountService < BaseService
@account.followers_count = 0 @account.followers_count = 0
@account.following_count = 0 @account.following_count = 0
@account.moved_to_account = nil @account.moved_to_account = nil
@account.reviewed_at = nil
@account.requested_review_at = nil
@account.also_known_as = [] @account.also_known_as = []
@account.trust_level = :untrusted
@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…
Cancel
Save