Merge pull request #1208 from ThibG/glitch-soc/merge-upstream

Merge upstream changes
main
ThibG 2019-08-30 00:18:08 +02:00 committed by GitHub
commit 2848c08953
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
88 changed files with 1351 additions and 240 deletions

View File

@ -31,7 +31,7 @@ gem 'charlock_holmes', '~> 0.7.6'
gem 'iso-639' gem 'iso-639'
gem 'chewy', '~> 5.0' gem 'chewy', '~> 5.0'
gem 'cld3', '~> 3.2.4' gem 'cld3', '~> 3.2.4'
gem 'devise', '~> 4.6' gem 'devise', '~> 4.7'
gem 'devise-two-factor', '~> 3.1' gem 'devise-two-factor', '~> 3.1'
group :pam_authentication, optional: true do group :pam_authentication, optional: true do
@ -43,6 +43,7 @@ gem 'omniauth-cas', '~> 1.1'
gem 'omniauth-saml', '~> 1.10' gem 'omniauth-saml', '~> 1.10'
gem 'omniauth', '~> 1.9' gem 'omniauth', '~> 1.9'
gem 'discard', '~> 1.1'
gem 'doorkeeper', '~> 5.1' gem 'doorkeeper', '~> 5.1'
gem 'fast_blank', '~> 1.0' gem 'fast_blank', '~> 1.0'
gem 'fastimage' gem 'fastimage'

View File

@ -127,7 +127,7 @@ GEM
brakeman (4.6.1) brakeman (4.6.1)
browser (2.6.1) browser (2.6.1)
builder (3.2.3) builder (3.2.3)
bullet (6.0.1) bullet (6.0.2)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
uniform_notifier (~> 1.11) uniform_notifier (~> 1.11)
bundler-audit (0.6.1) bundler-audit (0.6.1)
@ -188,10 +188,10 @@ GEM
rack (>= 1) rack (>= 1)
rake (> 10, < 13) rake (> 10, < 13)
thor (~> 0.19) thor (~> 0.19)
devise (4.6.2) devise (4.7.0)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
railties (>= 4.1.0, < 6.0) railties (>= 4.1.0)
responders responders
warden (~> 1.2.3) warden (~> 1.2.3)
devise-two-factor (3.1.0) devise-two-factor (3.1.0)
@ -204,6 +204,8 @@ GEM
devise (>= 4.0.0) devise (>= 4.0.0)
rpam2 (~> 4.0) rpam2 (~> 4.0)
diff-lcs (1.3) diff-lcs (1.3)
discard (1.1.0)
activerecord (>= 4.2, < 7)
docile (1.3.2) docile (1.3.2)
domain_name (0.5.20180417) domain_name (0.5.20180417)
unf (>= 0.0.5, < 1.0.0) unf (>= 0.0.5, < 1.0.0)
@ -555,7 +557,7 @@ GEM
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 1.7) unicode-display_width (>= 1.4.0, < 1.7)
rubocop-rails (2.3.0) rubocop-rails (2.3.1)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 0.72.0) rubocop (>= 0.72.0)
ruby-progressbar (1.10.1) ruby-progressbar (1.10.1)
@ -692,9 +694,10 @@ DEPENDENCIES
concurrent-ruby concurrent-ruby
connection_pool connection_pool
derailed_benchmarks derailed_benchmarks
devise (~> 4.6) devise (~> 4.7)
devise-two-factor (~> 3.1) devise-two-factor (~> 3.1)
devise_pam_authenticatable2 (~> 9.2) devise_pam_authenticatable2 (~> 9.2)
discard (~> 1.1)
doorkeeper (~> 5.1) doorkeeper (~> 5.1)
dotenv-rails (~> 2.7) dotenv-rails (~> 2.7)
fabrication (~> 2.20) fabrication (~> 2.20)

View File

@ -5,7 +5,7 @@ module Admin
before_action :set_account before_action :set_account
def new 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 @warning_presets = AccountWarningPreset.all
end end
@ -30,7 +30,7 @@ module Admin
end end
def resource_params 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 end
end end

View File

@ -21,7 +21,7 @@ class Api::V1::ReportsController < Api::BaseController
private private
def reported_status_ids def reported_status_ids
reported_account.statuses.find(status_ids).pluck(:id) reported_account.statuses.with_discarded.find(status_ids).pluck(:id)
end end
def status_ids def status_ids

View File

@ -18,6 +18,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
@reblogs_map = { @status.id => false } @reblogs_map = { @status.id => false }
authorize status_for_destroy, :unreblog? authorize status_for_destroy, :unreblog?
status_for_destroy.discard
RemovalWorker.perform_async(status_for_destroy.id) 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) 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 end
def status_for_destroy 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 end
def reblog_params def reblog_params

View File

@ -54,7 +54,8 @@ class Api::V1::StatusesController < Api::BaseController
@status = Status.where(account_id: current_user.account).find(params[:id]) @status = Status.where(account_id: current_user.account).find(params[:id])
authorize @status, :destroy? 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 render json: @status, serializer: REST::StatusSerializer, source_requested: true
end end

View File

@ -3,6 +3,8 @@ import { defineMessages } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages({
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' }, 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'; 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 { return {
type: ALERT_SHOW, type: ALERT_SHOW,
title, title,
message, message,
message_values,
}; };
}; };
export function showAlertForError(error) { export function showAlertForError(error) {
if (error.response) { if (error.response) {
const { data, status, statusText } = error.response; const { data, status, statusText, headers } = error.response;
if (status === 404 || status === 410) { if (status === 404 || status === 410) {
// Skip these errors as they are reflected in the UI // Skip these errors as they are reflected in the UI
return { type: ALERT_NOOP }; 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 message = statusText;
let title = `${status}`; let title = `${status}`;

View File

@ -10,7 +10,7 @@ import AttachmentList from './attachment_list';
import Card from '../features/status/components/card'; import Card from '../features/status/components/card';
import { injectIntl, FormattedMessage } from 'react-intl'; import { injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { MediaGallery, Video } from 'flavours/glitch/util/async-components'; import { MediaGallery, Video, Audio } from 'flavours/glitch/util/async-components';
import { HotKeys } from 'react-hotkeys'; import { HotKeys } from 'react-hotkeys';
import NotificationOverlayContainer from 'flavours/glitch/features/notifications/containers/overlay_container'; import NotificationOverlayContainer from 'flavours/glitch/features/notifications/containers/overlay_container';
import classNames from 'classnames'; import classNames from 'classnames';
@ -443,11 +443,15 @@ class Status extends ImmutablePureComponent {
} }
renderLoadingMediaGallery () { renderLoadingMediaGallery () {
return <div className='media_gallery' style={{ height: '110px' }} />; return <div className='media-gallery' style={{ height: '110px' }} />;
} }
renderLoadingVideoPlayer () { 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' }} />;
} }
render () { render () {
@ -561,7 +565,24 @@ class Status extends ImmutablePureComponent {
media={status.get('media_attachments')} media={status.get('media_attachments')}
/> />
); );
} else if (['video', 'audio'].includes(attachments.getIn([0, 'type']))) { } else if (attachments.getIn([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>
);
mediaIcon = 'music';
} else if (attachments.getIn([0, 'type']) === 'video') {
const attachment = status.getIn(['media_attachments', 0]); const attachment = status.getIn(['media_attachments', 0]);
media = ( media = (
@ -584,7 +605,7 @@ class Status extends ImmutablePureComponent {
/>)} />)}
</Bundle> </Bundle>
); );
mediaIcon = attachment.get('type') === 'video' ? 'video-camera' : 'music'; mediaIcon = 'video-camera';
} else { // Media type is 'image' or 'gifv' } else { // Media type is 'image' or 'gifv'
media = ( media = (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}> <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>

View File

@ -212,7 +212,7 @@ export default class StatusContent extends React.PureComponent {
let element = e.target; let element = e.target;
while (element) { while (element) {
if (element.localName === 'button' || element.localName === 'video' || element.localName === 'a' || element.localName === 'label') { if (['button', 'video', 'a', 'label', 'wave'].includes(element.localName)) {
return; return;
} }
element = element.parentNode; element = element.parentNode;

View File

@ -7,6 +7,7 @@ import MediaGallery from 'flavours/glitch/components/media_gallery';
import Video from 'flavours/glitch/features/video'; import Video from 'flavours/glitch/features/video';
import Card from 'flavours/glitch/features/status/components/card'; import Card from 'flavours/glitch/features/status/components/card';
import Poll from 'flavours/glitch/components/poll'; import Poll from 'flavours/glitch/components/poll';
import Audio from 'flavours/glitch/features/audio';
import ModalRoot from 'flavours/glitch/components/modal_root'; import ModalRoot from 'flavours/glitch/components/modal_root';
import MediaModal from 'flavours/glitch/features/ui/components/media_modal'; import MediaModal from 'flavours/glitch/features/ui/components/media_modal';
import { List as ImmutableList, fromJS } from 'immutable'; import { List as ImmutableList, fromJS } from 'immutable';
@ -14,7 +15,7 @@ import { List as ImmutableList, fromJS } from 'immutable';
const { localeData, messages } = getLocale(); const { localeData, messages } = getLocale();
addLocaleData(localeData); addLocaleData(localeData);
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll }; const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Audio };
export default class MediaContainer extends PureComponent { export default class MediaContainer extends PureComponent {

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 'flavours/glitch/features/video';
import Icon from 'flavours/glitch/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 icon={paused ? 'play' : 'pause'} fixedWidth /></button>
<button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon icon={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>
);
}
}

View File

@ -53,8 +53,18 @@ class Header extends ImmutablePureComponent {
showNotificationsBadge: PropTypes.bool, showNotificationsBadge: PropTypes.bool,
intl: PropTypes.object, intl: PropTypes.object,
onSettingsClick: PropTypes.func, onSettingsClick: PropTypes.func,
onLogout: PropTypes.func.isRequired,
}; };
handleLogoutClick = e => {
e.preventDefault();
e.stopPropagation();
this.props.onLogout();
return false;
}
render () { render () {
const { intl, columns, unreadNotifications, showNotificationsBadge, onSettingsClick } = this.props; const { intl, columns, unreadNotifications, showNotificationsBadge, onSettingsClick } = this.props;
@ -114,7 +124,7 @@ class Header extends ImmutablePureComponent {
><Icon icon='cogs' /></a> ><Icon icon='cogs' /></a>
<a <a
aria-label={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)}
data-method='delete' onClick={this.handleLogoutClick}
href={ signOutLink } href={ signOutLink }
title={intl.formatMessage(messages.logout)} title={intl.formatMessage(messages.logout)}
><Icon icon='sign-out' /></a> ><Icon icon='sign-out' /></a>

View File

@ -1,6 +1,13 @@
import { openModal } from 'flavours/glitch/actions/modal'; import { openModal } from 'flavours/glitch/actions/modal';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { defineMessages, injectIntl } from 'react-intl';
import Header from '../components/header'; import Header from '../components/header';
import { logOut } from 'flavours/glitch/util/log_out';
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 => { const mapStateToProps = state => {
return { return {
@ -16,6 +23,13 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
e.stopPropagation(); e.stopPropagation();
dispatch(openModal('SETTINGS', {})); dispatch(openModal('SETTINGS', {}));
}, },
onLogout () {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.logoutMessage),
confirm: intl.formatMessage(messages.logoutConfirm),
onConfirm: () => logOut(),
}));
},
}); });
export default connect(mapStateToProps, mapDispatchToProps)(Header); export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(Header));

