Browse Source

Merge branch 'master' into glitch-soc/merge-upstream

Conflicts:
- app/models/status.rb
- app/services/remove_status_service.rb
- db/schema.rb

All conflicts were due to the addition of a `deleted_at` attribute
to Statuses and reworked database indexes.
master
Thibaut Girka 2 weeks ago
parent
commit
48b8a1f414
66 changed files with 850 additions and 188 deletions
  1. 2
    1
      Gemfile
  2. 9
    6
      Gemfile.lock
  3. 2
    2
      app/controllers/admin/account_actions_controller.rb
  4. 1
    1
      app/controllers/api/v1/reports_controller.rb
  5. 2
    1
      app/controllers/api/v1/statuses/reblogs_controller.rb
  6. 2
    1
      app/controllers/api/v1/statuses_controller.rb
  7. 10
    2
      app/javascript/mastodon/actions/alerts.js
  8. 2
    0
      app/javascript/mastodon/actions/compose.js
  9. 3
    3
      app/javascript/mastodon/components/autosuggest_hashtag.js
  10. 13
    1
      app/javascript/mastodon/components/column_back_button.js
  11. 13
    1
      app/javascript/mastodon/components/column_header.js
  12. 24
    4
      app/javascript/mastodon/components/status.js
  13. 1
    1
      app/javascript/mastodon/components/status_content.js
  14. 2
    1
      app/javascript/mastodon/containers/media_container.js
  15. 226
    0
      app/javascript/mastodon/features/audio/index.js
  16. 6
    1
      app/javascript/mastodon/features/compose/components/action_bar.js
  17. 2
    1
      app/javascript/mastodon/features/compose/components/navigation_bar.js
  18. 19
    1
      app/javascript/mastodon/features/compose/containers/navigation_container.js
  19. 20
    1
      app/javascript/mastodon/features/compose/index.js
  20. 4
    1
      app/javascript/mastodon/features/getting_started/components/trends.js
  21. 14
    1
      app/javascript/mastodon/features/status/components/detailed_status.js
  22. 13
    1
      app/javascript/mastodon/features/ui/components/focal_point_modal.js
  23. 66
    29
      app/javascript/mastodon/features/ui/components/link_footer.js
  24. 1
    1
      app/javascript/mastodon/features/ui/containers/notifications_container.js
  25. 14
    4
      app/javascript/mastodon/features/ui/index.js
  26. 4
    0
      app/javascript/mastodon/features/ui/util/async-components.js
  27. 1
    1
      app/javascript/mastodon/features/video/index.js
  28. 27
    23
      app/javascript/mastodon/locales/defaultMessages.json
  29. 1
    3
      app/javascript/mastodon/locales/en.json
  30. 1
    0
      app/javascript/mastodon/reducers/alerts.js
  31. 25
    2
      app/javascript/mastodon/reducers/compose.js
  32. 1
    0
      app/javascript/mastodon/selectors/index.js
  33. 33
    0
      app/javascript/mastodon/utils/log_out.js
  34. 7
    0
      app/javascript/styles/mailer.scss
  35. 9
    1
      app/javascript/styles/mastodon-light/diff.scss
  36. 67
    5
      app/javascript/styles/mastodon/components.scss
  37. 1
    1
      app/lib/activitypub/activity/delete.rb
  38. 3
    1
      app/mailers/user_mailer.rb
  39. 16
    6
      app/models/admin/account_action.rb
  40. 2
    1
      app/models/form/status_batch.rb
  41. 1
    1
      app/models/report.rb
  42. 5
    1
      app/models/status.rb
  43. 1
    1
      app/services/batched_remove_status_service.rb
  44. 12
    0
      app/services/remove_status_service.rb
  45. 4
    0
      app/views/admin/account_actions/new.html.haml
  46. 1
    1
      app/views/admin/dashboard/index.html.haml
  47. 4
    1
      app/views/admin/reports/_status.html.haml
  48. 7
    1
      app/views/notification_mailer/_status.html.haml
  49. 5
    1
      app/views/statuses/_detailed_status.html.haml
  50. 5
    1
      app/views/statuses/_simple_status.html.haml
  51. 26
    1
      app/views/user_mailer/warning.html.haml
  52. 13
    0
      app/views/user_mailer/warning.text.erb
  53. 2
    2
      app/workers/removal_worker.rb
  54. 3
    0
      config/locales/en.yml
  55. 3
    0
      config/locales/simple_form.en.yml
  56. 5
    0
      db/migrate/20190819134503_add_deleted_at_to_statuses.rb
  57. 13
    0
      db/migrate/20190820003045_update_statuses_index.rb
  58. 11
    0
      db/migrate/20190823221802_add_local_index_to_statuses.rb
  59. 4
    2
      db/schema.rb
  60. 4
    3
      package.json
  61. 1
    1
      spec/controllers/admin/reported_statuses_controller_spec.rb
  62. 1
    1
      spec/controllers/admin/statuses_controller_spec.rb
  63. 1
    1
      spec/mailers/previews/user_mailer_preview.rb
  64. 2
    2
      spec/models/admin/account_action_spec.rb
  65. 2
    2
      spec/models/form/status_batch_spec.rb
  66. 45
    55
      yarn.lock

+ 2
- 1
Gemfile View File

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

+ 9
- 6
Gemfile.lock View File

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

+ 2
- 2
app/controllers/admin/account_actions_controller.rb View File

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

+ 1
- 1
app/controllers/api/v1/reports_controller.rb View File

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

