Browse Source

Merge branch 'master' into live

master
Zac 2 months ago
parent
commit
67327dd4d3
87 changed files with 1353 additions and 244 deletions
  1. 2
    1
      Gemfile
  2. 8
    5
      Gemfile.lock
  3. 2
    2
      app/controllers/admin/account_actions_controller.rb
  4. 1
    1
      app/controllers/api/v1/reports_controller.rb
  5. 2
    1
      app/controllers/api/v1/statuses/reblogs_controller.rb
  6. 2
    1
      app/controllers/api/v1/statuses_controller.rb
  7. 10
    2
      app/javascript/flavours/glitch/actions/alerts.js
  8. 26
    5
      app/javascript/flavours/glitch/components/status.js
  9. 1
    1
      app/javascript/flavours/glitch/components/status_content.js
  10. 2
    1
      app/javascript/flavours/glitch/containers/media_container.js
  11. 226
    0
      app/javascript/flavours/glitch/features/audio/index.js
  12. 11
    1
      app/javascript/flavours/glitch/features/compose/components/header.js
  13. 15
    1
      app/javascript/flavours/glitch/features/compose/containers/header_container.js
  14. 1
    3
      app/javascript/flavours/glitch/features/getting_started/index.js
  15. 16
    2
      app/javascript/flavours/glitch/features/status/components/detailed_status.js
  16. 13
    1
      app/javascript/flavours/glitch/features/ui/components/focal_point_modal.js
  17. 64
    29
      app/javascript/flavours/glitch/features/ui/components/link_footer.js
  18. 1
    1
      app/javascript/flavours/glitch/features/ui/containers/notifications_container.js
  19. 14
    4
      app/javascript/flavours/glitch/features/ui/index.js
  20. 1
    1
      app/javascript/flavours/glitch/features/video/index.js
  21. 1
    0
      app/javascript/flavours/glitch/reducers/alerts.js
  22. 1
    0
      app/javascript/flavours/glitch/selectors/index.js
  23. 48
    0
      app/javascript/flavours/glitch/styles/components/media.scss
  24. 4
    2
      app/javascript/flavours/glitch/styles/components/single_column.scss
  25. 6
    3
      app/javascript/flavours/glitch/styles/components/status.scss
  26. 7
    0
      app/javascript/flavours/glitch/styles/mastodon-light/diff.scss
  27. 4
    0
      app/javascript/flavours/glitch/util/async-components.js
  28. 34
    0
      app/javascript/flavours/glitch/util/log_out.js
  29. 10
    2
      app/javascript/mastodon/actions/alerts.js
  30. 2
    0
      app/javascript/mastodon/actions/compose.js
  31. 3
    3
      app/javascript/mastodon/components/autosuggest_hashtag.js
  32. 13
    1
      app/javascript/mastodon/components/column_back_button.js
  33. 13
    1
      app/javascript/mastodon/components/column_header.js
  34. 24
    4
      app/javascript/mastodon/components/status.js
  35. 1
    1
      app/javascript/mastodon/components/status_content.js
  36. 2
    1
      app/javascript/mastodon/containers/media_container.js
  37. 226
    0
      app/javascript/mastodon/features/audio/index.js
  38. 6
    1
      app/javascript/mastodon/features/compose/components/action_bar.js
  39. 2
    1
      app/javascript/mastodon/features/compose/components/navigation_bar.js
  40. 19
    1
      app/javascript/mastodon/features/compose/containers/navigation_container.js
  41. 20
    1
      app/javascript/mastodon/features/compose/index.js
  42. 4
    1
      app/javascript/mastodon/features/getting_started/components/trends.js
  43. 14
    1
      app/javascript/mastodon/features/status/components/detailed_status.js
  44. 13
    1
      app/javascript/mastodon/features/ui/components/focal_point_modal.js
  45. 66
    29
      app/javascript/mastodon/features/ui/components/link_footer.js
  46. 1
    1
      app/javascript/mastodon/features/ui/containers/notifications_container.js
  47. 14
    4
      app/javascript/mastodon/features/ui/index.js
  48. 4
    0
      app/javascript/mastodon/features/ui/util/async-components.js
  49. 1
    1
      app/javascript/mastodon/features/video/index.js
  50. 27
    23
      app/javascript/mastodon/locales/defaultMessages.json
  51. 1
    3
      app/javascript/mastodon/locales/en.json
  52. 1
    0
      app/javascript/mastodon/reducers/alerts.js
  53. 25
    2
      app/javascript/mastodon/reducers/compose.js
  54. 1
    0
      app/javascript/mastodon/selectors/index.js
  55. 33
    0
      app/javascript/mastodon/utils/log_out.js
  56. 7
    0
      app/javascript/styles/mailer.scss
  57. 9
    1
      app/javascript/styles/mastodon-light/diff.scss
  58. 67
    5
      app/javascript/styles/mastodon/components.scss
  59. 1
    1
      app/lib/activitypub/activity/delete.rb
  60. 3
    1
      app/mailers/user_mailer.rb
  61. 16
    6
      app/models/admin/account_action.rb
  62. 2
    1
      app/models/form/status_batch.rb
  63. 1
    1
      app/models/report.rb
  64. 5
    1
      app/models/status.rb
  65. 1
    1
      app/services/batched_remove_status_service.rb
  66. 12
    0
      app/services/remove_status_service.rb
  67. 4
    0
      app/views/admin/account_actions/new.html.haml
  68. 1
    1
      app/views/admin/dashboard/index.html.haml
  69. 4
    1
      app/views/admin/reports/_status.html.haml
  70. 7
    1
      app/views/notification_mailer/_status.html.haml
  71. 5
    1
      app/views/statuses/_detailed_status.html.haml
  72. 5
    1
      app/views/statuses/_simple_status.html.haml
  73. 26
    1
      app/views/user_mailer/warning.html.haml
  74. 13
    0
      app/views/user_mailer/warning.text.erb
  75. 2
    2
      app/workers/removal_worker.rb
  76. 5
    2
      config/locales/en.yml
  77. 3
    0
      config/locales/simple_form.en.yml
  78. 5
    0
      db/migrate/20190819134503_add_deleted_at_to_statuses.rb
  79. 13
    0
      db/migrate/20190820003045_update_statuses_index.rb
  80. 11
    0
      db/migrate/20190823221802_add_local_index_to_statuses.rb
  81. 4
    3
      package.json
  82. 1
    1
      spec/controllers/admin/reported_statuses_controller_spec.rb
  83. 1
    1
      spec/controllers/admin/statuses_controller_spec.rb
  84. 1
    1
      spec/mailers/previews/user_mailer_preview.rb
  85. 2
    2
      spec/models/admin/account_action_spec.rb
  86. 2
    2
      spec/models/form/status_batch_spec.rb
  87. 45
    55
      yarn.lock