View File

@ -13,7 +13,7 @@ import { fetchFollowRequests } from 'flavours/glitch/actions/accounts';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import { fetchLists } from 'flavours/glitch/actions/lists'; import { fetchLists } from 'flavours/glitch/actions/lists';
import { preferencesLink, signOutLink } from 'flavours/glitch/util/backend_links'; import { preferencesLink } from 'flavours/glitch/util/backend_links';
import NavigationBar from '../compose/components/navigation_bar'; import NavigationBar from '../compose/components/navigation_bar';
import LinkFooter from 'flavours/glitch/features/ui/components/link_footer'; import LinkFooter from 'flavours/glitch/features/ui/components/link_footer';
@ -30,7 +30,6 @@ const messages = defineMessages({
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' }, settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
sign_out: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' }, keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' }, lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
@ -174,7 +173,6 @@ const NAVIGATION_PANEL_BREAKPOINT = 600 + (285 * 2) + (10 * 2);
<ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} /> <ColumnSubheading text={intl.formatMessage(messages.settings_subheading)} />
{ preferencesLink !== undefined && <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href={preferencesLink} /> } { preferencesLink !== undefined && <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href={preferencesLink} /> }
<ColumnLink icon='cogs' text={intl.formatMessage(messages.settings)} onClick={openSettings} /> <ColumnLink icon='cogs' text={intl.formatMessage(messages.settings)} onClick={openSettings} />
<ColumnLink icon='sign-out' text={intl.formatMessage(messages.sign_out)} href={signOutLink} method='delete' />
</div> </div>
<LinkFooter /> <LinkFooter />

View File

@ -11,6 +11,7 @@ import { FormattedDate, FormattedNumber } from 'react-intl';
import Card from './card'; import Card from './card';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import Video from 'flavours/glitch/features/video'; import Video from 'flavours/glitch/features/video';
import Audio from 'flavours/glitch/features/audio';
import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon'; import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon';
import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task'; import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task';
import classNames from 'classnames'; import classNames from 'classnames';
@ -131,7 +132,20 @@ export default class DetailedStatus extends ImmutablePureComponent {
} else if (status.get('media_attachments').size > 0) { } else if (status.get('media_attachments').size > 0) {
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
media = <AttachmentList media={status.get('media_attachments')} />; media = <AttachmentList 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 = (
<Audio
src={attachment.get('url')}
alt={attachment.get('description')}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
height={110}
preload
/>
);
mediaIcon = 'music';
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const attachment = status.getIn(['media_attachments', 0]); const attachment = status.getIn(['media_attachments', 0]);
media = ( media = (
<Video <Video
@ -150,7 +164,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
onToggleVisibility={this.props.onToggleMediaVisibility} onToggleVisibility={this.props.onToggleMediaVisibility}
/> />
); );
mediaIcon = attachment.get('type') === 'video' ? 'video-camera' : 'music'; mediaIcon = 'video-camera';
} else { } else {
media = ( media = (
<MediaGallery <MediaGallery

View File

@ -10,6 +10,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import IconButton from 'flavours/glitch/components/icon_button'; import IconButton from 'flavours/glitch/components/icon_button';
import Button from 'flavours/glitch/components/button'; import Button from 'flavours/glitch/components/button';
import Video from 'flavours/glitch/features/video'; import Video from 'flavours/glitch/features/video';
import Audio from 'flavours/glitch/features/audio';
import Textarea from 'react-textarea-autosize'; import Textarea from 'react-textarea-autosize';
import UploadProgress from 'flavours/glitch/features/compose/components/upload_progress'; import UploadProgress from 'flavours/glitch/features/compose/components/upload_progress';
import CharacterCounter from 'flavours/glitch/features/compose/components/character_counter'; import CharacterCounter from 'flavours/glitch/features/compose/components/character_counter';
@ -244,12 +245,23 @@ class FocalPointModal extends ImmutablePureComponent {
</div> </div>
)} )}
{['audio', 'video'].includes(media.get('type')) && ( {media.get('type') === 'video' && (
<Video <Video
preview={media.get('preview_url')} preview={media.get('preview_url')}
blurhash={media.get('blurhash')} blurhash={media.get('blurhash')}
src={media.get('url')} src={media.get('url')}
detailed detailed
inline
editable
/>
)}
{media.get('type') === 'audio' && (
<Audio
src={media.get('url')}
duration={media.getIn(['meta', 'original', 'duration'], 0)}
height={150}
preload
editable editable
/> />
)} )}

View File

@ -1,36 +1,71 @@
import { connect } from 'react-redux';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { invitesEnabled, version, repository, source_url } from 'flavours/glitch/util/initial_state'; import { invitesEnabled, version, repository, source_url } from 'flavours/glitch/util/initial_state';
import { signOutLink } from 'flavours/glitch/util/backend_links'; import { signOutLink } from 'flavours/glitch/util/backend_links';
import { logOut } from 'flavours/glitch/util/log_out';
import { openModal } from 'flavours/glitch/actions/modal';
const LinkFooter = () => ( const messages = defineMessages({
<div className='getting-started__footer'> logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
<ul> logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
{invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </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={signOutLink} data-method='delete'><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
</ul>
<p> const mapDispatchToProps = (dispatch, { intl }) => ({
<FormattedMessage onLogout () {
id='getting_started.open_source_notice' dispatch(openModal('CONFIRM', {
defaultMessage='Glitchsoc is open source software, a friendly fork of {Mastodon}. You can contribute or report issues on GitHub at {github}.' message: intl.formatMessage(messages.logoutMessage),
values={{ confirm: intl.formatMessage(messages.logoutConfirm),
github: <span><a href='https://github.com/glitch-soc/mastodon' rel='noopener' target='_blank'>glitch-soc/mastodon</a> (v{version})</span>, onConfirm: () => logOut(),
Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>Mastodon</a> }} }));
/> },
</p> });
</div>
); export default @injectIntl
@connect(null, mapDispatchToProps)
class LinkFooter extends React.PureComponent {
static propTypes = {
onLogout: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleLogoutClick = e => {
e.preventDefault();
e.stopPropagation();
this.props.onLogout();
return false;
}
render () {
return (
<div className='getting-started__footer'>
<ul>
{invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </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={signOutLink} onClick={this.handleLogoutClick}><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
</ul>
<p>
<FormattedMessage
id='getting_started.open_source_notice'
defaultMessage='Glitchsoc is open source software, a friendly fork of {Mastodon}. You can contribute or report issues on GitHub at {github}.'
values={{
github: <span><a href='https://github.com/glitch-soc/mastodon' rel='noopener' target='_blank'>glitch-soc/mastodon</a> (v{version})</span>,
Mastodon: <a href='https://github.com/tootsuite/mastodon' rel='noopener' target='_blank'>Mastodon</a> }}
/>
</p>
</div>
);
}
LinkFooter.propTypes = {
}; };
export default LinkFooter;

View File

@ -11,7 +11,7 @@ const mapStateToProps = (state, { intl }) => {
const value = notification[key]; const value = notification[key];
if (typeof value === 'object') { if (typeof value === 'object') {
notification[key] = intl.formatMessage(value); notification[key] = intl.formatMessage(value, notification[`${key}_values`]);
} }
})); }));

