diff options
66 files changed, 850 insertions, 188 deletions
diff --git a/Gemfile b/Gemfile index ace4b58e6..4305c42b0 100644 --- a/Gemfile +++ b/Gemfile @@ -31,7 +31,7 @@ gem 'charlock_holmes', '~> 0.7.6' gem 'iso-639' gem 'chewy', '~> 5.0' gem 'cld3', '~> 3.2.4' -gem 'devise', '~> 4.6' +gem 'devise', '~> 4.7' gem 'devise-two-factor', '~> 3.1' group :pam_authentication, optional: true do @@ -43,6 +43,7 @@ gem 'omniauth-cas', '~> 1.1' gem 'omniauth-saml', '~> 1.10' gem 'omniauth', '~> 1.9' +gem 'discard', '~> 1.1' gem 'doorkeeper', '~> 5.1' gem 'fast_blank', '~> 1.0' gem 'fastimage' diff --git a/Gemfile.lock b/Gemfile.lock index 0af2b2a89..caa8452a7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -112,7 +112,7 @@ GEM aws-sigv4 (~> 1.1) aws-sigv4 (1.1.0) aws-eventstream (~> 1.0, >= 1.0.2) - bcrypt (3.1.12) + bcrypt (3.1.13) benchmark-ips (2.7.2) better_errors (2.5.1) coderay (>= 1.0.0) @@ -127,7 +127,7 @@ GEM brakeman (4.6.1) browser (2.6.1) builder (3.2.3) - bullet (6.0.1) + bullet (6.0.2) activesupport (>= 3.0.0) uniform_notifier (~> 1.11) bundler-audit (0.6.1) @@ -188,10 +188,10 @@ GEM rack (>= 1) rake (> 10, < 13) thor (~> 0.19) - devise (4.6.2) + devise (4.7.0) bcrypt (~> 3.0) orm_adapter (~> 0.1) - railties (>= 4.1.0, < 6.0) + railties (>= 4.1.0) responders warden (~> 1.2.3) devise-two-factor (3.1.0) @@ -204,6 +204,8 @@ GEM devise (>= 4.0.0) rpam2 (~> 4.0) diff-lcs (1.3) + discard (1.1.0) + activerecord (>= 4.2, < 7) docile (1.3.2) domain_name (0.5.20180417) unf (>= 0.0.5, < 1.0.0) @@ -555,7 +557,7 @@ GEM rainbow (>= 2.2.2, < 4.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 1.7) - rubocop-rails (2.3.0) + rubocop-rails (2.3.1) rack (>= 1.1) rubocop (>= 0.72.0) ruby-progressbar (1.10.1) @@ -692,9 +694,10 @@ DEPENDENCIES concurrent-ruby connection_pool derailed_benchmarks - devise (~> 4.6) + devise (~> 4.7) devise-two-factor (~> 3.1) devise_pam_authenticatable2 (~> 9.2) + discard (~> 1.1) doorkeeper (~> 5.1) dotenv-rails (~> 2.7) fabrication (~> 2.20) diff --git a/app/controllers/admin/account_actions_controller.rb b/app/controllers/admin/account_actions_controller.rb index a2cea461e..ea56fa0ac 100644 --- a/app/controllers/admin/account_actions_controller.rb +++ b/app/controllers/admin/account_actions_controller.rb @@ -5,7 +5,7 @@ module Admin before_action :set_account def new - @account_action = Admin::AccountAction.new(type: params[:type], report_id: params[:report_id], send_email_notification: true) + @account_action = Admin::AccountAction.new(type: params[:type], report_id: params[:report_id], send_email_notification: true, include_statuses: true) @warning_presets = AccountWarningPreset.all end @@ -30,7 +30,7 @@ module Admin end def resource_params - params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification) + params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification, :include_statuses) end end end diff --git a/app/controllers/api/v1/reports_controller.rb b/app/controllers/api/v1/reports_controller.rb index e182a9c6c..1b0b4b05b 100644 --- a/app/controllers/api/v1/reports_controller.rb +++ b/app/controllers/api/v1/reports_controller.rb @@ -21,7 +21,7 @@ class Api::V1::ReportsController < Api::BaseController private def reported_status_ids - reported_account.statuses.find(status_ids).pluck(:id) + reported_account.statuses.with_discarded.find(status_ids).pluck(:id) end def status_ids diff --git a/app/controllers/api/v1/statuses/reblogs_controller.rb b/app/controllers/api/v1/statuses/reblogs_controller.rb index ed4f55100..42381a37f 100644 --- a/app/controllers/api/v1/statuses/reblogs_controller.rb +++ b/app/controllers/api/v1/statuses/reblogs_controller.rb @@ -18,6 +18,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController @reblogs_map = { @status.id => false } authorize status_for_destroy, :unreblog? + status_for_destroy.discard RemovalWorker.perform_async(status_for_destroy.id) render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, reblogs_map: @reblogs_map) @@ -30,7 +31,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController end def status_for_destroy - current_user.account.statuses.where(reblog_of_id: params[:status_id]).first! + @status_for_destroy ||= current_user.account.statuses.where(reblog_of_id: params[:status_id]).first! end def reblog_params diff --git a/app/controllers/api/v1/statuses_controller.rb b/app/controllers/api/v1/statuses_controller.rb index 4e7476a84..486004f9c 100644 --- a/app/controllers/api/v1/statuses_controller.rb +++ b/app/controllers/api/v1/statuses_controller.rb @@ -54,7 +54,8 @@ class Api::V1::StatusesController < Api::BaseController @status = Status.where(account_id: current_user.account).find(params[:id]) authorize @status, :destroy? - RemovalWorker.perform_async(@status.id) + @status.discard + RemovalWorker.perform_async(@status.id, redraft: true) render json: @status, serializer: REST::StatusSerializer, source_requested: true end diff --git a/app/javascript/mastodon/actions/alerts.js b/app/javascript/mastodon/actions/alerts.js index ef2500e7b..cd36d8007 100644 --- a/app/javascript/mastodon/actions/alerts.js +++ b/app/javascript/mastodon/actions/alerts.js @@ -3,6 +3,8 @@ import { defineMessages } from 'react-intl'; const messages = defineMessages({ unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' }, + rateLimitedTitle: { id: 'alert.rate_limited.title', defaultMessage: 'Rate limited' }, + rateLimitedMessage: { id: 'alert.rate_limited.message', defaultMessage: 'Please retry after {retry_time, time, medium}.' }, }); export const ALERT_SHOW = 'ALERT_SHOW'; @@ -23,23 +25,29 @@ export function clearAlert() { }; }; -export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage) { +export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, message_values = undefined) { return { type: ALERT_SHOW, title, message, + message_values, }; }; export function showAlertForError(error) { if (error.response) { - const { data, status, statusText } = error.response; + const { data, status, statusText, headers } = error.response; if (status === 404 || status === 410) { // Skip these errors as they are reflected in the UI return { type: ALERT_NOOP }; } + if (status === 429 && headers['x-ratelimit-reset']) { + const reset_date = new Date(headers['x-ratelimit-reset']); + return showAlert(messages.rateLimitedTitle, messages.rateLimitedMessage, { 'retry_time': reset_date }); + } + let message = statusText; let title = `${status}`; diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index c27c53df0..061a36bb8 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -356,6 +356,8 @@ const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => { cancelFetchComposeSuggestionsTags(); } + dispatch(updateSuggestionTags(token)); + api(getState).get('/api/v2/search', { cancelToken: new CancelToken(cancel => { cancelFetchComposeSuggestionsTags = cancel; diff --git a/app/javascript/mastodon/components/autosuggest_hashtag.js b/app/javascript/mastodon/components/autosuggest_hashtag.js index eabb8b178..e2f4e320d 100644 --- a/app/javascript/mastodon/components/autosuggest_hashtag.js +++ b/app/javascript/mastodon/components/autosuggest_hashtag.js @@ -9,18 +9,18 @@ export default class AutosuggestHashtag extends React.PureComponent { tag: PropTypes.shape({ name: PropTypes.string.isRequired, url: PropTypes.string, - history: PropTypes.array.isRequired, + history: PropTypes.array, }).isRequired, }; render () { const { tag } = this.props; - const weeklyUses = shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0)); + const weeklyUses = tag.history && shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0)); return ( <div className='autosuggest-hashtag'> <div className='autosuggest-hashtag__name'>#<strong>{tag.name}</strong></div> - <div className='autosuggest-hashtag__uses'><FormattedMessage id='autosuggest_hashtag.per_week' defaultMessage='{count} per week' values={{ count: weeklyUses }} /></div> + {tag.history !== undefined && <div className='autosuggest-hashtag__uses'><FormattedMessage id='autosuggest_hashtag.per_week' defaultMessage='{count} per week' values={{ count: weeklyUses }} /></div>} </div> ); } diff --git a/app/javascript/mastodon/components/column_back_button.js b/app/javascript/mastodon/components/column_back_button.js index cc0e5c07c..d97622705 100644 --- a/app/javascript/mastodon/components/column_back_button.js +++ b/app/javascript/mastodon/components/column_back_button.js @@ -35,7 +35,19 @@ export default class ColumnBackButton extends React.PureComponent { if (multiColumn) { return component; } else { - return createPortal(component, document.getElementById('tabs-bar__portal')); + // The portal container and the component may be rendered to the DOM in + // the same React render pass, so the container might not be available at + // the time `render()` is called. + const container = document.getElementById('tabs-bar__portal'); + if (container === null) { + // The container wasn't available, force a re-render so that the + // component can eventually be inserted in the container and not scroll + // with the rest of the area. + this.forceUpdate(); + return component; + } else { + return createPortal(component, container); + } } } diff --git a/app/javascript/mastodon/components/column_header.js b/app/javascript/mastodon/components/column_header.js index 89c5fe723..8a26742b5 100644 --- a/app/javascript/mastodon/components/column_header.js +++ b/app/javascript/mastodon/components/column_header.js @@ -178,7 +178,19 @@ class ColumnHeader extends React.PureComponent { if (multiColumn || placeholder) { return component; } else { - return createPortal(component, document.getElementById('tabs-bar__portal')); + // The portal container and the component may be rendered to the DOM in + // the same React render pass, so the container might not be available at + // the time `render()` is called. + const container = document.getElementById('tabs-bar__portal'); + if (container === null) { + // The container wasn't available, force a re-render so that the + // component can eventually be inserted in the container and not scroll + // with the rest of the area. + this.forceUpdate(); + return component; + } else { + return createPortal(component, container); + } } } diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js index 735cab007..b5606aca5 100644 --- a/app/javascript/mastodon/components/status.js +++ b/app/javascript/mastodon/components/status.js @@ -12,7 +12,7 @@ import AttachmentList from './attachment_list'; import Card from '../features/status/components/card'; import { injectIntl, FormattedMessage } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; -import { MediaGallery, Video } from '../features/ui/util/async-components'; +import { MediaGallery, Video, Audio } from '../features/ui/util/async-components'; import { HotKeys } from 'react-hotkeys'; import classNames from 'classnames'; import Icon from 'mastodon/components/icon'; @@ -199,11 +199,15 @@ class Status extends ImmutablePureComponent { }; renderLoadingMediaGallery () { - return <div className='media_gallery' style={{ height: '110px' }} />; + return <div className='media-gallery' style={{ height: '110px' }} />; } renderLoadingVideoPlayer () { - return <div className='media-spoiler-video' style={{ height: '110px' }} />; + return <div className='video-player' style={{ height: '110px' }} />; + } + + renderLoadingAudioPlayer () { + return <div className='audio-player' style={{ height: '110px' }} />; } handleOpenVideo = (media, startTime) => { @@ -348,7 +352,23 @@ class Status extends ImmutablePureComponent { media={status.get('media_attachments')} /> ); - } else if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) { + } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + const attachment = status.getIn(['media_attachments', 0]); + + media = ( + <Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} > + {Component => ( + <Component + src={attachment.get('url')} + alt={attachment.get('description')} + duration={attachment.getIn(['meta', 'original', 'duration'], 0)} + peaks={[0]} + height={70} + /> + )} + </Bundle> + ); + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { const attachment = status.getIn(['media_attachments', 0]); media = ( diff --git a/app/javascript/mastodon/components/status_content.js b/app/javascript/mastodon/components/status_content.js index 6aa0bfcc2..c171e7a66 100644 --- a/app/javascript/mastodon/components/status_content.js +++ b/app/javascript/mastodon/components/status_content.js @@ -230,7 +230,7 @@ export default class StatusContent extends React.PureComponent { ); } else if (this.props.onClick) { const output = [ - <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> + <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content'> <div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} /> {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />} diff --git a/app/javascript/mastodon/containers/media_container.js b/app/javascript/mastodon/containers/media_container.js index 8fddb6f54..db340032a 100644 --- a/app/javascript/mastodon/containers/media_container.js +++ b/app/javascript/mastodon/containers/media_container.js @@ -8,6 +8,7 @@ import Video from '../features/video'; import Card from '../features/status/components/card'; import Poll from 'mastodon/components/poll'; import Hashtag from 'mastodon/components/hashtag'; +import Audio from 'mastodon/features/audio'; import ModalRoot from '../components/modal_root'; import { getScrollbarWidth } from '../features/ui/components/modal_root'; import MediaModal from '../features/ui/components/media_modal'; @@ -16,7 +17,7 @@ import { List as ImmutableList, fromJS } from 'immutable'; const { localeData, messages } = getLocale(); addLocaleData(localeData); -const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag }; +const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio }; export default class MediaContainer extends PureComponent { diff --git a/app/javascript/mastodon/features/audio/index.js b/app/javascript/mastodon/features/audio/index.js new file mode 100644 index 000000000..95e5675f3 --- /dev/null +++ b/app/javascript/mastodon/features/audio/index.js @@ -0,0 +1,226 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import WaveSurfer from 'wavesurfer.js'; +import { defineMessages, injectIntl } from 'react-intl'; +import { formatTime } from 'mastodon/features/video'; +import Icon from 'mastodon/components/icon'; +import classNames from 'classnames'; +import { throttle } from 'lodash'; + +const messages = defineMessages({ + play: { id: 'video.play', defaultMessage: 'Play' }, + pause: { id: 'video.pause', defaultMessage: 'Pause' }, + mute: { id: 'video.mute', defaultMessage: 'Mute sound' }, + unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' }, +}); + +export default @injectIntl +class Audio extends React.PureComponent { + + static propTypes = { + src: PropTypes.string.isRequired, + alt: PropTypes.string, + duration: PropTypes.number, + peaks: PropTypes.arrayOf(PropTypes.number), + height: PropTypes.number, + preload: PropTypes.bool, + editable: PropTypes.bool, + intl: PropTypes.object.isRequired, + }; + + state = { + currentTime: 0, + duration: null, + paused: true, + muted: false, + volume: 0.5, + }; + + // hard coded in components.scss + // any way to get ::before values programatically? + + volWidth = 50; + + volOffset = 70; + + volHandleOffset = v => { + const offset = v * this.volWidth + this.volOffset; + return (offset > 110) ? 110 : offset; + } + + setVolumeRef = c => { + this.volume = c; + } + + setWaveformRef = c => { + this.waveform = c; + } + + componentDidMount () { + if (this.waveform) { + this._updateWaveform(); + } + } + + componentDidUpdate (prevProps) { + if (this.waveform && prevProps.src !== this.props.src) { + this._updateWaveform(); + } + } + + componentWillUnmount () { + if (this.wavesurfer) { + this.wavesurfer.destroy(); + this.wavesurfer = null; + } + } + + _updateWaveform () { + const { src, height, duration, peaks, preload } = this.props; + + const progressColor = window.getComputedStyle(document.querySelector('.audio-player__progress-placeholder')).getPropertyValue('background-color'); + const waveColor = window.getComputedStyle(document.querySelector('.audio-player__wave-placeholder')).getPropertyValue('background-color'); + + if (this.wavesurfer) { + this.wavesurfer.destroy(); + this.loaded = false; + } + + const wavesurfer = WaveSurfer.create({ + container: this.waveform, + height, + barWidth: 3, + cursorWidth: 0, + progressColor, + waveColor, + backend: 'MediaElement', + interact: preload, + }); + + wavesurfer.setVolume(this.state.volume); + + if (preload) { + wavesurfer.load(src); + this.loaded = true; + } else { + wavesurfer.load(src, peaks, 'none', duration); + this.loaded = false; + } + + wavesurfer.on('ready', () => this.setState({ duration: Math.floor(wavesurfer.getDuration()) })); + wavesurfer.on('audioprocess', () => this.setState({ currentTime: Math.floor(wavesurfer.getCurrentTime()) })); + wavesurfer.on('pause', () => this.setState({ paused: true })); + wavesurfer.on('play', () => this.setState({ paused: false })); + wavesurfer.on('volume', volume => this.setState({ volume })); + wavesurfer.on('mute', muted => this.setState({ muted })); + + this.wavesurfer = wavesurfer; + } + + togglePlay = () => { + if (this.state.paused) { + if (!this.props.preload && !this.loaded) { + this.wavesurfer.createBackend(); + this.wavesurfer.createPeakCache(); + this.wavesurfer.load(this.props.src); + this.wavesurfer.toggleInteraction(); + this.loaded = true; + } + + this.wavesurfer.play(); + this.setState({ paused: false }); + } else { + this.wavesurfer.pause(); + this.setState({ paused: true }); + } + } + + toggleMute = () => { + this.wavesurfer.setMute(!this.state.muted); + } + + handleVolumeMouseDown = e => { + document.addEventListener('mousemove', this.handleMouseVolSlide, true); + document.addEventListener('mouseup', this.handleVolumeMouseUp, true); + document.addEventListener('touchmove', this.handleMouseVolSlide, true); + document.addEventListener('touchend', this.handleVolumeMouseUp, true); + + this.handleMouseVolSlide(e); + + e.preventDefault(); + e.stopPropagation(); + } + + handleVolumeMouseUp = () => { + document.removeEventListener('mousemove', this.handleMouseVolSlide, true); + document.removeEventListener('mouseup', this.handleVolumeMouseUp, true); + document.removeEventListener('touchmove', this.handleMouseVolSlide, true); + document.removeEventListener('touchend', this.handleVolumeMouseUp, true); + } + + handleMouseVolSlide = throttle(e => { + const rect = this.volume.getBoundingClientRect(); + const x = (e.clientX - rect.left) / this.volWidth; // x position within the element. + + if(!isNaN(x)) { + let slideamt = x; + + if (x > 1) { + slideamt = 1; + } else if(x < 0) { + slideamt = 0; + } + + this.wavesurfer.setVolume(slideamt); + } + }, 60); + + render () { + const { height, intl, alt, editable } = this.props; + const { paused, muted, volume, currentTime } = this.state; + + const volumeWidth = muted ? 0 : volume * this.volWidth; + const volumeHandleLoc = muted ? this.volHandleOffset(0) : this.volHandleOffset(volume); + + return ( + <div className={classNames('audio-player', { editable })}> + <div className='audio-player__progress-placeholder' style={{ display: 'none' }} /> + <div className='audio-player__wave-placeholder' style={{ display: 'none' }} /> + + <div + className='audio-player__waveform' + aria-label={alt} + title={alt} + style={{ height }} + ref={this.setWaveformRef} + /> + + <div className='video-player__controls active'> + <div className='video-player__buttons-bar'> + <div className='video-player__buttons left'> + <button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button> + <button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button> + + <div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}> + <div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} /> + + <span + className={classNames('video-player__volume__handle')} + tabIndex='0' + style={{ left: `${volumeHandleLoc}px` }} + /> + </div> + + <span> + <span className='video-player__time-current'>{formatTime(currentTime)}</span> + <span className='video-player__time-sep'>/</span> + <span className='video-player__time-total'>{formatTime(this.state.duration || Math.floor(this.props.duration))}</span> + </span> + </div> + </div> + </div> + </div> + ); + } + +} diff --git a/app/javascript/mastodon/features/compose/components/action_bar.js b/app/javascript/mastodon/features/compose/components/action_bar.js index d0303dbfb..dd2632796 100644 --- a/app/javascript/mastodon/features/compose/components/action_bar.js +++ b/app/javascript/mastodon/features/compose/components/action_bar.js @@ -23,9 +23,14 @@ class ActionBar extends React.PureComponent { static propTypes = { account: ImmutablePropTypes.map.isRequired, + onLogout: PropTypes.func.isRequired, intl: PropTypes.object.isRequired, }; + handleLogout = () => { + this.props.onLogout(); + } + render () { const { intl } = this.props; @@ -44,7 +49,7 @@ class ActionBar extends React.PureComponent { menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' }); menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' }); menu.push(null); - menu.push({ text: intl.formatMessage(messages.logout), href: '/auth/sign_out', target: null, method: 'delete' }); + menu.push({ text: intl.formatMessage(messages.logout), action: this.handleLogout }); return ( <div className='compose__action-bar'> diff --git a/app/javascript/mastodon/features/compose/components/navigation_bar.js b/app/javascript/mastodon/features/compose/components/navigation_bar.js index d8d49cb95..840d0a3da 100644 --- a/app/javascript/mastodon/features/compose/components/navigation_bar.js +++ b/app/javascript/mastodon/features/compose/components/navigation_bar.js @@ -12,6 +12,7 @@ export default class NavigationBar extends ImmutablePureComponent { static propTypes = { account: ImmutablePropTypes.map.isRequired, + onLogout: PropTypes.func.isRequired, onClose: PropTypes.func, }; @@ -33,7 +34,7 @@ export default class NavigationBar extends ImmutablePureComponent { <div className='navigation-bar__actions'> <IconButton className='close' title='' icon='close' onClick={this.props.onClose} /> - <ActionBar account={this.props.account} /> + <ActionBar account={this.props.account} onLogout={this.props.onLogout} /> </div> </div> ); diff --git a/app/javascript/mastodon/features/compose/containers/navigation_container.js b/app/javascript/mastodon/features/compose/containers/navigation_container.js index eb9f3ea45..8606a642e 100644 --- a/app/javascript/mastodon/features/compose/containers/navigation_container.js +++ b/app/javascript/mastodon/features/compose/containers/navigation_container.js @@ -1,11 +1,29 @@ import { connect } from 'react-redux'; +import { defineMessages, injectIntl } from 'react-intl'; import NavigationBar from '../components/navigation_bar'; +import { logOut } from 'mastodon/utils/log_out'; +import { openModal } from 'mastodon/actions/modal'; import { me } from '../../../initial_state'; +const messages = defineMessages({ + logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' }, + logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' }, +}); + const mapStateToProps = state => { return { account: state.getIn(['accounts', me]), }; }; -export default connect(mapStateToProps)(NavigationBar); +const mapDispatchToProps = (dispatch, { intl }) => ({ + onLogout () { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.logoutMessage), + confirm: intl.formatMessage(messages.logoutConfirm), + onConfirm: () => logOut(), + })); + }, +}); + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NavigationBar)); diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js index 0731abcf4..e2de8b0e6 100644 --- a/app/javascript/mastodon/features/compose/index.js +++ b/app/javascript/mastodon/features/compose/index.js @@ -12,9 +12,11 @@ import Motion from '../ui/util/optional_motion'; import spring from 'react-motion/lib/spring'; import SearchResultsContainer from './containers/search_results_container'; import { changeComposing } from '../../actions/compose'; +import { openModal } from 'mastodon/actions/modal'; import elephantUIPlane from '../../../images/elephant_ui_plane.svg'; import { mascot } from '../../initial_state'; import Icon from 'mastodon/components/icon'; +import { logOut } from 'mastodon/utils/log_out'; const messages = defineMessages({ start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, @@ -25,6 +27,8 @@ const messages = defineMessages({ preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new toot' }, + logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' }, + logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' }, }); const mapStateToProps = (state, ownProps) => ({ @@ -61,6 +65,21 @@ class Compose extends React.PureComponent { } } + handleLogoutClick = e => { + const { dispatch, intl } = this.props; + + e.preventDefault(); + e.stopPropagation(); + + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.logoutMessage), + confirm: intl.formatMessage(messages.logoutConfirm), + onConfirm: () => logOut(), + })); + + return false; + } + onFocus = () => { this.props.dispatch(changeComposing(true)); } @@ -92,7 +111,7 @@ class Compose extends React.PureComponent { <Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><Icon id='globe' fixedWidth /></Link> )} <a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)} aria-label={intl.formatMessage(messages.preferences)}><Icon id='cog' fixedWidth /></a> - <a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)}><Icon id='sign-out' fixedWidth /></a> + <a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' fixedWidth /></a> </nav> ); } diff --git a/app/javascript/mastodon/features/getting_started/components/trends.js b/app/javascript/mastodon/features/getting_started/components/trends.js index 1dcacc8b3..3b9a3075f 100644 --- a/app/javascript/mastodon/features/getting_started/components/trends.js +++ b/app/javascript/mastodon/features/getting_started/components/trends.js @@ -3,6 +3,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import Hashtag from 'mastodon/components/hashtag'; +import { FormattedMessage } from 'react-intl'; export default class Trends extends ImmutablePureComponent { @@ -17,7 +18,7 @@ export default class Trends extends ImmutablePureComponent { componentDidMount () { this.props.fetchTrends(); - this.refreshInterval = setInterval(() => this.props.fetchTrends(), 36000); + this.refreshInterval = setInterval(() => this.props.fetchTrends(), 900 * 1000); } componentWillUnmount () { @@ -35,6 +36,8 @@ export default class Trends extends ImmutablePureComponent { return ( <div className='getting-started__trends'> + <h4><FormattedMessage id='trends.trending_now' defaultMessage='Trending now' /></h4> + {trends.take(3).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)} </div> ); diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js index 4af157af1..e97f18f08 100644 --- a/app/javascript/mastodon/features/status/components/detailed_status.js +++ b/app/javascript/mastodon/features/status/components/detailed_status.js @@ -10,6 +10,7 @@ import { FormattedDate, FormattedNumber } from 'react-intl'; import Card from './card'; import ImmutablePureComponent from 'react-immutable-pure-component'; import Video from '../../video'; +import Audio from '../../audio'; import scheduleIdleTask from '../../ui/util/schedule_idle_task'; import classNames from 'classnames'; import Icon from 'mastodon/components/icon'; @@ -107,7 +108,19 @@ export default class DetailedStatus extends ImmutablePureComponent { } if (status.get('media_attachments').size > 0) { - if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) { + if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { + const attachment = status.getIn(['media_attachments', 0]); + + media = ( + <Audio + src={attachment.get('url')} + alt={attachment.get('description')} + duration={attachment.getIn(['meta', 'original', 'duration'], 0)} + height={110} + preload + /> + ); + } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { const attachment = status.getIn(['media_attachments', 0]); media = ( diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.js b/app/javascript/mastodon/features/ui/components/focal_point_modal.js index e0ef1a066..735e445e8 100644 --- a/app/javascript/mastodon/features/ui/components/focal_point_modal.js +++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.js @@ -10,6 +10,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import IconButton from 'mastodon/components/icon_button'; import Button from 'mastodon/components/button'; import Video from 'mastodon/features/video'; +import Audio from 'mastodon/features/audio'; import Textarea from 'react-textarea-autosize'; import UploadProgress from 'mastodon/features/compose/components/upload_progress'; import CharacterCounter from 'mastodon/features/compose/components/character_counter'; @@ -244,12 +245,23 @@ class FocalPointModal extends ImmutablePureComponent { </div> )} - {['audio', 'video'].includes(media.get('type')) && ( + {media.get('type') === 'video' && ( <Video preview={media.get('preview_url')} blurhash={media.get('blurhash')} src={media.get('url')} detailed + inline + editable + /> + )} + + {media.get('type') === 'audio' && ( + <Audio + src={media.get('url')} + duration={media.getIn(['meta', 'original', 'duration'], 0)} + height={150} + preload editable /> )} diff --git a/app/javascript/mastodon/features/ui/components/link_footer.js b/app/javascript/mastodon/features/ui/components/link_footer.js index b481983dc..2b9bd3875 100644 --- a/app/javascript/mastodon/features/ui/components/link_footer.js +++ b/app/javascript/mastodon/features/ui/components/link_footer.js @@ -1,35 +1,72 @@ +import { connect } from 'react-redux'; import React from 'react'; import PropTypes from 'prop-types'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, defineMessages, injectIntl } from 'react-intl'; import { Link } from 'react-router-dom'; import { invitesEnabled, version, repository, source_url } from 'mastodon/initial_state'; +import { logOut } from 'mastodon/utils/log_out'; +import { openModal } from 'mastodon/actions/modal'; -const LinkFooter = ({ withHotkeys }) => ( - <div className='getting-started__footer'> - <ul> - {invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>} - {withHotkeys && <li><Link to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link> · </li>} - <li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li> - <li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li> - <li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li> - <li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li> - <li><a href='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li> - <li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li> - <li><a href='/auth/sign_out' data-method='delete'><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li> - </ul> - - <p> - <FormattedMessage - id='getting_started.open_source_notice' - defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}.' - values={{ github: <span><a href={source_url} rel='noopener' target='_blank'>{repository}</a> (v{version})</span> }} - /> - </p> - </div> -); - -LinkFooter.propTypes = { - withHotkeys: PropTypes.bool, -}; +const messages = defineMessages({ + logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' }, + logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' }, +}); + +const mapDispatchToProps = (dispatch, { intl }) => ({ + onLogout () { + dispatch(openModal('CONFIRM', { + message: intl.formatMessage(messages.logoutMessage), + confirm: intl.formatMessage(messages.logoutConfirm), + onConfirm: () => logOut(), + })); + }, +}); + +export default @injectIntl +@connect(null, mapDispatchToProps) +class LinkFooter extends React.PureComponent { + + static propTypes = { + withHotkeys: PropTypes.bool, + onLogout: PropTypes.func.isRequired, + intl: PropTypes.object.isRequired, + }; + + handleLogoutClick = e => { + e.preventDefault(); + e.stopPropagation(); + + this.props.onLogout(); -export default LinkFooter; + return false; + } + + render () { + const { withHotkeys } = this.props; + + return ( + <div className='getting-started__footer'> + <ul> + {invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>} + {withHotkeys && <li><Link to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link> · </li>} + <li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li> + <li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li> + <li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li> + <li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li> + <li><a href='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li> + <li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li> + <li><a href='/auth/sign_out' onClick={this.handleLogoutClick}><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li> + </ul> + + <p> + <FormattedMessage + id='getting_started.open_source_notice' + defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}.' + values={{ github: <span><a href={source_url} rel='noopener' target='_blank'>{repository}</a> (v{version})</span> }} + /> + </p> + </div> + ); + } + +}; diff --git a/app/javascript/mastodon/features/ui/containers/notifications_container.js b/app/javascript/mastodon/features/ui/containers/notifications_container.js index b60a0216f..3819da3d8 100644 --- a/app/javascript/mastodon/features/ui/containers/notifications_container.js +++ b/app/javascript/mastodon/features/ui/containers/notifications_container.js @@ -11,7 +11,7 @@ const mapStateToProps = (state, { intl }) => { const value = notification[key]; if (typeof value === 'object') { - notification[key] = intl.formatMessage(value); + notification[key] = intl.formatMessage(value, notification[`${key}_values`]); } })); diff --git a/app/javascript/mastodon/features/ui/index.js b/app/javascript/mastodon/features/ui/index.js index f0c3eff83..9d284c221 100644 --- a/app/javascript/mastodon/features/ui/index.js +++ b/app/javascript/mastodon/features/ui/index.js @@ -141,14 +141,24 @@ class SwitchingColumnsArea extends React.PureComponent { return location.state !== previewMediaState && location.state !== previewVideoState; } - handleResize = debounce(() => { + handleLayoutChange = debounce(() => { // The cached heights are no longer accurate, invalidate this.props.onLayoutChange(); - - this.setState({ mobile: isMobile(window.innerWidth) }); }, 500, { trailing: true, - }); + }) + + handleResize = () => { + const mobile = isMobile(window.innerWidth); + + if (mobile !== this.state.mobile) { + this.handleLayoutChange.cancel(); + this.props.onLayoutChange(); + this.setState({ mobile }); + } else { + this.handleLayoutChange(); + } + } setRef = c => { this.node = c.getWrappedInstance(); diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js index 0a07aa75e..a9b95c7b8 100644 --- a/app/javascript/mastodon/features/ui/util/async-components.js +++ b/app/javascript/mastodon/features/ui/util/async-components.js @@ -137,3 +137,7 @@ export function Search () { export function Tesseract () { return import(/*webpackChunkName: "tesseract" */'tesseract.js'); } + +export function Audio () { + return import(/* webpackChunkName: "features/audio" */'../../audio'); +} diff --git a/app/javascript/mastodon/features/video/index.js b/app/javascript/mastodon/features/video/index.js index da48c165e..5fe4e956f 100644 --- a/app/javascript/mastodon/features/video/index.js +++ b/app/javascript/mastodon/features/video/index.js @@ -21,7 +21,7 @@ const messages = defineMessages({ exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' }, }); -const formatTime = secondsNum => { +export const formatTime = secondsNum => { let hours = Math.floor(secondsNum / 3600); let minutes = Math.floor((secondsNum - (hours * 3600)) / 60); let seconds = secondsNum - (hours * 3600) - (minutes * 60); diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index 246c9bd0e..617328613 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -744,6 +744,27 @@ { "descriptors": [ { + "defaultMessage": "Play", + "id": "video.play" + }, + { + "defaultMessage": "Pause", + "id": "video.pause" + }, + { + "defaultMessage": "Mute sound", + "id": "video.mute" + }, + { + "defaultMessage": "Unmute sound", + "id": "video.unmute" + } + ], + "path": "app/javascript/mastodon/features/audio/index.json" + }, + { + "descriptors": [ + { "defaultMessage": "Blocked users", "id": "column.blocks" }, @@ -1099,15 +1120,6 @@ { "descriptors": [ { - "defaultMessage": "Uploading...", - "id": "upload_progress.label" - } - ], - "path": "app/javascript/mastodon/features/compose/components/upload_progress.json" - }, - { - "descriptors": [ - { "defaultMessage": "Delete", "id": "upload_form.undo" }, @@ -1317,8 +1329,8 @@ { "descriptors": [ { - "defaultMessage": "Refresh", - "id": "trends.refresh" + "defaultMessage": "Trending now", + "id": "trends.trending_now" } ], "path": "app/javascript/mastodon/features/getting_started/components/trends.json" @@ -1457,6 +1469,10 @@ { "descriptors": [ { + "defaultMessage": "Basic", + "id": "home.column_settings.basic" + }, + { "defaultMessage": "Show boosts", "id": "home.column_settings.show_reblogs" }, @@ -1838,14 +1854,6 @@ "id": "notifications.column_settings.push" }, { - "defaultMessage": "Basic", - "id": "home.column_settings.basic" - }, - { - "defaultMessage": "Update in real-time", - "id": "home.column_settings.update_live" - }, - { "defaultMessage": "Quick filter bar", "id": "notifications.column_settings.filter_bar.category" }, @@ -1904,10 +1912,6 @@ { "descriptors": [ { - "defaultMessage": "and {count, plural, one {# other} other {# others}}", - "id": "notification.and_n_others" - }, - { "defaultMessage": "{name} followed you", "id": "notification.follow" }, diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 628ede3e3..28ea713a3 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -162,7 +162,6 @@ "home.column_settings.basic": "Basic", "home.column_settings.show_reblogs": "Show boosts", "home.column_settings.show_replies": "Show replies", - "home.column_settings.update_live": "Update in real-time", "intervals.full.days": "{number, plural, one {# day} other {# days}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", @@ -258,7 +257,6 @@ "navigation_bar.profile_directory": "Profile directory", "navigation_bar.public_timeline": "Federated timeline", "navigation_bar.security": "Security", - "notification.and_n_others": "and {count, plural, one {# other} other {# others}}", "notification.favourite": "{name} favourited your status", "notification.follow": "{name} followed you", "notification.mention": "{name} mentioned you", @@ -378,7 +376,7 @@ "time_remaining.moments": "Moments remaining", "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", - "trends.refresh": "Refresh", + "trends.trending_now": "Trending now", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.", "upload_area.title": "Drag & drop to upload", "upload_button.label": "Add media ({formats})", diff --git a/app/javascript/mastodon/reducers/alerts.js b/app/javascript/mastodon/reducers/alerts.js index 089d920c3..c62ab0dfd 100644 --- a/app/javascript/mastodon/reducers/alerts.js +++ b/app/javascript/mastodon/reducers/alerts.js @@ -14,6 +14,7 @@ export default function alerts(state = initialState, action) { key: state.size > 0 ? state.last().get('key') + 1 : 0, title: action.title, message: action.message, + message_values: action.message_values, })); case ALERT_DISMISS: return state.filterNot(item => item.get('key') === action.alert.key); diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index 7b0cdd5a5..268237846 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -17,6 +17,7 @@ import { COMPOSE_SUGGESTIONS_CLEAR, COMPOSE_SUGGESTIONS_READY, COMPOSE_SUGGESTION_SELECT, + COMPOSE_SUGGESTION_TAGS_UPDATE, COMPOSE_TAG_HISTORY_UPDATE, COMPOSE_SENSITIVITY_CHANGE, COMPOSE_SPOILERNESS_CHANGE, @@ -205,16 +206,36 @@ const expiresInFromExpiresAt = expires_at => { return [300, 1800, 3600, 21600, 86400, 259200, 604800].find(expires_in => expires_in >= delta) || 24 * 3600; }; -const normalizeSuggestions = (state, { accounts, emojis, tags }) => { +const mergeLocalHashtagResults = (suggestions, prefix, tagHistory) => { + prefix = prefix.toLowerCase(); + if (suggestions.length < 4) { + const localTags = tagHistory.filter(tag => tag.toLowerCase().startsWith(prefix) && !suggestions.some(suggestion => suggestion.type === 'hashtag' && suggestion.name.toLowerCase() === tag.toLowerCase())); + return suggestions.concat(localTags.slice(0, 4 - suggestions.length).toJS().map(tag => ({ type: 'hashtag', name: tag }))); + } else { + return suggestions; + } +}; + +const normalizeSuggestions = (state, { accounts, emojis, tags, token }) => { if (accounts) { return accounts.map(item => ({ id: item.id, type: 'account' })); } else if (emojis) { return emojis.map(item => ({ ...item, type: 'emoji' })); } else { - return sortHashtagsByUse(state, tags.map(item => ({ ...item, type: 'hashtag' }))); + return mergeLocalHashtagResults(sortHashtagsByUse(state, tags.map(item => ({ ...item, type: 'hashtag' }))), token.slice(1), state.get('tagHistory')); } }; +const updateSuggestionTags = (state, token) => { + const prefix = token.slice(1); + + const suggestions = state.get('suggestions').toJS(); + return state.merge({ + suggestions: ImmutableList(mergeLocalHashtagResults(suggestions, prefix, state.get('tagHistory'))), + suggestion_token: token, + }); +}; + export default function compose(state = initialState, action) { switch(action.type) { case STORE_HYDRATE: @@ -328,6 +349,8 @@ export default function compose(state = initialState, action) { return state.set('suggestions', ImmutableList(normalizeSuggestions(state, action))).set('suggestion_token', action.token); case COMPOSE_SUGGESTION_SELECT: return insertSuggestion(state, action.position, action.token, action.completion, action.path); + case COMPOSE_SUGGESTION_TAGS_UPDATE: + return updateSuggestionTags(state, action.token); case COMPOSE_TAG_HISTORY_UPDATE: return state.set('tagHistory', fromJS(action.tags)); case TIMELINE_DELETE: diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js index c87654547..6f1ce9602 100644 --- a/app/javascript/mastodon/selectors/index.js +++ b/app/javascript/mastodon/selectors/index.js @@ -128,6 +128,7 @@ export const getAlerts = createSelector([getAlertsBase], (base) => { base.forEach(item => { arr.push({ message: item.get('message'), + message_values: item.get('message_values'), title: item.get('title'), key: item.get('key'), dismissAfter: 5000, diff --git a/app/javascript/mastodon/utils/log_out.js b/app/javascript/mastodon/utils/log_out.js new file mode 100644 index 000000000..b43417f4b --- /dev/null +++ b/app/javascript/mastodon/utils/log_out.js @@ -0,0 +1,33 @@ +import Rails from 'rails-ujs'; + +export const logOut = () => { + const form = document.createElement('form'); + + const methodInput = document.createElement('input'); + methodInput.setAttribute('name', '_method'); + methodInput.setAttribute('value', 'delete'); + methodInput.setAttribute('type', 'hidden'); + form.appendChild(methodInput); + + const csrfToken = Rails.csrfToken(); + const csrfParam = Rails.csrfParam(); + + if (csrfParam && csrfToken) { + const csrfInput = document.createElement('input'); + csrfInput.setAttribute('name', csrfParam); + csrfInput.setAttribute('value', csrfToken); + csrfInput.setAttribute('type', 'hidden'); + form.appendChild(csrfInput); + } + + const submitButton = document.createElement('input'); + submitButton.setAttribute('type', 'submit'); + form.appendChild(submitButton); + + form.method = 'post'; + form.action = '/auth/sign_out'; + form.style.display = 'none'; + + document.body.appendChild(form); + submitButton.click(); +}; diff --git a/app/javascript/styles/mailer.scss b/app/javascript/styles/mailer.scss index b4fb1d709..e25a80c04 100644 --- a/app/javascript/styles/mailer.scss +++ b/app/javascript/styles/mailer.scss @@ -457,6 +457,13 @@ h5 { .status { padding-bottom: 32px; + &--highlighted { + border: 1px solid lighten($ui-base-color, 8%); + border-radius: 4px; + padding-bottom: 16px; + margin-bottom: 16px; + } + .status-header { td { font-size: 14px; diff --git a/app/javascript/styles/mastodon-light/diff.scss b/app/javascript/styles/mastodon-light/diff.scss index ee8a7d265..e7114ed07 100644 --- a/app/javascript/styles/mastodon-light/diff.scss +++ b/app/javascript/styles/mastodon-light/diff.scss @@ -104,7 +104,8 @@ html { .box-widget input[type="email"], .box-widget input[type="password"], .box-widget textarea, -.statuses-grid .detailed-status { +.statuses-grid .detailed-status, +.audio-player { border: 1px solid lighten($ui-base-color, 8%); } @@ -700,3 +701,10 @@ html { .compose-form .compose-form__warning { box-shadow: none; } + +.audio-player .video-player__controls button, +.audio-player .video-player__time-sep, +.audio-player .video-player__time-current, +.audio-player .video-player__time-total { + color: $primary-text-color; +} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 5c30c1295..8aaa068d3 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -948,7 +948,8 @@ opacity: 1; animation: fade 150ms linear; - .video-player { + .video-player, + .audio-player { margin-top: 8px; } @@ -1043,7 +1044,8 @@ white-space: normal; } - .video-player { + .video-player, + .audio-player { margin-top: 8px; max-width: 250px; } @@ -1154,7 +1156,8 @@ } } - .video-player { + .video-player, + .audio-player { margin-top: 8px; } } @@ -2130,7 +2133,8 @@ a.account__display-name { padding: 15px; .media-gallery, - .video-player { + .video-player, + .audio-player { margin-top: 15px; } } @@ -2172,7 +2176,8 @@ a.account__display-name { .media-gallery, &__action-bar, - .video-player { + .video-player, + .audio-player { margin-top: 10px; } } @@ -2765,6 +2770,15 @@ a.account__display-name { animation: fade 150ms linear; margin-top: 10px; + h4 { + font-size: 12px; + text-transform: uppercase; + color: $darker-text-color; + padding: 10px; + font-weight: 500; + border-bottom: 1px solid lighten($ui-base-color, 8%); + } + @media screen and (max-height: 810px) { .trends__item:nth-child(3) { display: none; @@ -5034,15 +5048,63 @@ a.status-card.compact:hover { } +.audio-player { + box-sizing: border-box; + position: relative; + background: darken($ui-base-color, 8%); + border-radius: 4px; + padding-bottom: 44px; + + &.editable { + border-radius: 0; + height: 100%; + } + + &__waveform { + padding: 15px 0; + position: relative; + overflow: hidden; + + &::before { + content: ""; + display: block; + position: absolute; + border-top: 1px solid lighten($ui-base-color, 4%); + width: 100%; + height: 0; + left: 0; + top: calc(50% + 1px); + } + } + + &__progress-placeholder { + background-color: rgba(lighten($ui-highlight-color, 8%), 0.5); + } + + &__wave-placeholder { + background-color: lighten($ui-base-color, 16%); + } + + .video-player__controls { + padding: 0 15px; + padding-top: 10px; + background: darken($ui-base-color, 8%); + border-top: 1px solid lighten($ui-base-color, 4%); + border-radius: 0 0 4px 4px; + } +} + .video-player { overflow: hidden; position: relative; background: $base-shadow-color; max-width: 100%; border-radius: 4px; + box-sizing: border-box; &.editable { border-radius: 0; + height: 100% !important; } &:focus { diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb index 1f2b40c15..345060462 100644 --- a/app/lib/activitypub/activity/delete.rb +++ b/app/lib/activitypub/activity/delete.rb @@ -70,7 +70,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity end def delete_now! - RemoveStatusService.new.call(@status) + RemoveStatusService.new.call(@status, redraft: false) end def payload diff --git a/app/mailers/user_mailer.rb b/app/mailers/user_mailer.rb index 8f3a4ab3a..b41004acc 100644 --- a/app/mailers/user_mailer.rb +++ b/app/mailers/user_mailer.rb @@ -5,6 +5,7 @@ class UserMailer < Devise::Mailer helper :application helper :instance + helper :statuses add_template_helper RoutingHelper @@ -79,10 +80,11 @@ class UserMailer < Devise::Mailer end end - def warning(user, warning) + def warning(user, warning, status_ids = nil) @resource = user @warning = warning @instance = Rails.configuration.x.local_domain + @statuses = Status.where(id: status_ids).includes(:account) if status_ids.is_a?(Array) I18n.with_locale(@resource.locale || I18n.default_locale) do mail to: @resource.email, diff --git a/app/models/admin/account_action.rb b/app/models/admin/account_action.rb index bdbd342fb..c7da8b52c 100644 --- a/app/models/admin/account_action.rb +++ b/app/models/admin/account_action.rb @@ -19,20 +19,25 @@ class Admin::AccountAction :report_id, :warning_preset_id - attr_reader :warning, :send_email_notification + attr_reader :warning, :send_email_notification, :include_statuses def send_email_notification=(value) @send_email_notification = ActiveModel::Type::Boolean.new.cast(value) end + def include_statuses=(value) + @include_statuses = ActiveModel::Type::Boolean.new.cast(value) + end + def save! ApplicationRecord.transaction do process_action! process_warning! end - queue_email! + process_email! process_reports! + process_queue! end def report @@ -110,7 +115,6 @@ class Admin::AccountAction authorize(target_account, :suspend?) log_action(:suspend, target_account) target_account.suspend! - queue_suspension_worker! end def text_for_warning @@ -121,16 +125,22 @@ class Admin::AccountAction Admin::SuspensionWorker.perform_async(target_account.id) end - def queue_email! - return unless warnable? + def process_queue! + queue_suspension_worker! if type == 'suspend' + end - UserMailer.warning(target_account.user, warning).deliver_later! + def process_email! + UserMailer.warning(target_account.user, warning, status_ids).deliver_now! if warnable? end def warnable? send_email_notification && target_account.local? end + def status_ids + @report.status_ids if @report && include_statuses + end + def warning_preset @warning_preset ||= AccountWarningPreset.find(warning_preset_id) if warning_preset_id.present? end diff --git a/app/models/form/status_batch.rb b/app/models/form/status_batch.rb index 933dfdaca..e09cc2594 100644 --- a/app/models/form/status_batch.rb +++ b/app/models/form/status_batch.rb @@ -34,7 +34,8 @@ class Form::StatusBatch def delete_statuses Status.where(id: status_ids).reorder(nil).find_each do |status| - RemovalWorker.perform_async(status.id) + status.discard + RemovalWorker.perform_async(status.id, redraft: false) Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true) log_action :destroy, status end diff --git a/app/models/report.rb b/app/models/report.rb index 5192ceef7..1e707ff1c 100644 --- a/app/models/report.rb +++ b/app/models/report.rb @@ -43,7 +43,7 @@ class Report < ApplicationRecord end def statuses - Status.where(id: status_ids).includes(:account, :media_attachments, :mentions) + Status.with_discarded.where(id: status_ids).includes(:account, :media_attachments, :mentions) end def media_attachments diff --git a/app/models/status.rb b/app/models/status.rb index de790027d..757deea06 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -25,15 +25,19 @@ # full_status_text :text default(""), not null # poll_id :bigint(8) # content_type :string +# deleted_at :datetime # class Status < ApplicationRecord before_destroy :unlink_from_conversations + include Discard::Model include Paginable include Cacheable include StatusThreadingConcern + self.discard_column = :deleted_at + # If `override_timestamps` is set at creation time, Snowflake ID creation # will be based on current time instead of `created_at` attr_accessor :override_timestamps @@ -77,7 +81,7 @@ class Status < ApplicationRecord accepts_nested_attributes_for :poll - default_scope { recent } + default_scope { recent.kept } scope :recent, -> { reorder(id: :desc) } scope :remote, -> { where(local: false).where.not(uri: nil) } diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb index c9a9a5a6e..31237337a 100644 --- a/app/services/batched_remove_status_service.rb +++ b/app/services/batched_remove_status_service.rb @@ -8,7 +8,7 @@ class BatchedRemoveStatusService < BaseService # Dispatch Salmon deletes, unique per domain, of the deleted statuses, but only local ones # Remove statuses from home feeds # Push delete events to streaming API for home feeds and public feeds - # @param [Status] statuses A preferably batched array of statuses + # @param [Enumerable<Status>] statuses A preferably batched array of statuses # @param [Hash] options # @option [Boolean] :skip_side_effects def call(statuses, **options) diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb index c19fa2126..b2f712089 100644 --- a/app/services/remove_status_service.rb +++ b/app/services/remove_status_service.rb @@ -4,6 +4,11 @@ class RemoveStatusService < BaseService include Redisable include Payloadable + # Delete a status + # @param [Status] status + # @param [Hash] options + # @option [Boolean] :redraft + # @options [Boolean] :original_removed def call(status, **options) @payload = Oj.dump(event: :delete, payload: status.id.to_s) @status = status @@ -25,6 +30,7 @@ class RemoveStatusService < BaseService remove_from_media if status.media_attachments.any? remove_from_direct if status.direct_visibility? remove_from_spam_check + remove_media @status.destroy! else @@ -151,6 +157,12 @@ class RemoveStatusService < BaseService end end + def remove_media + return if @options[:redraft] + + @status.media_attachments.destroy_all + end + def remove_from_spam_check redis.zremrangebyscore("spam_check:#{@status.account_id}", @status.id, @status.id) end diff --git a/app/views/admin/account_actions/new.html.haml b/app/views/admin/account_actions/new.html.haml index 97286c8e5..20fbeef33 100644 --- a/app/views/admin/account_actions/new.html.haml +++ b/app/views/admin/account_actions/new.html.haml @@ -13,6 +13,10 @@ .fields-group = f.input :send_email_notification, as: :boolean, wrapper: :with_label + - if params[:report_id].present? + .fields-group + = f.input :include_statuses, as: :boolean, wrapper: :with_label + %hr.spacer/ - unless @warning_presets.empty? diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 408d515ca..af7a59802 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -105,7 +105,7 @@ %li = feature_hint(t('admin.dashboard.authorized_fetch_mode'), @authorized_fetch) %li - = feature_hint(t('admin.dashboard.whitelist_mode'), @whitelist_mode) + = feature_hint(t('admin.dashboard.whitelist_mode'), @whitelist_enabled) %li = feature_hint('LDAP', @ldap_enabled) %li diff --git a/app/views/admin/reports/_status.html.haml b/app/views/admin/reports/_status.html.haml index 9376db7ff..6facc0a56 100644 --- a/app/views/admin/reports/_status.html.haml +++ b/app/views/admin/reports/_status.html.haml @@ -16,11 +16,14 @@ - video = status.proper.media_attachments.first = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.proper.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description - else - = react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.proper.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } + = react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.proper.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.proper.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } .detailed-status__meta = link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: stream_link_target, rel: 'noopener' do %time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at) + - if status.discarded? + · + %span.negative-hint= t('admin.statuses.deleted') · - if status.reblog? = fa_icon('retweet fw') diff --git a/app/views/notification_mailer/_status.html.haml b/app/views/notification_mailer/_status.html.haml index 57b5688bd..40f3aa88a 100644 --- a/app/views/notification_mailer/_status.html.haml +++ b/app/views/notification_mailer/_status.html.haml @@ -1,4 +1,5 @@ - i ||= 0 +- highlighted ||= false %table.email-table{ cellspacing: 0, cellpadding: 0, dir: 'ltr' } %tbody @@ -14,7 +15,7 @@ %table.column{ cellspacing: 0, cellpadding: 0 } %tbody %tr - %td.column-cell.padded.status + %td.column-cell.padded.status{ class: highlighted ? 'status--highlighted' : '' } %table.status-header{ cellspacing: 0, cellpadding: 0 } %tbody %tr @@ -32,5 +33,10 @@ %div{ dir: rtl_status?(status) ? 'rtl' : 'ltr' } = Formatter.instance.format(status) + - if status.media_attachments.size > 0 + %p + - status.media_attachments.each do |a| + = link_to medium_url(a), medium_url(a) + %p.status-footer = link_to l(status.created_at), web_url("statuses/#{status.id}") diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml index 8686c2033..12f03ccdd 100644 --- a/app/views/statuses/_detailed_status.html.haml +++ b/app/views/statuses/_detailed_status.html.haml @@ -27,10 +27,14 @@ = render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay } - if !status.media_attachments.empty? - - if status.media_attachments.first.audio_or_video? + - if status.media_attachments.first.video? - video = status.media_attachments.first = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } + - elsif status.media_attachments.first.audio? + - audio = status.media_attachments.first + = react_component :audio, src: audio.file.url(:original), height: 130, alt: audio.description, preload: true, duration: audio.file.meta.dig(:original, :duration) do + = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } - else = react_component :media_gallery, height: 380, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml index 27f6fc227..fe1591bf9 100644 --- a/app/views/statuses/_simple_status.html.haml +++ b/app/views/statuses/_simple_status.html.haml @@ -31,10 +31,14 @@ = render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay } - if !status.media_attachments.empty? - - if status.media_attachments.first.audio_or_video? + - if status.media_attachments.first.video? - video = status.media_attachments.first = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description do = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } + - elsif status.media_attachments.first.audio? + - audio = status.media_attachments.first + = react_component :audio, src: audio.file.url(:original), height: 110, alt: audio.description, duration: audio.file.meta.dig(:original, :duration) do + = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } - else = react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } diff --git a/app/views/user_mailer/warning.html.haml b/app/views/user_mailer/warning.html.haml index 72ea5e5d2..030a57bb4 100644 --- a/app/views/user_mailer/warning.html.haml +++ b/app/views/user_mailer/warning.html.haml @@ -42,6 +42,14 @@ - unless @warning.text.blank? = Formatter.instance.linkify(@warning.text) + - unless @statuses.empty? + %p + %strong= t('user_mailer.warning.statuses') + +- unless @statuses.empty? + - @statuses.each_with_index do |status, i| + = render 'notification_mailer/status', status: status, i: i + 1, highlighted: true + %table.email-table{ cellspacing: 0, cellpadding: 0 } %tbody %tr @@ -50,7 +58,7 @@ %table.content-section{ cellspacing: 0, cellpadding: 0 } %tbody %tr - %td.content-cell + %td.content-cell{ class: @statuses.empty? ? '' : 'content-start' } %table.column{ cellspacing: 0, cellpadding: 0 } %tbody %tr @@ -61,3 +69,20 @@ %td.button-primary = link_to about_more_url do %span= t 'user_mailer.warning.review_server_policies' + +%table.email-table{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.email-body + .email-container + %table.content-section{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.content-cell + .email-row + .col-6 + %table.column{ cellspacing: 0, cellpadding: 0 } + %tbody + %tr + %td.column-cell.text-center + %p= t 'user_mailer.warning.get_in_touch', instance: @instance diff --git a/app/views/user_mailer/warning.text.erb b/app/views/user_mailer/warning.text.erb index b4f2402cb..24c1f86f2 100644 --- a/app/views/user_mailer/warning.text.erb +++ b/app/views/user_mailer/warning.text.erb @@ -7,3 +7,16 @@ <% end %> <%= @warning.text %> +<% unless @statuses.empty? %> +<%= t('user_mailer.warning.statuses') %> + +<% @statuses.each do |status| %> + +<%= render 'notification_mailer/status', status: status %> +--- +<% end %> +<% else %> +--- +<% end %> + +<%= t 'user_mailer.warning.get_in_touch', instance: @instance %> diff --git a/app/workers/removal_worker.rb b/app/workers/removal_worker.rb index 19a660dd3..2a1eaa89b 100644 --- a/app/workers/removal_worker.rb +++ b/app/workers/removal_worker.rb @@ -3,8 +3,8 @@ class RemovalWorker include Sidekiq::Worker - def perform(status_id) - RemoveStatusService.new.call(Status.find(status_id)) + def perform(status_id, options = {}) + RemoveStatusService.new.call(Status.with_discarded.find(status_id), **options.symbolize_keys) rescue ActiveRecord::RecordNotFound true end diff --git a/config/locales/en.yml b/config/locales/en.yml index be190f0f1..8e5ee8543 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -512,6 +512,7 @@ en: delete: Delete nsfw_off: Mark as not sensitive nsfw_on: Mark as sensitive + deleted: Deleted failed_to_execute: Failed to execute media: title: Media @@ -1129,7 +1130,9 @@ en: disable: While your account is frozen, your account data remains intact, but you cannot perform any actions until it is unlocked. silence: While your account is limited, only people who are already following you will see your toots on this server, and you may be excluded from various public listings. However, others may still manually follow you. suspend: Your account has been suspended, and all of your toots and your uploaded media files have been irreversibly removed from this server, and servers where you had followers. + get_in_touch: You can reply to this e-mail to get in touch with the staff of %{instance}. review_server_policies: Review server policies + statuses: 'Specifically, for:' subject: disable: Your account %{acct} has been frozen none: Warning for %{acct} diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index df898c621..14378b7bd 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -5,6 +5,7 @@ en: account_warning_preset: text: You can use toot syntax, such as URLs, hashtags and mentions admin_account_action: + include_statuses: The user will see which toots have caused the moderation action or warning send_email_notification: The user will receive an explanation of what happened with their account text_html: Optional. You can use toot syntax. You can <a href="%{path}">add warning presets</a> to save time type_html: Choose what to do with <strong>%{acct}</strong> @@ -65,6 +66,7 @@ en: account_warning_preset: text: Preset text admin_account_action: + include_statuses: Include reported toots in the e-mail send_email_notification: Notify the user per e-mail text: Custom warning type: Action @@ -156,6 +158,7 @@ en: trending_tag: Send e-mail when an unreviewed hashtag is trending tag: listable: Allow this hashtag to appear in searches and on the profile directory + name: Hashtag trendable: Allow this hashtag to appear under trends usable: Allow toots to use this hashtag 'no': 'No' diff --git a/db/migrate/20190819134503_add_deleted_at_to_statuses.rb b/db/migrate/20190819134503_add_deleted_at_to_statuses.rb new file mode 100644 index 000000000..5af109097 --- /dev/null +++ b/db/migrate/20190819134503_add_deleted_at_to_statuses.rb @@ -0,0 +1,5 @@ +class AddDeletedAtToStatuses < ActiveRecord::Migration[5.2] + def change + add_column :statuses, :deleted_at, :datetime + end +end diff --git a/db/migrate/20190820003045_update_statuses_index.rb b/db/migrate/20190820003045_update_statuses_index.rb new file mode 100644 index 000000000..5c2ea1f6a --- /dev/null +++ b/db/migrate/20190820003045_update_statuses_index.rb @@ -0,0 +1,13 @@ +class UpdateStatusesIndex < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def up + safety_assured { add_index :statuses, [:account_id, :id, :visibility, :updated_at], where: 'deleted_at IS NULL', order: { id: :desc }, algorithm: :concurrently, name: :index_statuses_20190820 } + remove_index :statuses, name: :index_statuses_20180106 + end + + def down + safety_assured { add_index :statuses, [:account_id, :id, :visibility, :updated_at], order: { id: :desc }, algorithm: :concurrently, name: :index_statuses_20180106 } + remove_index :statuses, name: :index_statuses_20190820 + end +end diff --git a/db/migrate/20190823221802_add_local_index_to_statuses.rb b/db/migrate/20190823221802_add_local_index_to_statuses.rb new file mode 100644 index 000000000..deca25c35 --- /dev/null +++ b/db/migrate/20190823221802_add_local_index_to_statuses.rb @@ -0,0 +1,11 @@ +class AddLocalIndexToStatuses < ActiveRecord::Migration[5.2] + disable_ddl_transaction! + + def up + add_index :statuses, [:id, :account_id], name: :index_statuses_local_20190824, algorithm: :concurrently, order: { id: :desc }, where: '(local OR (uri IS NULL)) AND deleted_at IS NULL AND visibility = 0 AND reblog_of_id IS NULL AND ((NOT reply) OR (in_reply_to_account_id = account_id))' + end + + def down + remove_index :statuses, name: :index_statuses_local_20190824 + end +end diff --git a/db/schema.rb b/db/schema.rb index 7e62fe1f5..328506b50 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2019_08_15_225426) do +ActiveRecord::Schema.define(version: 2019_08_23_221802) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -657,7 +657,9 @@ ActiveRecord::Schema.define(version: 2019_08_15_225426) do t.boolean "local_only" t.bigint "poll_id" t.string "content_type" - t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20180106", order: { id: :desc } + t.datetime "deleted_at" + t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)" + t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))" t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id" t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id" t.index ["reblog_of_id", "account_id"], name: "index_statuses_on_reblog_of_id_and_account_id" diff --git a/package.json b/package.json index 6f6730b9a..cba13911f 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "private": true, "dependencies": { "@babel/core": "^7.4.5", - "@babel/plugin-proposal-class-properties": "^7.5.0", + "@babel/plugin-proposal-class-properties": "^7.5.5", "@babel/plugin-proposal-decorators": "^7.4.4", "@babel/plugin-proposal-object-rest-spread": "^7.4.4", "@babel/plugin-syntax-dynamic-import": "^7.2.0", @@ -137,7 +137,7 @@ "react-motion": "^0.5.2", "react-notification": "^6.8.4", "react-overlays": "^0.8.3", - "react-redux": "^7.1.0", + "react-redux": "^7.1.1", "react-redux-loading-bar": "^4.0.8", "react-router-dom": "^4.1.1", "react-router-scroll-4": "^1.0.0-beta.1", @@ -163,10 +163,11 @@ "throng": "^4.0.0", "tiny-queue": "^0.2.1", "uuid": "^3.1.0", + "wavesurfer.js": "^3.0.0", "webpack": "^4.35.3", "webpack-assets-manifest": "^3.1.1", "webpack-bundle-analyzer": "^3.3.2", - "webpack-cli": "^3.3.6", + "webpack-cli": "^3.3.7", "webpack-merge": "^4.2.1", "websocket.js": "^0.1.12" }, diff --git a/spec/controllers/admin/reported_statuses_controller_spec.rb b/spec/controllers/admin/reported_statuses_controller_spec.rb index c358506d6..bd146b795 100644 --- a/spec/controllers/admin/reported_statuses_controller_spec.rb +++ b/spec/controllers/admin/reported_statuses_controller_spec.rb @@ -47,7 +47,7 @@ describe Admin::ReportedStatusesController do it 'removes a status' do allow(RemovalWorker).to receive(:perform_async) subject.call - expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first) + expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first, redraft: false) end end diff --git a/spec/controllers/admin/statuses_controller_spec.rb b/spec/controllers/admin/statuses_controller_spec.rb index 1a08c10b7..6b06343ef 100644 --- a/spec/controllers/admin/statuses_controller_spec.rb +++ b/spec/controllers/admin/statuses_controller_spec.rb @@ -65,7 +65,7 @@ describe Admin::StatusesController do it 'removes a status' do allow(RemovalWorker).to receive(:perform_async) subject.call - expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first) + expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first, redraft: false) end end diff --git a/spec/mailers/previews/user_mailer_preview.rb b/spec/mailers/previews/user_mailer_preview.rb index 53c836494..ead3b3baa 100644 --- a/spec/mailers/previews/user_mailer_preview.rb +++ b/spec/mailers/previews/user_mailer_preview.rb @@ -42,6 +42,6 @@ class UserMailerPreview < ActionMailer::Preview # Preview this email at http://localhost:3000/rails/mailers/user_mailer/warning def warning - UserMailer.warning(User.first, AccountWarning.new(text: '', action: :silence)) + UserMailer.warning(User.first, AccountWarning.new(text: '', action: :silence), [Status.first.id]) end end diff --git a/spec/models/admin/account_action_spec.rb b/spec/models/admin/account_action_spec.rb index a3db60cfc..87fc28500 100644 --- a/spec/models/admin/account_action_spec.rb +++ b/spec/models/admin/account_action_spec.rb @@ -58,8 +58,8 @@ RSpec.describe Admin::AccountAction, type: :model do end.to change { Admin::ActionLog.count }.by 1 end - it 'calls queue_email!' do - expect(account_action).to receive(:queue_email!) + it 'calls process_email!' do + expect(account_action).to receive(:process_email!) subject end diff --git a/spec/models/form/status_batch_spec.rb b/spec/models/form/status_batch_spec.rb index 00c790a11..f9c58c90f 100644 --- a/spec/models/form/status_batch_spec.rb +++ b/spec/models/form/status_batch_spec.rb @@ -41,12 +41,12 @@ describe Form::StatusBatch do it 'call RemovalWorker' do form.save - expect(RemovalWorker).to have_received(:perform_async).with(status.id) + expect(RemovalWorker).to have_received(:perform_async).with(status.id, redraft: false) end it 'do not call RemovalWorker' do form.save - expect(RemovalWorker).not_to have_received(:perform_async).with(another_status.id) + expect(RemovalWorker).not_to have_received(:perform_async).with(another_status.id, redraft: false) end end end diff --git a/yarn.lock b/yarn.lock index ecc4e317c..ab20731ff 100644 --- a/yarn.lock +++ b/yarn.lock @@ -121,16 +121,16 @@ "@babel/traverse" "^7.4.4" "@babel/types" "^7.4.4" -"@babel/helper-create-class-features-plugin@^7.4.4", "@babel/helper-create-class-features-plugin@^7.5.0": - version "7.5.0" - resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.5.0.tgz#02edb97f512d44ba23b3227f1bf2ed43454edac5" - integrity sha512-EAoMc3hE5vE5LNhMqDOwB1usHvmRjCDAnH8CD4PVkX9/Yr3W/tcz8xE8QvdZxfsFBDICwZnF2UTHIqslRpvxmA== +"@babel/helper-create-class-features-plugin@^7.4.4", "@babel/helper-create-class-features-plugin@^7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.5.5.tgz#401f302c8ddbc0edd36f7c6b2887d8fa1122e5a4" + integrity sha512-ZsxkyYiRA7Bg+ZTRpPvB6AbOFKTFFK4LrvTet8lInm0V468MWCaSYJE+I7v2z2r8KNLtYiV+K5kTCnR7dvyZjg== dependencies: "@babel/helper-function-name" "^7.1.0" - "@babel/helper-member-expression-to-functions" "^7.0.0" + "@babel/helper-member-expression-to-functions" "^7.5.5" "@babel/helper-optimise-call-expression" "^7.0.0" "@babel/helper-plugin-utils" "^7.0.0" - "@babel/helper-replace-supers" "^7.4.4" + "@babel/helper-replace-supers" "^7.5.5" "@babel/helper-split-export-declaration" "^7.4.4" "@babel/helper-define-map@^7.5.5": @@ -173,13 +173,6 @@ dependencies: "@babel/types" "^7.4.4" -"@babel/helper-member-expression-to-functions@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.0.0.tgz#8cd14b0a0df7ff00f009e7d7a436945f47c7a16f" - integrity sha512-avo+lm/QmZlv27Zsi0xEor2fKcqWG56D5ae9dzklpIaY7cQMK5N8VSpaNVPPagiqmy7LrEjK1IWdGMOqPu5csg== - dependencies: - "@babel/types" "^7.0.0" - "@babel/helper-member-expression-to-functions@^7.5.5": version "7.5.5" resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.5.5.tgz#1fb5b8ec4453a93c439ee9fe3aeea4a84b76b590" @@ -236,16 +229,6 @@ "@babel/traverse" "^7.1.0" "@babel/types" "^7.0.0" -"@babel/helper-replace-supers@^7.4.4": - version "7.4.4" - resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.4.4.tgz#aee41783ebe4f2d3ab3ae775e1cc6f1a90cefa27" - integrity sha512-04xGEnd+s01nY1l15EuMS1rfKktNF+1CkKmHoErDppjAAZL+IUBZpzT748x262HF7fibaQPhbvWUl5HeSt1EXg== - dependencies: - "@babel/helper-member-expression-to-functions" "^7.0.0" - "@babel/helper-optimise-call-expression" "^7.0.0" - "@babel/traverse" "^7.4.4" - "@babel/types" "^7.4.4" - "@babel/helper-replace-supers@^7.5.5": version "7.5.5" resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.5.5.tgz#f84ce43df031222d2bad068d2626cb5799c34bc2" @@ -327,12 +310,12 @@ "@babel/helper-remap-async-to-generator" "^7.1.0" "@babel/plugin-syntax-async-generators" "^7.2.0" -"@babel/plugin-proposal-class-properties@^7.5.0": - version "7.5.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.5.0.tgz#5bc6a0537d286fcb4fd4e89975adbca334987007" - integrity sha512-9L/JfPCT+kShiiTTzcnBJ8cOwdKVmlC1RcCf9F0F9tERVrM4iWtWnXtjWCRqNm2la2BxO1MPArWNsU9zsSJWSQ== +"@babel/plugin-proposal-class-properties@^7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.5.5.tgz#a974cfae1e37c3110e71f3c6a2e48b8e71958cd4" + integrity sha512-AF79FsnWFxjlaosgdi421vmYG6/jg79bVD0dpD44QdgobzHKuLZ6S3vl8la9qIeSwGi8i1fS0O1mfuDAAdo1/A== dependencies: - "@babel/helper-create-class-features-plugin" "^7.5.0" + "@babel/helper-create-class-features-plugin" "^7.5.5" "@babel/helper-plugin-utils" "^7.0.0" "@babel/plugin-proposal-decorators@^7.4.4": @@ -811,10 +794,10 @@ dependencies: regenerator-runtime "^0.12.0" -"@babel/runtime@^7.1.2", "@babel/runtime@^7.4.2", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.4": - version "7.5.4" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.5.4.tgz#cb7d1ad7c6d65676e66b47186577930465b5271b" - integrity sha512-Na84uwyImZZc3FKf4aUF1tysApzwf3p2yuFBIyBfbzT5glzKTdvYI4KVW4kcgjrzoGUjC7w3YyCHcJKaRxsr2Q== +"@babel/runtime@^7.1.2", "@babel/runtime@^7.4.2", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5": + version "7.5.5" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.5.5.tgz#74fba56d35efbeca444091c7850ccd494fd2f132" + integrity sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ== dependencies: regenerator-runtime "^0.13.2" @@ -3858,14 +3841,16 @@ eslint-scope@^5.0.0: estraverse "^4.1.1" eslint-utils@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.3.1.tgz#9a851ba89ee7c460346f97cf8939c7298827e512" - integrity sha512-Z7YjnIldX+2XMcjr7ZkgEsOj/bREONV60qYeB/bjMAqqqZ4zxKyWX+BOUkdmRmA9riiIPVvo5x86m5elviOk0Q== + version "1.4.2" + resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.2.tgz#166a5180ef6ab7eb462f162fd0e6f2463d7309ab" + integrity sha512-eAZS2sEUMlIeCjBeubdj45dmBHQwPHWyBcT1VSYB7o9x9WRRqKxyUoiXlRjyAwzN7YEzHJlYg0NmzDRWx6GP4Q== + dependencies: + eslint-visitor-keys "^1.0.0" eslint-visitor-keys@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#3f3180fb2e291017716acb4c9d6d5b5c34a6a81d" - integrity sha512-qzm/XxIbxm/FHyH341ZrbnMUpe+5Bocte9xkmFMzPMjRaZMcXww+MpBptFvtU+79L362nqiLhekCxCxDPaUMBQ== + version "1.1.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2" + integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A== eslint@^2.7.0: version "2.13.1" @@ -6764,9 +6749,9 @@ mississippi@^3.0.0: through2 "^2.0.0" mixin-deep@^1.2.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe" - integrity sha512-8ZItLHeEgaqEvd5lYBXfm4EZSFCX29Jb9K+lAHhDKzReKBQKj3R+7NOF6tjqYi9t4oI8VUfaWITJQm86wnXGNQ== + version "1.3.2" + resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" + integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA== dependencies: for-in "^1.0.2" is-extendable "^1.0.1" @@ -8484,10 +8469,10 @@ react-intl@^2.9.0: intl-relativeformat "^2.1.0" invariant "^2.1.1" -react-is@^16.3.2, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6: - version "16.8.6" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.6.tgz#5bbc1e2d29141c9fbdfed456343fe2bc430a6a16" - integrity sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA== +react-is@^16.3.2, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0: + version "16.9.0" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.9.0.tgz#21ca9561399aad0ff1a7701c01683e8ca981edcb" + integrity sha512-tJBzzzIgnnRfEm046qRcURvwQnZVXmuCbscxUO5RWrGTXpon2d4c8mI0D8WE6ydVIm29JiLB6+RslkIvym9Rjw== react-lifecycles-compat@^3.0.2, react-lifecycles-compat@^3.0.4: version "3.0.4" @@ -8539,17 +8524,17 @@ react-redux-loading-bar@^4.0.8: prop-types "^15.6.2" react-lifecycles-compat "^3.0.2" -react-redux@^7.1.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.1.0.tgz#72af7cf490a74acdc516ea9c1dd80e25af9ea0b2" - integrity sha512-hyu/PoFK3vZgdLTg9ozbt7WF3GgX5+Yn3pZm5/96/o4UueXA+zj08aiSC9Mfj2WtD1bvpIb3C5yvskzZySzzaw== +react-redux@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.1.1.tgz#ce6eee1b734a7a76e0788b3309bf78ff6b34fa0a" + integrity sha512-QsW0vcmVVdNQzEkrgzh2W3Ksvr8cqpAv5FhEk7tNEft+5pp7rXxAudTz3VOPawRkLIepItpkEIyLcN/VVXzjTg== dependencies: - "@babel/runtime" "^7.4.5" + "@babel/runtime" "^7.5.5" hoist-non-react-statics "^3.3.0" invariant "^2.2.4" loose-envify "^1.4.0" prop-types "^15.7.2" - react-is "^16.8.6" + react-is "^16.9.0" react-router-dom@^4.1.1: version "4.3.1" @@ -10474,6 +10459,11 @@ watchpack@^1.5.0: graceful-fs "^4.1.2" neo-async "^2.5.0" +wavesurfer.js@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/wavesurfer.js/-/wavesurfer.js-3.0.0.tgz#35f36d76d59c749dca453cf4e10ee0ec49f454f8" + integrity sha512-DANu206c6gb9pSUbYFevsSiXMy8+Ri+CNtqm0UsouUdsn9fVQRtYs8uxzBtXK+rUPlIc6FlO54DU8uWeW3lDzw== + wbuf@^1.1.0, wbuf@^1.7.3: version "1.7.3" resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df" @@ -10518,10 +10508,10 @@ webpack-bundle-analyzer@^3.3.2: opener "^1.5.1" ws "^6.0.0" -webpack-cli@^3.3.6: - version "3.3.6" - resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-3.3.6.tgz#2c8c399a2642133f8d736a359007a052e060032c" - integrity sha512-0vEa83M7kJtxK/jUhlpZ27WHIOndz5mghWL2O53kiDoA9DIxSKnfqB92LoqEn77cT4f3H2cZm1BMEat/6AZz3A== +webpack-cli@^3.3.7: + version "3.3.7" + resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-3.3.7.tgz#77c8580dd8e92f69d635e0238eaf9d9c15759a91" + integrity sha512-OhTUCttAsr+IZSMVwGROGRHvT+QAs8H6/mHIl4SvhAwYywjiylYjpwybGx7WQ9Hkb45FhjtsymkwiRRbGJ1SZQ== dependencies: chalk "2.4.2" cross-spawn "6.0.5" |