+ 2
- 1
Gemfile View File

@@ -31,7 +31,7 @@ gem 'charlock_holmes', '~> 0.7.6'
gem 'iso-639'
gem 'chewy', '~> 5.0'
gem 'cld3', '~> 3.2.4'
gem 'devise', '~> 4.6'
gem 'devise', '~> 4.7'
gem 'devise-two-factor', '~> 3.1'

group :pam_authentication, optional: true do
@@ -43,6 +43,7 @@ gem 'omniauth-cas', '~> 1.1'
gem 'omniauth-saml', '~> 1.10'
gem 'omniauth', '~> 1.9'

gem 'discard', '~> 1.1'
gem 'doorkeeper', '~> 5.1'
gem 'fast_blank', '~> 1.0'
gem 'fastimage'

+ 8
- 5
Gemfile.lock View File

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

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

@@ -5,7 +5,7 @@ module Admin
before_action :set_account

def new
@account_action = Admin::AccountAction.new(type: params[:type], report_id: params[:report_id], send_email_notification: true)
@account_action = Admin::AccountAction.new(type: params[:type], report_id: params[:report_id], send_email_notification: true, include_statuses: true)
@warning_presets = AccountWarningPreset.all
end

@@ -30,7 +30,7 @@ module Admin
end

def resource_params
params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification)
params.require(:admin_account_action).permit(:type, :report_id, :warning_preset_id, :text, :send_email_notification, :include_statuses)
end
end
end

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

@@ -21,7 +21,7 @@ class Api::V1::ReportsController < Api::BaseController
private

def reported_status_ids
reported_account.statuses.find(status_ids).pluck(:id)
reported_account.statuses.with_discarded.find(status_ids).pluck(:id)
end

def status_ids

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

@@ -18,6 +18,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
@reblogs_map = { @status.id => false }

authorize status_for_destroy, :unreblog?
status_for_destroy.discard
RemovalWorker.perform_async(status_for_destroy.id)

render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, reblogs_map: @reblogs_map)
@@ -30,7 +31,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController
end

def status_for_destroy
current_user.account.statuses.where(reblog_of_id: params[:status_id]).first!
@status_for_destroy ||= current_user.account.statuses.where(reblog_of_id: params[:status_id]).first!
end

def reblog_params

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

@@ -54,7 +54,8 @@ class Api::V1::StatusesController < Api::BaseController
@status = Status.where(account_id: current_user.account).find(params[:id])
authorize @status, :destroy?

RemovalWorker.perform_async(@status.id)
@status.discard
RemovalWorker.perform_async(@status.id, redraft: true)

render json: @status, serializer: REST::StatusSerializer, source_requested: true
end

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

@@ -3,6 +3,8 @@ import { defineMessages } from 'react-intl';
const messages = defineMessages({
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
rateLimitedTitle: { id: 'alert.rate_limited.title', defaultMessage: 'Rate limited' },
rateLimitedMessage: { id: 'alert.rate_limited.message', defaultMessage: 'Please retry after {retry_time, time, medium}.' },
});

export const ALERT_SHOW = 'ALERT_SHOW';
@@ -23,23 +25,29 @@ export function clearAlert() {
};
};

export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage) {
export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, message_values = undefined) {
return {
type: ALERT_SHOW,
title,
message,
message_values,
};
};

