diff options
Diffstat (limited to 'app')
113 files changed, 1993 insertions, 444 deletions
diff --git a/app/controllers/admin/email_domain_blocks_controller.rb b/app/controllers/admin/email_domain_blocks_controller.rb index f7bdfb0c5..a4bbbba5b 100644 --- a/app/controllers/admin/email_domain_blocks_controller.rb +++ b/app/controllers/admin/email_domain_blocks_controller.rb @@ -6,7 +6,20 @@ module Admin def index authorize :email_domain_block, :index? + @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 def new @@ -19,41 +32,27 @@ module Admin @email_domain_block = EmailDomainBlock.new(resource_params) - if @email_domain_block.save - log_action :create, @email_domain_block - - if @email_domain_block.with_dns_records? - hostnames = [] - ips = [] - - Resolv::DNS.open do |dns| - dns.timeouts = 5 + if action_from_button == 'save' + EmailDomainBlock.transaction do + @email_domain_block.save! + log_action :create, @email_domain_block - hostnames = dns.getresources(@email_domain_block.domain, Resolv::DNS::Resource::IN::MX).to_a.map { |e| e.exchange.to_s } + (@email_domain_block.other_domains || []).uniq.each do |domain| + next if EmailDomainBlock.where(domain: domain).exists? - ([@email_domain_block.domain] + hostnames).uniq.each do |hostname| - ips.concat(dns.getresources(hostname, Resolv::DNS::Resource::IN::A).to_a.map { |e| e.address.to_s }) - ips.concat(dns.getresources(hostname, Resolv::DNS::Resource::IN::AAAA).to_a.map { |e| e.address.to_s }) - end - end - - (hostnames + ips).each do |hostname| - another_email_domain_block = EmailDomainBlock.new(domain: hostname, parent: @email_domain_block) - log_action :create, another_email_domain_block if another_email_domain_block.save + other_email_domain_block = EmailDomainBlock.create!(domain: domain, parent: @email_domain_block) + log_action :create, other_email_domain_block end end redirect_to admin_email_domain_blocks_path, notice: I18n.t('admin.email_domain_blocks.created_msg') else + set_resolved_records render :new end - end - - def destroy - 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') + rescue ActiveRecord::RecordInvalid + set_resolved_records + render :new end private @@ -62,8 +61,27 @@ module Admin @email_domain_block = EmailDomainBlock.find(params[:id]) 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 - 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 diff --git a/app/controllers/admin/trends/links/preview_card_providers_controller.rb b/app/controllers/admin/trends/links/preview_card_providers_controller.rb index 2c26e03f3..40a466cd6 100644 --- a/app/controllers/admin/trends/links/preview_card_providers_controller.rb +++ b/app/controllers/admin/trends/links/preview_card_providers_controller.rb @@ -5,11 +5,11 @@ class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseControll authorize :preview_card_provider, :index? @preview_card_providers = filtered_preview_card_providers.page(params[:page]) - @form = Form::PreviewCardProviderBatch.new + @form = Trends::PreviewCardProviderBatch.new end 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 rescue ActionController::ParameterMissing flash[:alert] = I18n.t('admin.accounts.no_account_selected') @@ -20,15 +20,15 @@ class Admin::Trends::Links::PreviewCardProvidersController < Admin::BaseControll private def filtered_preview_card_providers - PreviewCardProviderFilter.new(filter_params).results + Trends::PreviewCardProviderFilter.new(filter_params).results end def filter_params - params.slice(:page, *PreviewCardProviderFilter::KEYS).permit(:page, *PreviewCardProviderFilter::KEYS) + params.slice(:page, *Trends::PreviewCardProviderFilter::KEYS).permit(:page, *Trends::PreviewCardProviderFilter::KEYS) end - def form_preview_card_provider_batch_params - params.require(:form_preview_card_provider_batch).permit(:action, preview_card_provider_ids: []) + def trends_preview_card_provider_batch_params + params.require(:trends_preview_card_provider_batch).permit(:action, preview_card_provider_ids: []) end def action_from_button diff --git a/app/controllers/admin/trends/links_controller.rb b/app/controllers/admin/trends/links_controller.rb index 619b37deb..434eec5fe 100644 --- a/app/controllers/admin/trends/links_controller.rb +++ b/app/controllers/admin/trends/links_controller.rb @@ -5,11 +5,11 @@ class Admin::Trends::LinksController < Admin::BaseController authorize :preview_card, :index? @preview_cards = filtered_preview_cards.page(params[:page]) - @form = Form::PreviewCardBatch.new + @form = Trends::PreviewCardBatch.new end 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 rescue ActionController::ParameterMissing flash[:alert] = I18n.t('admin.accounts.no_account_selected') @@ -20,26 +20,26 @@ class Admin::Trends::LinksController < Admin::BaseController private def filtered_preview_cards - PreviewCardFilter.new(filter_params.with_defaults(trending: 'all')).results + Trends::PreviewCardFilter.new(filter_params.with_defaults(trending: 'all')).results end def filter_params - params.slice(:page, *PreviewCardFilter::KEYS).permit(:page, *PreviewCardFilter::KEYS) + params.slice(:page, *Trends::PreviewCardFilter::KEYS).permit(:page, *Trends::PreviewCardFilter::KEYS) end - def form_preview_card_batch_params - params.require(:form_preview_card_batch).permit(:action, preview_card_ids: []) + def trends_preview_card_batch_params + params.require(:trends_preview_card_batch).permit(:action, preview_card_ids: []) end def action_from_button if params[:approve] 'approve' - elsif params[:approve_all] - 'approve_all' + elsif params[:approve_providers] + 'approve_providers' elsif params[:reject] 'reject' - elsif params[:reject_all] - 'reject_all' + elsif params[:reject_providers] + 'reject_providers' end end end diff --git a/app/controllers/admin/trends/statuses_controller.rb b/app/controllers/admin/trends/statuses_controller.rb new file mode 100644 index 000000000..766242738 --- /dev/null +++ b/app/controllers/admin/trends/statuses_controller.rb @@ -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 diff --git a/app/controllers/admin/trends/tags_controller.rb b/app/controllers/admin/trends/tags_controller.rb index 91ff33d40..f4d1ec0d1 100644 --- a/app/controllers/admin/trends/tags_controller.rb +++ b/app/controllers/admin/trends/tags_controller.rb @@ -5,11 +5,11 @@ class Admin::Trends::TagsController < Admin::BaseController authorize :tag, :index? @tags = filtered_tags.page(params[:page]) - @form = Form::TagBatch.new + @form = Trends::TagBatch.new end 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 rescue ActionController::ParameterMissing flash[:alert] = I18n.t('admin.accounts.no_account_selected') @@ -20,15 +20,15 @@ class Admin::Trends::TagsController < Admin::BaseController private def filtered_tags - TagFilter.new(filter_params).results + Trends::TagFilter.new(filter_params).results end def filter_params - params.slice(:page, *TagFilter::KEYS).permit(:page, *TagFilter::KEYS) + params.slice(:page, *Trends::TagFilter::KEYS).permit(:page, *Trends::TagFilter::KEYS) end - def form_tag_batch_params - params.require(:form_tag_batch).permit(:action, tag_ids: []) + def trends_tag_batch_params + params.require(:trends_tag_batch).permit(:action, tag_ids: []) end def action_from_button diff --git a/app/controllers/api/v1/admin/trends/links_controller.rb b/app/controllers/api/v1/admin/trends/links_controller.rb new file mode 100644 index 000000000..63b3d9358 --- /dev/null +++ b/app/controllers/api/v1/admin/trends/links_controller.rb @@ -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 diff --git a/app/controllers/api/v1/admin/trends/statuses_controller.rb b/app/controllers/api/v1/admin/trends/statuses_controller.rb new file mode 100644 index 000000000..86633cc74 --- /dev/null +++ b/app/controllers/api/v1/admin/trends/statuses_controller.rb @@ -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 diff --git a/app/controllers/api/v1/admin/trends/tags_controller.rb b/app/controllers/api/v1/admin/trends/tags_controller.rb index 4815af31e..5cc4c269d 100644 --- a/app/controllers/api/v1/admin/trends/tags_controller.rb +++ b/app/controllers/api/v1/admin/trends/tags_controller.rb @@ -14,6 +14,6 @@ class Api::V1::Admin::Trends::TagsController < Api::BaseController private def set_tags - @tags = Trends.tags.get(false, limit_param(10)) + @tags = Trends.tags.query.limit(limit_param(10)) end end diff --git a/app/controllers/api/v1/trends/links_controller.rb b/app/controllers/api/v1/trends/links_controller.rb index 1c3ab1e1c..ad20e7f8b 100644 --- a/app/controllers/api/v1/trends/links_controller.rb +++ b/app/controllers/api/v1/trends/links_controller.rb @@ -12,10 +12,14 @@ class Api::V1::Trends::LinksController < Api::BaseController def set_links @links = begin if Setting.trends - Trends.links.get(true, limit_param(10)) + links_from_trends else [] end end end + + def links_from_trends + Trends.links.query.allowed.in_locale(content_locale).limit(limit_param(10)) + end end diff --git a/app/controllers/api/v1/trends/statuses_controller.rb b/app/controllers/api/v1/trends/statuses_controller.rb new file mode 100644 index 000000000..d4ec97ae5 --- /dev/null +++ b/app/controllers/api/v1/trends/statuses_controller.rb @@ -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 diff --git a/app/controllers/api/v1/trends/tags_controller.rb b/app/controllers/api/v1/trends/tags_controller.rb index 947b53de2..1334b72d2 100644 --- a/app/controllers/api/v1/trends/tags_controller.rb +++ b/app/controllers/api/v1/trends/tags_controller.rb @@ -12,7 +12,7 @@ class Api::V1::Trends::TagsController < Api::BaseController def set_tags @tags = begin if Setting.trends - Trends.tags.get(true, limit_param(10)) + Trends.tags.query.allowed.limit(limit_param(10)) else [] end diff --git a/app/controllers/concerns/localized.rb b/app/controllers/concerns/localized.rb index 173316800..ede299d5a 100644 --- a/app/controllers/concerns/localized.rb +++ b/app/controllers/concerns/localized.rb @@ -27,4 +27,8 @@ module Localized def available_locale_or_nil(locale_name) locale_name.to_sym if locale_name.present? && I18n.available_locales.include?(locale_name.to_sym) end + + def content_locale + @content_locale ||= I18n.locale.to_s.split(/[_-]/).first + end end diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index d05ceb53f..dfe2ae2e5 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -58,7 +58,7 @@ class Settings::PreferencesController < Settings::BaseController :setting_use_pending_items, :setting_trends, :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) ) end diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb index 907529b37..140fc73ed 100644 --- a/app/helpers/admin/filter_helper.rb +++ b/app/helpers/admin/filter_helper.rb @@ -5,9 +5,10 @@ module Admin::FilterHelper AccountFilter::KEYS, CustomEmojiFilter::KEYS, ReportFilter::KEYS, - TagFilter::KEYS, - PreviewCardProviderFilter::KEYS, - PreviewCardFilter::KEYS, + Trends::TagFilter::KEYS, + Trends::PreviewCardProviderFilter::KEYS, + Trends::PreviewCardFilter::KEYS, + Trends::StatusFilter::KEYS, InstanceFilter::KEYS, InviteFilter::KEYS, RelationshipFilter::KEYS, diff --git a/app/helpers/languages_helper.rb b/app/helpers/languages_helper.rb index 3a65af686..f22cc6d28 100644 --- a/app/helpers/languages_helper.rb +++ b/app/helpers/languages_helper.rb @@ -242,6 +242,6 @@ module LanguagesHelper end def valid_locale?(locale) - SUPPORTED_LOCALES.key?(locale.to_sym) + locale.present? && SUPPORTED_LOCALES.key?(locale.to_sym) end end diff --git a/app/javascript/flavours/glitch/actions/modal.js b/app/javascript/flavours/glitch/actions/modal.js index 3d0299db5..3e576fab8 100644 --- a/app/javascript/flavours/glitch/actions/modal.js +++ b/app/javascript/flavours/glitch/actions/modal.js @@ -9,9 +9,10 @@ export function openModal(type, props) { }; }; -export function closeModal(type) { +export function closeModal(type, options = { ignoreFocus: false }) { return { type: MODAL_CLOSE, modalType: type, + ignoreFocus: options.ignoreFocus, }; }; diff --git a/app/javascript/flavours/glitch/components/icon_button.js b/app/javascript/flavours/glitch/components/icon_button.js index 58d3568dd..3999409cd 100644 --- a/app/javascript/flavours/glitch/components/icon_button.js +++ b/app/javascript/flavours/glitch/components/icon_button.js @@ -30,6 +30,7 @@ export default class IconButton extends React.PureComponent { label: PropTypes.string, counter: PropTypes.number, obfuscateCount: PropTypes.bool, + href: PropTypes.string, }; static defaultProps = { @@ -109,6 +110,7 @@ export default class IconButton extends React.PureComponent { title, counter, obfuscateCount, + href, } = this.props; const { @@ -130,6 +132,21 @@ export default class IconButton extends React.PureComponent { 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 ( <button aria-label={title} @@ -145,8 +162,7 @@ export default class IconButton extends React.PureComponent { tabIndex={tabIndex} disabled={disabled} > - <Icon id={icon} fixedWidth aria-hidden='true' /> {typeof counter !== 'undefined' && <span className='icon-button__counter'><AnimatedNumber value={counter} obfuscate={obfuscateCount} /></span>} - {this.props.label} + {contents} </button> ); } diff --git a/app/javascript/flavours/glitch/components/modal_root.js b/app/javascript/flavours/glitch/components/modal_root.js index 7b5a630e5..0595f6a0e 100644 --- a/app/javascript/flavours/glitch/components/modal_root.js +++ b/app/javascript/flavours/glitch/components/modal_root.js @@ -18,6 +18,7 @@ export default class ModalRoot extends React.PureComponent { b: PropTypes.number, }), noEsc: PropTypes.bool, + ignoreFocus: PropTypes.bool, }; 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 // described in https://github.com/WICG/inert#performance-and-gotchas Promise.resolve().then(() => { - this.activeElement.focus({ preventScroll: true }); + if (!this.props.ignoreFocus) { + this.activeElement.focus({ preventScroll: true }); + } this.activeElement = null; }).catch(console.error); diff --git a/app/javascript/flavours/glitch/features/compose/components/compose_form.js b/app/javascript/flavours/glitch/features/compose/components/compose_form.js index c75906ce7..b03bc34b8 100644 --- a/app/javascript/flavours/glitch/features/compose/components/compose_form.js +++ b/app/javascript/flavours/glitch/features/compose/components/compose_form.js @@ -246,9 +246,14 @@ class ComposeForm extends ImmutablePureComponent { selectionStart = selectionEnd = text.length; } if (textarea) { - textarea.setSelectionRange(selectionStart, selectionEnd); - textarea.focus(); - if (!singleColumn) textarea.scrollIntoView(); + // 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.focus(); + if (!singleColumn) textarea.scrollIntoView(); + }).catch(console.error); } // Refocuses the textarea after submitting. diff --git a/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js index e01d277a1..0408105ae 100644 --- a/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js +++ b/app/javascript/flavours/glitch/features/picture_in_picture/components/footer.js @@ -62,7 +62,7 @@ class Footer extends ImmutablePureComponent { const { router } = this.context; if (onClose) { - onClose(); + onClose(true); } dispatch(replyCompose(status, router.history)); @@ -181,7 +181,7 @@ class Footer extends ImmutablePureComponent { {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='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> ); } diff --git a/app/javascript/flavours/glitch/features/ui/components/modal_root.js b/app/javascript/flavours/glitch/features/ui/components/modal_root.js index 1e065c171..a975c4013 100644 --- a/app/javascript/flavours/glitch/features/ui/components/modal_root.js +++ b/app/javascript/flavours/glitch/features/ui/components/modal_root.js @@ -55,6 +55,7 @@ export default class ModalRoot extends React.PureComponent { type: PropTypes.string, props: PropTypes.object, onClose: PropTypes.func.isRequired, + ignoreFocus: PropTypes.bool, }; state = { @@ -85,7 +86,7 @@ export default class ModalRoot extends React.PureComponent { return <BundleModalError {...props} onClose={onClose} />; } - handleClose = () => { + handleClose = (ignoreFocus = false) => { const { onClose } = this.props; let message = null; try { @@ -95,7 +96,7 @@ export default class ModalRoot extends React.PureComponent { // isn't set. // This would be much smoother with react-intl 3+ and `forwardRef`. } - onClose(message); + onClose(message, ignoreFocus); } setModalRef = (c) => { @@ -103,12 +104,12 @@ export default class ModalRoot extends React.PureComponent { } render () { - const { type, props } = this.props; + const { type, props, ignoreFocus } = this.props; const { backgroundColor } = this.state; const visible = !!type; 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 && ( <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} />} diff --git a/app/javascript/flavours/glitch/features/ui/containers/modal_container.js b/app/javascript/flavours/glitch/features/ui/containers/modal_container.js index 039aabd8a..560c34f01 100644 --- a/app/javascript/flavours/glitch/features/ui/containers/modal_container.js +++ b/app/javascript/flavours/glitch/features/ui/containers/modal_container.js @@ -3,22 +3,23 @@ import { openModal, closeModal } from 'flavours/glitch/actions/modal'; import ModalRoot from '../components/modal_root'; const mapStateToProps = state => ({ - type: state.getIn(['modal', 0, 'modalType'], null), - props: state.getIn(['modal', 0, 'modalProps'], {}), + ignoreFocus: state.getIn(['modal', 'ignoreFocus']), + type: state.getIn(['modal', 'stack', 0, 'modalType'], null), + props: state.getIn(['modal', 'stack', 0, 'modalProps'], {}), }); const mapDispatchToProps = dispatch => ({ - onClose (confirmationMessage) { + onClose (confirmationMessage, ignoreFocus = false) { if (confirmationMessage) { dispatch( openModal('CONFIRM', { message: confirmationMessage.message, confirm: confirmationMessage.confirm, - onConfirm: () => dispatch(closeModal()), + onConfirm: () => dispatch(closeModal(undefined, { ignoreFocus })), }), ); } else { - dispatch(closeModal()); + dispatch(closeModal(undefined, { ignoreFocus })); } }, }); diff --git a/app/javascript/flavours/glitch/reducers/modal.js b/app/javascript/flavours/glitch/reducers/modal.js index ae205c6d5..2ef0aef24 100644 --- a/app/javascript/flavours/glitch/reducers/modal.js +++ b/app/javascript/flavours/glitch/reducers/modal.js @@ -3,16 +3,36 @@ import { TIMELINE_DELETE } from 'flavours/glitch/actions/timelines'; import { COMPOSE_UPLOAD_CHANGE_SUCCESS } from 'flavours/glitch/actions/compose'; 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) { case MODAL_OPEN: - return state.unshift(ImmutableMap({ modalType: action.modalType, modalProps: action.modalProps })); + return pushModal(state, action.modalType, action.modalProps); 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: - return state.getIn([0, 'modalType']) === 'FOCAL_POINT' ? state.shift() : state; + return popModal(state, { modalType: 'FOCAL_POINT', ignoreFocus: false }); 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: return state; } diff --git a/app/javascript/flavours/glitch/styles/accounts.scss b/app/javascript/flavours/glitch/styles/accounts.scss index 56b143fe6..a3bfb0507 100644 --- a/app/javascript/flavours/glitch/styles/accounts.scss +++ b/app/javascript/flavours/glitch/styles/accounts.scss @@ -333,7 +333,8 @@ } .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, strong { @@ -341,6 +342,10 @@ } } +.batch-table__row--muted .name-tag .avatar { + opacity: 0.5; +} + .batch-table__row--muted .accounts-table { tbody td.accounts-table__extra, &__count, @@ -354,7 +359,8 @@ } .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, strong { diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss index 55abd6e1e..b6372c096 100644 --- a/app/javascript/flavours/glitch/styles/components/index.scss +++ b/app/javascript/flavours/glitch/styles/components/index.scss @@ -146,6 +146,11 @@ transition-property: background-color, color; text-decoration: none; + a { + color: inherit; + text-decoration: none; + } + &:hover, &:active, &:focus { diff --git a/app/javascript/flavours/glitch/styles/tables.scss b/app/javascript/flavours/glitch/styles/tables.scss index 12c84a6c9..8b5933b7b 100644 --- a/app/javascript/flavours/glitch/styles/tables.scss +++ b/app/javascript/flavours/glitch/styles/tables.scss @@ -210,6 +210,7 @@ a.table-action-link { &__content { padding-top: 12px; padding-bottom: 16px; + overflow: hidden; &--unpadded { padding: 0; @@ -292,3 +293,9 @@ a.table-action-link { } } } + +.one-liner { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/app/javascript/mastodon/actions/modal.js b/app/javascript/mastodon/actions/modal.js index 3d0299db5..3e576fab8 100644 --- a/app/javascript/mastodon/actions/modal.js +++ b/app/javascript/mastodon/actions/modal.js @@ -9,9 +9,10 @@ export function openModal(type, props) { }; }; -export function closeModal(type) { +export function closeModal(type, options = { ignoreFocus: false }) { return { type: MODAL_CLOSE, modalType: type, + ignoreFocus: options.ignoreFocus, }; }; diff --git a/app/javascript/mastodon/actions/trends.js b/app/javascript/mastodon/actions/trends.js index 853e4f60a..304bbebef 100644 --- a/app/javascript/mastodon/actions/trends.js +++ b/app/javascript/mastodon/actions/trends.js @@ -1,31 +1,94 @@ import api from '../api'; +import { importFetchedStatuses } from './importer'; -export const TRENDS_FETCH_REQUEST = 'TRENDS_FETCH_REQUEST'; -export const TRENDS_FETCH_SUCCESS = 'TRENDS_FETCH_SUCCESS'; -export const TRENDS_FETCH_FAIL = 'TRENDS_FETCH_FAIL'; +export const TRENDS_TAGS_FETCH_REQUEST = 'TRENDS_TAGS_FETCH_REQUEST'; +export const TRENDS_TAGS_FETCH_SUCCESS = 'TRENDS_TAGS_FETCH_SUCCESS'; +export const TRENDS_TAGS_FETCH_FAIL = 'TRENDS_TAGS_FETCH_FAIL'; -export const fetchTrends = () => (dispatch, getState) => { - dispatch(fetchTrendsRequest()); +export const TRENDS_LINKS_FETCH_REQUEST = 'TRENDS_LINKS_FETCH_REQUEST'; +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) - .get('/api/v1/trends') - .then(({ data }) => dispatch(fetchTrendsSuccess(data))) - .catch(err => dispatch(fetchTrendsFail(err))); + .get('/api/v1/trends/links') + .then(({ data }) => dispatch(fetchTrendingLinksSuccess(data))) + .catch(err => dispatch(fetchTrendingLinksFail(err))); }; -export const fetchTrendsRequest = () => ({ - type: TRENDS_FETCH_REQUEST, +export const fetchTrendingLinksRequest = () => ({ + type: TRENDS_LINKS_FETCH_REQUEST, skipLoading: true, }); -export const fetchTrendsSuccess = trends => ({ - type: TRENDS_FETCH_SUCCESS, +export const fetchTrendingLinksSuccess = trends => ({ + type: TRENDS_LINKS_FETCH_SUCCESS, trends, skipLoading: true, }); -export const fetchTrendsFail = error => ({ - type: TRENDS_FETCH_FAIL, +export const fetchTrendingLinksFail = error => ({ + type: TRENDS_LINKS_FETCH_FAIL, + error, + skipLoading: true, + skipAlert: true, +}); + +export const fetchTrendingStatuses = () => (dispatch, getState) => { + dispatch(fetchTrendingStatusesRequest()); + + api(getState).get('/api/v1/trends/statuses').then(({ data }) => { + dispatch(importFetchedStatuses(data)); + dispatch(fetchTrendingStatusesSuccess(data)); + }).catch(err => dispatch(fetchTrendingStatusesFail(err))); +}; + +export const fetchTrendingStatusesRequest = () => ({ + type: TRENDS_STATUSES_FETCH_REQUEST, + skipLoading: true, +}); + +export const fetchTrendingStatusesSuccess = statuses => ({ + type: TRENDS_STATUSES_FETCH_SUCCESS, + statuses, + skipLoading: true, +}); + +export const fetchTrendingStatusesFail = error => ({ + type: TRENDS_STATUSES_FETCH_FAIL, error, skipLoading: true, skipAlert: true, diff --git a/app/javascript/mastodon/components/hashtag.js b/app/javascript/mastodon/components/hashtag.js index a793a32f5..7f442d189 100644 --- a/app/javascript/mastodon/components/hashtag.js +++ b/app/javascript/mastodon/components/hashtag.js @@ -38,7 +38,7 @@ class SilentErrorBoundary extends React.Component { * * @type {(displayNumber: JSX.Element, pluralReady: number) => JSX.Element} */ -const accountsCountRenderer = (displayNumber, pluralReady) => ( +export const accountsCountRenderer = (displayNumber, pluralReady) => ( <FormattedMessage id='trends.counter_by_accounts' defaultMessage='{count, plural, one {{counter} person} other {{counter} people}} talking' diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js index 7ec39198a..6a653675b 100644 --- a/app/javascript/mastodon/components/icon_button.js +++ b/app/javascript/mastodon/components/icon_button.js @@ -27,6 +27,7 @@ export default class IconButton extends React.PureComponent { tabIndex: PropTypes.string, counter: PropTypes.number, obfuscateCount: PropTypes.bool, + href: PropTypes.string, }; static defaultProps = { @@ -102,6 +103,7 @@ export default class IconButton extends React.PureComponent { title, counter, obfuscateCount, + href, } = this.props; const { @@ -123,6 +125,20 @@ export default class IconButton extends React.PureComponent { 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 ( <button aria-label={title} @@ -138,7 +154,7 @@ export default class IconButton extends React.PureComponent { tabIndex={tabIndex} 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> ); } diff --git a/app/javascript/mastodon/components/modal_root.js b/app/javascript/mastodon/components/modal_root.js index 755c46fd6..b894aeaf9 100644 --- a/app/javascript/mastodon/components/modal_root.js +++ b/app/javascript/mastodon/components/modal_root.js @@ -18,6 +18,7 @@ export default class ModalRoot extends React.PureComponent { g: PropTypes.number, b: PropTypes.number, }), + ignoreFocus: PropTypes.bool, }; 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 // described in https://github.com/WICG/inert#performance-and-gotchas Promise.resolve().then(() => { - this.activeElement.focus({ preventScroll: true }); + if (!this.props.ignoreFocus) { + this.activeElement.focus({ preventScroll: true }); + } this.activeElement = null; }).catch(console.error); diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js index e3a7d763f..1d8fe23da 100644 --- a/app/javascript/mastodon/components/status_action_bar.js +++ b/app/javascript/mastodon/components/status_action_bar.js @@ -77,6 +77,7 @@ class StatusActionBar extends ImmutablePureComponent { onPin: PropTypes.func, onBookmark: PropTypes.func, withDismiss: PropTypes.bool, + withCounters: PropTypes.bool, scrollKey: PropTypes.string, intl: PropTypes.object.isRequired, }; @@ -226,7 +227,7 @@ class StatusActionBar extends ImmutablePureComponent { } render () { - const { status, relationship, intl, withDismiss, scrollKey } = this.props; + const { status, relationship, intl, withDismiss, withCounters, scrollKey } = this.props; const anonymousAccess = !me; const publicStatus = ['public', 'unlisted'].includes(status.get('visibility')); @@ -331,8 +332,8 @@ class StatusActionBar extends ImmutablePureComponent { return ( <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={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='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={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} counter={withCounters ? status.get('favourites_count') : undefined} /> {shareButton} diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index eaaffcc3a..35e5749a3 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -24,6 +24,7 @@ export default class StatusList extends ImmutablePureComponent { prepend: PropTypes.node, emptyMessage: PropTypes.node, alwaysPrepend: PropTypes.bool, + withCounters: PropTypes.bool, timelineId: PropTypes.string, }; @@ -100,6 +101,7 @@ export default class StatusList extends ImmutablePureComponent { contextType={timelineId} scrollKey={this.props.scrollKey} showThread + withCounters={this.props.withCounters} /> )) ) : null; @@ -114,6 +116,7 @@ export default class StatusList extends ImmutablePureComponent { onMoveDown={this.handleMoveDown} contextType={timelineId} showThread + withCounters={this.props.withCounters} /> )).concat(scrollableContent); } diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index 26232247d..826d9f504 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -164,8 +164,13 @@ class ComposeForm extends ImmutablePureComponent { selectionStart = selectionEnd; } - this.autosuggestTextarea.textarea.setSelectionRange(selectionStart, selectionEnd); - this.autosuggestTextarea.textarea.focus(); + // 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.focus(); + }).catch(console.error); } else if(prevProps.isSubmitting && !this.props.isSubmitting) { this.autosuggestTextarea.textarea.focus(); } else if (this.props.spoiler !== prevProps.spoiler) { diff --git a/app/javascript/mastodon/features/explore/components/story.js b/app/javascript/mastodon/features/explore/components/story.js new file mode 100644 index 000000000..563128029 --- /dev/null +++ b/app/javascript/mastodon/features/explore/components/story.js @@ -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> + ); + } + +} diff --git a/app/javascript/mastodon/features/explore/index.js b/app/javascript/mastodon/features/explore/index.js new file mode 100644 index 000000000..ddacf5812 --- /dev/null +++ b/app/javascript/mastodon/features/explore/index.js @@ -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> + ); + } + +} diff --git a/app/javascript/mastodon/features/explore/links.js b/app/javascript/mastodon/features/explore/links.js new file mode 100644 index 000000000..6649fb6e4 --- /dev/null +++ b/app/javascript/mastodon/features/explore/links.js @@ -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> + ); + } + +} diff --git a/app/javascript/mastodon/features/explore/results.js b/app/javascript/mastodon/features/explore/results.js new file mode 100644 index 000000000..27e8aaa4f --- /dev/null +++ b/app/javascript/mastodon/features/explore/results.js @@ -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> + ); + } + +} diff --git a/app/javascript/mastodon/features/explore/statuses.js b/app/javascript/mastodon/features/explore/statuses.js new file mode 100644 index 000000000..4e5530d84 --- /dev/null +++ b/app/javascript/mastodon/features/explore/statuses.js @@ -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 + /> + ); + } + +} diff --git a/app/javascript/mastodon/features/explore/suggestions.js b/app/javascript/mastodon/features/explore/suggestions.js new file mode 100644 index 000000000..c094a8d93 --- /dev/null +++ b/app/javascript/mastodon/features/explore/suggestions.js @@ -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> + ); + } + +} diff --git a/app/javascript/mastodon/features/explore/tags.js b/app/javascript/mastodon/features/explore/tags.js new file mode 100644 index 000000000..c0ad9fc6e --- /dev/null +++ b/app/javascript/mastodon/features/explore/tags.js @@ -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> + ); + } + +} diff --git a/app/javascript/mastodon/features/getting_started/containers/trends_container.js b/app/javascript/mastodon/features/getting_started/containers/trends_container.js index 7a5268780..a73832db7 100644 --- a/app/javascript/mastodon/features/getting_started/containers/trends_container.js +++ b/app/javascript/mastodon/features/getting_started/containers/trends_container.js @@ -1,13 +1,13 @@ import { connect } from 'react-redux'; -import { fetchTrends } from 'mastodon/actions/trends'; +import { fetchTrendingHashtags } from 'mastodon/actions/trends'; import Trends from '../components/trends'; const mapStateToProps = state => ({ - trends: state.getIn(['trends', 'items']), + trends: state.getIn(['trends', 'tags', 'items']), }); const mapDispatchToProps = dispatch => ({ - fetchTrends: () => dispatch(fetchTrends()), + fetchTrends: () => dispatch(fetchTrendingHashtags()), }); export default connect(mapStateToProps, mapDispatchToProps)(Trends); diff --git a/app/javascript/mastodon/features/picture_in_picture/components/footer.js b/app/javascript/mastodon/features/picture_in_picture/components/footer.js index 0de562ee1..0cb42b25a 100644 --- a/app/javascript/mastodon/features/picture_in_picture/components/footer.js +++ b/app/javascript/mastodon/features/picture_in_picture/components/footer.js @@ -60,7 +60,7 @@ class Footer extends ImmutablePureComponent { const { router } = this.context; if (onClose) { - onClose(); + onClose(true); } 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={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')} /> - {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> ); } diff --git a/app/javascript/mastodon/features/search/index.js b/app/javascript/mastodon/features/search/index.js deleted file mode 100644 index 76bf70d4b..000000000 --- a/app/javascript/mastodon/features/search/index.js +++ /dev/null @@ -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; diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js index 193637113..db047f5f0 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.js +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -53,7 +53,7 @@ const messages = defineMessages({ 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 })) class ColumnsArea extends ImmutablePureComponent { diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index 7b14fe5ca..3fc235849 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -45,6 +45,7 @@ export default class ModalRoot extends React.PureComponent { type: PropTypes.string, props: PropTypes.object, onClose: PropTypes.func.isRequired, + ignoreFocus: PropTypes.bool, }; state = { @@ -79,7 +80,7 @@ export default class ModalRoot extends React.PureComponent { return <BundleModalError {...props} onClose={onClose} />; } - handleClose = () => { + handleClose = (ignoreFocus = false) => { const { onClose } = this.props; let message = null; try { @@ -89,7 +90,7 @@ export default class ModalRoot extends React.PureComponent { // isn't set. // This would be much smoother with react-intl 3+ and `forwardRef`. } - onClose(message); + onClose(message, ignoreFocus); } setModalRef = (c) => { @@ -97,12 +98,12 @@ export default class ModalRoot extends React.PureComponent { } render () { - const { type, props } = this.props; + const { type, props, ignoreFocus } = this.props; const { backgroundColor } = this.state; const visible = !!type; return ( - <Base backgroundColor={backgroundColor} onClose={this.handleClose}> + <Base backgroundColor={backgroundColor} onClose={this.handleClose} ignoreFocus={ignoreFocus}> {visible && ( <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} />} diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.js b/app/javascript/mastodon/features/ui/components/navigation_panel.js index 901dbdfcb..a70e5ab61 100644 --- a/app/javascript/mastodon/features/ui/components/navigation_panel.js +++ b/app/javascript/mastodon/features/ui/components/navigation_panel.js @@ -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='/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 /> + <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' 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> diff --git a/app/javascript/mastodon/features/ui/components/tabs_bar.js b/app/javascript/mastodon/features/ui/components/tabs_bar.js index a023bcf34..195403fd3 100644 --- a/app/javascript/mastodon/features/ui/components/tabs_bar.js +++ b/app/javascript/mastodon/features/ui/components/tabs_bar.js @@ -10,9 +10,9 @@ import NotificationsCounterIcon from './notifications_counter_icon'; 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='/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' 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 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 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' 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>, ]; diff --git a/app/javascript/mastodon/features/ui/containers/modal_container.js b/app/javascript/mastodon/features/ui/containers/modal_container.js index 34fec8206..35be26222 100644 --- a/app/javascript/mastodon/features/ui/containers/modal_container.js +++ b/app/javascript/mastodon/features/ui/containers/modal_container.js @@ -3,22 +3,23 @@ import { openModal, closeModal } from '../../../actions/modal'; import ModalRoot from '../components/modal_root'; const mapStateToProps = state => ({ - type: state.getIn(['modal', 0, 'modalType'], null), - props: state.getIn(['modal', 0, 'modalProps'], {}), + ignoreFocus: state.getIn(['modal', 'ignoreFocus']), + type: state.getIn(['modal', 'stack', 0, 'modalType'], null), + props: state.getIn(['modal', 'stack', 0, 'modalProps'], {}), }); const mapDispatchToProps = dispatch => ({ - onClose (confirmationMessage) { + onClose (confirmationMessage, ignoreFocus = false) { if (confirmationMessage) { dispatch( openModal('CONFIRM', { message: confirmationMessage.message, confirm: confirmationMessage.confirm, - onConfirm: () => dispatch(closeModal()), + onConfirm: () => dispatch(closeModal(undefined, { ignoreFocus })), }), ); } else { - dispatch(closeModal()); + dispatch(closeModal(undefined, { ignoreFocus })); } }, }); diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index 3feffa656..2d0136992 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -49,8 +49,8 @@ import { Mutes, PinnedStatuses, Lists, - Search, Directory, + Explore, FollowRecommendations, } from './util/async-components'; import { me } from '../../initial_state'; @@ -167,8 +167,8 @@ class SwitchingColumnsArea extends React.PureComponent { <WrappedRoute path='/pinned' component={PinnedStatuses} 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={['/explore', '/search']} component={Explore} content={children} /> <WrappedRoute path={['/publish', '/statuses/new']} component={Compose} content={children} /> <WrappedRoute path={['/@:acct', '/accounts/:id']} exact component={AccountTimeline} content={children} /> diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 5349bd656..92c683e2f 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -138,10 +138,6 @@ export function ListAdder () { return import(/*webpackChunkName: "features/list_adder" */'../../list_adder'); } -export function Search () { - return import(/*webpackChunkName: "features/search" */'../../search'); -} - export function Tesseract () { return import(/*webpackChunkName: "tesseract" */'tesseract.js'); } @@ -161,3 +157,7 @@ export function FollowRecommendations () { export function CompareHistoryModal () { return import(/*webpackChunkName: "modals/compare_history_modal" */'../components/compare_history_modal'); } + +export function Explore () { + return import(/* webpackChunkName: "features/explore" */'../../explore'); +} diff --git a/app/javascript/mastodon/reducers/modal.js b/app/javascript/mastodon/reducers/modal.js index 41161a206..3eab07d9d 100644 --- a/app/javascript/mastodon/reducers/modal.js +++ b/app/javascript/mastodon/reducers/modal.js @@ -3,16 +3,36 @@ import { TIMELINE_DELETE } from '../actions/timelines'; import { COMPOSE_UPLOAD_CHANGE_SUCCESS } from '../actions/compose'; 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) { case MODAL_OPEN: - return state.unshift(ImmutableMap({ modalType: action.modalType, modalProps: action.modalProps })); + return pushModal(state, action.modalType, action.modalProps); 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: - return state.getIn([0, 'modalType']) === 'FOCAL_POINT' ? state.shift() : state; + return popModal(state, { modalType: 'FOCAL_POINT', ignoreFocus: false }); 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: return state; } diff --git a/app/javascript/mastodon/reducers/search.js b/app/javascript/mastodon/reducers/search.js index 875b2d92b..23bbe4d99 100644 --- a/app/javascript/mastodon/reducers/search.js +++ b/app/javascript/mastodon/reducers/search.js @@ -1,6 +1,8 @@ import { SEARCH_CHANGE, SEARCH_CLEAR, + SEARCH_FETCH_REQUEST, + SEARCH_FETCH_FAIL, SEARCH_FETCH_SUCCESS, SEARCH_SHOW, SEARCH_EXPAND_SUCCESS, @@ -17,6 +19,7 @@ const initialState = ImmutableMap({ submitted: false, hidden: false, results: ImmutableMap(), + isLoading: false, searchTerm: '', }); @@ -37,12 +40,22 @@ export default function search(state = initialState, action) { case COMPOSE_MENTION: case COMPOSE_DIRECT: 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: - return state.set('results', ImmutableMap({ - accounts: ImmutableList(action.results.accounts.map(item => item.id)), - statuses: ImmutableList(action.results.statuses.map(item => item.id)), - hashtags: fromJS(action.results.hashtags), - })).set('submitted', true).set('searchTerm', action.searchTerm); + return state.withMutations(map => { + map.set('results', ImmutableMap({ + accounts: ImmutableList(action.results.accounts.map(item => item.id)), + statuses: ImmutableList(action.results.statuses.map(item => item.id)), + hashtags: fromJS(action.results.hashtags), + })); + + map.set('submitted', true); + map.set('searchTerm', action.searchTerm); + map.set('isLoading', false); + }); case SEARCH_EXPAND_SUCCESS: 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)); diff --git a/app/javascript/mastodon/reducers/status_lists.js b/app/javascript/mastodon/reducers/status_lists.js index 9f8f28dee..49bc94a40 100644 --- a/app/javascript/mastodon/reducers/status_lists.js +++ b/app/javascript/mastodon/reducers/status_lists.js @@ -17,6 +17,11 @@ import { import { PINNED_STATUSES_FETCH_SUCCESS, } 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 { FAVOURITE_SUCCESS, @@ -26,6 +31,10 @@ import { PIN_SUCCESS, UNPIN_SUCCESS, } from '../actions/interactions'; +import { + ACCOUNT_BLOCK_SUCCESS, + ACCOUNT_MUTE_SUCCESS, +} from '../actions/accounts'; const initialState = ImmutableMap({ favourites: ImmutableMap({ @@ -43,6 +52,11 @@ const initialState = ImmutableMap({ loaded: false, items: ImmutableList(), }), + trending: ImmutableMap({ + next: null, + loaded: false, + items: ImmutableList(), + }), }); 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); case BOOKMARKED_STATUSES_EXPAND_SUCCESS: 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: return prependOneToList(state, 'favourites', action.status); case UNFAVOURITE_SUCCESS: @@ -110,6 +130,9 @@ export default function statusLists(state = initialState, action) { return prependOneToList(state, 'pins', action.status); case UNPIN_SUCCESS: 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: return state; } diff --git a/app/javascript/mastodon/reducers/trends.js b/app/javascript/mastodon/reducers/trends.js index 5cecc8fca..3e01bd07d 100644 --- a/app/javascript/mastodon/reducers/trends.js +++ b/app/javascript/mastodon/reducers/trends.js @@ -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'; const initialState = ImmutableMap({ - items: ImmutableList(), - isLoading: false, + tags: ImmutableMap({ + items: ImmutableList(), + isLoading: false, + }), + + links: ImmutableMap({ + items: ImmutableList(), + isLoading: false, + }), }); export default function trendsReducer(state = initialState, action) { switch(action.type) { - case TRENDS_FETCH_REQUEST: - return state.set('isLoading', true); - case TRENDS_FETCH_SUCCESS: + case TRENDS_TAGS_FETCH_REQUEST: + return state.setIn(['tags', 'isLoading'], true); + 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 => { - map.set('items', fromJS(action.trends)); - map.set('isLoading', false); + map.setIn(['links', 'items'], fromJS(action.trends)); + map.setIn(['links', 'isLoading'], false); }); - case TRENDS_FETCH_FAIL: - return state.set('isLoading', false); + case TRENDS_LINKS_FETCH_FAIL: + return state.setIn(['links', 'isLoading'], false); default: return state; } diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss index 485fe4a9d..215774a19 100644 --- a/app/javascript/styles/mastodon/accounts.scss +++ b/app/javascript/styles/mastodon/accounts.scss @@ -331,7 +331,8 @@ } .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, strong { @@ -339,6 +340,10 @@ } } +.batch-table__row--muted .name-tag .avatar { + opacity: 0.5; +} + .batch-table__row--muted .accounts-table { tbody td.accounts-table__extra, &__count, @@ -352,7 +357,8 @@ } .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, strong { diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 108bf68a5..8f6d4b69a 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -166,6 +166,11 @@ transition-property: background-color, color; text-decoration: none; + a { + color: inherit; + text-decoration: none; + } + &:hover, &:active, &:focus { @@ -2797,6 +2802,10 @@ a.account__display-name { position: relative; min-height: 120px; } + + .scrollable { + flex: 1 1 auto; + } } .scrollable.fullscreen { @@ -7724,3 +7733,122 @@ noscript { 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; + } + } + } +} diff --git a/app/javascript/styles/mastodon/tables.scss b/app/javascript/styles/mastodon/tables.scss index 36bc07a72..1f7e71776 100644 --- a/app/javascript/styles/mastodon/tables.scss +++ b/app/javascript/styles/mastodon/tables.scss @@ -210,6 +210,7 @@ a.table-action-link { &__content { padding-top: 12px; padding-bottom: 16px; + overflow: hidden; &--unpadded { padding: 0; @@ -296,3 +297,9 @@ a.table-action-link { } } } + +.one-liner { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb index 12fad8da4..7cd5a41e8 100644 --- a/app/lib/activitypub/activity/announce.rb +++ b/app/lib/activitypub/activity/announce.rb @@ -23,8 +23,7 @@ class ActivityPub::Activity::Announce < ActivityPub::Activity visibility: visibility_from_audience ) - Trends.tags.register(@status) - Trends.links.register(@status) + Trends.register!(@status) distribute end diff --git a/app/lib/activitypub/activity/like.rb b/app/lib/activitypub/activity/like.rb index c065f01f8..ebbda15b9 100644 --- a/app/lib/activitypub/activity/like.rb +++ b/app/lib/activitypub/activity/like.rb @@ -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) favourite = original_status.favourites.create!(account: @account) + NotifyService.new.call(original_status.account, :favourite, favourite) + Trends.statuses.register(original_status) end end diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb index a9d00c000..f416977d8 100644 --- a/app/mailers/admin_mailer.rb +++ b/app/mailers/admin_mailer.rb @@ -35,25 +35,18 @@ class AdminMailer < ApplicationMailer end end - def new_trending_tags(recipient, tags) - @tags = tags - @me = recipient - @instance = Rails.configuration.x.local_domain - @lowest_trending_tag = Trends.tags.get(true, Trends.tags.options[:review_threshold]).last + def new_trends(recipient, links, tags, statuses) + @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 + @instance = Rails.configuration.x.local_domain 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 - @me = recipient - @instance = Rails.configuration.x.local_domain - @lowest_trending_link = Trends.links.get(true, Trends.links.options[:review_threshold]).last - - locale_for_account(@me) do - mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trending_links.subject', instance: @instance) + mail to: @me.user_email, subject: I18n.t('admin_mailer.new_trends.subject', instance: @instance) end end end diff --git a/app/models/account.rb b/app/models/account.rb index 8f6663e7c..8617b389c 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -40,13 +40,15 @@ # also_known_as :string is an Array # silenced_at :datetime # suspended_at :datetime -# trust_level :integer # hide_collections :boolean # avatar_storage_schema_version :integer # header_storage_schema_version :integer # devices_url :string # suspension_origin :integer # sensitized_at :datetime +# trendable :boolean +# reviewed_at :datetime +# requested_review_at :datetime # class Account < ApplicationRecord @@ -56,6 +58,7 @@ class Account < ApplicationRecord remote_url salmon_url hub_url + trust_level ) 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_FIELDS = (ENV['MAX_PROFILE_FIELDS'] || 4).to_i - TRUST_LEVELS = { - untrusted: 0, - trusted: 1, - }.freeze - enum protocol: [:ostatus, :activitypub] enum suspension_origin: [:local, :remote], _prefix: true @@ -206,10 +204,6 @@ class Account < ApplicationRecord last_webfingered_at.nil? || last_webfingered_at <= 1.day.ago end - def trust_level - self[:trust_level] || 0 - end - def refresh! ResolveAccountService.new.call(acct) unless local? end @@ -390,6 +384,22 @@ class Account < ApplicationRecord @synchronization_uri_prefix ||= "#{uri[URL_PREFIX_RE]}/" 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 attributes :name, :value, :verified_at, :account diff --git a/app/models/email_domain_block.rb b/app/models/email_domain_block.rb index f50fa46ba..36e7e62ab 100644 --- a/app/models/email_domain_block.rb +++ b/app/models/email_domain_block.rb @@ -3,11 +3,13 @@ # # Table name: email_domain_blocks # -# id :bigint(8) not null, primary key -# domain :string default(""), not null -# created_at :datetime not null -# updated_at :datetime not null -# parent_id :bigint(8) +# id :bigint(8) not null, primary key +# domain :string default(""), not null +# created_at :datetime not null +# updated_at :datetime not null +# parent_id :bigint(8) +# ips :inet is an Array +# last_refresh_at :datetime # class EmailDomainBlock < ApplicationRecord @@ -18,27 +20,42 @@ class EmailDomainBlock < ApplicationRecord validates :domain, presence: true, uniqueness: true, domain: true - def with_dns_records=(val) - @with_dns_records = ActiveModel::Type::Boolean.new.cast(val) - end + # Used for adding multiple blocks at once + attr_accessor :other_domains - def with_dns_records? - @with_dns_records + def history + @history ||= Trends::History.new('email_domain_blocks', id) end - alias with_dns_records with_dns_records? + 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 + + TagManager.instance.normalize_domain(domain) if domain.present? + rescue Addressable::URI::InvalidURIError + nil + end - def self.block?(email) - _, domain = email.split('@', 2) + # 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 - return true if domain.nil? + blocked = domains.any?(&:nil?) - begin - domain = TagManager.instance.normalize_domain(domain) - rescue Addressable::URI::InvalidURIError - return true + scope = where(domain: domains) + scope = scope.or(where('ips && ARRAY[?]::inet[]', ips)) if ips.any? + + scope.find_each do |block| + blocked = true + block.history.add(attempt_ip) if attempt_ip.present? end - where(domain: domain).exists? + blocked end end diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index 34f14e312..5627f8a84 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -35,6 +35,7 @@ class Form::AdminSettings show_replies_in_public_timelines trends trendable_by_default + trending_status_cw show_domain_blocks show_domain_blocks_rationale noindex @@ -57,6 +58,7 @@ class Form::AdminSettings show_replies_in_public_timelines trends trendable_by_default + trending_status_cw noindex require_invite_text captcha_enabled diff --git a/app/models/form/email_domain_block_batch.rb b/app/models/form/email_domain_block_batch.rb new file mode 100644 index 000000000..df120182b --- /dev/null +++ b/app/models/form/email_domain_block_batch.rb @@ -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 diff --git a/app/models/status.rb b/app/models/status.rb index 607b70712..2fe4eedb5 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -27,6 +27,7 @@ # content_type :string # deleted_at :datetime # edited_at :datetime +# trendable :boolean # class Status < ApplicationRecord @@ -274,6 +275,18 @@ class Status < ApplicationRecord update_status_stat!(key => [public_send(key) - 1, 0].max) 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_destroy_commit :decrement_counter_caches diff --git a/app/models/trends.rb b/app/models/trends.rb index 8f8cb0261..0be900b04 100644 --- a/app/models/trends.rb +++ b/app/models/trends.rb @@ -13,15 +13,40 @@ module Trends @tags ||= Trends::Tags.new 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! - [links, tags].each(&:refresh) + [links, tags, statuses].each(&:refresh) end 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 def self.enabled? Setting.trends end + + def self.available_locales + @available_locales ||= I18n.available_locales.map { |locale| locale.to_s.split(/[_-]/).first }.uniq + end end diff --git a/app/models/trends/base.rb b/app/models/trends/base.rb index b767dcb1a..7ed13228d 100644 --- a/app/models/trends/base.rb +++ b/app/models/trends/base.rb @@ -2,6 +2,7 @@ class Trends::Base include Redisable + include LanguagesHelper class_attribute :default_options @@ -32,8 +33,8 @@ class Trends::Base raise NotImplementedError end - def get(*) - raise NotImplementedError + def query + Trends::Query.new(key_prefix, klass) end def score(id) @@ -72,6 +73,21 @@ class Trends::Base redis.zrevrange("#{key_prefix}:allowed", 0, rank, with_scores: true).last&.last || 0 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 def used_key(at_time) diff --git a/app/models/trends/links.rb b/app/models/trends/links.rb index a0d65138b..62308e706 100644 --- a/app/models/trends/links.rb +++ b/app/models/trends/links.rb @@ -4,8 +4,8 @@ class Trends::Links < Trends::Base PREFIX = 'trending_links' self.default_options = { - threshold: 15, - review_threshold: 10, + threshold: 5, + review_threshold: 3, max_score_cooldown: 2.days.freeze, max_score_halflife: 8.hours.freeze, } @@ -27,12 +27,6 @@ class Trends::Links < Trends::Base record_used_id(preview_card.id, at_time) 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) preview_cards = PreviewCard.where(id: (recently_used_ids(at_time) + currently_trending_ids(false, -1)).uniq) calculate_scores(preview_cards, at_time) @@ -42,7 +36,7 @@ class Trends::Links < Trends::Base def request_review 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? if preview_card.provider.nil? @@ -53,12 +47,6 @@ class Trends::Links < Trends::Base preview_card 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 protected @@ -67,6 +55,10 @@ class Trends::Links < Trends::Base PREFIX end + def klass + PreviewCard + end + private 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)) - if decaying_score.zero? - redis.zrem("#{PREFIX}:all", preview_card.id) - redis.zrem("#{PREFIX}:allowed", preview_card.id) - else - redis.zadd("#{PREFIX}:all", decaying_score, preview_card.id) + add_to_and_remove_from_subsets(preview_card.id, decaying_score, { + all: true, + allowed: preview_card.trendable?, + }) - if preview_card.trendable? - redis.zadd("#{PREFIX}:allowed", decaying_score, preview_card.id) - else - redis.zrem("#{PREFIX}:allowed", preview_card.id) - end + next unless valid_locale?(preview_card.language) + + add_to_and_remove_from_subsets(preview_card.id, decaying_score, { + "all:#{preview_card.language}" => true, + "allowed:#{preview_card.language}" => preview_card.trendable?, + }) + end + + # Clean up localized sets by calculating the intersection with the main + # set. We do this instead of just deleting the localized sets to avoid + # having moments where the API returns empty results + + redis.pipelined do + Trends.available_locales.each do |locale| + redis.zinterstore("#{key_prefix}:all:#{locale}", ["#{key_prefix}:all:#{locale}", "#{key_prefix}:all"], aggregate: 'max') + redis.zinterstore("#{key_prefix}:allowed:#{locale}", ["#{key_prefix}:allowed:#{locale}", "#{key_prefix}:all"], aggregate: 'max') end end end diff --git a/app/models/form/preview_card_batch.rb b/app/models/trends/preview_card_batch.rb index 5f6e6522a..b1d682910 100644 --- a/app/models/form/preview_card_batch.rb +++ b/app/models/trends/preview_card_batch.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Form::PreviewCardBatch +class Trends::PreviewCardBatch include ActiveModel::Model include Authorization @@ -10,12 +10,12 @@ class Form::PreviewCardBatch case action when 'approve' approve! - when 'approve_all' - approve_all! + when 'approve_providers' + approve_providers! when 'reject' reject! - when 'reject_all' - reject_all! + when 'reject_providers' + reject_providers! end end @@ -30,13 +30,13 @@ class Form::PreviewCardBatch end 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) end - def approve_all! + def approve_providers! preview_card_providers.each do |provider| - authorize(provider, :update?) + authorize(provider, :review?) provider.update(trendable: true, reviewed_at: action_time) end @@ -45,13 +45,13 @@ class Form::PreviewCardBatch end 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) end - def reject_all! + def reject_providers! preview_card_providers.each do |provider| - authorize(provider, :update?) + authorize(provider, :review?) provider.update(trendable: false, reviewed_at: action_time) end diff --git a/app/models/preview_card_filter.rb b/app/models/trends/preview_card_filter.rb index 8dda9989c..25add58c8 100644 --- a/app/models/preview_card_filter.rb +++ b/app/models/trends/preview_card_filter.rb @@ -1,8 +1,9 @@ # frozen_string_literal: true -class PreviewCardFilter +class Trends::PreviewCardFilter KEYS = %i( trending + locale ).freeze attr_reader :params @@ -15,7 +16,7 @@ class PreviewCardFilter scope = PreviewCard.unscoped 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? end @@ -35,19 +36,11 @@ class PreviewCardFilter end def trending_scope(value) - ids = begin - case value.to_s - when 'allowed' - Trends.links.currently_trending_ids(true, -1) - else - Trends.links.currently_trending_ids(false, -1) - end - end + scope = Trends.links.query - if ids.empty? - PreviewCard.none - 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') - end + scope = scope.in_locale(@params[:locale].to_s) if @params[:locale].present? + scope = scope.allowed if value == 'allowed' + + scope.to_arel end end diff --git a/app/models/form/preview_card_provider_batch.rb b/app/models/trends/preview_card_provider_batch.rb index e6ab3d8fa..062720c81 100644 --- a/app/models/form/preview_card_provider_batch.rb +++ b/app/models/trends/preview_card_provider_batch.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Form::PreviewCardProviderBatch +class Trends::PreviewCardProviderBatch include ActiveModel::Model include Authorization @@ -22,12 +22,12 @@ class Form::PreviewCardProviderBatch end 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) end 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) end end diff --git a/app/models/preview_card_provider_filter.rb b/app/models/trends/preview_card_provider_filter.rb index 1e90d3c9d..abfdd07e8 100644 --- a/app/models/preview_card_provider_filter.rb +++ b/app/models/trends/preview_card_provider_filter.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class PreviewCardProviderFilter +class Trends::PreviewCardProviderFilter KEYS = %i( status ).freeze diff --git a/app/models/trends/query.rb b/app/models/trends/query.rb new file mode 100644 index 000000000..64a4c0c1f --- /dev/null +++ b/app/models/trends/query.rb @@ -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 diff --git a/app/models/trends/status_batch.rb b/app/models/trends/status_batch.rb new file mode 100644 index 000000000..78d93bed4 --- /dev/null +++ b/app/models/trends/status_batch.rb @@ -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 diff --git a/app/models/trends/status_filter.rb b/app/models/trends/status_filter.rb new file mode 100644 index 000000000..7c453e339 --- /dev/null +++ b/app/models/trends/status_filter.rb @@ -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 diff --git a/app/models/trends/statuses.rb b/app/models/trends/statuses.rb new file mode 100644 index 000000000..e9c48a06b --- /dev/null +++ b/app/models/trends/statuses.rb @@ -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 diff --git a/app/models/form/tag_batch.rb b/app/models/trends/tag_batch.rb index b9330745f..16ee08c06 100644 --- a/app/models/form/tag_batch.rb +++ b/app/models/trends/tag_batch.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Form::TagBatch +class Trends::TagBatch include ActiveModel::Model include Authorization @@ -22,12 +22,12 @@ class Form::TagBatch end def approve! - tags.each { |tag| authorize(tag, :update?) } + tags.each { |tag| authorize(tag, :review?) } tags.update_all(trendable: true, reviewed_at: action_time) end def reject! - tags.each { |tag| authorize(tag, :update?) } + tags.each { |tag| authorize(tag, :review?) } tags.update_all(trendable: false, reviewed_at: action_time) end diff --git a/app/models/tag_filter.rb b/app/models/trends/tag_filter.rb index ecdb52503..3b142efc4 100644 --- a/app/models/tag_filter.rb +++ b/app/models/trends/tag_filter.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class TagFilter +class Trends::TagFilter KEYS = %i( trending status @@ -42,13 +42,7 @@ class TagFilter end def trending_scope - ids = Trends.tags.currently_trending_ids(false, -1) - - 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 + Trends.tags.query.to_arel end def status_scope(value) diff --git a/app/models/trends/tags.rb b/app/models/trends/tags.rb index 2ea4550df..3caa58815 100644 --- a/app/models/trends/tags.rb +++ b/app/models/trends/tags.rb @@ -5,7 +5,7 @@ class Trends::Tags < Trends::Base self.default_options = { threshold: 5, - review_threshold: 10, + review_threshold: 3, max_score_cooldown: 2.days.freeze, max_score_halflife: 4.hours.freeze, } @@ -29,27 +29,15 @@ class Trends::Tags < Trends::Base trim_older_items 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 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? tag.touch(:requested_review_at) tag 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 protected @@ -58,6 +46,10 @@ class Trends::Tags < Trends::Base PREFIX end + def klass + Tag + end + private 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)) - if decaying_score.zero? - redis.zrem("#{PREFIX}:all", tag.id) - redis.zrem("#{PREFIX}:allowed", tag.id) - 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 + add_to_and_remove_from_subsets(tag.id, decaying_score, { + all: true, + allowed: tag.trendable?, + }) end end diff --git a/app/models/user.rb b/app/models/user.rb index a21e96ae5..77685ad02 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -269,10 +269,18 @@ class User < ApplicationRecord settings.notification_emails['appeal'] end - def allows_trending_tag_emails? + def allows_trending_tags_review_emails? settings.notification_emails['trending_tag'] 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? @hides_network ||= settings.hide_network end diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb index 46237e45c..cc23771e7 100644 --- a/app/policies/account_policy.rb +++ b/app/policies/account_policy.rb @@ -68,4 +68,8 @@ class AccountPolicy < ApplicationPolicy def unblock_email? staff? end + + def review? + staff? + end end diff --git a/app/policies/preview_card_policy.rb b/app/policies/preview_card_policy.rb index 4f485d7fc..0410987e4 100644 --- a/app/policies/preview_card_policy.rb +++ b/app/policies/preview_card_policy.rb @@ -5,7 +5,7 @@ class PreviewCardPolicy < ApplicationPolicy staff? end - def update? + def review? staff? end end diff --git a/app/policies/preview_card_provider_policy.rb b/app/policies/preview_card_provider_policy.rb index 598d54a5e..44d2ad5cf 100644 --- a/app/policies/preview_card_provider_policy.rb +++ b/app/policies/preview_card_provider_policy.rb @@ -5,7 +5,7 @@ class PreviewCardProviderPolicy < ApplicationPolicy staff? end - def update? + def review? staff? end end diff --git a/app/policies/status_policy.rb b/app/policies/status_policy.rb index 8746fb2c6..d3a3b36c0 100644 --- a/app/policies/status_policy.rb +++ b/app/policies/status_policy.rb @@ -42,6 +42,10 @@ class StatusPolicy < ApplicationPolicy staff? || owned? end + def review? + staff? + end + private def requires_mention? diff --git a/app/policies/tag_policy.rb b/app/policies/tag_policy.rb index aaf70fcab..bdfcec0c9 100644 --- a/app/policies/tag_policy.rb +++ b/app/policies/tag_policy.rb @@ -12,4 +12,8 @@ class TagPolicy < ApplicationPolicy def update? staff? end + + def review? + staff? + end end diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb index 70fb2ba90..4163bb098 100644 --- a/app/presenters/status_relationships_presenter.rb +++ b/app/presenters/status_relationships_presenter.rb @@ -15,7 +15,7 @@ class StatusRelationshipsPresenter statuses = statuses.compact status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq.compact 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] || {}) @favourites_map = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {}) diff --git a/app/services/delete_account_service.rb b/app/services/delete_account_service.rb index a572a7c59..a2d535d26 100644 --- a/app/services/delete_account_service.rb +++ b/app/services/delete_account_service.rb @@ -220,21 +220,23 @@ class DeleteAccountService < BaseService return unless keep_account_record? - @account.silenced_at = nil - @account.suspended_at = @options[:suspended_at] || Time.now.utc - @account.suspension_origin = :local - @account.locked = false - @account.memorial = false - @account.discoverable = false - @account.display_name = '' - @account.note = '' - @account.fields = [] - @account.statuses_count = 0 - @account.followers_count = 0 - @account.following_count = 0 - @account.moved_to_account = nil - @account.also_known_as = [] - @account.trust_level = :untrusted + @account.silenced_at = nil + @account.suspended_at = @options[:suspended_at] || Time.now.utc + @account.suspension_origin = :local + @account.locked = false + @account.memorial = false + @account.discoverable = false + @account.trendable = false + @account.display_name = '' + @account.note = '' + @account.fields = [] + @account.statuses_count = 0 + @account.followers_count = 0 + @account.following_count = 0 + @account.moved_to_account = nil + @account.reviewed_at = nil + @account.requested_review_at = nil + @account.also_known_as = [] @account.avatar.destroy @account.header.destroy @account.save! diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb index a0ab3b4b7..0ca0081b4 100644 --- a/app/services/favourite_service.rb +++ b/app/services/favourite_service.rb @@ -17,6 +17,8 @@ class FavouriteService < BaseService favourite = Favourite.create!(account: account, status: status) + Trends.statuses.register(status) + create_notification(favourite) bump_potential_friendship(account, status) diff --git a/app/services/reblog_service.rb b/app/services/reblog_service.rb index 6556fbff7..97f9f8e92 100644 --- a/app/services/reblog_service.rb +++ b/app/services/reblog_service.rb @@ -30,8 +30,7 @@ class ReblogService < BaseService reblog = account.statuses.create!(reblog: reblogged_status, text: '', visibility: visibility, rate_limit: options[:with_rate_limit]) - Trends.tags.register(reblog) - Trends.links.register(reblog) + Trends.register!(reblog) DistributionWorker.perform_async(reblog.id) ActivityPub::DistributionWorker.perform_async(reblog.id) unless reblogged_status.local_only? diff --git a/app/validators/blacklisted_email_validator.rb b/app/validators/blacklisted_email_validator.rb index eb66ad93d..9b3f2e33e 100644 --- a/app/validators/blacklisted_email_validator.rb +++ b/app/validators/blacklisted_email_validator.rb @@ -4,41 +4,39 @@ class BlacklistedEmailValidator < ActiveModel::Validator def validate(user) return if user.valid_invitation? || user.email.blank? - @email = user.email - - user.errors.add(:email, :blocked) if blocked_email_provider? - user.errors.add(:email, :taken) if blocked_canonical_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) end private - def blocked_email_provider? - disallowed_through_email_domain_block? || disallowed_through_configuration? || not_allowed_through_configuration? + def blocked_email_provider?(email, ip) + disallowed_through_email_domain_block?(email, ip) || disallowed_through_configuration?(email) || not_allowed_through_configuration?(email) end - def blocked_canonical_email? - CanonicalEmailBlock.block?(@email) + def blocked_canonical_email?(email) + CanonicalEmailBlock.block?(email) end - def disallowed_through_email_domain_block? - EmailDomainBlock.block?(@email) + def disallowed_through_email_domain_block?(email, ip) + EmailDomainBlock.block?(email, attempt_ip: ip) end - def not_allowed_through_configuration? + def not_allowed_through_configuration?(email) return false if Rails.configuration.x.email_domains_whitelist.blank? domains = Rails.configuration.x.email_domains_whitelist.gsub('.', '\.') regexp = Regexp.new("@(.+\\.)?(#{domains})$", true) - @email !~ regexp + email !~ regexp end - def disallowed_through_configuration? + def disallowed_through_configuration?(email) return false if Rails.configuration.x.email_domains_blacklist.blank? domains = Rails.configuration.x.email_domains_blacklist.gsub('.', '\.') regexp = Regexp.new("@(.+\\.)?(#{domains})", true) - regexp.match?(@email) + regexp.match?(email) end end diff --git a/app/validators/email_mx_validator.rb b/app/validators/email_mx_validator.rb index dceef5029..237ca4c7b 100644 --- a/app/validators/email_mx_validator.rb +++ b/app/validators/email_mx_validator.rb @@ -11,11 +11,11 @@ class EmailMxValidator < ActiveModel::Validator if domain.blank? user.errors.add(:email, :invalid) 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) - elsif on_blacklist?(hostnames + ips) + elsif on_blacklist?(resolved_domains, resolved_ips, user.sign_up_ip) user.errors.add(:email, :blocked) end end @@ -40,24 +40,24 @@ class EmailMxValidator < ActiveModel::Validator end def resolve_mx(domain) - hostnames = [] - ips = [] + records = [] + ips = [] Resolv::DNS.open do |dns| 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::AAAA).to_a.map { |e| e.address.to_s }) end end - [ips, hostnames] + [ips, records] end - def on_blacklist?(values) - EmailDomainBlock.where(domain: values.uniq).any? + def on_blacklist?(domains, resolved_ips, attempt_ip) + EmailDomainBlock.block?(domains, ips: resolved_ips, attempt_ip: attempt_ip) end end diff --git a/app/views/admin/custom_emojis/_custom_emoji.html.haml b/app/views/admin/custom_emojis/_custom_emoji.html.haml index 526c844e9..41f3975cf 100644 --- a/app/views/admin/custom_emojis/_custom_emoji.html.haml +++ b/app/views/admin/custom_emojis/_custom_emoji.html.haml @@ -3,7 +3,7 @@ = 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__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 %samp= ":#{custom_emoji.shortcode}:" diff --git a/app/views/admin/email_domain_blocks/_email_domain_block.html.haml b/app/views/admin/email_domain_blocks/_email_domain_block.html.haml index 41ab8c171..c5a55bc27 100644 --- a/app/views/admin/email_domain_blocks/_email_domain_block.html.haml +++ b/app/views/admin/email_domain_blocks/_email_domain_block.html.haml @@ -1,15 +1,14 @@ -%tr - %td - %samp= email_domain_block.domain - %td - = table_link_to 'trash', t('admin.email_domain_blocks.delete'), admin_email_domain_block_path(email_domain_block), method: :delete +.batch-table__row + %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox + = f.check_box :email_domain_block_ids, { multiple: true, include_hidden: false }, email_domain_block.id + .batch-table__row__content.pending-account + .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| - %tr - %td - %samp= child_email_domain_block.domain - %span.muted-hint - = surround '(', ')' do - = t('admin.email_domain_blocks.from_html', domain: content_tag(:samp, email_domain_block.domain)) - %td - = table_link_to 'trash', t('admin.email_domain_blocks.delete'), admin_email_domain_block_path(child_email_domain_block), method: :delete + %br/ + + - if email_domain_block.parent.present? + = t('admin.email_domain_blocks.resolved_through_html', domain: content_tag(:samp, email_domain_block.parent.domain)) + • + + = t('admin.email_domain_blocks.attempts_over_week', count: email_domain_block.history.reduce(0) { |sum, day| sum + day.accounts }) diff --git a/app/views/admin/email_domain_blocks/index.html.haml b/app/views/admin/email_domain_blocks/index.html.haml index fa5d86b67..9f16e0d5c 100644 --- a/app/views/admin/email_domain_blocks/index.html.haml +++ b/app/views/admin/email_domain_blocks/index.html.haml @@ -4,16 +4,19 @@ - content_for :heading_actions do = link_to t('admin.email_domain_blocks.add_new'), new_admin_email_domain_block_path, class: 'button' -- if @email_domain_blocks.empty? - %div.muted-hint.center-text=t 'admin.email_domain_blocks.empty' -- else - .table-wrapper - %table.table - %thead - %tr - %th= t('admin.email_domain_blocks.domain') - %th - %tbody - = render partial: 'email_domain_block', collection: @email_domain_blocks += form_for(@form, url: batch_admin_email_domain_blocks_path) do |f| + = hidden_field_tag :page, params[:page] || 1 + + .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 + = 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') } + .batch-table__body + - 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 diff --git a/app/views/admin/email_domain_blocks/new.html.haml b/app/views/admin/email_domain_blocks/new.html.haml index 4a346f240..fa1d950ad 100644 --- a/app/views/admin/email_domain_blocks/new.html.haml +++ b/app/views/admin/email_domain_blocks/new.html.haml @@ -5,10 +5,31 @@ = render 'shared/error_messages', object: @email_domain_block .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 - = f.input :with_dns_records, as: :boolean, wrapper: :with_label + - if defined?(@resolved_records) + %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 - = 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 diff --git a/app/views/admin/follow_recommendations/show.html.haml b/app/views/admin/follow_recommendations/show.html.haml index 85dee210a..dc65a7213 100644 --- a/app/views/admin/follow_recommendations/show.html.haml +++ b/app/views/admin/follow_recommendations/show.html.haml @@ -6,12 +6,14 @@ %hr.spacer/ = 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 .filter-subset.filter-subset--with-select %strong= t('admin.follow_recommendations.language') .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 %strong= t('admin.follow_recommendations.status') %ul diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml index 49b03a9e3..a287e52ff 100644 --- a/app/views/admin/settings/edit.html.haml +++ b/app/views/admin/settings/edit.html.haml @@ -87,6 +87,9 @@ = f.input :trendable_by_default, as: :boolean, wrapper: :with_label, label: t('admin.settings.trendable_by_default.title'), hint: t('admin.settings.trendable_by_default.desc_html') .fields-group + = f.input :trending_status_cw, as: :boolean, wrapper: :with_label, label: t('admin.settings.trending_status_cw.title'), hint: t('trending_status_cw.desc_html') + + .fields-group = f.input :noindex, as: :boolean, wrapper: :with_label, label: t('admin.settings.default_noindex.title'), hint: t('admin.settings.default_noindex.desc_html') .fields-group diff --git a/app/views/admin/trends/links/index.html.haml b/app/views/admin/trends/links/index.html.haml index acd2b0466..e05f877b0 100644 --- a/app/views/admin/trends/links/index.html.haml +++ b/app/views/admin/trends/links/index.html.haml @@ -1,23 +1,29 @@ - content_for :page_title do = t('admin.trends.links.title') -.filters - .filter-subset - %strong= t('admin.trends.trending') - %ul - %li= filter_link_to t('generic.all'), trending: nil - %li= filter_link_to t('admin.trends.only_allowed'), trending: 'allowed' - .back-link - = link_to admin_trends_links_preview_card_providers_path do - = t('admin.trends.preview_card_providers.title') - = fa_icon 'chevron-right fw' += form_tag admin_trends_links_path, method: 'GET', class: 'simple_form' do + - Trends::PreviewCardFilter::KEYS.each do |key| + = hidden_field_tag key, params[key] if params[key].present? -%hr.spacer/ + .filters + .filter-subset.filter-subset--with-select + %strong= t('admin.follow_recommendations.language') + .input.select.optional + = select_tag :locale, options_for_select(Trends.available_locales.map { |key| [standard_locale_name(key), key] }, params[:locale]), include_blank: true + .filter-subset + %strong= t('admin.trends.trending') + %ul + %li= filter_link_to t('generic.all'), trending: nil + %li= filter_link_to t('admin.trends.only_allowed'), trending: 'allowed' + .back-link + = link_to admin_trends_links_preview_card_providers_path do + = t('admin.trends.preview_card_providers.title') + = fa_icon 'chevron-right fw' = form_for(@form, url: batch_admin_trends_links_path) do |f| = hidden_field_tag :page, params[:page] || 1 - - PreviewCardFilter::KEYS.each do |key| + - Trends::PreviewCardFilter::KEYS.each do |key| = hidden_field_tag key, params[key] if params[key].present? .batch-table @@ -26,9 +32,9 @@ = check_box_tag :batch_checkbox_all, nil, false .batch-table__toolbar__actions = f.button safe_join([fa_icon('check'), t('admin.trends.links.allow')]), name: :approve, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } - = f.button safe_join([fa_icon('check'), t('admin.trends.links.allow_provider')]), name: :approve_all, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + = f.button safe_join([fa_icon('check'), t('admin.trends.links.allow_provider')]), name: :approve_providers, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } = f.button safe_join([fa_icon('times'), t('admin.trends.links.disallow')]), name: :reject, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } - = f.button safe_join([fa_icon('times'), t('admin.trends.links.disallow_provider')]), name: :reject_all, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + = f.button safe_join([fa_icon('times'), t('admin.trends.links.disallow_provider')]), name: :reject_providers, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } .batch-table__body - if @preview_cards.empty? = nothing_here 'nothing-here--under-tabs' diff --git a/app/views/admin/trends/links/preview_card_providers/index.html.haml b/app/views/admin/trends/links/preview_card_providers/index.html.haml index df54f58ba..13c279b53 100644 --- a/app/views/admin/trends/links/preview_card_providers/index.html.haml +++ b/app/views/admin/trends/links/preview_card_providers/index.html.haml @@ -20,7 +20,7 @@ = form_for(@form, url: batch_admin_trends_links_preview_card_providers_path) do |f| = hidden_field_tag :page, params[:page] || 1 - - PreviewCardProviderFilter::KEYS.each do |key| + - Trends::PreviewCardProviderFilter::KEYS.each do |key| = hidden_field_tag key, params[key] if params[key].present? .batch-table.optional diff --git a/app/views/admin/trends/statuses/_status.html.haml b/app/views/admin/trends/statuses/_status.html.haml new file mode 100644 index 000000000..c99ee5d60 --- /dev/null +++ b/app/views/admin/trends/statuses/_status.html.haml @@ -0,0 +1,30 @@ +.batch-table__row{ class: [status.account.requires_review? && 'batch-table__row--attention', !status.account.requires_review? && !status.trendable? && 'batch-table__row--muted'] } + %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox + = f.check_box :status_ids, { multiple: true, include_hidden: false }, status.id + + .batch-table__row__content.pending-account__header + .one-liner + = admin_account_link_to status.account + + = link_to ActivityPub::TagManager.instance.url_for(status), target: '_blank', class: 'emojify', rel: 'noopener noreferrer' do + = one_line_preview(status) + + - status.media_attachments.each do |media_attachment| + %abbr{ title: media_attachment.description } + = fa_icon 'link' + = media_attachment.file_file_name + + = t('admin.trends.statuses.shared_by', count: status.reblogs_count + status.favourites_count, friendly_count: friendly_number_to_human(status.reblogs_count + status.favourites_count)) + + - if status.account.domain.present? + • + = status.account.domain + - if status.language.present? + • + = standard_locale_name(status.language) + - if status.trendable? && (rank = Trends.statuses.rank(status.id)) + • + %abbr{ title: t('admin.trends.tags.current_score', score: Trends.statuses.score(status.id)) }= t('admin.trends.tags.trending_rank', rank: rank + 1) + - elsif status.account.requires_review? + • + = t('admin.trends.pending_review') diff --git a/app/views/admin/trends/statuses/index.html.haml b/app/views/admin/trends/statuses/index.html.haml new file mode 100644 index 000000000..3166bc6c1 --- /dev/null +++ b/app/views/admin/trends/statuses/index.html.haml @@ -0,0 +1,40 @@ +- content_for :page_title do + = t('admin.trends.statuses.title') + += form_tag admin_trends_statuses_path, method: 'GET', class: 'simple_form' do + - Trends::StatusFilter::KEYS.each do |key| + = hidden_field_tag key, params[key] if params[key].present? + + .filters + .filter-subset.filter-subset--with-select + %strong= t('admin.follow_recommendations.language') + .input.select.optional + = select_tag :locale, options_for_select(Trends.available_locales.map { |key| [standard_locale_name(key), key]}, params[:locale]), include_blank: true + .filter-subset + %strong= t('admin.trends.trending') + %ul + %li= filter_link_to t('generic.all'), trending: nil + %li= filter_link_to t('admin.trends.only_allowed'), trending: 'allowed' + += form_for(@form, url: batch_admin_trends_statuses_path) do |f| + = hidden_field_tag :page, params[:page] || 1 + + - Trends::StatusFilter::KEYS.each do |key| + = hidden_field_tag key, params[key] if params[key].present? + + .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 + = f.button safe_join([fa_icon('check'), t('admin.trends.statuses.allow')]), name: :approve, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + = f.button safe_join([fa_icon('check'), t('admin.trends.statuses.allow_account')]), name: :approve_accounts, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + = f.button safe_join([fa_icon('times'), t('admin.trends.statuses.disallow')]), name: :reject, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + = f.button safe_join([fa_icon('times'), t('admin.trends.statuses.disallow_account')]), name: :reject_accounts, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') } + .batch-table__body + - if @statuses.empty? + = nothing_here 'nothing-here--under-tabs' + - else + = render partial: 'status', collection: @statuses, locals: { f: f } + += paginate @statuses diff --git a/app/views/admin/trends/tags/index.html.haml b/app/views/admin/trends/tags/index.html.haml index 99ad5490f..3433b8dd4 100644 --- a/app/views/admin/trends/tags/index.html.haml +++ b/app/views/admin/trends/tags/index.html.haml @@ -10,12 +10,10 @@ %li= filter_link_to t('admin.trends.rejected'), status: 'rejected' %li= filter_link_to safe_join([t('admin.accounts.moderation.pending'), "(#{Tag.pending_review.count})"], ' '), status: 'pending_review' -%hr.spacer/ - = form_for(@form, url: batch_admin_trends_tags_path) do |f| = hidden_field_tag :page, params[:page] || 1 - - TagFilter::KEYS.each do |key| + - Trends::TagFilter::KEYS.each do |key| = hidden_field_tag key, params[key] if params[key].present? .batch-table.optional diff --git a/app/views/admin_mailer/_new_trending_links.text.erb b/app/views/admin_mailer/_new_trending_links.text.erb new file mode 100644 index 000000000..405926fdd --- /dev/null +++ b/app/views/admin_mailer/_new_trending_links.text.erb @@ -0,0 +1,14 @@ +<%= raw t('admin_mailer.new_trends.new_trending_links.title') %> + +<% @links.each do |link| %> +- <%= link.title %> • <%= link.url %> + <%= raw t('admin.trends.links.usage_comparison', today: link.history.get(Time.now.utc).accounts, yesterday: link.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: Trends.links.score(link.id).round(2)) %> +<% end %> + +<% if @lowest_trending_link %> +<%= raw t('admin_mailer.new_trends.new_trending_links.requirements', lowest_link_title: @lowest_trending_link.title, lowest_link_score: Trends.links.score(@lowest_trending_link.id).round(2), rank: Trends.links.options[:review_threshold]) %> +<% else %> +<%= raw t('admin_mailer.new_trends.new_trending_links.no_approved_links') %> +<% end %> + +<%= raw t('application_mailer.view')%> <%= admin_trends_links_url %> diff --git a/app/views/admin_mailer/_new_trending_statuses.text.erb b/app/views/admin_mailer/_new_trending_statuses.text.erb new file mode 100644 index 000000000..8d11a80c2 --- /dev/null +++ b/app/views/admin_mailer/_new_trending_statuses.text.erb @@ -0,0 +1,14 @@ +<%= raw t('admin_mailer.new_trends.new_trending_statuses.title') %> + +<% @statuses.each do |status| %> +- <%= ActivityPub::TagManager.instance.url_for(status) %> + <%= raw t('admin.trends.tags.current_score', score: Trends.statuses.score(status.id).round(2)) %> +<% end %> + +<% if @lowest_trending_status %> +<%= raw t('admin_mailer.new_trends.new_trending_statuses.requirements', lowest_status_url: ActivityPub::TagManager.instance.url_for(@lowest_trending_status), lowest_status_score: Trends.statuses.score(@lowest_trending_status.id).round(2), rank: Trends.statuses.options[:review_threshold]) %> +<% else %> +<%= raw t('admin_mailer.new_trends.new_trending_statuses.no_approved_statuses') %> +<% end %> + +<%= raw t('application_mailer.view')%> <%= admin_trends_statuses_url %> diff --git a/app/views/admin_mailer/_new_trending_tags.text.erb b/app/views/admin_mailer/_new_trending_tags.text.erb new file mode 100644 index 000000000..49fe84309 --- /dev/null +++ b/app/views/admin_mailer/_new_trending_tags.text.erb @@ -0,0 +1,14 @@ +<%= raw t('admin_mailer.new_trends.new_trending_tags.title') %> + +<% @tags.each do |tag| %> +- #<%= tag.name %> + <%= raw t('admin.trends.tags.usage_comparison', today: tag.history.get(Time.now.utc).accounts, yesterday: tag.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id).round(2)) %> +<% end %> + +<% if @lowest_trending_tag %> +<%= raw t('admin_mailer.new_trends.new_trending_tags.requirements', lowest_tag_name: @lowest_trending_tag.name, lowest_tag_score: Trends.tags.score(@lowest_trending_tag.id).round(2), rank: Trends.tags.options[:review_threshold]) %> +<% else %> +<%= raw t('admin_mailer.new_trends.new_trending_tags.no_approved_tags') %> +<% end %> + +<%= raw t('application_mailer.view')%> <%= admin_trends_tags_url(pending_review: '1') %> diff --git a/app/views/admin_mailer/new_trending_links.text.erb b/app/views/admin_mailer/new_trending_links.text.erb deleted file mode 100644 index 51789aca5..000000000 --- a/app/views/admin_mailer/new_trending_links.text.erb +++ /dev/null @@ -1,16 +0,0 @@ -<%= raw t('application_mailer.salutation', name: display_name(@me)) %> - -<%= raw t('admin_mailer.new_trending_links.body') %> - -<% @links.each do |link| %> -- <%= link.title %> • <%= link.url %> - <%= t('admin.trends.links.usage_comparison', today: link.history.get(Time.now.utc).accounts, yesterday: link.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: Trends.links.score(link.id).round(2)) %> -<% end %> - -<% if @lowest_trending_link %> -<%= t('admin_mailer.new_trending_links.requirements', lowest_link_title: @lowest_trending_link.title, lowest_link_score: Trends.links.score(@lowest_trending_link.id).round(2)) %> -<% else %> -<%= t('admin_mailer.new_trending_links.no_approved_links') %> -<% end %> - -<%= raw t('application_mailer.view')%> <%= admin_trends_links_url %> diff --git a/app/views/admin_mailer/new_trending_tags.text.erb b/app/views/admin_mailer/new_trending_tags.text.erb deleted file mode 100644 index 9ea31fa7c..000000000 --- a/app/views/admin_mailer/new_trending_tags.text.erb +++ /dev/null @@ -1,16 +0,0 @@ -<%= raw t('application_mailer.salutation', name: display_name(@me)) %> - -<%= raw t('admin_mailer.new_trending_tags.body') %> - -<% @tags.each do |tag| %> -- #<%= tag.name %> - <%= t('admin.trends.tags.usage_comparison', today: tag.history.get(Time.now.utc).accounts, yesterday: tag.history.get(Time.now.utc - 1.day).accounts) %> • <%= t('admin.trends.tags.current_score', score: Trends.tags.score(tag.id).round(2)) %> -<% end %> - -<% if @lowest_trending_tag %> -<%= t('admin_mailer.new_trending_tags.requirements', lowest_tag_name: @lowest_trending_tag.name, lowest_tag_score: Trends.tags.score(@lowest_trending_tag.id).round(2)) %> -<% else %> -<%= t('admin_mailer.new_trending_tags.no_approved_tags') %> -<% end %> - -<%= raw t('application_mailer.view')%> <%= admin_trends_tags_url(status: 'pending_review') %> diff --git a/app/views/admin_mailer/new_trends.text.erb b/app/views/admin_mailer/new_trends.text.erb new file mode 100644 index 000000000..13b296846 --- /dev/null +++ b/app/views/admin_mailer/new_trends.text.erb @@ -0,0 +1,13 @@ +<%= raw t('application_mailer.salutation', name: display_name(@me)) %> + +<%= raw t('admin_mailer.new_trends.body') %> + +<% unless @links.empty? %> +<%= render 'new_trending_links' %> +<% end %> +<% unless @tags.empty? %> +<%= render 'new_trending_tags' unless @tags.empty? %> +<% end %> +<% unless @statuses.empty? %> +<%= render 'new_trending_statuses' unless @statuses.empty? %> +<% end %> diff --git a/app/views/application/_sidebar.html.haml b/app/views/application/_sidebar.html.haml index 6826c3b58..e97c493fe 100644 --- a/app/views/application/_sidebar.html.haml +++ b/app/views/application/_sidebar.html.haml @@ -6,7 +6,7 @@ %p= @instance_presenter.site_short_description.html_safe.presence || t('about.about_mastodon_html') - if Setting.trends && !(user_signed_in? && !current_user.setting_trends) - - trends = Trends.tags.get(true, 3) + - trends = Trends.tags.query.allowed.limit(3) - unless trends.empty? .endorsements-widget.trends-widget diff --git a/app/views/settings/preferences/notifications/show.html.haml b/app/views/settings/preferences/notifications/show.html.haml index 223e5d740..e01cd077f 100644 --- a/app/views/settings/preferences/notifications/show.html.haml +++ b/app/views/settings/preferences/notifications/show.html.haml @@ -24,6 +24,8 @@ = ff.input :appeal, as: :boolean, wrapper: :with_label = ff.input :pending_account, as: :boolean, wrapper: :with_label = ff.input :trending_tag, as: :boolean, wrapper: :with_label + = ff.input :trending_link, as: :boolean, wrapper: :with_label + = ff.input :trending_status, as: :boolean, wrapper: :with_label .fields-group = f.simple_fields_for :notification_emails, hash_to_object(current_user.settings.notification_emails) do |ff| diff --git a/app/workers/scheduler/email_domain_block_refresh_scheduler.rb b/app/workers/scheduler/email_domain_block_refresh_scheduler.rb new file mode 100644 index 000000000..e0ad89866 --- /dev/null +++ b/app/workers/scheduler/email_domain_block_refresh_scheduler.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class Scheduler::EmailDomainBlockRefreshScheduler + include Sidekiq::Worker + include Redisable + + sidekiq_options retry: 0 + + def perform + Resolv::DNS.open do |dns| + dns.timeouts = 5 + + EmailDomainBlock.find_each do |email_domain_block| + ips = begin + if ip?(email_domain_block.domain) + [email_domain_block.domain] + else + resources = dns.getresources(email_domain_block.domain, Resolv::DNS::Resource::IN::A).to_a + dns.getresources(email_domain_block.domain, Resolv::DNS::Resource::IN::AAAA).to_a + resources.map { |resource| resource.address.to_s } + end + end + + email_domain_block.update(ips: ips, last_refresh_at: Time.now.utc) + end + end + end + + def ip?(str) + str =~ Regexp.union([Resolv::IPv4::Regex, Resolv::IPv6::Regex]) + end +end diff --git a/app/workers/scheduler/follow_recommendations_scheduler.rb b/app/workers/scheduler/follow_recommendations_scheduler.rb index 084619cbd..57f78170e 100644 --- a/app/workers/scheduler/follow_recommendations_scheduler.rb +++ b/app/workers/scheduler/follow_recommendations_scheduler.rb @@ -18,7 +18,7 @@ class Scheduler::FollowRecommendationsScheduler fallback_recommendations = FollowRecommendation.order(rank: :desc).limit(SET_SIZE) - I18n.available_locales.map { |locale| locale.to_s.split(/[_-]/).first }.uniq.each do |locale| + Trends.available_locales.each do |locale| recommendations = begin if AccountSummary.safe.filtered.localized(locale).exists? # We can skip the work if no accounts with that language exist FollowRecommendation.localized(locale).order(rank: :desc).limit(SET_SIZE).map { |recommendation| [recommendation.account_id, recommendation.rank] } @@ -49,11 +49,11 @@ class Scheduler::FollowRecommendationsScheduler end end - redis.pipelined do - redis.del(key(locale)) + redis.multi do |multi| + multi.del(key(locale)) recommendations.each do |(account_id, rank)| - redis.zadd(key(locale), rank, account_id) + multi.zadd(key(locale), rank, account_id) end end end |