View File

@ -138,14 +138,24 @@ class SwitchingColumnsArea extends React.PureComponent {
window.removeEventListener('resize', this.handleResize); window.removeEventListener('resize', this.handleResize);
} }
handleResize = debounce(() => { handleLayoutChange = debounce(() => {
// The cached heights are no longer accurate, invalidate // The cached heights are no longer accurate, invalidate
this.props.onLayoutChange(); this.props.onLayoutChange();
this.setState({ mobile: isMobile(window.innerWidth, this.props.layout) });
}, 500, { }, 500, {
trailing: true, trailing: true,
}); })
handleResize = () => {
const mobile = isMobile(window.innerWidth, this.props.layout);
if (mobile !== this.state.mobile) {
this.handleLayoutChange.cancel();
this.props.onLayoutChange();
this.setState({ mobile });
} else {
this.handleLayoutChange();
}
}
setRef = c => { setRef = c => {
this.node = c.getWrappedInstance(); this.node = c.getWrappedInstance();

View File

@ -20,7 +20,7 @@ const messages = defineMessages({
exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' }, exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
}); });
const formatTime = secondsNum => { export const formatTime = secondsNum => {
let hours = Math.floor(secondsNum / 3600); let hours = Math.floor(secondsNum / 3600);
let minutes = Math.floor((secondsNum - (hours * 3600)) / 60); let minutes = Math.floor((secondsNum - (hours * 3600)) / 60);
let seconds = secondsNum - (hours * 3600) - (minutes * 60); let seconds = secondsNum - (hours * 3600) - (minutes * 60);

View File

@ -14,6 +14,7 @@ export default function alerts(state = initialState, action) {
key: state.size > 0 ? state.last().get('key') + 1 : 0, key: state.size > 0 ? state.last().get('key') + 1 : 0,
title: action.title, title: action.title,
message: action.message, message: action.message,
message_values: action.message_values,
})); }));
case ALERT_DISMISS: case ALERT_DISMISS:
return state.filterNot(item => item.get('key') === action.alert.key); return state.filterNot(item => item.get('key') === action.alert.key);

View File

@ -157,6 +157,7 @@ export const getAlerts = createSelector([getAlertsBase], (base) => {
base.forEach(item => { base.forEach(item => {
arr.push({ arr.push({
message: item.get('message'), message: item.get('message'),
message_values: item.get('message_values'),
title: item.get('title'), title: item.get('title'),
key: item.get('key'), key: item.get('key'),
dismissAfter: 5000, dismissAfter: 5000,

View File

@ -333,15 +333,63 @@
} }
.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 { .video-player {
overflow: hidden; overflow: hidden;
position: relative; position: relative;
background: $base-shadow-color; background: $base-shadow-color;
max-width: 100%; max-width: 100%;
border-radius: 4px; border-radius: 4px;
box-sizing: border-box;
&.editable { &.editable {
border-radius: 0; border-radius: 0;
height: 100% !important;
} }
&:focus { &:focus {

View File

@ -107,7 +107,8 @@
padding: 15px; padding: 15px;
.media-gallery, .media-gallery,
.video-player { .video-player,
.audio-player {
margin-top: 15px; margin-top: 15px;
} }
} }
@ -131,7 +132,8 @@
.media-gallery, .media-gallery,
&__action-bar, &__action-bar,
.video-player { .video-player,
.audio-player {
margin-top: 10px; margin-top: 10px;
} }
} }

View File

@ -263,7 +263,8 @@
opacity: 1; opacity: 1;
animation: fade 150ms linear; animation: fade 150ms linear;
.video-player { .video-player,
.audio-player {
margin-top: 8px; margin-top: 8px;
} }
@ -453,7 +454,8 @@
white-space: normal; white-space: normal;
} }
.video-player { .video-player,
.audio-player {
margin-top: 8px; margin-top: 8px;
max-width: 250px; max-width: 250px;
} }
@ -561,7 +563,8 @@
} }
} }
.video-player { .video-player,
.audio-player {
margin-top: 8px; margin-top: 8px;
} }
} }

View File

@ -372,3 +372,10 @@
.directory__tag > div { .directory__tag > div {
box-shadow: none; 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;
}

View File

@ -138,6 +138,10 @@ export function Video () {
return import(/* webpackChunkName: "flavours/glitch/async/video" */'flavours/glitch/features/video'); return import(/* webpackChunkName: "flavours/glitch/async/video" */'flavours/glitch/features/video');
} }
export function Audio () {
return import(/* webpackChunkName: "features/glitch/async/audio" */'flavours/glitch/features/audio');
}
export function EmbedModal () { export function EmbedModal () {
return import(/* webpackChunkName: "flavours/glitch/async/embed_modal" */'flavours/glitch/features/ui/components/embed_modal'); return import(/* webpackChunkName: "flavours/glitch/async/embed_modal" */'flavours/glitch/features/ui/components/embed_modal');
} }

View File

@ -0,0 +1,34 @@
import Rails from 'rails-ujs';
import { signOutLink } from 'flavours/glitch/util/backend_links';
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 = signOutLink;
form.style.display = 'none';
document.body.appendChild(form);
submitButton.click();
};

View File

