diff options
33 files changed, 192 insertions, 61 deletions
diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb index 5d5db937c..5ff6990d7 100644 --- a/app/controllers/about_controller.rb +++ b/app/controllers/about_controller.rb @@ -1,13 +1,17 @@ # frozen_string_literal: true class AboutController < ApplicationController + include RegistrationSpamConcern + before_action :set_pack + layout 'public' before_action :require_open_federation!, only: [:show, :more] before_action :set_body_classes, only: :show before_action :set_instance_presenter - before_action :set_expires_in, only: [:show, :more, :terms] + before_action :set_expires_in, only: [:more, :terms] + before_action :set_registration_form_time, only: :show skip_before_action :require_functional!, only: [:more, :terms] diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb index 23e5a22e1..6429bd969 100644 --- a/app/controllers/auth/registrations_controller.rb +++ b/app/controllers/auth/registrations_controller.rb @@ -2,6 +2,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController include Devise::Controllers::Rememberable + include RegistrationSpamConcern layout :determine_layout @@ -14,6 +15,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController before_action :set_body_classes, only: [:new, :create, :edit, :update] before_action :require_not_suspended!, only: [:update] before_action :set_cache_headers, only: [:edit, :update] + before_action :set_registration_form_time, only: :new skip_before_action :require_functional!, only: [:edit, :update] @@ -46,16 +48,17 @@ class Auth::RegistrationsController < Devise::RegistrationsController def build_resource(hash = nil) super(hash) - resource.locale = I18n.locale - resource.invite_code = params[:invite_code] if resource.invite_code.blank? - resource.sign_up_ip = request.remote_ip + resource.locale = I18n.locale + resource.invite_code = params[:invite_code] if resource.invite_code.blank? + resource.registration_form_time = session[:registration_form_time] + resource.sign_up_ip = request.remote_ip resource.build_account if resource.account.nil? end def configure_sign_up_params devise_parameter_sanitizer.permit(:sign_up) do |u| - u.permit({ account_attributes: [:username], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement) + u.permit({ account_attributes: [:username], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password) end end diff --git a/app/controllers/concerns/registration_spam_concern.rb b/app/controllers/concerns/registration_spam_concern.rb new file mode 100644 index 000000000..af434c985 --- /dev/null +++ b/app/controllers/concerns/registration_spam_concern.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module RegistrationSpamConcern + extend ActiveSupport::Concern + + def set_registration_form_time + session[:registration_form_time] = Time.now.utc + end +end diff --git a/app/javascript/flavours/glitch/actions/markers.js b/app/javascript/flavours/glitch/actions/markers.js index c0e7a93af..a086def97 100644 --- a/app/javascript/flavours/glitch/actions/markers.js +++ b/app/javascript/flavours/glitch/actions/markers.js @@ -1,7 +1,6 @@ import api from 'flavours/glitch/util/api'; import { debounce } from 'lodash'; import compareId from 'flavours/glitch/util/compare_id'; -import { showAlertForError } from './alerts'; export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST'; export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS'; @@ -29,15 +28,19 @@ export const synchronouslySubmitMarkers = () => (dispatch, getState) => { }, body: JSON.stringify(params), }); + return; } else if (navigator && navigator.sendBeacon) { // Failing that, we can use sendBeacon, but we have to encode the data as // FormData for DoorKeeper to recognize the token. const formData = new FormData(); + formData.append('bearer_token', accessToken); + for (const [id, value] of Object.entries(params)) { formData.append(`${id}[last_read_id]`, value.last_read_id); } + if (navigator.sendBeacon('/api/v1/markers', formData)) { return; } @@ -85,11 +88,9 @@ const debouncedSubmitMarkers = debounce((dispatch, getState) => { return; } - api().post('/api/v1/markers', params).then(() => { + api(getState).post('/api/v1/markers', params).then(() => { dispatch(submitMarkersSuccess(params)); - }).catch(error => { - dispatch(showAlertForError(error)); - }); + }).catch(() => {}); }, 300000, { leading: true, trailing: true }); export function submitMarkersSuccess({ home, notifications }) { @@ -102,9 +103,11 @@ export function submitMarkersSuccess({ home, notifications }) { export function submitMarkers(params = {}) { const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); + if (params.immediate === true) { debouncedSubmitMarkers.flush(); } + return result; }; diff --git a/app/javascript/flavours/glitch/features/list_timeline/index.js b/app/javascript/flavours/glitch/features/list_timeline/index.js index 70e530bae..d826c8ccd 100644 --- a/app/javascript/flavours/glitch/features/list_timeline/index.js +++ b/app/javascript/flavours/glitch/features/list_timeline/index.js @@ -19,9 +19,9 @@ import RadioButton from 'flavours/glitch/components/radio_button'; const messages = defineMessages({ deleteMessage: { id: 'confirmations.delete_list.message', defaultMessage: 'Are you sure you want to permanently delete this list?' }, deleteConfirm: { id: 'confirmations.delete_list.confirm', defaultMessage: 'Delete' }, - all_replies: { id: 'lists.replies_policy.all_replies', defaultMessage: 'Any followed user' }, - no_replies: { id: 'lists.replies_policy.no_replies', defaultMessage: 'No one' }, - list_replies: { id: 'lists.replies_policy.list_replies', defaultMessage: 'Members of the list' }, + followed: { id: 'lists.replies_policy.followed', defaultMessage: 'Any followed user' }, + none: { id: 'lists.replies_policy.none', defaultMessage: 'No one' }, + list: { id: 'lists.replies_policy.list', defaultMessage: 'Members of the list' }, }); const mapStateToProps = (state, props) => ({ @@ -193,7 +193,7 @@ class ListTimeline extends React.PureComponent { <FormattedMessage id='lists.replies_policy.title' defaultMessage='Show replies to:' /> </span> <div className='column-settings__row'> - { ['no_replies', 'list_replies', 'all_replies'].map(policy => ( + { ['none', 'list', 'followed'].map(policy => ( <RadioButton name='order' value={policy} label={intl.formatMessage(messages[policy])} checked={replies_policy === policy} onChange={this.handleRepliesPolicyChange} /> ))} </div> diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js index 97434b586..73d969517 100644 --- a/app/javascript/flavours/glitch/features/notifications/index.js +++ b/app/javascript/flavours/glitch/features/notifications/index.js @@ -37,10 +37,16 @@ const messages = defineMessages({ markAsRead : { id: 'notifications.mark_as_read', defaultMessage: 'Mark every notification as read' }, }); +const getExcludedTypes = createSelector([ + state => state.getIn(['settings', 'notifications', 'shows']), +], (shows) => { + return ImmutableList(shows.filter(item => !item).keys()); +}); + const getNotifications = createSelector([ state => state.getIn(['settings', 'notifications', 'quickFilter', 'show']), state => state.getIn(['settings', 'notifications', 'quickFilter', 'active']), - state => ImmutableList(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()), + getExcludedTypes, state => state.getIn(['notifications', 'items']), ], (showFilterBar, allowedType, excludedTypes, notifications) => { if (!showFilterBar || allowedType === 'all') { diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.js b/app/javascript/flavours/glitch/features/ui/components/columns_area.js index 729ade212..640be19ab 100644 --- a/app/javascript/flavours/glitch/features/ui/components/columns_area.js +++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js @@ -75,7 +75,9 @@ class ColumnsArea extends ImmutablePureComponent { } componentWillReceiveProps() { - this.setState({ shouldAnimate: false }); + if (typeof this.pendingIndex !== 'number' && this.lastIndex !== getIndex(this.context.router.history.location.pathname)) { + this.setState({ shouldAnimate: false }); + } } componentDidMount() { @@ -99,8 +101,13 @@ class ColumnsArea extends ImmutablePureComponent { if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) { this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false); } - this.lastIndex = getIndex(this.context.router.history.location.pathname); - this.setState({ shouldAnimate: true }); + + const newIndex = getIndex(this.context.router.history.location.pathname); + + if (this.lastIndex !== newIndex) { + this.lastIndex = newIndex; + this.setState({ shouldAnimate: true }); + } } componentWillUnmount () { diff --git a/app/javascript/flavours/glitch/packs/public.js b/app/javascript/flavours/glitch/packs/public.js index c74e5c9af..dccdbc8d0 100644 --- a/app/javascript/flavours/glitch/packs/public.js +++ b/app/javascript/flavours/glitch/packs/public.js @@ -151,6 +151,17 @@ function main() { target.style.display = 'block'; } }); + + // Empty the honeypot fields in JS in case something like an extension + // automatically filled them. + delegate(document, '#registration_new_user,#new_user', 'submit', () => { + ['user_website', 'user_confirm_password', 'registration_user_website', 'registration_user_confirm_password'].forEach(id => { + const field = document.getElementById(id); + if (field) { + field.value = ''; + } + }); + }); } loadPolyfills() diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss index cf60ce7d6..f973cad22 100644 --- a/app/javascript/flavours/glitch/styles/forms.scss +++ b/app/javascript/flavours/glitch/styles/forms.scss @@ -339,6 +339,7 @@ code { input[type=number], input[type=email], input[type=password], + input[type=url], textarea { box-sizing: border-box; font-size: 16px; @@ -979,3 +980,10 @@ code { flex-direction: row; } } + +.input.user_confirm_password, +.input.user_website { + &:not(.field_with_errors) { + display: none; + } +} diff --git a/app/javascript/flavours/glitch/theme.yml b/app/javascript/flavours/glitch/theme.yml index 0fd627f19..2a98e4c29 100644 --- a/app/javascript/flavours/glitch/theme.yml +++ b/app/javascript/flavours/glitch/theme.yml @@ -2,7 +2,7 @@ pack: about: packs/about.js admin: packs/public.js - auth: + auth: packs/public.js common: filename: packs/common.js stylesheet: true diff --git a/app/javascript/flavours/vanilla/theme.yml b/app/javascript/flavours/vanilla/theme.yml index 42e26daea..74e9fb1b5 100644 --- a/app/javascript/flavours/vanilla/theme.yml +++ b/app/javascript/flavours/vanilla/theme.yml @@ -2,7 +2,7 @@ pack: about: about.js admin: public.js - auth: + auth: public.js common: filename: common.js stylesheet: true diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 22098d57e..295e83f58 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -97,7 +97,7 @@ class Status extends ImmutablePureComponent { cachedMediaWidth: PropTypes.number, scrollKey: PropTypes.string, deployPictureInPicture: PropTypes.func, - pictureInPicture: PropTypes.shape({ + pictureInPicture: ImmutablePropTypes.contains({ inUse: PropTypes.bool, available: PropTypes.bool, }), @@ -203,15 +203,15 @@ class Status extends ImmutablePureComponent { handleHotkeyOpenMedia = e => { const { onOpenMedia, onOpenVideo } = this.props; - const statusId = this._properStatus().get('id'); + const status = this._properStatus(); e.preventDefault(); if (status.get('media_attachments').size > 0) { if (status.getIn(['media_attachments', 0, 'type']) === 'video') { - onOpenVideo(statusId, status.getIn(['media_attachments', 0]), { startTime: 0 }); + onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), { startTime: 0 }); } else { - onOpenMedia(statusId, status.get('media_attachments'), 0); + onOpenMedia(status.get('id'), status.get('media_attachments'), 0); } } } @@ -354,7 +354,7 @@ class Status extends ImmutablePureComponent { status = status.get('reblog'); } - if (pictureInPicture.inUse) { + if (pictureInPicture.get('inUse')) { media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />; } else if (status.get('media_attachments').size > 0) { if (this.props.muted) { @@ -381,7 +381,7 @@ class Status extends ImmutablePureComponent { width={this.props.cachedMediaWidth} height={110} cacheWidth={this.props.cacheMediaWidth} - deployPictureInPicture={pictureInPicture.available ? this.handleDeployPictureInPicture : undefined} + deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined} /> )} </Bundle> @@ -404,7 +404,7 @@ class Status extends ImmutablePureComponent { sensitive={status.get('sensitive')} onOpenVideo={this.handleOpenVideo} cacheWidth={this.props.cacheMediaWidth} - deployPictureInPicture={pictureInPicture.available ? this.handleDeployPictureInPicture : undefined} + deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined} visible={this.state.showMedia} onToggleVisibility={this.handleToggleMediaVisibility} /> @@ -432,7 +432,7 @@ class Status extends ImmutablePureComponent { } else if (status.get('spoiler_text').length === 0 && status.get('card')) { media = ( <Card - onOpenMedia={this.props.onOpenMedia} + onOpenMedia={this.handleOpenMedia} card={status.get('card')} compact cacheWidth={this.props.cacheMediaWidth} diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js index ef520b96a..d6bcb8973 100644 --- a/app/javascript/mastodon/containers/status_container.js +++ b/app/javascript/mastodon/containers/status_container.js @@ -1,7 +1,7 @@ import React from 'react'; import { connect } from 'react-redux'; import Status from '../components/status'; -import { makeGetStatus } from '../selectors'; +import { makeGetStatus, makeGetPictureInPicture } from '../selectors'; import { replyCompose, mentionCompose, @@ -54,14 +54,11 @@ const messages = defineMessages({ const makeMapStateToProps = () => { const getStatus = makeGetStatus(); + const getPictureInPicture = makeGetPictureInPicture(); const mapStateToProps = (state, props) => ({ status: getStatus(state, props), - - pictureInPicture: { - inUse: state.getIn(['meta', 'layout']) !== 'mobile' && state.get('picture_in_picture').statusId === props.id, - available: state.getIn(['meta', 'layout']) !== 'mobile', - }, + pictureInPicture: getPictureInPicture(state, props), }); return mapStateToProps; diff --git a/app/javascript/mastodon/features/list_timeline/index.js b/app/javascript/mastodon/features/list_timeline/index.js index a3be8fbea..02b018247 100644 --- a/app/javascript/mastodon/features/list_timeline/index.js +++ b/app/javascript/mastodon/features/list_timeline/index.js @@ -20,9 +20,9 @@ import RadioButton from 'mastodon/components/radio_button'; const messages = defineMessages({ deleteMessage: { id: 'confirmations.delete_list.message', defaultMessage: 'Are you sure you want to permanently delete this list?' }, deleteConfirm: { id: 'confirmations.delete_list.confirm', defaultMessage: 'Delete' }, - all_replies: { id: 'lists.replies_policy.all_replies', defaultMessage: 'Any followed user' }, - no_replies: { id: 'lists.replies_policy.no_replies', defaultMessage: 'No one' }, - list_replies: { id: 'lists.replies_policy.list_replies', defaultMessage: 'Members of the list' }, + followed: { id: 'lists.replies_policy.followed', defaultMessage: 'Any followed user' }, + none: { id: 'lists.replies_policy.none', defaultMessage: 'No one' }, + list: { id: 'lists.replies_policy.list', defaultMessage: 'Members of the list' }, }); const mapStateToProps = (state, props) => ({ @@ -193,7 +193,7 @@ class ListTimeline extends React.PureComponent { <FormattedMessage id='lists.replies_policy.title' defaultMessage='Show replies to:' /> </span> <div className='column-settings__row'> - { ['no_replies', 'list_replies', 'all_replies'].map(policy => ( + { ['none', 'list', 'followed'].map(policy => ( <RadioButton name='order' value={policy} label={intl.formatMessage(messages[policy])} checked={replies_policy === policy} onChange={this.handleRepliesPolicyChange} /> ))} </div> diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js index 73df7f49d..2e0afd863 100644 --- a/app/javascript/mastodon/features/notifications/index.js +++ b/app/javascript/mastodon/features/notifications/index.js @@ -32,10 +32,16 @@ const messages = defineMessages({ markAsRead : { id: 'notifications.mark_as_read', defaultMessage: 'Mark every notification as read' }, }); +const getExcludedTypes = createSelector([ + state => state.getIn(['settings', 'notifications', 'shows']), +], (shows) => { + return ImmutableList(shows.filter(item => !item).keys()); +}); + const getNotifications = createSelector([ state => state.getIn(['settings', 'notifications', 'quickFilter', 'show']), state => state.getIn(['settings', 'notifications', 'quickFilter', 'active']), - state => ImmutableList(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()), + getExcludedTypes, state => state.getIn(['notifications', 'items']), ], (showFilterBar, allowedType, excludedTypes, notifications) => { if (!showFilterBar || allowedType === 'all') { diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index e20557eb3..043a749ed 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -41,7 +41,10 @@ class DetailedStatus extends ImmutablePureComponent { domain: PropTypes.string.isRequired, compact: PropTypes.bool, showMedia: PropTypes.bool, - usingPiP: PropTypes.bool, + pictureInPicture: ImmutablePropTypes.contains({ + inUse: PropTypes.bool, + available: PropTypes.bool, + }), onToggleMediaVisibility: PropTypes.func, }; @@ -102,7 +105,7 @@ class DetailedStatus extends ImmutablePureComponent { render () { const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status; const outerStyle = { boxSizing: 'border-box' }; - const { intl, compact, usingPiP } = this.props; + const { intl, compact, pictureInPicture } = this.props; if (!status) { return null; @@ -118,7 +121,7 @@ class DetailedStatus extends ImmutablePureComponent { outerStyle.height = `${this.state.height}px`; } - if (usingPiP) { + if (pictureInPicture.get('inUse')) { media = <PictureInPicturePlaceholder />; } else if (status.get('media_attachments').size > 0) { if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { diff --git a/app/javascript/mastodon/features/status/containers/detailed_status_container.js b/app/javascript/mastodon/features/status/containers/detailed_status_container.js index 6d5c33240..0ac4519c8 100644 --- a/app/javascript/mastodon/features/status/containers/detailed_status_container.js +++ b/app/javascript/mastodon/features/status/containers/detailed_status_container.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import DetailedStatus from '../components/detailed_status'; -import { makeGetStatus } from '../../../selectors'; +import { makeGetStatus, makeGetPictureInPicture } from '../../../selectors'; import { replyCompose, mentionCompose, @@ -40,10 +40,12 @@ const messages = defineMessages({ const makeMapStateToProps = () => { const getStatus = makeGetStatus(); + const getPictureInPicture = makeGetPictureInPicture(); const mapStateToProps = (state, props) => ({ status: getStatus(state, props), domain: state.getIn(['meta', 'domain']), + pictureInPicture: getPictureInPicture(state, props), }); return mapStateToProps; diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js index c5e7ba776..09822f372 100644 --- a/app/javascript/mastodon/features/status/index.js +++ b/app/javascript/mastodon/features/status/index.js @@ -43,7 +43,7 @@ import { import { initMuteModal } from '../../actions/mutes'; import { initBlockModal } from '../../actions/blocks'; import { initReport } from '../../actions/reports'; -import { makeGetStatus } from '../../selectors'; +import { makeGetStatus, makeGetPictureInPicture } from '../../selectors'; import { ScrollContainer } from 'react-router-scroll-4'; import ColumnBackButton from '../../components/column_back_button'; import ColumnHeader from '../../components/column_header'; @@ -72,6 +72,7 @@ const messages = defineMessages({ const makeMapStateToProps = () => { const getStatus = makeGetStatus(); + const getPictureInPicture = makeGetPictureInPicture(); const getAncestorsIds = createSelector([ (_, { id }) => id, @@ -129,11 +130,12 @@ const makeMapStateToProps = () => { const mapStateToProps = (state, props) => { const status = getStatus(state, { id: props.params.statusId }); - let ancestorsIds = Immutable.List(); + + let ancestorsIds = Immutable.List(); let descendantsIds = Immutable.List(); if (status) { - ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') }); + ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') }); descendantsIds = getDescendantsIds(state, { id: status.get('id') }); } @@ -143,7 +145,7 @@ const makeMapStateToProps = () => { descendantsIds, askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0, domain: state.getIn(['meta', 'domain']), - usingPiP: state.get('picture_in_picture').statusId === props.params.statusId, + pictureInPicture: getPictureInPicture(state, { id: props.params.statusId }), }; }; @@ -168,7 +170,10 @@ class Status extends ImmutablePureComponent { askReplyConfirmation: PropTypes.bool, multiColumn: PropTypes.bool, domain: PropTypes.string.isRequired, - usingPiP: PropTypes.bool, + pictureInPicture: ImmutablePropTypes.contains({ + inUse: PropTypes.bool, + available: PropTypes.bool, + }), }; state = { @@ -492,7 +497,7 @@ class Status extends ImmutablePureComponent { render () { let ancestors, descendants; - const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, usingPiP } = this.props; + const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props; const { fullscreen } = this.state; if (status === null) { @@ -550,7 +555,7 @@ class Status extends ImmutablePureComponent { domain={domain} showMedia={this.state.showMedia} onToggleMediaVisibility={this.handleToggleMediaVisibility} - usingPiP={usingPiP} + pictureInPicture={pictureInPicture} /> <ActionBar diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js index 36a84fcbf..6837450eb 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.js +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -75,7 +75,9 @@ class ColumnsArea extends ImmutablePureComponent { } componentWillReceiveProps() { - this.setState({ shouldAnimate: false }); + if (typeof this.pendingIndex !== 'number' && this.lastIndex !== getIndex(this.context.router.history.location.pathname)) { + this.setState({ shouldAnimate: false }); + } } componentDidMount() { @@ -99,8 +101,13 @@ class ColumnsArea extends ImmutablePureComponent { if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) { this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false); } - this.lastIndex = getIndex(this.context.router.history.location.pathname); - this.setState({ shouldAnimate: true }); + + const newIndex = getIndex(this.context.router.history.location.pathname); + + if (this.lastIndex !== newIndex) { + this.lastIndex = newIndex; + this.setState({ shouldAnimate: true }); + } } componentWillUnmount () { diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index fd3b72f96..1e19db65d 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -1,5 +1,5 @@ import { createSelector } from 'reselect'; -import { List as ImmutableList, is } from 'immutable'; +import { List as ImmutableList, Map as ImmutableMap, is } from 'immutable'; import { me } from '../initial_state'; const getAccountBase = (state, id) => state.getIn(['accounts', id], null); @@ -121,6 +121,16 @@ export const makeGetStatus = () => { ); }; +export const makeGetPictureInPicture = () => { + return createSelector([ + (state, { id }) => state.get('picture_in_picture').statusId === id, + (state) => state.getIn(['meta', 'layout']) !== 'mobile', + ], (inUse, available) => ImmutableMap({ + inUse: inUse && available, + available, + })); +}; + const getAlertsBase = state => state.get('alerts'); export const getAlerts = createSelector([getAlertsBase], (base) => { diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js index 3f6700195..2166d8df0 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/packs/public.js @@ -155,6 +155,17 @@ function main() { target.style.display = 'block'; } }); + + // Empty the honeypot fields in JS in case something like an extension + // automatically filled them. + delegate(document, '#registration_new_user,#new_user', 'submit', () => { + ['user_website', 'user_confirm_password', 'registration_user_website', 'registration_user_confirm_password'].forEach(id => { + const field = document.getElementById(id); + if (field) { + field.value = ''; + } + }); + }); } loadPolyfills() diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index d1d8bda21..f7e03f028 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -348,6 +348,7 @@ code { input[type=number], input[type=email], input[type=password], + input[type=url], textarea { box-sizing: border-box; font-size: 16px; @@ -988,3 +989,10 @@ code { flex-direction: row; } } + +.input.user_confirm_password, +.input.user_website { + &:not(.field_with_errors) { + display: none; + } +} diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index 3c1f8d6e2..ebd25b398 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -403,8 +403,8 @@ class FeedManager def filter_from_list?(status, list) if status.reply? && status.in_reply_to_account_id != status.account_id should_filter = status.in_reply_to_account_id != list.account_id - should_filter &&= !list.show_all_replies? - should_filter &&= !(list.show_list_replies? && ListAccount.where(list_id: list.id, account_id: status.in_reply_to_account_id).exists?) + should_filter &&= !list.show_followed? + should_filter &&= !(list.show_list? && ListAccount.where(list_id: list.id, account_id: status.in_reply_to_account_id).exists?) return !!should_filter end diff --git a/app/models/list.rb b/app/models/list.rb index 8493046e5..655d55ff6 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -8,7 +8,7 @@ # title :string default(""), not null # created_at :datetime not null # updated_at :datetime not null -# replies_policy :integer default("list_replies"), not null +# replies_policy :integer default("list"), not null # class List < ApplicationRecord @@ -16,7 +16,7 @@ class List < ApplicationRecord PER_ACCOUNT_LIMIT = 50 - enum replies_policy: [:list_replies, :all_replies, :no_replies], _prefix: :show + enum replies_policy: [:list, :followed, :none], _prefix: :show belongs_to :account, optional: true diff --git a/app/models/user.rb b/app/models/user.rb index 9bdbac76d..984f04b4e 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -89,6 +89,13 @@ class User < ApplicationRecord validates_with EmailMxValidator, if: :validate_email_dns? validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create + # Those are honeypot/antispam fields + attr_accessor :registration_form_time, :website, :confirm_password + + validates_with RegistrationFormTimeValidator, on: :create + validates :website, absence: true, on: :create + validates :confirm_password, absence: true, on: :create + scope :recent, -> { order(id: :desc) } scope :pending, -> { where(approved: false) } scope :approved, -> { where(approved: true) } diff --git a/app/validators/registration_form_time_validator.rb b/app/validators/registration_form_time_validator.rb new file mode 100644 index 000000000..ba7c7e6c6 --- /dev/null +++ b/app/validators/registration_form_time_validator.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class RegistrationFormTimeValidator < ActiveModel::Validator + REGISTRATION_FORM_MIN_TIME = 3.seconds.freeze + + def validate(user) + user.errors.add(:base, I18n.t('auth.too_fast')) if user.registration_form_time.present? && user.registration_form_time > REGISTRATION_FORM_MIN_TIME.ago + end +end diff --git a/app/views/about/_registration.html.haml b/app/views/about/_registration.html.haml index 5d159e9e6..6160ca4d4 100644 --- a/app/views/about/_registration.html.haml +++ b/app/views/about/_registration.html.haml @@ -10,6 +10,9 @@ = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off', :minlength => User.password_length.first, :maxlength => User.password_length.last }, hint: false, disabled: closed_registrations? = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' }, hint: false, disabled: closed_registrations? + = f.input :confirm_password, as: :string, placeholder: t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), required: false, input_html: { 'aria-label' => t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), :autocomplete => 'off' }, hint: false, disabled: closed_registrations? + = f.input :website, as: :url, placeholder: t('simple_form.labels.defaults.honeypot', label: 'Website'), required: false, input_html: { 'aria-label' => t('simple_form.labels.defaults.honeypot', label: 'Website'), :autocomplete => 'off' }, hint: false, disabled: closed_registrations? + - if approved_registrations? .fields-group = f.simple_fields_for :invite_request do |invite_request_fields| diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml index cc72b87ce..de541847f 100644 --- a/app/views/auth/registrations/new.html.haml +++ b/app/views/auth/registrations/new.html.haml @@ -24,6 +24,9 @@ .fields-group = f.input :password_confirmation, wrapper: :with_label, label: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' } + = f.input :confirm_password, as: :string, wrapper: :with_label, label: t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), required: false, input_html: { 'aria-label' => t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), :autocomplete => 'off' } + + = f.input :website, as: :url, wrapper: :with_label, label: t('simple_form.labels.defaults.honeypot', label: 'Website'), required: false, input_html: { 'aria-label' => t('simple_form.labels.defaults.honeypot', label: 'Website'), :autocomplete => 'off' } - if approved_registrations? && !@invite.present? .fields-group diff --git a/app/views/shared/_error_messages.html.haml b/app/views/shared/_error_messages.html.haml index 28becd6c4..4916bd424 100644 --- a/app/views/shared/_error_messages.html.haml +++ b/app/views/shared/_error_messages.html.haml @@ -1,3 +1,6 @@ - if object.errors.any? .flash-message.alert#error_explanation %strong= t('generic.validation_errors', count: object.errors.count) +- object.errors[:base].each do |error| + .flash-message.alert + %strong= error diff --git a/config/locales/en.yml b/config/locales/en.yml index 263ffcdc7..59f561aa3 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -751,6 +751,7 @@ en: functional: Your account is fully operational. pending: Your application is pending review by our staff. This may take some time. You will receive an e-mail if your application is approved. redirecting_to: Your account is inactive because it is currently redirecting to %{acct}. + too_fast: Form submitted too fast, try again. trouble_logging_in: Trouble logging in? use_security_key: Use security key authorize_follow: diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index f982d50f9..cea777550 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -126,6 +126,7 @@ en: expires_in: Expire after fields: Profile metadata header: Header + honeypot: "%{label} (do not fill in)" inbox_url: URL of the relay inbox irreversible: Drop instead of hide locale: Interface language diff --git a/spec/controllers/auth/registrations_controller_spec.rb b/spec/controllers/auth/registrations_controller_spec.rb index bef822763..c701a3b8b 100644 --- a/spec/controllers/auth/registrations_controller_spec.rb +++ b/spec/controllers/auth/registrations_controller_spec.rb @@ -82,6 +82,10 @@ RSpec.describe Auth::RegistrationsController, type: :controller do describe 'POST #create' do let(:accept_language) { Rails.application.config.i18n.available_locales.sample.to_s } + before do + session[:registration_form_time] = 5.seconds.ago + end + around do |example| current_locale = I18n.locale example.run diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb index 78563ee94..2217ad272 100644 --- a/spec/lib/feed_manager_spec.rb +++ b/spec/lib/feed_manager_spec.rb @@ -342,7 +342,7 @@ RSpec.describe FeedManager do context 'when replies policy is set to no replies' do before do - list.replies_policy = :no_replies + list.replies_policy = :none end it 'pushes statuses that are not replies' do @@ -365,7 +365,7 @@ RSpec.describe FeedManager do context 'when replies policy is set to list-only replies' do before do - list.replies_policy = :list_replies + list.replies_policy = :list end it 'pushes statuses that are not replies' do @@ -394,7 +394,7 @@ RSpec.describe FeedManager do context 'when replies policy is set to any reply' do before do - list.replies_policy = :all_replies + list.replies_policy = :followed end it 'pushes statuses that are not replies' do |