mirror of
https://git.kescher.at/CatCatNya/catstodon.git
synced 2024-11-27 23:01:36 +01:00
Merge pull request #1208 from ThibG/glitch-soc/merge-upstream
Merge upstream changes
This commit is contained in:
commit
2848c08953
88 changed files with 1351 additions and 240 deletions
3
Gemfile
3
Gemfile
|
@ -31,7 +31,7 @@ gem 'charlock_holmes', '~> 0.7.6'
|
|||
gem 'iso-639'
|
||||
gem 'chewy', '~> 5.0'
|
||||
gem 'cld3', '~> 3.2.4'
|
||||
gem 'devise', '~> 4.6'
|
||||
gem 'devise', '~> 4.7'
|
||||
gem 'devise-two-factor', '~> 3.1'
|
||||
|
||||
group :pam_authentication, optional: true do
|
||||
|
@ -43,6 +43,7 @@ gem 'omniauth-cas', '~> 1.1'
|
|||
gem 'omniauth-saml', '~> 1.10'
|
||||
gem 'omniauth', '~> 1.9'
|
||||
|
||||
gem 'discard', '~> 1.1'
|
||||
gem 'doorkeeper', '~> 5.1'
|
||||
gem 'fast_blank', '~> 1.0'
|
||||
gem 'fastimage'
|
||||
|
|
13
Gemfile.lock
13
Gemfile.lock
|
@ -127,7 +127,7 @@ GEM
|
|||
brakeman (4.6.1)
|
||||
browser (2.6.1)
|
||||
builder (3.2.3)
|
||||
bullet (6.0.1)
|
||||
bullet (6.0.2)
|
||||
activesupport (>= 3.0.0)
|
||||
uniform_notifier (~> 1.11)
|
||||
bundler-audit (0.6.1)
|
||||
|
@ -188,10 +188,10 @@ GEM
|
|||
rack (>= 1)
|
||||
rake (> 10, < 13)
|
||||
thor (~> 0.19)
|
||||
devise (4.6.2)
|
||||
devise (4.7.0)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
railties (>= 4.1.0, < 6.0)
|
||||
railties (>= 4.1.0)
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
devise-two-factor (3.1.0)
|
||||
|
@ -204,6 +204,8 @@ GEM
|
|||
devise (>= 4.0.0)
|
||||
rpam2 (~> 4.0)
|
||||
diff-lcs (1.3)
|
||||
discard (1.1.0)
|
||||
activerecord (>= 4.2, < 7)
|
||||
docile (1.3.2)
|
||||
domain_name (0.5.20180417)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
|
@ -555,7 +557,7 @@ GEM
|
|||
rainbow (>= 2.2.2, < 4.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 1.4.0, < 1.7)
|
||||
rubocop-rails (2.3.0)
|
||||
rubocop-rails (2.3.1)
|
||||
rack (>= 1.1)
|
||||
rubocop (>= 0.72.0)
|
||||
ruby-progressbar (1.10.1)
|
||||
|
@ -692,9 +694,10 @@ DEPENDENCIES
|
|||
concurrent-ruby
|
||||
connection_pool
|
||||
derailed_benchmarks
|
||||
devise (~> 4.6)
|
||||
devise (~> 4.7)
|
||||
devise-two-factor (~> 3.1)
|
||||
devise_pam_authenticatable2 (~> 9.2)
|
||||
discard (~> 1.1)
|
||||
doorkeeper (~> 5.1)
|
||||
dotenv-rails (~> 2.7)
|
||||
fabrication (~> 2.20)
|
||||
|
|
|
@ -5,7 +5,7 @@ module Admin
|
|||
before_action :set_account
|
||||
|
||||
def new
|
||||
@account_action = Admin::AccountAction.new(type: params[:type], report_id: params[:report_id], send_email_notification: true)
|
||||
@account_action = Admin::AccountAction.new(type: params[:type], report_id: params[:report_id], send_email_notification: true, include_statuses: true)
|
||||
@warning_presets = AccountWarningPreset.all
|
||||
end
|
||||
|
||||
|
@ -30,7 +30,7 @@ module Admin
|
|||
end
|
||||
|
||||
def resource_params
|
||||
params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification)
|
||||
params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification, :include_statuses)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -21,7 +21,7 @@ class Api::V1::ReportsController < Api::BaseController
|
|||
private
|
||||
|
||||
def reported_status_ids
|
||||
reported_account.statuses.find(status_ids).pluck(:id)
|
||||
reported_account.statuses.with_discarded.find(status_ids).pluck(:id)
|
||||
end
|
||||
|
||||
def status_ids
|
||||
|
|
|
@ -18,6 +18,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
|
|||
@reblogs_map = { @status.id => false }
|
||||
|
||||
authorize status_for_destroy, :unreblog?
|
||||
status_for_destroy.discard
|
||||
RemovalWorker.perform_async(status_for_destroy.id)
|
||||
|
||||
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, reblogs_map: @reblogs_map)
|
||||
|
@ -30,7 +31,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
|
|||
end
|
||||
|
||||
def status_for_destroy
|
||||
current_user.account.statuses.where(reblog_of_id: params[:status_id]).first!
|
||||
@status_for_destroy ||= current_user.account.statuses.where(reblog_of_id: params[:status_id]).first!
|
||||
end
|
||||
|
||||
def reblog_params
|
||||
|
|
|
@ -54,7 +54,8 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
@status = Status.where(account_id: current_user.account).find(params[:id])
|
||||
authorize @status, :destroy?
|
||||
|
||||
RemovalWorker.perform_async(@status.id)
|
||||
@status.discard
|
||||
RemovalWorker.perform_async(@status.id, redraft: true)
|
||||
|
||||
render json: @status, serializer: REST::StatusSerializer, source_requested: true
|
||||
end
|
||||
|
|
|
@ -3,6 +3,8 @@ import { defineMessages } from 'react-intl';
|
|||
const messages = defineMessages({
|
||||
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
|
||||
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
|
||||
rateLimitedTitle: { id: 'alert.rate_limited.title', defaultMessage: 'Rate limited' },
|
||||
rateLimitedMessage: { id: 'alert.rate_limited.message', defaultMessage: 'Please retry after {retry_time, time, medium}.' },
|
||||
});
|
||||
|
||||
export const ALERT_SHOW = 'ALERT_SHOW';
|
||||
|
@ -23,23 +25,29 @@ export function clearAlert() {
|
|||
};
|
||||
};
|
||||
|
||||
export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage) {
|
||||
export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, message_values = undefined) {
|
||||
return {
|
||||
type: ALERT_SHOW,
|
||||
title,
|
||||
message,
|
||||
message_values,
|
||||
};
|
||||
};
|
||||
|
||||
export function showAlertForError(error) {
|
||||
if (error.response) {
|
||||
const { data, status, statusText } = error.response;
|
||||
const { data, status, statusText, headers } = error.response;
|
||||
|
||||
if (status === 404 || status === 410) {
|
||||
// Skip these errors as they are reflected in the UI
|
||||
return { type: ALERT_NOOP };
|
||||
}
|
||||
|
||||
if (status === 429 && headers['x-ratelimit-reset']) {
|
||||
const reset_date = new Date(headers['x-ratelimit-reset']);
|
||||
return showAlert(messages.rateLimitedTitle, messages.rateLimitedMessage, { 'retry_time': reset_date });
|
||||
}
|
||||
|
||||
let message = statusText;
|
||||
let title = `${status}`;
|
||||
|
||||
|
|
|
@ -10,7 +10,7 @@ import AttachmentList from './attachment_list';
|
|||
import Card from '../features/status/components/card';
|
||||
import { injectIntl, FormattedMessage } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { MediaGallery, Video } from 'flavours/glitch/util/async-components';
|
||||
import { MediaGallery, Video, Audio } from 'flavours/glitch/util/async-components';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import NotificationOverlayContainer from 'flavours/glitch/features/notifications/containers/overlay_container';
|
||||
import classNames from 'classnames';
|
||||
|
@ -443,11 +443,15 @@ class Status extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
renderLoadingMediaGallery () {
|
||||
return <div className='media_gallery' style={{ height: '110px' }} />;
|
||||
return <div className='media-gallery' style={{ height: '110px' }} />;
|
||||
}
|
||||
|
||||
renderLoadingVideoPlayer () {
|
||||
return <div className='media-spoiler-video' style={{ height: '110px' }} />;
|
||||
return <div className='video-player' style={{ height: '110px' }} />;
|
||||
}
|
||||
|
||||
renderLoadingAudioPlayer () {
|
||||
return <div className='audio-player' style={{ height: '110px' }} />;
|
||||
}
|
||||
|
||||
render () {
|
||||
|
@ -561,7 +565,24 @@ class Status extends ImmutablePureComponent {
|
|||
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]);
|
||||
|
||||
media = (
|
||||
|
@ -584,7 +605,7 @@ class Status extends ImmutablePureComponent {
|
|||
/>)}
|
||||
</Bundle>
|
||||
);
|
||||
mediaIcon = attachment.get('type') === 'video' ? 'video-camera' : 'music';
|
||||
mediaIcon = 'video-camera';
|
||||
} else { // Media type is 'image' or 'gifv'
|
||||
media = (
|
||||
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>
|
||||
|
|
|
@ -212,7 +212,7 @@ export default class StatusContent extends React.PureComponent {
|
|||
|
||||
let element = e.target;
|
||||
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;
|
||||
}
|
||||
element = element.parentNode;
|
||||
|
|
|
@ -7,6 +7,7 @@ import MediaGallery from 'flavours/glitch/components/media_gallery';
|
|||
import Video from 'flavours/glitch/features/video';
|
||||
import Card from 'flavours/glitch/features/status/components/card';
|
||||
import Poll from 'flavours/glitch/components/poll';
|
||||
import Audio from 'flavours/glitch/features/audio';
|
||||
import ModalRoot from 'flavours/glitch/components/modal_root';
|
||||
import MediaModal from 'flavours/glitch/features/ui/components/media_modal';
|
||||
import { List as ImmutableList, fromJS } from 'immutable';
|
||||
|
@ -14,7 +15,7 @@ import { List as ImmutableList, fromJS } from 'immutable';
|
|||
const { localeData, messages } = getLocale();
|
||||
addLocaleData(localeData);
|
||||
|
||||
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll };
|
||||
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Audio };
|
||||
|
||||
export default class MediaContainer extends PureComponent {
|
||||
|
||||
|
|
226
app/javascript/flavours/glitch/features/audio/index.js
Normal file
226
app/javascript/flavours/glitch/features/audio/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -53,8 +53,18 @@ class Header extends ImmutablePureComponent {
|
|||
showNotificationsBadge: PropTypes.bool,
|
||||
intl: PropTypes.object,
|
||||
onSettingsClick: PropTypes.func,
|
||||
onLogout: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
handleLogoutClick = e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.props.onLogout();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl, columns, unreadNotifications, showNotificationsBadge, onSettingsClick } = this.props;
|
||||
|
||||
|
@ -114,7 +124,7 @@ class Header extends ImmutablePureComponent {
|
|||
><Icon icon='cogs' /></a>
|
||||
<a
|
||||
aria-label={intl.formatMessage(messages.logout)}
|
||||
data-method='delete'
|
||||
onClick={this.handleLogoutClick}
|
||||
href={ signOutLink }
|
||||
title={intl.formatMessage(messages.logout)}
|
||||
><Icon icon='sign-out' /></a>
|
||||
|
|
|
@ -1,6 +1,13 @@
|
|||
import { openModal } from 'flavours/glitch/actions/modal';
|
||||
import { connect } from 'react-redux';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
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 => {
|
||||
return {
|
||||
|
@ -16,6 +23,13 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
|
|||
e.stopPropagation();
|
||||
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));
|
||||
|
|
|
@ -13,7 +13,7 @@ import { fetchFollowRequests } from 'flavours/glitch/actions/accounts';
|
|||
import { List as ImmutableList } from 'immutable';
|
||||
import { createSelector } from 'reselect';
|
||||
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 LinkFooter from 'flavours/glitch/features/ui/components/link_footer';
|
||||
|
||||
|
@ -30,7 +30,6 @@ const messages = defineMessages({
|
|||
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||
settings: { id: 'navigation_bar.app_settings', defaultMessage: 'App settings' },
|
||||
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' },
|
||||
keyboard_shortcuts: { id: 'navigation_bar.keyboard_shortcuts', defaultMessage: 'Keyboard shortcuts' },
|
||||
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)} />
|
||||
{ preferencesLink !== undefined && <ColumnLink icon='cog' text={intl.formatMessage(messages.preferences)} href={preferencesLink} /> }
|
||||
<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>
|
||||
|
||||
<LinkFooter />
|
||||
|
|
|
@ -11,6 +11,7 @@ import { FormattedDate, FormattedNumber } from 'react-intl';
|
|||
import Card from './card';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Video from 'flavours/glitch/features/video';
|
||||
import Audio from 'flavours/glitch/features/audio';
|
||||
import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon';
|
||||
import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task';
|
||||
import classNames from 'classnames';
|
||||
|
@ -131,7 +132,20 @@ export default class DetailedStatus extends ImmutablePureComponent {
|
|||
} else if (status.get('media_attachments').size > 0) {
|
||||
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
|
||||
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]);
|
||||
media = (
|
||||
<Video
|
||||
|
@ -150,7 +164,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
|
|||
onToggleVisibility={this.props.onToggleMediaVisibility}
|
||||
/>
|
||||
);
|
||||
mediaIcon = attachment.get('type') === 'video' ? 'video-camera' : 'music';
|
||||
mediaIcon = 'video-camera';
|
||||
} else {
|
||||
media = (
|
||||
<MediaGallery
|
||||
|
|
|
@ -10,6 +10,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
|||
import IconButton from 'flavours/glitch/components/icon_button';
|
||||
import Button from 'flavours/glitch/components/button';
|
||||
import Video from 'flavours/glitch/features/video';
|
||||
import Audio from 'flavours/glitch/features/audio';
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
import UploadProgress from 'flavours/glitch/features/compose/components/upload_progress';
|
||||
import CharacterCounter from 'flavours/glitch/features/compose/components/character_counter';
|
||||
|
@ -244,12 +245,23 @@ class FocalPointModal extends ImmutablePureComponent {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{['audio', 'video'].includes(media.get('type')) && (
|
||||
{media.get('type') === 'video' && (
|
||||
<Video
|
||||
preview={media.get('preview_url')}
|
||||
blurhash={media.get('blurhash')}
|
||||
src={media.get('url')}
|
||||
detailed
|
||||
inline
|
||||
editable
|
||||
/>
|
||||
)}
|
||||
|
||||
{media.get('type') === 'audio' && (
|
||||
<Audio
|
||||
src={media.get('url')}
|
||||
duration={media.getIn(['meta', 'original', 'duration'], 0)}
|
||||
height={150}
|
||||
preload
|
||||
editable
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -1,36 +1,71 @@
|
|||
import { connect } from 'react-redux';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { invitesEnabled, version, repository, source_url } from 'flavours/glitch/util/initial_state';
|
||||
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 = () => (
|
||||
<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} data-method='delete'><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
|
||||
</ul>
|
||||
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' },
|
||||
});
|
||||
|
||||
<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>
|
||||
);
|
||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
onLogout () {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.logoutMessage),
|
||||
confirm: intl.formatMessage(messages.logoutConfirm),
|
||||
onConfirm: () => logOut(),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
@connect(null, mapDispatchToProps)
|
||||
class LinkFooter extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
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;
|
||||
|
|
|
@ -11,7 +11,7 @@ const mapStateToProps = (state, { intl }) => {
|
|||
const value = notification[key];
|
||||
|
||||
if (typeof value === 'object') {
|
||||
notification[key] = intl.formatMessage(value);
|
||||
notification[key] = intl.formatMessage(value, notification[`${key}_values`]);
|
||||
}
|
||||
}));
|
||||
|
||||
|
|
|
@ -138,14 +138,24 @@ class SwitchingColumnsArea extends React.PureComponent {
|
|||
window.removeEventListener('resize', this.handleResize);
|
||||
}
|
||||
|
||||
handleResize = debounce(() => {
|
||||
handleLayoutChange = debounce(() => {
|
||||
// The cached heights are no longer accurate, invalidate
|
||||
this.props.onLayoutChange();
|
||||
|
||||
this.setState({ mobile: isMobile(window.innerWidth, this.props.layout) });
|
||||
}, 500, {
|
||||
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 => {
|
||||
this.node = c.getWrappedInstance();
|
||||
|
|
|
@ -20,7 +20,7 @@ const messages = defineMessages({
|
|||
exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
|
||||
});
|
||||
|
||||
const formatTime = secondsNum => {
|
||||
export const formatTime = secondsNum => {
|
||||
let hours = Math.floor(secondsNum / 3600);
|
||||
let minutes = Math.floor((secondsNum - (hours * 3600)) / 60);
|
||||
let seconds = secondsNum - (hours * 3600) - (minutes * 60);
|
||||
|
|
|
@ -14,6 +14,7 @@ export default function alerts(state = initialState, action) {
|
|||
key: state.size > 0 ? state.last().get('key') + 1 : 0,
|
||||
title: action.title,
|
||||
message: action.message,
|
||||
message_values: action.message_values,
|
||||
}));
|
||||
case ALERT_DISMISS:
|
||||
return state.filterNot(item => item.get('key') === action.alert.key);
|
||||
|
|
|
@ -157,6 +157,7 @@ export const getAlerts = createSelector([getAlertsBase], (base) => {
|
|||
base.forEach(item => {
|
||||
arr.push({
|
||||
message: item.get('message'),
|
||||
message_values: item.get('message_values'),
|
||||
title: item.get('title'),
|
||||
key: item.get('key'),
|
||||
dismissAfter: 5000,
|
||||
|
|
|
@ -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 {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: $base-shadow-color;
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.editable {
|
||||
border-radius: 0;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
|
|
|
@ -107,7 +107,8 @@
|
|||
padding: 15px;
|
||||
|
||||
.media-gallery,
|
||||
.video-player {
|
||||
.video-player,
|
||||
.audio-player {
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
|
@ -131,7 +132,8 @@
|
|||
|
||||
.media-gallery,
|
||||
&__action-bar,
|
||||
.video-player {
|
||||
.video-player,
|
||||
.audio-player {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -263,7 +263,8 @@
|
|||
opacity: 1;
|
||||
animation: fade 150ms linear;
|
||||
|
||||
.video-player {
|
||||
.video-player,
|
||||
.audio-player {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
|
@ -453,7 +454,8 @@
|
|||
white-space: normal;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
.video-player,
|
||||
.audio-player {
|
||||
margin-top: 8px;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
@ -561,7 +563,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
.video-player {
|
||||
.video-player,
|
||||
.audio-player {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -372,3 +372,10 @@
|
|||
.directory__tag > div {
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -138,6 +138,10 @@ export function 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 () {
|
||||
return import(/* webpackChunkName: "flavours/glitch/async/embed_modal" */'flavours/glitch/features/ui/components/embed_modal');
|
||||
}
|
||||
|
|
34
app/javascript/flavours/glitch/util/log_out.js
Normal file
34
app/javascript/flavours/glitch/util/log_out.js
Normal 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();
|
||||
};
|
|
@ -3,6 +3,8 @@ import { defineMessages } from 'react-intl';
|
|||
const messages = defineMessages({
|
||||
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
|
||||
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
|
||||
rateLimitedTitle: { id: 'alert.rate_limited.title', defaultMessage: 'Rate limited' },
|
||||
rateLimitedMessage: { id: 'alert.rate_limited.message', defaultMessage: 'Please retry after {retry_time, time, medium}.' },
|
||||
});
|
||||
|
||||
export const ALERT_SHOW = 'ALERT_SHOW';
|
||||
|
@ -23,23 +25,29 @@ export function clearAlert() {
|
|||
};
|
||||
};
|
||||
|
||||
export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage) {
|
||||
export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, message_values = undefined) {
|
||||
return {
|
||||
type: ALERT_SHOW,
|
||||
title,
|
||||
message,
|
||||
message_values,
|
||||
};
|
||||
};
|
||||
|
||||
export function showAlertForError(error) {
|
||||
if (error.response) {
|
||||
const { data, status, statusText } = error.response;
|
||||
const { data, status, statusText, headers } = error.response;
|
||||
|
||||
if (status === 404 || status === 410) {
|
||||
// Skip these errors as they are reflected in the UI
|
||||
return { type: ALERT_NOOP };
|
||||
}
|
||||
|
||||
if (status === 429 && headers['x-ratelimit-reset']) {
|
||||
const reset_date = new Date(headers['x-ratelimit-reset']);
|
||||
return showAlert(messages.rateLimitedTitle, messages.rateLimitedMessage, { 'retry_time': reset_date });
|
||||
}
|
||||
|
||||
let message = statusText;
|
||||
let title = `${status}`;
|
||||
|
||||
|
|
|
@ -356,6 +356,8 @@ const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => {
|
|||
cancelFetchComposeSuggestionsTags();
|
||||
}
|
||||
|
||||
dispatch(updateSuggestionTags(token));
|
||||
|
||||
api(getState).get('/api/v2/search', {
|
||||
cancelToken: new CancelToken(cancel => {
|
||||
cancelFetchComposeSuggestionsTags = cancel;
|
||||
|
|
|
@ -9,18 +9,18 @@ export default class AutosuggestHashtag extends React.PureComponent {
|
|||
tag: PropTypes.shape({
|
||||
name: PropTypes.string.isRequired,
|
||||
url: PropTypes.string,
|
||||
history: PropTypes.array.isRequired,
|
||||
history: PropTypes.array,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
render () {
|
||||
const { tag } = this.props;
|
||||
const weeklyUses = shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0));
|
||||
const weeklyUses = tag.history && shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0));
|
||||
|
||||
return (
|
||||
<div className='autosuggest-hashtag'>
|
||||
<div className='autosuggest-hashtag__name'>#<strong>{tag.name}</strong></div>
|
||||
<div className='autosuggest-hashtag__uses'><FormattedMessage id='autosuggest_hashtag.per_week' defaultMessage='{count} per week' values={{ count: weeklyUses }} /></div>
|
||||
{tag.history !== undefined && <div className='autosuggest-hashtag__uses'><FormattedMessage id='autosuggest_hashtag.per_week' defaultMessage='{count} per week' values={{ count: weeklyUses }} /></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -35,7 +35,19 @@ export default class ColumnBackButton extends React.PureComponent {
|
|||
if (multiColumn) {
|
||||
return component;
|
||||
} else {
|
||||
return createPortal(component, document.getElementById('tabs-bar__portal'));
|
||||
// The portal container and the component may be rendered to the DOM in
|
||||
// the same React render pass, so the container might not be available at
|
||||
// the time `render()` is called.
|
||||
const container = document.getElementById('tabs-bar__portal');
|
||||
if (container === null) {
|
||||
// The container wasn't available, force a re-render so that the
|
||||
// component can eventually be inserted in the container and not scroll
|
||||
// with the rest of the area.
|
||||
this.forceUpdate();
|
||||
return component;
|
||||
} else {
|
||||
return createPortal(component, container);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -178,7 +178,19 @@ class ColumnHeader extends React.PureComponent {
|
|||
if (multiColumn || placeholder) {
|
||||
return component;
|
||||
} else {
|
||||
return createPortal(component, document.getElementById('tabs-bar__portal'));
|
||||
// The portal container and the component may be rendered to the DOM in
|
||||
// the same React render pass, so the container might not be available at
|
||||
// the time `render()` is called.
|
||||
const container = document.getElementById('tabs-bar__portal');
|
||||
if (container === null) {
|
||||
// The container wasn't available, force a re-render so that the
|
||||
// component can eventually be inserted in the container and not scroll
|
||||
// with the rest of the area.
|
||||
this.forceUpdate();
|
||||
return component;
|
||||
} else {
|
||||
return createPortal(component, container);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -12,7 +12,7 @@ import AttachmentList from './attachment_list';
|
|||
import Card from '../features/status/components/card';
|
||||
import { injectIntl, FormattedMessage } from 'react-intl';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import { MediaGallery, Video } from '../features/ui/util/async-components';
|
||||
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
|
||||
import { HotKeys } from 'react-hotkeys';
|
||||
import classNames from 'classnames';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
|
@ -199,11 +199,15 @@ class Status extends ImmutablePureComponent {
|
|||
};
|
||||
|
||||
renderLoadingMediaGallery () {
|
||||
return <div className='media_gallery' style={{ height: '110px' }} />;
|
||||
return <div className='media-gallery' style={{ height: '110px' }} />;
|
||||
}
|
||||
|
||||
renderLoadingVideoPlayer () {
|
||||
return <div className='media-spoiler-video' style={{ height: '110px' }} />;
|
||||
return <div className='video-player' style={{ height: '110px' }} />;
|
||||
}
|
||||
|
||||
renderLoadingAudioPlayer () {
|
||||
return <div className='audio-player' style={{ height: '110px' }} />;
|
||||
}
|
||||
|
||||
handleOpenVideo = (media, startTime) => {
|
||||
|
@ -348,7 +352,23 @@ class Status extends ImmutablePureComponent {
|
|||
media={status.get('media_attachments')}
|
||||
/>
|
||||
);
|
||||
} else if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) {
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
|
||||
media = (
|
||||
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
|
||||
{Component => (
|
||||
<Component
|
||||
src={attachment.get('url')}
|
||||
alt={attachment.get('description')}
|
||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||
peaks={[0]}
|
||||
height={70}
|
||||
/>
|
||||
)}
|
||||
</Bundle>
|
||||
);
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
|
||||
media = (
|
||||
|
|
|
@ -230,7 +230,7 @@ export default class StatusContent extends React.PureComponent {
|
|||
);
|
||||
} else if (this.props.onClick) {
|
||||
const output = [
|
||||
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
|
||||
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content'>
|
||||
<div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} />
|
||||
|
||||
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
|
||||
|
|
|
@ -8,6 +8,7 @@ import Video from '../features/video';
|
|||
import Card from '../features/status/components/card';
|
||||
import Poll from 'mastodon/components/poll';
|
||||
import Hashtag from 'mastodon/components/hashtag';
|
||||
import Audio from 'mastodon/features/audio';
|
||||
import ModalRoot from '../components/modal_root';
|
||||
import { getScrollbarWidth } from '../features/ui/components/modal_root';
|
||||
import MediaModal from '../features/ui/components/media_modal';
|
||||
|
@ -16,7 +17,7 @@ import { List as ImmutableList, fromJS } from 'immutable';
|
|||
const { localeData, messages } = getLocale();
|
||||
addLocaleData(localeData);
|
||||
|
||||
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag };
|
||||
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
|
||||
|
||||
export default class MediaContainer extends PureComponent {
|
||||
|
||||
|
|
226
app/javascript/mastodon/features/audio/index.js
Normal file
226
app/javascript/mastodon/features/audio/index.js
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
|
@ -23,9 +23,14 @@ class ActionBar extends React.PureComponent {
|
|||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
onLogout: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleLogout = () => {
|
||||
this.props.onLogout();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { intl } = this.props;
|
||||
|
||||
|
@ -44,7 +49,7 @@ class ActionBar extends React.PureComponent {
|
|||
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
|
||||
menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
|
||||
menu.push(null);
|
||||
menu.push({ text: intl.formatMessage(messages.logout), href: '/auth/sign_out', target: null, method: 'delete' });
|
||||
menu.push({ text: intl.formatMessage(messages.logout), action: this.handleLogout });
|
||||
|
||||
return (
|
||||
<div className='compose__action-bar'>
|
||||
|
|
|
@ -12,6 +12,7 @@ export default class NavigationBar extends ImmutablePureComponent {
|
|||
|
||||
static propTypes = {
|
||||
account: ImmutablePropTypes.map.isRequired,
|
||||
onLogout: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func,
|
||||
};
|
||||
|
||||
|
@ -33,7 +34,7 @@ export default class NavigationBar extends ImmutablePureComponent {
|
|||
|
||||
<div className='navigation-bar__actions'>
|
||||
<IconButton className='close' title='' icon='close' onClick={this.props.onClose} />
|
||||
<ActionBar account={this.props.account} />
|
||||
<ActionBar account={this.props.account} onLogout={this.props.onLogout} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,11 +1,29 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
import NavigationBar from '../components/navigation_bar';
|
||||
import { logOut } from 'mastodon/utils/log_out';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import { me } from '../../../initial_state';
|
||||
|
||||
const messages = defineMessages({
|
||||
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
|
||||
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
|
||||
});
|
||||
|
||||
const mapStateToProps = state => {
|
||||
return {
|
||||
account: state.getIn(['accounts', me]),
|
||||
};
|
||||
};
|
||||
|
||||
export default connect(mapStateToProps)(NavigationBar);
|
||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
onLogout () {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.logoutMessage),
|
||||
confirm: intl.formatMessage(messages.logoutConfirm),
|
||||
onConfirm: () => logOut(),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NavigationBar));
|
||||
|
|
|
@ -12,9 +12,11 @@ import Motion from '../ui/util/optional_motion';
|
|||
import spring from 'react-motion/lib/spring';
|
||||
import SearchResultsContainer from './containers/search_results_container';
|
||||
import { changeComposing } from '../../actions/compose';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
import elephantUIPlane from '../../../images/elephant_ui_plane.svg';
|
||||
import { mascot } from '../../initial_state';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
import { logOut } from 'mastodon/utils/log_out';
|
||||
|
||||
const messages = defineMessages({
|
||||
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
|
||||
|
@ -25,6 +27,8 @@ const messages = defineMessages({
|
|||
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
|
||||
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
|
||||
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new toot' },
|
||||
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
|
||||
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
|
||||
});
|
||||
|
||||
const mapStateToProps = (state, ownProps) => ({
|
||||
|
@ -61,6 +65,21 @@ class Compose extends React.PureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
handleLogoutClick = e => {
|
||||
const { dispatch, intl } = this.props;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.logoutMessage),
|
||||
confirm: intl.formatMessage(messages.logoutConfirm),
|
||||
onConfirm: () => logOut(),
|
||||
}));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
onFocus = () => {
|
||||
this.props.dispatch(changeComposing(true));
|
||||
}
|
||||
|
@ -92,7 +111,7 @@ class Compose extends React.PureComponent {
|
|||
<Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><Icon id='globe' fixedWidth /></Link>
|
||||
)}
|
||||
<a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)} aria-label={intl.formatMessage(messages.preferences)}><Icon id='cog' fixedWidth /></a>
|
||||
<a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)}><Icon id='sign-out' fixedWidth /></a>
|
||||
<a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' fixedWidth /></a>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
import PropTypes from 'prop-types';
|
||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||
import Hashtag from 'mastodon/components/hashtag';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
export default class Trends extends ImmutablePureComponent {
|
||||
|
||||
|
@ -17,7 +18,7 @@ export default class Trends extends ImmutablePureComponent {
|
|||
|
||||
componentDidMount () {
|
||||
this.props.fetchTrends();
|
||||
this.refreshInterval = setInterval(() => this.props.fetchTrends(), 36000);
|
||||
this.refreshInterval = setInterval(() => this.props.fetchTrends(), 900 * 1000);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
|
@ -35,6 +36,8 @@ export default class Trends extends ImmutablePureComponent {
|
|||
|
||||
return (
|
||||
<div className='getting-started__trends'>
|
||||
<h4><FormattedMessage id='trends.trending_now' defaultMessage='Trending now' /></h4>
|
||||
|
||||
{trends.take(3).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -10,6 +10,7 @@ import { FormattedDate, FormattedNumber } from 'react-intl';
|
|||
import Card from './card';
|
||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||
import Video from '../../video';
|
||||
import Audio from '../../audio';
|
||||
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
|
||||
import classNames from 'classnames';
|
||||
import Icon from 'mastodon/components/icon';
|
||||
|
@ -107,7 +108,19 @@ export default class DetailedStatus extends ImmutablePureComponent {
|
|||
}
|
||||
|
||||
if (status.get('media_attachments').size > 0) {
|
||||
if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) {
|
||||
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
|
||||
media = (
|
||||
<Audio
|
||||
src={attachment.get('url')}
|
||||
alt={attachment.get('description')}
|
||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||
height={110}
|
||||
preload
|
||||
/>
|
||||
);
|
||||
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
|
||||
const attachment = status.getIn(['media_attachments', 0]);
|
||||
|
||||
media = (
|
||||
|
|
|
@ -10,6 +10,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
|||
import IconButton from 'mastodon/components/icon_button';
|
||||
import Button from 'mastodon/components/button';
|
||||
import Video from 'mastodon/features/video';
|
||||
import Audio from 'mastodon/features/audio';
|
||||
import Textarea from 'react-textarea-autosize';
|
||||
import UploadProgress from 'mastodon/features/compose/components/upload_progress';
|
||||
import CharacterCounter from 'mastodon/features/compose/components/character_counter';
|
||||
|
@ -244,12 +245,23 @@ class FocalPointModal extends ImmutablePureComponent {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{['audio', 'video'].includes(media.get('type')) && (
|
||||
{media.get('type') === 'video' && (
|
||||
<Video
|
||||
preview={media.get('preview_url')}
|
||||
blurhash={media.get('blurhash')}
|
||||
src={media.get('url')}
|
||||
detailed
|
||||
inline
|
||||
editable
|
||||
/>
|
||||
)}
|
||||
|
||||
{media.get('type') === 'audio' && (
|
||||
<Audio
|
||||
src={media.get('url')}
|
||||
duration={media.getIn(['meta', 'original', 'duration'], 0)}
|
||||
height={150}
|
||||
preload
|
||||
editable
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -1,35 +1,72 @@
|
|||
import { connect } from 'react-redux';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { invitesEnabled, version, repository, source_url } from 'mastodon/initial_state';
|
||||
import { logOut } from 'mastodon/utils/log_out';
|
||||
import { openModal } from 'mastodon/actions/modal';
|
||||
|
||||
const LinkFooter = ({ withHotkeys }) => (
|
||||
<div className='getting-started__footer'>
|
||||
<ul>
|
||||
{invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
|
||||
{withHotkeys && <li><Link to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link> · </li>}
|
||||
<li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li>
|
||||
<li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li>
|
||||
<li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li>
|
||||
<li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li>
|
||||
<li><a href='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li>
|
||||
<li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li>
|
||||
<li><a href='/auth/sign_out' data-method='delete'><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
|
||||
</ul>
|
||||
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' },
|
||||
});
|
||||
|
||||
<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>
|
||||
);
|
||||
const mapDispatchToProps = (dispatch, { intl }) => ({
|
||||
onLogout () {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
message: intl.formatMessage(messages.logoutMessage),
|
||||
confirm: intl.formatMessage(messages.logoutConfirm),
|
||||
onConfirm: () => logOut(),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
export default @injectIntl
|
||||
@connect(null, mapDispatchToProps)
|
||||
class LinkFooter extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
withHotkeys: PropTypes.bool,
|
||||
onLogout: PropTypes.func.isRequired,
|
||||
intl: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
handleLogoutClick = e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
this.props.onLogout();
|
||||
|
||||
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;
|
||||
|
|
|
@ -11,7 +11,7 @@ const mapStateToProps = (state, { intl }) => {
|
|||
const value = notification[key];
|
||||
|
||||
if (typeof value === 'object') {
|
||||
notification[key] = intl.formatMessage(value);
|
||||
notification[key] = intl.formatMessage(value, notification[`${key}_values`]);
|
||||
}
|
||||
}));
|
||||
|
||||
|
|
|
@ -141,14 +141,24 @@ class SwitchingColumnsArea extends React.PureComponent {
|
|||
return location.state !== previewMediaState && location.state !== previewVideoState;
|
||||
}
|
||||
|
||||
handleResize = debounce(() => {
|
||||
handleLayoutChange = debounce(() => {
|
||||
// The cached heights are no longer accurate, invalidate
|
||||
this.props.onLayoutChange();
|
||||
|
||||
this.setState({ mobile: isMobile(window.innerWidth) });
|
||||
}, 500, {
|
||||
trailing: true,
|
||||
});
|
||||
})
|
||||
|
||||
handleResize = () => {
|
||||
const mobile = isMobile(window.innerWidth);
|
||||
|
||||
if (mobile !== this.state.mobile) {
|
||||
this.handleLayoutChange.cancel();
|
||||
this.props.onLayoutChange();
|
||||
this.setState({ mobile });
|
||||
} else {
|
||||
this.handleLayoutChange();
|
||||
}
|
||||
}
|
||||
|
||||
setRef = c => {
|
||||
this.node = c.getWrappedInstance();
|
||||
|
|
|
@ -137,3 +137,7 @@ export function Search () {
|
|||
export function Tesseract () {
|
||||
return import(/*webpackChunkName: "tesseract" */'tesseract.js');
|
||||
}
|
||||
|
||||
export function Audio () {
|
||||
return import(/* webpackChunkName: "features/audio" */'../../audio');
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ const messages = defineMessages({
|
|||
exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
|
||||
});
|
||||
|
||||
const formatTime = secondsNum => {
|
||||
export const formatTime = secondsNum => {
|
||||
let hours = Math.floor(secondsNum / 3600);
|
||||
let minutes = Math.floor((secondsNum - (hours * 3600)) / 60);
|
||||
let seconds = secondsNum - (hours * 3600) - (minutes * 60);
|
||||
|
|
|
@ -741,6 +741,27 @@
|
|||
],
|
||||
"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": [
|
||||
{
|
||||
|
@ -1096,15 +1117,6 @@
|
|||
],
|
||||
"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": [
|
||||
{
|
||||
|
@ -1317,8 +1329,8 @@
|
|||
{
|
||||
"descriptors": [
|
||||
{
|
||||
"defaultMessage": "Refresh",
|
||||
"id": "trends.refresh"
|
||||
"defaultMessage": "Trending now",
|
||||
"id": "trends.trending_now"
|
||||
}
|
||||
],
|
||||
"path": "app/javascript/mastodon/features/getting_started/components/trends.json"
|
||||
|
@ -1456,6 +1468,10 @@
|
|||
},
|
||||
{
|
||||
"descriptors": [
|
||||
{
|
||||
"defaultMessage": "Basic",
|
||||
"id": "home.column_settings.basic"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Show boosts",
|
||||
"id": "home.column_settings.show_reblogs"
|
||||
|
@ -1837,14 +1853,6 @@
|
|||
"defaultMessage": "Push notifications",
|
||||
"id": "notifications.column_settings.push"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Basic",
|
||||
"id": "home.column_settings.basic"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Update in real-time",
|
||||
"id": "home.column_settings.update_live"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "Quick filter bar",
|
||||
"id": "notifications.column_settings.filter_bar.category"
|
||||
|
@ -1903,10 +1911,6 @@
|
|||
},
|
||||
{
|
||||
"descriptors": [
|
||||
{
|
||||
"defaultMessage": "and {count, plural, one {# other} other {# others}}",
|
||||
"id": "notification.and_n_others"
|
||||
},
|
||||
{
|
||||
"defaultMessage": "{name} followed you",
|
||||
"id": "notification.follow"
|
||||
|
|
|
@ -162,7 +162,6 @@
|
|||
"home.column_settings.basic": "Basic",
|
||||
"home.column_settings.show_reblogs": "Show boosts",
|
||||
"home.column_settings.show_replies": "Show replies",
|
||||
"home.column_settings.update_live": "Update in real-time",
|
||||
"intervals.full.days": "{number, plural, one {# day} other {# days}}",
|
||||
"intervals.full.hours": "{number, plural, one {# hour} other {# hours}}",
|
||||
"intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}",
|
||||
|
@ -258,7 +257,6 @@
|
|||
"navigation_bar.profile_directory": "Profile directory",
|
||||
"navigation_bar.public_timeline": "Federated timeline",
|
||||
"navigation_bar.security": "Security",
|
||||
"notification.and_n_others": "and {count, plural, one {# other} other {# others}}",
|
||||
"notification.favourite": "{name} favourited your status",
|
||||
"notification.follow": "{name} followed you",
|
||||
"notification.mention": "{name} mentioned you",
|
||||
|
@ -378,7 +376,7 @@
|
|||
"time_remaining.moments": "Moments remaining",
|
||||
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
|
||||
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
|
||||
"trends.refresh": "Refresh",
|
||||
"trends.trending_now": "Trending now",
|
||||
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
|
||||
"upload_area.title": "Drag & drop to upload",
|
||||
"upload_button.label": "Add media ({formats})",
|
||||
|
|
|
@ -14,6 +14,7 @@ export default function alerts(state = initialState, action) {
|
|||
key: state.size > 0 ? state.last().get('key') + 1 : 0,
|
||||
title: action.title,
|
||||
message: action.message,
|
||||
message_values: action.message_values,
|
||||
}));
|
||||
case ALERT_DISMISS:
|
||||
return state.filterNot(item => item.get('key') === action.alert.key);
|
||||
|
|
|
@ -17,6 +17,7 @@ import {
|
|||
COMPOSE_SUGGESTIONS_CLEAR,
|
||||
COMPOSE_SUGGESTIONS_READY,
|
||||
COMPOSE_SUGGESTION_SELECT,
|
||||
COMPOSE_SUGGESTION_TAGS_UPDATE,
|
||||
COMPOSE_TAG_HISTORY_UPDATE,
|
||||
COMPOSE_SENSITIVITY_CHANGE,
|
||||
COMPOSE_SPOILERNESS_CHANGE,
|
||||
|
@ -205,16 +206,36 @@ const expiresInFromExpiresAt = expires_at => {
|
|||
return [300, 1800, 3600, 21600, 86400, 259200, 604800].find(expires_in => expires_in >= delta) || 24 * 3600;
|
||||
};
|
||||
|
||||
const normalizeSuggestions = (state, { accounts, emojis, tags }) => {
|
||||
const mergeLocalHashtagResults = (suggestions, prefix, tagHistory) => {
|
||||
prefix = prefix.toLowerCase();
|
||||
if (suggestions.length < 4) {
|
||||
const localTags = tagHistory.filter(tag => tag.toLowerCase().startsWith(prefix) && !suggestions.some(suggestion => suggestion.type === 'hashtag' && suggestion.name.toLowerCase() === tag.toLowerCase()));
|
||||
return suggestions.concat(localTags.slice(0, 4 - suggestions.length).toJS().map(tag => ({ type: 'hashtag', name: tag })));
|
||||
} else {
|
||||
return suggestions;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeSuggestions = (state, { accounts, emojis, tags, token }) => {
|
||||
if (accounts) {
|
||||
return accounts.map(item => ({ id: item.id, type: 'account' }));
|
||||
} else if (emojis) {
|
||||
return emojis.map(item => ({ ...item, type: 'emoji' }));
|
||||
} else {
|
||||
return sortHashtagsByUse(state, tags.map(item => ({ ...item, type: 'hashtag' })));
|
||||
return mergeLocalHashtagResults(sortHashtagsByUse(state, tags.map(item => ({ ...item, type: 'hashtag' }))), token.slice(1), state.get('tagHistory'));
|
||||
}
|
||||
};
|
||||
|
||||
const updateSuggestionTags = (state, token) => {
|
||||
const prefix = token.slice(1);
|
||||
|
||||
const suggestions = state.get('suggestions').toJS();
|
||||
return state.merge({
|
||||
suggestions: ImmutableList(mergeLocalHashtagResults(suggestions, prefix, state.get('tagHistory'))),
|
||||
suggestion_token: token,
|
||||
});
|
||||
};
|
||||
|
||||
export default function compose(state = initialState, action) {
|
||||
switch(action.type) {
|
||||
case STORE_HYDRATE:
|
||||
|
@ -328,6 +349,8 @@ export default function compose(state = initialState, action) {
|
|||
return state.set('suggestions', ImmutableList(normalizeSuggestions(state, action))).set('suggestion_token', action.token);
|
||||
case COMPOSE_SUGGESTION_SELECT:
|
||||
return insertSuggestion(state, action.position, action.token, action.completion, action.path);
|
||||
case COMPOSE_SUGGESTION_TAGS_UPDATE:
|
||||
return updateSuggestionTags(state, action.token);
|
||||
case COMPOSE_TAG_HISTORY_UPDATE:
|
||||
return state.set('tagHistory', fromJS(action.tags));
|
||||
case TIMELINE_DELETE:
|
||||
|
|
|
@ -128,6 +128,7 @@ export const getAlerts = createSelector([getAlertsBase], (base) => {
|
|||
base.forEach(item => {
|
||||
arr.push({
|
||||
message: item.get('message'),
|
||||
message_values: item.get('message_values'),
|
||||
title: item.get('title'),
|
||||
key: item.get('key'),
|
||||
dismissAfter: 5000,
|
||||
|
|
33
app/javascript/mastodon/utils/log_out.js
Normal file
33
app/javascript/mastodon/utils/log_out.js
Normal 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();
|
||||
};
|
|
@ -457,6 +457,13 @@ h5 {
|
|||
.status {
|
||||
padding-bottom: 32px;
|
||||
|
||||
&--highlighted {
|
||||
border: 1px solid lighten($ui-base-color, 8%);
|
||||
border-radius: 4px;
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.status-header {
|
||||
td {
|
||||
font-size: 14px;
|
||||
|
|
|
@ -104,7 +104,8 @@ html {
|
|||
.box-widget input[type="email"],
|
||||
.box-widget input[type="password"],
|
||||
.box-widget textarea,
|
||||
.statuses-grid .detailed-status {
|
||||
.statuses-grid .detailed-status,
|
||||
.audio-player {
|
||||
border: 1px solid lighten($ui-base-color, 8%);
|
||||
}
|
||||
|
||||
|
@ -700,3 +701,10 @@ html {
|
|||
.compose-form .compose-form__warning {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.audio-player .video-player__controls button,
|
||||
.audio-player .video-player__time-sep,
|
||||
.audio-player .video-player__time-current,
|
||||
.audio-player .video-player__time-total {
|
||||
color: $primary-text-color;
|
||||
}
|
||||
|
|
|
@ -948,7 +948,8 @@
|
|||
opacity: 1;
|
||||
animation: fade 150ms linear;
|
||||
|
||||
.video-player {
|
||||
.video-player,
|
||||
.audio-player {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
|
@ -1043,7 +1044,8 @@
|
|||
white-space: normal;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
.video-player,
|
||||
.audio-player {
|
||||
margin-top: 8px;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
@ -1154,7 +1156,8 @@
|
|||
}
|
||||
}
|
||||
|
||||
.video-player {
|
||||
.video-player,
|
||||
.audio-player {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
@ -2130,7 +2133,8 @@ a.account__display-name {
|
|||
padding: 15px;
|
||||
|
||||
.media-gallery,
|
||||
.video-player {
|
||||
.video-player,
|
||||
.audio-player {
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
|
@ -2172,7 +2176,8 @@ a.account__display-name {
|
|||
|
||||
.media-gallery,
|
||||
&__action-bar,
|
||||
.video-player {
|
||||
.video-player,
|
||||
.audio-player {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
@ -2765,6 +2770,15 @@ a.account__display-name {
|
|||
animation: fade 150ms linear;
|
||||
margin-top: 10px;
|
||||
|
||||
h4 {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
color: $darker-text-color;
|
||||
padding: 10px;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid lighten($ui-base-color, 8%);
|
||||
}
|
||||
|
||||
@media screen and (max-height: 810px) {
|
||||
.trends__item:nth-child(3) {
|
||||
display: none;
|
||||
|
@ -5034,15 +5048,63 @@ a.status-card.compact:hover {
|
|||
|
||||
}
|
||||
|
||||
.audio-player {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
background: darken($ui-base-color, 8%);
|
||||
border-radius: 4px;
|
||||
padding-bottom: 44px;
|
||||
|
||||
&.editable {
|
||||
border-radius: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__waveform {
|
||||
padding: 15px 0;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
position: absolute;
|
||||
border-top: 1px solid lighten($ui-base-color, 4%);
|
||||
width: 100%;
|
||||
height: 0;
|
||||
left: 0;
|
||||
top: calc(50% + 1px);
|
||||
}
|
||||
}
|
||||
|
||||
&__progress-placeholder {
|
||||
background-color: rgba(lighten($ui-highlight-color, 8%), 0.5);
|
||||
}
|
||||
|
||||
&__wave-placeholder {
|
||||
background-color: lighten($ui-base-color, 16%);
|
||||
}
|
||||
|
||||
.video-player__controls {
|
||||
padding: 0 15px;
|
||||
padding-top: 10px;
|
||||
background: darken($ui-base-color, 8%);
|
||||
border-top: 1px solid lighten($ui-base-color, 4%);
|
||||
border-radius: 0 0 4px 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.video-player {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
background: $base-shadow-color;
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&.editable {
|
||||
border-radius: 0;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
|
|
|
@ -70,7 +70,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
|
|||
end
|
||||
|
||||
def delete_now!
|
||||
RemoveStatusService.new.call(@status)
|
||||
RemoveStatusService.new.call(@status, redraft: false)
|
||||
end
|
||||
|
||||
def payload
|
||||
|
|
|
@ -5,6 +5,7 @@ class UserMailer < Devise::Mailer
|
|||
|
||||
helper :application
|
||||
helper :instance
|
||||
helper :statuses
|
||||
|
||||
add_template_helper RoutingHelper
|
||||
|
||||
|
@ -79,10 +80,11 @@ class UserMailer < Devise::Mailer
|
|||
end
|
||||
end
|
||||
|
||||
def warning(user, warning)
|
||||
def warning(user, warning, status_ids = nil)
|
||||
@resource = user
|
||||
@warning = warning
|
||||
@instance = Rails.configuration.x.local_domain
|
||||
@statuses = Status.where(id: status_ids).includes(:account) if status_ids.is_a?(Array)
|
||||
|
||||
I18n.with_locale(@resource.locale || I18n.default_locale) do
|
||||
mail to: @resource.email,
|
||||
|
|
|
@ -19,20 +19,25 @@ class Admin::AccountAction
|
|||
:report_id,
|
||||
:warning_preset_id
|
||||
|
||||
attr_reader :warning, :send_email_notification
|
||||
attr_reader :warning, :send_email_notification, :include_statuses
|
||||
|
||||
def send_email_notification=(value)
|
||||
@send_email_notification = ActiveModel::Type::Boolean.new.cast(value)
|
||||
end
|
||||
|
||||
def include_statuses=(value)
|
||||
@include_statuses = ActiveModel::Type::Boolean.new.cast(value)
|
||||
end
|
||||
|
||||
def save!
|
||||
ApplicationRecord.transaction do
|
||||
process_action!
|
||||
process_warning!
|
||||
end
|
||||
|
||||
queue_email!
|
||||
process_email!
|
||||
process_reports!
|
||||
process_queue!
|
||||
end
|
||||
|
||||
def report
|
||||
|
@ -110,7 +115,6 @@ class Admin::AccountAction
|
|||
authorize(target_account, :suspend?)
|
||||
log_action(:suspend, target_account)
|
||||
target_account.suspend!
|
||||
queue_suspension_worker!
|
||||
end
|
||||
|
||||
def text_for_warning
|
||||
|
@ -121,16 +125,22 @@ class Admin::AccountAction
|
|||
Admin::SuspensionWorker.perform_async(target_account.id)
|
||||
end
|
||||
|
||||
def queue_email!
|
||||
return unless warnable?
|
||||
def process_queue!
|
||||
queue_suspension_worker! if type == 'suspend'
|
||||
end
|
||||
|
||||
UserMailer.warning(target_account.user, warning).deliver_later!
|
||||
def process_email!
|
||||
UserMailer.warning(target_account.user, warning, status_ids).deliver_now! if warnable?
|
||||
end
|
||||
|
||||
def warnable?
|
||||
send_email_notification && target_account.local?
|
||||
end
|
||||
|
||||
def status_ids
|
||||
@report.status_ids if @report && include_statuses
|
||||
end
|
||||
|
||||
def warning_preset
|
||||
@warning_preset ||= AccountWarningPreset.find(warning_preset_id) if warning_preset_id.present?
|
||||
end
|
||||
|
|
|
@ -34,7 +34,8 @@ class Form::StatusBatch
|
|||
|
||||
def delete_statuses
|
||||
Status.where(id: status_ids).reorder(nil).find_each do |status|
|
||||
RemovalWorker.perform_async(status.id)
|
||||
status.discard
|
||||
RemovalWorker.perform_async(status.id, redraft: false)
|
||||
Tombstone.find_or_create_by(uri: status.uri, account: status.account, by_moderator: true)
|
||||
log_action :destroy, status
|
||||
end
|
||||
|
|
|
@ -43,7 +43,7 @@ class Report < ApplicationRecord
|
|||
end
|
||||
|
||||
def statuses
|
||||
Status.where(id: status_ids).includes(:account, :media_attachments, :mentions)
|
||||
Status.with_discarded.where(id: status_ids).includes(:account, :media_attachments, :mentions)
|
||||
end
|
||||
|
||||
def media_attachments
|
||||
|
|
|
@ -25,15 +25,19 @@
|
|||
# full_status_text :text default(""), not null
|
||||
# poll_id :bigint(8)
|
||||
# content_type :string
|
||||
# deleted_at :datetime
|
||||
#
|
||||
|
||||
class Status < ApplicationRecord
|
||||
before_destroy :unlink_from_conversations
|
||||
|
||||
include Discard::Model
|
||||
include Paginable
|
||||
include Cacheable
|
||||
include StatusThreadingConcern
|
||||
|
||||
self.discard_column = :deleted_at
|
||||
|
||||
# If `override_timestamps` is set at creation time, Snowflake ID creation
|
||||
# will be based on current time instead of `created_at`
|
||||
attr_accessor :override_timestamps
|
||||
|
@ -77,7 +81,7 @@ class Status < ApplicationRecord
|
|||
|
||||
accepts_nested_attributes_for :poll
|
||||
|
||||
default_scope { recent }
|
||||
default_scope { recent.kept }
|
||||
|
||||
scope :recent, -> { reorder(id: :desc) }
|
||||
scope :remote, -> { where(local: false).where.not(uri: nil) }
|
||||
|
|
|
@ -8,7 +8,7 @@ class BatchedRemoveStatusService < BaseService
|
|||
# Dispatch Salmon deletes, unique per domain, of the deleted statuses, but only local ones
|
||||
# Remove statuses from home feeds
|
||||
# Push delete events to streaming API for home feeds and public feeds
|
||||
# @param [Status] statuses A preferably batched array of statuses
|
||||
# @param [Enumerable<Status>] statuses A preferably batched array of statuses
|
||||
# @param [Hash] options
|
||||
# @option [Boolean] :skip_side_effects
|
||||
def call(statuses, **options)
|
||||
|
|
|
@ -4,6 +4,11 @@ class RemoveStatusService < BaseService
|
|||
include Redisable
|
||||
include Payloadable
|
||||
|
||||
# Delete a status
|
||||
# @param [Status] status
|
||||
# @param [Hash] options
|
||||
# @option [Boolean] :redraft
|
||||
# @options [Boolean] :original_removed
|
||||
def call(status, **options)
|
||||
@payload = Oj.dump(event: :delete, payload: status.id.to_s)
|
||||
@status = status
|
||||
|
@ -25,6 +30,7 @@ class RemoveStatusService < BaseService
|
|||
remove_from_media if status.media_attachments.any?
|
||||
remove_from_direct if status.direct_visibility?
|
||||
remove_from_spam_check
|
||||
remove_media
|
||||
|
||||
@status.destroy!
|
||||
else
|
||||
|
@ -151,6 +157,12 @@ class RemoveStatusService < BaseService
|
|||
end
|
||||
end
|
||||
|
||||
def remove_media
|
||||
return if @options[:redraft]
|
||||
|
||||
@status.media_attachments.destroy_all
|
||||
end
|
||||
|
||||
def remove_from_spam_check
|
||||
redis.zremrangebyscore("spam_check:#{@status.account_id}", @status.id, @status.id)
|
||||
end
|
||||
|
|
|
@ -13,6 +13,10 @@
|
|||
.fields-group
|
||||
= f.input :send_email_notification, as: :boolean, wrapper: :with_label
|
||||
|
||||
- if params[:report_id].present?
|
||||
.fields-group
|
||||
= f.input :include_statuses, as: :boolean, wrapper: :with_label
|
||||
|
||||
%hr.spacer/
|
||||
|
||||
- unless @warning_presets.empty?
|
||||
|
|
|
@ -105,7 +105,7 @@
|
|||
%li
|
||||
= feature_hint(t('admin.dashboard.authorized_fetch_mode'), @authorized_fetch)
|
||||
%li
|
||||
= feature_hint(t('admin.dashboard.whitelist_mode'), @whitelist_mode)
|
||||
= feature_hint(t('admin.dashboard.whitelist_mode'), @whitelist_enabled)
|
||||
%li
|
||||
= feature_hint('LDAP', @ldap_enabled)
|
||||
%li
|
||||
|
|
|
@ -16,11 +16,14 @@
|
|||
- video = status.proper.media_attachments.first
|
||||
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.proper.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description
|
||||
- else
|
||||
= react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.proper.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }
|
||||
= react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.proper.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.proper.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }
|
||||
|
||||
.detailed-status__meta
|
||||
= link_to ActivityPub::TagManager.instance.url_for(status), class: 'detailed-status__datetime', target: stream_link_target, rel: 'noopener' do
|
||||
%time.formatted{ datetime: status.created_at.iso8601, title: l(status.created_at) }= l(status.created_at)
|
||||
- if status.discarded?
|
||||
·
|
||||
%span.negative-hint= t('admin.statuses.deleted')
|
||||
·
|
||||
- if status.reblog?
|
||||
= fa_icon('retweet fw')
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
- i ||= 0
|
||||
- highlighted ||= false
|
||||
|
||||
%table.email-table{ cellspacing: 0, cellpadding: 0, dir: 'ltr' }
|
||||
%tbody
|
||||
|
@ -14,7 +15,7 @@
|
|||
%table.column{ cellspacing: 0, cellpadding: 0 }
|
||||
%tbody
|
||||
%tr
|
||||
%td.column-cell.padded.status
|
||||
%td.column-cell.padded.status{ class: highlighted ? 'status--highlighted' : '' }
|
||||
%table.status-header{ cellspacing: 0, cellpadding: 0 }
|
||||
%tbody
|
||||
%tr
|
||||
|
@ -32,5 +33,10 @@
|
|||
%div{ dir: rtl_status?(status) ? 'rtl' : 'ltr' }
|
||||
= Formatter.instance.format(status)
|
||||
|
||||
- if status.media_attachments.size > 0
|
||||
%p
|
||||
- status.media_attachments.each do |a|
|
||||
= link_to medium_url(a), medium_url(a)
|
||||
|
||||
%p.status-footer
|
||||
= link_to l(status.created_at), web_url("statuses/#{status.id}")
|
||||
|
|
|
@ -27,10 +27,14 @@
|
|||
= render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay }
|
||||
|
||||
- if !status.media_attachments.empty?
|
||||
- if status.media_attachments.first.audio_or_video?
|
||||
- if status.media_attachments.first.video?
|
||||
- video = status.media_attachments.first
|
||||
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- elsif status.media_attachments.first.audio?
|
||||
- audio = status.media_attachments.first
|
||||
= react_component :audio, src: audio.file.url(:original), height: 130, alt: audio.description, preload: true, duration: audio.file.meta.dig(:original, :duration) do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- else
|
||||
= react_component :media_gallery, height: 380, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
|
|
|
@ -31,10 +31,14 @@
|
|||
= render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay }
|
||||
|
||||
- if !status.media_attachments.empty?
|
||||
- if status.media_attachments.first.audio_or_video?
|
||||
- if status.media_attachments.first.video?
|
||||
- video = status.media_attachments.first
|
||||
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- elsif status.media_attachments.first.audio?
|
||||
- audio = status.media_attachments.first
|
||||
= react_component :audio, src: audio.file.url(:original), height: 110, alt: audio.description, duration: audio.file.meta.dig(:original, :duration) do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
- else
|
||||
= react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
|
||||
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
|
||||
|
|
|
@ -42,6 +42,14 @@
|
|||
- unless @warning.text.blank?
|
||||
= Formatter.instance.linkify(@warning.text)
|
||||
|
||||
- unless @statuses.empty?
|
||||
%p
|
||||
%strong= t('user_mailer.warning.statuses')
|
||||
|
||||
- unless @statuses.empty?
|
||||
- @statuses.each_with_index do |status, i|
|
||||
= render 'notification_mailer/status', status: status, i: i + 1, highlighted: true
|
||||
|
||||
%table.email-table{ cellspacing: 0, cellpadding: 0 }
|
||||
%tbody
|
||||
%tr
|
||||
|
@ -50,7 +58,7 @@
|
|||
%table.content-section{ cellspacing: 0, cellpadding: 0 }
|
||||
%tbody
|
||||
%tr
|
||||
%td.content-cell
|
||||
%td.content-cell{ class: @statuses.empty? ? '' : 'content-start' }
|
||||
%table.column{ cellspacing: 0, cellpadding: 0 }
|
||||
%tbody
|
||||
%tr
|
||||
|
@ -61,3 +69,20 @@
|
|||
%td.button-primary
|
||||
= link_to about_more_url do
|
||||
%span= t 'user_mailer.warning.review_server_policies'
|
||||
|
||||
%table.email-table{ cellspacing: 0, cellpadding: 0 }
|
||||
%tbody
|
||||
%tr
|
||||
%td.email-body
|
||||
.email-container
|
||||
%table.content-section{ cellspacing: 0, cellpadding: 0 }
|
||||
%tbody
|
||||
%tr
|
||||
%td.content-cell
|
||||
.email-row
|
||||
.col-6
|
||||
%table.column{ cellspacing: 0, cellpadding: 0 }
|
||||
%tbody
|
||||
%tr
|
||||
%td.column-cell.text-center
|
||||
%p= t 'user_mailer.warning.get_in_touch', instance: @instance
|
||||
|
|
|
@ -7,3 +7,16 @@
|
|||
|
||||
<% end %>
|
||||
<%= @warning.text %>
|
||||
<% unless @statuses.empty? %>
|
||||
<%= t('user_mailer.warning.statuses') %>
|
||||
|
||||
<% @statuses.each do |status| %>
|
||||
|
||||
<%= render 'notification_mailer/status', status: status %>
|
||||
---
|
||||
<% end %>
|
||||
<% else %>
|
||||
---
|
||||
<% end %>
|
||||
|
||||
<%= t 'user_mailer.warning.get_in_touch', instance: @instance %>
|
||||
|
|
|
@ -3,8 +3,8 @@
|
|||
class RemovalWorker
|
||||
include Sidekiq::Worker
|
||||
|
||||
def perform(status_id)
|
||||
RemoveStatusService.new.call(Status.find(status_id))
|
||||
def perform(status_id, options = {})
|
||||
RemoveStatusService.new.call(Status.with_discarded.find(status_id), **options.symbolize_keys)
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
true
|
||||
end
|
||||
|
|
|
@ -512,6 +512,7 @@ en:
|
|||
delete: Delete
|
||||
nsfw_off: Mark as not sensitive
|
||||
nsfw_on: Mark as sensitive
|
||||
deleted: Deleted
|
||||
failed_to_execute: Failed to execute
|
||||
media:
|
||||
title: Media
|
||||
|
@ -1129,7 +1130,9 @@ en:
|
|||
disable: While your account is frozen, your account data remains intact, but you cannot perform any actions until it is unlocked.
|
||||
silence: While your account is limited, only people who are already following you will see your toots on this server, and you may be excluded from various public listings. However, others may still manually follow you.
|
||||
suspend: Your account has been suspended, and all of your toots and your uploaded media files have been irreversibly removed from this server, and servers where you had followers.
|
||||
get_in_touch: You can reply to this e-mail to get in touch with the staff of %{instance}.
|
||||
review_server_policies: Review server policies
|
||||
statuses: 'Specifically, for:'
|
||||
subject:
|
||||
disable: Your account %{acct} has been frozen
|
||||
none: Warning for %{acct}
|
||||
|
|
|
@ -5,6 +5,7 @@ en:
|
|||
account_warning_preset:
|
||||
text: You can use toot syntax, such as URLs, hashtags and mentions
|
||||
admin_account_action:
|
||||
include_statuses: The user will see which toots have caused the moderation action or warning
|
||||
send_email_notification: The user will receive an explanation of what happened with their account
|
||||
text_html: Optional. You can use toot syntax. You can <a href="%{path}">add warning presets</a> to save time
|
||||
type_html: Choose what to do with <strong>%{acct}</strong>
|
||||
|
@ -65,6 +66,7 @@ en:
|
|||
account_warning_preset:
|
||||
text: Preset text
|
||||
admin_account_action:
|
||||
include_statuses: Include reported toots in the e-mail
|
||||
send_email_notification: Notify the user per e-mail
|
||||
text: Custom warning
|
||||
type: Action
|
||||
|
@ -156,6 +158,7 @@ en:
|
|||
trending_tag: Send e-mail when an unreviewed hashtag is trending
|
||||
tag:
|
||||
listable: Allow this hashtag to appear in searches and on the profile directory
|
||||
name: Hashtag
|
||||
trendable: Allow this hashtag to appear under trends
|
||||
usable: Allow toots to use this hashtag
|
||||
'no': 'No'
|
||||
|
|
5
db/migrate/20190819134503_add_deleted_at_to_statuses.rb
Normal file
5
db/migrate/20190819134503_add_deleted_at_to_statuses.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
class AddDeletedAtToStatuses < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
add_column :statuses, :deleted_at, :datetime
|
||||
end
|
||||
end
|
13
db/migrate/20190820003045_update_statuses_index.rb
Normal file
13
db/migrate/20190820003045_update_statuses_index.rb
Normal file
|
@ -0,0 +1,13 @@
|
|||
class UpdateStatusesIndex < ActiveRecord::Migration[5.2]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
safety_assured { add_index :statuses, [:account_id, :id, :visibility, :updated_at], where: 'deleted_at IS NULL', order: { id: :desc }, algorithm: :concurrently, name: :index_statuses_20190820 }
|
||||
remove_index :statuses, name: :index_statuses_20180106
|
||||
end
|
||||
|
||||
def down
|
||||
safety_assured { add_index :statuses, [:account_id, :id, :visibility, :updated_at], order: { id: :desc }, algorithm: :concurrently, name: :index_statuses_20180106 }
|
||||
remove_index :statuses, name: :index_statuses_20190820
|
||||
end
|
||||
end
|
11
db/migrate/20190823221802_add_local_index_to_statuses.rb
Normal file
11
db/migrate/20190823221802_add_local_index_to_statuses.rb
Normal 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
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 2019_08_15_225426) do
|
||||
ActiveRecord::Schema.define(version: 2019_08_23_221802) do
|
||||
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
@ -657,7 +657,9 @@ ActiveRecord::Schema.define(version: 2019_08_15_225426) do
|
|||
t.boolean "local_only"
|
||||
t.bigint "poll_id"
|
||||
t.string "content_type"
|
||||
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20180106", order: { id: :desc }
|
||||
t.datetime "deleted_at"
|
||||
t.index ["account_id", "id", "visibility", "updated_at"], name: "index_statuses_20190820", order: { id: :desc }, where: "(deleted_at IS NULL)"
|
||||
t.index ["id", "account_id"], name: "index_statuses_local_20190824", order: { id: :desc }, where: "((local OR (uri IS NULL)) AND (deleted_at IS NULL) AND (visibility = 0) AND (reblog_of_id IS NULL) AND ((NOT reply) OR (in_reply_to_account_id = account_id)))"
|
||||
t.index ["in_reply_to_account_id"], name: "index_statuses_on_in_reply_to_account_id"
|
||||
t.index ["in_reply_to_id"], name: "index_statuses_on_in_reply_to_id"
|
||||
t.index ["reblog_of_id", "account_id"], name: "index_statuses_on_reblog_of_id_and_account_id"
|
||||
|
|
|
@ -61,7 +61,7 @@
|
|||
"private": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.4.5",
|
||||
"@babel/plugin-proposal-class-properties": "^7.5.0",
|
||||
"@babel/plugin-proposal-class-properties": "^7.5.5",
|
||||
"@babel/plugin-proposal-decorators": "^7.4.4",
|
||||
"@babel/plugin-proposal-object-rest-spread": "^7.4.4",
|
||||
"@babel/plugin-syntax-dynamic-import": "^7.2.0",
|
||||
|
@ -137,7 +137,7 @@
|
|||
"react-motion": "^0.5.2",
|
||||
"react-notification": "^6.8.4",
|
||||
"react-overlays": "^0.8.3",
|
||||
"react-redux": "^7.1.0",
|
||||
"react-redux": "^7.1.1",
|
||||
"react-redux-loading-bar": "^4.0.8",
|
||||
"react-router-dom": "^4.1.1",
|
||||
"react-router-scroll-4": "^1.0.0-beta.1",
|
||||
|
@ -163,10 +163,11 @@
|
|||
"throng": "^4.0.0",
|
||||
"tiny-queue": "^0.2.1",
|
||||
"uuid": "^3.1.0",
|
||||
"wavesurfer.js": "^3.0.0",
|
||||
"webpack": "^4.35.3",
|
||||
"webpack-assets-manifest": "^3.1.1",
|
||||
"webpack-bundle-analyzer": "^3.3.2",
|
||||
"webpack-cli": "^3.3.6",
|
||||
"webpack-cli": "^3.3.7",
|
||||
"webpack-merge": "^4.2.1",
|
||||
"websocket.js": "^0.1.12"
|
||||
},
|
||||
|
|
|
@ -47,7 +47,7 @@ describe Admin::ReportedStatusesController do
|
|||
it 'removes a status' do
|
||||
allow(RemovalWorker).to receive(:perform_async)
|
||||
subject.call
|
||||
expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first)
|
||||
expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first, redraft: false)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -65,7 +65,7 @@ describe Admin::StatusesController do
|
|||
it 'removes a status' do
|
||||
allow(RemovalWorker).to receive(:perform_async)
|
||||
subject.call
|
||||
expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first)
|
||||
expect(RemovalWorker).to have_received(:perform_async).with(status_ids.first, redraft: false)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -42,6 +42,6 @@ class UserMailerPreview < ActionMailer::Preview
|
|||
|
||||
# Preview this email at http://localhost:3000/rails/mailers/user_mailer/warning
|
||||
def warning
|
||||
UserMailer.warning(User.first, AccountWarning.new(text: '', action: :silence))
|
||||
UserMailer.warning(User.first, AccountWarning.new(text: '', action: :silence), [Status.first.id])
|
||||
end
|
||||
end
|
||||
|
|
|
@ -58,8 +58,8 @@ RSpec.describe Admin::AccountAction, type: :model do
|
|||
end.to change { Admin::ActionLog.count }.by 1
|
||||
end
|
||||
|
||||
it 'calls queue_email!' do
|
||||
expect(account_action).to receive(:queue_email!)
|
||||
it 'calls process_email!' do
|
||||
expect(account_action).to receive(:process_email!)
|
||||
subject
|
||||
end
|
||||
|
||||
|
|
|
@ -41,12 +41,12 @@ describe Form::StatusBatch do
|
|||
|
||||
it 'call RemovalWorker' do
|
||||
form.save
|
||||
expect(RemovalWorker).to have_received(:perform_async).with(status.id)
|
||||
expect(RemovalWorker).to have_received(:perform_async).with(status.id, redraft: false)
|
||||
end
|
||||
|
||||
it 'do not call RemovalWorker' do
|
||||
form.save
|
||||
expect(RemovalWorker).not_to have_received(:perform_async).with(another_status.id)
|
||||
expect(RemovalWorker).not_to have_received(:perform_async).with(another_status.id, redraft: false)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
100
yarn.lock
100
yarn.lock
|
@ -121,16 +121,16 @@
|
|||
"@babel/traverse" "^7.4.4"
|
||||
"@babel/types" "^7.4.4"
|
||||
|
||||
"@babel/helper-create-class-features-plugin@^7.4.4", "@babel/helper-create-class-features-plugin@^7.5.0":
|
||||
version "7.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.5.0.tgz#02edb97f512d44ba23b3227f1bf2ed43454edac5"
|
||||
integrity sha512-EAoMc3hE5vE5LNhMqDOwB1usHvmRjCDAnH8CD4PVkX9/Yr3W/tcz8xE8QvdZxfsFBDICwZnF2UTHIqslRpvxmA==
|
||||
"@babel/helper-create-class-features-plugin@^7.4.4", "@babel/helper-create-class-features-plugin@^7.5.5":
|
||||
version "7.5.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.5.5.tgz#401f302c8ddbc0edd36f7c6b2887d8fa1122e5a4"
|
||||
integrity sha512-ZsxkyYiRA7Bg+ZTRpPvB6AbOFKTFFK4LrvTet8lInm0V468MWCaSYJE+I7v2z2r8KNLtYiV+K5kTCnR7dvyZjg==
|
||||
dependencies:
|
||||
"@babel/helper-function-name" "^7.1.0"
|
||||
"@babel/helper-member-expression-to-functions" "^7.0.0"
|
||||
"@babel/helper-member-expression-to-functions" "^7.5.5"
|
||||
"@babel/helper-optimise-call-expression" "^7.0.0"
|
||||
"@babel/helper-plugin-utils" "^7.0.0"
|
||||
"@babel/helper-replace-supers" "^7.4.4"
|
||||
"@babel/helper-replace-supers" "^7.5.5"
|
||||
"@babel/helper-split-export-declaration" "^7.4.4"
|
||||
|
||||
"@babel/helper-define-map@^7.5.5":
|
||||
|
@ -173,13 +173,6 @@
|
|||
dependencies:
|
||||
"@babel/types" "^7.4.4"
|
||||
|
||||
"@babel/helper-member-expression-to-functions@^7.0.0":
|
||||
version "7.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.0.0.tgz#8cd14b0a0df7ff00f009e7d7a436945f47c7a16f"
|
||||
integrity sha512-avo+lm/QmZlv27Zsi0xEor2fKcqWG56D5ae9dzklpIaY7cQMK5N8VSpaNVPPagiqmy7LrEjK1IWdGMOqPu5csg==
|
||||
dependencies:
|
||||
"@babel/types" "^7.0.0"
|
||||
|
||||
"@babel/helper-member-expression-to-functions@^7.5.5":
|
||||
version "7.5.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.5.5.tgz#1fb5b8ec4453a93c439ee9fe3aeea4a84b76b590"
|
||||
|
@ -236,16 +229,6 @@
|
|||
"@babel/traverse" "^7.1.0"
|
||||
"@babel/types" "^7.0.0"
|
||||
|
||||
"@babel/helper-replace-supers@^7.4.4":
|
||||
version "7.4.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.4.4.tgz#aee41783ebe4f2d3ab3ae775e1cc6f1a90cefa27"
|
||||
integrity sha512-04xGEnd+s01nY1l15EuMS1rfKktNF+1CkKmHoErDppjAAZL+IUBZpzT748x262HF7fibaQPhbvWUl5HeSt1EXg==
|
||||
dependencies:
|
||||
"@babel/helper-member-expression-to-functions" "^7.0.0"
|
||||
"@babel/helper-optimise-call-expression" "^7.0.0"
|
||||
"@babel/traverse" "^7.4.4"
|
||||
"@babel/types" "^7.4.4"
|
||||
|
||||
"@babel/helper-replace-supers@^7.5.5":
|
||||
version "7.5.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.5.5.tgz#f84ce43df031222d2bad068d2626cb5799c34bc2"
|
||||
|
@ -327,12 +310,12 @@
|
|||
"@babel/helper-remap-async-to-generator" "^7.1.0"
|
||||
"@babel/plugin-syntax-async-generators" "^7.2.0"
|
||||
|
||||
"@babel/plugin-proposal-class-properties@^7.5.0":
|
||||
version "7.5.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.5.0.tgz#5bc6a0537d286fcb4fd4e89975adbca334987007"
|
||||
integrity sha512-9L/JfPCT+kShiiTTzcnBJ8cOwdKVmlC1RcCf9F0F9tERVrM4iWtWnXtjWCRqNm2la2BxO1MPArWNsU9zsSJWSQ==
|
||||
"@babel/plugin-proposal-class-properties@^7.5.5":
|
||||
version "7.5.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.5.5.tgz#a974cfae1e37c3110e71f3c6a2e48b8e71958cd4"
|
||||
integrity sha512-AF79FsnWFxjlaosgdi421vmYG6/jg79bVD0dpD44QdgobzHKuLZ6S3vl8la9qIeSwGi8i1fS0O1mfuDAAdo1/A==
|
||||
dependencies:
|
||||
"@babel/helper-create-class-features-plugin" "^7.5.0"
|
||||
"@babel/helper-create-class-features-plugin" "^7.5.5"
|
||||
"@babel/helper-plugin-utils" "^7.0.0"
|
||||
|
||||
"@babel/plugin-proposal-decorators@^7.4.4":
|
||||
|
@ -811,10 +794,10 @@
|
|||
dependencies:
|
||||
regenerator-runtime "^0.12.0"
|
||||
|
||||
"@babel/runtime@^7.1.2", "@babel/runtime@^7.4.2", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.4":
|
||||
version "7.5.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.5.4.tgz#cb7d1ad7c6d65676e66b47186577930465b5271b"
|
||||
integrity sha512-Na84uwyImZZc3FKf4aUF1tysApzwf3p2yuFBIyBfbzT5glzKTdvYI4KVW4kcgjrzoGUjC7w3YyCHcJKaRxsr2Q==
|
||||
"@babel/runtime@^7.1.2", "@babel/runtime@^7.4.2", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.4", "@babel/runtime@^7.5.5":
|
||||
version "7.5.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.5.5.tgz#74fba56d35efbeca444091c7850ccd494fd2f132"
|
||||
integrity sha512-28QvEGyQyNkB0/m2B4FU7IEZGK2NUrcMtT6BZEFALTguLk+AUT6ofsHtPk5QyjAdUkpMJ+/Em+quwz4HOt30AQ==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.13.2"
|
||||
|
||||
|
@ -3858,14 +3841,16 @@ eslint-scope@^5.0.0:
|
|||
estraverse "^4.1.1"
|
||||
|
||||
eslint-utils@^1.3.1:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.3.1.tgz#9a851ba89ee7c460346f97cf8939c7298827e512"
|
||||
integrity sha512-Z7YjnIldX+2XMcjr7ZkgEsOj/bREONV60qYeB/bjMAqqqZ4zxKyWX+BOUkdmRmA9riiIPVvo5x86m5elviOk0Q==
|
||||
version "1.4.2"
|
||||
resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.4.2.tgz#166a5180ef6ab7eb462f162fd0e6f2463d7309ab"
|
||||
integrity sha512-eAZS2sEUMlIeCjBeubdj45dmBHQwPHWyBcT1VSYB7o9x9WRRqKxyUoiXlRjyAwzN7YEzHJlYg0NmzDRWx6GP4Q==
|
||||
dependencies:
|
||||
eslint-visitor-keys "^1.0.0"
|
||||
|
||||
eslint-visitor-keys@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#3f3180fb2e291017716acb4c9d6d5b5c34a6a81d"
|
||||
integrity sha512-qzm/XxIbxm/FHyH341ZrbnMUpe+5Bocte9xkmFMzPMjRaZMcXww+MpBptFvtU+79L362nqiLhekCxCxDPaUMBQ==
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.1.0.tgz#e2a82cea84ff246ad6fb57f9bde5b46621459ec2"
|
||||
integrity sha512-8y9YjtM1JBJU/A9Kc+SbaOV4y29sSWckBwMHa+FGtVj5gN/sbnKDf6xJUl+8g7FAij9LVaP8C24DUiH/f/2Z9A==
|
||||
|
||||
eslint@^2.7.0:
|
||||
version "2.13.1"
|
||||
|
@ -6764,9 +6749,9 @@ mississippi@^3.0.0:
|
|||
through2 "^2.0.0"
|
||||
|
||||
mixin-deep@^1.2.0:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.1.tgz#a49e7268dce1a0d9698e45326c5626df3543d0fe"
|
||||
integrity sha512-8ZItLHeEgaqEvd5lYBXfm4EZSFCX29Jb9K+lAHhDKzReKBQKj3R+7NOF6tjqYi9t4oI8VUfaWITJQm86wnXGNQ==
|
||||
version "1.3.2"
|
||||
resolved "https://registry.yarnpkg.com/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566"
|
||||
integrity sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==
|
||||
dependencies:
|
||||
for-in "^1.0.2"
|
||||
is-extendable "^1.0.1"
|
||||
|
@ -8484,10 +8469,10 @@ react-intl@^2.9.0:
|
|||
intl-relativeformat "^2.1.0"
|
||||
invariant "^2.1.1"
|
||||
|
||||
react-is@^16.3.2, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6:
|
||||
version "16.8.6"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.8.6.tgz#5bbc1e2d29141c9fbdfed456343fe2bc430a6a16"
|
||||
integrity sha512-aUk3bHfZ2bRSVFFbbeVS4i+lNPZr3/WM5jT2J5omUVV1zzcs1nAaf3l51ctA5FFvCRbhrH0bdAsRRQddFJZPtA==
|
||||
react-is@^16.3.2, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.8.4, react-is@^16.8.6, react-is@^16.9.0:
|
||||
version "16.9.0"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.9.0.tgz#21ca9561399aad0ff1a7701c01683e8ca981edcb"
|
||||
integrity sha512-tJBzzzIgnnRfEm046qRcURvwQnZVXmuCbscxUO5RWrGTXpon2d4c8mI0D8WE6ydVIm29JiLB6+RslkIvym9Rjw==
|
||||
|
||||
react-lifecycles-compat@^3.0.2, react-lifecycles-compat@^3.0.4:
|
||||
version "3.0.4"
|
||||
|
@ -8539,17 +8524,17 @@ react-redux-loading-bar@^4.0.8:
|
|||
prop-types "^15.6.2"
|
||||
react-lifecycles-compat "^3.0.2"
|
||||
|
||||
react-redux@^7.1.0:
|
||||
version "7.1.0"
|
||||
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.1.0.tgz#72af7cf490a74acdc516ea9c1dd80e25af9ea0b2"
|
||||
integrity sha512-hyu/PoFK3vZgdLTg9ozbt7WF3GgX5+Yn3pZm5/96/o4UueXA+zj08aiSC9Mfj2WtD1bvpIb3C5yvskzZySzzaw==
|
||||
react-redux@^7.1.1:
|
||||
version "7.1.1"
|
||||
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-7.1.1.tgz#ce6eee1b734a7a76e0788b3309bf78ff6b34fa0a"
|
||||
integrity sha512-QsW0vcmVVdNQzEkrgzh2W3Ksvr8cqpAv5FhEk7tNEft+5pp7rXxAudTz3VOPawRkLIepItpkEIyLcN/VVXzjTg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.4.5"
|
||||
"@babel/runtime" "^7.5.5"
|
||||
hoist-non-react-statics "^3.3.0"
|
||||
invariant "^2.2.4"
|
||||
loose-envify "^1.4.0"
|
||||
prop-types "^15.7.2"
|
||||
react-is "^16.8.6"
|
||||
react-is "^16.9.0"
|
||||
|
||||
react-router-dom@^4.1.1:
|
||||
version "4.3.1"
|
||||
|
@ -10474,6 +10459,11 @@ watchpack@^1.5.0:
|
|||
graceful-fs "^4.1.2"
|
||||
neo-async "^2.5.0"
|
||||
|
||||
wavesurfer.js@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/wavesurfer.js/-/wavesurfer.js-3.0.0.tgz#35f36d76d59c749dca453cf4e10ee0ec49f454f8"
|
||||
integrity sha512-DANu206c6gb9pSUbYFevsSiXMy8+Ri+CNtqm0UsouUdsn9fVQRtYs8uxzBtXK+rUPlIc6FlO54DU8uWeW3lDzw==
|
||||
|
||||
wbuf@^1.1.0, wbuf@^1.7.3:
|
||||
version "1.7.3"
|
||||
resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df"
|
||||
|
@ -10518,10 +10508,10 @@ webpack-bundle-analyzer@^3.3.2:
|
|||
opener "^1.5.1"
|
||||
ws "^6.0.0"
|
||||
|
||||
webpack-cli@^3.3.6:
|
||||
version "3.3.6"
|
||||
resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-3.3.6.tgz#2c8c399a2642133f8d736a359007a052e060032c"
|
||||
integrity sha512-0vEa83M7kJtxK/jUhlpZ27WHIOndz5mghWL2O53kiDoA9DIxSKnfqB92LoqEn77cT4f3H2cZm1BMEat/6AZz3A==
|
||||
webpack-cli@^3.3.7:
|
||||
version "3.3.7"
|
||||
resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-3.3.7.tgz#77c8580dd8e92f69d635e0238eaf9d9c15759a91"
|
||||
integrity sha512-OhTUCttAsr+IZSMVwGROGRHvT+QAs8H6/mHIl4SvhAwYywjiylYjpwybGx7WQ9Hkb45FhjtsymkwiRRbGJ1SZQ==
|
||||
dependencies:
|
||||
chalk "2.4.2"
|
||||
cross-spawn "6.0.5"
|
||||
|
|
Loading…
Reference in a new issue