@ -3,6 +3,8 @@ import { defineMessages } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages({
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' }, unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' }, 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'; 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 { return {
type: ALERT_SHOW, type: ALERT_SHOW,
title, title,
message, message,
message_values,
}; };
}; };
export function showAlertForError(error) { export function showAlertForError(error) {
if (error.response) { if (error.response) {
const { data, status, statusText } = error.response; const { data, status, statusText, headers } = error.response;
if (status === 404 || status === 410) { if (status === 404 || status === 410) {
// Skip these errors as they are reflected in the UI // Skip these errors as they are reflected in the UI
return { type: ALERT_NOOP }; 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 message = statusText;
let title = `${status}`; let title = `${status}`;

View File

@ -356,6 +356,8 @@ const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => {
cancelFetchComposeSuggestionsTags(); cancelFetchComposeSuggestionsTags();
} }
dispatch(updateSuggestionTags(token));
api(getState).get('/api/v2/search', { api(getState).get('/api/v2/search', {
cancelToken: new CancelToken(cancel => { cancelToken: new CancelToken(cancel => {
cancelFetchComposeSuggestionsTags = cancel; cancelFetchComposeSuggestionsTags = cancel;

View File

@ -9,18 +9,18 @@ export default class AutosuggestHashtag extends React.PureComponent {
tag: PropTypes.shape({ tag: PropTypes.shape({
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
url: PropTypes.string, url: PropTypes.string,
history: PropTypes.array.isRequired, history: PropTypes.array,
}).isRequired, }).isRequired,
}; };
render () { render () {
const { tag } = this.props; 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 ( return (
<div className='autosuggest-hashtag'> <div className='autosuggest-hashtag'>
<div className='autosuggest-hashtag__name'>#<strong>{tag.name}</strong></div> <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> </div>
); );
} }

View File

@ -35,7 +35,19 @@ export default class ColumnBackButton extends React.PureComponent {
if (multiColumn) { if (multiColumn) {
return component; return component;
} else { } 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);
}
} }
} }

View File

@ -178,7 +178,19 @@ class ColumnHeader extends React.PureComponent {
if (multiColumn || placeholder) { if (multiColumn || placeholder) {
return component; return component;
} else { } 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);
}
} }
} }

View File

@ -12,7 +12,7 @@ import AttachmentList from './attachment_list';
import Card from '../features/status/components/card'; import Card from '../features/status/components/card';
import { injectIntl, FormattedMessage } from 'react-intl'; import { injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; 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 { HotKeys } from 'react-hotkeys';
import classNames from 'classnames'; import classNames from 'classnames';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
@ -199,11 +199,15 @@ class Status extends ImmutablePureComponent {
}; };
renderLoadingMediaGallery () { renderLoadingMediaGallery () {
return <div className='media_gallery' style={{ height: '110px' }} />; return <div className='media-gallery' style={{ height: '110px' }} />;
} }
renderLoadingVideoPlayer () { 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) => { handleOpenVideo = (media, startTime) => {
@ -348,7 +352,23 @@ class Status extends ImmutablePureComponent {
media={status.get('media_attachments')} 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]); const attachment = status.getIn(['media_attachments', 0]);
media = ( media = (

View File

@ -230,7 +230,7 @@ export default class StatusContent extends React.PureComponent {
); );
} else if (this.props.onClick) { } else if (this.props.onClick) {
const output = [ 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')} /> <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')} />} {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}

View File

@ -8,6 +8,7 @@ import Video from '../features/video';
import Card from '../features/status/components/card'; import Card from '../features/status/components/card';
import Poll from 'mastodon/components/poll'; import Poll from 'mastodon/components/poll';
import Hashtag from 'mastodon/components/hashtag'; import Hashtag from 'mastodon/components/hashtag';
import Audio from 'mastodon/features/audio';
import ModalRoot from '../components/modal_root'; import ModalRoot from '../components/modal_root';
import { getScrollbarWidth } from '../features/ui/components/modal_root'; import { getScrollbarWidth } from '../features/ui/components/modal_root';
import MediaModal from '../features/ui/components/media_modal'; import MediaModal from '../features/ui/components/media_modal';
@ -16,7 +17,7 @@ import { List as ImmutableList, fromJS } from 'immutable';
const { localeData, messages } = getLocale(); const { localeData, messages } = getLocale();
addLocaleData(localeData); 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 { export default class MediaContainer extends PureComponent {

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

View File

@ -23,9 +23,14 @@ class ActionBar extends React.PureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.map.isRequired,
onLogout: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
handleLogout = () => {
this.props.onLogout();
}
render () { render () {
const { intl } = this.props; 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.domain_blocks), to: '/domain_blocks' });
menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' }); menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
menu.push(null); 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 ( return (
<div className='compose__action-bar'> <div className='compose__action-bar'>

View File

@ -12,6 +12,7 @@ export default class NavigationBar extends ImmutablePureComponent {
static propTypes = { static propTypes = {
account: ImmutablePropTypes.map.isRequired, account: ImmutablePropTypes.map.isRequired,
onLogout: PropTypes.func.isRequired,
onClose: PropTypes.func, onClose: PropTypes.func,
}; };
@ -33,7 +34,7 @@ export default class NavigationBar extends ImmutablePureComponent {
<div className='navigation-bar__actions'> <div className='navigation-bar__actions'>
<IconButton className='close' title='' icon='close' onClick={this.props.onClose} /> <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>
</div> </div>
); );

View File

@ -1,11 +1,29 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { defineMessages, injectIntl } from 'react-intl';
import NavigationBar from '../components/navigation_bar'; import NavigationBar from '../components/navigation_bar';
import { logOut } from 'mastodon/utils/log_out';
import { openModal } from 'mastodon/actions/modal';
import { me } from '../../../initial_state'; 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 => { const mapStateToProps = state => {
return { return {
account: state.getIn(['accounts', me]), 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));

View File

@ -12,9 +12,11 @@ import Motion from '../ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import SearchResultsContainer from './containers/search_results_container'; import SearchResultsContainer from './containers/search_results_container';
import { changeComposing } from '../../actions/compose'; import { changeComposing } from '../../actions/compose';
import { openModal } from 'mastodon/actions/modal';
import elephantUIPlane from '../../../images/elephant_ui_plane.svg'; import elephantUIPlane from '../../../images/elephant_ui_plane.svg';
import { mascot } from '../../initial_state'; import { mascot } from '../../initial_state';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import { logOut } from 'mastodon/utils/log_out';
const messages = defineMessages({ const messages = defineMessages({
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' }, start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
@ -25,6 +27,8 @@ const messages = defineMessages({
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' }, logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new toot' }, 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) => ({ 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 = () => { onFocus = () => {
this.props.dispatch(changeComposing(true)); 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> <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='/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> </nav>
); );
} }

View File

@ -3,6 +3,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import Hashtag from 'mastodon/components/hashtag'; import Hashtag from 'mastodon/components/hashtag';
import { FormattedMessage } from 'react-intl';
export default class Trends extends ImmutablePureComponent { export default class Trends extends ImmutablePureComponent {
@ -17,7 +18,7 @@ export default class Trends extends ImmutablePureComponent {
componentDidMount () { componentDidMount () {
this.props.fetchTrends(); this.props.fetchTrends();
this.refreshInterval = setInterval(() => this.props.fetchTrends(), 36000); this.refreshInterval = setInterval(() => this.props.fetchTrends(), 900 * 1000);
} }
componentWillUnmount () { componentWillUnmount () {
@ -35,6 +36,8 @@ export default class Trends extends ImmutablePureComponent {
return ( return (
<div className='getting-started__trends'> <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} />)} {trends.take(3).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
</div> </div>
); );

View File

@ -10,6 +10,7 @@ import { FormattedDate, FormattedNumber } from 'react-intl';
import Card from './card'; import Card from './card';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import Video from '../../video'; import Video from '../../video';
import Audio from '../../audio';
import scheduleIdleTask from '../../ui/util/schedule_idle_task'; import scheduleIdleTask from '../../ui/util/schedule_idle_task';
import classNames from 'classnames'; import classNames from 'classnames';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
@ -107,7 +108,19 @@ export default class DetailedStatus extends ImmutablePureComponent {
} }
if (status.get('media_attachments').size > 0) { 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]); const attachment = status.getIn(['media_attachments', 0]);
media = ( media = (

View File

@ -10,6 +10,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import IconButton from 'mastodon/components/icon_button'; import IconButton from 'mastodon/components/icon_button';
import Button from 'mastodon/components/button'; import Button from 'mastodon/components/button';
import Video from 'mastodon/features/video'; import Video from 'mastodon/features/video';
import Audio from 'mastodon/features/audio';
import Textarea from 'react-textarea-autosize'; import Textarea from 'react-textarea-autosize';
import UploadProgress from 'mastodon/features/compose/components/upload_progress'; import UploadProgress from 'mastodon/features/compose/components/upload_progress';
import CharacterCounter from 'mastodon/features/compose/components/character_counter'; import CharacterCounter from 'mastodon/features/compose/components/character_counter';
@ -244,12 +245,23 @@ class FocalPointModal extends ImmutablePureComponent {
</div> </div>
)} )}
{['audio', 'video'].includes(media.get('type')) && ( {media.get('type') === 'video' && (
<Video <Video
preview={media.get('preview_url')} preview={media.get('preview_url')}
blurhash={media.get('blurhash')} blurhash={media.get('blurhash')}
src={media.get('url')} src={media.get('url')}
detailed detailed
inline
editable
/>
)}
{media.get('type') === 'audio' && (
<Audio
src={media.get('url')}
duration={media.getIn(['meta', 'original', 'duration'], 0)}
height={150}
preload
editable editable
/> />
)} )}

View File

@ -1,35 +1,72 @@
import { connect } from 'react-redux';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { invitesEnabled, version, repository, source_url } from 'mastodon/initial_state'; 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 }) => ( const messages = defineMessages({
<div className='getting-started__footer'> logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
<ul> logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
{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> const mapDispatchToProps = (dispatch, { intl }) => ({
<FormattedMessage onLogout () {
id='getting_started.open_source_notice' dispatch(openModal('CONFIRM', {
defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}.' message: intl.formatMessage(messages.logoutMessage),
values={{ github: <span><a href={source_url} rel='noopener' target='_blank'>{repository}</a> (v{version})</span> }} confirm: intl.formatMessage(messages.logoutConfirm),
/> onConfirm: () => logOut(),
</p> }));
</div> },
); });
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();
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>
);
}
LinkFooter.propTypes = {
withHotkeys: PropTypes.bool,
}; };
export default LinkFooter;

View File

@ -11,7 +11,7 @@ const mapStateToProps = (state, { intl }) => {
const value = notification[key]; const value = notification[key];
if (typeof value === 'object') { if (typeof value === 'object') {
notification[key] = intl.formatMessage(value); notification[key] = intl.formatMessage(value, notification[`${key}_values`]);
} }
})); }));

View File

@ -141,14 +141,24 @@ class SwitchingColumnsArea extends React.PureComponent {
return location.state !== previewMediaState && location.state !== previewVideoState; return location.state !== previewMediaState && location.state !== previewVideoState;
} }
handleResize = debounce(() => { handleLayoutChange = debounce(() => {
// The cached heights are no longer accurate, invalidate // The cached heights are no longer accurate, invalidate
this.props.onLayoutChange(); this.props.onLayoutChange();
this.setState({ mobile: isMobile(window.innerWidth) });
}, 500, { }, 500, {
trailing: true, 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 => { setRef = c => {
this.node = c.getWrappedInstance(); this.node = c.getWrappedInstance();

View File

@ -137,3 +137,7 @@ export function Search () {
export function Tesseract () { export function Tesseract () {
return import(/*webpackChunkName: "tesseract" */'tesseract.js'); return import(/*webpackChunkName: "tesseract" */'tesseract.js');
} }
export function Audio () {
return import(/* webpackChunkName: "features/audio" */'../../audio');
}

View File

@ -21,7 +21,7 @@ const messages = defineMessages({
exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' }, exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
}); });
const formatTime = secondsNum => { export const formatTime = secondsNum => {
let hours = Math.floor(secondsNum / 3600); let hours = Math.floor(secondsNum / 3600);
let minutes = Math.floor((secondsNum - (hours * 3600)) / 60); let minutes = Math.floor((secondsNum - (hours * 3600)) / 60);
let seconds = secondsNum - (hours * 3600) - (minutes * 60); let seconds = secondsNum - (hours * 3600) - (minutes * 60);

View File

@ -741,6 +741,27 @@
], ],
"path": "app/javascript/mastodon/features/account/components/header.json" "path": "app/javascript/mastodon/features/account/components/header.json"
}, },
{
"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": [ "descriptors": [
{ {
@ -1096,15 +1117,6 @@
], ],
"path": "app/javascript/mastodon/features/compose/components/upload_form.json" "path": "app/javascript/mastodon/features/compose/components/upload_form.json"
}, },
{
"descriptors": [
{
"defaultMessage": "Uploading...",
"id": "upload_progress.label"
}
],
"path": "app/javascript/mastodon/features/compose/components/upload_progress.json"
},
{ {
"descriptors": [ "descriptors": [
{ {
@ -1317,8 +1329,8 @@
{ {
"descriptors": [ "descriptors": [
{ {
"defaultMessage": "Refresh", "defaultMessage": "Trending now",
"id": "trends.refresh" "id": "trends.trending_now"
} }
], ],
"path": "app/javascript/mastodon/features/getting_started/components/trends.json" "path": "app/javascript/mastodon/features/getting_started/components/trends.json"
@ -1456,6 +1468,10 @@
}, },
{ {
"descriptors": [ "descriptors": [
{
"defaultMessage": "Basic",
"id": "home.column_settings.basic"
},
{ {
"defaultMessage": "Show boosts", "defaultMessage": "Show boosts",
"id": "home.column_settings.show_reblogs" "id": "home.column_settings.show_reblogs"
@ -1837,14 +1853,6 @@
"defaultMessage": "Push notifications", "defaultMessage": "Push notifications",
"id": "notifications.column_settings.push" "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", "defaultMessage": "Quick filter bar",
"id": "notifications.column_settings.filter_bar.category" "id": "notifications.column_settings.filter_bar.category"
@ -1903,10 +1911,6 @@
}, },
{ {
"descriptors": [ "descriptors": [
{
"defaultMessage": "and {count, plural, one {# other} other {# others}}",
"id": "notification.and_n_others"
},
{ {
"defaultMessage": "{name} followed you", "defaultMessage": "{name} followed you",
"id": "notification.follow" "id": "notification.follow"

View File

@ -162,7 +162,6 @@
"home.column_settings.basic": "Basic", "home.column_settings.basic": "Basic",
"home.column_settings.show_reblogs": "Show boosts", "home.column_settings.show_reblogs": "Show boosts",
"home.column_settings.show_replies": "Show replies", "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.days": "{number, plural, one {# day} other {# days}}",
"intervals.full.hours": "{number, plural, one {# hour} other {# hours}}", "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}",
"intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}", "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}",
@ -258,7 +257,6 @@
"navigation_bar.profile_directory": "Profile directory", "navigation_bar.profile_directory": "Profile directory",
"navigation_bar.public_timeline": "Federated timeline", "navigation_bar.public_timeline": "Federated timeline",
"navigation_bar.security": "Security", "navigation_bar.security": "Security",
"notification.and_n_others": "and {count, plural, one {# other} other {# others}}",
"notification.favourite": "{name} favourited your status", "notification.favourite": "{name} favourited your status",
"notification.follow": "{name} followed you", "notification.follow": "{name} followed you",
"notification.mention": "{name} mentioned you", "notification.mention": "{name} mentioned you",
@ -378,7 +376,7 @@
"time_remaining.moments": "Moments remaining", "time_remaining.moments": "Moments remaining",
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left", "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking", "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.", "ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
"upload_area.title": "Drag & drop to upload", "upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media ({formats})", "upload_button.label": "Add media ({formats})",

View File

@ -14,6 +14,7 @@ export default function alerts(state = initialState, action) {
key: state.size > 0 ? state.last().get('key') + 1 : 0, key: state.size > 0 ? state.last().get('key') + 1 : 0,
title: action.title, title: action.title,
message: action.message, message: action.message,
message_values: action.message_values,
})); }));
case ALERT_DISMISS: case ALERT_DISMISS:
return state.filterNot(item => item.get('key') === action.alert.key); return state.filterNot(item => item.get('key') === action.alert.key);

View File

@ -17,6 +17,7 @@ import {
COMPOSE_SUGGESTIONS_CLEAR, COMPOSE_SUGGESTIONS_CLEAR,
COMPOSE_SUGGESTIONS_READY, COMPOSE_SUGGESTIONS_READY,
COMPOSE_SUGGESTION_SELECT, COMPOSE_SUGGESTION_SELECT,
COMPOSE_SUGGESTION_TAGS_UPDATE,
COMPOSE_TAG_HISTORY_UPDATE, COMPOSE_TAG_HISTORY_UPDATE,
COMPOSE_SENSITIVITY_CHANGE, COMPOSE_SENSITIVITY_CHANGE,
COMPOSE_SPOILERNESS_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; 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) { if (accounts) {
return accounts.map(item => ({ id: item.id, type: 'account' })); return accounts.map(item => ({ id: item.id, type: 'account' }));
} else if (emojis) { } else if (emojis) {
return emojis.map(item => ({ ...item, type: 'emoji' })); return emojis.map(item => ({ ...item, type: 'emoji' }));
} else { } 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) { export default function compose(state = initialState, action) {
switch(action.type) { switch(action.type) {
case STORE_HYDRATE: 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); return state.set('suggestions', ImmutableList(normalizeSuggestions(state, action))).set('suggestion_token', action.token);
case COMPOSE_SUGGESTION_SELECT: case COMPOSE_SUGGESTION_SELECT:
return insertSuggestion(state, action.position, action.token, action.completion, action.path); 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: case COMPOSE_TAG_HISTORY_UPDATE:
return state.set('tagHistory', fromJS(action.tags)); return state.set('tagHistory', fromJS(action.tags));
case TIMELINE_DELETE: case TIMELINE_DELETE:

View File

@ -128,6 +128,7 @@ export const getAlerts = createSelector([getAlertsBase], (base) => {
base.forEach(item => { base.forEach(item => {
arr.push({ arr.push({
message: item.get('message'), message: item.get('message'),
message_values: item.get('message_values'),
title: item.get('title'), title: item.get('title'),
key: item.get('key'), key: item.get('key'),
dismissAfter: 5000, dismissAfter: 5000,

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

View File

@ -457,6 +457,13 @@ h5 {
.status { .status {
padding-bottom: 32px; padding-bottom: 32px;
&--highlighted {
border: 1px solid lighten($ui-base-color, 8%);
border-radius: 4px;
padding-bottom: 16px;
margin-bottom: 16px;
}
.status-header { .status-header {
td { td {
font-size: 14px; font-size: 14px;

View File

@ -104,7 +104,8 @@ html {
.box-widget input[type="email"], .box-widget input[type="email"],
.box-widget input[type="password"], .box-widget input[type="password"],
.box-widget textarea, .box-widget textarea,
.statuses-grid .detailed-status { .statuses-grid .detailed-status,
.audio-player {
border: 1px solid lighten($ui-base-color, 8%); border: 1px solid lighten($ui-base-color, 8%);
} }
@ -700,3 +701,10 @@ html {
.compose-form .compose-form__warning { .compose-form .compose-form__warning {
box-shadow: none; 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;
}

View File

@ -948,7 +948,8 @@
opacity: 1; opacity: 1;
animation: fade 150ms linear; animation: fade 150ms linear;
.video-player { .video-player,
.audio-player {
margin-top: 8px; margin-top: 8px;
} }
@ -1043,7 +1044,8 @@
white-space: normal; white-space: normal;
} }
.video-player { .video-player,
.audio-player {
margin-top: 8px; margin-top: 8px;
max-width: 250px; max-width: 250px;
} }
@ -1154,7 +1156,8 @@
} }
} }
.video-player { .video-player,
.audio-player {
margin-top: 8px; margin-top: 8px;
} }
} }
@ -2130,7 +2133,8 @@ a.account__display-name {
padding: 15px; padding: 15px;
.media-gallery, .media-gallery,
.video-player { .video-player,
.audio-player {
margin-top: 15px; margin-top: 15px;
} }
} }
@ -2172,7 +2176,8 @@ a.account__display-name {
.media-gallery, .media-gallery,
&__action-bar, &__action-bar,
.video-player { .video-player,
.audio-player {
margin-top: 10px; margin-top: 10px;
} }
} }
@ -2765,6 +2770,15 @@ a.account__display-name {
animation: fade 150ms linear; animation: fade 150ms linear;
margin-top: 10px; 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) { @media screen and (max-height: 810px) {
.trends__item:nth-child(3) { .trends__item:nth-child(3) {
display: none; 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 { .video-player {
overflow: hidden; overflow: hidden;
position: relative; position: relative;
background: $base-shadow-color; background: $base-shadow-color;
max-width: 100%; max-width: 100%;
border-radius: 4px; border-radius: 4px;
box-sizing: border-box;
&.editable { &.editable {
border-radius: 0; border-radius: 0;
height: 100% !important;
} }
&:focus { &:focus {

View File

@ -70,7 +70,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
end end
def delete_now! def delete_now!
RemoveStatusService.new.call(@status) RemoveStatusService.new.call(@status, redraft: false)
end end
def payload def payload

View File

@ -5,6 +5,7 @@ class UserMailer < Devise::Mailer
helper :application helper :application
helper :instance helper :instance
helper :statuses
add_template_helper RoutingHelper add_template_helper RoutingHelper
@ -79,10 +80,11 @@ class UserMailer < Devise::Mailer
end end
end end
def warning(user, warning) def warning(user, warning, status_ids = nil)
@resource = user @resource = user
@warning = warning @warning = warning
@instance = Rails.configuration.x.local_domain @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 I18n.with_locale(@resource.locale || I18n.default_locale) do
mail to: @resource.email, mail to: @resource.email,

View File

@ -19,20 +19,25 @@ class Admin::AccountAction
:report_id, :report_id,
:warning_preset_id :warning_preset_id
attr_reader :warning, :send_email_notification attr_reader :warning, :send_email_notification, :include_statuses
def send_email_notification=(value) def send_email_notification=(value)
@send_email_notification = ActiveModel::Type::Boolean.new.cast(value) @send_email_notification = ActiveModel::Type::Boolean.new.cast(value)
end end
def include_statuses=(value)
@include_statuses = ActiveModel::Type::Boolean.new.cast(value)
end
def save! def save!
ApplicationRecord.transaction do ApplicationRecord.transaction do
process_action! process_action!
process_warning! process_warning!
end end
queue_email! process_email!
process_reports! process_reports!
process_queue!
end end
def report def report
@ -110,7 +115,6 @@ class Admin::AccountAction
authorize(target_account, :suspend?) authorize(target_account, :suspend?)
log_action(:suspend, target_account) log_action(:suspend, target_account)
target_account.suspend! target_account.suspend!
queue_suspension_worker!
end end
def text_for_warning def text_for_warning
@ -121,16 +125,22 @@ class Admin::AccountAction
Admin::SuspensionWorker.perform_async(target_account.id) Admin::SuspensionWorker.perform_async(target_account.id)
end end
def queue_email! def process_queue!
return unless warnable? 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 end
def warnable? def warnable?
send_email_notification && target_account.local? send_email_notification && target_account.local?
end end
def status_ids
@report.status_ids if @report && include_statuses
end
def warning_preset def warning_preset
@warning_preset ||= AccountWarningPreset.find(warning_preset_id) if warning_preset_id.present? @warning_preset ||= AccountWarningPreset.find(warning_preset_id) if warning_preset_id.present?
end end

View File

@ -34,7 +34,8 @@ class Form::StatusBatch
def delete_statuses def delete_statuses
Status.where(id: status_ids).reorder(nil).find_each do |status| 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) Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true)
log_action :destroy, status log_action :destroy, status
end end

View File

@ -43,7 +43,7 @@ class Report < ApplicationRecord
end end
def statuses 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 end
def media_attachments def media_attachments

View File

@ -25,15 +25,19 @@
# full_status_text :text default(""), not null # full_status_text :text default(""), not null
# poll_id :bigint(8) # poll_id :bigint(8)
# content_type :string # content_type :string
# deleted_at :datetime
# #
class Status < ApplicationRecord class Status < ApplicationRecord
before_destroy :unlink_from_conversations before_destroy :unlink_from_conversations
include Discard::Model
include Paginable include Paginable
include Cacheable include Cacheable
include StatusThreadingConcern include StatusThreadingConcern
self.discard_column = :deleted_at
# If `override_timestamps` is set at creation time, Snowflake ID creation # If `override_timestamps` is set at creation time, Snowflake ID creation
# will be based on current time instead of `created_at` # will be based on current time instead of `created_at`
attr_accessor :override_timestamps attr_accessor :override_timestamps
@ -77,7 +81,7 @@ class Status < ApplicationRecord
accepts_nested_attributes_for :poll accepts_nested_attributes_for :poll
default_scope { recent } default_scope { recent.kept }
scope :recent, -> { reorder(id: :desc) } scope :recent, -> { reorder(id: :desc) }
scope :remote, -> { where(local: false).where.not(uri: nil) } scope :remote, -> { where(local: false).where.not(uri: nil) }

View File

@ -8,7 +8,7 @@ class BatchedRemoveStatusService < BaseService
# Dispatch Salmon deletes, unique per domain, of the deleted statuses, but only local ones # Dispatch Salmon deletes, unique per domain, of the deleted statuses, but only local ones
# Remove statuses from home feeds # Remove statuses from home feeds
# Push delete events to streaming API for home feeds and public 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 # @param [Hash] options
# @option [Boolean] :skip_side_effects # @option [Boolean] :skip_side_effects
def call(statuses, **options) def call(statuses, **options)

View File

@ -4,6 +4,11 @@ class RemoveStatusService < BaseService
include Redisable include Redisable
include Payloadable include Payloadable
# Delete a status
# @param [Status] status
# @param [Hash] options
# @option [Boolean] :redraft
# @options [Boolean] :original_removed
def call(status, **options) def call(status, **options)
@payload = Oj.dump(event: :delete, payload: status.id.to_s) @payload = Oj.dump(event: :delete, payload: status.id.to_s)
@status = status @status = status
@ -25,6 +30,7 @@ class RemoveStatusService < BaseService
remove_from_media if status.media_attachments.any? remove_from_media if status.media_attachments.any?
remove_from_direct if status.direct_visibility? remove_from_direct if status.direct_visibility?
remove_from_spam_check remove_from_spam_check
remove_media
@status.destroy! @status.destroy!
else else
@ -151,6 +157,12 @@ class RemoveStatusService < BaseService
end end
end end
def remove_media
return if @options[:redraft]
@status.media_attachments.destroy_all
end
def remove_from_spam_check def remove_from_spam_check
redis.zremrangebyscore("spam_check:#{@status.account_id}", @status.id, @status.id) redis.zremrangebyscore("spam_check:#{@status.account_id}", @status.id, @status.id)
end end

View File

@ -13,6 +13,10 @@
.fields-group .fields-group
= f.input :send_email_notification, as: :boolean, wrapper: :with_label = 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/ %hr.spacer/
- unless @warning_presets.empty? - unless @warning_presets.empty?

View File

@ -105,7 +105,7 @@
%li %li
= feature_hint(t('admin.dashboard.authorized_fetch_mode'), @authorized_fetch) = feature_hint(t('admin.dashboard.authorized_fetch_mode'), @authorized_fetch)
%li %li
= feature_hint(t('admin.dashboard.whitelist_mode'), @whitelist_mode) = feature_hint(t('admin.dashboard.whitelist_mode'), @whitelist_enabled)
%li %li
= feature_hint('LDAP', @ldap_enabled) = feature_hint('LDAP', @ldap_enabled)
%li %li

View File

@ -16,11 +16,14 @@
- video = status.proper.media_attachments.first - 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 = 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 - 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 .detailed-status__meta
= link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: stream_link_target, rel: 'noopener' do = 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) %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? - if status.reblog?
= fa_icon('retweet fw') = fa_icon('retweet fw')

View File

@ -1,4 +1,5 @@
- i ||= 0 - i ||= 0
- highlighted ||= false
%table.email-table{ cellspacing: 0, cellpadding: 0, dir: 'ltr' } %table.email-table{ cellspacing: 0, cellpadding: 0, dir: 'ltr' }
%tbody %tbody
@ -14,7 +15,7 @@
%table.column{ cellspacing: 0, cellpadding: 0 } %table.column{ cellspacing: 0, cellpadding: 0 }
%tbody %tbody
%tr %tr
%td.column-cell.padded.status %td.column-cell.padded.status{ class: highlighted ? 'status--highlighted' : '' }
%table.status-header{ cellspacing: 0, cellpadding: 0 } %table.status-header{ cellspacing: 0, cellpadding: 0 }
%tbody %tbody
%tr %tr
@ -32,5 +33,10 @@
%div{ dir: rtl_status?(status) ? 'rtl' : 'ltr' } %div{ dir: rtl_status?(status) ? 'rtl' : 'ltr' }
= Formatter.instance.format(status) = 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 %p.status-footer
= link_to l(status.created_at), web_url("statuses/#{status.id}") = link_to l(status.created_at), web_url("statuses/#{status.id}")

View File

@ -27,10 +27,14 @@
= render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay } = render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay }
- if !status.media_attachments.empty? - if !status.media_attachments.empty?
- if status.media_attachments.first.audio_or_video? - if status.media_attachments.first.video?
- video = status.media_attachments.first - 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 = 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 } = 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 - 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 = 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 } = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }

View File

@ -31,10 +31,14 @@
= render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay } = render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay }
- if !status.media_attachments.empty? - if !status.media_attachments.empty?
- if status.media_attachments.first.audio_or_video? - if status.media_attachments.first.video?
- video = status.media_attachments.first - 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 = 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 } = 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 - 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 = 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 } = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }

View File

@ -42,6 +42,14 @@
- unless @warning.text.blank? - unless @warning.text.blank?
= Formatter.instance.linkify(@warning.text) = 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 } %table.email-table{ cellspacing: 0, cellpadding: 0 }
%tbody %tbody
%tr %tr
@ -50,7 +58,7 @@
%table.content-section{ cellspacing: 0, cellpadding: 0 } %table.content-section{ cellspacing: 0, cellpadding: 0 }
%tbody %tbody
%tr %tr
%td.content-cell %td.content-cell{ class: @statuses.empty? ? '' : 'content-start' }
%table.column{ cellspacing: 0, cellpadding: 0 } %table.column{ cellspacing: 0, cellpadding: 0 }
%tbody %tbody
%tr %tr
@ -61,3 +69,20 @@
%td.button-primary %td.button-primary
= link_to about_more_url do = link_to about_more_url do
%span= t 'user_mailer.warning.review_server_policies' %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

View File

@ -7,3 +7,16 @@
<% end %> <% end %>
<%= @warning.text %> <%= @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 %>

View File

@ -3,8 +3,8 @@
class RemovalWorker class RemovalWorker
include Sidekiq::Worker include Sidekiq::Worker
def perform(status_id) def perform(status_id, options = {})
RemoveStatusService.new.call(Status.find(status_id)) RemoveStatusService.new.call(Status.with_discarded.find(status_id), **options.symbolize_keys)
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
true true
end end

View File

@ -512,6 +512,7 @@ en:
delete: Delete delete: Delete
nsfw_off: Mark as not sensitive nsfw_off: Mark as not sensitive
nsfw_on: Mark as sensitive nsfw_on: Mark as sensitive
deleted: Deleted
failed_to_execute: Failed to execute failed_to_execute: Failed to execute
media: media:
title: 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. 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. 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. 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 review_server_policies: Review server policies
statuses: 'Specifically, for:'
subject: subject:
disable: Your account %{acct} has been frozen disable: Your account %{acct} has been frozen
none: Warning for %{acct} none: Warning for %{acct}

View File

@ -5,6 +5,7 @@ en:
account_warning_preset: account_warning_preset:
text: You can use toot syntax, such as URLs, hashtags and mentions text: You can use toot syntax, such as URLs, hashtags and mentions
admin_account_action: 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 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 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> type_html: Choose what to do with <strong>%{acct}</strong>
@ -65,6 +66,7 @@ en:
account_warning_preset: account_warning_preset:
text: Preset text text: Preset text
admin_account_action: admin_account_action:
include_statuses: Include reported toots in the e-mail
send_email_notification: Notify the user per e-mail send_email_notification: Notify the user per e-mail
text: Custom warning text: Custom warning
type: Action type: Action
@ -156,6 +158,7 @@ en:
trending_tag: Send e-mail when an unreviewed hashtag is trending trending_tag: Send e-mail when an unreviewed hashtag is trending
tag: tag:
listable: Allow this hashtag to appear in searches and on the profile directory listable: Allow this hashtag to appear in searches and on the profile directory
name: Hashtag
trendable: Allow this hashtag to appear under trends trendable: Allow this hashtag to appear under trends
usable: Allow toots to use this hashtag usable: Allow toots to use this hashtag
'no': 'No' 'no': 'No'

View File

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

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

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

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -657,7 +657,9 @@ ActiveRecord::Schema.define(version: 2019_08_15_225426) do
t.boolean "local_only" t.boolean "local_only"
t.bigint "poll_id" t.bigint "poll_id"
t.string "content_type" 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_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 ["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" t.index ["reblog_of_id", "account_id"], name: "index_statuses_on_reblog_of_id_and_account_id"

View File

@ -61,7 +61,7 @@
"private": true, "private": true,
"dependencies": { "dependencies": {
"@babel/core": "^7.4.5", "@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-decorators": "^7.4.4",
"@babel/plugin-proposal-object-rest-spread": "^7.4.4", "@babel/plugin-proposal-object-rest-spread": "^7.4.4",
"@babel/plugin-syntax-dynamic-import": "^7.2.0", "@babel/plugin-syntax-dynamic-import": "^7.2.0",
@ -137,7 +137,7 @@
"react-motion": "^0.5.2", "react-motion": "^0.5.2",
"react-notification": "^6.8.4", "react-notification": "^6.8.4",
"react-overlays": "^0.8.3", "react-overlays": "^0.8.3",
"react-redux": "^7.1.0", "react-redux": "^7.1.1",
"react-redux-loading-bar": "^4.0.8", "react-redux-loading-bar": "^4.0.8",
"react-router-dom": "^4.1.1", "react-router-dom": "^4.1.1",
"react-router-scroll-4": "^1.0.0-beta.1", "react-router-scroll-4": "^1.0.0-beta.1",
@ -163,10 +163,11 @@
"throng": "^4.0.0", "throng": "^4.0.0",
"tiny-queue": "^0.2.1", "tiny-queue": "^0.2.1",
"uuid": "^3.1.0", "uuid": "^3.1.0",
"wavesurfer.js": "^3.0.0",
"webpack": "^4.35.3", "webpack": "^4.35.3",
"webpack-assets-manifest": "^3.1.1", "webpack-assets-manifest": "^3.1.1",
"webpack-bundle-analyzer": "^3.3.2", "webpack-bundle-analyzer": "^3.3.2",
"webpack-cli": "^3.3.6", "webpack-cli": "^3.3.7",
"webpack-merge": "^4.2.1", "webpack-merge": "^4.2.1",
"websocket.js": "^0.1.12" "websocket.js": "^0.1.12"
}, },

View File

@ -47,7 +47,7 @@ describe Admin::ReportedStatusesController do
it 'removes a status' do it 'removes a status' do
allow(RemovalWorker).to receive(:perform_async) allow(RemovalWorker).to receive(:perform_async)
subject.call 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
end end

View File

@ -65,7 +65,7 @@ describe Admin::StatusesController do
it 'removes a status' do it 'removes a status' do
allow(RemovalWorker).to receive(:perform_async) allow(RemovalWorker).to receive(:perform_async)
subject.call 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
end end

View File

@ -42,6 +42,6 @@ class UserMailerPreview < ActionMailer::Preview
# Preview this email at http://localhost:3000/rails/mailers/user_mailer/warning # Preview this email at http://localhost:3000/rails/mailers/user_mailer/warning
def 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
end end

View File

@ -58,8 +58,8 @@ RSpec.describe Admin::AccountAction, type: :model do
end.to change { Admin::ActionLog.count }.by 1 end.to change { Admin::ActionLog.count }.by 1
end end
it 'calls queue_email!' do it 'calls process_email!' do
expect(account_action).to receive(:queue_email!) expect(account_action).to receive(:process_email!)
subject subject
end end

View File

@ -41,12 +41,12 @@ describe Form::StatusBatch do
it 'call RemovalWorker' do it 'call RemovalWorker' do
form.save 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 end
it 'do not call RemovalWorker' do it 'do not call RemovalWorker' do
form.save 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 end
end end

100
yarn.lock
View File

@ -121,16 +121,16 @@
"@babel/traverse" "^7.4.4" "@babel/traverse" "^7.4.4"
"@babel/types" "^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": "@babel/helper-create-class-features-plugin@^7.4.4", "@babel/helper-create-class-features-plugin@^7.5.5":
version "7.5.0" version "7.5.5"
resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.5.0.tgz#02edb97f512d44ba23b3227f1bf2ed43454edac5" resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.5.5.tgz#401f302c8ddbc0edd36f7c6b2887d8fa1122e5a4"
integrity sha512-EAoMc3hE5vE5LNhMqDOwB1usHvmRjCDAnH8CD4PVkX9/Yr3W/tcz8xE8QvdZxfsFBDICwZnF2UTHIqslRpvxmA== integrity sha512-ZsxkyYiRA7Bg+ZTRpPvB6AbOFKTFFK4LrvTet8lInm0V468MWCaSYJE+I7v2z2r8KNLtYiV+K5kTCnR7dvyZjg==
dependencies: dependencies:
"@babel/helper-function-name" "^7.1.0" "@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-optimise-call-expression" "^7.0.0"
"@babel/helper-plugin-utils" "^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-split-export-declaration" "^7.4.4"
"@babel/helper-define-map@^7.5.5": "@babel/helper-define-map@^7.5.5":
@ -173,13 +173,6 @@
dependencies: dependencies:
"@babel/types" "^7.4.4" "@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": "@babel/helper-member-expression-to-functions@^7.5.5":
version "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" 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/traverse" "^7.1.0"
"@babel/types" "^7.0.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": "@babel/helper-replace-supers@^7.5.5":
version "7.5.5" version "7.5.5"
resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.5.5.tgz#f84ce43df031222d2bad068d2626cb5799c34bc2" 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/helper-remap-async-to-generator" "^7.1.0"
"@babel/plugin-syntax-async-generators" "^7.2.0" "@babel/plugin-syntax-async-generators" "^7.2.0"
"@babel/plugin-proposal-class-properties@^7.5.0": "@babel/plugin-proposal-class-properties@^7.5.5":
version "7.5.0" version "7.5.5"
resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.5.0.tgz#5bc6a0537d286fcb4fd4e89975adbca334987007" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.5.5.tgz#a974cfae1e37c3110e71f3c6a2e48b8e71958cd4"
integrity sha512-9L/JfPCT+kShiiTTzcnBJ8cOwdKVmlC1RcCf9F0F9tERVrM4iWtWnXtjWCRqNm2la2BxO1MPArWNsU9zsSJWSQ== integrity sha512-AF79FsnWFxjlaosgdi421vmYG6/jg79bVD0dpD44QdgobzHKuLZ6S3vl8la9qIeSwGi8i1fS0O1mfuDAAdo1/A==
dependencies: 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/helper-plugin-utils" "^7.0.0"
"@babel/plugin-proposal-decorators@^7.4.4": "@babel/plugin-proposal-decorators@^7.4.4":
@ -811,10 +794,10 @@
dependencies: dependencies:
regenerator-runtime "^0.12.0" 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": "@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.4" version "7.5.5"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.5.4.tgz#cb7d1ad7c6d65676e66b47186577930465b5271b" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.5.5.tgz#74fba56d35efbeca444091c7850ccd494fd2f132"
integrity sha512-Na84uwyImZZc3FKf4aUF1tysApzwf3p2yuFBIyBfbzT5glzKTdvYI4KVW4kcgjrzoGUjC7w3YyCHcJKaRxsr2Q== integrity sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ==
dependencies: dependencies:
regenerator-runtime "^0.13.2" regenerator-runtime "^0.13.2"
@ -3858,14 +3841,16 @@ eslint-scope@^5.0.0:
estraverse "^4.1.1" estraverse "^4.1.1"
eslint-utils@^1.3.1: eslint-utils@^1.3.1:
version "1.3.1" version "1.4.2"
resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.3.1.tgz#9a851ba89ee7c460346f97cf8939c7298827e512" resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.2.tgz#166a5180ef6ab7eb462f162fd0e6f2463d7309ab"
integrity sha512-Z7YjnIldX+2XMcjr7ZkgEsOj/bREONV60qYeB/bjMAqqqZ4zxKyWX+BOUkdmRmA9riiIPVvo5x86m5elviOk0Q== integrity sha512-eAZS2sEUMlIeCjBeubdj45dmBHQwPHWyBcT1VSYB7o9x9WRRqKxyUoiXlRjyAwzN7YEzHJlYg0NmzDRWx6GP4Q==
dependencies:
eslint-visitor-keys "^1.0.0"
eslint-visitor-keys@^1.0.0: eslint-visitor-keys@^1.0.0:
version "1.0.0" version "1.1.0"
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#3f3180fb2e291017716acb4c9d6d5b5c34a6a81d" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2"
integrity sha512-qzm/XxIbxm/FHyH341ZrbnMUpe+5Bocte9xkmFMzPMjRaZMcXww+MpBptFvtU+79L362nqiLhekCxCxDPaUMBQ== integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A==
eslint@^2.7.0: eslint@^2.7.0:
version "2.13.1" version "2.13.1"
@ -6764,9 +6749,9 @@ mississippi@^3.0.0:
through2 "^2.0.0" through2 "^2.0.0"
mixin-deep@^1.2.0: mixin-deep@^1.2.0:
version "1.3.1" version "1.3.2"
resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe" resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"
integrity sha512-8ZItLHeEgaqEvd5lYBXfm4EZSFCX29Jb9K+lAHhDKzReKBQKj3R+7NOF6tjqYi9t4oI8VUfaWITJQm86wnXGNQ== integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==
dependencies: dependencies:
for-in "^1.0.2" for-in "^1.0.2"
is-extendable "^1.0.1" is-extendable "^1.0.1"
@ -8484,10 +8469,10 @@ react-intl@^2.9.0:
intl-relativeformat "^2.1.0" intl-relativeformat "^2.1.0"
invariant "^2.1.1" 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: 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.8.6" version "16.9.0"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.6.tgz#5bbc1e2d29141c9fbdfed456343fe2bc430a6a16" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.9.0.tgz#21ca9561399aad0ff1a7701c01683e8ca981edcb"
integrity sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA== integrity sha512-tJBzzzIgnnRfEm046qRcURvwQnZVXmuCbscxUO5RWrGTXpon2d4c8mI0D8WE6ydVIm29JiLB6+RslkIvym9Rjw==
react-lifecycles-compat@^3.0.2, react-lifecycles-compat@^3.0.4: react-lifecycles-compat@^3.0.2, react-lifecycles-compat@^3.0.4:
version "3.0.4" version "3.0.4"
@ -8539,17 +8524,17 @@ react-redux-loading-bar@^4.0.8:
prop-types "^15.6.2" prop-types "^15.6.2"
react-lifecycles-compat "^3.0.2" react-lifecycles-compat "^3.0.2"
react-redux@^7.1.0: react-redux@^7.1.1:
version "7.1.0" version "7.1.1"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.1.0.tgz#72af7cf490a74acdc516ea9c1dd80e25af9ea0b2" resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.1.1.tgz#ce6eee1b734a7a76e0788b3309bf78ff6b34fa0a"
integrity sha512-hyu/PoFK3vZgdLTg9ozbt7WF3GgX5+Yn3pZm5/96/o4UueXA+zj08aiSC9Mfj2WtD1bvpIb3C5yvskzZySzzaw== integrity sha512-QsW0vcmVVdNQzEkrgzh2W3Ksvr8cqpAv5FhEk7tNEft+5pp7rXxAudTz3VOPawRkLIepItpkEIyLcN/VVXzjTg==
dependencies: dependencies:
"@babel/runtime" "^7.4.5" "@babel/runtime" "^7.5.5"
hoist-non-react-statics "^3.3.0" hoist-non-react-statics "^3.3.0"
invariant "^2.2.4" invariant "^2.2.4"
loose-envify "^1.4.0" loose-envify "^1.4.0"
prop-types "^15.7.2" prop-types "^15.7.2"
react-is "^16.8.6" react-is "^16.9.0"
react-router-dom@^4.1.1: react-router-dom@^4.1.1:
version "4.3.1" version "4.3.1"
@ -10474,6 +10459,11 @@ watchpack@^1.5.0:
graceful-fs "^4.1.2" graceful-fs "^4.1.2"
neo-async "^2.5.0" 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: wbuf@^1.1.0, wbuf@^1.7.3:
version "1.7.3" version "1.7.3"
resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df" 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" opener "^1.5.1"
ws "^6.0.0" ws "^6.0.0"
webpack-cli@^3.3.6: webpack-cli@^3.3.7:
version "3.3.6" version "3.3.7"
resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-3.3.6.tgz#2c8c399a2642133f8d736a359007a052e060032c" resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-3.3.7.tgz#77c8580dd8e92f69d635e0238eaf9d9c15759a91"
integrity sha512-0vEa83M7kJtxK/jUhlpZ27WHIOndz5mghWL2O53kiDoA9DIxSKnfqB92LoqEn77cT4f3H2cZm1BMEat/6AZz3A== integrity sha512-OhTUCttAsr+IZSMVwGROGRHvT+QAs8H6/mHIl4SvhAwYywjiylYjpwybGx7WQ9Hkb45FhjtsymkwiRRbGJ1SZQ==
dependencies: dependencies:
chalk "2.4.2" chalk "2.4.2"
cross-spawn "6.0.5" cross-spawn "6.0.5"