export function showAlertForError(error) {
if (error.response) {
const { data, status, statusText } = error.response;
const { data, status, statusText, headers } = error.response;

if (status === 404 || status === 410) {
// Skip these errors as they are reflected in the UI
return { type: ALERT_NOOP };
}

if (status === 429 && headers['x-ratelimit-reset']) {
const reset_date = new Date(headers['x-ratelimit-reset']);
return showAlert(messages.rateLimitedTitle, messages.rateLimitedMessage, { 'retry_time': reset_date });
}

let message = statusText;
let title = `${status}`;


+ 26
- 5
app/javascript/flavours/glitch/components/status.js View File

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

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

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

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

@@ -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
- 0
app/javascript/flavours/glitch/features/audio/index.js View File

@@ -0,0 +1,226 @@
import React from 'react';
import PropTypes from 'prop-types';
import WaveSurfer from 'wavesurfer.js';
import { defineMessages, injectIntl } from 'react-intl';
import { formatTime } from '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>
);
}

}

+ 11
- 1
app/javascript/flavours/glitch/features/compose/components/header.js View File

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

+ 15
- 1
app/javascript/flavours/glitch/features/compose/containers/header_container.js View File

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

+ 1
- 3
app/javascript/flavours/glitch/features/getting_started/index.js View File

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

+ 16
- 2
app/javascript/flavours/glitch/features/status/components/detailed_status.js View File

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

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

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

+ 64
- 29
app/javascript/flavours/glitch/features/ui/components/link_footer.js View File

@@ -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>
<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 = {
};
const messages = defineMessages({
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
});
const mapDispatchToProps = (dispatch, { intl }) => ({
onLogout () {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.logoutMessage),
confirm: intl.formatMessage(messages.logoutConfirm),
onConfirm: () => logOut(),
}));
},
});
export default @injectIntl
@connect(null, mapDispatchToProps)
class LinkFooter extends React.PureComponent {
static propTypes = {
onLogout: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};
handleLogoutClick = e => {
e.preventDefault();
e.stopPropagation();

export default LinkFooter;
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>
);
}

};

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

@@ -11,7 +11,7 @@ const mapStateToProps = (state, { intl }) => {
const value = notification[key];

if (typeof value === 'object') {
notification[key] = intl.formatMessage(value);
notification[key] = intl.formatMessage(value, notification[`${key}_values`]);
}
}));


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

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

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

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

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

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

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

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

+ 48
- 0
app/javascript/flavours/glitch/styles/components/media.scss View File

@@ -333,15 +333,63 @@

}

.audio-player {
box-sizing: border-box;
position: relative;
background: darken($ui-base-color, 8%);
border-radius: 4px;
padding-bottom: 44px;

&.editable {
border-radius: 0;
height: 100%;
}

&__waveform {
padding: 15px 0;
position: relative;
overflow: hidden;

&::before {
content: "";
display: block;
position: absolute;
border-top: 1px solid lighten($ui-base-color, 4%);
width: 100%;
height: 0;
left: 0;
top: calc(50% + 1px);
}
}

&__progress-placeholder {
background-color: rgba(lighten($ui-highlight-color, 8%), 0.5);
}

&__wave-placeholder {
background-color: lighten($ui-base-color, 16%);
}

.video-player__controls {
padding: 0 15px;
padding-top: 10px;
background: darken($ui-base-color, 8%);
border-top: 1px solid lighten($ui-base-color, 4%);
border-radius: 0 0 4px 4px;
}
}