+ 2
- 1
app/controllers/api/v1/statuses/reblogs_controller.rb View File

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

+ 2
- 1
app/controllers/api/v1/statuses_controller.rb View File

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

+ 10
- 2
app/javascript/mastodon/actions/alerts.js View File

@@ -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}`;


+ 2
- 0
app/javascript/mastodon/actions/compose.js View File

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

+ 3
- 3
app/javascript/mastodon/components/autosuggest_hashtag.js View File

@@ -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>
);
}

+ 13
- 1
app/javascript/mastodon/components/column_back_button.js View File

@@ -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);
}
}
}


+ 13
- 1
app/javascript/mastodon/components/column_header.js View File

@@ -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);
}
}
}


+ 24
- 4
app/javascript/mastodon/components/status.js View File

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

+ 1
- 1
app/javascript/mastodon/components/status_content.js View File

@@ -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')} />}

+ 2
- 1
app/javascript/mastodon/containers/media_container.js View File

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


+ 226
- 0
app/javascript/mastodon/features/audio/index.js View File

@@ -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>
);
}

}

+ 6
- 1
app/javascript/mastodon/features/compose/components/action_bar.js View File

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

+ 2
- 1
app/javascript/mastodon/features/compose/components/navigation_bar.js View File

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

+ 19
- 1
app/javascript/mastodon/features/compose/containers/navigation_container.js View File

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

+ 20
- 1
app/javascript/mastodon/features/compose/index.js View File

@@ -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>
);
}

+ 4
- 1
app/javascript/mastodon/features/getting_started/components/trends.js View File

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

+ 14
- 1
app/javascript/mastodon/features/status/components/detailed_status.js View File

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

+ 13
- 1
app/javascript/mastodon/features/ui/components/focal_point_modal.js View File

@@ -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
/>
)}

+ 66
- 29
app/javascript/mastodon/features/ui/components/link_footer.js View File

@@ -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>
);
}

};

+ 1
- 1
app/javascript/mastodon/features/ui/containers/notifications_container.js View File

@@ -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`]);
}
}));


+ 14
- 4
app/javascript/mastodon/features/ui/index.js View File

@@ -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();

+ 4
- 0
app/javascript/mastodon/features/ui/util/async-components.js View File

@@ -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');
}

+ 1
- 1
app/javascript/mastodon/features/video/index.js View File

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

+ 27
- 23
app/javascript/mastodon/locales/defaultMessages.json View File

@@ -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"
},

+ 1
- 3
app/javascript/mastodon/locales/en.json View File

@@ -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})",

+ 1
- 0
app/javascript/mastodon/reducers/alerts.js View File

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

+ 25
- 2
app/javascript/mastodon/reducers/compose.js View File

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

+ 1
- 0
app/javascript/mastodon/selectors/index.js View File

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

+ 33
- 0
app/javascript/mastodon/utils/log_out.js View File

@@ -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();
};

+ 7
- 0
app/javascript/styles/mailer.scss View File

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

+ 9
- 1
app/javascript/styles/mastodon-light/diff.scss View File

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

+ 67
- 5
app/javascript/styles/mastodon/components.scss View File

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

+ 1
- 1
app/lib/activitypub/activity/delete.rb View File

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

+ 3
- 1
app/mailers/user_mailer.rb View File

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

+ 16
- 6
app/models/admin/account_action.rb View File

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

+ 2
- 1
app/models/form/status_batch.rb View File

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

+ 1
- 1
app/models/report.rb View File

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

+ 5
- 1
app/models/status.rb View File

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

+ 1
- 1
app/services/batched_remove_status_service.rb View File

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

+ 12
- 0
app/services/remove_status_service.rb View File

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

+ 4
- 0
app/views/admin/account_actions/new.html.haml View File

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

+ 1
- 1
app/views/admin/dashboard/index.html.haml View File

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

+ 4
- 1
app/views/admin/reports/_status.html.haml View File

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

+ 7
- 1
app/views/notification_mailer/_status.html.haml View File

@@ -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}")

+ 5
- 1
app/views/statuses/_detailed_status.html.haml View File

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

+ 5
- 1
app/views/statuses/_simple_status.html.haml View File

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

+ 26
- 1
app/views/user_mailer/warning.html.haml View File

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

+ 13
- 0
app/views/user_mailer/warning.text.erb View File

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

+ 2
- 2
app/workers/removal_worker.rb View File

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

+ 3
- 0
config/locales/en.yml View File

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

+ 3
- 0
config/locales/simple_form.en.yml View File

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

+ 5
- 0
db/migrate/20190819134503_add_deleted_at_to_statuses.rb View File

@@ -0,0 +1,5 @@
class AddDeletedAtToStatuses < ActiveRecord::Migration[5.2]
def change
add_column :statuses, :deleted_at, :datetime
end
end

+ 13
- 0
db/migrate/20190820003045_update_statuses_index.rb View File

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

+ 11
- 0
db/migrate/20190823221802_add_local_index_to_statuses.rb View File

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

+ 4
- 2
db/schema.rb View File

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

+ 4
- 3
package.json View File

@@ -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"
},

+ 1
- 1
spec/controllers/admin/reported_statuses_controller_spec.rb View File

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


+ 1
- 1
spec/controllers/admin/statuses_controller_spec.rb View File

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


+ 1
- 1
spec/mailers/previews/user_mailer_preview.rb View File

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

+ 2
- 2
spec/models/admin/account_action_spec.rb View File

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


+ 2
- 2
spec/models/form/status_batch_spec.rb View File

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

+ 45
- 55
yarn.lock View File

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