Merge pull request #1467 from ThibG/glitch-soc/merge-upstream
Merge upstream changes
This commit is contained in:
		
						commit
						24696458bf
					
				| 
						 | 
				
			
			@ -1,7 +1,7 @@
 | 
			
		|||
---
 | 
			
		||||
name: Bug Report
 | 
			
		||||
about: Create a report to help us improve
 | 
			
		||||
 | 
			
		||||
about: If something isn't working as expected
 | 
			
		||||
labels: bug
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
[Issue text goes here].
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,7 +1,6 @@
 | 
			
		|||
---
 | 
			
		||||
name: Feature Request
 | 
			
		||||
about: I have a suggestion
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<!-- Please use a concise and distinct title for the issue -->
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1 +1 @@
 | 
			
		|||
2.6.6
 | 
			
		||||
2.7.2
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -40,7 +40,7 @@ RUN apt update && \
 | 
			
		|||
	cd .. && rm -rf jemalloc-$JE_VER $JE_VER.tar.gz
 | 
			
		||||
 | 
			
		||||
# Install Ruby
 | 
			
		||||
ENV RUBY_VER="2.6.6"
 | 
			
		||||
ENV RUBY_VER="2.7.2"
 | 
			
		||||
ENV CPPFLAGS="-I/opt/jemalloc/include"
 | 
			
		||||
ENV LDFLAGS="-L/opt/jemalloc/lib/"
 | 
			
		||||
RUN apt update && \
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										9
									
								
								Gemfile
								
								
								
								
							
							
						
						
									
										9
									
								
								Gemfile
								
								
								
								
							| 
						 | 
				
			
			@ -11,9 +11,6 @@ gem 'sprockets', '~> 3.7.2'
 | 
			
		|||
gem 'thor', '~> 1.0'
 | 
			
		||||
gem 'rack', '~> 2.2.3'
 | 
			
		||||
 | 
			
		||||
gem 'thwait', '~> 0.2.0'
 | 
			
		||||
gem 'e2mmap', '~> 0.1.0'
 | 
			
		||||
 | 
			
		||||
gem 'hamlit-rails', '~> 0.2'
 | 
			
		||||
gem 'pg', '~> 1.2'
 | 
			
		||||
gem 'makara', '~> 0.4'
 | 
			
		||||
| 
						 | 
				
			
			@ -44,7 +41,7 @@ group :pam_authentication, optional: true do
 | 
			
		|||
end
 | 
			
		||||
 | 
			
		||||
gem 'net-ldap', '~> 0.16'
 | 
			
		||||
gem 'omniauth-cas', '~> 1.1'
 | 
			
		||||
gem 'omniauth-cas', '~> 2.0'
 | 
			
		||||
gem 'omniauth-saml', '~> 1.10'
 | 
			
		||||
gem 'omniauth', '~> 1.9'
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -127,7 +124,7 @@ group :test do
 | 
			
		|||
  gem 'rails-controller-testing', '~> 1.0'
 | 
			
		||||
  gem 'rspec-sidekiq', '~> 3.1'
 | 
			
		||||
  gem 'simplecov', '~> 0.19', require: false
 | 
			
		||||
  gem 'webmock', '~> 3.9'
 | 
			
		||||
  gem 'webmock', '~> 3.10'
 | 
			
		||||
  gem 'parallel_tests', '~> 3.3'
 | 
			
		||||
  gem 'rspec_junit_formatter', '~> 0.4'
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -141,7 +138,7 @@ group :development do
 | 
			
		|||
  gem 'letter_opener', '~> 1.7'
 | 
			
		||||
  gem 'letter_opener_web', '~> 1.4'
 | 
			
		||||
  gem 'memory_profiler'
 | 
			
		||||
  gem 'rubocop', '~> 0.93', require: false
 | 
			
		||||
  gem 'rubocop', '~> 1.3', require: false
 | 
			
		||||
  gem 'rubocop-rails', '~> 2.8', require: false
 | 
			
		||||
  gem 'brakeman', '~> 4.10', require: false
 | 
			
		||||
  gem 'bundler-audit', '~> 0.7', require: false
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										30
									
								
								Gemfile.lock
								
								
								
								
							
							
						
						
									
										30
									
								
								Gemfile.lock
								
								
								
								
							| 
						 | 
				
			
			@ -79,7 +79,7 @@ GEM
 | 
			
		|||
      cocaine (~> 0.5.3)
 | 
			
		||||
    awrence (1.1.1)
 | 
			
		||||
    aws-eventstream (1.1.0)
 | 
			
		||||
    aws-partitions (1.390.0)
 | 
			
		||||
    aws-partitions (1.393.0)
 | 
			
		||||
    aws-sdk-core (3.109.2)
 | 
			
		||||
      aws-eventstream (~> 1, >= 1.0.2)
 | 
			
		||||
      aws-partitions (~> 1, >= 1.239.0)
 | 
			
		||||
| 
						 | 
				
			
			@ -88,7 +88,7 @@ GEM
 | 
			
		|||
    aws-sdk-kms (1.39.0)
 | 
			
		||||
      aws-sdk-core (~> 3, >= 3.109.0)
 | 
			
		||||
      aws-sigv4 (~> 1.1)
 | 
			
		||||
    aws-sdk-s3 (1.84.0)
 | 
			
		||||
    aws-sdk-s3 (1.84.1)
 | 
			
		||||
      aws-sdk-core (~> 3, >= 3.109.0)
 | 
			
		||||
      aws-sdk-kms (~> 1)
 | 
			
		||||
      aws-sigv4 (~> 1.1)
 | 
			
		||||
| 
						 | 
				
			
			@ -104,7 +104,7 @@ GEM
 | 
			
		|||
      debug_inspector (>= 0.0.1)
 | 
			
		||||
    blurhash (0.1.4)
 | 
			
		||||
      ffi (~> 1.10.0)
 | 
			
		||||
    bootsnap (1.5.0)
 | 
			
		||||
    bootsnap (1.5.1)
 | 
			
		||||
      msgpack (~> 1.0)
 | 
			
		||||
    brakeman (4.10.0)
 | 
			
		||||
    browser (4.2.0)
 | 
			
		||||
| 
						 | 
				
			
			@ -289,7 +289,7 @@ GEM
 | 
			
		|||
    jmespath (1.4.0)
 | 
			
		||||
    json (2.3.1)
 | 
			
		||||
    json-canonicalization (0.2.0)
 | 
			
		||||
    json-ld (3.1.4)
 | 
			
		||||
    json-ld (3.1.5)
 | 
			
		||||
      htmlentities (~> 4.3)
 | 
			
		||||
      json-canonicalization (~> 0.2)
 | 
			
		||||
      link_header (~> 0.0, >= 0.0.8)
 | 
			
		||||
| 
						 | 
				
			
			@ -367,11 +367,11 @@ GEM
 | 
			
		|||
      concurrent-ruby (~> 1.0, >= 1.0.2)
 | 
			
		||||
      sidekiq (>= 3.5)
 | 
			
		||||
      statsd-ruby (~> 1.4, >= 1.4.0)
 | 
			
		||||
    oj (3.10.15)
 | 
			
		||||
    oj (3.10.16)
 | 
			
		||||
    omniauth (1.9.1)
 | 
			
		||||
      hashie (>= 3.4.6)
 | 
			
		||||
      rack (>= 1.6.2, < 3)
 | 
			
		||||
    omniauth-cas (1.1.1)
 | 
			
		||||
    omniauth-cas (2.0.0)
 | 
			
		||||
      addressable (~> 2.3)
 | 
			
		||||
      nokogiri (~> 1.5)
 | 
			
		||||
      omniauth (~> 1.2)
 | 
			
		||||
| 
						 | 
				
			
			@ -473,7 +473,7 @@ GEM
 | 
			
		|||
      thor (>= 0.19.0, < 2.0)
 | 
			
		||||
    rainbow (3.0.0)
 | 
			
		||||
    rake (13.0.1)
 | 
			
		||||
    rdf (3.1.6)
 | 
			
		||||
    rdf (3.1.7)
 | 
			
		||||
      hamster (~> 3.0)
 | 
			
		||||
      link_header (~> 0.0, >= 0.0.8)
 | 
			
		||||
    rdf-normalize (0.4.0)
 | 
			
		||||
| 
						 | 
				
			
			@ -533,16 +533,16 @@ GEM
 | 
			
		|||
    rspec-support (3.9.3)
 | 
			
		||||
    rspec_junit_formatter (0.4.1)
 | 
			
		||||
      rspec-core (>= 2, < 4, != 2.12.0)
 | 
			
		||||
    rubocop (0.93.1)
 | 
			
		||||
    rubocop (1.3.0)
 | 
			
		||||
      parallel (~> 1.10)
 | 
			
		||||
      parser (>= 2.7.1.5)
 | 
			
		||||
      rainbow (>= 2.2.2, < 4.0)
 | 
			
		||||
      regexp_parser (>= 1.8)
 | 
			
		||||
      rexml
 | 
			
		||||
      rubocop-ast (>= 0.6.0)
 | 
			
		||||
      rubocop-ast (>= 1.1.1)
 | 
			
		||||
      ruby-progressbar (~> 1.7)
 | 
			
		||||
      unicode-display_width (>= 1.4.0, < 2.0)
 | 
			
		||||
    rubocop-ast (0.8.0)
 | 
			
		||||
    rubocop-ast (1.1.1)
 | 
			
		||||
      parser (>= 2.7.1.5)
 | 
			
		||||
    rubocop-rails (2.8.1)
 | 
			
		||||
      activesupport (>= 4.2.0)
 | 
			
		||||
| 
						 | 
				
			
			@ -650,7 +650,7 @@ GEM
 | 
			
		|||
      safety_net_attestation (~> 0.4.0)
 | 
			
		||||
      securecompare (~> 1.0)
 | 
			
		||||
      tpm-key_attestation (~> 0.9.0)
 | 
			
		||||
    webmock (3.9.5)
 | 
			
		||||
    webmock (3.10.0)
 | 
			
		||||
      addressable (>= 2.3.6)
 | 
			
		||||
      crack (>= 0.3.2)
 | 
			
		||||
      hashdiff (>= 0.4.0, < 2.0.0)
 | 
			
		||||
| 
						 | 
				
			
			@ -705,7 +705,6 @@ DEPENDENCIES
 | 
			
		|||
  discard (~> 1.2)
 | 
			
		||||
  doorkeeper (~> 5.4)
 | 
			
		||||
  dotenv-rails (~> 2.7)
 | 
			
		||||
  e2mmap (~> 0.1.0)
 | 
			
		||||
  ed25519 (~> 1.2)
 | 
			
		||||
  fabrication (~> 2.21)
 | 
			
		||||
  faker (~> 2.14)
 | 
			
		||||
| 
						 | 
				
			
			@ -742,7 +741,7 @@ DEPENDENCIES
 | 
			
		|||
  nsa (~> 0.2)
 | 
			
		||||
  oj (~> 3.10)
 | 
			
		||||
  omniauth (~> 1.9)
 | 
			
		||||
  omniauth-cas (~> 1.1)
 | 
			
		||||
  omniauth-cas (~> 2.0)
 | 
			
		||||
  omniauth-saml (~> 1.10)
 | 
			
		||||
  ox (~> 2.13)
 | 
			
		||||
  paperclip (~> 6.0)
 | 
			
		||||
| 
						 | 
				
			
			@ -777,7 +776,7 @@ DEPENDENCIES
 | 
			
		|||
  rspec-rails (~> 4.0)
 | 
			
		||||
  rspec-sidekiq (~> 3.1)
 | 
			
		||||
  rspec_junit_formatter (~> 0.4)
 | 
			
		||||
  rubocop (~> 0.93)
 | 
			
		||||
  rubocop (~> 1.3)
 | 
			
		||||
  rubocop-rails (~> 2.8)
 | 
			
		||||
  ruby-progressbar (~> 1.10)
 | 
			
		||||
  sanitize (~> 5.2)
 | 
			
		||||
| 
						 | 
				
			
			@ -795,12 +794,11 @@ DEPENDENCIES
 | 
			
		|||
  streamio-ffmpeg (~> 3.0)
 | 
			
		||||
  strong_migrations (~> 0.7)
 | 
			
		||||
  thor (~> 1.0)
 | 
			
		||||
  thwait (~> 0.2.0)
 | 
			
		||||
  tty-prompt (~> 0.22)
 | 
			
		||||
  twitter-text (~> 1.14)
 | 
			
		||||
  tzinfo-data (~> 1.2020)
 | 
			
		||||
  webauthn (~> 3.0.0.alpha1)
 | 
			
		||||
  webmock (~> 3.9)
 | 
			
		||||
  webmock (~> 3.10)
 | 
			
		||||
  webpacker (~> 5.2)
 | 
			
		||||
  webpush
 | 
			
		||||
  xorcist (~> 1.1)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,19 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