.video-player {
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 {

+ 4
- 2
app/javascript/flavours/glitch/styles/components/single_column.scss View File

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

+ 6
- 3
app/javascript/flavours/glitch/styles/components/status.scss View File

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

+ 7
- 0
app/javascript/flavours/glitch/styles/mastodon-light/diff.scss View File

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

+ 4
- 0
app/javascript/flavours/glitch/util/async-components.js View File

@@ -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
- 0
app/javascript/flavours/glitch/util/log_out.js View File

@@ -0,0 +1,34 @@
import Rails from 'rails-ujs';
import { signOutLink } from 'flavours/glitch/util/backend_links';

export const logOut = () => {
const form = document.createElement('form');

const methodInput = document.createElement('input');
methodInput.setAttribute('name', '_method');
methodInput.setAttribute('value', 'delete');
methodInput.setAttribute('type', 'hidden');
form.appendChild(methodInput);

const csrfToken = Rails.csrfToken();
const csrfParam = Rails.csrfParam();

if (csrfParam && csrfToken) {
const csrfInput = document.createElement('input');
csrfInput.setAttribute('name', csrfParam);
csrfInput.setAttribute('value', csrfToken);
csrfInput.setAttribute('type', 'hidden');
form.appendChild(csrfInput);
}

const submitButton = document.createElement('input');
submitButton.setAttribute('type', 'submit');
form.appendChild(submitButton);

form.method = 'post';
form.action = signOutLink;
form.style.display = 'none';

document.body.appendChild(form);
submitButton.click();
};

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

@@ -3,6 +3,8 @@ import { defineMessages } from 'react-intl';
const messages = defineMessages({
unexpectedTitle: { id: 'alert.unexpected.title', defaultMessage: 'Oops!' },
unexpectedMessage: { id: 'alert.unexpected.message', defaultMessage: 'An unexpected error occurred.' },
rateLimitedTitle: { id: 'alert.rate_limited.title', defaultMessage: 'Rate limited' },
rateLimitedMessage: { id: 'alert.rate_limited.message', defaultMessage: 'Please retry after {retry_time, time, medium}.' },
});

export const ALERT_SHOW = 'ALERT_SHOW';
@@ -23,23 +25,29 @@ export function clearAlert() {
};
};

export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage) {
export function showAlert(title = messages.unexpectedTitle, message = messages.unexpectedMessage, message_values = undefined) {
return {
type: ALERT_SHOW,
title,
message,
message_values,
};
};

export function showAlertForError(error) {
if (error.response) {
const { data, status, statusText } = error.response;
const { data, status, statusText, headers } = error.response;

if (status === 404 || status === 410) {
// Skip these errors as they are reflected in the UI
return { type: ALERT_NOOP };
}

if (status === 429 && headers['x-ratelimit-reset']) {
const reset_date = new Date(headers['x-ratelimit-reset']);
return showAlert(messages.rateLimitedTitle, messages.rateLimitedMessage, { 'retry_time': reset_date });
}

let message = statusText;
let title = `${status}`;


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

@@ -356,6 +356,8 @@ const fetchComposeSuggestionsTags = throttle((dispatch, getState, token) => {
cancelFetchComposeSuggestionsTags();
}

dispatch(updateSuggestionTags(token));

api(getState).get('/api/v2/search', {
cancelToken: new CancelToken(cancel => {
cancelFetchComposeSuggestionsTags = cancel;

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

@@ -9,18 +9,18 @@ export default class AutosuggestHashtag extends React.PureComponent {
tag: PropTypes.shape({
name: PropTypes.string.isRequired,
url: PropTypes.string,
history: PropTypes.array.isRequired,
history: PropTypes.array,
}).isRequired,
};

render () {
const { tag } = this.props;
const weeklyUses = shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0));
const weeklyUses = tag.history && shortNumberFormat(tag.history.reduce((total, day) => total + (day.uses * 1), 0));

return (
<div className='autosuggest-hashtag'>
<div className='autosuggest-hashtag__name'>#<strong>{tag.name}</strong></div>
<div className='autosuggest-hashtag__uses'><FormattedMessage id='autosuggest_hashtag.per_week' defaultMessage='{count} per week' values={{ count: weeklyUses }} /></div>
{tag.history !== undefined && <div className='autosuggest-hashtag__uses'><FormattedMessage id='autosuggest_hashtag.per_week' defaultMessage='{count} per week' values={{ count: weeklyUses }} /></div>}
</div>
);
}

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

@@ -35,7 +35,19 @@ export default class ColumnBackButton extends React.PureComponent {
if (multiColumn) {
return component;
} else {
return createPortal(component, document.getElementById('tabs-bar__portal'));
// The portal container and the component may be rendered to the DOM in
// the same React render pass, so the container might not be available at
// the time `render()` is called.
const container = document.getElementById('tabs-bar__portal');
if (container === null) {
// The container wasn't available, force a re-render so that the
// component can eventually be inserted in the container and not scroll
// with the rest of the area.
this.forceUpdate();
return component;
} else {
return createPortal(component, container);
}
}
}


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

@@ -178,7 +178,19 @@ class ColumnHeader extends React.PureComponent {
if (multiColumn || placeholder) {
return component;
} else {
return createPortal(component, document.getElementById('tabs-bar__portal'));
// The portal container and the component may be rendered to the DOM in
// the same React render pass, so the container might not be available at
// the time `render()` is called.
const container = document.getElementById('tabs-bar__portal');
if (container === null) {
// The container wasn't available, force a re-render so that the
// component can eventually be inserted in the container and not scroll
// with the rest of the area.
this.forceUpdate();
return component;
} else {
return createPortal(component, container);
}
}
}


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

@@ -12,7 +12,7 @@ import AttachmentList from './attachment_list';
import Card from '../features/status/components/card';
import { injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { MediaGallery, Video } from '../features/ui/util/async-components';
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
import { HotKeys } from 'react-hotkeys';
import classNames from 'classnames';
import Icon from 'mastodon/components/icon';
@@ -199,11 +199,15 @@ class Status extends ImmutablePureComponent {
};

renderLoadingMediaGallery () {
return <div className='media_gallery' style={{ height: '110px' }} />;
return <div className='media-gallery' style={{ height: '110px' }} />;
}

renderLoadingVideoPlayer () {
return <div className='media-spoiler-video' style={{ height: '110px' }} />;
return <div className='video-player' style={{ height: '110px' }} />;
}

renderLoadingAudioPlayer () {
return <div className='audio-player' style={{ height: '110px' }} />;
}

handleOpenVideo = (media, startTime) => {
@@ -348,7 +352,23 @@ class Status extends ImmutablePureComponent {
media={status.get('media_attachments')}
/>
);
} else if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) {
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]);

media = (
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
{Component => (
<Component
src={attachment.get('url')}
alt={attachment.get('description')}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
peaks={[0]}
height={70}
/>
)}
</Bundle>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const attachment = status.getIn(['media_attachments', 0]);

media = (

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

@@ -230,7 +230,7 @@ export default class StatusContent extends React.PureComponent {
);
} else if (this.props.onClick) {
const output = [
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content'>
<div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} />

{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}

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

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

const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag };
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };

export default class MediaContainer extends PureComponent {


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

@@ -0,0 +1,226 @@
import React from 'react';
import PropTypes from 'prop-types';
import WaveSurfer from 'wavesurfer.js';
import { defineMessages, injectIntl } from 'react-intl';
import { formatTime } from 'mastodon/features/video';
import Icon from 'mastodon/components/icon';
import classNames from 'classnames';
import { throttle } from 'lodash';

const messages = defineMessages({
play: { id: 'video.play', defaultMessage: 'Play' },
pause: { id: 'video.pause', defaultMessage: 'Pause' },
mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
});

export default @injectIntl
class Audio extends React.PureComponent {

static propTypes = {
src: PropTypes.string.isRequired,
alt: PropTypes.string,
duration: PropTypes.number,
peaks: PropTypes.arrayOf(PropTypes.number),
height: PropTypes.number,
preload: PropTypes.bool,
editable: PropTypes.bool,
intl: PropTypes.object.isRequired,
};

state = {
currentTime: 0,
duration: null,
paused: true,
muted: false,
volume: 0.5,
};

// hard coded in components.scss
// any way to get ::before values programatically?

volWidth = 50;

volOffset = 70;

volHandleOffset = v => {
const offset = v * this.volWidth + this.volOffset;
return (offset > 110) ? 110 : offset;
}

setVolumeRef = c => {
this.volume = c;
}

setWaveformRef = c => {
this.waveform = c;
}

componentDidMount () {
if (this.waveform) {
this._updateWaveform();
}
}

componentDidUpdate (prevProps) {
if (this.waveform && prevProps.src !== this.props.src) {
this._updateWaveform();
}
}

componentWillUnmount () {
if (this.wavesurfer) {
this.wavesurfer.destroy();
this.wavesurfer = null;
}
}

_updateWaveform () {
const { src, height, duration, peaks, preload } = this.props;

const progressColor = window.getComputedStyle(document.querySelector('.audio-player__progress-placeholder')).getPropertyValue('background-color');
const waveColor = window.getComputedStyle(document.querySelector('.audio-player__wave-placeholder')).getPropertyValue('background-color');

if (this.wavesurfer) {
this.wavesurfer.destroy();
this.loaded = false;
}

const wavesurfer = WaveSurfer.create({
container: this.waveform,
height,
barWidth: 3,
cursorWidth: 0,
progressColor,
waveColor,
backend: 'MediaElement',
interact: preload,
});

wavesurfer.setVolume(this.state.volume);

if (preload) {
wavesurfer.load(src);
this.loaded = true;
} else {
wavesurfer.load(src, peaks, 'none', duration);
this.loaded = false;
}

wavesurfer.on('ready', () => this.setState({ duration: Math.floor(wavesurfer.getDuration()) }));
wavesurfer.on('audioprocess', () => this.setState({ currentTime: Math.floor(wavesurfer.getCurrentTime()) }));
wavesurfer.on('pause', () => this.setState({ paused: true }));
wavesurfer.on('play', () => this.setState({ paused: false }));
wavesurfer.on('volume', volume => this.setState({ volume }));
wavesurfer.on('mute', muted => this.setState({ muted }));

this.wavesurfer = wavesurfer;
}

togglePlay = () => {
if (this.state.paused) {
if (!this.props.preload && !this.loaded) {
this.wavesurfer.createBackend();
this.wavesurfer.createPeakCache();
this.wavesurfer.load(this.props.src);
this.wavesurfer.toggleInteraction();
this.loaded = true;
}

this.wavesurfer.play();
this.setState({ paused: false });
} else {
this.wavesurfer.pause();
this.setState({ paused: true });
}
}

toggleMute = () => {
this.wavesurfer.setMute(!this.state.muted);
}

handleVolumeMouseDown = e => {
document.addEventListener('mousemove', this.handleMouseVolSlide, true);
document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
document.addEventListener('touchmove', this.handleMouseVolSlide, true);
document.addEventListener('touchend', this.handleVolumeMouseUp, true);

this.handleMouseVolSlide(e);

e.preventDefault();
e.stopPropagation();
}

handleVolumeMouseUp = () => {
document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
}

handleMouseVolSlide = throttle(e => {
const rect = this.volume.getBoundingClientRect();
const x = (e.clientX - rect.left) / this.volWidth; // x position within the element.

if(!isNaN(x)) {
let slideamt = x;

if (x > 1) {
slideamt = 1;
} else if(x < 0) {
slideamt = 0;
}

this.wavesurfer.setVolume(slideamt);
}
}, 60);

render () {
const { height, intl, alt, editable } = this.props;
const { paused, muted, volume, currentTime } = this.state;

const volumeWidth = muted ? 0 : volume * this.volWidth;
const volumeHandleLoc = muted ? this.volHandleOffset(0) : this.volHandleOffset(volume);

return (
<div className={classNames('audio-player', { editable })}>
<div className='audio-player__progress-placeholder' style={{ display: 'none' }} />
<div className='audio-player__wave-placeholder' style={{ display: 'none' }} />

<div
className='audio-player__waveform'
aria-label={alt}
title={alt}
style={{ height }}
ref={this.setWaveformRef}
/>

<div className='video-player__controls active'>
<div className='video-player__buttons-bar'>
<div className='video-player__buttons left'>
<button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
<button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>

<div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
<div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />

<span
className={classNames('video-player__volume__handle')}
tabIndex='0'
style={{ left: `${volumeHandleLoc}px` }}
/>
</div>

<span>
<span className='video-player__time-current'>{formatTime(currentTime)}</span>
<span className='video-player__time-sep'>/</span>
<span className='video-player__time-total'>{formatTime(this.state.duration || Math.floor(this.props.duration))}</span>
</span>
</div>
</div>
</div>
</div>
);
}

}

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

@@ -23,9 +23,14 @@ class ActionBar extends React.PureComponent {

static propTypes = {
account: ImmutablePropTypes.map.isRequired,
onLogout: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};

handleLogout = () => {
this.props.onLogout();
}

render () {
const { intl } = this.props;

@@ -44,7 +49,7 @@ class ActionBar extends React.PureComponent {
menu.push({ text: intl.formatMessage(messages.domain_blocks), to: '/domain_blocks' });
menu.push({ text: intl.formatMessage(messages.filters), href: '/filters' });
menu.push(null);
menu.push({ text: intl.formatMessage(messages.logout), href: '/auth/sign_out', target: null, method: 'delete' });
menu.push({ text: intl.formatMessage(messages.logout), action: this.handleLogout });

return (
<div className='compose__action-bar'>

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

@@ -12,6 +12,7 @@ export default class NavigationBar extends ImmutablePureComponent {

static propTypes = {
account: ImmutablePropTypes.map.isRequired,
onLogout: PropTypes.func.isRequired,
onClose: PropTypes.func,
};

@@ -33,7 +34,7 @@ export default class NavigationBar extends ImmutablePureComponent {

<div className='navigation-bar__actions'>
<IconButton className='close' title='' icon='close' onClick={this.props.onClose} />
<ActionBar account={this.props.account} />
<ActionBar account={this.props.account} onLogout={this.props.onLogout} />
</div>
</div>
);

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

@@ -1,11 +1,29 @@
import { connect } from 'react-redux';
import { defineMessages, injectIntl } from 'react-intl';
import NavigationBar from '../components/navigation_bar';
import { logOut } from 'mastodon/utils/log_out';
import { openModal } from 'mastodon/actions/modal';
import { me } from '../../../initial_state';

const messages = defineMessages({
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
});

const mapStateToProps = state => {
return {
account: state.getIn(['accounts', me]),
};
};

export default connect(mapStateToProps)(NavigationBar);
const mapDispatchToProps = (dispatch, { intl }) => ({
onLogout () {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.logoutMessage),
confirm: intl.formatMessage(messages.logoutConfirm),
onConfirm: () => logOut(),
}));
},
});

export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(NavigationBar));

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

@@ -12,9 +12,11 @@ import Motion from '../ui/util/optional_motion';
import spring from 'react-motion/lib/spring';
import SearchResultsContainer from './containers/search_results_container';
import { changeComposing } from '../../actions/compose';
import { openModal } from 'mastodon/actions/modal';
import elephantUIPlane from '../../../images/elephant_ui_plane.svg';
import { mascot } from '../../initial_state';
import Icon from 'mastodon/components/icon';
import { logOut } from 'mastodon/utils/log_out';

const messages = defineMessages({
start: { id: 'getting_started.heading', defaultMessage: 'Getting started' },
@@ -25,6 +27,8 @@ const messages = defineMessages({
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
logout: { id: 'navigation_bar.logout', defaultMessage: 'Logout' },
compose: { id: 'navigation_bar.compose', defaultMessage: 'Compose new toot' },
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
});

const mapStateToProps = (state, ownProps) => ({
@@ -61,6 +65,21 @@ class Compose extends React.PureComponent {
}
}

handleLogoutClick = e => {
const { dispatch, intl } = this.props;

e.preventDefault();
e.stopPropagation();

dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.logoutMessage),
confirm: intl.formatMessage(messages.logoutConfirm),
onConfirm: () => logOut(),
}));

return false;
}

onFocus = () => {
this.props.dispatch(changeComposing(true));
}
@@ -92,7 +111,7 @@ class Compose extends React.PureComponent {
<Link to='/timelines/public' className='drawer__tab' title={intl.formatMessage(messages.public)} aria-label={intl.formatMessage(messages.public)}><Icon id='globe' fixedWidth /></Link>
)}
<a href='/settings/preferences' className='drawer__tab' title={intl.formatMessage(messages.preferences)} aria-label={intl.formatMessage(messages.preferences)}><Icon id='cog' fixedWidth /></a>
<a href='/auth/sign_out' className='drawer__tab' data-method='delete' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)}><Icon id='sign-out' fixedWidth /></a>
<a href='/auth/sign_out' className='drawer__tab' title={intl.formatMessage(messages.logout)} aria-label={intl.formatMessage(messages.logout)} onClick={this.handleLogoutClick}><Icon id='sign-out' fixedWidth /></a>
</nav>
);
}

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

@@ -3,6 +3,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes';
import Hashtag from 'mastodon/components/hashtag';
import { FormattedMessage } from 'react-intl';

export default class Trends extends ImmutablePureComponent {

@@ -17,7 +18,7 @@ export default class Trends extends ImmutablePureComponent {

componentDidMount () {
this.props.fetchTrends();
this.refreshInterval = setInterval(() => this.props.fetchTrends(), 36000);
this.refreshInterval = setInterval(() => this.props.fetchTrends(), 900 * 1000);
}

componentWillUnmount () {
@@ -35,6 +36,8 @@ export default class Trends extends ImmutablePureComponent {

return (
<div className='getting-started__trends'>
<h4><FormattedMessage id='trends.trending_now' defaultMessage='Trending now' /></h4>

{trends.take(3).map(hashtag => <Hashtag key={hashtag.get('name')} hashtag={hashtag} />)}
</div>
);

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

@@ -10,6 +10,7 @@ import { FormattedDate, FormattedNumber } from 'react-intl';
import Card from './card';
import ImmutablePureComponent from 'react-immutable-pure-component';
import Video from '../../video';
import Audio from '../../audio';
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
import classNames from 'classnames';
import Icon from 'mastodon/components/icon';
@@ -107,7 +108,19 @@ export default class DetailedStatus extends ImmutablePureComponent {
}

if (status.get('media_attachments').size > 0) {
if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) {
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]);

media = (
<Audio
src={attachment.get('url')}
alt={attachment.get('description')}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
height={110}
preload
/>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const attachment = status.getIn(['media_attachments', 0]);

media = (

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

@@ -10,6 +10,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import IconButton from 'mastodon/components/icon_button';
import Button from 'mastodon/components/button';
import Video from 'mastodon/features/video';
import Audio from 'mastodon/features/audio';
import Textarea from 'react-textarea-autosize';
import UploadProgress from 'mastodon/features/compose/components/upload_progress';
import CharacterCounter from 'mastodon/features/compose/components/character_counter';
@@ -244,12 +245,23 @@ class FocalPointModal extends ImmutablePureComponent {
</div>
)}

{['audio', 'video'].includes(media.get('type')) && (
{media.get('type') === 'video' && (
<Video
preview={media.get('preview_url')}
blurhash={media.get('blurhash')}
src={media.get('url')}
detailed
inline
editable
/>
)}

{media.get('type') === 'audio' && (
<Audio
src={media.get('url')}
duration={media.getIn(['meta', 'original', 'duration'], 0)}
height={150}
preload
editable
/>
)}

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

@@ -1,35 +1,72 @@
import { connect } from 'react-redux';
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import { Link } from 'react-router-dom';
import { invitesEnabled, version, repository, source_url } from 'mastodon/initial_state';
import { logOut } from 'mastodon/utils/log_out';
import { openModal } from 'mastodon/actions/modal';

const LinkFooter = ({ withHotkeys }) => (
<div className='getting-started__footer'>
<ul>
{invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
{withHotkeys && <li><Link to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link> · </li>}
<li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li>
<li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li>
<li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li>
<li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li>
<li><a href='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li>
<li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li>
<li><a href='/auth/sign_out' data-method='delete'><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
</ul>

<p>
<FormattedMessage
id='getting_started.open_source_notice'
defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}.'
values={{ github: <span><a href={source_url} rel='noopener' target='_blank'>{repository}</a> (v{version})</span> }}
/>
</p>
</div>
);

LinkFooter.propTypes = {
withHotkeys: PropTypes.bool,
};
const messages = defineMessages({
logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
logoutConfirm: { id: 'confirmations.logout.confirm', defaultMessage: 'Log out' },
});

const mapDispatchToProps = (dispatch, { intl }) => ({
onLogout () {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.logoutMessage),
confirm: intl.formatMessage(messages.logoutConfirm),
onConfirm: () => logOut(),
}));
},
});

export default @injectIntl
@connect(null, mapDispatchToProps)
class LinkFooter extends React.PureComponent {

static propTypes = {
withHotkeys: PropTypes.bool,
onLogout: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired,
};

handleLogoutClick = e => {
e.preventDefault();
e.stopPropagation();

this.props.onLogout();

export default LinkFooter;
return false;
}

render () {
const { withHotkeys } = this.props;

return (
<div className='getting-started__footer'>
<ul>
{invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
{withHotkeys && <li><Link to='/keyboard-shortcuts'><FormattedMessage id='navigation_bar.keyboard_shortcuts' defaultMessage='Hotkeys' /></Link> · </li>}
<li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li>
<li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li>
<li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li>
<li><a href='/terms' target='_blank'><FormattedMessage id='getting_started.terms' defaultMessage='Terms of service' /></a> · </li>
<li><a href='/settings/applications' target='_blank'><FormattedMessage id='getting_started.developers' defaultMessage='Developers' /></a> · </li>
<li><a href='https://docs.joinmastodon.org' target='_blank'><FormattedMessage id='getting_started.documentation' defaultMessage='Documentation' /></a> · </li>
<li><a href='/auth/sign_out' onClick={this.handleLogoutClick}><FormattedMessage id='navigation_bar.logout' defaultMessage='Logout' /></a></li>
</ul>

<p>
<FormattedMessage
id='getting_started.open_source_notice'
defaultMessage='Mastodon is open source software. You can contribute or report issues on GitHub at {github}.'
values={{ github: <span><a href={source_url} rel='noopener' target='_blank'>{repository}</a> (v{version})</span> }}
/>
</p>
</div>
);
}

};

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

@@ -11,7 +11,7 @@ const mapStateToProps = (state, { intl }) => {
const value = notification[key];

if (typeof value === 'object') {
notification[key] = intl.formatMessage(value);
notification[key] = intl.formatMessage(value, notification[`${key}_values`]);
}
}));


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

@@ -141,14 +141,24 @@ class SwitchingColumnsArea extends React.PureComponent {
return location.state !== previewMediaState && location.state !== previewVideoState;
}

handleResize = debounce(() => {
handleLayoutChange = debounce(() => {
// The cached heights are no longer accurate, invalidate
this.props.onLayoutChange();

this.setState({ mobile: isMobile(window.innerWidth) });
}, 500, {
trailing: true,
});
})

handleResize = () => {
const mobile = isMobile(window.innerWidth);

if (mobile !== this.state.mobile) {
this.handleLayoutChange.cancel();
this.props.onLayoutChange();
this.setState({ mobile });
} else {
this.handleLayoutChange();
}
}

setRef = c => {
this.node = c.getWrappedInstance();

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

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

export function Audio () {
return import(/* webpackChunkName: "features/audio" */'../../audio');
}

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

@@ -21,7 +21,7 @@ const messages = defineMessages({
exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
});

const formatTime = secondsNum => {
export const formatTime = secondsNum => {
let hours = Math.floor(secondsNum / 3600);
let minutes = Math.floor((secondsNum - (hours * 3600)) / 60);
let seconds = secondsNum - (hours * 3600) - (minutes * 60);

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

@@ -744,6 +744,27 @@
{
"descriptors": [
{
"defaultMessage": "Play",
"id": "video.play"
},
{
"defaultMessage": "Pause",
"id": "video.pause"
},
{
"defaultMessage": "Mute sound",
"id": "video.mute"
},
{
"defaultMessage": "Unmute sound",
"id": "video.unmute"
}
],
"path": "app/javascript/mastodon/features/audio/index.json"
},
{
"descriptors": [
{
"defaultMessage": "Blocked users",
"id": "column.blocks"
},
@@ -1099,15 +1120,6 @@
{
"descriptors": [
{
"defaultMessage": "Uploading...",
"id": "upload_progress.label"
}
],
"path": "app/javascript/mastodon/features/compose/components/upload_progress.json"
},
{
"descriptors": [
{
"defaultMessage": "Delete",
"id": "upload_form.undo"
},
@@ -1317,8 +1329,8 @@
{
"descriptors": [
{
"defaultMessage": "Refresh",
"id": "trends.refresh"
"defaultMessage": "Trending now",
"id": "trends.trending_now"
}
],
"path": "app/javascript/mastodon/features/getting_started/components/trends.json"
@@ -1457,6 +1469,10 @@
{
"descriptors": [
{
"defaultMessage": "Basic",
"id": "home.column_settings.basic"
},
{
"defaultMessage": "Show boosts",
"id": "home.column_settings.show_reblogs"
},
@@ -1838,14 +1854,6 @@
"id": "notifications.column_settings.push"
},
{
"defaultMessage": "Basic",
"id": "home.column_settings.basic"
},
{
"defaultMessage": "Update in real-time",
"id": "home.column_settings.update_live"
},
{
"defaultMessage": "Quick filter bar",
"id": "notifications.column_settings.filter_bar.category"
},
@@ -1904,10 +1912,6 @@
{
"descriptors": [
{
"defaultMessage": "and {count, plural, one {# other} other {# others}}",
"id": "notification.and_n_others"
},
{
"defaultMessage": "{name} followed you",
"id": "notification.follow"
},

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

@@ -163,7 +163,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}}",
@@ -259,7 +258,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",
@@ -379,7 +377,7 @@
"time_remaining.moments": "Moments remaining",
"time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
"trends.count_by_accounts": "{count} {rawCount, plural, one {person} other {people}} talking",
"trends.refresh": "Refresh",
"trends.trending_now": "Trending now",
"ui.beforeunload": "Your draft will be lost if you leave Mastodon.",
"upload_area.title": "Drag & drop to upload",
"upload_button.label": "Add media ({formats})",

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

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

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

@@ -17,6 +17,7 @@ import {
COMPOSE_SUGGESTIONS_CLEAR,
COMPOSE_SUGGESTIONS_READY,
COMPOSE_SUGGESTION_SELECT,
COMPOSE_SUGGESTION_TAGS_UPDATE,
COMPOSE_TAG_HISTORY_UPDATE,
COMPOSE_SENSITIVITY_CHANGE,
COMPOSE_SPOILERNESS_CHANGE,
@@ -205,16 +206,36 @@ const expiresInFromExpiresAt = expires_at => {
return [300, 1800, 3600, 21600, 86400, 259200, 604800].find(expires_in => expires_in >= delta) || 24 * 3600;
};

const normalizeSuggestions = (state, { accounts, emojis, tags }) => {
const mergeLocalHashtagResults = (suggestions, prefix, tagHistory) => {
prefix = prefix.toLowerCase();
if (suggestions.length < 4) {
const localTags = tagHistory.filter(tag => tag.toLowerCase().startsWith(prefix) && !suggestions.some(suggestion => suggestion.type === 'hashtag' && suggestion.name.toLowerCase() === tag.toLowerCase()));
return suggestions.concat(localTags.slice(0, 4 - suggestions.length).toJS().map(tag => ({ type: 'hashtag', name: tag })));
} else {
return suggestions;
}
};