module Settings
 | 
			
		||||
  module Exports
 | 
			
		||||
    class BookmarksController < BaseController
 | 
			
		||||
      include ExportControllerConcern
 | 
			
		||||
 | 
			
		||||
      def index
 | 
			
		||||
        send_export_file
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      private
 | 
			
		||||
 | 
			
		||||
      def export_data
 | 
			
		||||
        @export.to_bookmarks_csv
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -392,13 +392,59 @@ class Audio extends React.PureComponent {
 | 
			
		|||
    return this.props.foregroundColor || '#ffffff';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  seekBy (time) {
 | 
			
		||||
    const currentTime = this.audio.currentTime + time;
 | 
			
		||||
 | 
			
		||||
    if (!isNaN(currentTime)) {
 | 
			
		||||
      this.setState({ currentTime }, () => {
 | 
			
		||||
        this.audio.currentTime = currentTime;
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleAudioKeyDown = e => {
 | 
			
		||||
    // On the audio element or the seek bar, we can safely use the space bar
 | 
			
		||||
    // for playback control because there are no buttons to press
 | 
			
		||||
 | 
			
		||||
    if (e.key === ' ') {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.togglePlay();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleKeyDown = e => {
 | 
			
		||||
    switch(e.key) {
 | 
			
		||||
    case 'k':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.togglePlay();
 | 
			
		||||
      break;
 | 
			
		||||
    case 'm':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.toggleMute();
 | 
			
		||||
      break;
 | 
			
		||||
    case 'j':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.seekBy(-10);
 | 
			
		||||
      break;
 | 
			
		||||
    case 'l':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.seekBy(10);
 | 
			
		||||
      break;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  render () {
 | 
			
		||||
    const { src, intl, alt, editable, autoPlay } = this.props;
 | 
			
		||||
    const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state;
 | 
			
		||||
    const progress = Math.min((currentTime / duration) * 100, 100);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
 | 
			
		||||
      <div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex='0' onKeyDown={this.handleKeyDown}>
 | 
			
		||||
        <audio
 | 
			
		||||
          src={src}
 | 
			
		||||
          ref={this.setAudioRef}
 | 
			
		||||
| 
						 | 
				
			
			@ -412,12 +458,14 @@ class Audio extends React.PureComponent {
 | 
			
		|||
 | 
			
		||||
        <canvas
 | 
			
		||||
          role='button'
 | 
			
		||||
          tabIndex='0'
 | 
			
		||||
          className='audio-player__canvas'
 | 
			
		||||
          width={this.state.width}
 | 
			
		||||
          height={this.state.height}
 | 
			
		||||
          style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
 | 
			
		||||
          ref={this.setCanvasRef}
 | 
			
		||||
          onClick={this.togglePlay}
 | 
			
		||||
          onKeyDown={this.handleAudioKeyDown}
 | 
			
		||||
          title={alt}
 | 
			
		||||
          aria-label={alt}
 | 
			
		||||
        />
 | 
			
		||||
| 
						 | 
				
			
			@ -438,6 +486,7 @@ class Audio extends React.PureComponent {
 | 
			
		|||
            className={classNames('video-player__seek__handle', { active: dragging })}
 | 
			
		||||
            tabIndex='0'
 | 
			
		||||
            style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }}
 | 
			
		||||
            onKeyDown={this.handleAudioKeyDown}
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -182,10 +182,7 @@ class Video extends React.PureComponent {
 | 
			
		|||
    this.volume = c;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleMouseDownRoot = e => {
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
    e.stopPropagation();
 | 
			
		||||
  }
 | 
			
		||||
  handleClickRoot = e => e.stopPropagation();
 | 
			
		||||
 | 
			
		||||
  handlePlay = () => {
 | 
			
		||||
    this.setState({ paused: false });
 | 
			
		||||
| 
						 | 
				
			
			@ -279,6 +276,81 @@ class Video extends React.PureComponent {
 | 
			
		|||
    }
 | 
			
		||||
  }, 15);
 | 
			
		||||
 | 
			
		||||
  seekBy (time) {
 | 
			
		||||
    const currentTime = this.video.currentTime + time;
 | 
			
		||||
 | 
			
		||||
    if (!isNaN(currentTime)) {
 | 
			
		||||
      this.setState({ currentTime }, () => {
 | 
			
		||||
        this.video.currentTime = currentTime;
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleVideoKeyDown = e => {
 | 
			
		||||
    // On the video element or the seek bar, we can safely use the space bar
 | 
			
		||||
    // for playback control because there are no buttons to press
 | 
			
		||||
 | 
			
		||||
    if (e.key === ' ') {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.togglePlay();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleKeyDown = e => {
 | 
			
		||||
    const frameTime = 1 / 25;
 | 
			
		||||
 | 
			
		||||
    switch(e.key) {
 | 
			
		||||
    case 'k':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.togglePlay();
 | 
			
		||||
      break;
 | 
			
		||||
    case 'm':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.toggleMute();
 | 
			
		||||
      break;
 | 
			
		||||
    case 'f':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.toggleFullscreen();
 | 
			
		||||
      break;
 | 
			
		||||
    case 'j':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.seekBy(-10);
 | 
			
		||||
      break;
 | 
			
		||||
    case 'l':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.seekBy(10);
 | 
			
		||||
      break;
 | 
			
		||||
    case ',':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.seekBy(-frameTime);
 | 
			
		||||
      break;
 | 
			
		||||
    case '.':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.seekBy(frameTime);
 | 
			
		||||
      break;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // If we are in fullscreen mode, we don't want any hotkeys
 | 
			
		||||
    // interacting with the UI that's not visible
 | 
			
		||||
 | 
			
		||||
    if (this.state.fullscreen) {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
 | 
			
		||||
      if (e.key === 'Escape') {
 | 
			
		||||
        exitFullscreen();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  togglePlay = () => {
 | 
			
		||||
    if (this.state.paused) {
 | 
			
		||||
      this.setState({ paused: false }, () => this.video.play());
 | 
			
		||||
| 
						 | 
				
			
			@ -503,7 +575,8 @@ class Video extends React.PureComponent {
 | 
			
		|||
        ref={this.setPlayerRef}
 | 
			
		||||
        onMouseEnter={this.handleMouseEnter}
 | 
			
		||||
        onMouseLeave={this.handleMouseLeave}
 | 
			
		||||
        onMouseDown={this.handleMouseDownRoot}
 | 
			
		||||
        onClick={this.handleClickRoot}
 | 
			
		||||
        onKeyDown={this.handleKeyDown}
 | 
			
		||||
        tabIndex={0}
 | 
			
		||||
      >
 | 
			
		||||
        <Blurhash
 | 
			
		||||
| 
						 | 
				
			
			@ -528,6 +601,7 @@ class Video extends React.PureComponent {
 | 
			
		|||
          height={height}
 | 
			
		||||
          volume={volume}
 | 
			
		||||
          onClick={this.togglePlay}
 | 
			
		||||
          onKeyDown={this.handleVideoKeyDown}
 | 
			
		||||
          onPlay={this.handlePlay}
 | 
			
		||||
          onPause={this.handlePause}
 | 
			
		||||
          onLoadedData={this.handleLoadedData}
 | 
			
		||||
| 
						 | 
				
			
			@ -550,6 +624,7 @@ class Video extends React.PureComponent {
 | 
			
		|||
              className={classNames('video-player__seek__handle', { active: dragging })}
 | 
			
		||||
              tabIndex='0'
 | 
			
		||||
              style={{ left: `${progress}%` }}
 | 
			
		||||
              onKeyDown={this.handleVideoKeyDown}
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -41,6 +41,45 @@ const dropOrientationIfNeeded = (orientation) => new Promise(resolve => {
 | 
			
		|||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Some browsers don't allow reading from a canvas and instead return all-white
 | 
			
		||||
// or randomized data. Use a pre-defined image to check if reading the canvas
 | 
			
		||||
// works.
 | 
			
		||||
const checkCanvasReliability = () => new Promise((resolve, reject) => {
 | 
			
		||||
  switch(_browser_quirks['canvas-read-unreliable']) {
 | 
			
		||||
  case true:
 | 
			
		||||
    reject('Canvas reading unreliable');
 | 
			
		||||
    break;
 | 
			
		||||
  case false:
 | 
			
		||||
    resolve();
 | 
			
		||||
    break;
 | 
			
		||||
  default:
 | 
			
		||||
    // 2×2 GIF with white, red, green and blue pixels
 | 
			
		||||
    const testImageURL =
 | 
			
		||||
      'data:image/gif;base64,R0lGODdhAgACAKEDAAAA//8AAAD/AP///ywAAAAAAgACAAACA1wEBQA7';
 | 
			
		||||
    const refData =
 | 
			
		||||
      [255, 255, 255, 255,  255, 0, 0, 255,  0, 255, 0, 255,  0, 0, 255, 255];
 | 
			
		||||
    const img = new Image();
 | 
			
		||||
    img.onload = () => {
 | 
			
		||||
      const canvas  = document.createElement('canvas');
 | 
			
		||||
      const context = canvas.getContext('2d');
 | 
			
		||||
      context.drawImage(img, 0, 0, 2, 2);
 | 
			
		||||
      const imageData = context.getImageData(0, 0, 2, 2);
 | 
			
		||||
      if (imageData.data.every((x, i) => refData[i] === x)) {
 | 
			
		||||
        _browser_quirks['canvas-read-unreliable'] = false;
 | 
			
		||||
        resolve();
 | 
			
		||||
      } else {
 | 
			
		||||
        _browser_quirks['canvas-read-unreliable'] = true;
 | 
			
		||||
        reject('Canvas reading unreliable');
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
    img.onerror = () => {
 | 
			
		||||
      _browser_quirks['canvas-read-unreliable'] = true;
 | 
			
		||||
      reject('Failed to load test image');
 | 
			
		||||
    };
 | 
			
		||||
    img.src = testImageURL;
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const getImageUrl = inputFile => new Promise((resolve, reject) => {
 | 
			
		||||
  if (window.URL && URL.createObjectURL) {
 | 
			
		||||
    try {
 | 
			
		||||
| 
						 | 
				
			
			@ -110,14 +149,6 @@ const processImage = (img, { width, height, orientation, type = 'image/png' }) =
 | 
			
		|||
 | 
			
		||||
  context.drawImage(img, 0, 0, width, height);
 | 
			
		||||
 | 
			
		||||
  // The Tor Browser and maybe other browsers may prevent reading from canvas
 | 
			
		||||
  // and return an all-white image instead. Assume reading failed if the resized
 | 
			
		||||
  // image is perfectly white.
 | 
			
		||||
  const imageData = context.getImageData(0, 0, width, height);
 | 
			
		||||
  if (imageData.data.every(value => value === 255)) {
 | 
			
		||||
    throw 'Failed to read from canvas';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  canvas.toBlob(resolve, type);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -127,7 +158,8 @@ const resizeImage = (img, type = 'image/png') => new Promise((resolve, reject) =
 | 
			
		|||
  const newWidth  = Math.round(Math.sqrt(MAX_IMAGE_PIXELS * (width / height)));
 | 
			
		||||
  const newHeight = Math.round(Math.sqrt(MAX_IMAGE_PIXELS * (height / width)));
 | 
			
		||||
 | 
			
		||||
  getOrientation(img, type)
 | 
			
		||||
  checkCanvasReliability()
 | 
			
		||||
    .then(getOrientation(img, type))
 | 
			
		||||
    .then(orientation => processImage(img, {
 | 
			
		||||
      width: newWidth,
 | 
			
		||||
      height: newHeight,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,3 +8,10 @@ export const focusApp = () => ({
 | 
			
		|||
export const unfocusApp = () => ({
 | 
			
		||||
  type: APP_UNFOCUS,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const APP_LAYOUT_CHANGE = 'APP_LAYOUT_CHANGE';
 | 
			
		||||
 | 
			
		||||
export const changeLayout = layout => ({
 | 
			
		||||
  type: APP_LAYOUT_CHANGE,
 | 
			
		||||
  layout,
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -97,7 +97,10 @@ class Status extends ImmutablePureComponent {
 | 
			
		|||
    cachedMediaWidth: PropTypes.number,
 | 
			
		||||
    scrollKey: PropTypes.string,
 | 
			
		||||
    deployPictureInPicture: PropTypes.func,
 | 
			
		||||
    usingPiP: PropTypes.bool,
 | 
			
		||||
    pictureInPicture: PropTypes.shape({
 | 
			
		||||
      inUse: PropTypes.bool,
 | 
			
		||||
      available: PropTypes.bool,
 | 
			
		||||
    }),
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  // Avoid checking props that are functions (and whose equality will always
 | 
			
		||||
| 
						 | 
				
			
			@ -108,7 +111,7 @@ class Status extends ImmutablePureComponent {
 | 
			
		|||
    'muted',
 | 
			
		||||
    'hidden',
 | 
			
		||||
    'unread',
 | 
			
		||||
    'usingPiP',
 | 
			
		||||
    'pictureInPicture',
 | 
			
		||||
  ];
 | 
			
		||||
 | 
			
		||||
  state = {
 | 
			
		||||
| 
						 | 
				
			
			@ -277,7 +280,7 @@ class Status extends ImmutablePureComponent {
 | 
			
		|||
    let media = null;
 | 
			
		||||
    let statusAvatar, prepend, rebloggedByText;
 | 
			
		||||
 | 
			
		||||
    const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, usingPiP } = this.props;
 | 
			
		||||
    const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture } = this.props;
 | 
			
		||||
 | 
			
		||||
    let { status, account, ...other } = this.props;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -348,7 +351,7 @@ class Status extends ImmutablePureComponent {
 | 
			
		|||
      status  = status.get('reblog');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (usingPiP) {
 | 
			
		||||
    if (pictureInPicture.inUse) {
 | 
			
		||||
      media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />;
 | 
			
		||||
    } else if (status.get('media_attachments').size > 0) {
 | 
			
		||||
      if (this.props.muted) {
 | 
			
		||||
| 
						 | 
				
			
			@ -375,7 +378,7 @@ class Status extends ImmutablePureComponent {
 | 
			
		|||
                width={this.props.cachedMediaWidth}
 | 
			
		||||
                height={110}
 | 
			
		||||
                cacheWidth={this.props.cacheMediaWidth}
 | 
			
		||||
                deployPictureInPicture={this.handleDeployPictureInPicture}
 | 
			
		||||
                deployPictureInPicture={pictureInPicture.available ? this.handleDeployPictureInPicture : undefined}
 | 
			
		||||
              />
 | 
			
		||||
            )}
 | 
			
		||||
          </Bundle>
 | 
			
		||||
| 
						 | 
				
			
			@ -397,7 +400,7 @@ class Status extends ImmutablePureComponent {
 | 
			
		|||
                sensitive={status.get('sensitive')}
 | 
			
		||||
                onOpenVideo={this.handleOpenVideo}
 | 
			
		||||
                cacheWidth={this.props.cacheMediaWidth}
 | 
			
		||||
                deployPictureInPicture={this.handleDeployPictureInPicture}
 | 
			
		||||
                deployPictureInPicture={pictureInPicture.available ? this.handleDeployPictureInPicture : undefined}
 | 
			
		||||
                visible={this.state.showMedia}
 | 
			
		||||
                onToggleVisibility={this.handleToggleMediaVisibility}
 | 
			
		||||
              />
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -57,7 +57,11 @@ const makeMapStateToProps = () => {
 | 
			
		|||
 | 
			
		||||
  const mapStateToProps = (state, props) => ({
 | 
			
		||||
    status: getStatus(state, props),
 | 
			
		||||
    usingPiP: state.get('picture_in_picture').statusId === props.id,
 | 
			
		||||
 | 
			
		||||
    pictureInPicture: {
 | 
			
		||||
      inUse: state.getIn(['meta', 'layout']) !== 'mobile' && state.get('picture_in_picture').statusId === props.id,
 | 
			
		||||
      available: state.getIn(['meta', 'layout']) !== 'mobile',
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return mapStateToProps;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -386,13 +386,59 @@ class Audio extends React.PureComponent {
 | 
			
		|||
    return this.props.foregroundColor || '#ffffff';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  seekBy (time) {
 | 
			
		||||
    const currentTime = this.audio.currentTime + time;
 | 
			
		||||
 | 
			
		||||
    if (!isNaN(currentTime)) {
 | 
			
		||||
      this.setState({ currentTime }, () => {
 | 
			
		||||
        this.audio.currentTime = currentTime;
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleAudioKeyDown = e => {
 | 
			
		||||
    // On the audio element or the seek bar, we can safely use the space bar
 | 
			
		||||
    // for playback control because there are no buttons to press
 | 
			
		||||
 | 
			
		||||
    if (e.key === ' ') {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.togglePlay();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleKeyDown = e => {
 | 
			
		||||
    switch(e.key) {
 | 
			
		||||
    case 'k':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.togglePlay();
 | 
			
		||||
      break;
 | 
			
		||||
    case 'm':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.toggleMute();
 | 
			
		||||
      break;
 | 
			
		||||
    case 'j':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.seekBy(-10);
 | 
			
		||||
      break;
 | 
			
		||||
    case 'l':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.seekBy(10);
 | 
			
		||||
      break;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  render () {
 | 
			
		||||
    const { src, intl, alt, editable, autoPlay } = this.props;
 | 
			
		||||
    const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state;
 | 
			
		||||
    const progress = Math.min((currentTime / duration) * 100, 100);
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
 | 
			
		||||
      <div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex='0' onKeyDown={this.handleKeyDown}>
 | 
			
		||||
        <audio
 | 
			
		||||
          src={src}
 | 
			
		||||
          ref={this.setAudioRef}
 | 
			
		||||
| 
						 | 
				
			
			@ -406,12 +452,14 @@ class Audio extends React.PureComponent {
 | 
			
		|||
 | 
			
		||||
        <canvas
 | 
			
		||||
          role='button'
 | 
			
		||||
          tabIndex='0'
 | 
			
		||||
          className='audio-player__canvas'
 | 
			
		||||
          width={this.state.width}
 | 
			
		||||
          height={this.state.height}
 | 
			
		||||
          style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
 | 
			
		||||
          ref={this.setCanvasRef}
 | 
			
		||||
          onClick={this.togglePlay}
 | 
			
		||||
          onKeyDown={this.handleAudioKeyDown}
 | 
			
		||||
          title={alt}
 | 
			
		||||
          aria-label={alt}
 | 
			
		||||
        />
 | 
			
		||||
| 
						 | 
				
			
			@ -432,6 +480,7 @@ class Audio extends React.PureComponent {
 | 
			
		|||
            className={classNames('video-player__seek__handle', { active: dragging })}
 | 
			
		||||
            tabIndex='0'
 | 
			
		||||
            style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }}
 | 
			
		||||
            onKeyDown={this.handleAudioKeyDown}
 | 
			
		||||
          />
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -396,7 +396,7 @@ class Announcements extends ImmutablePureComponent {
 | 
			
		|||
  _markAnnouncementAsRead () {
 | 
			
		||||
    const { dismissAnnouncement, announcements } = this.props;
 | 
			
		||||
    const { index } = this.state;
 | 
			
		||||
    const announcement = announcements.get(index);
 | 
			
		||||
    const announcement = announcements.get(announcements.size - 1 - index);
 | 
			
		||||
    if (!announcement.get('read')) dismissAnnouncement(announcement.get('id'));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -8,14 +8,14 @@ import PropTypes from 'prop-types';
 | 
			
		|||
import NotificationsContainer from './containers/notifications_container';
 | 
			
		||||
import LoadingBarContainer from './containers/loading_bar_container';
 | 
			
		||||
import ModalContainer from './containers/modal_container';
 | 
			
		||||
import { isMobile } from '../../is_mobile';
 | 
			
		||||
import { layoutFromWindow } from 'mastodon/is_mobile';
 | 
			
		||||
import { debounce } from 'lodash';
 | 
			
		||||
import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../actions/compose';
 | 
			
		||||
import { expandHomeTimeline } from '../../actions/timelines';
 | 
			
		||||
import { expandNotifications } from '../../actions/notifications';
 | 
			
		||||
import { fetchFilters } from '../../actions/filters';
 | 
			
		||||
import { clearHeight } from '../../actions/height_cache';
 | 
			
		||||
import { focusApp, unfocusApp } from 'mastodon/actions/app';
 | 
			
		||||
import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
 | 
			
		||||
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
 | 
			
		||||
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
 | 
			
		||||
import UploadArea from './components/upload_area';
 | 
			
		||||
| 
						 | 
				
			
			@ -52,7 +52,7 @@ import {
 | 
			
		|||
  Search,
 | 
			
		||||
  Directory,
 | 
			
		||||
} from './util/async-components';
 | 
			
		||||
import { me, forceSingleColumn } from '../../initial_state';
 | 
			
		||||
import { me } from '../../initial_state';
 | 
			
		||||
import { previewState as previewMediaState } from './components/media_modal';
 | 
			
		||||
import { previewState as previewVideoState } from './components/video_modal';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -65,6 +65,7 @@ const messages = defineMessages({
 | 
			
		|||
});
 | 
			
		||||
 | 
			
		||||
const mapStateToProps = state => ({
 | 
			
		||||
  layout: state.getIn(['meta', 'layout']),
 | 
			
		||||
  isComposing: state.getIn(['compose', 'is_composing']),
 | 
			
		||||
  hasComposingText: state.getIn(['compose', 'text']).trim().length !== 0,
 | 
			
		||||
  hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0,
 | 
			
		||||
| 
						 | 
				
			
			@ -110,17 +111,11 @@ class SwitchingColumnsArea extends React.PureComponent {
 | 
			
		|||
  static propTypes = {
 | 
			
		||||
    children: PropTypes.node,
 | 
			
		||||
    location: PropTypes.object,
 | 
			
		||||
    onLayoutChange: PropTypes.func.isRequired,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  state = {
 | 
			
		||||
    mobile: isMobile(window.innerWidth),
 | 
			
		||||
    mobile: PropTypes.bool,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  componentWillMount () {
 | 
			
		||||
    window.addEventListener('resize', this.handleResize, { passive: true });
 | 
			
		||||
 | 
			
		||||
    if (this.state.mobile || forceSingleColumn) {
 | 
			
		||||
    if (this.props.mobile) {
 | 
			
		||||
      document.body.classList.toggle('layout-single-column', true);
 | 
			
		||||
      document.body.classList.toggle('layout-multiple-columns', false);
 | 
			
		||||
    } else {
 | 
			
		||||
| 
						 | 
				
			
			@ -129,44 +124,21 @@ class SwitchingColumnsArea extends React.PureComponent {
 | 
			
		|||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  componentDidUpdate (prevProps, prevState) {
 | 
			
		||||
  componentDidUpdate (prevProps) {
 | 
			
		||||
    if (![this.props.location.pathname, '/'].includes(prevProps.location.pathname)) {
 | 
			
		||||
      this.node.handleChildrenContentChange();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (prevState.mobile !== this.state.mobile && !forceSingleColumn) {
 | 
			
		||||
      document.body.classList.toggle('layout-single-column', this.state.mobile);
 | 
			
		||||
      document.body.classList.toggle('layout-multiple-columns', !this.state.mobile);
 | 
			
		||||
    if (prevProps.mobile !== this.props.mobile) {
 | 
			
		||||
      document.body.classList.toggle('layout-single-column', this.props.mobile);
 | 
			
		||||
      document.body.classList.toggle('layout-multiple-columns', !this.props.mobile);
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  componentWillUnmount () {
 | 
			
		||||
    window.removeEventListener('resize', this.handleResize);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  shouldUpdateScroll (_, { location }) {
 | 
			
		||||
    return location.state !== previewMediaState && location.state !== previewVideoState;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleLayoutChange = debounce(() => {
 | 
			
		||||
    // The cached heights are no longer accurate, invalidate
 | 
			
		||||
    this.props.onLayoutChange();
 | 
			
		||||
  }, 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 => {
 | 
			
		||||
    if (c) {
 | 
			
		||||
      this.node = c.getWrappedInstance();
 | 
			
		||||
| 
						 | 
				
			
			@ -174,13 +146,11 @@ class SwitchingColumnsArea extends React.PureComponent {
 | 
			
		|||
  }
 | 
			
		||||
 | 
			
		||||
  render () {
 | 
			
		||||
    const { children } = this.props;
 | 
			
		||||
    const { mobile } = this.state;
 | 
			
		||||
    const singleColumn = forceSingleColumn || mobile;
 | 
			
		||||
    const redirect = singleColumn ? <Redirect from='/' to='/timelines/home' exact /> : <Redirect from='/' to='/getting-started' exact />;
 | 
			
		||||
    const { children, mobile } = this.props;
 | 
			
		||||
    const redirect = mobile ? <Redirect from='/' to='/timelines/home' exact /> : <Redirect from='/' to='/getting-started' exact />;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <ColumnsAreaContainer ref={this.setRef} singleColumn={singleColumn}>
 | 
			
		||||
      <ColumnsAreaContainer ref={this.setRef} singleColumn={mobile}>
 | 
			
		||||
        <WrappedSwitch>
 | 
			
		||||
          {redirect}
 | 
			
		||||
          <WrappedRoute path='/getting-started' component={GettingStarted} content={children} />
 | 
			
		||||
| 
						 | 
				
			
			@ -244,6 +214,7 @@ class UI extends React.PureComponent {
 | 
			
		|||
    location: PropTypes.object,
 | 
			
		||||
    intl: PropTypes.object.isRequired,
 | 
			
		||||
    dropdownMenuIsOpen: PropTypes.bool,
 | 
			
		||||
    layout: PropTypes.string.isRequired,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  state = {
 | 
			
		||||
| 
						 | 
				
			
			@ -273,11 +244,6 @@ class UI extends React.PureComponent {
 | 
			
		|||
    this.props.dispatch(unfocusApp());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleLayoutChange = () => {
 | 
			
		||||
    // The cached heights are no longer accurate, invalidate
 | 
			
		||||
    this.props.dispatch(clearHeight());
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleDragEnter = (e) => {
 | 
			
		||||
    e.preventDefault();
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -351,10 +317,28 @@ class UI extends React.PureComponent {
 | 
			
		|||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  componentWillMount () {
 | 
			
		||||
  handleLayoutChange = debounce(() => {
 | 
			
		||||
    this.props.dispatch(clearHeight()); // The cached heights are no longer accurate, invalidate
 | 
			
		||||
  }, 500, {
 | 
			
		||||
    trailing: true,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  handleResize = () => {
 | 
			
		||||
    const layout = layoutFromWindow();
 | 
			
		||||
 | 
			
		||||
    if (layout !== this.props.layout) {
 | 
			
		||||
      this.handleLayoutChange.cancel();
 | 
			
		||||
      this.props.dispatch(changeLayout(layout));
 | 
			
		||||
    } else {
 | 
			
		||||
      this.handleLayoutChange();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  componentDidMount () {
 | 
			
		||||
    window.addEventListener('focus', this.handleWindowFocus, false);
 | 
			
		||||
    window.addEventListener('blur', this.handleWindowBlur, false);
 | 
			
		||||
    window.addEventListener('beforeunload', this.handleBeforeUnload, false);
 | 
			
		||||
    window.addEventListener('resize', this.handleResize, { passive: true });
 | 
			
		||||
 | 
			
		||||
    document.addEventListener('dragenter', this.handleDragEnter, false);
 | 
			
		||||
    document.addEventListener('dragover', this.handleDragOver, false);
 | 
			
		||||
| 
						 | 
				
			
			@ -371,9 +355,7 @@ class UI extends React.PureComponent {
 | 
			
		|||
    this.props.dispatch(expandNotifications());
 | 
			
		||||
 | 
			
		||||
    setTimeout(() => this.props.dispatch(fetchFilters()), 500);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  componentDidMount () {
 | 
			
		||||
    this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
 | 
			
		||||
      return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName);
 | 
			
		||||
    };
 | 
			
		||||
| 
						 | 
				
			
			@ -383,6 +365,7 @@ class UI extends React.PureComponent {
 | 
			
		|||
    window.removeEventListener('focus', this.handleWindowFocus);
 | 
			
		||||
    window.removeEventListener('blur', this.handleWindowBlur);
 | 
			
		||||
    window.removeEventListener('beforeunload', this.handleBeforeUnload);
 | 
			
		||||
    window.removeEventListener('resize', this.handleResize);
 | 
			
		||||
 | 
			
		||||
    document.removeEventListener('dragenter', this.handleDragEnter);
 | 
			
		||||
    document.removeEventListener('dragover', this.handleDragOver);
 | 
			
		||||
| 
						 | 
				
			
			@ -513,7 +496,7 @@ class UI extends React.PureComponent {
 | 
			
		|||
 | 
			
		||||
  render () {
 | 
			
		||||
    const { draggingOver } = this.state;
 | 
			
		||||
    const { children, isComposing, location, dropdownMenuIsOpen } = this.props;
 | 
			
		||||
    const { children, isComposing, location, dropdownMenuIsOpen, layout } = this.props;
 | 
			
		||||
 | 
			
		||||
    const handlers = {
 | 
			
		||||
      help: this.handleHotkeyToggleHelp,
 | 
			
		||||
| 
						 | 
				
			
			@ -540,11 +523,11 @@ class UI extends React.PureComponent {
 | 
			
		|||
    return (
 | 
			
		||||
      <HotKeys keyMap={keyMap} handlers={handlers} ref={this.setHotkeysRef} attach={window} focused>
 | 
			
		||||
        <div className={classNames('ui', { 'is-composing': isComposing })} ref={this.setRef} style={{ pointerEvents: dropdownMenuIsOpen ? 'none' : null }}>
 | 
			
		||||
          <SwitchingColumnsArea location={location} onLayoutChange={this.handleLayoutChange}>
 | 
			
		||||
          <SwitchingColumnsArea location={location} mobile={layout === 'mobile' || layout === 'single-column'}>
 | 
			
		||||
            {children}
 | 
			
		||||
          </SwitchingColumnsArea>
 | 
			
		||||
 | 
			
		||||
          <PictureInPicture />
 | 
			
		||||
          {layout !== 'mobile' && <PictureInPicture />}
 | 
			
		||||
          <NotificationsContainer />
 | 
			
		||||
          <LoadingBarContainer className='loading-bar' />
 | 
			
		||||
          <ModalContainer />
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -266,6 +266,81 @@ class Video extends React.PureComponent {
 | 
			
		|||
    }
 | 
			
		||||
  }, 15);
 | 
			
		||||
 | 
			
		||||
  seekBy (time) {
 | 
			
		||||
    const currentTime = this.video.currentTime + time;
 | 
			
		||||
 | 
			
		||||
    if (!isNaN(currentTime)) {
 | 
			
		||||
      this.setState({ currentTime }, () => {
 | 
			
		||||
        this.video.currentTime = currentTime;
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleVideoKeyDown = e => {
 | 
			
		||||
    // On the video element or the seek bar, we can safely use the space bar
 | 
			
		||||
    // for playback control because there are no buttons to press
 | 
			
		||||
 | 
			
		||||
    if (e.key === ' ') {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.togglePlay();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleKeyDown = e => {
 | 
			
		||||
    const frameTime = 1 / 25;
 | 
			
		||||
 | 
			
		||||
    switch(e.key) {
 | 
			
		||||
    case 'k':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.togglePlay();
 | 
			
		||||
      break;
 | 
			
		||||
    case 'm':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.toggleMute();
 | 
			
		||||
      break;
 | 
			
		||||
    case 'f':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.toggleFullscreen();
 | 
			
		||||
      break;
 | 
			
		||||
    case 'j':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.seekBy(-10);
 | 
			
		||||
      break;
 | 
			
		||||
    case 'l':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.seekBy(10);
 | 
			
		||||
      break;
 | 
			
		||||
    case ',':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.seekBy(-frameTime);
 | 
			
		||||
      break;
 | 
			
		||||
    case '.':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.seekBy(frameTime);
 | 
			
		||||
      break;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // If we are in fullscreen mode, we don't want any hotkeys
 | 
			
		||||
    // interacting with the UI that's not visible
 | 
			
		||||
 | 
			
		||||
    if (this.state.fullscreen) {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
 | 
			
		||||
      if (e.key === 'Escape') {
 | 
			
		||||
        exitFullscreen();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  togglePlay = () => {
 | 
			
		||||
    if (this.state.paused) {
 | 
			
		||||
      this.setState({ paused: false }, () => this.video.play());
 | 
			
		||||
| 
						 | 
				
			
			@ -484,6 +559,7 @@ class Video extends React.PureComponent {
 | 
			
		|||
        onMouseEnter={this.handleMouseEnter}
 | 
			
		||||
        onMouseLeave={this.handleMouseLeave}
 | 
			
		||||
        onClick={this.handleClickRoot}
 | 
			
		||||
        onKeyDown={this.handleKeyDown}
 | 
			
		||||
        tabIndex={0}
 | 
			
		||||
      >
 | 
			
		||||
        <Blurhash
 | 
			
		||||
| 
						 | 
				
			
			@ -507,6 +583,7 @@ class Video extends React.PureComponent {
 | 
			
		|||
          height={height}
 | 
			
		||||
          volume={volume}
 | 
			
		||||
          onClick={this.togglePlay}
 | 
			
		||||
          onKeyDown={this.handleVideoKeyDown}
 | 
			
		||||
          onPlay={this.handlePlay}
 | 
			
		||||
          onPause={this.handlePause}
 | 
			
		||||
          onLoadedData={this.handleLoadedData}
 | 
			
		||||
| 
						 | 
				
			
			@ -529,6 +606,7 @@ class Video extends React.PureComponent {
 | 
			
		|||
              className={classNames('video-player__seek__handle', { active: dragging })}
 | 
			
		||||
              tabIndex='0'
 | 
			
		||||
              style={{ left: `${progress}%` }}
 | 
			
		||||
              onKeyDown={this.handleVideoKeyDown}
 | 
			
		||||
            />
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,9 +1,18 @@
 | 
			
		|||
import { supportsPassiveEvents } from 'detect-passive-events';
 | 
			
		||||
import { forceSingleColumn } from 'mastodon/initial_state';
 | 
			
		||||
 | 
			
		||||
const LAYOUT_BREAKPOINT = 630;
 | 
			
		||||
 | 
			
		||||
export function isMobile(width) {
 | 
			
		||||
  return width <= LAYOUT_BREAKPOINT;
 | 
			
		||||
export const isMobile = width => width <= LAYOUT_BREAKPOINT;
 | 
			
		||||
 | 
			
		||||
export const layoutFromWindow = () => {
 | 
			
		||||
  if (isMobile(window.innerWidth)) {
 | 
			
		||||
    return 'mobile';
 | 
			
		||||
  } else if (forceSingleColumn) {
 | 
			
		||||
    return 'single-column';
 | 
			
		||||
  } else {
 | 
			
		||||
    return 'multi-column';
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
 | 
			
		||||
| 
						 | 
				
			
			@ -11,17 +20,13 @@ const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
 | 
			
		|||
let userTouching = false;
 | 
			
		||||
let listenerOptions = supportsPassiveEvents ? { passive: true } : false;
 | 
			
		||||
 | 
			
		||||
function touchListener() {
 | 
			
		||||
const touchListener = () => {
 | 
			
		||||
  userTouching = true;
 | 
			
		||||
  window.removeEventListener('touchstart', touchListener, listenerOptions);
 | 
			
		||||
}
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
window.addEventListener('touchstart', touchListener, listenerOptions);
 | 
			
		||||
 | 
			
		||||
export function isUserTouching() {
 | 
			
		||||
  return userTouching;
 | 
			
		||||
}
 | 
			
		||||
export const isUserTouching = () => userTouching;
 | 
			
		||||
 | 
			
		||||
export function isIOS() {
 | 
			
		||||
  return iOS;
 | 
			
		||||
};
 | 
			
		||||
export const isIOS = () => iOS;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,15 +1,20 @@
 | 
			
		|||
import { STORE_HYDRATE } from '../actions/store';
 | 
			
		||||
import { STORE_HYDRATE } from 'mastodon/actions/store';
 | 
			
		||||
import { APP_LAYOUT_CHANGE } from 'mastodon/actions/app';
 | 
			
		||||
import { Map as ImmutableMap } from 'immutable';
 | 
			
		||||
import { layoutFromWindow } from 'mastodon/is_mobile';
 | 
			
		||||
 | 
			
		||||
const initialState = ImmutableMap({
 | 
			
		||||
  streaming_api_base_url: null,
 | 
			
		||||
  access_token: null,
 | 
			
		||||
  layout: layoutFromWindow(),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default function meta(state = initialState, action) {
 | 
			
		||||
  switch(action.type) {
 | 
			
		||||
  case STORE_HYDRATE:
 | 
			
		||||
    return state.merge(action.state.get('meta'));
 | 
			
		||||
  case APP_LAYOUT_CHANGE:
 | 
			
		||||
    return state.set('layout', action.layout);
 | 
			
		||||
  default:
 | 
			
		||||
    return state;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -41,6 +41,45 @@ const dropOrientationIfNeeded = (orientation) => new Promise(resolve => {
 | 
			
		|||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
// Some browsers don't allow reading from a canvas and instead return all-white
 | 
			
		||||
// or randomized data. Use a pre-defined image to check if reading the canvas
 | 
			
		||||
// works.
 | 
			
		||||
const checkCanvasReliability = () => new Promise((resolve, reject) => {
 | 
			
		||||
  switch(_browser_quirks['canvas-read-unreliable']) {
 | 
			
		||||
  case true:
 | 
			
		||||
    reject('Canvas reading unreliable');
 | 
			
		||||
    break;
 | 
			
		||||
  case false:
 | 
			
		||||
    resolve();
 | 
			
		||||
    break;
 | 
			
		||||
  default:
 | 
			
		||||
    // 2×2 GIF with white, red, green and blue pixels
 | 
			
		||||
    const testImageURL =
 | 
			
		||||
      'data:image/gif;base64,R0lGODdhAgACAKEDAAAA//8AAAD/AP///ywAAAAAAgACAAACA1wEBQA7';
 | 
			
		||||
    const refData =
 | 
			
		||||
      [255, 255, 255, 255,  255, 0, 0, 255,  0, 255, 0, 255,  0, 0, 255, 255];
 | 
			
		||||
    const img = new Image();
 | 
			
		||||
    img.onload = () => {
 | 
			
		||||
      const canvas  = document.createElement('canvas');
 | 
			
		||||
      const context = canvas.getContext('2d');
 | 
			
		||||
      context.drawImage(img, 0, 0, 2, 2);
 | 
			
		||||
      const imageData = context.getImageData(0, 0, 2, 2);
 | 
			
		||||
      if (imageData.data.every((x, i) => refData[i] === x)) {
 | 
			
		||||
        _browser_quirks['canvas-read-unreliable'] = false;
 | 
			
		||||
        resolve();
 | 
			
		||||
      } else {
 | 
			
		||||
        _browser_quirks['canvas-read-unreliable'] = true;
 | 
			
		||||
        reject('Canvas reading unreliable');
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
    img.onerror = () => {
 | 
			
		||||
      _browser_quirks['canvas-read-unreliable'] = true;
 | 
			
		||||
      reject('Failed to load test image');
 | 
			
		||||
    };
 | 
			
		||||
    img.src = testImageURL;
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const getImageUrl = inputFile => new Promise((resolve, reject) => {
 | 
			
		||||
  if (window.URL && URL.createObjectURL) {
 | 
			
		||||
    try {
 | 
			
		||||
| 
						 | 
				
			
			@ -110,14 +149,6 @@ const processImage = (img, { width, height, orientation, type = 'image/png' }) =
 | 
			
		|||
 | 
			
		||||
  context.drawImage(img, 0, 0, width, height);
 | 
			
		||||
 | 
			
		||||
  // The Tor Browser and maybe other browsers may prevent reading from canvas
 | 
			
		||||
  // and return an all-white image instead. Assume reading failed if the resized
 | 
			
		||||
  // image is perfectly white.
 | 
			
		||||
  const imageData = context.getImageData(0, 0, width, height);
 | 
			
		||||
  if (imageData.data.every(value => value === 255)) {
 | 
			
		||||
    throw 'Failed to read from canvas';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  canvas.toBlob(resolve, type);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -127,7 +158,8 @@ const resizeImage = (img, type = 'image/png') => new Promise((resolve, reject) =
 | 
			
		|||
  const newWidth  = Math.round(Math.sqrt(MAX_IMAGE_PIXELS * (width / height)));
 | 
			
		||||
  const newHeight = Math.round(Math.sqrt(MAX_IMAGE_PIXELS * (height / width)));
 | 
			
		||||
 | 
			
		||||
  getOrientation(img, type)
 | 
			
		||||
  checkCanvasReliability()
 | 
			
		||||
    .then(getOrientation(img, type))
 | 
			
		||||
    .then(orientation => processImage(img, {
 | 
			
		||||
      width: newWidth,
 | 
			
		||||
      height: newHeight,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -13,7 +13,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
 | 
			
		|||
 | 
			
		||||
  def delete_person
 | 
			
		||||
    lock_or_return("delete_in_progress:#{@account.id}") do
 | 
			
		||||
      DeleteAccountService.new.call(@account, reserve_username: false)
 | 
			
		||||
      DeleteAccountService.new.call(@account, reserve_username: false, skip_activitypub: true)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,28 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class CacheBuster
 | 
			
		||||
  def initialize(options = {})
 | 
			
		||||
    @secret_header = options[:secret_header] || 'Secret-Header'
 | 
			
		||||
    @secret        = options[:secret] || 'True'
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def bust(url)
 | 
			
		||||
    site = Addressable::URI.parse(url).normalized_site
 | 
			
		||||
 | 
			
		||||
    request_pool.with(site) do |http_client|
 | 
			
		||||
      build_request(url, http_client).perform
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def request_pool
 | 
			
		||||
    RequestPool.current
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def build_request(url, http_client)
 | 
			
		||||
    Request.new(:get, url, http_client: http_client).tap do |request|
 | 
			
		||||
      request.add_headers(@secret_header => @secret)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -9,6 +9,14 @@ class Export
 | 
			
		|||
    @account = account
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def to_bookmarks_csv
 | 
			
		||||
    CSV.generate do |csv|
 | 
			
		||||
      account.bookmarks.includes(:status).reorder(id: :desc).each do |bookmark|
 | 
			
		||||
        csv << [ActivityPub::TagManager.instance.uri_for(bookmark.status)]
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def to_blocked_accounts_csv
 | 
			
		||||
    to_csv account.blocking.select(:username, :domain)
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			@ -55,6 +63,10 @@ class Export
 | 
			
		|||
    account.statuses_count
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def total_bookmarks
 | 
			
		||||
    account.bookmarks.count
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def total_follows
 | 
			
		||||
    account.following_count
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -24,7 +24,7 @@ class Import < ApplicationRecord
 | 
			
		|||
 | 
			
		||||
  belongs_to :account
 | 
			
		||||
 | 
			
		||||
  enum type: [:following, :blocking, :muting, :domain_blocking]
 | 
			
		||||
  enum type: [:following, :blocking, :muting, :domain_blocking, :bookmarks]
 | 
			
		||||
 | 
			
		||||
  validates :type, presence: true
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -41,6 +41,7 @@ class DeleteAccountService < BaseService
 | 
			
		|||
  # @option [Boolean] :reserve_email Keep user record. Only applicable for local accounts
 | 
			
		||||
  # @option [Boolean] :reserve_username Keep account record
 | 
			
		||||
  # @option [Boolean] :skip_side_effects Side effects are ActivityPub and streaming API payloads
 | 
			
		||||
  # @option [Boolean] :skip_activitypub Skip sending ActivityPub payloads. Implied by :skip_side_effects
 | 
			
		||||
  # @option [Time]    :suspended_at Only applicable when :reserve_username is true
 | 
			
		||||
  def call(account, **options)
 | 
			
		||||
    @account = account
 | 
			
		||||
| 
						 | 
				
			
			@ -52,6 +53,8 @@ class DeleteAccountService < BaseService
 | 
			
		|||
      @options[:skip_side_effects] = true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    @options[:skip_activitypub] = true if @options[:skip_side_effects]
 | 
			
		||||
 | 
			
		||||
    reject_follows!
 | 
			
		||||
    purge_user!
 | 
			
		||||
    purge_profile!
 | 
			
		||||
| 
						 | 
				
			
			@ -62,7 +65,7 @@ class DeleteAccountService < BaseService
 | 
			
		|||
  private
 | 
			
		||||
 | 
			
		||||
  def reject_follows!
 | 
			
		||||
    return if @account.local? || !@account.activitypub?
 | 
			
		||||
    return if @account.local? || !@account.activitypub? || @options[:skip_activitypub]
 | 
			
		||||
 | 
			
		||||
    # When deleting a remote account, the account obviously doesn't
 | 
			
		||||
    # actually become deleted on its origin server, i.e. unlike a
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -18,6 +18,8 @@ class ImportService < BaseService
 | 
			
		|||
      import_mutes!
 | 
			
		||||
    when 'domain_blocking'
 | 
			
		||||
      import_domain_blocks!
 | 
			
		||||
    when 'bookmarks'
 | 
			
		||||
      import_bookmarks!
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -88,6 +90,39 @@ class ImportService < BaseService
 | 
			
		|||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def import_bookmarks!
 | 
			
		||||
    parse_import_data!(['#uri'])
 | 
			
		||||
    items = @data.take(ROWS_PROCESSING_LIMIT).map { |row| row['#uri'].strip }
 | 
			
		||||
 | 
			
		||||
    if @import.overwrite?
 | 
			
		||||
      presence_hash = items.each_with_object({}) { |id, mapping| mapping[id] = true }
 | 
			
		||||
 | 
			
		||||
      @account.bookmarks.find_each do |bookmark|
 | 
			
		||||
        if presence_hash[bookmark.status.uri]
 | 
			
		||||
          items.delete(bookmark.status.uri)
 | 
			
		||||
        else
 | 
			
		||||
          bookmark.destroy!
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    statuses = items.map do |uri|
 | 
			
		||||
      status = ActivityPub::TagManager.instance.uri_to_resource(uri, Status)
 | 
			
		||||
      next if status.nil? && ActivityPub::TagManager.instance.local_uri?(uri)
 | 
			
		||||
 | 
			
		||||
      status || ActivityPub::FetchRemoteStatusService.new.call(uri)
 | 
			
		||||
    end.compact
 | 
			
		||||
 | 
			
		||||
    account_ids         = statuses.map(&:account_id)
 | 
			
		||||
    preloaded_relations = relations_map_for_account(@account, account_ids)
 | 
			
		||||
 | 
			
		||||
    statuses.keep_if { |status| StatusPolicy.new(@account, status, preloaded_relations).show? }
 | 
			
		||||
 | 
			
		||||
    statuses.each do |status|
 | 
			
		||||
      @account.bookmarks.find_or_create_by!(account: @account, status: status)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def parse_import_data!(default_headers)
 | 
			
		||||
    data = CSV.parse(import_data, headers: true)
 | 
			
		||||
    data = CSV.parse(import_data, headers: default_headers) unless data.headers&.first&.strip&.include?(' ')
 | 
			
		||||
| 
						 | 
				
			
			@ -101,4 +136,14 @@ class ImportService < BaseService
 | 
			
		|||
  def follow_limit
 | 
			
		||||
    FollowLimitValidator.limit_for_account(@account)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def relations_map_for_account(account, account_ids)
 | 
			
		||||
    {
 | 
			
		||||
      blocking: {},
 | 
			
		||||
      blocked_by: Account.blocked_by_map(account_ids, account.id),
 | 
			
		||||
      muting: {},
 | 
			
		||||
      following: Account.following_map(account_ids, account.id),
 | 
			
		||||
      domain_blocking_by_domain: {},
 | 
			
		||||
    }
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -29,6 +29,7 @@ class ResolveAccountService < BaseService
 | 
			
		|||
    # At this point we are in need of a Webfinger query, which may
 | 
			
		||||
    # yield us a different username/domain through a redirect
 | 
			
		||||
    process_webfinger!(@uri)
 | 
			
		||||
    @domain = nil if TagManager.instance.local_domain?(@domain)
 | 
			
		||||
 | 
			
		||||
    # Because the username/domain pair may be different than what
 | 
			
		||||
    # we already checked, we need to check if we've already got
 | 
			
		||||
| 
						 | 
				
			
			@ -78,25 +79,31 @@ class ResolveAccountService < BaseService
 | 
			
		|||
    @uri = [@username, @domain].compact.join('@')
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def process_webfinger!(uri, redirected = false)
 | 
			
		||||
  def process_webfinger!(uri)
 | 
			
		||||
    @webfinger                           = webfinger!("acct:#{uri}")
 | 
			
		||||
    confirmed_username, confirmed_domain = @webfinger.subject.gsub(/\Aacct:/, '').split('@')
 | 
			
		||||
    confirmed_username, confirmed_domain = split_acct(@webfinger.subject)
 | 
			
		||||
 | 
			
		||||
    if confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero?
 | 
			
		||||
      @username = confirmed_username
 | 
			
		||||
      @domain   = confirmed_domain
 | 
			
		||||
      @uri      = uri
 | 
			
		||||
    elsif !redirected
 | 
			
		||||
      return process_webfinger!("#{confirmed_username}@#{confirmed_domain}", true)
 | 
			
		||||
    else
 | 
			
		||||
      raise Webfinger::RedirectError, "The URI #{uri} tries to hijack #{@username}@#{@domain}"
 | 
			
		||||
      return
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    @domain = nil if TagManager.instance.local_domain?(@domain)
 | 
			
		||||
    # Account doesn't match, so it may have been redirected
 | 
			
		||||
    @webfinger         = webfinger!("acct:#{confirmed_username}@#{confirmed_domain}")
 | 
			
		||||
    @username, @domain = split_acct(@webfinger.subject)
 | 
			
		||||
 | 
			
		||||
    unless confirmed_username.casecmp(@username).zero? && confirmed_domain.casecmp(@domain).zero?
 | 
			
		||||
      raise Webfinger::RedirectError, "The URI #{uri} tries to hijack #{@username}@#{@domain}"
 | 
			
		||||
    end
 | 
			
		||||
  rescue Webfinger::GoneError
 | 
			
		||||
    @gone = true
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def split_acct(acct)
 | 
			
		||||
    acct.gsub(/\Aacct:/, '').split('@')
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def process_account!
 | 
			
		||||
    return unless activitypub_ready?
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -145,7 +152,7 @@ class ResolveAccountService < BaseService
 | 
			
		|||
  end
 | 
			
		||||
 | 
			
		||||
  def queue_deletion!
 | 
			
		||||
    AccountDeletionWorker.perform_async(@account.id, reserve_username: false)
 | 
			
		||||
    AccountDeletionWorker.perform_async(@account.id, reserve_username: false, skip_activitypub: true)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def lock_options
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -78,6 +78,8 @@ class SuspendAccountService < BaseService
 | 
			
		|||
              Rails.logger.warn "Tried to change permission on non-existent file #{attachment.path(style)}"
 | 
			
		||||
            end
 | 
			
		||||
          end
 | 
			
		||||
 | 
			
		||||
          CacheBusterWorker.perform_async(attachment.path(style)) if Rails.configuration.x.cache_buster_enabled
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -69,6 +69,8 @@ class UnsuspendAccountService < BaseService
 | 
			
		|||
              Rails.logger.warn "Tried to change permission on non-existent file #{attachment.path(style)}"
 | 
			
		||||
            end
 | 
			
		||||
          end
 | 
			
		||||
 | 
			
		||||
          CacheBusterWorker.perform_async(attachment.path(style)) if Rails.configuration.x.cache_buster_enabled
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -36,6 +36,10 @@
 | 
			
		|||
        %th= t('exports.domain_blocks')
 | 
			
		||||
        %td= number_with_delimiter @export.total_domain_blocks
 | 
			
		||||
        %td= table_link_to 'download', t('exports.csv'), settings_exports_domain_blocks_path(format: :csv)
 | 
			
		||||
      %tr
 | 
			
		||||
        %th= t('exports.bookmarks')
 | 
			
		||||
        %td= number_with_delimiter @export.total_bookmarks
 | 
			
		||||
        %td= table_link_to 'download', t('bookmarks.csv'), settings_exports_bookmarks_path(format: :csv)
 | 
			
		||||
 | 
			
		||||
%hr.spacer/
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,7 +7,8 @@ class AccountDeletionWorker
 | 
			
		|||
 | 
			
		||||
  def perform(account_id, options = {})
 | 
			
		||||
    reserve_username = options.with_indifferent_access.fetch(:reserve_username, true)
 | 
			
		||||
    DeleteAccountService.new.call(Account.find(account_id), reserve_username: reserve_username, reserve_email: false)
 | 
			
		||||
    skip_activitypub = options.with_indifferent_access.fetch(:skip_activitypub, false)
 | 
			
		||||
    DeleteAccountService.new.call(Account.find(account_id), reserve_username: reserve_username, skip_activitypub: skip_activitypub, reserve_email: false)
 | 
			
		||||
  rescue ActiveRecord::RecordNotFound
 | 
			
		||||
    true
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,18 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class CacheBusterWorker
 | 
			
		||||
  include Sidekiq::Worker
 | 
			
		||||
  include RoutingHelper
 | 
			
		||||
 | 
			
		||||
  sidekiq_options queue: 'pull'
 | 
			
		||||
 | 
			
		||||
  def perform(path)
 | 
			
		||||
    cache_buster.bust(full_asset_url(path))
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def cache_buster
 | 
			
		||||
    CacheBuster.new(Rails.configuration.x.cache_buster)
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,10 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
Rails.application.configure do
 | 
			
		||||
  config.x.cache_buster_enabled = ENV['CACHE_BUSTER_ENABLED'] == 'true'
 | 
			
		||||
 | 
			
		||||
  config.x.cache_buster = {
 | 
			
		||||
    secret_header: ENV['CACHE_BUSTER_SECRET_HEADER'],
 | 
			
		||||
    secret: ENV['CACHE_BUSTER_SECRET'],
 | 
			
		||||
  }
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -107,7 +107,6 @@ elsif ENV['SWIFT_ENABLED'] == 'true'
 | 
			
		|||
else
 | 
			
		||||
  Paperclip::Attachment.default_options.merge!(
 | 
			
		||||
    storage: :filesystem,
 | 
			
		||||
    use_timestamp: true,
 | 
			
		||||
    path: File.join(ENV.fetch('PAPERCLIP_ROOT_PATH', File.join(':rails_root', 'public', 'system')), ':prefix_path:class', ':attachment', ':id_partition', ':style', ':filename'),
 | 
			
		||||
    url: ENV.fetch('PAPERCLIP_ROOT_URL', '/system') + '/:prefix_url:class/:attachment/:id_partition/:style/:filename',
 | 
			
		||||
  )
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -842,6 +842,7 @@ en:
 | 
			
		|||
      request: Request your archive
 | 
			
		||||
      size: Size
 | 
			
		||||
    blocks: You block
 | 
			
		||||
    bookmarks: Bookmarks
 | 
			
		||||
    csv: CSV
 | 
			
		||||
    domain_blocks: Domain blocks
 | 
			
		||||
    lists: Lists
 | 
			
		||||
| 
						 | 
				
			
			@ -918,6 +919,7 @@ en:
 | 
			
		|||
    success: Your data was successfully uploaded and will now be processed in due time
 | 
			
		||||
    types:
 | 
			
		||||
      blocking: Blocking list
 | 
			
		||||
      bookmarks: Bookmarks
 | 
			
		||||
      domain_blocking: Domain blocking list
 | 
			
		||||
      following: Following list
 | 
			
		||||
      muting: Muting list
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -125,6 +125,7 @@ Rails.application.routes.draw do
 | 
			
		|||
      resources :mutes, only: :index, controller: :muted_accounts
 | 
			
		||||
      resources :lists, only: :index, controller: :lists
 | 
			
		||||
      resources :domain_blocks, only: :index, controller: :blocked_domains
 | 
			
		||||
      resources :bookmarks, only: :index, controller: :bookmarks
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    resources :two_factor_authentication_methods, only: [:index] do
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -14,6 +14,7 @@ require_relative 'mastodon/cache_cli'
 | 
			
		|||
require_relative 'mastodon/upgrade_cli'
 | 
			
		||||
require_relative 'mastodon/email_domain_blocks_cli'
 | 
			
		||||
require_relative 'mastodon/ip_blocks_cli'
 | 
			
		||||
require_relative 'mastodon/maintenance_cli'
 | 
			
		||||
require_relative 'mastodon/version'
 | 
			
		||||
 | 
			
		||||
module Mastodon
 | 
			
		||||
| 
						 | 
				
			
			@ -61,6 +62,9 @@ module Mastodon
 | 
			
		|||
    desc 'ip_blocks SUBCOMMAND ...ARGS', 'Manage IP blocks'
 | 
			
		||||
    subcommand 'ip_blocks', Mastodon::IpBlocksCLI
 | 
			
		||||
 | 
			
		||||
    desc 'maintenance SUBCOMMAND ...ARGS', 'Various maintenance utilities'
 | 
			
		||||
    subcommand 'maintenance', Mastodon::MaintenanceCLI
 | 
			
		||||
 | 
			
		||||
    option :dry_run, type: :boolean
 | 
			
		||||
    desc 'self-destruct', 'Erase the server from the federation'
 | 
			
		||||
    long_desc <<~LONG_DESC
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,610 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
require 'tty-prompt'
 | 
			
		||||
require_relative '../../config/boot'
 | 
			
		||||
require_relative '../../config/environment'
 | 
			
		||||
require_relative 'cli_helper'
 | 
			
		||||
 | 
			
		||||
module Mastodon
 | 
			
		||||
  class MaintenanceCLI < Thor
 | 
			
		||||
    include CLIHelper
 | 
			
		||||
 | 
			
		||||
    def self.exit_on_failure?
 | 
			
		||||
      true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    MIN_SUPPORTED_VERSION = 2019_10_01_213028
 | 
			
		||||
    MAX_SUPPORTED_VERSION = 2020_10_17_234926
 | 
			
		||||
 | 
			
		||||
    # Stubs to enjoy ActiveRecord queries while not depending on a particular
 | 
			
		||||
    # version of the code/database
 | 
			
		||||
 | 
			
		||||
    class Status < ApplicationRecord; end
 | 
			
		||||
    class StatusPin < ApplicationRecord; end
 | 
			
		||||
    class Poll < ApplicationRecord; end
 | 
			
		||||
    class Report < ApplicationRecord; end
 | 
			
		||||
    class Tombstone < ApplicationRecord; end
 | 
			
		||||
    class Favourite < ApplicationRecord; end
 | 
			
		||||
    class Follow < ApplicationRecord; end
 | 
			
		||||
    class FollowRequest < ApplicationRecord; end
 | 
			
		||||
    class Block < ApplicationRecord; end
 | 
			
		||||
    class Mute < ApplicationRecord; end
 | 
			
		||||
    class AccountIdentityProof < ApplicationRecord; end
 | 
			
		||||
    class AccountModerationNote < ApplicationRecord; end
 | 
			
		||||
    class AccountPin < ApplicationRecord; end
 | 
			
		||||
    class ListAccount < ApplicationRecord; end
 | 
			
		||||
    class PollVote < ApplicationRecord; end
 | 
			
		||||
    class Mention < ApplicationRecord; end
 | 
			
		||||
    class AccountDomainBlock < ApplicationRecord; end
 | 
			
		||||
    class AnnouncementReaction < ApplicationRecord; end
 | 
			
		||||
    class FeaturedTag < ApplicationRecord; end
 | 
			
		||||
    class CustomEmoji < ApplicationRecord; end
 | 
			
		||||
    class CustomEmojiCategory < ApplicationRecord; end
 | 
			
		||||
    class Bookmark < ApplicationRecord; end
 | 
			
		||||
    class WebauthnCredential < ApplicationRecord; end
 | 
			
		||||
 | 
			
		||||
    class PreviewCard < ApplicationRecord
 | 
			
		||||
      self.inheritance_column = false
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    class MediaAttachment < ApplicationRecord
 | 
			
		||||
      self.inheritance_column = nil
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    class AccountStat < ApplicationRecord
 | 
			
		||||
      belongs_to :account, inverse_of: :account_stat
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    class Account < ApplicationRecord
 | 
			
		||||
      # Dummy class, to make migration possible across version changes
 | 
			
		||||
      has_one :user, inverse_of: :account
 | 
			
		||||
      has_one :account_stat, inverse_of: :account
 | 
			
		||||
 | 
			
		||||
      scope :local, -> { where(domain: nil) }
 | 
			
		||||
 | 
			
		||||
      def local?
 | 
			
		||||
        domain.nil?
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      def acct
 | 
			
		||||
        local? ? username : "#{username}@#{domain}"
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    class User < ApplicationRecord
 | 
			
		||||
      belongs_to :account, inverse_of: :user
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    desc 'fix-duplicates', 'Fix duplicates in database and rebuild indexes'
 | 
			
		||||
    long_desc <<~LONG_DESC
 | 
			
		||||
      Delete or merge duplicate accounts, statuses, emojis, etc. and rebuild indexes.
 | 
			
		||||
 | 
			
		||||
      This is useful if your database indexes are corrupted because of issues such as https://wiki.postgresql.org/wiki/Locale_data_changes
 | 
			
		||||
 | 
			
		||||
      Mastodon has to be stopped to run this task, which will take a long time and may be destructive.
 | 
			
		||||
    LONG_DESC
 | 
			
		||||
    def fix_duplicates
 | 
			
		||||
      @prompt = TTY::Prompt.new
 | 
			
		||||
 | 
			
		||||
      if ActiveRecord::Migrator.current_version < MIN_SUPPORTED_VERSION
 | 
			
		||||
        @prompt.warn 'Your version of the database schema is too old and is not supported by this script.'
 | 
			
		||||
        @prompt.warn 'Please update to at least Mastodon 3.0.0 before running this script.'
 | 
			
		||||
        exit(1)
 | 
			
		||||
      elsif ActiveRecord::Migrator.current_version > MAX_SUPPORTED_VERSION
 | 
			
		||||
        @prompt.warn 'Your version of the database schema is more recent than this script, this may cause unexpected errors.'
 | 
			
		||||
        exit(1) unless @prompt.yes?('Continue anyway?')
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      @prompt.warn 'This task will take a long time to run and is potentially destructive.'
 | 
			
		||||
      @prompt.warn 'Please make sure to stop Mastodon and have a backup.'
 | 
			
		||||
      exit(1) unless @prompt.yes?('Continue?')
 | 
			
		||||
 | 
			
		||||
      deduplicate_accounts!
 | 
			
		||||
      deduplicate_users!
 | 
			
		||||
      deduplicate_account_domain_blocks!
 | 
			
		||||
      deduplicate_account_identity_proofs!
 | 
			
		||||
      deduplicate_announcement_reactions!
 | 
			
		||||
      deduplicate_conversations!
 | 
			
		||||
      deduplicate_custom_emojis!
 | 
			
		||||
      deduplicate_custom_emoji_categories!
 | 
			
		||||
      deduplicate_domain_allows!
 | 
			
		||||
      deduplicate_domain_blocks!
 | 
			
		||||
      deduplicate_unavailable_domains!
 | 
			
		||||
      deduplicate_email_domain_blocks!
 | 
			
		||||
      deduplicate_media_attachments!
 | 
			
		||||
      deduplicate_preview_cards!
 | 
			
		||||
      deduplicate_statuses!
 | 
			
		||||
      deduplicate_tags!
 | 
			
		||||
      deduplicate_webauthn_credentials!
 | 
			
		||||
 | 
			
		||||
      Rails.cache.clear
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Finished!'
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    private
 | 
			
		||||
 | 
			
		||||
    def deduplicate_accounts!
 | 
			
		||||
      remove_index_if_exists!(:accounts, 'index_accounts_on_username_and_domain_lower')
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Deduplicating accounts… for local accounts, you will be asked to chose which account to keep unchanged.'
 | 
			
		||||
 | 
			
		||||
      find_duplicate_accounts.each do |row|
 | 
			
		||||
        accounts = Account.where(id: row['ids'].split(',')).to_a
 | 
			
		||||
 | 
			
		||||
        if accounts.first.local?
 | 
			
		||||
          deduplicate_local_accounts!(accounts)
 | 
			
		||||
        else
 | 
			
		||||
          deduplicate_remote_accounts!(accounts)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Restoring index_accounts_on_username_and_domain_lower…'
 | 
			
		||||
      if ActiveRecord::Migrator.current_version < 20200620164023
 | 
			
		||||
        ActiveRecord::Base.connection.add_index :accounts, 'lower (username), lower(domain)', name: 'index_accounts_on_username_and_domain_lower', unique: true
 | 
			
		||||
      else
 | 
			
		||||
        ActiveRecord::Base.connection.add_index :accounts, "lower (username), COALESCE(lower(domain), '')", name: 'index_accounts_on_username_and_domain_lower', unique: true
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def deduplicate_users!
 | 
			
		||||
      remove_index_if_exists!(:users, 'index_users_on_confirmation_token')
 | 
			
		||||
      remove_index_if_exists!(:users, 'index_users_on_email')
 | 
			
		||||
      remove_index_if_exists!(:users, 'index_users_on_remember_token')
 | 
			
		||||
      remove_index_if_exists!(:users, 'index_users_on_reset_password_token')
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Deduplicating user records…'
 | 
			
		||||
 | 
			
		||||
      # Deduplicating email
 | 
			
		||||
      ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users GROUP BY email HAVING count(*) > 1").each do |row|
 | 
			
		||||
        users = User.where(id: row['ids'].split(',')).sort_by(&:updated_at).reverse
 | 
			
		||||
        ref_user = users.shift
 | 
			
		||||
        @prompt.warn "Multiple users registered with e-mail address #{ref_user.email}."
 | 
			
		||||
        @prompt.warn "e-mail will be disabled for the following accounts: #{user.map(&:account).map(&:acct).join(', ')}"
 | 
			
		||||
        @prompt.warn 'Please reach out to them and set another address with `tootctl account modify` or delete them.'
 | 
			
		||||
 | 
			
		||||
        i = 0
 | 
			
		||||
        users.each do |user|
 | 
			
		||||
          user.update!(email: "#{i} " + user.email)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users WHERE confirmation_token IS NOT NULL GROUP BY confirmation_token HAVING count(*) > 1").each do |row|
 | 
			
		||||
        users = User.where(id: row['ids'].split(',')).sort_by(&:created_at).reverse.drop(1)
 | 
			
		||||
        @prompt.warn "Unsetting confirmation token for those accounts: #{users.map(&:account).map(&:acct).join(', ')}"
 | 
			
		||||
 | 
			
		||||
        users.each do |user|
 | 
			
		||||
          user.update!(confirmation_token: nil)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users WHERE remember_token IS NOT NULL GROUP BY remember_token HAVING count(*) > 1").each do |row|
 | 
			
		||||
        users = User.where(id: row['ids'].split(',')).sort_by(&:updated_at).reverse.drop(1)
 | 
			
		||||
        @prompt.warn "Unsetting remember token for those accounts: #{users.map(&:account).map(&:acct).join(', ')}"
 | 
			
		||||
 | 
			
		||||
        users.each do |user|
 | 
			
		||||
          user.update!(remember_token: nil)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM users WHERE reset_password_token IS NOT NULL GROUP BY reset_password_token HAVING count(*) > 1").each do |row|
 | 
			
		||||
        users = User.where(id: row['ids'].split(',')).sort_by(&:updated_at).reverse.drop(1)
 | 
			
		||||
        @prompt.warn "Unsetting password reset token for those accounts: #{users.map(&:account).map(&:acct).join(', ')}"
 | 
			
		||||
 | 
			
		||||
        users.each do |user|
 | 
			
		||||
          user.update!(reset_password_token: nil)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Restoring users indexes…'
 | 
			
		||||
      ActiveRecord::Base.connection.add_index :users, ['confirmation_token'], name: 'index_users_on_confirmation_token', unique: true
 | 
			
		||||
      ActiveRecord::Base.connection.add_index :users, ['email'], name: 'index_users_on_email', unique: true
 | 
			
		||||
      ActiveRecord::Base.connection.add_index :users, ['remember_token'], name: 'index_users_on_remember_token', unique: true
 | 
			
		||||
      ActiveRecord::Base.connection.add_index :users, ['reset_password_token'], name: 'index_users_on_reset_password_token', unique: true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def deduplicate_account_domain_blocks!
 | 
			
		||||
      remove_index_if_exists!(:account_domain_blocks, 'index_account_domain_blocks_on_account_id_and_domain')
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Removing duplicate account domain blocks…'
 | 
			
		||||
      ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM account_domain_blocks GROUP BY account_id, domain HAVING count(*) > 1").each do |row|
 | 
			
		||||
        AccountDomainBlock.where(id: row['ids'].split(',').drop(1)).delete_all
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Restoring account domain blocks indexes…'
 | 
			
		||||
      ActiveRecord::Base.connection.add_index :account_domain_blocks, ['account_id', 'domain'], name: 'index_account_domain_blocks_on_account_id_and_domain', unique: true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def deduplicate_account_identity_proofs!
 | 
			
		||||
      remove_index_if_exists!(:account_identity_proofs, 'index_account_proofs_on_account_and_provider_and_username')
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Removing duplicate account identity proofs…'
 | 
			
		||||
      ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM account_identity_proofs GROUP BY account_id, provider, provider_username HAVING count(*) > 1").each do |row|
 | 
			
		||||
        AccountIdentityProof.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Restoring account identity proofs indexes…'
 | 
			
		||||
      ActiveRecord::Base.connection.add_index :account_identity_proofs, ['account_id', 'provider', 'provider_username'], name: 'index_account_proofs_on_account_and_provider_and_username', unique: true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def deduplicate_announcement_reactions!
 | 
			
		||||
      return unless ActiveRecord::Base.connection.table_exists?(:announcement_reactions)
 | 
			
		||||
 | 
			
		||||
      remove_index_if_exists!(:announcement_reactions, 'index_announcement_reactions_on_account_id_and_announcement_id')
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Removing duplicate account identity proofs…'
 | 
			
		||||
      ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM announcement_reactions GROUP BY account_id, announcement_id, name HAVING count(*) > 1").each do |row|
 | 
			
		||||
        AnnouncementReaction.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Restoring announcement_reactions indexes…'
 | 
			
		||||
      ActiveRecord::Base.connection.add_index :announcement_reactions, ['account_id', 'announcement_id', 'name'], name: 'index_announcement_reactions_on_account_id_and_announcement_id', unique: true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def deduplicate_conversations!
 | 
			
		||||
      remove_index_if_exists!(:conversations, 'index_conversations_on_uri')
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Deduplicating conversations…'
 | 
			
		||||
      ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM conversations WHERE uri IS NOT NULL GROUP BY uri HAVING count(*) > 1").each do |row|
 | 
			
		||||
        conversations = Conversation.where(id: row['ids'].split(',')).sort_by(&:id).reverse
 | 
			
		||||
 | 
			
		||||
        ref_conversation = conversations.shift
 | 
			
		||||
 | 
			
		||||
        conversations.each do |other|
 | 
			
		||||
          merge_conversations!(ref_conversation, other)
 | 
			
		||||
          other.destroy
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Restoring conversations indexes…'
 | 
			
		||||
      ActiveRecord::Base.connection.add_index :conversations, ['uri'], name: 'index_conversations_on_uri', unique: true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def deduplicate_custom_emojis!
 | 
			
		||||
      remove_index_if_exists!(:custom_emojis, 'index_custom_emojis_on_shortcode_and_domain')
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Deduplicating custom_emojis…'
 | 
			
		||||
      ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM custom_emojis GROUP BY shortcode, domain HAVING count(*) > 1").each do |row|
 | 
			
		||||
        emojis = CustomEmoji.where(id: row['ids'].split(',')).sort_by(&:id).reverse
 | 
			
		||||
 | 
			
		||||
        ref_emoji = emojis.shift
 | 
			
		||||
 | 
			
		||||
        emojis.each do |other|
 | 
			
		||||
          merge_custom_emojis!(ref_emoji, other)
 | 
			
		||||
          other.destroy
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Restoring custom_emojis indexes…'
 | 
			
		||||
      ActiveRecord::Base.connection.add_index :custom_emojis, ['shortcode', 'domain'], name: 'index_custom_emojis_on_shortcode_and_domain', unique: true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def deduplicate_custom_emoji_categories!
 | 
			
		||||
      remove_index_if_exists!(:custom_emoji_categories, 'index_custom_emoji_categories_on_name')
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Deduplicating custom_emoji_categories…'
 | 
			
		||||
      ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM custom_emoji_categories GROUP BY name HAVING count(*) > 1").each do |row|
 | 
			
		||||
        categories = CustomEmojiCategory.where(id: row['ids'].split(',')).sort_by(&:id).reverse
 | 
			
		||||
 | 
			
		||||
        ref_category = categories.shift
 | 
			
		||||
 | 
			
		||||
        categories.each do |other|
 | 
			
		||||
          merge_custom_emoji_categories!(ref_category, other)
 | 
			
		||||
          other.destroy
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Restoring custom_emoji_categories indexes…'
 | 
			
		||||
      ActiveRecord::Base.connection.add_index :custom_emoji_categories, ['name'], name: 'index_custom_emoji_categories_on_name', unique: true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def deduplicate_domain_allows!
 | 
			
		||||
      remove_index_if_exists!(:domain_allows, 'index_domain_allows_on_domain')
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Deduplicating domain_allows…'
 | 
			
		||||
      ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM domain_allows GROUP BY domain HAVING count(*) > 1").each do |row|
 | 
			
		||||
        DomainAllow.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Restoring domain_allows indexes…'
 | 
			
		||||
      ActiveRecord::Base.connection.add_index :domain_allows, ['domain'], name: 'index_domain_allows_on_domain', unique: true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def deduplicate_domain_blocks!
 | 
			
		||||
      remove_index_if_exists!(:domain_blocks, 'index_domain_blocks_on_domain')
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Deduplicating domain_allows…'
 | 
			
		||||
      ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM domain_blocks GROUP BY domain HAVING count(*) > 1").each do |row|
 | 
			
		||||
        domain_blocks = DomainBlock.where(id: row['ids'].split(',')).by_severity.reverse.to_a
 | 
			
		||||
 | 
			
		||||
        reject_media = domain_blocks.any?(&:reject_media?)
 | 
			
		||||
        reject_reports = domain_blocks.any?(&:reject_reports?)
 | 
			
		||||
 | 
			
		||||
        reference_block = domain_blocks.shift
 | 
			
		||||
 | 
			
		||||
        private_comment = domain_blocks.reduce(reference_block.private_comment.presence) { |a, b| a || b.private_comment.presence }
 | 
			
		||||
        public_comment  = domain_blocks.reduce(reference_block.public_comment.presence)  { |a, b| a || b.public_comment.presence }
 | 
			
		||||
 | 
			
		||||
        reference_block.update!(reject_media: reject_media, reject_reports: reject_reports, private_comment: private_comment, public_comment: public_comment)
 | 
			
		||||
 | 
			
		||||
        domain_blocks.each(&:destroy)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Restoring domain_blocks indexes…'
 | 
			
		||||
      ActiveRecord::Base.connection.add_index :domain_blocks, ['domain'], name: 'index_domain_blocks_on_domain', unique: true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def deduplicate_unavailable_domains!
 | 
			
		||||
      return unless ActiveRecord::Base.connection.table_exists?(:unavailable_domains)
 | 
			
		||||
 | 
			
		||||
      remove_index_if_exists!(:unavailable_domains, 'index_unavailable_domains_on_domain')
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Deduplicating unavailable_domains…'
 | 
			
		||||
      ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM unavailable_domains GROUP BY domain HAVING count(*) > 1").each do |row|
 | 
			
		||||
        UnavailableDomain.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Restoring domain_allows indexes…'
 | 
			
		||||
      ActiveRecord::Base.connection.add_index :unavailable_domains, ['domain'], name: 'index_unavailable_domains_on_domain', unique: true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def deduplicate_email_domain_blocks!
 | 
			
		||||
      remove_index_if_exists!(:email_domain_blocks, 'index_email_domain_blocks_on_domain')
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Deduplicating email_domain_blocks…'
 | 
			
		||||
      ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM email_domain_blocks GROUP BY domain HAVING count(*) > 1").each do |row|
 | 
			
		||||
        domain_blocks = EmailDomainBlock.where(id: row['ids'].split(',')).sort_by { |b| b.parent.nil? ? 1 : 0 }.to_a
 | 
			
		||||
        domain_blocks.drop(1).each(&:destroy)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Restoring email_domain_blocks indexes…'
 | 
			
		||||
      ActiveRecord::Base.connection.add_index :email_domain_blocks, ['domain'], name: 'index_email_domain_blocks_on_domain', unique: true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def deduplicate_media_attachments!
 | 
			
		||||
      remove_index_if_exists!(:media_attachments, 'index_media_attachments_on_shortcode')
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Deduplicating media_attachments…'
 | 
			
		||||
      ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM media_attachments WHERE shortcode IS NOT NULL GROUP BY shortcode HAVING count(*) > 1").each do |row|
 | 
			
		||||
        MediaAttachment.where(id: row['ids'].split(',').drop(1)).update_all(shortcode: nil)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Restoring media_attachments indexes…'
 | 
			
		||||
      ActiveRecord::Base.connection.add_index :media_attachments, ['shortcode'], name: 'index_media_attachments_on_shortcode', unique: true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def deduplicate_preview_cards!
 | 
			
		||||
      remove_index_if_exists!(:preview_cards, 'index_preview_cards_on_url')
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Deduplicating preview_cards…'
 | 
			
		||||
      ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM preview_cards GROUP BY url HAVING count(*) > 1").each do |row|
 | 
			
		||||
        PreviewCard.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Restoring preview_cards indexes…'
 | 
			
		||||
      ActiveRecord::Base.connection.add_index :preview_cards, ['url'], name: 'index_preview_cards_on_url', unique: true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def deduplicate_statuses!
 | 
			
		||||
      remove_index_if_exists!(:statuses, 'index_statuses_on_uri')
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Deduplicating statuses…'
 | 
			
		||||
      ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM statuses WHERE uri IS NOT NULL GROUP BY uri HAVING count(*) > 1").each do |row|
 | 
			
		||||
        statuses = Status.where(id: row['ids'].split(',')).sort_by(&:id)
 | 
			
		||||
        ref_status = statuses.shift
 | 
			
		||||
        statuses.each do |status|
 | 
			
		||||
          merge_statuses!(ref_status, status) if status.account_id == ref_status.account_id
 | 
			
		||||
          status.destroy
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Restoring statuses indexes…'
 | 
			
		||||
      ActiveRecord::Base.connection.add_index :statuses, ['uri'], name: 'index_statuses_on_uri', unique: true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def deduplicate_tags!
 | 
			
		||||
      remove_index_if_exists!(:tags, 'index_tags_on_name_lower')
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Deduplicating tags…'
 | 
			
		||||
      ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM tags GROUP BY lower((name)::text) HAVING count(*) > 1").each do |row|
 | 
			
		||||
        tags = Tag.where(id: row['ids'].split(',')).sort_by { |t| [t.usable?, t.trendable?, t.listable?].count(false) }
 | 
			
		||||
        ref_tag = tags.shift
 | 
			
		||||
        tags.each do |tag|
 | 
			
		||||
          merge_tags!(ref_tag, tag)
 | 
			
		||||
          tag.destroy
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Restoring tags indexes…'
 | 
			
		||||
      ActiveRecord::Base.connection.add_index :tags, 'lower((name)::text)', name: 'index_tags_on_name_lower', unique: true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def deduplicate_webauthn_credentials!
 | 
			
		||||
      return unless ActiveRecord::Base.connection.table_exists?(:webauthn_credentials)
 | 
			
		||||
 | 
			
		||||
      remove_index_if_exists!(:webauthn_credentials, 'index_webauthn_credentials_on_external_id')
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Deduplicating webauthn_credentials…'
 | 
			
		||||
      ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM webauthn_credentials GROUP BY external_id HAVING count(*) > 1").each do |row|
 | 
			
		||||
        WebauthnCredential.where(id: row['ids'].split(',')).sort_by(&:id).reverse.drop(1).each(&:destroy)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Restoring webauthn_credentials indexes…'
 | 
			
		||||
      ActiveRecord::Base.connection.add_index :webauthn_credentials, ['external_id'], name: 'index_webauthn_credentials_on_external_id', unique: true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def deduplicate_local_accounts!(accounts)
 | 
			
		||||
      accounts = accounts.sort_by(&:id).reverse
 | 
			
		||||
 | 
			
		||||
      @prompt.warn "Multiple local accounts were found for username '#{accounts.first.username}'."
 | 
			
		||||
      @prompt.warn 'All those accounts are distinct accounts but only the most recently-created one is fully-functionnal.'
 | 
			
		||||
 | 
			
		||||
      accounts.each_with_index do |account, idx|
 | 
			
		||||
        @prompt.say '%2d. %s: created at: %s; updated at: %s; last logged in at: %s; statuses: %5d; last status at: %s' % [idx, account.username, account.created_at, account.updated_at, account.user&.last_sign_in_at&.to_s || 'N/A', account.account_stat&.statuses_count || 0, account.account_stat&.last_status_at || 'N/A']
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      @prompt.say 'Please chose the one to keep unchanged, other ones will be automatically renamed.'
 | 
			
		||||
 | 
			
		||||
      ref_id = @prompt.ask('Account to keep unchanged:') do |q|
 | 
			
		||||
        q.required true
 | 
			
		||||
        q.default 0
 | 
			
		||||
        q.convert :int
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      accounts.delete_at(ref_id)
 | 
			
		||||
 | 
			
		||||
      i = 0
 | 
			
		||||
      accounts.each do |account|
 | 
			
		||||
        i += 1
 | 
			
		||||
        username = account.username + "_#{i}"
 | 
			
		||||
 | 
			
		||||
        while Account.local.exists?(username: username)
 | 
			
		||||
          i += 1
 | 
			
		||||
          username = account.username + "_#{i}"
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        account.update!(username: username)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def deduplicate_remote_accounts!(accounts)
 | 
			
		||||
      accounts = accounts.sort_by(&:updated_at).reverse
 | 
			
		||||
 | 
			
		||||
      reference_account = accounts.shift
 | 
			
		||||
 | 
			
		||||
      accounts.each do |other_account|
 | 
			
		||||
        if other_account.public_key == reference_account.public_key
 | 
			
		||||
          # The accounts definitely point to the same resource, so
 | 
			
		||||
          # it's safe to re-attribute content and relationships
 | 
			
		||||
          merge_accounts!(reference_account, other_account)
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        other_account.destroy
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def merge_accounts!(main_account, duplicate_account)
 | 
			
		||||
      # Since it's the same remote resource, the remote resource likely
 | 
			
		||||
      # already believes we are following/blocking, so it's safe to
 | 
			
		||||
      # re-attribute the relationships too. However, during the presence
 | 
			
		||||
      # of the index bug users could have *also* followed the reference
 | 
			
		||||
      # account already, therefore mass update will not work and we need
 | 
			
		||||
      # to check for (and skip past) uniqueness errors
 | 
			
		||||
      owned_classes = [
 | 
			
		||||
        Status, StatusPin, MediaAttachment, Poll, Report, Tombstone, Favourite,
 | 
			
		||||
        Follow, FollowRequest, Block, Mute, AccountIdentityProof,
 | 
			
		||||
        AccountModerationNote, AccountPin, AccountStat, ListAccount,
 | 
			
		||||
        PollVote, Mention
 | 
			
		||||
      ]
 | 
			
		||||
      owned_classes.each do |klass|
 | 
			
		||||
        klass.where(account_id: duplicate_account.id).find_each do |record|
 | 
			
		||||
          begin
 | 
			
		||||
            record.update_attribute(:account_id, main_account.id)
 | 
			
		||||
          rescue ActiveRecord::RecordNotUnique
 | 
			
		||||
            next
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      target_classes = [Follow, FollowRequest, Block, Mute, AccountModerationNote, AccountPin]
 | 
			
		||||
      target_classes.each do |klass|
 | 
			
		||||
        klass.where(target_account_id: duplicate_account.id).find_each do |record|
 | 
			
		||||
          begin
 | 
			
		||||
            record.update_attribute(:target_account_id, main_account.id)
 | 
			
		||||
          rescue ActiveRecord::RecordNotUnique
 | 
			
		||||
            next
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def merge_conversations!(main_conv, duplicate_conv)
 | 
			
		||||
      owned_classes = [ConversationMute, AccountConversation]
 | 
			
		||||
      owned_classes.each do |klass|
 | 
			
		||||
        klass.where(conversation_id: duplicate_conv.id).find_each do |record|
 | 
			
		||||
          begin
 | 
			
		||||
            record.update_attribute(:account_id, main_conv.id)
 | 
			
		||||
          rescue ActiveRecord::RecordNotUnique
 | 
			
		||||
            next
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def merge_custom_emojis!(main_emoji, duplicate_emoji)
 | 
			
		||||
      owned_classes = [AnnouncementReaction]
 | 
			
		||||
      owned_classes.each do |klass|
 | 
			
		||||
        klass.where(custom_emoji_id: duplicate_emoji.id).update_all(custom_emoji_id: main_emoji.id)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def merge_custom_emoji_categories!(main_category, duplicate_category)
 | 
			
		||||
      owned_classes = [CustomEmoji]
 | 
			
		||||
      owned_classes.each do |klass|
 | 
			
		||||
        klass.where(category_id: duplicate_category.id).update_all(category_id: main_category.id)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def merge_statuses!(main_status, duplicate_status)
 | 
			
		||||
      owned_classes = [Favourite, Mention, Poll]
 | 
			
		||||
      owned_classes << Bookmark if ActiveRecord::Base.connection.table_exists?(:bookmarks)
 | 
			
		||||
      owned_classes.each do |klass|
 | 
			
		||||
        klass.where(status_id: duplicate_status.id).find_each do |record|
 | 
			
		||||
          begin
 | 
			
		||||
            record.update_attribute(:status_id, main_status.id)
 | 
			
		||||
          rescue ActiveRecord::RecordNotUnique
 | 
			
		||||
            next
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      StatusPin.where(account_id: main_status.account_id, status_id: duplicate_status.id).find_each do |record|
 | 
			
		||||
        begin
 | 
			
		||||
          record.update_attribute(:status_id, main_status.id)
 | 
			
		||||
        rescue ActiveRecord::RecordNotUnique
 | 
			
		||||
          next
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      Status.where(in_reply_to_id: duplicate_status.id).find_each do |record|
 | 
			
		||||
        begin
 | 
			
		||||
          record.update_attribute(:in_reply_to_id, main_status.id)
 | 
			
		||||
        rescue ActiveRecord::RecordNotUnique
 | 
			
		||||
          next
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      Status.where(reblog_of_id: duplicate_status.id).find_each do |record|
 | 
			
		||||
        begin
 | 
			
		||||
          record.update_attribute(:reblog_of_id, main_status.id)
 | 
			
		||||
        rescue ActiveRecord::RecordNotUnique
 | 
			
		||||
          next
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def merge_tags!(main_tag, duplicate_tag)
 | 
			
		||||
      [FeaturedTag].each do |klass|
 | 
			
		||||
        klass.where(tag_id: duplicate_tag.id).find_each do |record|
 | 
			
		||||
          begin
 | 
			
		||||
            record.update_attribute(:tag_id, main_tag.id)
 | 
			
		||||
          rescue ActiveRecord::RecordNotUnique
 | 
			
		||||
            next
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def find_duplicate_accounts
 | 
			
		||||
      ActiveRecord::Base.connection.select_all("SELECT string_agg(id::text, ',') AS ids FROM accounts GROUP BY lower(username), COALESCE(lower(domain), '') HAVING count(*) > 1")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def remove_index_if_exists!(table, name)
 | 
			
		||||
      ActiveRecord::Base.connection.remove_index(table, name: name)
 | 
			
		||||
    rescue ArgumentError
 | 
			
		||||
      nil
 | 
			
		||||
    rescue ActiveRecord::StatementInvalid
 | 
			
		||||
      nil
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -48,6 +48,17 @@ namespace :db do
 | 
			
		|||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  task :post_migration_hook do
 | 
			
		||||
    at_exit do
 | 
			
		||||
      unless %w(C POSIX).include?(ActiveRecord::Base.connection.execute('SELECT datcollate FROM pg_database WHERE datname = current_database();').first['datcollate'])
 | 
			
		||||
        Rails.logger.warn 'WARNING: Your database is using an unsafe collation setting, which might result in index corruption.'
 | 
			
		||||
        Rails.logger.warn 'WARNING: See https://docs.joinmastodon.org/admin/troubleshooting/index-corruption/#am-i-affected'
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  Rake::Task['db:migrate'].enhance(['db:post_migration_hook'])
 | 
			
		||||
 | 
			
		||||
  # Before we load the schema, define the timestamp_id function.
 | 
			
		||||
  # Idiomatically, we might do this in a migration, but then it
 | 
			
		||||
  # wouldn't end up in schema.rb, so we'd need to figure out a way to
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										18
									
								
								package.json
								
								
								
								
							
							
						
						
									
										18
									
								
								package.json
								
								
								
								
							| 
						 | 
				
			
			@ -77,7 +77,7 @@
 | 
			
		|||
    "arrow-key-navigation": "^1.2.0",
 | 
			
		||||
    "autoprefixer": "^9.8.6",
 | 
			
		||||
    "axios": "^0.21.0",
 | 
			
		||||
    "babel-loader": "^8.1.0",
 | 
			
		||||
    "babel-loader": "^8.2.1",
 | 
			
		||||
    "babel-plugin-lodash": "^3.3.4",
 | 
			
		||||
    "babel-plugin-preval": "^5.0.0",
 | 
			
		||||
    "babel-plugin-react-intl": "^6.2.0",
 | 
			
		||||
| 
						 | 
				
			
			@ -85,7 +85,7 @@
 | 
			
		|||
    "babel-runtime": "^6.26.0",
 | 
			
		||||
    "blurhash": "^1.1.3",
 | 
			
		||||
    "classnames": "^2.2.5",
 | 
			
		||||
    "compression-webpack-plugin": "^6.1.0",
 | 
			
		||||
    "compression-webpack-plugin": "^6.1.1",
 | 
			
		||||
    "cross-env": "^7.0.2",
 | 
			
		||||
    "css-loader": "^5.0.1",
 | 
			
		||||
    "cssnano": "^4.1.10",
 | 
			
		||||
| 
						 | 
				
			
			@ -113,7 +113,7 @@
 | 
			
		|||
    "lodash": "^4.17.19",
 | 
			
		||||
    "mark-loader": "^0.1.6",
 | 
			
		||||
    "marky": "^1.2.1",
 | 
			
		||||
    "mini-css-extract-plugin": "^1.3.0",
 | 
			
		||||
    "mini-css-extract-plugin": "^1.3.1",
 | 
			
		||||
    "mkdirp": "^1.0.4",
 | 
			
		||||
    "npmlog": "^4.1.2",
 | 
			
		||||
    "object-assign": "^4.1.1",
 | 
			
		||||
| 
						 | 
				
			
			@ -156,7 +156,7 @@
 | 
			
		|||
    "reselect": "^4.0.0",
 | 
			
		||||
    "rimraf": "^3.0.2",
 | 
			
		||||
    "sass": "^1.29.0",
 | 
			
		||||
    "sass-loader": "^10.0.5",
 | 
			
		||||
    "sass-loader": "^10.1.0",
 | 
			
		||||
    "stacktrace-js": "^2.0.2",
 | 
			
		||||
    "stringz": "^2.1.0",
 | 
			
		||||
    "substring-trie": "^1.0.2",
 | 
			
		||||
| 
						 | 
				
			
			@ -168,13 +168,13 @@
 | 
			
		|||
    "webpack": "^4.44.2",
 | 
			
		||||
    "webpack-assets-manifest": "^3.1.1",
 | 
			
		||||
    "webpack-bundle-analyzer": "^4.1.0",
 | 
			
		||||
    "webpack-cli": "^3.3.12",
 | 
			
		||||
    "webpack-merge": "^5.3.0",
 | 
			
		||||
    "webpack-cli": "^4.2.0",
 | 
			
		||||
    "webpack-merge": "^5.4.0",
 | 
			
		||||
    "wicg-inert": "^3.1.0"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@testing-library/jest-dom": "^5.11.5",
 | 
			
		||||
    "@testing-library/react": "^11.1.1",
 | 
			
		||||
    "@testing-library/jest-dom": "^5.11.6",
 | 
			
		||||
    "@testing-library/react": "^11.2.0",
 | 
			
		||||
    "babel-eslint": "^10.1.0",
 | 
			
		||||
    "babel-jest": "^26.6.3",
 | 
			
		||||
    "eslint": "^7.13.0",
 | 
			
		||||
| 
						 | 
				
			
			@ -188,7 +188,7 @@
 | 
			
		|||
    "react-test-renderer": "^16.14.0",
 | 
			
		||||
    "sass-lint": "^1.13.1",
 | 
			
		||||
    "webpack-dev-server": "^3.11.0",
 | 
			
		||||
    "yargs": "^16.1.0"
 | 
			
		||||
    "yargs": "^16.1.1"
 | 
			
		||||
  },
 | 
			
		||||
  "resolutions": {
 | 
			
		||||
    "kind-of": "^6.0.3"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,17 @@
 | 
			
		|||
require 'rails_helper'
 | 
			
		||||
 | 
			
		||||
describe Settings::Exports::BookmarksController do
 | 
			
		||||
  render_views
 | 
			
		||||
 | 
			
		||||
  describe 'GET #index' do
 | 
			
		||||
    it 'returns a csv of the bookmarked toots' do
 | 
			
		||||
      user = Fabricate(:user)
 | 
			
		||||
      user.account.bookmarks.create!(status: Fabricate(:status, uri: 'https://foo.bar/statuses/1312'))
 | 
			
		||||
 | 
			
		||||
      sign_in user, scope: :user
 | 
			
		||||
      get :index, format: :csv
 | 
			
		||||
 | 
			
		||||
      expect(response.body).to eq "https://foo.bar/statuses/1312\n"
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,4 @@
 | 
			
		|||
https://example.com/statuses/1312
 | 
			
		||||
https://local.com/users/foo/statuses/42
 | 
			
		||||
https://unknown-remote.com/users/bar/statuses/1
 | 
			
		||||
https://example.com/statuses/direct
 | 
			
		||||
| 
						 | 
				
			
			@ -1,6 +1,8 @@
 | 
			
		|||
require 'rails_helper'
 | 
			
		||||
 | 
			
		||||
RSpec.describe ImportService, type: :service do
 | 
			
		||||
  include RoutingHelper
 | 
			
		||||
 | 
			
		||||
  let!(:account) { Fabricate(:account, locked: false) }
 | 
			
		||||
  let!(:bob)     { Fabricate(:account, username: 'bob', locked: false) }
 | 
			
		||||
  let!(:eve)     { Fabricate(:account, username: 'eve', domain: 'example.com', locked: false, protocol: :activitypub, inbox_url: 'https://example.com/inbox') }
 | 
			
		||||
| 
						 | 
				
			
			@ -169,4 +171,44 @@ RSpec.describe ImportService, type: :service do
 | 
			
		|||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  context 'import bookmarks' do
 | 
			
		||||
    subject { ImportService.new }
 | 
			
		||||
 | 
			
		||||
    let(:csv) { attachment_fixture('bookmark-imports.txt') }
 | 
			
		||||
 | 
			
		||||
    around(:each) do |example|
 | 
			
		||||
      local_before = Rails.configuration.x.local_domain
 | 
			
		||||
      web_before = Rails.configuration.x.web_domain
 | 
			
		||||
      Rails.configuration.x.local_domain = 'local.com'
 | 
			
		||||
      Rails.configuration.x.web_domain = 'local.com'
 | 
			
		||||
      example.run
 | 
			
		||||
      Rails.configuration.x.web_domain = web_before
 | 
			
		||||
      Rails.configuration.x.local_domain = local_before
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    let(:local_account)  { Fabricate(:account, username: 'foo', domain: '') }
 | 
			
		||||
    let!(:remote_status) { Fabricate(:status, uri: 'https://example.com/statuses/1312') }
 | 
			
		||||
    let!(:direct_status) { Fabricate(:status, uri: 'https://example.com/statuses/direct', visibility: :direct) }
 | 
			
		||||
 | 
			
		||||
    before do
 | 
			
		||||
      service = double
 | 
			
		||||
      allow(ActivityPub::FetchRemoteStatusService).to receive(:new).and_return(service)
 | 
			
		||||
      allow(service).to receive(:call).with('https://unknown-remote.com/users/bar/statuses/1') do
 | 
			
		||||
        Fabricate(:status, uri: 'https://unknown-remote.com/users/bar/statuses/1')
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    describe 'when no bookmarks are set' do
 | 
			
		||||
      let(:import) { Import.create(account: account, type: 'bookmarks', data: csv) }
 | 
			
		||||
      it 'adds the toots the user has access to to bookmarks' do
 | 
			
		||||
        local_status = Fabricate(:status, account: local_account, uri: 'https://local.com/users/foo/statuses/42', id: 42, local: true)
 | 
			
		||||
        subject.call(import)
 | 
			
		||||
        expect(account.bookmarks.map(&:status).map(&:id)).to include(local_status.id)
 | 
			
		||||
        expect(account.bookmarks.map(&:status).map(&:id)).to include(remote_status.id)
 | 
			
		||||
        expect(account.bookmarks.map(&:status).map(&:id)).not_to include(direct_status.id)
 | 
			
		||||
        expect(account.bookmarks.count).to eq 3
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,11 +4,8 @@ RSpec.describe ResolveAccountService, type: :service do
 | 
			
		|||
  subject { described_class.new }
 | 
			
		||||
 | 
			
		||||
  before do
 | 
			
		||||
    stub_request(:get, "https://quitter.no/.well-known/host-meta").to_return(request_fixture('.host-meta.txt'))
 | 
			
		||||
    stub_request(:get, "https://example.com/.well-known/webfinger?resource=acct:catsrgr8@example.com").to_return(status: 404)
 | 
			
		||||
    stub_request(:get, "https://example.com/.well-known/host-meta").to_return(status: 404)
 | 
			
		||||
    stub_request(:get, "https://quitter.no/avatar/7477-300-20160211190340.png").to_return(request_fixture('avatar.txt'))
 | 
			
		||||
    stub_request(:get, "https://quitter.no/.well-known/webfinger?resource=acct:catsrgr8@quitter.no").to_return(status: 404)
 | 
			
		||||
    stub_request(:get, "https://ap.example.com/.well-known/webfinger?resource=acct:foo@ap.example.com").to_return(request_fixture('activitypub-webfinger.txt'))
 | 
			
		||||
    stub_request(:get, "https://ap.example.com/users/foo").to_return(request_fixture('activitypub-actor.txt'))
 | 
			
		||||
    stub_request(:get, "https://ap.example.com/users/foo.atom").to_return(request_fixture('activitypub-feed.txt'))
 | 
			
		||||
| 
						 | 
				
			
			@ -16,13 +13,26 @@ RSpec.describe ResolveAccountService, type: :service do
 | 
			
		|||
    stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:hoge@example.com').to_return(status: 410)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  it 'returns nil if no such user can be resolved via webfinger' do
 | 
			
		||||
    expect(subject.call('catsrgr8@quitter.no')).to be_nil
 | 
			
		||||
  context 'when there is an LRDD endpoint but no resolvable account' do
 | 
			
		||||
    before do
 | 
			
		||||
      stub_request(:get, "https://quitter.no/.well-known/host-meta").to_return(request_fixture('.host-meta.txt'))
 | 
			
		||||
      stub_request(:get, "https://quitter.no/.well-known/webfinger?resource=acct:catsrgr8@quitter.no").to_return(status: 404)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
  it 'returns nil if the domain does not have webfinger' do
 | 
			
		||||
    it 'returns nil' do
 | 
			
		||||
      expect(subject.call('catsrgr8@quitter.no')).to be_nil
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  context 'when there is no LRDD endpoint nor resolvable account' do
 | 
			
		||||
    before do
 | 
			
		||||
      stub_request(:get, "https://example.com/.well-known/webfinger?resource=acct:catsrgr8@example.com").to_return(status: 404)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns nil' do
 | 
			
		||||
      expect(subject.call('catsrgr8@example.com')).to be_nil
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  context 'when webfinger returns http gone' do
 | 
			
		||||
    context 'for a previously known account' do
 | 
			
		||||
| 
						 | 
				
			
			@ -48,6 +58,34 @@ RSpec.describe ResolveAccountService, type: :service do
 | 
			
		|||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  context 'with a legitimate webfinger redirection' do
 | 
			
		||||
    before do
 | 
			
		||||
      webfinger = { subject: 'acct:foo@ap.example.com', links: [{ rel: 'self', href: 'https://ap.example.com/users/foo' }] }
 | 
			
		||||
      stub_request(:get, 'https://redirected.example.com/.well-known/webfinger?resource=acct:Foo@redirected.example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns new remote account' do
 | 
			
		||||
      account = subject.call('Foo@redirected.example.com')
 | 
			
		||||
 | 
			
		||||
      expect(account.activitypub?).to eq true
 | 
			
		||||
      expect(account.acct).to eq 'foo@ap.example.com'
 | 
			
		||||
      expect(account.inbox_url).to eq 'https://ap.example.com/users/foo/inbox'
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  context 'with too many webfinger redirections' do
 | 
			
		||||
    before do
 | 
			
		||||
      webfinger = { subject: 'acct:foo@evil.example.com', links: [{ rel: 'self', href: 'https://ap.example.com/users/foo' }] }
 | 
			
		||||
      stub_request(:get, 'https://redirected.example.com/.well-known/webfinger?resource=acct:Foo@redirected.example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
 | 
			
		||||
      webfinger2 = { subject: 'acct:foo@ap.example.com', links: [{ rel: 'self', href: 'https://ap.example.com/users/foo' }] }
 | 
			
		||||
      stub_request(:get, 'https://evil.example.com/.well-known/webfinger?resource=acct:foo@evil.example.com').to_return(body: Oj.dump(webfinger2), headers: { 'Content-Type': 'application/jrd+json' })
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns new remote account' do
 | 
			
		||||
      expect { subject.call('Foo@redirected.example.com') }.to raise_error Webfinger::RedirectError
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  context 'with an ActivityPub account' do
 | 
			
		||||
    it 'returns new remote account' do
 | 
			
		||||
      account = subject.call('foo@ap.example.com')
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										363
									
								
								yarn.lock
								
								
								
								
							
							
						
						
									
										363
									
								
								yarn.lock
								
								
								
								
							| 
						 | 
				
			
			@ -940,7 +940,7 @@
 | 
			
		|||
  dependencies:
 | 
			
		||||
    regenerator-runtime "^0.12.0"
 | 
			
		||||
 | 
			
		||||
"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.2.0", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
 | 
			
		||||
"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.2.0", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.8.4", "@babel/runtime@^7.9.2":
 | 
			
		||||
  version "7.12.5"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.12.5.tgz#410e7e487441e1b360c29be715d870d9b985882e"
 | 
			
		||||
  integrity sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==
 | 
			
		||||
| 
						 | 
				
			
			@ -1304,7 +1304,7 @@
 | 
			
		|||
    "@types/yargs" "^15.0.0"
 | 
			
		||||
    chalk "^3.0.0"
 | 
			
		||||
 | 
			
		||||
"@jest/types@^26.3.0", "@jest/types@^26.6.2":
 | 
			
		||||
"@jest/types@^26.6.2":
 | 
			
		||||
  version "26.6.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@jest/types/-/types-26.6.2.tgz#bef5a532030e1d88a2f5a6d933f84e97226ed48e"
 | 
			
		||||
  integrity sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==
 | 
			
		||||
| 
						 | 
				
			
			@ -1341,24 +1341,24 @@
 | 
			
		|||
  dependencies:
 | 
			
		||||
    "@sinonjs/commons" "^1.7.0"
 | 
			
		||||
 | 
			
		||||
"@testing-library/dom@^7.26.4":
 | 
			
		||||
  version "7.26.5"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.26.5.tgz#804a74fc893bf6da1a7970dbca7b94c2bbfe983d"
 | 
			
		||||
  integrity sha512-2v/fv0s4keQjJIcD4bjfJMFtvxz5icartxUWdIZVNJR539WD9oxVrvIAPw+3Ydg4RLgxt0rvQx3L9cAjCci0Kg==
 | 
			
		||||
"@testing-library/dom@^7.27.1":
 | 
			
		||||
  version "7.27.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-7.27.1.tgz#b760182513357e4448a8461f9565d733a88d71d0"
 | 
			
		||||
  integrity sha512-AF56RoeUU8bO4DOvLyMI44H3O1LVKZQi2D/m5fNDr+iR4drfOFikTr26hT6IY7YG+l8g69FXsHERa+uThaYYQg==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@babel/code-frame" "^7.10.4"
 | 
			
		||||
    "@babel/runtime" "^7.10.3"
 | 
			
		||||
    "@babel/runtime" "^7.12.5"
 | 
			
		||||
    "@types/aria-query" "^4.2.0"
 | 
			
		||||
    aria-query "^4.2.2"
 | 
			
		||||
    chalk "^4.1.0"
 | 
			
		||||
    dom-accessibility-api "^0.5.1"
 | 
			
		||||
    dom-accessibility-api "^0.5.4"
 | 
			
		||||
    lz-string "^1.4.4"
 | 
			
		||||
    pretty-format "^26.4.2"
 | 
			
		||||
    pretty-format "^26.6.2"
 | 
			
		||||
 | 
			
		||||
"@testing-library/jest-dom@^5.11.5":
 | 
			
		||||
  version "5.11.5"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.11.5.tgz#44010f37f4b1e15f9d433963b515db0b05182fc8"
 | 
			
		||||
  integrity sha512-XI+ClHR864i6p2kRCEyhvpVejuer+ObVUF4cjCvRSF88eOMIfqw7RoS9+qoRhyigGswMfT64L6Nt0Ufotxbwtg==
 | 
			
		||||
"@testing-library/jest-dom@^5.11.6":
 | 
			
		||||
  version "5.11.6"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.11.6.tgz#782940e82e5cd17bc0a36f15156ba16f3570ac81"
 | 
			
		||||
  integrity sha512-cVZyUNRWwUKI0++yepYpYX7uhrP398I+tGz4zOlLVlUYnZS+Svuxv4fwLeCIy7TnBYKXUaOlQr3vopxL8ZfEnA==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@babel/runtime" "^7.9.2"
 | 
			
		||||
    "@types/testing-library__jest-dom" "^5.9.1"
 | 
			
		||||
| 
						 | 
				
			
			@ -1369,13 +1369,13 @@
 | 
			
		|||
    lodash "^4.17.15"
 | 
			
		||||
    redent "^3.0.0"
 | 
			
		||||
 | 
			
		||||
"@testing-library/react@^11.1.1":
 | 
			
		||||
  version "11.1.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-11.1.1.tgz#226d8dc7491b702fcaac2d7d88d42892e655893a"
 | 
			
		||||
  integrity sha512-DT/P2opE9o4NWCd/oIL73b6VF/Xk9AY8iYSstKfz9cXw0XYPQ5IhA/cuYfoN9nU+mAynW8DpAVfEWdM6e7zF6g==
 | 
			
		||||
"@testing-library/react@^11.2.0":
 | 
			
		||||
  version "11.2.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-11.2.0.tgz#ce977a76b6342ea95c71ccd6de3012b1635fb559"
 | 
			
		||||
  integrity sha512-90xKYJzskZ7q/AoSuWraQL4EGZlr75uZvDt3nrO4M+rugN02zjO45tmOBq/JBOgDiMIL1tkhHioKXjJsVaSINA==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@babel/runtime" "^7.12.1"
 | 
			
		||||
    "@testing-library/dom" "^7.26.4"
 | 
			
		||||
    "@babel/runtime" "^7.12.5"
 | 
			
		||||
    "@testing-library/dom" "^7.27.1"
 | 
			
		||||
 | 
			
		||||
"@types/aria-query@^4.2.0":
 | 
			
		||||
  version "4.2.0"
 | 
			
		||||
| 
						 | 
				
			
			@ -1697,6 +1697,18 @@
 | 
			
		|||
    "@webassemblyjs/wast-parser" "1.9.0"
 | 
			
		||||
    "@xtuc/long" "4.2.2"
 | 
			
		||||
 | 
			
		||||
"@webpack-cli/info@^1.1.0":
 | 
			
		||||
  version "1.1.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@webpack-cli/info/-/info-1.1.0.tgz#c596d5bc48418b39df00c5ed7341bf0f102dbff1"
 | 
			
		||||
  integrity sha512-uNWSdaYHc+f3LdIZNwhdhkjjLDDl3jP2+XBqAq9H8DjrJUvlOKdP8TNruy1yEaDfgpAIgbSAN7pye4FEHg9tYQ==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    envinfo "^7.7.3"
 | 
			
		||||
 | 
			
		||||
"@webpack-cli/serve@^1.1.0":
 | 
			
		||||
  version "1.1.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-1.1.0.tgz#13ad38f89b6e53d1133bac0006a128217a6ebf92"
 | 
			
		||||
  integrity sha512-7RfnMXCpJ/NThrhq4gYQYILB18xWyoQcBey81oIyVbmgbc6m5ZHHyFK+DyH7pLHJf0p14MxL4mTsoPAgBSTpIg==
 | 
			
		||||
 | 
			
		||||
"@xtuc/ieee754@^1.2.0":
 | 
			
		||||
  version "1.2.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"
 | 
			
		||||
| 
						 | 
				
			
			@ -1947,6 +1959,11 @@ arr-union@^3.1.0:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4"
 | 
			
		||||
  integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=
 | 
			
		||||
 | 
			
		||||
array-back@^4.0.1:
 | 
			
		||||
  version "4.0.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/array-back/-/array-back-4.0.1.tgz#9b80312935a52062e1a233a9c7abeb5481b30e90"
 | 
			
		||||
  integrity sha512-Z/JnaVEXv+A9xabHzN43FiiiWEE7gPCRXMrVmRm00tWbjZRul1iHm7ECzlyNq1p4a4ATXz+G9FJ3GqGOkOV3fg==
 | 
			
		||||
 | 
			
		||||
array-flatten@1.1.1:
 | 
			
		||||
  version "1.1.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
 | 
			
		||||
| 
						 | 
				
			
			@ -2153,14 +2170,14 @@ babel-jest@^26.6.3:
 | 
			
		|||
    graceful-fs "^4.2.4"
 | 
			
		||||
    slash "^3.0.0"
 | 
			
		||||
 | 
			
		||||
babel-loader@^8.1.0:
 | 
			
		||||
  version "8.1.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-8.1.0.tgz#c611d5112bd5209abe8b9fa84c3e4da25275f1c3"
 | 
			
		||||
  integrity sha512-7q7nC1tYOrqvUrN3LQK4GwSk/TQorZSOlO9C+RZDZpODgyN4ZlCqE5q9cDsyWOliN+aU9B4JX01xK9eJXowJLw==
 | 
			
		||||
babel-loader@^8.2.1:
 | 
			
		||||
  version "8.2.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-8.2.1.tgz#e53313254677e86f27536f5071d807e01d24ec00"
 | 
			
		||||
  integrity sha512-dMF8sb2KQ8kJl21GUjkW1HWmcsL39GOV5vnzjqrCzEPNY0S0UfMLnumidiwIajDSBmKhYf5iRW+HXaM4cvCKBw==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    find-cache-dir "^2.1.0"
 | 
			
		||||
    loader-utils "^1.4.0"
 | 
			
		||||
    mkdirp "^0.5.3"
 | 
			
		||||
    make-dir "^2.1.0"
 | 
			
		||||
    pify "^4.0.1"
 | 
			
		||||
    schema-utils "^2.6.5"
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -2983,6 +3000,16 @@ combined-stream@^1.0.6, combined-stream@~1.0.6:
 | 
			
		|||
  dependencies:
 | 
			
		||||
    delayed-stream "~1.0.0"
 | 
			
		||||
 | 
			
		||||
command-line-usage@^6.1.0:
 | 
			
		||||
  version "6.1.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/command-line-usage/-/command-line-usage-6.1.1.tgz#c908e28686108917758a49f45efb4f02f76bc03f"
 | 
			
		||||
  integrity sha512-F59pEuAR9o1SF/bD0dQBDluhpT4jJQNWUHEuVBqpDmCUo6gPjCi+m9fCWnWZVR/oG6cMTUms4h+3NPl74wGXvA==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    array-back "^4.0.1"
 | 
			
		||||
    chalk "^2.4.2"
 | 
			
		||||
    table-layout "^1.0.1"
 | 
			
		||||
    typical "^5.2.0"
 | 
			
		||||
 | 
			
		||||
commander@^2.20.0, commander@^2.8.1:
 | 
			
		||||
  version "2.20.3"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
 | 
			
		||||
| 
						 | 
				
			
			@ -3010,10 +3037,10 @@ compressible@~2.0.16:
 | 
			
		|||
  dependencies:
 | 
			
		||||
    mime-db ">= 1.43.0 < 2"
 | 
			
		||||
 | 
			
		||||
compression-webpack-plugin@^6.1.0:
 | 
			
		||||
  version "6.1.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/compression-webpack-plugin/-/compression-webpack-plugin-6.1.0.tgz#ef88a4c35e240aa14bec6cccc0582ed47e148605"
 | 
			
		||||
  integrity sha512-RK/bBW3JwQpb7tH91trro7ulNa0ynSTPxQO48rn/oS1Y2nGUYuX6CWIOqbhUF2+b+2clqJeDGIYYckvg6WKabA==
 | 
			
		||||
compression-webpack-plugin@^6.1.1:
 | 
			
		||||
  version "6.1.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/compression-webpack-plugin/-/compression-webpack-plugin-6.1.1.tgz#ae8e4b2ffdb7396bb776e66918d751a20d8ccf0e"
 | 
			
		||||
  integrity sha512-BEHft9M6lwOqVIQFMS/YJGmeCYXVOakC5KzQk05TFpMBlODByh1qNsZCWjUBxCQhUP9x0WfGidxTbGkjbWO/TQ==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    cacache "^15.0.5"
 | 
			
		||||
    find-cache-dir "^3.3.1"
 | 
			
		||||
| 
						 | 
				
			
			@ -3202,7 +3229,7 @@ cross-env@^7.0.2:
 | 
			
		|||
  dependencies:
 | 
			
		||||
    cross-spawn "^7.0.1"
 | 
			
		||||
 | 
			
		||||
cross-spawn@^6.0.0, cross-spawn@^6.0.5:
 | 
			
		||||
cross-spawn@^6.0.0:
 | 
			
		||||
  version "6.0.5"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
 | 
			
		||||
  integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==
 | 
			
		||||
| 
						 | 
				
			
			@ -3546,6 +3573,11 @@ deep-extend@^0.5.1:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.5.1.tgz#b894a9dd90d3023fbf1c55a394fb858eb2066f1f"
 | 
			
		||||
  integrity sha512-N8vBdOa+DF7zkRrDCsaOXoCs/E2fJfx9B9MrKnnSiHNh4ws7eSys6YQE4KvT1cecKmOASYQBhbKjeuDD9lT81w==
 | 
			
		||||
 | 
			
		||||
deep-extend@~0.6.0:
 | 
			
		||||
  version "0.6.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
 | 
			
		||||
  integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
 | 
			
		||||
 | 
			
		||||
deep-is@^0.1.3, deep-is@~0.1.3:
 | 
			
		||||
  version "0.1.3"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
 | 
			
		||||
| 
						 | 
				
			
			@ -3639,11 +3671,6 @@ destroy@~1.0.4:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
 | 
			
		||||
  integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=
 | 
			
		||||
 | 
			
		||||
detect-file@^1.0.0:
 | 
			
		||||
  version "1.0.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/detect-file/-/detect-file-1.0.0.tgz#f0d66d03672a825cb1b73bdb3fe62310c8e552b7"
 | 
			
		||||
  integrity sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc=
 | 
			
		||||
 | 
			
		||||
detect-newline@^3.0.0:
 | 
			
		||||
  version "3.1.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651"
 | 
			
		||||
| 
						 | 
				
			
			@ -3720,10 +3747,10 @@ doctrine@^3.0.0:
 | 
			
		|||
  dependencies:
 | 
			
		||||
    esutils "^2.0.2"
 | 
			
		||||
 | 
			
		||||
dom-accessibility-api@^0.5.1:
 | 
			
		||||
  version "0.5.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.2.tgz#ef3cdb5d3f0d599d8f9c8b18df2fb63c9793739d"
 | 
			
		||||
  integrity sha512-k7hRNKAiPJXD2aBqfahSo4/01cTsKWXf+LqJgglnkN2Nz8TsxXKQBXHhKe0Ye9fEfHEZY49uSA5Sr3AqP/sWKA==
 | 
			
		||||
dom-accessibility-api@^0.5.4:
 | 
			
		||||
  version "0.5.4"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.4.tgz#b06d059cdd4a4ad9a79275f9d414a5c126241166"
 | 
			
		||||
  integrity sha512-TvrjBckDy2c6v6RLxPv5QXOnU+SmF9nBII5621Ve5fu6Z/BDrENurBEvlC1f44lKEUVqOpK4w9E5Idc5/EgkLQ==
 | 
			
		||||
 | 
			
		||||
dom-helpers@^3.2.1, dom-helpers@^3.4.0:
 | 
			
		||||
  version "3.4.0"
 | 
			
		||||
| 
						 | 
				
			
			@ -3894,7 +3921,7 @@ end-of-stream@^1.0.0, end-of-stream@^1.1.0:
 | 
			
		|||
  dependencies:
 | 
			
		||||
    once "^1.4.0"
 | 
			
		||||
 | 
			
		||||
enhanced-resolve@^4.1.1, enhanced-resolve@^4.3.0:
 | 
			
		||||
enhanced-resolve@^4.3.0:
 | 
			
		||||
  version "4.3.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.3.0.tgz#3b806f3bfafc1ec7de69551ef93cca46c1704126"
 | 
			
		||||
  integrity sha512-3e87LvavsdxyoCfGusJnrZ5G8SLPOFeHSNpZI/ATL9a5leXo2k0w6MKnbqhdBad9qTobSfB20Ld7UmgoNbAZkQ==
 | 
			
		||||
| 
						 | 
				
			
			@ -3903,7 +3930,7 @@ enhanced-resolve@^4.1.1, enhanced-resolve@^4.3.0:
 | 
			
		|||
    memory-fs "^0.5.0"
 | 
			
		||||
    tapable "^1.0.0"
 | 
			
		||||
 | 
			
		||||
enquirer@^2.3.5:
 | 
			
		||||
enquirer@^2.3.5, enquirer@^2.3.6:
 | 
			
		||||
  version "2.3.6"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d"
 | 
			
		||||
  integrity sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==
 | 
			
		||||
| 
						 | 
				
			
			@ -3915,6 +3942,11 @@ entities@^2.0.0:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.3.tgz#5c487e5742ab93c15abb5da22759b8590ec03b7f"
 | 
			
		||||
  integrity sha512-MyoZ0jgnLvB2X3Lg5HqpFmn1kybDiIfEQmKzTb5apr51Rb+T3KdmMiqa70T+bhGnyv7bQ6WMj2QMHpGMmlrUYQ==
 | 
			
		||||
 | 
			
		||||
envinfo@^7.7.3:
 | 
			
		||||
  version "7.7.3"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.7.3.tgz#4b2d8622e3e7366afb8091b23ed95569ea0208cc"
 | 
			
		||||
  integrity sha512-46+j5QxbPWza0PB1i15nZx0xQ4I/EfQxg9J8Had3b408SV63nEtor2e+oiY63amTo9KTuh2a3XLObNwduxYwwA==
 | 
			
		||||
 | 
			
		||||
errno@^0.1.3, errno@~0.1.7:
 | 
			
		||||
  version "0.1.7"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618"
 | 
			
		||||
| 
						 | 
				
			
			@ -4385,10 +4417,10 @@ execa@^1.0.0:
 | 
			
		|||
    signal-exit "^3.0.0"
 | 
			
		||||
    strip-eof "^1.0.0"
 | 
			
		||||
 | 
			
		||||
execa@^4.0.0:
 | 
			
		||||
  version "4.0.3"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/execa/-/execa-4.0.3.tgz#0a34dabbad6d66100bd6f2c576c8669403f317f2"
 | 
			
		||||
  integrity sha512-WFDXGHckXPWZX19t1kCsXzOpqX9LWYNqn4C+HqZlk/V0imTkzJZqf87ZBhvpHaftERYknpk0fjSylnXVlVgI0A==
 | 
			
		||||
execa@^4.0.0, execa@^4.1.0:
 | 
			
		||||
  version "4.1.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/execa/-/execa-4.1.0.tgz#4e5491ad1572f2f17a77d388c6c857135b22847a"
 | 
			
		||||
  integrity sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    cross-spawn "^7.0.0"
 | 
			
		||||
    get-stream "^5.0.0"
 | 
			
		||||
| 
						 | 
				
			
			@ -4428,13 +4460,6 @@ expand-brackets@^2.1.4:
 | 
			
		|||
    snapdragon "^0.8.1"
 | 
			
		||||
    to-regex "^3.0.1"
 | 
			
		||||
 | 
			
		||||
expand-tilde@^2.0.0, expand-tilde@^2.0.2:
 | 
			
		||||
  version "2.0.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/expand-tilde/-/expand-tilde-2.0.2.tgz#97e801aa052df02454de46b02bf621642cdc8502"
 | 
			
		||||
  integrity sha1-l+gBqgUt8CRU3kawK/YhZCzchQI=
 | 
			
		||||
  dependencies:
 | 
			
		||||
    homedir-polyfill "^1.0.1"
 | 
			
		||||
 | 
			
		||||
expect@^26.6.2:
 | 
			
		||||
  version "26.6.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/expect/-/expect-26.6.2.tgz#c6b996bf26bf3fe18b67b2d0f51fc981ba934417"
 | 
			
		||||
| 
						 | 
				
			
			@ -4708,16 +4733,6 @@ find-up@^4.0.0, find-up@^4.1.0:
 | 
			
		|||
    locate-path "^5.0.0"
 | 
			
		||||
    path-exists "^4.0.0"
 | 
			
		||||
 | 
			
		||||
findup-sync@^3.0.0:
 | 
			
		||||
  version "3.0.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/findup-sync/-/findup-sync-3.0.0.tgz#17b108f9ee512dfb7a5c7f3c8b27ea9e1a9c08d1"
 | 
			
		||||
  integrity sha512-YbffarhcicEhOrm4CtrwdKBdCuz576RLdhJDsIfvNtxUuhdRet1qZcsMjqbePtAseKdAnDyM/IyXbu7PRPRLYg==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    detect-file "^1.0.0"
 | 
			
		||||
    is-glob "^4.0.0"
 | 
			
		||||
    micromatch "^3.0.4"
 | 
			
		||||
    resolve-dir "^1.0.1"
 | 
			
		||||
 | 
			
		||||
flat-cache@^1.2.1:
 | 
			
		||||
  version "1.3.4"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-1.3.4.tgz#2c2ef77525cc2929007dfffa1dd314aa9c9dee6f"
 | 
			
		||||
| 
						 | 
				
			
			@ -4975,42 +4990,6 @@ glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, gl
 | 
			
		|||
    once "^1.3.0"
 | 
			
		||||
    path-is-absolute "^1.0.0"
 | 
			
		||||
 | 
			
		||||
global-modules@^1.0.0:
 | 
			
		||||
  version "1.0.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-1.0.0.tgz#6d770f0eb523ac78164d72b5e71a8877265cc3ea"
 | 
			
		||||
  integrity sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    global-prefix "^1.0.1"
 | 
			
		||||
    is-windows "^1.0.1"
 | 
			
		||||
    resolve-dir "^1.0.0"
 | 
			
		||||
 | 
			
		||||
global-modules@^2.0.0:
 | 
			
		||||
  version "2.0.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780"
 | 
			
		||||
  integrity sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    global-prefix "^3.0.0"
 | 
			
		||||
 | 
			
		||||
global-prefix@^1.0.1:
 | 
			
		||||
  version "1.0.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-1.0.2.tgz#dbf743c6c14992593c655568cb66ed32c0122ebe"
 | 
			
		||||
  integrity sha1-2/dDxsFJklk8ZVVoy2btMsASLr4=
 | 
			
		||||
  dependencies:
 | 
			
		||||
    expand-tilde "^2.0.2"
 | 
			
		||||
    homedir-polyfill "^1.0.1"
 | 
			
		||||
    ini "^1.3.4"
 | 
			
		||||
    is-windows "^1.0.1"
 | 
			
		||||
    which "^1.2.14"
 | 
			
		||||
 | 
			
		||||
global-prefix@^3.0.0:
 | 
			
		||||
  version "3.0.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-3.0.0.tgz#fc85f73064df69f50421f47f883fe5b913ba9b97"
 | 
			
		||||
  integrity sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    ini "^1.3.5"
 | 
			
		||||
    kind-of "^6.0.2"
 | 
			
		||||
    which "^1.3.1"
 | 
			
		||||
 | 
			
		||||
globals@^11.1.0:
 | 
			
		||||
  version "11.12.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
 | 
			
		||||
| 
						 | 
				
			
			@ -5227,13 +5206,6 @@ hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
 | 
			
		|||
  dependencies:
 | 
			
		||||
    react-is "^16.7.0"
 | 
			
		||||
 | 
			
		||||
homedir-polyfill@^1.0.1:
 | 
			
		||||
  version "1.0.3"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/homedir-polyfill/-/homedir-polyfill-1.0.3.tgz#743298cef4e5af3e194161fbadcc2151d3a058e8"
 | 
			
		||||
  integrity sha512-eSmmWE5bZTK2Nou4g0AI3zZ9rswp7GRKoKXS1BLUkvPviOqs4YTN1djQIqrXy9k5gEtdLPy86JjRwsNM9tnDcA==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    parse-passwd "^1.0.0"
 | 
			
		||||
 | 
			
		||||
hosted-git-info@^2.1.4:
 | 
			
		||||
  version "2.8.8"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488"
 | 
			
		||||
| 
						 | 
				
			
			@ -5507,11 +5479,6 @@ inherits@2.0.3:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
 | 
			
		||||
  integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
 | 
			
		||||
 | 
			
		||||
ini@^1.3.4, ini@^1.3.5:
 | 
			
		||||
  version "1.3.5"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
 | 
			
		||||
  integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==
 | 
			
		||||
 | 
			
		||||
inquirer@^0.12.0:
 | 
			
		||||
  version "0.12.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-0.12.0.tgz#1ef2bfd63504df0bc75785fff8c2c41df12f077e"
 | 
			
		||||
| 
						 | 
				
			
			@ -5548,10 +5515,10 @@ internal-slot@^1.0.2:
 | 
			
		|||
    has "^1.0.3"
 | 
			
		||||
    side-channel "^1.0.2"
 | 
			
		||||
 | 
			
		||||
interpret@^1.4.0:
 | 
			
		||||
  version "1.4.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e"
 | 
			
		||||
  integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==
 | 
			
		||||
interpret@^2.2.0:
 | 
			
		||||
  version "2.2.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9"
 | 
			
		||||
  integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==
 | 
			
		||||
 | 
			
		||||
intersection-observer@^0.11.0:
 | 
			
		||||
  version "0.11.0"
 | 
			
		||||
| 
						 | 
				
			
			@ -5705,6 +5672,13 @@ is-core-module@^2.0.0:
 | 
			
		|||
  dependencies:
 | 
			
		||||
    has "^1.0.3"
 | 
			
		||||
 | 
			
		||||
is-core-module@^2.1.0:
 | 
			
		||||
  version "2.1.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.1.0.tgz#a4cc031d9b1aca63eecbd18a650e13cb4eeab946"
 | 
			
		||||
  integrity sha512-YcV7BgVMRFRua2FqQzKtTDMz8iCuLEyGKjr70q8Zm1yy2qKcurbFEd79PAdHV77oL3NrAaOVQIbMmiHQCHB7ZA==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    has "^1.0.3"
 | 
			
		||||
 | 
			
		||||
is-data-descriptor@^0.1.4:
 | 
			
		||||
  version "0.1.4"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz#0b5ee648388e2c860282e793f1856fec3f301b56"
 | 
			
		||||
| 
						 | 
				
			
			@ -5949,7 +5923,7 @@ is-url@^1.2.4:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/is-url/-/is-url-1.2.4.tgz#04a4df46d28c4cff3d73d01ff06abeb318a1aa52"
 | 
			
		||||
  integrity sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==
 | 
			
		||||
 | 
			
		||||
is-windows@^1.0.1, is-windows@^1.0.2:
 | 
			
		||||
is-windows@^1.0.2:
 | 
			
		||||
  version "1.0.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/is-windows/-/is-windows-1.0.2.tgz#d1850eb9791ecd18e6182ce12a30f396634bb19d"
 | 
			
		||||
  integrity sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==
 | 
			
		||||
| 
						 | 
				
			
			@ -6852,7 +6826,7 @@ lz-string@^1.4.4:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26"
 | 
			
		||||
  integrity sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=
 | 
			
		||||
 | 
			
		||||
make-dir@^2.0.0:
 | 
			
		||||
make-dir@^2.0.0, make-dir@^2.1.0:
 | 
			
		||||
  version "2.1.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-2.1.0.tgz#5f0310e18b8be898cc07009295a30ae41e91e6f5"
 | 
			
		||||
  integrity sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==
 | 
			
		||||
| 
						 | 
				
			
			@ -6961,7 +6935,7 @@ methods@~1.1.2:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
 | 
			
		||||
  integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=
 | 
			
		||||
 | 
			
		||||
micromatch@^3.0.4, micromatch@^3.1.10, micromatch@^3.1.4:
 | 
			
		||||
micromatch@^3.1.10, micromatch@^3.1.4:
 | 
			
		||||
  version "3.1.10"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-3.1.10.tgz#70859bc95c9840952f359a068a3fc49f9ecfac23"
 | 
			
		||||
  integrity sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==
 | 
			
		||||
| 
						 | 
				
			
			@ -7028,10 +7002,10 @@ min-indent@^1.0.0:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869"
 | 
			
		||||
  integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==
 | 
			
		||||
 | 
			
		||||
mini-css-extract-plugin@^1.3.0:
 | 
			
		||||
  version "1.3.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-1.3.0.tgz#bbcba978b68c39f0a9c75822cfb2874f9cf6b018"
 | 
			
		||||
  integrity sha512-4DKmPwFd0XKlwoqvrkLi2X8Mlosh2ey/E/OVAucnPUdzGqrSWHgSqed/p4Ue2Q39JjIvcdSDgmZDO6mir5Ovmw==
 | 
			
		||||
mini-css-extract-plugin@^1.3.1:
 | 
			
		||||
  version "1.3.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-1.3.1.tgz#1375c88b2bc2a9d197670a55761edcd1b5d72f21"
 | 
			
		||||
  integrity sha512-jIOheqh9EU98rqj6ZaFTYNNDSFqdakNqaUZfkYwaXPjI9batmXVXX+K71NrqRAgtoGefELBMld1EQ7dqSAD5SQ==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    loader-utils "^2.0.0"
 | 
			
		||||
    schema-utils "^3.0.0"
 | 
			
		||||
| 
						 | 
				
			
			@ -7753,11 +7727,6 @@ parse-json@^5.0.0:
 | 
			
		|||
    json-parse-better-errors "^1.0.1"
 | 
			
		||||
    lines-and-columns "^1.1.6"
 | 
			
		||||
 | 
			
		||||
parse-passwd@^1.0.0:
 | 
			
		||||
  version "1.0.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/parse-passwd/-/parse-passwd-1.0.0.tgz#6d5b934a456993b23d37f40a382d6f1666a8e5c6"
 | 
			
		||||
  integrity sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=
 | 
			
		||||
 | 
			
		||||
parse5@5.1.1:
 | 
			
		||||
  version "5.1.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178"
 | 
			
		||||
| 
						 | 
				
			
			@ -8403,16 +8372,6 @@ pretty-format@^25.2.1, pretty-format@^25.5.0:
 | 
			
		|||
    ansi-styles "^4.0.0"
 | 
			
		||||
    react-is "^16.12.0"
 | 
			
		||||
 | 
			
		||||
pretty-format@^26.4.2:
 | 
			
		||||
  version "26.4.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-26.4.2.tgz#d081d032b398e801e2012af2df1214ef75a81237"
 | 
			
		||||
  integrity sha512-zK6Gd8zDsEiVydOCGLkoBoZuqv8VTiHyAbKznXe/gaph/DAeZOmit9yMfgIz5adIgAMMs5XfoYSwAX3jcCO1tA==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@jest/types" "^26.3.0"
 | 
			
		||||
    ansi-regex "^5.0.0"
 | 
			
		||||
    ansi-styles "^4.0.0"
 | 
			
		||||
    react-is "^16.12.0"
 | 
			
		||||
 | 
			
		||||
pretty-format@^26.6.2:
 | 
			
		||||
  version "26.6.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-26.6.2.tgz#e35c2705f14cb7fe2fe94fa078345b444120fc93"
 | 
			
		||||
| 
						 | 
				
			
			@ -8995,6 +8954,13 @@ readline2@^1.0.1:
 | 
			
		|||
    is-fullwidth-code-point "^1.0.0"
 | 
			
		||||
    mute-stream "0.0.5"
 | 
			
		||||
 | 
			
		||||
rechoir@^0.7.0:
 | 
			
		||||
  version "0.7.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.7.0.tgz#32650fd52c21ab252aa5d65b19310441c7e03aca"
 | 
			
		||||
  integrity sha512-ADsDEH2bvbjltXEP+hTIAmeFekTFK0V2BTxMkok6qILyAJEXV0AFfoWcAq4yfll5VdIMd/RVXq0lR+wQi5ZU3Q==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    resolve "^1.9.0"
 | 
			
		||||
 | 
			
		||||
redent@^3.0.0:
 | 
			
		||||
  version "3.0.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/redent/-/redent-3.0.0.tgz#e557b7998316bb53c9f1f56fa626352c6963059f"
 | 
			
		||||
| 
						 | 
				
			
			@ -9030,6 +8996,11 @@ redis@^3.0.2:
 | 
			
		|||
    redis-errors "^1.2.0"
 | 
			
		||||
    redis-parser "^3.0.0"
 | 
			
		||||
 | 
			
		||||
reduce-flatten@^2.0.0:
 | 
			
		||||
  version "2.0.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-2.0.0.tgz#734fd84e65f375d7ca4465c69798c25c9d10ae27"
 | 
			
		||||
  integrity sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w==
 | 
			
		||||
 | 
			
		||||
redux-immutable@^4.0.0:
 | 
			
		||||
  version "4.0.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/redux-immutable/-/redux-immutable-4.0.0.tgz#3a1a32df66366462b63691f0e1dc35e472bbc9f3"
 | 
			
		||||
| 
						 | 
				
			
			@ -9246,14 +9217,6 @@ resolve-cwd@^3.0.0:
 | 
			
		|||
  dependencies:
 | 
			
		||||
    resolve-from "^5.0.0"
 | 
			
		||||
 | 
			
		||||
resolve-dir@^1.0.0, resolve-dir@^1.0.1:
 | 
			
		||||
  version "1.0.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/resolve-dir/-/resolve-dir-1.0.1.tgz#79a40644c362be82f26effe739c9bb5382046f43"
 | 
			
		||||
  integrity sha1-eaQGRMNivoLybv/nOcm7U4IEb0M=
 | 
			
		||||
  dependencies:
 | 
			
		||||
    expand-tilde "^2.0.0"
 | 
			
		||||
    global-modules "^1.0.0"
 | 
			
		||||
 | 
			
		||||
resolve-from@^1.0.0:
 | 
			
		||||
  version "1.0.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-1.0.1.tgz#26cbfe935d1aeeeabb29bc3fe5aeb01e93d44226"
 | 
			
		||||
| 
						 | 
				
			
			@ -9297,6 +9260,14 @@ resolve@^1.10.0, resolve@^1.12.0, resolve@^1.13.1, resolve@^1.17.0, resolve@^1.1
 | 
			
		|||
    is-core-module "^2.0.0"
 | 
			
		||||
    path-parse "^1.0.6"
 | 
			
		||||
 | 
			
		||||
resolve@^1.9.0:
 | 
			
		||||
  version "1.19.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.19.0.tgz#1af5bf630409734a067cae29318aac7fa29a267c"
 | 
			
		||||
  integrity sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    is-core-module "^2.1.0"
 | 
			
		||||
    path-parse "^1.0.6"
 | 
			
		||||
 | 
			
		||||
restore-cursor@^1.0.1:
 | 
			
		||||
  version "1.0.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541"
 | 
			
		||||
| 
						 | 
				
			
			@ -9435,10 +9406,10 @@ sass-lint@^1.13.1:
 | 
			
		|||
    path-is-absolute "^1.0.0"
 | 
			
		||||
    util "^0.10.3"
 | 
			
		||||
 | 
			
		||||
sass-loader@^10.0.5:
 | 
			
		||||
  version "10.0.5"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-10.0.5.tgz#f53505b5ddbedf43797470ceb34066ded82bb769"
 | 
			
		||||
  integrity sha512-2LqoNPtKkZq/XbXNQ4C64GFEleSEHKv6NPSI+bMC/l+jpEXGJhiRYkAQToO24MR7NU4JRY2RpLpJ/gjo2Uf13w==
 | 
			
		||||
sass-loader@^10.1.0:
 | 
			
		||||
  version "10.1.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-10.1.0.tgz#1727fcc0c32ab3eb197cda61d78adf4e9174a4b3"
 | 
			
		||||
  integrity sha512-ZCKAlczLBbFd3aGAhowpYEy69Te3Z68cg8bnHHl6WnSCvnKpbM6pQrz957HWMa8LKVuhnD9uMplmMAHwGQtHeg==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    klona "^2.0.4"
 | 
			
		||||
    loader-utils "^2.0.0"
 | 
			
		||||
| 
						 | 
				
			
			@ -10274,6 +10245,16 @@ symbol-tree@^3.2.4:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2"
 | 
			
		||||
  integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==
 | 
			
		||||
 | 
			
		||||
table-layout@^1.0.1:
 | 
			
		||||
  version "1.0.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/table-layout/-/table-layout-1.0.1.tgz#8411181ee951278ad0638aea2f779a9ce42894f9"
 | 
			
		||||
  integrity sha512-dEquqYNJiGwY7iPfZ3wbXDI944iqanTSchrACLL2nOB+1r+h1Nzu2eH+DuPPvWvm5Ry7iAPeFlgEtP5bIp5U7Q==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    array-back "^4.0.1"
 | 
			
		||||
    deep-extend "~0.6.0"
 | 
			
		||||
    typical "^5.2.0"
 | 
			
		||||
    wordwrapjs "^4.0.0"
 | 
			
		||||
 | 
			
		||||
table@^3.7.8:
 | 
			
		||||
  version "3.8.3"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/table/-/table-3.8.3.tgz#2bbc542f0fda9861a755d3947fefd8b3f513855f"
 | 
			
		||||
| 
						 | 
				
			
			@ -10644,6 +10625,11 @@ typedarray@^0.0.6:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777"
 | 
			
		||||
  integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=
 | 
			
		||||
 | 
			
		||||
typical@^5.0.0, typical@^5.2.0:
 | 
			
		||||
  version "5.2.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/typical/-/typical-5.2.0.tgz#4daaac4f2b5315460804f0acf6cb69c52bb93066"
 | 
			
		||||
  integrity sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg==
 | 
			
		||||
 | 
			
		||||
unicode-canonical-property-names-ecmascript@^1.0.4:
 | 
			
		||||
  version "1.0.4"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"
 | 
			
		||||
| 
						 | 
				
			
			@ -10839,10 +10825,10 @@ uuid@^8.3.0, uuid@^8.3.1:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.1.tgz#2ba2e6ca000da60fce5a196954ab241131e05a31"
 | 
			
		||||
  integrity sha512-FOmRr+FmWEIG8uhZv6C2bTgEVXsHk08kE7mPlrBbEe+c3r9pjceVPgupIfNIhc4yx55H69OXANrUaSuu9eInKg==
 | 
			
		||||
 | 
			
		||||
v8-compile-cache@^2.0.3, v8-compile-cache@^2.1.1:
 | 
			
		||||
  version "2.1.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.1.tgz#54bc3cdd43317bca91e35dcaf305b1a7237de745"
 | 
			
		||||
  integrity sha512-8OQ9CL+VWyt3JStj7HX7/ciTL2V3Rl1Wf5OL+SNTm0yK1KvtReVulksyeRnCANHHuUxHlQig+JJDlUhBt1NQDQ==
 | 
			
		||||
v8-compile-cache@^2.0.3, v8-compile-cache@^2.2.0:
 | 
			
		||||
  version "2.2.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.2.0.tgz#9471efa3ef9128d2f7c6a7ca39c4dd6b5055b132"
 | 
			
		||||
  integrity sha512-gTpR5XQNKFwOd4clxfnhaqvfqMpqEwr4tOtCyz4MtYZX2JYhfr1JvBFKdS+7K/9rfpZR3VLX+YWBbKoxCgS43Q==
 | 
			
		||||
 | 
			
		||||
v8-to-istanbul@^7.0.0:
 | 
			
		||||
  version "7.0.0"
 | 
			
		||||
| 
						 | 
				
			
			@ -10996,22 +10982,24 @@ webpack-bundle-analyzer@^4.1.0:
 | 
			
		|||
    opener "^1.5.2"
 | 
			
		||||
    ws "^7.3.1"
 | 
			
		||||
 | 
			
		||||
webpack-cli@^3.3.12:
 | 
			
		||||
  version "3.3.12"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-3.3.12.tgz#94e9ada081453cd0aa609c99e500012fd3ad2d4a"
 | 
			
		||||
  integrity sha512-NVWBaz9k839ZH/sinurM+HcDvJOTXwSjYp1ku+5XKeOC03z8v5QitnK/x+lAxGXFyhdayoIf/GOpv85z3/xPag==
 | 
			
		||||
webpack-cli@^4.2.0:
 | 
			
		||||
  version "4.2.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-4.2.0.tgz#10a09030ad2bd4d8b0f78322fba6ea43ec56aaaa"
 | 
			
		||||
  integrity sha512-EIl3k88vaF4fSxWSgtAQR+VwicfLMTZ9amQtqS4o+TDPW9HGaEpbFBbAZ4A3ZOT5SOnMxNOzROsSTPiE8tBJPA==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    chalk "^2.4.2"
 | 
			
		||||
    cross-spawn "^6.0.5"
 | 
			
		||||
    enhanced-resolve "^4.1.1"
 | 
			
		||||
    findup-sync "^3.0.0"
 | 
			
		||||
    global-modules "^2.0.0"
 | 
			
		||||
    import-local "^2.0.0"
 | 
			
		||||
    interpret "^1.4.0"
 | 
			
		||||
    loader-utils "^1.4.0"
 | 
			
		||||
    supports-color "^6.1.0"
 | 
			
		||||
    v8-compile-cache "^2.1.1"
 | 
			
		||||
    yargs "^13.3.2"
 | 
			
		||||
    "@webpack-cli/info" "^1.1.0"
 | 
			
		||||
    "@webpack-cli/serve" "^1.1.0"
 | 
			
		||||
    colorette "^1.2.1"
 | 
			
		||||
    command-line-usage "^6.1.0"
 | 
			
		||||
    commander "^6.2.0"
 | 
			
		||||
    enquirer "^2.3.6"
 | 
			
		||||
    execa "^4.1.0"
 | 
			
		||||
    import-local "^3.0.2"
 | 
			
		||||
    interpret "^2.2.0"
 | 
			
		||||
    leven "^3.1.0"
 | 
			
		||||
    rechoir "^0.7.0"
 | 
			
		||||
    v8-compile-cache "^2.2.0"
 | 
			
		||||
    webpack-merge "^4.2.2"
 | 
			
		||||
 | 
			
		||||
webpack-dev-middleware@^3.7.2:
 | 
			
		||||
  version "3.7.2"
 | 
			
		||||
| 
						 | 
				
			
			@ -11071,10 +11059,17 @@ webpack-log@^2.0.0:
 | 
			
		|||
    ansi-colors "^3.0.0"
 | 
			
		||||
    uuid "^3.3.2"
 | 
			
		||||
 | 
			
		||||
webpack-merge@^5.3.0:
 | 
			
		||||
  version "5.3.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-5.3.0.tgz#a80df44d35fabace680bf430a19fda9ec49ed8eb"
 | 
			
		||||
  integrity sha512-4PtsBAWnmJULIJYviiPq4BxwAykbAgGMheyEVaemj2bJI54h+p/gnlbXZEH2EM0IYC3blOE1Qm6kzKlc06N1UQ==
 | 
			
		||||
webpack-merge@^4.2.2:
 | 
			
		||||
  version "4.2.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-4.2.2.tgz#a27c52ea783d1398afd2087f547d7b9d2f43634d"
 | 
			
		||||
  integrity sha512-TUE1UGoTX2Cd42j3krGYqObZbOD+xF7u28WB7tfUordytSjbWTIjK/8V0amkBfTYN4/pB/GIDlJZZ657BGG19g==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    lodash "^4.17.15"
 | 
			
		||||
 | 
			
		||||
webpack-merge@^5.4.0:
 | 
			
		||||
  version "5.4.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-5.4.0.tgz#81bef0a7d23fc1e6c24b06ad8bf22ddeb533a3a3"
 | 
			
		||||
  integrity sha512-/scBgu8LVPlHDgqH95Aw1xS+L+PHrpHKOwYVGFaNOQl4Q4wwwWDarwB1WdZAbLQ24SKhY3Awe7VZGYAdp+N+gQ==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    clone-deep "^4.0.1"
 | 
			
		||||
    wildcard "^2.0.0"
 | 
			
		||||
| 
						 | 
				
			
			@ -11163,7 +11158,7 @@ which-module@^2.0.0:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a"
 | 
			
		||||
  integrity sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=
 | 
			
		||||
 | 
			
		||||
which@^1.2.14, which@^1.2.9, which@^1.3.1:
 | 
			
		||||
which@^1.2.9:
 | 
			
		||||
  version "1.3.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
 | 
			
		||||
  integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==
 | 
			
		||||
| 
						 | 
				
			
			@ -11199,6 +11194,14 @@ word-wrap@^1.2.3, word-wrap@~1.2.3:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
 | 
			
		||||
  integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
 | 
			
		||||
 | 
			
		||||
wordwrapjs@^4.0.0:
 | 
			
		||||
  version "4.0.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/wordwrapjs/-/wordwrapjs-4.0.0.tgz#9aa9394155993476e831ba8e59fb5795ebde6800"
 | 
			
		||||
  integrity sha512-Svqw723a3R34KvsMgpjFBYCgNOSdcW3mQFK4wIfhGQhtaFVOJmdYoXgi63ne3dTlWgatVcUc7t4HtQ/+bUVIzQ==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    reduce-flatten "^2.0.0"
 | 
			
		||||
    typical "^5.0.0"
 | 
			
		||||
 | 
			
		||||
worker-farm@^1.7.0:
 | 
			
		||||
  version "1.7.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/worker-farm/-/worker-farm-1.7.0.tgz#26a94c5391bbca926152002f69b84a4bf772e5a8"
 | 
			
		||||
| 
						 | 
				
			
			@ -11294,10 +11297,10 @@ y18n@^4.0.0:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.0.tgz#95ef94f85ecc81d007c264e190a120f0a3c8566b"
 | 
			
		||||
  integrity sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==
 | 
			
		||||
 | 
			
		||||
y18n@^5.0.2:
 | 
			
		||||
  version "5.0.4"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.4.tgz#0ab2db89dd5873b5ec4682d8e703e833373ea897"
 | 
			
		||||
  integrity sha512-deLOfD+RvFgrpAmSZgfGdWYE+OKyHcVHaRQ7NphG/63scpRvTHHeQMAxGGvaLVGJ+HYVcCXlzcTK0ZehFf+eHQ==
 | 
			
		||||
y18n@^5.0.5:
 | 
			
		||||
  version "5.0.5"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.5.tgz#8769ec08d03b1ea2df2500acef561743bbb9ab18"
 | 
			
		||||
  integrity sha512-hsRUr4FFrvhhRH12wOdfs38Gy7k2FFzB9qgN9v3aLykRq0dRcdcpz5C9FxdS2NuhOrI/628b/KSTJ3rwHysYSg==
 | 
			
		||||
 | 
			
		||||
yallist@^3.0.2:
 | 
			
		||||
  version "3.1.1"
 | 
			
		||||
| 
						 | 
				
			
			@ -11368,17 +11371,17 @@ yargs@^15.4.1:
 | 
			
		|||
    y18n "^4.0.0"
 | 
			
		||||
    yargs-parser "^18.1.2"
 | 
			
		||||
 | 
			
		||||
yargs@^16.1.0:
 | 
			
		||||
  version "16.1.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.1.0.tgz#fc333fe4791660eace5a894b39d42f851cd48f2a"
 | 
			
		||||
  integrity sha512-upWFJOmDdHN0syLuESuvXDmrRcWd1QafJolHskzaw79uZa7/x53gxQKiR07W59GWY1tFhhU/Th9DrtSfpS782g==
 | 
			
		||||
yargs@^16.1.1:
 | 
			
		||||
  version "16.1.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.1.1.tgz#5a4a095bd1ca806b0a50d0c03611d38034d219a1"
 | 
			
		||||
  integrity sha512-hAD1RcFP/wfgfxgMVswPE+z3tlPFtxG8/yWUrG2i17sTWGCGqWnxKcLTF4cUKDUK8fzokwsmO9H0TDkRbMHy8w==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    cliui "^7.0.2"
 | 
			
		||||
    escalade "^3.1.1"
 | 
			
		||||
    get-caller-file "^2.0.5"
 | 
			
		||||
    require-directory "^2.1.1"
 | 
			
		||||
    string-width "^4.2.0"
 | 
			
		||||
    y18n "^5.0.2"
 | 
			
		||||
    y18n "^5.0.5"
 | 
			
		||||
    yargs-parser "^20.2.2"
 | 
			
		||||
 | 
			
		||||
zlibjs@^0.3.1:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue