Merge pull request #1233 from ThibG/glitch-soc/merge-upstream
Merge upstream changes
This commit is contained in:
		
						commit
						ef925f31a6
					
				
					 75 changed files with 1005 additions and 883 deletions
				
			
		
							
								
								
									
										668
									
								
								AUTHORS.md
									
									
									
									
									
								
							
							
						
						
									
										668
									
								
								AUTHORS.md
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load diff
											
										
									
								
							
							
								
								
									
										33
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										33
									
								
								CHANGELOG.md
									
									
									
									
									
								
							|  | @ -3,6 +3,39 @@ Changelog | ||||||
| 
 | 
 | ||||||
| All notable changes to this project will be documented in this file. | All notable changes to this project will be documented in this file. | ||||||
| 
 | 
 | ||||||
|  | ## [3.0.1] - 2019-10-10 | ||||||
|  | ### Added | ||||||
|  | 
 | ||||||
|  | - Add `tootctl media usage` command ([Gargron](https://github.com/tootsuite/mastodon/pull/12115)) | ||||||
|  | - Add admin setting to auto-approve trending hashtags ([Gargron](https://github.com/tootsuite/mastodon/pull/12122), [Gargron](https://github.com/tootsuite/mastodon/pull/12130)) | ||||||
|  | 
 | ||||||
|  | ### Changed | ||||||
|  | 
 | ||||||
|  | - Change `tootctl media refresh` to skip already downloaded attachments ([Gargron](https://github.com/tootsuite/mastodon/pull/12118)) | ||||||
|  | 
 | ||||||
|  | ### Removed | ||||||
|  | 
 | ||||||
|  | - Remove auto-silence behaviour from spam check ([Gargron](https://github.com/tootsuite/mastodon/pull/12117)) | ||||||
|  | - Remove HTML `lang` attribute from individual statuses in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/12124)) | ||||||
|  | - Remove fallback to long description on sidebar and meta description ([Gargron](https://github.com/tootsuite/mastodon/pull/12119)) | ||||||
|  | 
 | ||||||
|  | ### Fixed | ||||||
|  | 
 | ||||||
|  | - Fix preloaded JSON-LD context for identity not being used ([Gargron](https://github.com/tootsuite/mastodon/pull/12138)) | ||||||
|  | - Fix media editing modal changing dimensions once the image loads ([Gargron](https://github.com/tootsuite/mastodon/pull/12131)) | ||||||
|  | - Fix not showing whether a custom emoji has a local counterpart in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/12135)) | ||||||
|  | - Fix attachment not being re-downloaded even if file is not stored ([Gargron](https://github.com/tootsuite/mastodon/pull/12125)) | ||||||
|  | - Fix old migration trying to use new column due to default status scope ([Gargron](https://github.com/tootsuite/mastodon/pull/12095)) | ||||||
|  | - Fix column back button missing for not found accounts ([trwnh](https://github.com/tootsuite/mastodon/pull/12094)) | ||||||
|  | - Fix issues with tootctl's parallelization and progress reporting ([Gargron](https://github.com/tootsuite/mastodon/pull/12093), [Gargron](https://github.com/tootsuite/mastodon/pull/12097)) | ||||||
|  | - Fix existing user records with now-renamed `pt` locale ([Gargron](https://github.com/tootsuite/mastodon/pull/12092)) | ||||||
|  | - Fix hashtag timeline REST API accepting too many hashtags ([Gargron](https://github.com/tootsuite/mastodon/pull/12091)) | ||||||
|  | - Fix `GET /api/v1/instance` REST APIs being unavailable in secure mode ([Gargron](https://github.com/tootsuite/mastodon/pull/12089)) | ||||||
|  | - Fix performance of home feed regeneration and merging ([Gargron](https://github.com/tootsuite/mastodon/pull/12084)) | ||||||
|  | - Fix ffmpeg performance issues due to stdout buffer overflow ([hugogameiro](https://github.com/tootsuite/mastodon/pull/12088)) | ||||||
|  | - Fix S3 adapter retrying failing uploads with exponential backoff ([Gargron](https://github.com/tootsuite/mastodon/pull/12085)) | ||||||
|  | - Fix `tootctl accounts cull` advertising unused option flag ([Kjwon15](https://github.com/tootsuite/mastodon/pull/12074)) | ||||||
|  | 
 | ||||||
| ## [3.0.0] - 2019-10-03 | ## [3.0.0] - 2019-10-03 | ||||||
| ### Added | ### Added | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
							
								
								
									
										4
									
								
								Gemfile
									
									
									
									
									
								
							
							
						
						
									
										4
									
								
								Gemfile
									
									
									
									
									
								
							|  | @ -90,7 +90,7 @@ gem 'simple_form', '~> 4.1' | ||||||
| gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie' | gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie' | ||||||
| gem 'stoplight', '~> 2.1.3' | gem 'stoplight', '~> 2.1.3' | ||||||
| gem 'strong_migrations', '~> 0.4' | gem 'strong_migrations', '~> 0.4' | ||||||
| gem 'tty-command', '~> 0.8', require: false | gem 'tty-command', '~> 0.9', require: false | ||||||
| gem 'tty-prompt', '~> 0.19', require: false | gem 'tty-prompt', '~> 0.19', require: false | ||||||
| gem 'twitter-text', '~> 1.14' | gem 'twitter-text', '~> 1.14' | ||||||
| gem 'tzinfo-data', '~> 1.2019' | gem 'tzinfo-data', '~> 1.2019' | ||||||
|  | @ -119,7 +119,7 @@ end | ||||||
| group :test do | group :test do | ||||||
|   gem 'capybara', '~> 3.29' |   gem 'capybara', '~> 3.29' | ||||||
|   gem 'climate_control', '~> 0.2' |   gem 'climate_control', '~> 0.2' | ||||||
|   gem 'faker', '~> 2.4' |   gem 'faker', '~> 2.5' | ||||||
|   gem 'microformats', '~> 4.1' |   gem 'microformats', '~> 4.1' | ||||||
|   gem 'rails-controller-testing', '~> 1.0' |   gem 'rails-controller-testing', '~> 1.0' | ||||||
|   gem 'rspec-sidekiq', '~> 3.0' |   gem 'rspec-sidekiq', '~> 3.0' | ||||||
|  |  | ||||||
							
								
								
									
										33
									
								
								Gemfile.lock
									
									
									
									
									
								
							
							
						
						
									
										33
									
								
								Gemfile.lock
									
									
									
									
									
								
							|  | @ -93,7 +93,7 @@ GEM | ||||||
|       tzinfo (~> 1.1) |       tzinfo (~> 1.1) | ||||||
|     addressable (2.7.0) |     addressable (2.7.0) | ||||||
|       public_suffix (>= 2.0.2, < 5.0) |       public_suffix (>= 2.0.2, < 5.0) | ||||||
|     airbrussh (1.3.3) |     airbrussh (1.3.4) | ||||||
|       sshkit (>= 1.6.1, != 1.7.0) |       sshkit (>= 1.6.1, != 1.7.0) | ||||||
|     annotate (2.7.5) |     annotate (2.7.5) | ||||||
|       activerecord (>= 3.2, < 7.0) |       activerecord (>= 3.2, < 7.0) | ||||||
|  | @ -142,7 +142,7 @@ GEM | ||||||
|       bundler (>= 1.2.0, < 3) |       bundler (>= 1.2.0, < 3) | ||||||
|       thor (~> 0.18) |       thor (~> 0.18) | ||||||
|     byebug (11.0.0) |     byebug (11.0.0) | ||||||
|     capistrano (3.11.1) |     capistrano (3.11.2) | ||||||
|       airbrussh (>= 1.0.0) |       airbrussh (>= 1.0.0) | ||||||
|       i18n |       i18n | ||||||
|       rake (>= 10.0.0) |       rake (>= 10.0.0) | ||||||
|  | @ -188,13 +188,14 @@ GEM | ||||||
|     css_parser (1.7.0) |     css_parser (1.7.0) | ||||||
|       addressable |       addressable | ||||||
|     debug_inspector (0.0.3) |     debug_inspector (0.0.3) | ||||||
|     derailed_benchmarks (1.3.6) |     derailed_benchmarks (1.4.0) | ||||||
|       benchmark-ips (~> 2) |       benchmark-ips (~> 2) | ||||||
|       get_process_mem (~> 0) |       get_process_mem (~> 0) | ||||||
|       heapy (~> 0) |       heapy (~> 0) | ||||||
|       memory_profiler (~> 0) |       memory_profiler (~> 0) | ||||||
|       rack (>= 1) |       rack (>= 1) | ||||||
|       rake (> 10, < 13) |       rake (> 10, < 13) | ||||||
|  |       ruby-statistics (>= 2.1) | ||||||
|       thor (~> 0.19) |       thor (~> 0.19) | ||||||
|     devise (4.7.1) |     devise (4.7.1) | ||||||
|       bcrypt (~> 3.0) |       bcrypt (~> 3.0) | ||||||
|  | @ -233,13 +234,13 @@ GEM | ||||||
|       faraday |       faraday | ||||||
|       multi_json |       multi_json | ||||||
|     encryptor (3.0.0) |     encryptor (3.0.0) | ||||||
|     equatable (0.5.0) |     equatable (0.6.1) | ||||||
|     erubi (1.8.0) |     erubi (1.8.0) | ||||||
|     et-orbi (1.1.6) |     et-orbi (1.1.6) | ||||||
|       tzinfo |       tzinfo | ||||||
|     excon (0.62.0) |     excon (0.62.0) | ||||||
|     fabrication (2.20.2) |     fabrication (2.20.2) | ||||||
|     faker (2.4.0) |     faker (2.5.0) | ||||||
|       i18n (~> 1.6.0) |       i18n (~> 1.6.0) | ||||||
|     faraday (0.15.4) |     faraday (0.15.4) | ||||||
|       multipart-post (>= 1.2, < 3) |       multipart-post (>= 1.2, < 3) | ||||||
|  | @ -265,7 +266,8 @@ GEM | ||||||
|     fuubar (2.4.1) |     fuubar (2.4.1) | ||||||
|       rspec-core (~> 3.0) |       rspec-core (~> 3.0) | ||||||
|       ruby-progressbar (~> 1.4) |       ruby-progressbar (~> 1.4) | ||||||
|     get_process_mem (0.2.3) |     get_process_mem (0.2.4) | ||||||
|  |       ffi (~> 1.0) | ||||||
|     globalid (0.4.2) |     globalid (0.4.2) | ||||||
|       activesupport (>= 4.2.0) |       activesupport (>= 4.2.0) | ||||||
|     goldfinger (2.1.0) |     goldfinger (2.1.0) | ||||||
|  | @ -429,13 +431,13 @@ GEM | ||||||
|     parser (2.6.4.0) |     parser (2.6.4.0) | ||||||
|       ast (~> 2.4.0) |       ast (~> 2.4.0) | ||||||
|     parslet (1.8.2) |     parslet (1.8.2) | ||||||
|     pastel (0.7.2) |     pastel (0.7.3) | ||||||
|       equatable (~> 0.5.0) |       equatable (~> 0.6) | ||||||
|       tty-color (~> 0.4.0) |       tty-color (~> 0.5) | ||||||
|     pg (1.1.4) |     pg (1.1.4) | ||||||
|     pghero (2.3.0) |     pghero (2.3.0) | ||||||
|       activerecord (>= 5) |       activerecord (>= 5) | ||||||
|     pkg-config (1.3.8) |     pkg-config (1.3.9) | ||||||
|     premailer (1.11.1) |     premailer (1.11.1) | ||||||
|       addressable |       addressable | ||||||
|       css_parser (>= 1.6.0) |       css_parser (>= 1.6.0) | ||||||
|  | @ -571,6 +573,7 @@ GEM | ||||||
|     ruby-progressbar (1.10.1) |     ruby-progressbar (1.10.1) | ||||||
|     ruby-saml (1.9.0) |     ruby-saml (1.9.0) | ||||||
|       nokogiri (>= 1.5.10) |       nokogiri (>= 1.5.10) | ||||||
|  |     ruby-statistics (2.1.1) | ||||||
|     rufus-scheduler (3.5.2) |     rufus-scheduler (3.5.2) | ||||||
|       fugit (~> 1.1, >= 1.1.5) |       fugit (~> 1.1, >= 1.1.5) | ||||||
|     safe_yaml (1.0.5) |     safe_yaml (1.0.5) | ||||||
|  | @ -629,8 +632,8 @@ GEM | ||||||
|     thor (0.20.3) |     thor (0.20.3) | ||||||
|     thread_safe (0.3.6) |     thread_safe (0.3.6) | ||||||
|     tilt (2.0.9) |     tilt (2.0.9) | ||||||
|     tty-color (0.4.3) |     tty-color (0.5.0) | ||||||
|     tty-command (0.8.2) |     tty-command (0.9.0) | ||||||
|       pastel (~> 0.7.0) |       pastel (~> 0.7.0) | ||||||
|     tty-cursor (0.7.0) |     tty-cursor (0.7.0) | ||||||
|     tty-prompt (0.19.0) |     tty-prompt (0.19.0) | ||||||
|  | @ -655,7 +658,7 @@ GEM | ||||||
|     uniform_notifier (1.12.1) |     uniform_notifier (1.12.1) | ||||||
|     warden (1.2.8) |     warden (1.2.8) | ||||||
|       rack (>= 2.0.6) |       rack (>= 2.0.6) | ||||||
|     webmock (3.7.5) |     webmock (3.7.6) | ||||||
|       addressable (>= 2.3.6) |       addressable (>= 2.3.6) | ||||||
|       crack (>= 0.3.2) |       crack (>= 0.3.2) | ||||||
|       hashdiff (>= 0.4.0, < 2.0.0) |       hashdiff (>= 0.4.0, < 2.0.0) | ||||||
|  | @ -709,7 +712,7 @@ DEPENDENCIES | ||||||
|   doorkeeper (~> 5.2) |   doorkeeper (~> 5.2) | ||||||
|   dotenv-rails (~> 2.7) |   dotenv-rails (~> 2.7) | ||||||
|   fabrication (~> 2.20) |   fabrication (~> 2.20) | ||||||
|   faker (~> 2.4) |   faker (~> 2.5) | ||||||
|   fast_blank (~> 1.0) |   fast_blank (~> 1.0) | ||||||
|   fastimage |   fastimage | ||||||
|   fog-core (<= 2.1.0) |   fog-core (<= 2.1.0) | ||||||
|  | @ -796,7 +799,7 @@ DEPENDENCIES | ||||||
|   streamio-ffmpeg (~> 3.0) |   streamio-ffmpeg (~> 3.0) | ||||||
|   strong_migrations (~> 0.4) |   strong_migrations (~> 0.4) | ||||||
|   thor (~> 0.20) |   thor (~> 0.20) | ||||||
|   tty-command (~> 0.8) |   tty-command (~> 0.9) | ||||||
|   tty-prompt (~> 0.19) |   tty-prompt (~> 0.19) | ||||||
|   twitter-text (~> 1.14) |   twitter-text (~> 1.14) | ||||||
|   tzinfo-data (~> 1.2019) |   tzinfo-data (~> 1.2019) | ||||||
|  |  | ||||||
|  | @ -4,6 +4,7 @@ class Api::V1::Instances::ActivityController < Api::BaseController | ||||||
|   before_action :require_enabled_api! |   before_action :require_enabled_api! | ||||||
| 
 | 
 | ||||||
|   skip_before_action :set_cache_headers |   skip_before_action :set_cache_headers | ||||||
|  |   skip_before_action :require_authenticated_user!, unless: :whitelist_mode? | ||||||
| 
 | 
 | ||||||
|   respond_to :json |   respond_to :json | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -4,6 +4,7 @@ class Api::V1::Instances::PeersController < Api::BaseController | ||||||
|   before_action :require_enabled_api! |   before_action :require_enabled_api! | ||||||
| 
 | 
 | ||||||
|   skip_before_action :set_cache_headers |   skip_before_action :set_cache_headers | ||||||
|  |   skip_before_action :require_authenticated_user!, unless: :whitelist_mode? | ||||||
| 
 | 
 | ||||||
|   respond_to :json |   respond_to :json | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -4,6 +4,7 @@ class Api::V1::InstancesController < Api::BaseController | ||||||
|   respond_to :json |   respond_to :json | ||||||
| 
 | 
 | ||||||
|   skip_before_action :set_cache_headers |   skip_before_action :set_cache_headers | ||||||
|  |   skip_before_action :require_authenticated_user!, unless: :whitelist_mode? | ||||||
| 
 | 
 | ||||||
|   def show |   def show | ||||||
|     expires_in 3.minutes, public: true |     expires_in 3.minutes, public: true | ||||||
|  |  | ||||||
|  | @ -5,11 +5,17 @@ class Api::V1::StreamingController < Api::BaseController | ||||||
| 
 | 
 | ||||||
|   def index |   def index | ||||||
|     if Rails.configuration.x.streaming_api_base_url != request.host |     if Rails.configuration.x.streaming_api_base_url != request.host | ||||||
|       uri = URI.parse(request.url) |       redirect_to streaming_api_url, status: 301 | ||||||
|       uri.host = URI.parse(Rails.configuration.x.streaming_api_base_url).host |  | ||||||
|       redirect_to uri.to_s, status: 301 |  | ||||||
|     else |     else | ||||||
|       raise ActiveRecord::RecordNotFound |       not_found | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  | 
 | ||||||
|  |   private | ||||||
|  | 
 | ||||||
|  |   def streaming_api_url | ||||||
|  |     Addressable::URI.parse(request.url).tap do |uri| | ||||||
|  |       uri.host = Addressable::URI.parse(Rails.configuration.x.streaming_api_base_url).host | ||||||
|  |     end.to_s | ||||||
|  |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -13,7 +13,7 @@ class Api::V1::Timelines::HomeController < Api::BaseController | ||||||
|     render json: @statuses, |     render json: @statuses, | ||||||
|            each_serializer: REST::StatusSerializer, |            each_serializer: REST::StatusSerializer, | ||||||
|            relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), |            relationships: StatusRelationshipsPresenter.new(@statuses, current_user&.account_id), | ||||||
|            status: regeneration_in_progress? ? 206 : 200 |            status: account_home_feed.regenerating? ? 206 : 200 | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   private |   private | ||||||
|  | @ -62,8 +62,4 @@ class Api::V1::Timelines::HomeController < Api::BaseController | ||||||
|   def pagination_since_id |   def pagination_since_id | ||||||
|     @statuses.first.id |     @statuses.first.id | ||||||
|   end |   end | ||||||
| 
 |  | ||||||
|   def regeneration_in_progress? |  | ||||||
|     Redis.current.exists("account:#{current_account.id}:regeneration") |  | ||||||
|   end |  | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -97,7 +97,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { | ||||||
|     api(getState).get(path, { params }).then(response => { |     api(getState).get(path, { params }).then(response => { | ||||||
|       const next = getLinks(response).refs.find(link => link.rel === 'next'); |       const next = getLinks(response).refs.find(link => link.rel === 'next'); | ||||||
|       dispatch(importFetchedStatuses(response.data)); |       dispatch(importFetchedStatuses(response.data)); | ||||||
|       dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems)); |       dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems)); | ||||||
|       done(); |       done(); | ||||||
|     }).catch(error => { |     }).catch(error => { | ||||||
|       dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); |       dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); | ||||||
|  |  | ||||||
|  | @ -1,63 +0,0 @@ | ||||||
| import React from 'react'; |  | ||||||
| import PropTypes from 'prop-types'; |  | ||||||
| 
 |  | ||||||
| export default class ExtendedVideoPlayer extends React.PureComponent { |  | ||||||
| 
 |  | ||||||
|   static propTypes = { |  | ||||||
|     src: PropTypes.string.isRequired, |  | ||||||
|     alt: PropTypes.string, |  | ||||||
|     width: PropTypes.number, |  | ||||||
|     height: PropTypes.number, |  | ||||||
|     time: PropTypes.number, |  | ||||||
|     controls: PropTypes.bool.isRequired, |  | ||||||
|     muted: PropTypes.bool.isRequired, |  | ||||||
|     onClick: PropTypes.func, |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   handleLoadedData = () => { |  | ||||||
|     if (this.props.time) { |  | ||||||
|       this.video.currentTime = this.props.time; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   componentDidMount () { |  | ||||||
|     this.video.addEventListener('loadeddata', this.handleLoadedData); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   componentWillUnmount () { |  | ||||||
|     this.video.removeEventListener('loadeddata', this.handleLoadedData); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   setRef = (c) => { |  | ||||||
|     this.video = c; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   handleClick = e => { |  | ||||||
|     e.stopPropagation(); |  | ||||||
|     const handler = this.props.onClick; |  | ||||||
|     if (handler) handler(); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   render () { |  | ||||||
|     const { src, muted, controls, alt } = this.props; |  | ||||||
| 
 |  | ||||||
|     return ( |  | ||||||
|       <div className='extended-video-player'> |  | ||||||
|         <video |  | ||||||
|           ref={this.setRef} |  | ||||||
|           src={src} |  | ||||||
|           autoPlay |  | ||||||
|           role='button' |  | ||||||
|           tabIndex='0' |  | ||||||
|           aria-label={alt} |  | ||||||
|           title={alt} |  | ||||||
|           muted={muted} |  | ||||||
|           controls={controls} |  | ||||||
|           loop={!controls} |  | ||||||
|           onClick={this.handleClick} |  | ||||||
|         /> |  | ||||||
|       </div> |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
| } |  | ||||||
							
								
								
									
										75
									
								
								app/javascript/flavours/glitch/components/gifv.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								app/javascript/flavours/glitch/components/gifv.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,75 @@ | ||||||
|  | import React from 'react'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
|  | 
 | ||||||
|  | export default class GIFV extends React.PureComponent { | ||||||
|  | 
 | ||||||
|  |   static propTypes = { | ||||||
|  |     src: PropTypes.string.isRequired, | ||||||
|  |     alt: PropTypes.string, | ||||||
|  |     width: PropTypes.number, | ||||||
|  |     height: PropTypes.number, | ||||||
|  |     onClick: PropTypes.func, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   state = { | ||||||
|  |     loading: true, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   handleLoadedData = () => { | ||||||
|  |     this.setState({ loading: false }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   componentWillReceiveProps (nextProps) { | ||||||
|  |     if (nextProps.src !== this.props.src) { | ||||||
|  |       this.setState({ loading: true }); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   handleClick = e => { | ||||||
|  |     const { onClick } = this.props; | ||||||
|  | 
 | ||||||
|  |     if (onClick) { | ||||||
|  |       e.stopPropagation(); | ||||||
|  |       onClick(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   render () { | ||||||
|  |     const { src, width, height, alt } = this.props; | ||||||
|  |     const { loading } = this.state; | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |       <div className='gifv' style={{ position: 'relative' }}> | ||||||
|  |         {loading && ( | ||||||
|  |           <canvas | ||||||
|  |             width={width} | ||||||
|  |             height={height} | ||||||
|  |             role='button' | ||||||
|  |             tabIndex='0' | ||||||
|  |             aria-label={alt} | ||||||
|  |             title={alt} | ||||||
|  |             onClick={this.handleClick} | ||||||
|  |           /> | ||||||
|  |         )} | ||||||
|  | 
 | ||||||
|  |         <video | ||||||
|  |           src={src} | ||||||
|  |           width={width} | ||||||
|  |           height={height} | ||||||
|  |           role='button' | ||||||
|  |           tabIndex='0' | ||||||
|  |           aria-label={alt} | ||||||
|  |           title={alt} | ||||||
|  |           muted | ||||||
|  |           loop | ||||||
|  |           autoPlay | ||||||
|  |           playsInline | ||||||
|  |           onClick={this.handleClick} | ||||||
|  |           onLoadedData={this.handleLoadedData} | ||||||
|  |           style={{ position: loading ? 'absolute' : 'static', top: 0, left: 0 }} | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | @ -1,17 +1,24 @@ | ||||||
| import React from 'react'; | import React from 'react'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
| import { FormattedMessage } from 'react-intl'; | import { FormattedMessage } from 'react-intl'; | ||||||
|  | import illustration from 'flavours/glitch/images/elephant_ui_disappointed.svg'; | ||||||
|  | import classNames from 'classnames'; | ||||||
| 
 | 
 | ||||||
| const MissingIndicator = () => ( | const MissingIndicator = ({ fullPage }) => ( | ||||||
|   <div className='regeneration-indicator missing-indicator'> |   <div className={classNames('regeneration-indicator', { 'regeneration-indicator--without-header': fullPage })}> | ||||||
|     <div> |     <div className='regeneration-indicator__figure'> | ||||||
|       <div className='regeneration-indicator__figure' /> |       <img src={illustration} alt='' /> | ||||||
|  |     </div> | ||||||
| 
 | 
 | ||||||
|       <div className='regeneration-indicator__label'> |     <div className='regeneration-indicator__label'> | ||||||
|         <FormattedMessage id='missing_indicator.label' tagName='strong' defaultMessage='Not found' /> |       <FormattedMessage id='missing_indicator.label' tagName='strong' defaultMessage='Not found' /> | ||||||
|         <FormattedMessage id='missing_indicator.sublabel' defaultMessage='This resource could not be found' /> |       <FormattedMessage id='missing_indicator.sublabel' defaultMessage='This resource could not be found' /> | ||||||
|       </div> |  | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| ); | ); | ||||||
| 
 | 
 | ||||||
|  | MissingIndicator.propTypes = { | ||||||
|  |   fullPage: PropTypes.bool, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
| export default MissingIndicator; | export default MissingIndicator; | ||||||
|  |  | ||||||
|  | @ -0,0 +1,18 @@ | ||||||
|  | import React from 'react'; | ||||||
|  | import { FormattedMessage } from 'react-intl'; | ||||||
|  | import illustration from 'flavours/glitch/images/elephant_ui_working.svg'; | ||||||
|  | 
 | ||||||
|  | const MissingIndicator = () => ( | ||||||
|  |   <div className='regeneration-indicator'> | ||||||
|  |     <div className='regeneration-indicator__figure'> | ||||||
|  |       <img src={illustration} alt='' /> | ||||||
|  |     </div> | ||||||
|  | 
 | ||||||
|  |     <div className='regeneration-indicator__label'> | ||||||
|  |       <FormattedMessage id='regeneration_indicator.label' tagName='strong' defaultMessage='Loading…' /> | ||||||
|  |       <FormattedMessage id='regeneration_indicator.sublabel' defaultMessage='Your home feed is being prepared!' /> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | export default MissingIndicator; | ||||||
|  | @ -315,7 +315,7 @@ export default class StatusContent extends React.PureComponent { | ||||||
|           <p |           <p | ||||||
|             style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }} |             style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }} | ||||||
|           > |           > | ||||||
|             <span dangerouslySetInnerHTML={spoilerContent} lang={status.get('language')} /> |             <span dangerouslySetInnerHTML={spoilerContent} /> | ||||||
|             {' '} |             {' '} | ||||||
|             <button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}> |             <button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}> | ||||||
|               {toggleText} |               {toggleText} | ||||||
|  | @ -332,7 +332,6 @@ export default class StatusContent extends React.PureComponent { | ||||||
|               tabIndex={!hidden ? 0 : null} |               tabIndex={!hidden ? 0 : null} | ||||||
|               dangerouslySetInnerHTML={content} |               dangerouslySetInnerHTML={content} | ||||||
|               className='status__content__text' |               className='status__content__text' | ||||||
|               lang={status.get('language')} |  | ||||||
|             /> |             /> | ||||||
|             {media} |             {media} | ||||||
|           </div> |           </div> | ||||||
|  | @ -353,7 +352,6 @@ export default class StatusContent extends React.PureComponent { | ||||||
|             ref={this.setContentsRef} |             ref={this.setContentsRef} | ||||||
|             key={`contents-${tagLinks}-${rewriteMentions}`} |             key={`contents-${tagLinks}-${rewriteMentions}`} | ||||||
|             dangerouslySetInnerHTML={content} |             dangerouslySetInnerHTML={content} | ||||||
|             lang={status.get('language')} |  | ||||||
|             className='status__content__text' |             className='status__content__text' | ||||||
|             tabIndex='0' |             tabIndex='0' | ||||||
|           /> |           /> | ||||||
|  | @ -368,7 +366,7 @@ export default class StatusContent extends React.PureComponent { | ||||||
|           tabIndex='0' |           tabIndex='0' | ||||||
|           ref={this.setRef} |           ref={this.setRef} | ||||||
|         > |         > | ||||||
|           <div ref={this.setContentsRef} key={`contents-${tagLinks}`} className='status__content__text' dangerouslySetInnerHTML={content} lang={status.get('language')} tabIndex='0' /> |           <div ref={this.setContentsRef} key={`contents-${tagLinks}`} className='status__content__text' dangerouslySetInnerHTML={content} tabIndex='0' /> | ||||||
|           {media} |           {media} | ||||||
|         </div> |         </div> | ||||||
|       ); |       ); | ||||||
|  |  | ||||||
|  | @ -6,7 +6,7 @@ import StatusContainer from 'flavours/glitch/containers/status_container'; | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
| import LoadGap from './load_gap'; | import LoadGap from './load_gap'; | ||||||
| import ScrollableList from './scrollable_list'; | import ScrollableList from './scrollable_list'; | ||||||
| import { FormattedMessage } from 'react-intl'; | import RegenerationIndicator from 'flavours/glitch/components/regeneration_indicator'; | ||||||
| 
 | 
 | ||||||
| export default class StatusList extends ImmutablePureComponent { | export default class StatusList extends ImmutablePureComponent { | ||||||
| 
 | 
 | ||||||
|  | @ -81,18 +81,7 @@ export default class StatusList extends ImmutablePureComponent { | ||||||
|     const { isLoading, isPartial } = other; |     const { isLoading, isPartial } = other; | ||||||
| 
 | 
 | ||||||
|     if (isPartial) { |     if (isPartial) { | ||||||
|       return ( |       return <RegenerationIndicator />; | ||||||
|         <div className='regeneration-indicator'> |  | ||||||
|           <div> |  | ||||||
|             <div className='regeneration-indicator__figure' /> |  | ||||||
| 
 |  | ||||||
|             <div className='regeneration-indicator__label'> |  | ||||||
|               <FormattedMessage id='regeneration_indicator.label' tagName='strong' defaultMessage='Loading…' /> |  | ||||||
|               <FormattedMessage id='regeneration_indicator.sublabel' defaultMessage='Your home feed is being prepared!' /> |  | ||||||
|             </div> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|       ); |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     let scrollableContent = (isLoading || statusIds.size > 0) ? ( |     let scrollableContent = (isLoading || statusIds.size > 0) ? ( | ||||||
|  |  | ||||||
|  | @ -9,6 +9,7 @@ import LoadingIndicator from '../../components/loading_indicator'; | ||||||
| import Column from '../ui/components/column'; | import Column from '../ui/components/column'; | ||||||
| import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header'; | import ProfileColumnHeader from 'flavours/glitch/features/account/components/profile_column_header'; | ||||||
| import HeaderContainer from './containers/header_container'; | import HeaderContainer from './containers/header_container'; | ||||||
|  | import ColumnBackButton from 'flavours/glitch/components/column_back_button'; | ||||||
| import { List as ImmutableList } from 'immutable'; | import { List as ImmutableList } from 'immutable'; | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
| import { FormattedMessage } from 'react-intl'; | import { FormattedMessage } from 'react-intl'; | ||||||
|  | @ -82,6 +83,7 @@ class AccountTimeline extends ImmutablePureComponent { | ||||||
|     if (!isAccount) { |     if (!isAccount) { | ||||||
|       return ( |       return ( | ||||||
|         <Column> |         <Column> | ||||||
|  |           <ColumnBackButton multiColumn={multiColumn} /> | ||||||
|           <MissingIndicator /> |           <MissingIndicator /> | ||||||
|         </Column> |         </Column> | ||||||
|       ); |       ); | ||||||
|  |  | ||||||
|  | @ -4,7 +4,7 @@ import MissingIndicator from 'flavours/glitch/components/missing_indicator'; | ||||||
| 
 | 
 | ||||||
| const GenericNotFound = () => ( | const GenericNotFound = () => ( | ||||||
|   <Column> |   <Column> | ||||||
|     <MissingIndicator /> |     <MissingIndicator fullPage /> | ||||||
|   </Column> |   </Column> | ||||||
| ); | ); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -16,6 +16,7 @@ import UploadProgress from 'flavours/glitch/features/compose/components/upload_p | ||||||
| import CharacterCounter from 'flavours/glitch/features/compose/components/character_counter'; | import CharacterCounter from 'flavours/glitch/features/compose/components/character_counter'; | ||||||
| import { length } from 'stringz'; | import { length } from 'stringz'; | ||||||
| import { Tesseract as fetchTesseract } from 'flavours/glitch/util/async-components'; | import { Tesseract as fetchTesseract } from 'flavours/glitch/util/async-components'; | ||||||
|  | import GIFV from 'flavours/glitch/components/gifv'; | ||||||
| 
 | 
 | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|   close: { id: 'lightbox.close', defaultMessage: 'Close' }, |   close: { id: 'lightbox.close', defaultMessage: 'Close' }, | ||||||
|  | @ -41,6 +42,36 @@ const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******') | ||||||
| 
 | 
 | ||||||
| const assetHost = process.env.CDN_HOST || ''; | const assetHost = process.env.CDN_HOST || ''; | ||||||
| 
 | 
 | ||||||
|  | class ImageLoader extends React.PureComponent { | ||||||
|  | 
 | ||||||
|  |   static propTypes = { | ||||||
|  |     src: PropTypes.string.isRequired, | ||||||
|  |     width: PropTypes.number, | ||||||
|  |     height: PropTypes.number, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   state = { | ||||||
|  |     loading: true, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   componentDidMount() { | ||||||
|  |     const image = new Image(); | ||||||
|  |     image.addEventListener('load', () => this.setState({ loading: false })); | ||||||
|  |     image.src = this.props.src; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   render () { | ||||||
|  |     const { loading } = this.state; | ||||||
|  | 
 | ||||||
|  |     if (loading) { | ||||||
|  |       return <canvas width={this.props.width} height={this.props.height} />; | ||||||
|  |     } else { | ||||||
|  |       return <img {...this.props} alt='' />; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export default @connect(mapStateToProps, mapDispatchToProps) | export default @connect(mapStateToProps, mapDispatchToProps) | ||||||
| @injectIntl | @injectIntl | ||||||
| class FocalPointModal extends ImmutablePureComponent { | class FocalPointModal extends ImmutablePureComponent { | ||||||
|  | @ -60,6 +91,7 @@ class FocalPointModal extends ImmutablePureComponent { | ||||||
|     description: '', |     description: '', | ||||||
|     dirty: false, |     dirty: false, | ||||||
|     progress: 0, |     progress: 0, | ||||||
|  |     loading: true, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   componentWillMount () { |   componentWillMount () { | ||||||
|  | @ -242,8 +274,8 @@ class FocalPointModal extends ImmutablePureComponent { | ||||||
|           <div className='focal-point-modal__content'> |           <div className='focal-point-modal__content'> | ||||||
|             {focals && ( |             {focals && ( | ||||||
|               <div className={classNames('focal-point', { dragging })} ref={this.setRef} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart}> |               <div className={classNames('focal-point', { dragging })} ref={this.setRef} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart}> | ||||||
|                 {media.get('type') === 'image' && <img src={media.get('url')} width={width} height={height} alt='' />} |                 {media.get('type') === 'image' && <ImageLoader src={media.get('url')} width={width} height={height} alt='' />} | ||||||
|                 {media.get('type') === 'gifv' && <video src={media.get('url')} width={width} height={height} loop muted autoPlay />} |                 {media.get('type') === 'gifv' && <GIFV src={media.get('url')} width={width} height={height} />} | ||||||
| 
 | 
 | ||||||
|                 <div className='focal-point__preview'> |                 <div className='focal-point__preview'> | ||||||
|                   <strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong> |                   <strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong> | ||||||
|  |  | ||||||
|  | @ -3,13 +3,13 @@ import ReactSwipeableViews from 'react-swipeable-views'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import Video from 'flavours/glitch/features/video'; | import Video from 'flavours/glitch/features/video'; | ||||||
| import ExtendedVideoPlayer from 'flavours/glitch/components/extended_video_player'; |  | ||||||
| import classNames from 'classnames'; | import classNames from 'classnames'; | ||||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||||
| import IconButton from 'flavours/glitch/components/icon_button'; | import IconButton from 'flavours/glitch/components/icon_button'; | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
| import ImageLoader from './image_loader'; | import ImageLoader from './image_loader'; | ||||||
| import Icon from 'flavours/glitch/components/icon'; | import Icon from 'flavours/glitch/components/icon'; | ||||||
|  | import GIFV from 'flavours/glitch/components/gifv'; | ||||||
| 
 | 
 | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|   close: { id: 'lightbox.close', defaultMessage: 'Close' }, |   close: { id: 'lightbox.close', defaultMessage: 'Close' }, | ||||||
|  | @ -149,10 +149,8 @@ class MediaModal extends ImmutablePureComponent { | ||||||
|         ); |         ); | ||||||
|       } else if (image.get('type') === 'gifv') { |       } else if (image.get('type') === 'gifv') { | ||||||
|         return ( |         return ( | ||||||
|           <ExtendedVideoPlayer |           <GIFV | ||||||
|             src={image.get('url')} |             src={image.get('url')} | ||||||
|             muted |  | ||||||
|             controls={false} |  | ||||||
|             width={width} |             width={width} | ||||||
|             height={height} |             height={height} | ||||||
|             key={image.get('preview_url')} |             key={image.get('preview_url')} | ||||||
|  |  | ||||||
|  | @ -878,7 +878,8 @@ | ||||||
|   background: $base-shadow-color; |   background: $base-shadow-color; | ||||||
| 
 | 
 | ||||||
|   img, |   img, | ||||||
|   video { |   video, | ||||||
|  |   canvas { | ||||||
|     display: block; |     display: block; | ||||||
|     max-height: 80vh; |     max-height: 80vh; | ||||||
|     width: 100%; |     width: 100%; | ||||||
|  |  | ||||||
|  | @ -7,37 +7,27 @@ | ||||||
|   cursor: default; |   cursor: default; | ||||||
|   display: flex; |   display: flex; | ||||||
|   flex: 1 1 auto; |   flex: 1 1 auto; | ||||||
|  |   flex-direction: column; | ||||||
|   align-items: center; |   align-items: center; | ||||||
|   justify-content: center; |   justify-content: center; | ||||||
|   padding: 20px; |   padding: 20px; | ||||||
| 
 | 
 | ||||||
|   & > div { |  | ||||||
|     width: 100%; |  | ||||||
|     background: transparent; |  | ||||||
|     padding-top: 0; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   &__figure { |   &__figure { | ||||||
|     background: url('~flavours/glitch/images/elephant_ui_working.svg') no-repeat center 0; |     &, | ||||||
|     width: 100%; |     img { | ||||||
|     height: 160px; |       display: block; | ||||||
|     background-size: contain; |       width: auto; | ||||||
|     position: absolute; |       height: 160px; | ||||||
|     top: 50%; |       margin: 0; | ||||||
|     left: 50%; |  | ||||||
|     transform: translate(-50%, -50%); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   &.missing-indicator { |  | ||||||
|     padding-top: 20px + 48px; |  | ||||||
| 
 |  | ||||||
|     .regeneration-indicator__figure { |  | ||||||
|       background-image: url('~flavours/glitch/images/elephant_ui_disappointed.svg'); |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   &--without-header { | ||||||
|  |     padding-top: 20px + 48px; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   &__label { |   &__label { | ||||||
|     margin-top: 200px; |     margin-top: 30px; | ||||||
| 
 | 
 | ||||||
|     strong { |     strong { | ||||||
|       display: block; |       display: block; | ||||||
|  |  | ||||||
|  | @ -97,7 +97,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { | ||||||
|     api(getState).get(path, { params }).then(response => { |     api(getState).get(path, { params }).then(response => { | ||||||
|       const next = getLinks(response).refs.find(link => link.rel === 'next'); |       const next = getLinks(response).refs.find(link => link.rel === 'next'); | ||||||
|       dispatch(importFetchedStatuses(response.data)); |       dispatch(importFetchedStatuses(response.data)); | ||||||
|       dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems)); |       dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems)); | ||||||
|       done(); |       done(); | ||||||
|     }).catch(error => { |     }).catch(error => { | ||||||
|       dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); |       dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); | ||||||
|  |  | ||||||
|  | @ -1,63 +0,0 @@ | ||||||
| import React from 'react'; |  | ||||||
| import PropTypes from 'prop-types'; |  | ||||||
| 
 |  | ||||||
| export default class ExtendedVideoPlayer extends React.PureComponent { |  | ||||||
| 
 |  | ||||||
|   static propTypes = { |  | ||||||
|     src: PropTypes.string.isRequired, |  | ||||||
|     alt: PropTypes.string, |  | ||||||
|     width: PropTypes.number, |  | ||||||
|     height: PropTypes.number, |  | ||||||
|     time: PropTypes.number, |  | ||||||
|     controls: PropTypes.bool.isRequired, |  | ||||||
|     muted: PropTypes.bool.isRequired, |  | ||||||
|     onClick: PropTypes.func, |  | ||||||
|   }; |  | ||||||
| 
 |  | ||||||
|   handleLoadedData = () => { |  | ||||||
|     if (this.props.time) { |  | ||||||
|       this.video.currentTime = this.props.time; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   componentDidMount () { |  | ||||||
|     this.video.addEventListener('loadeddata', this.handleLoadedData); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   componentWillUnmount () { |  | ||||||
|     this.video.removeEventListener('loadeddata', this.handleLoadedData); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   setRef = (c) => { |  | ||||||
|     this.video = c; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   handleClick = e => { |  | ||||||
|     e.stopPropagation(); |  | ||||||
|     const handler = this.props.onClick; |  | ||||||
|     if (handler) handler(); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   render () { |  | ||||||
|     const { src, muted, controls, alt } = this.props; |  | ||||||
| 
 |  | ||||||
|     return ( |  | ||||||
|       <div className='extended-video-player'> |  | ||||||
|         <video |  | ||||||
|           ref={this.setRef} |  | ||||||
|           src={src} |  | ||||||
|           autoPlay |  | ||||||
|           role='button' |  | ||||||
|           tabIndex='0' |  | ||||||
|           aria-label={alt} |  | ||||||
|           title={alt} |  | ||||||
|           muted={muted} |  | ||||||
|           controls={controls} |  | ||||||
|           loop={!controls} |  | ||||||
|           onClick={this.handleClick} |  | ||||||
|         /> |  | ||||||
|       </div> |  | ||||||
|     ); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
| } |  | ||||||
							
								
								
									
										75
									
								
								app/javascript/mastodon/components/gifv.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								app/javascript/mastodon/components/gifv.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,75 @@ | ||||||
|  | import React from 'react'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
|  | 
 | ||||||
|  | export default class GIFV extends React.PureComponent { | ||||||
|  | 
 | ||||||
|  |   static propTypes = { | ||||||
|  |     src: PropTypes.string.isRequired, | ||||||
|  |     alt: PropTypes.string, | ||||||
|  |     width: PropTypes.number, | ||||||
|  |     height: PropTypes.number, | ||||||
|  |     onClick: PropTypes.func, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   state = { | ||||||
|  |     loading: true, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   handleLoadedData = () => { | ||||||
|  |     this.setState({ loading: false }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   componentWillReceiveProps (nextProps) { | ||||||
|  |     if (nextProps.src !== this.props.src) { | ||||||
|  |       this.setState({ loading: true }); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   handleClick = e => { | ||||||
|  |     const { onClick } = this.props; | ||||||
|  | 
 | ||||||
|  |     if (onClick) { | ||||||
|  |       e.stopPropagation(); | ||||||
|  |       onClick(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   render () { | ||||||
|  |     const { src, width, height, alt } = this.props; | ||||||
|  |     const { loading } = this.state; | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |       <div className='gifv' style={{ position: 'relative' }}> | ||||||
|  |         {loading && ( | ||||||
|  |           <canvas | ||||||
|  |             width={width} | ||||||
|  |             height={height} | ||||||
|  |             role='button' | ||||||
|  |             tabIndex='0' | ||||||
|  |             aria-label={alt} | ||||||
|  |             title={alt} | ||||||
|  |             onClick={this.handleClick} | ||||||
|  |           /> | ||||||
|  |         )} | ||||||
|  | 
 | ||||||
|  |         <video | ||||||
|  |           src={src} | ||||||
|  |           width={width} | ||||||
|  |           height={height} | ||||||
|  |           role='button' | ||||||
|  |           tabIndex='0' | ||||||
|  |           aria-label={alt} | ||||||
|  |           title={alt} | ||||||
|  |           muted | ||||||
|  |           loop | ||||||
|  |           autoPlay | ||||||
|  |           playsInline | ||||||
|  |           onClick={this.handleClick} | ||||||
|  |           onLoadedData={this.handleLoadedData} | ||||||
|  |           style={{ position: loading ? 'absolute' : 'static', top: 0, left: 0 }} | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | @ -1,17 +1,24 @@ | ||||||
| import React from 'react'; | import React from 'react'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
| import { FormattedMessage } from 'react-intl'; | import { FormattedMessage } from 'react-intl'; | ||||||
|  | import illustration from 'mastodon/../images/elephant_ui_disappointed.svg'; | ||||||
|  | import classNames from 'classnames'; | ||||||
| 
 | 
 | ||||||
| const MissingIndicator = () => ( | const MissingIndicator = ({ fullPage }) => ( | ||||||
|   <div className='regeneration-indicator missing-indicator'> |   <div className={classNames('regeneration-indicator', { 'regeneration-indicator--without-header': fullPage })}> | ||||||
|     <div> |     <div className='regeneration-indicator__figure'> | ||||||
|       <div className='regeneration-indicator__figure' /> |       <img src={illustration} alt='' /> | ||||||
|  |     </div> | ||||||
| 
 | 
 | ||||||
|       <div className='regeneration-indicator__label'> |     <div className='regeneration-indicator__label'> | ||||||
|         <FormattedMessage id='missing_indicator.label' tagName='strong' defaultMessage='Not found' /> |       <FormattedMessage id='missing_indicator.label' tagName='strong' defaultMessage='Not found' /> | ||||||
|         <FormattedMessage id='missing_indicator.sublabel' defaultMessage='This resource could not be found' /> |       <FormattedMessage id='missing_indicator.sublabel' defaultMessage='This resource could not be found' /> | ||||||
|       </div> |  | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
| ); | ); | ||||||
| 
 | 
 | ||||||
|  | MissingIndicator.propTypes = { | ||||||
|  |   fullPage: PropTypes.bool, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
| export default MissingIndicator; | export default MissingIndicator; | ||||||
|  |  | ||||||
							
								
								
									
										18
									
								
								app/javascript/mastodon/components/regeneration_indicator.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								app/javascript/mastodon/components/regeneration_indicator.js
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,18 @@ | ||||||
|  | import React from 'react'; | ||||||
|  | import { FormattedMessage } from 'react-intl'; | ||||||
|  | import illustration from 'mastodon/../images/elephant_ui_working.svg'; | ||||||
|  | 
 | ||||||
|  | const MissingIndicator = () => ( | ||||||
|  |   <div className='regeneration-indicator'> | ||||||
|  |     <div className='regeneration-indicator__figure'> | ||||||
|  |       <img src={illustration} alt='' /> | ||||||
|  |     </div> | ||||||
|  | 
 | ||||||
|  |     <div className='regeneration-indicator__label'> | ||||||
|  |       <FormattedMessage id='regeneration_indicator.label' tagName='strong' defaultMessage='Loading…' /> | ||||||
|  |       <FormattedMessage id='regeneration_indicator.sublabel' defaultMessage='Your home feed is being prepared!' /> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | ); | ||||||
|  | 
 | ||||||
|  | export default MissingIndicator; | ||||||
|  | @ -216,14 +216,14 @@ export default class StatusContent extends React.PureComponent { | ||||||
|       return ( |       return ( | ||||||
|         <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> |         <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}> | ||||||
|           <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}> |           <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}> | ||||||
|             <span dangerouslySetInnerHTML={spoilerContent} lang={status.get('language')} /> |             <span dangerouslySetInnerHTML={spoilerContent} /> | ||||||
|             {' '} |             {' '} | ||||||
|             <button tabIndex='0' className={`status__content__spoiler-link ${hidden ? 'status__content__spoiler-link--show-more' : 'status__content__spoiler-link--show-less'}`} onClick={this.handleSpoilerClick}>{toggleText}</button> |             <button tabIndex='0' className={`status__content__spoiler-link ${hidden ? 'status__content__spoiler-link--show-more' : 'status__content__spoiler-link--show-less'}`} onClick={this.handleSpoilerClick}>{toggleText}</button> | ||||||
|           </p> |           </p> | ||||||
| 
 | 
 | ||||||
|           {mentionsPlaceholder} |           {mentionsPlaceholder} | ||||||
| 
 | 
 | ||||||
|           <div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} /> |           <div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} /> | ||||||
| 
 | 
 | ||||||
|           {!hidden && !!status.get('poll') && <PollContainer pollId={status.get('poll')} />} |           {!hidden && !!status.get('poll') && <PollContainer pollId={status.get('poll')} />} | ||||||
|         </div> |         </div> | ||||||
|  | @ -231,7 +231,7 @@ export default class StatusContent extends React.PureComponent { | ||||||
|     } else if (this.props.onClick) { |     } else if (this.props.onClick) { | ||||||
|       const output = [ |       const output = [ | ||||||
|         <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content'> |         <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content'> | ||||||
|           <div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} /> |           <div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} /> | ||||||
| 
 | 
 | ||||||
|           {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />} |           {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />} | ||||||
|         </div>, |         </div>, | ||||||
|  | @ -245,7 +245,7 @@ export default class StatusContent extends React.PureComponent { | ||||||
|     } else { |     } else { | ||||||
|       return ( |       return ( | ||||||
|         <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle}> |         <div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle}> | ||||||
|           <div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} lang={status.get('language')} /> |           <div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} /> | ||||||
| 
 | 
 | ||||||
|           {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />} |           {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />} | ||||||
|         </div> |         </div> | ||||||
|  |  | ||||||
|  | @ -1,12 +1,12 @@ | ||||||
| import { debounce } from 'lodash'; | import { debounce } from 'lodash'; | ||||||
| import React from 'react'; | import React from 'react'; | ||||||
| import { FormattedMessage } from 'react-intl'; |  | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import StatusContainer from '../containers/status_container'; | import StatusContainer from '../containers/status_container'; | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
| import LoadGap from './load_gap'; | import LoadGap from './load_gap'; | ||||||
| import ScrollableList from './scrollable_list'; | import ScrollableList from './scrollable_list'; | ||||||
|  | import RegenerationIndicator from 'mastodon/components/regeneration_indicator'; | ||||||
| 
 | 
 | ||||||
| export default class StatusList extends ImmutablePureComponent { | export default class StatusList extends ImmutablePureComponent { | ||||||
| 
 | 
 | ||||||
|  | @ -81,18 +81,7 @@ export default class StatusList extends ImmutablePureComponent { | ||||||
|     const { isLoading, isPartial } = other; |     const { isLoading, isPartial } = other; | ||||||
| 
 | 
 | ||||||
|     if (isPartial) { |     if (isPartial) { | ||||||
|       return ( |       return <RegenerationIndicator />; | ||||||
|         <div className='regeneration-indicator'> |  | ||||||
|           <div> |  | ||||||
|             <div className='regeneration-indicator__figure' /> |  | ||||||
| 
 |  | ||||||
|             <div className='regeneration-indicator__label'> |  | ||||||
|               <FormattedMessage id='regeneration_indicator.label' tagName='strong' defaultMessage='Loading…' /> |  | ||||||
|               <FormattedMessage id='regeneration_indicator.sublabel' defaultMessage='Your home feed is being prepared!' /> |  | ||||||
|             </div> |  | ||||||
|           </div> |  | ||||||
|         </div> |  | ||||||
|       ); |  | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     let scrollableContent = (isLoading || statusIds.size > 0) ? ( |     let scrollableContent = (isLoading || statusIds.size > 0) ? ( | ||||||
|  |  | ||||||
|  | @ -83,6 +83,7 @@ class AccountTimeline extends ImmutablePureComponent { | ||||||
|     if (!isAccount) { |     if (!isAccount) { | ||||||
|       return ( |       return ( | ||||||
|         <Column> |         <Column> | ||||||
|  |           <ColumnBackButton multiColumn={multiColumn} /> | ||||||
|           <MissingIndicator /> |           <MissingIndicator /> | ||||||
|         </Column> |         </Column> | ||||||
|       ); |       ); | ||||||
|  |  | ||||||
|  | @ -4,7 +4,7 @@ import MissingIndicator from '../../components/missing_indicator'; | ||||||
| 
 | 
 | ||||||
| const GenericNotFound = () => ( | const GenericNotFound = () => ( | ||||||
|   <Column> |   <Column> | ||||||
|     <MissingIndicator /> |     <MissingIndicator fullPage /> | ||||||
|   </Column> |   </Column> | ||||||
| ); | ); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -16,6 +16,7 @@ import UploadProgress from 'mastodon/features/compose/components/upload_progress | ||||||
| import CharacterCounter from 'mastodon/features/compose/components/character_counter'; | import CharacterCounter from 'mastodon/features/compose/components/character_counter'; | ||||||
| import { length } from 'stringz'; | import { length } from 'stringz'; | ||||||
| import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components'; | import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components'; | ||||||
|  | import GIFV from 'mastodon/components/gifv'; | ||||||
| 
 | 
 | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|   close: { id: 'lightbox.close', defaultMessage: 'Close' }, |   close: { id: 'lightbox.close', defaultMessage: 'Close' }, | ||||||
|  | @ -41,6 +42,36 @@ const removeExtraLineBreaks = str => str.replace(/\n\n/g, '******') | ||||||
| 
 | 
 | ||||||
| const assetHost = process.env.CDN_HOST || ''; | const assetHost = process.env.CDN_HOST || ''; | ||||||
| 
 | 
 | ||||||
|  | class ImageLoader extends React.PureComponent { | ||||||
|  | 
 | ||||||
|  |   static propTypes = { | ||||||
|  |     src: PropTypes.string.isRequired, | ||||||
|  |     width: PropTypes.number, | ||||||
|  |     height: PropTypes.number, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   state = { | ||||||
|  |     loading: true, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   componentDidMount() { | ||||||
|  |     const image = new Image(); | ||||||
|  |     image.addEventListener('load', () => this.setState({ loading: false })); | ||||||
|  |     image.src = this.props.src; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   render () { | ||||||
|  |     const { loading } = this.state; | ||||||
|  | 
 | ||||||
|  |     if (loading) { | ||||||
|  |       return <canvas width={this.props.width} height={this.props.height} />; | ||||||
|  |     } else { | ||||||
|  |       return <img {...this.props} alt='' />; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | 
 | ||||||
| export default @connect(mapStateToProps, mapDispatchToProps) | export default @connect(mapStateToProps, mapDispatchToProps) | ||||||
| @injectIntl | @injectIntl | ||||||
| class FocalPointModal extends ImmutablePureComponent { | class FocalPointModal extends ImmutablePureComponent { | ||||||
|  | @ -60,6 +91,7 @@ class FocalPointModal extends ImmutablePureComponent { | ||||||
|     description: '', |     description: '', | ||||||
|     dirty: false, |     dirty: false, | ||||||
|     progress: 0, |     progress: 0, | ||||||
|  |     loading: true, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   componentWillMount () { |   componentWillMount () { | ||||||
|  | @ -242,8 +274,8 @@ class FocalPointModal extends ImmutablePureComponent { | ||||||
|           <div className='focal-point-modal__content'> |           <div className='focal-point-modal__content'> | ||||||
|             {focals && ( |             {focals && ( | ||||||
|               <div className={classNames('focal-point', { dragging })} ref={this.setRef} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart}> |               <div className={classNames('focal-point', { dragging })} ref={this.setRef} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart}> | ||||||
|                 {media.get('type') === 'image' && <img src={media.get('url')} width={width} height={height} alt='' />} |                 {media.get('type') === 'image' && <ImageLoader src={media.get('url')} width={width} height={height} alt='' />} | ||||||
|                 {media.get('type') === 'gifv' && <video src={media.get('url')} width={width} height={height} loop muted autoPlay />} |                 {media.get('type') === 'gifv' && <GIFV src={media.get('url')} width={width} height={height} />} | ||||||
| 
 | 
 | ||||||
|                 <div className='focal-point__preview'> |                 <div className='focal-point__preview'> | ||||||
|                   <strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong> |                   <strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong> | ||||||
|  |  | ||||||
|  | @ -3,13 +3,13 @@ import ReactSwipeableViews from 'react-swipeable-views'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import Video from 'mastodon/features/video'; | import Video from 'mastodon/features/video'; | ||||||
| import ExtendedVideoPlayer from 'mastodon/components/extended_video_player'; |  | ||||||
| import classNames from 'classnames'; | import classNames from 'classnames'; | ||||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||||
| import IconButton from 'mastodon/components/icon_button'; | import IconButton from 'mastodon/components/icon_button'; | ||||||
| import ImmutablePureComponent from 'react-immutable-pure-component'; | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
| import ImageLoader from './image_loader'; | import ImageLoader from './image_loader'; | ||||||
| import Icon from 'mastodon/components/icon'; | import Icon from 'mastodon/components/icon'; | ||||||
|  | import GIFV from 'mastodon/components/gifv'; | ||||||
| 
 | 
 | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|   close: { id: 'lightbox.close', defaultMessage: 'Close' }, |   close: { id: 'lightbox.close', defaultMessage: 'Close' }, | ||||||
|  | @ -169,10 +169,8 @@ class MediaModal extends ImmutablePureComponent { | ||||||
|         ); |         ); | ||||||
|       } else if (image.get('type') === 'gifv') { |       } else if (image.get('type') === 'gifv') { | ||||||
|         return ( |         return ( | ||||||
|           <ExtendedVideoPlayer |           <GIFV | ||||||
|             src={image.get('url')} |             src={image.get('url')} | ||||||
|             muted |  | ||||||
|             controls={false} |  | ||||||
|             width={width} |             width={width} | ||||||
|             height={height} |             height={height} | ||||||
|             key={image.get('preview_url')} |             key={image.get('preview_url')} | ||||||
|  |  | ||||||
|  | @ -3127,37 +3127,27 @@ a.status-card.compact:hover { | ||||||
|   cursor: default; |   cursor: default; | ||||||
|   display: flex; |   display: flex; | ||||||
|   flex: 1 1 auto; |   flex: 1 1 auto; | ||||||
|  |   flex-direction: column; | ||||||
|   align-items: center; |   align-items: center; | ||||||
|   justify-content: center; |   justify-content: center; | ||||||
|   padding: 20px; |   padding: 20px; | ||||||
| 
 | 
 | ||||||
|   & > div { |  | ||||||
|     width: 100%; |  | ||||||
|     background: transparent; |  | ||||||
|     padding-top: 0; |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   &__figure { |   &__figure { | ||||||
|     background: url('~images/elephant_ui_working.svg') no-repeat center 0; |     &, | ||||||
|     width: 100%; |     img { | ||||||
|     height: 160px; |       display: block; | ||||||
|     background-size: contain; |       width: auto; | ||||||
|     position: absolute; |       height: 160px; | ||||||
|     top: 50%; |       margin: 0; | ||||||
|     left: 50%; |  | ||||||
|     transform: translate(-50%, -50%); |  | ||||||
|   } |  | ||||||
| 
 |  | ||||||
|   &.missing-indicator { |  | ||||||
|     padding-top: 20px + 48px; |  | ||||||
| 
 |  | ||||||
|     .regeneration-indicator__figure { |  | ||||||
|       background-image: url('~images/elephant_ui_disappointed.svg'); |  | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   &--without-header { | ||||||
|  |     padding-top: 20px + 48px; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   &__label { |   &__label { | ||||||
|     margin-top: 200px; |     margin-top: 30px; | ||||||
| 
 | 
 | ||||||
|     strong { |     strong { | ||||||
|       display: block; |       display: block; | ||||||
|  | @ -6102,7 +6092,8 @@ noscript { | ||||||
|   background: $base-shadow-color; |   background: $base-shadow-color; | ||||||
| 
 | 
 | ||||||
|   img, |   img, | ||||||
|   video { |   video, | ||||||
|  |   canvas { | ||||||
|     display: block; |     display: block; | ||||||
|     max-height: 80vh; |     max-height: 80vh; | ||||||
|     width: 100%; |     width: 100%; | ||||||
|  |  | ||||||
|  | @ -3,9 +3,10 @@ | ||||||
|   flex-direction: column; |   flex-direction: column; | ||||||
|   justify-content: center; |   justify-content: center; | ||||||
|   align-items: center; |   align-items: center; | ||||||
|  |   height: 100vh; | ||||||
|  |   background: $ui-base-color; | ||||||
| 
 | 
 | ||||||
|   @media screen and (max-width: 920px) { |   @media screen and (max-width: 920px) { | ||||||
|     background: darken($ui-base-color, 8%); |  | ||||||
|     display: block !important; |     display: block !important; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -19,7 +19,7 @@ class FeedManager | ||||||
| 
 | 
 | ||||||
|   def filter?(timeline_type, status, receiver_id) |   def filter?(timeline_type, status, receiver_id) | ||||||
|     if timeline_type == :home |     if timeline_type == :home | ||||||
|       filter_from_home?(status, receiver_id) |       filter_from_home?(status, receiver_id, build_crutches(receiver_id, [status])) | ||||||
|     elsif timeline_type == :mentions |     elsif timeline_type == :mentions | ||||||
|       filter_from_mentions?(status, receiver_id) |       filter_from_mentions?(status, receiver_id) | ||||||
|     elsif timeline_type == :direct |     elsif timeline_type == :direct | ||||||
|  | @ -31,6 +31,7 @@ class FeedManager | ||||||
| 
 | 
 | ||||||
|   def push_to_home(account, status) |   def push_to_home(account, status) | ||||||
|     return false unless add_to_feed(:home, account.id, status, account.user&.aggregates_reblogs?) |     return false unless add_to_feed(:home, account.id, status, account.user&.aggregates_reblogs?) | ||||||
|  | 
 | ||||||
|     trim(:home, account.id) |     trim(:home, account.id) | ||||||
|     PushUpdateWorker.perform_async(account.id, status.id, "timeline:#{account.id}") if push_update_required?("timeline:#{account.id}") |     PushUpdateWorker.perform_async(account.id, status.id, "timeline:#{account.id}") if push_update_required?("timeline:#{account.id}") | ||||||
|     true |     true | ||||||
|  | @ -38,6 +39,7 @@ class FeedManager | ||||||
| 
 | 
 | ||||||
|   def unpush_from_home(account, status) |   def unpush_from_home(account, status) | ||||||
|     return false unless remove_from_feed(:home, account.id, status, account.user&.aggregates_reblogs?) |     return false unless remove_from_feed(:home, account.id, status, account.user&.aggregates_reblogs?) | ||||||
|  | 
 | ||||||
|     redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) |     redis.publish("timeline:#{account.id}", Oj.dump(event: :delete, payload: status.id.to_s)) | ||||||
|     true |     true | ||||||
|   end |   end | ||||||
|  | @ -49,7 +51,9 @@ class FeedManager | ||||||
|       should_filter &&= !(list.show_list_replies? && ListAccount.where(list_id: list.id, account_id: status.in_reply_to_account_id).exists?) |       should_filter &&= !(list.show_list_replies? && ListAccount.where(list_id: list.id, account_id: status.in_reply_to_account_id).exists?) | ||||||
|       return false if should_filter |       return false if should_filter | ||||||
|     end |     end | ||||||
|  | 
 | ||||||
|     return false unless add_to_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) |     return false unless add_to_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) | ||||||
|  | 
 | ||||||
|     trim(:list, list.id) |     trim(:list, list.id) | ||||||
|     PushUpdateWorker.perform_async(list.account_id, status.id, "timeline:list:#{list.id}") if push_update_required?("timeline:list:#{list.id}") |     PushUpdateWorker.perform_async(list.account_id, status.id, "timeline:list:#{list.id}") if push_update_required?("timeline:list:#{list.id}") | ||||||
|     true |     true | ||||||
|  | @ -57,6 +61,7 @@ class FeedManager | ||||||
| 
 | 
 | ||||||
|   def unpush_from_list(list, status) |   def unpush_from_list(list, status) | ||||||
|     return false unless remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) |     return false unless remove_from_feed(:list, list.id, status, list.account.user&.aggregates_reblogs?) | ||||||
|  | 
 | ||||||
|     redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s)) |     redis.publish("timeline:list:#{list.id}", Oj.dump(event: :delete, payload: status.id.to_s)) | ||||||
|     true |     true | ||||||
|   end |   end | ||||||
|  | @ -100,16 +105,21 @@ class FeedManager | ||||||
| 
 | 
 | ||||||
|   def merge_into_timeline(from_account, into_account) |   def merge_into_timeline(from_account, into_account) | ||||||
|     timeline_key = key(:home, into_account.id) |     timeline_key = key(:home, into_account.id) | ||||||
|     query        = from_account.statuses.limit(FeedManager::MAX_ITEMS / 4) |     aggregate    = into_account.user&.aggregates_reblogs? | ||||||
|  |     query        = from_account.statuses.where(visibility: [:public, :unlisted, :private]).includes(:preloadable_poll, reblog: :account).limit(FeedManager::MAX_ITEMS / 4) | ||||||
| 
 | 
 | ||||||
|     if redis.zcard(timeline_key) >= FeedManager::MAX_ITEMS / 4 |     if redis.zcard(timeline_key) >= FeedManager::MAX_ITEMS / 4 | ||||||
|       oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true)&.first&.last&.to_i || 0 |       oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true).first.last.to_i | ||||||
|       query = query.where('id > ?', oldest_home_score) |       query = query.where('id > ?', oldest_home_score) | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     query.each do |status| |     statuses = query.to_a | ||||||
|       next if status.direct_visibility? || status.limited_visibility? || filter?(:home, status, into_account) |     crutches = build_crutches(into_account.id, statuses) | ||||||
|       add_to_feed(:home, into_account.id, status, into_account.user&.aggregates_reblogs?) | 
 | ||||||
|  |     statuses.each do |status| | ||||||
|  |       next if filter_from_home?(status, into_account, crutches) | ||||||
|  | 
 | ||||||
|  |       add_to_feed(:home, into_account.id, status, aggregate) | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     trim(:home, into_account.id) |     trim(:home, into_account.id) | ||||||
|  | @ -135,24 +145,35 @@ class FeedManager | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def populate_feed(account) |   def populate_feed(account) | ||||||
|     added  = 0 |     limit        = FeedManager::MAX_ITEMS / 2 | ||||||
|     limit  = FeedManager::MAX_ITEMS / 2 |     aggregate    = account.user&.aggregates_reblogs? | ||||||
|     max_id = nil |     timeline_key = key(:home, account.id) | ||||||
| 
 | 
 | ||||||
|     loop do |     account.statuses.where.not(visibility: :direct).limit(limit).each do |status| | ||||||
|       statuses = Status.as_home_timeline(account) |       add_to_feed(:home, account.id, status, aggregate) | ||||||
|                        .paginate_by_max_id(limit, max_id) |     end | ||||||
| 
 | 
 | ||||||
|       break if statuses.empty? |     account.following.includes(:account_stat).find_each do |target_account| | ||||||
|  |       if redis.zcard(timeline_key) >= limit | ||||||
|  |         oldest_home_score = redis.zrange(timeline_key, 0, 0, with_scores: true).first.last.to_i | ||||||
|  |         last_status_score = Mastodon::Snowflake.id_at(account.last_status_at) | ||||||
| 
 | 
 | ||||||
|       statuses.each do |status| |         # If the feed is full and this account has not posted more recently | ||||||
|         next if filter_from_home?(status, account) |         # than the last item on the feed, then we can skip the whole account | ||||||
|         added += 1 if add_to_feed(:home, account.id, status, account.user&.aggregates_reblogs?) |         # because none of its statuses would stay on the feed anyway | ||||||
|  |         next if last_status_score < oldest_home_score | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|       break unless added.zero? |       statuses = target_account.statuses.where(visibility: [:public, :unlisted, :private]).includes(:preloadable_poll, reblog: :account).limit(limit) | ||||||
|  |       crutches = build_crutches(account.id, statuses) | ||||||
| 
 | 
 | ||||||
|       max_id = statuses.last.id |       statuses.each do |status| | ||||||
|  |         next if filter_from_home?(status, account, crutches) | ||||||
|  | 
 | ||||||
|  |         add_to_feed(:home, account.id, status, aggregate) | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       trim(:home, account.id) | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  | @ -188,31 +209,33 @@ class FeedManager | ||||||
|       (context == :home ? Mute.where(account_id: receiver_id, target_account_id: account_ids).any? : Mute.where(account_id: receiver_id, target_account_id: account_ids, hide_notifications: true).any?) |       (context == :home ? Mute.where(account_id: receiver_id, target_account_id: account_ids).any? : Mute.where(account_id: receiver_id, target_account_id: account_ids, hide_notifications: true).any?) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def filter_from_home?(status, receiver_id) |   def filter_from_home?(status, receiver_id, crutches) | ||||||
|     return false if receiver_id == status.account_id |     return false if receiver_id == status.account_id | ||||||
|     return true  if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?) |     return true  if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?) | ||||||
|     return true  if phrase_filtered?(status, receiver_id, :home) |     return true  if phrase_filtered?(status, receiver_id, :home) | ||||||
| 
 | 
 | ||||||
|     check_for_blocks = status.active_mentions.pluck(:account_id) |     check_for_blocks = crutches[:active_mentions][status.id] || [] | ||||||
|     check_for_blocks.concat([status.account_id]) |     check_for_blocks.concat([status.account_id]) | ||||||
| 
 | 
 | ||||||
|     if status.reblog? |     if status.reblog? | ||||||
|       check_for_blocks.concat([status.reblog.account_id]) |       check_for_blocks.concat([status.reblog.account_id]) | ||||||
|       check_for_blocks.concat(status.reblog.active_mentions.pluck(:account_id)) |       check_for_blocks.concat(crutches[:active_mentions][status.reblog_of_id] || []) | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     return true if blocks_or_mutes?(receiver_id, check_for_blocks, :home) |     return true if check_for_blocks.any? { |target_account_id| crutches[:blocking][target_account_id] || crutches[:muting][target_account_id] } | ||||||
| 
 | 
 | ||||||
|     if status.reply? && !status.in_reply_to_account_id.nil?                                                                      # Filter out if it's a reply |     if status.reply? && !status.in_reply_to_account_id.nil?                                                                      # Filter out if it's a reply | ||||||
|       should_filter   = !Follow.where(account_id: receiver_id, target_account_id: status.in_reply_to_account_id).exists?         # and I'm not following the person it's a reply to |       should_filter   = !crutches[:following][status.in_reply_to_account_id]                                                     # and I'm not following the person it's a reply to | ||||||
|       should_filter &&= receiver_id != status.in_reply_to_account_id                                                             # and it's not a reply to me |       should_filter &&= receiver_id != status.in_reply_to_account_id                                                             # and it's not a reply to me | ||||||
|       should_filter &&= status.account_id != status.in_reply_to_account_id                                                       # and it's not a self-reply |       should_filter &&= status.account_id != status.in_reply_to_account_id                                                       # and it's not a self-reply | ||||||
|       return should_filter | 
 | ||||||
|  |       return !!should_filter | ||||||
|     elsif status.reblog?                                                                                                         # Filter out a reblog |     elsif status.reblog?                                                                                                         # Filter out a reblog | ||||||
|       should_filter   = Follow.where(account_id: receiver_id, target_account_id: status.account_id, show_reblogs: false).exists? # if the reblogger's reblogs are suppressed |       should_filter   = crutches[:hiding_reblogs][status.account_id]                                                             # if the reblogger's reblogs are suppressed | ||||||
|       should_filter ||= Block.where(account_id: status.reblog.account_id, target_account_id: receiver_id).exists?                # or if the author of the reblogged status is blocking me |       should_filter ||= crutches[:blocked_by][status.reblog.account_id]                                                          # or if the author of the reblogged status is blocking me | ||||||
|       should_filter ||= AccountDomainBlock.where(account_id: receiver_id, domain: status.reblog.account.domain).exists?          # or the author's domain is blocked |       should_filter ||= crutches[:domain_blocking][status.reblog.account.domain]                                                 # or the author's domain is blocked | ||||||
|       return should_filter | 
 | ||||||
|  |       return !!should_filter | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     false |     false | ||||||
|  | @ -349,4 +372,31 @@ class FeedManager | ||||||
| 
 | 
 | ||||||
|     redis.zrem(timeline_key, status.id) |     redis.zrem(timeline_key, status.id) | ||||||
|   end |   end | ||||||
|  | 
 | ||||||
|  |   def build_crutches(receiver_id, statuses) | ||||||
|  |     crutches = {} | ||||||
|  | 
 | ||||||
|  |     crutches[:active_mentions] = Mention.active.where(status_id: statuses.flat_map { |s| [s.id, s.reblog_of_id] }.compact).pluck(:status_id, :account_id).each_with_object({}) { |(id, account_id), mapping| (mapping[id] ||= []).push(account_id) } | ||||||
|  | 
 | ||||||
|  |     check_for_blocks = statuses.flat_map do |s| | ||||||
|  |       arr = crutches[:active_mentions][s.id] || [] | ||||||
|  |       arr.concat([s.account_id]) | ||||||
|  | 
 | ||||||
|  |       if s.reblog? | ||||||
|  |         arr.concat([s.reblog.account_id]) | ||||||
|  |         arr.concat(crutches[:active_mentions][s.reblog_of_id] || []) | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       arr | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     crutches[:following]       = Follow.where(account_id: receiver_id, target_account_id: statuses.map(&:in_reply_to_account_id).compact).pluck(:target_account_id).each_with_object({}) { |id, mapping| mapping[id] = true } | ||||||
|  |     crutches[:hiding_reblogs]  = Follow.where(account_id: receiver_id, target_account_id: statuses.map { |s| s.account_id if s.reblog? }.compact, show_reblogs: false).pluck(:target_account_id).each_with_object({}) { |id, mapping| mapping[id] = true } | ||||||
|  |     crutches[:blocking]        = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).pluck(:target_account_id).each_with_object({}) { |id, mapping| mapping[id] = true } | ||||||
|  |     crutches[:muting]          = Mute.where(account_id: receiver_id, target_account_id: check_for_blocks).pluck(:target_account_id).each_with_object({}) { |id, mapping| mapping[id] = true } | ||||||
|  |     crutches[:domain_blocking] = AccountDomainBlock.where(account_id: receiver_id, domain: statuses.map { |s| s.reblog&.account&.domain }.compact).pluck(:domain).each_with_object({}) { |domain, mapping| mapping[domain] = true } | ||||||
|  |     crutches[:blocked_by]      = Block.where(target_account_id: receiver_id, account_id: statuses.map { |s| s.reblog&.account_id }.compact).pluck(:account_id).each_with_object({}) { |id, mapping| mapping[id] = true } | ||||||
|  | 
 | ||||||
|  |     crutches | ||||||
|  |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -44,7 +44,6 @@ class SpamCheck | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def flag! |   def flag! | ||||||
|     auto_silence_account! |  | ||||||
|     auto_report_status! |     auto_report_status! | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  | @ -134,17 +133,13 @@ class SpamCheck | ||||||
|     text.gsub(/\s+/, ' ').strip |     text.gsub(/\s+/, ' ').strip | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def auto_silence_account! |  | ||||||
|     @account.silence! |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def auto_report_status! |   def auto_report_status! | ||||||
|     status_ids = Status.where(visibility: %i(public unlisted)).where(id: matching_status_ids).pluck(:id) + [@status.id] if @status.distributable? |     status_ids = Status.where(visibility: %i(public unlisted)).where(id: matching_status_ids).pluck(:id) + [@status.id] if @status.distributable? | ||||||
|     ReportService.new.call(Account.representative, @account, status_ids: status_ids, comment: I18n.t('spam_check.spam_detected_and_silenced')) |     ReportService.new.call(Account.representative, @account, status_ids: status_ids, comment: I18n.t('spam_check.spam_detected_and_silenced')) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def already_flagged? |   def already_flagged? | ||||||
|     @account.silenced? |     @account.silenced? || @account.targeted_reports.unresolved.where(account_id: -99).exists? | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def trusted? |   def trusted? | ||||||
|  |  | ||||||
|  | @ -202,7 +202,7 @@ class Account < ApplicationRecord | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def unsilence! |   def unsilence! | ||||||
|     update!(silenced_at: nil, trust_level: trust_level == TRUST_LEVELS[:untrusted] ? TRUST_LEVELS[:trusted] : trust_level) |     update!(silenced_at: nil) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def suspended? |   def suspended? | ||||||
|  | @ -312,10 +312,9 @@ class Account < ApplicationRecord | ||||||
|   def save_with_optional_media! |   def save_with_optional_media! | ||||||
|     save! |     save! | ||||||
|   rescue ActiveRecord::RecordInvalid |   rescue ActiveRecord::RecordInvalid | ||||||
|     self.avatar              = nil |     self.avatar = nil | ||||||
|     self.header              = nil |     self.header = nil | ||||||
|     self[:avatar_remote_url] = '' | 
 | ||||||
|     self[:header_remote_url] = '' |  | ||||||
|     save! |     save! | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -62,6 +62,8 @@ class Admin::AccountAction | ||||||
| 
 | 
 | ||||||
|   def process_action! |   def process_action! | ||||||
|     case type |     case type | ||||||
|  |     when 'none' | ||||||
|  |       handle_resolve! | ||||||
|     when 'disable' |     when 'disable' | ||||||
|       handle_disable! |       handle_disable! | ||||||
|     when 'silence' |     when 'silence' | ||||||
|  | @ -103,6 +105,16 @@ class Admin::AccountAction | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   def handle_resolve! | ||||||
|  |     if with_report? && report.account_id == -99 && target_account.trust_level == Account::TRUST_LEVELS[:untrusted] | ||||||
|  |       # This is an automated report and it is being dismissed, so it's | ||||||
|  |       # a false positive, in which case update the account's trust level | ||||||
|  |       # to prevent further spam checks | ||||||
|  | 
 | ||||||
|  |       target_account.update(trust_level: Account::TRUST_LEVELS[:trusted]) | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   def handle_disable! |   def handle_disable! | ||||||
|     authorize(target_account.user, :disable?) |     authorize(target_account.user, :disable?) | ||||||
|     log_action(:disable, target_account.user) |     log_action(:disable, target_account.user) | ||||||
|  |  | ||||||
|  | @ -18,7 +18,7 @@ module Remotable | ||||||
|           return |           return | ||||||
|         end |         end | ||||||
| 
 | 
 | ||||||
|         return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.blank? || self[attribute_name] == url |         return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.blank? || (self[attribute_name] == url && send("#{attachment_name}_file_name").present?) | ||||||
| 
 | 
 | ||||||
|         begin |         begin | ||||||
|           Request.new(:get, url).perform do |response| |           Request.new(:get, url).perform do |response| | ||||||
|  |  | ||||||
|  | @ -36,6 +36,7 @@ class Form::AdminSettings | ||||||
|     show_replies_in_public_timelines |     show_replies_in_public_timelines | ||||||
|     spam_check_enabled |     spam_check_enabled | ||||||
|     trends |     trends | ||||||
|  |     trendable_by_default | ||||||
|     show_domain_blocks |     show_domain_blocks | ||||||
|     show_domain_blocks_rationale |     show_domain_blocks_rationale | ||||||
|     noindex |     noindex | ||||||
|  | @ -56,6 +57,7 @@ class Form::AdminSettings | ||||||
|     show_replies_in_public_timelines |     show_replies_in_public_timelines | ||||||
|     spam_check_enabled |     spam_check_enabled | ||||||
|     trends |     trends | ||||||
|  |     trendable_by_default | ||||||
|     noindex |     noindex | ||||||
|   ).freeze |   ).freeze | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -7,19 +7,7 @@ class HomeFeed < Feed | ||||||
|     @account = account |     @account = account | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def get(limit, max_id = nil, since_id = nil, min_id = nil) |   def regenerating? | ||||||
|     if redis.exists("account:#{@account.id}:regeneration") |     redis.exists("account:#{@id}:regeneration") | ||||||
|       from_database(limit, max_id, since_id, min_id) |  | ||||||
|     else |  | ||||||
|       super |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   private |  | ||||||
| 
 |  | ||||||
|   def from_database(limit, max_id, since_id, min_id) |  | ||||||
|     Status.as_home_timeline(@account) |  | ||||||
|           .paginate_by_id(limit, max_id: max_id, since_id: since_id, min_id: min_id) |  | ||||||
|           .reject { |status| FeedManager.instance.filter?(:home, status, @account.id) } |  | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -57,6 +57,7 @@ class MediaAttachment < ApplicationRecord | ||||||
|     small: { |     small: { | ||||||
|       convert_options: { |       convert_options: { | ||||||
|         output: { |         output: { | ||||||
|  |           'loglevel' => 'fatal', | ||||||
|           vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease', |           vf: 'scale=\'min(400\, iw):min(400\, ih)\':force_original_aspect_ratio=decrease', | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|  | @ -70,6 +71,7 @@ class MediaAttachment < ApplicationRecord | ||||||
|       keep_same_format: true, |       keep_same_format: true, | ||||||
|       convert_options: { |       convert_options: { | ||||||
|         output: { |         output: { | ||||||
|  |           'loglevel' => 'fatal', | ||||||
|           'map_metadata' => '-1', |           'map_metadata' => '-1', | ||||||
|           'c:v' => 'copy', |           'c:v' => 'copy', | ||||||
|           'c:a' => 'copy', |           'c:a' => 'copy', | ||||||
|  | @ -84,6 +86,7 @@ class MediaAttachment < ApplicationRecord | ||||||
|       content_type: 'audio/mpeg', |       content_type: 'audio/mpeg', | ||||||
|       convert_options: { |       convert_options: { | ||||||
|         output: { |         output: { | ||||||
|  |           'loglevel' => 'fatal', | ||||||
|           'q:a' => 2, |           'q:a' => 2, | ||||||
|         }, |         }, | ||||||
|       }, |       }, | ||||||
|  |  | ||||||
|  | @ -291,10 +291,6 @@ class Status < ApplicationRecord | ||||||
|       where(language: nil).or where(language: account.chosen_languages) |       where(language: nil).or where(language: account.chosen_languages) | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def as_home_timeline(account) |  | ||||||
|       where(account: [account] + account.following).where(visibility: [:public, :unlisted, :private]) |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     def as_direct_timeline(account, limit = 20, max_id = nil, since_id = nil, cache_ids = false) |     def as_direct_timeline(account, limit = 20, max_id = nil, since_id = nil, cache_ids = false) | ||||||
|       # direct timeline is mix of direct message from_me and to_me. |       # direct timeline is mix of direct message from_me and to_me. | ||||||
|       # 2 queries are executed with pagination. |       # 2 queries are executed with pagination. | ||||||
|  |  | ||||||
|  | @ -37,6 +37,7 @@ class Tag < ApplicationRecord | ||||||
|   scope :pending_review, -> { unreviewed.where.not(requested_review_at: nil) } |   scope :pending_review, -> { unreviewed.where.not(requested_review_at: nil) } | ||||||
|   scope :usable, -> { where(usable: [true, nil]) } |   scope :usable, -> { where(usable: [true, nil]) } | ||||||
|   scope :listable, -> { where(listable: [true, nil]) } |   scope :listable, -> { where(listable: [true, nil]) } | ||||||
|  |   scope :trendable, -> { Setting.trendable_by_default ? where(trendable: [true, nil]) : where(trendable: true) } | ||||||
|   scope :discoverable, -> { listable.joins(:account_tag_stat).where(AccountTagStat.arel_table[:accounts_count].gt(0)).order(Arel.sql('account_tag_stats.accounts_count desc')) } |   scope :discoverable, -> { listable.joins(:account_tag_stat).where(AccountTagStat.arel_table[:accounts_count].gt(0)).order(Arel.sql('account_tag_stats.accounts_count desc')) } | ||||||
|   scope :most_used, ->(account) { joins(:statuses).where(statuses: { account: account }).group(:id).order(Arel.sql('count(*) desc')) } |   scope :most_used, ->(account) { joins(:statuses).where(statuses: { account: account }).group(:id).order(Arel.sql('count(*) desc')) } | ||||||
|   scope :matches_name, ->(value) { where(arel_table[:name].matches("#{value}%")) } |   scope :matches_name, ->(value) { where(arel_table[:name].matches("#{value}%")) } | ||||||
|  | @ -76,7 +77,7 @@ class Tag < ApplicationRecord | ||||||
|   alias listable? listable |   alias listable? listable | ||||||
| 
 | 
 | ||||||
|   def trendable |   def trendable | ||||||
|     boolean_with_default('trendable', false) |     boolean_with_default('trendable', Setting.trendable_by_default) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   alias trendable? trendable |   alias trendable? trendable | ||||||
|  |  | ||||||
|  | @ -90,7 +90,7 @@ class TrendingTags | ||||||
|       tag_ids = redis.zrevrange(KEY, 0, LIMIT - 1).map(&:to_i) |       tag_ids = redis.zrevrange(KEY, 0, LIMIT - 1).map(&:to_i) | ||||||
| 
 | 
 | ||||||
|       tags = Tag.where(id: tag_ids) |       tags = Tag.where(id: tag_ids) | ||||||
|       tags = tags.where(trendable: true) if filtered |       tags = tags.trendable if filtered | ||||||
|       tags = tags.each_with_object({}) { |tag, h| h[tag.id] = tag } |       tags = tags.each_with_object({}) { |tag, h| h[tag.id] = tag } | ||||||
| 
 | 
 | ||||||
|       tag_ids.map { |tag_id| tags[tag_id] }.compact.take(limit) |       tag_ids.map { |tag_id| tags[tag_id] }.compact.take(limit) | ||||||
|  |  | ||||||
|  | @ -1,6 +1,8 @@ | ||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| class HashtagQueryService < BaseService | class HashtagQueryService < BaseService | ||||||
|  |   LIMIT_PER_MODE = 4 | ||||||
|  | 
 | ||||||
|   def call(tag, params, account = nil, local = false) |   def call(tag, params, account = nil, local = false) | ||||||
|     tags = tags_for(Array(tag.name) | Array(params[:any])).pluck(:id) |     tags = tags_for(Array(tag.name) | Array(params[:any])).pluck(:id) | ||||||
|     all  = tags_for(params[:all]) |     all  = tags_for(params[:all]) | ||||||
|  | @ -15,6 +17,6 @@ class HashtagQueryService < BaseService | ||||||
|   private |   private | ||||||
| 
 | 
 | ||||||
|   def tags_for(names) |   def tags_for(names) | ||||||
|     Tag.matching_name(names) if names.presence |     Tag.matching_name(Array(names).take(LIMIT_PER_MODE)) if names.present? | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -52,13 +52,12 @@ | ||||||
|         .hero-widget__img |         .hero-widget__img | ||||||
|           = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.jpg'), alt: @instance_presenter.site_title |           = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.jpg'), alt: @instance_presenter.site_title | ||||||
| 
 | 
 | ||||||
|         - if @instance_presenter.site_short_description.present? |         .hero-widget__text | ||||||
|           .hero-widget__text |           %p | ||||||
|             %p |             = @instance_presenter.site_short_description.html_safe.presence || t('about.about_mastodon_html') | ||||||
|               = @instance_presenter.site_short_description.html_safe.presence |             = link_to about_more_path do | ||||||
|               = link_to about_more_path do |               = t('about.learn_more') | ||||||
|                 = t('about.learn_more') |               = fa_icon 'angle-double-right' | ||||||
|                 = fa_icon 'angle-double-right' |  | ||||||
| 
 | 
 | ||||||
|         .hero-widget__footer |         .hero-widget__footer | ||||||
|           .hero-widget__footer__column |           .hero-widget__footer__column | ||||||
|  |  | ||||||
|  | @ -17,6 +17,10 @@ | ||||||
|       - else |       - else | ||||||
|         = custom_emoji.domain |         = custom_emoji.domain | ||||||
| 
 | 
 | ||||||
|  |         - if custom_emoji.local_counterpart.present? | ||||||
|  |           • | ||||||
|  |           = t('admin.accounts.location.local') | ||||||
|  | 
 | ||||||
|       %br/ |       %br/ | ||||||
| 
 | 
 | ||||||
|       - if custom_emoji.disabled? |       - if custom_emoji.disabled? | ||||||
|  |  | ||||||
|  | @ -20,10 +20,10 @@ | ||||||
|       = f.input :site_contact_email, wrapper: :with_label, label: t('admin.settings.contact_information.email') |       = f.input :site_contact_email, wrapper: :with_label, label: t('admin.settings.contact_information.email') | ||||||
| 
 | 
 | ||||||
|   .fields-group |   .fields-group | ||||||
|     = f.input :site_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_description.title'), hint: t('admin.settings.site_description.desc_html'), input_html: { rows: 4 } |     = f.input :site_short_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_short_description.title'), hint: t('admin.settings.site_short_description.desc_html'), input_html: { rows: 2 } | ||||||
| 
 | 
 | ||||||
|   .fields-group |   .fields-group | ||||||
|     = f.input :site_short_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_short_description.title'), hint: t('admin.settings.site_short_description.desc_html'), input_html: { rows: 2 } |     = f.input :site_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_description.title'), hint: t('admin.settings.site_description.desc_html'), input_html: { rows: 2 } | ||||||
| 
 | 
 | ||||||
|   .fields-row |   .fields-row | ||||||
|     .fields-row__column.fields-row__column-6.fields-group |     .fields-row__column.fields-row__column-6.fields-group | ||||||
|  | @ -71,6 +71,9 @@ | ||||||
|     .fields-group |     .fields-group | ||||||
|       = f.input :trends, as: :boolean, wrapper: :with_label, label: t('admin.settings.trends.title'), hint: t('admin.settings.trends.desc_html') |       = f.input :trends, as: :boolean, wrapper: :with_label, label: t('admin.settings.trends.title'), hint: t('admin.settings.trends.desc_html') | ||||||
| 
 | 
 | ||||||
|  |     .fields-group | ||||||
|  |       = f.input :trendable_by_default, as: :boolean, wrapper: :with_label, label: t('admin.settings.trendable_by_default.title'), hint: t('admin.settings.trendable_by_default.desc_html') | ||||||
|  | 
 | ||||||
|     .fields-group |     .fields-group | ||||||
|       = f.input :noindex, as: :boolean, wrapper: :with_label, label: t('admin.settings.default_noindex.title'), hint: t('admin.settings.default_noindex.desc_html') |       = f.input :noindex, as: :boolean, wrapper: :with_label, label: t('admin.settings.default_noindex.title'), hint: t('admin.settings.default_noindex.desc_html') | ||||||
| 
 | 
 | ||||||
|  | @ -101,8 +104,8 @@ | ||||||
|       = f.input :show_domain_blocks_rationale, wrapper: :with_label, collection: %i(disabled users all), label: t('admin.settings.domain_blocks_rationale.title'), label_method: lambda { |value| t("admin.settings.domain_blocks.#{value}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' |       = f.input :show_domain_blocks_rationale, wrapper: :with_label, collection: %i(disabled users all), label: t('admin.settings.domain_blocks_rationale.title'), label_method: lambda { |value| t("admin.settings.domain_blocks.#{value}") }, include_blank: false, collection_wrapper_tag: 'ul', item_wrapper_tag: 'li' | ||||||
| 
 | 
 | ||||||
|   .fields-group |   .fields-group | ||||||
|     = f.input :closed_registrations_message, as: :text, wrapper: :with_block_label, label: t('admin.settings.registrations.closed_message.title'), hint: t('admin.settings.registrations.closed_message.desc_html'), input_html: { rows: 8 } |  | ||||||
|     = f.input :site_extended_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_description_extended.title'), hint: t('admin.settings.site_description_extended.desc_html'), input_html: { rows: 8 } unless whitelist_mode? |     = f.input :site_extended_description, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_description_extended.title'), hint: t('admin.settings.site_description_extended.desc_html'), input_html: { rows: 8 } unless whitelist_mode? | ||||||
|  |     = f.input :closed_registrations_message, as: :text, wrapper: :with_block_label, label: t('admin.settings.registrations.closed_message.title'), hint: t('admin.settings.registrations.closed_message.desc_html'), input_html: { rows: 8 } | ||||||
|     = f.input :site_terms, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_terms.title'), hint: t('admin.settings.site_terms.desc_html'), input_html: { rows: 8 } |     = f.input :site_terms, wrapper: :with_block_label, as: :text, label: t('admin.settings.site_terms.title'), hint: t('admin.settings.site_terms.desc_html'), input_html: { rows: 8 } | ||||||
|     = f.input :custom_css, wrapper: :with_block_label, as: :text, input_html: { rows: 8 }, label: t('admin.settings.custom_css.title'), hint: t('admin.settings.custom_css.desc_html') |     = f.input :custom_css, wrapper: :with_block_label, as: :text, input_html: { rows: 8 }, label: t('admin.settings.custom_css.title'), hint: t('admin.settings.custom_css.desc_html') | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -3,7 +3,7 @@ | ||||||
|     = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.jpg'), alt: @instance_presenter.site_title |     = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('media/images/preview.jpg'), alt: @instance_presenter.site_title | ||||||
| 
 | 
 | ||||||
|   .hero-widget__text |   .hero-widget__text | ||||||
|     %p= @instance_presenter.site_short_description.html_safe.presence || @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname) |     %p= @instance_presenter.site_short_description.html_safe.presence || t('about.about_mastodon_html') | ||||||
| 
 | 
 | ||||||
| - if Setting.trends && !(user_signed_in? && !current_user.setting_trends) | - if Setting.trends && !(user_signed_in? && !current_user.setting_trends) | ||||||
|   - trends = TrendingTags.get(3) |   - trends = TrendingTags.get(3) | ||||||
|  |  | ||||||
|  | @ -1,5 +1,5 @@ | ||||||
| - thumbnail     = @instance_presenter.thumbnail | - thumbnail     = @instance_presenter.thumbnail | ||||||
| - description ||= strip_tags(@instance_presenter.site_short_description.presence || @instance_presenter.site_description.presence || t('about.about_mastodon_html')) | - description ||= strip_tags(@instance_presenter.site_short_description.presence || t('about.about_mastodon_html')) | ||||||
| 
 | 
 | ||||||
| %meta{ name: 'description', content: description }/ | %meta{ name: 'description', content: description }/ | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -20,7 +20,7 @@ | ||||||
|       %p{ :style => ('margin-bottom: 0' unless current_account&.user&.setting_expand_spoilers) }< |       %p{ :style => ('margin-bottom: 0' unless current_account&.user&.setting_expand_spoilers) }< | ||||||
|         %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}  |         %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}  | ||||||
|         %button.status__content__spoiler-link= t('statuses.show_more') |         %button.status__content__spoiler-link= t('statuses.show_more') | ||||||
|     .e-content{ lang: status.language, style: "display: #{!current_account&.user&.setting_expand_spoilers && status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" } |     .e-content{ style: "display: #{!current_account&.user&.setting_expand_spoilers && status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" } | ||||||
|       = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay) |       = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay) | ||||||
|       - if status.preloadable_poll |       - if status.preloadable_poll | ||||||
|         = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do |         = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do | ||||||
|  |  | ||||||
|  | @ -24,7 +24,7 @@ | ||||||
|       %p{ :style => ('margin-bottom: 0' unless current_account&.user&.setting_expand_spoilers) }< |       %p{ :style => ('margin-bottom: 0' unless current_account&.user&.setting_expand_spoilers) }< | ||||||
|         %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}  |         %span.p-summary> #{Formatter.instance.format_spoiler(status, autoplay: autoplay)}  | ||||||
|         %button.status__content__spoiler-link= t('statuses.show_more') |         %button.status__content__spoiler-link= t('statuses.show_more') | ||||||
|     .e-content{ lang: status.language, style: "display: #{!current_account&.user&.setting_expand_spoilers && status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }< |     .e-content{ style: "display: #{!current_account&.user&.setting_expand_spoilers && status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }< | ||||||
|       = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay) |       = Formatter.instance.format(status, custom_emojify: true, autoplay: autoplay) | ||||||
|       - if status.preloadable_poll |       - if status.preloadable_poll | ||||||
|         = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do |         = react_component :poll, disabled: true, poll: ActiveModelSerializers::SerializableResource.new(status.preloadable_poll, serializer: REST::PollSerializer, scope: current_user, scope_name: :current_user).as_json do | ||||||
|  |  | ||||||
|  | @ -1,6 +1,6 @@ | ||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| lock '3.11.1' | lock '3.11.2' | ||||||
| 
 | 
 | ||||||
| set :repo_url, ENV.fetch('REPO', 'https://github.com/tootsuite/mastodon.git') | set :repo_url, ENV.fetch('REPO', 'https://github.com/tootsuite/mastodon.git') | ||||||
| set :branch, ENV.fetch('BRANCH', 'master') | set :branch, ENV.fetch('BRANCH', 'master') | ||||||
|  |  | ||||||
|  | @ -1,3 +1,4 @@ | ||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| require_relative '../../lib/json_ld/security' | require_relative '../../lib/json_ld/security' | ||||||
|  | require_relative '../../lib/json_ld/identity' | ||||||
|  |  | ||||||
|  | @ -1,10 +1,11 @@ | ||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| Paperclip.options[:read_timeout] = 60 |  | ||||||
| 
 |  | ||||||
| Paperclip.interpolates :filename do |attachment, style| | Paperclip.interpolates :filename do |attachment, style| | ||||||
|   return attachment.original_filename if style == :original |   if style == :original | ||||||
|   [basename(attachment, style), extension(attachment, style)].delete_if(&:blank?).join('.') |     attachment.original_filename | ||||||
|  |   else | ||||||
|  |     [basename(attachment, style), extension(attachment, style)].delete_if(&:blank?).join('.') | ||||||
|  |   end | ||||||
| end | end | ||||||
| 
 | 
 | ||||||
| Paperclip::Attachment.default_options.merge!( | Paperclip::Attachment.default_options.merge!( | ||||||
|  | @ -24,22 +25,27 @@ if ENV['S3_ENABLED'] == 'true' | ||||||
|     storage: :s3, |     storage: :s3, | ||||||
|     s3_protocol: s3_protocol, |     s3_protocol: s3_protocol, | ||||||
|     s3_host_name: s3_hostname, |     s3_host_name: s3_hostname, | ||||||
|  | 
 | ||||||
|     s3_headers: { |     s3_headers: { | ||||||
|       'X-Amz-Multipart-Threshold' => ENV.fetch('S3_MULTIPART_THRESHOLD') { 15.megabytes }.to_i, |       'X-Amz-Multipart-Threshold' => ENV.fetch('S3_MULTIPART_THRESHOLD') { 15.megabytes }.to_i, | ||||||
|       'Cache-Control' => 'public, max-age=315576000, immutable', |       'Cache-Control' => 'public, max-age=315576000, immutable', | ||||||
|     }, |     }, | ||||||
|  | 
 | ||||||
|     s3_permissions: ENV.fetch('S3_PERMISSION') { 'public-read' }, |     s3_permissions: ENV.fetch('S3_PERMISSION') { 'public-read' }, | ||||||
|     s3_region: s3_region, |     s3_region: s3_region, | ||||||
|  | 
 | ||||||
|     s3_credentials: { |     s3_credentials: { | ||||||
|       bucket: ENV['S3_BUCKET'], |       bucket: ENV['S3_BUCKET'], | ||||||
|       access_key_id: ENV['AWS_ACCESS_KEY_ID'], |       access_key_id: ENV['AWS_ACCESS_KEY_ID'], | ||||||
|       secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'], |       secret_access_key: ENV['AWS_SECRET_ACCESS_KEY'], | ||||||
|     }, |     }, | ||||||
|  | 
 | ||||||
|     s3_options: { |     s3_options: { | ||||||
|       signature_version: ENV.fetch('S3_SIGNATURE_VERSION') { 'v4' }, |       signature_version: ENV.fetch('S3_SIGNATURE_VERSION') { 'v4' }, | ||||||
|       http_open_timeout: 5, |       http_open_timeout: 5, | ||||||
|       http_read_timeout: 5, |       http_read_timeout: 5, | ||||||
|       http_idle_timeout: 5, |       http_idle_timeout: 5, | ||||||
|  |       retry_limit: 0, | ||||||
|     } |     } | ||||||
|   ) |   ) | ||||||
| 
 | 
 | ||||||
|  | @ -48,6 +54,7 @@ if ENV['S3_ENABLED'] == 'true' | ||||||
|       endpoint: ENV['S3_ENDPOINT'], |       endpoint: ENV['S3_ENDPOINT'], | ||||||
|       force_path_style: true |       force_path_style: true | ||||||
|     ) |     ) | ||||||
|  | 
 | ||||||
|     Paperclip::Attachment.default_options[:url] = ':s3_path_url' |     Paperclip::Attachment.default_options[:url] = ':s3_path_url' | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  | @ -73,6 +80,7 @@ elsif ENV['SWIFT_ENABLED'] == 'true' | ||||||
|       openstack_region: ENV['SWIFT_REGION'], |       openstack_region: ENV['SWIFT_REGION'], | ||||||
|       openstack_cache_ttl: ENV.fetch('SWIFT_CACHE_TTL') { 60 }, |       openstack_cache_ttl: ENV.fetch('SWIFT_CACHE_TTL') { 60 }, | ||||||
|     }, |     }, | ||||||
|  | 
 | ||||||
|     fog_directory: ENV['SWIFT_CONTAINER'], |     fog_directory: ENV['SWIFT_CONTAINER'], | ||||||
|     fog_host: ENV['SWIFT_OBJECT_URL'], |     fog_host: ENV['SWIFT_OBJECT_URL'], | ||||||
|     fog_public: true |     fog_public: true | ||||||
|  | @ -81,7 +89,7 @@ else | ||||||
|   Paperclip::Attachment.default_options.merge!( |   Paperclip::Attachment.default_options.merge!( | ||||||
|     storage: :filesystem, |     storage: :filesystem, | ||||||
|     use_timestamp: true, |     use_timestamp: true, | ||||||
|     path: (ENV['PAPERCLIP_ROOT_PATH'] || ':rails_root/public/system') + '/:class/:attachment/:id_partition/:style/:filename', |     path: ENV.fetch('PAPERCLIP_ROOT_PATH', ':rails_root/public/system') + '/:class/:attachment/:id_partition/:style/:filename', | ||||||
|     url: (ENV['PAPERCLIP_ROOT_URL'] || '/system') + '/:class/:attachment/:id_partition/:style/:filename', |     url: ENV.fetch('PAPERCLIP_ROOT_URL', '/system') + '/:class/:attachment/:id_partition/:style/:filename', | ||||||
|   ) |   ) | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -2,7 +2,7 @@ | ||||||
| en: | en: | ||||||
|   about: |   about: | ||||||
|     about_hashtag_html: These are public toots tagged with <strong>#%{hashtag}</strong>. You can interact with them if you have an account anywhere in the fediverse. |     about_hashtag_html: These are public toots tagged with <strong>#%{hashtag}</strong>. You can interact with them if you have an account anywhere in the fediverse. | ||||||
|     about_mastodon_html: Mastodon is a social network based on open web protocols and free, open-source software. It is decentralized like e-mail. |     about_mastodon_html: 'The social network of the future: No ads, no corporate surveillance, ethical design, and decentralization! Own your data with Mastodon!' | ||||||
|     about_this: About |     about_this: About | ||||||
|     active_count_after: active |     active_count_after: active | ||||||
|     active_footnote: Monthly Active Users (MAU) |     active_footnote: Monthly Active Users (MAU) | ||||||
|  | @ -18,7 +18,6 @@ en: | ||||||
|     discover_users: Discover users |     discover_users: Discover users | ||||||
|     documentation: Documentation |     documentation: Documentation | ||||||
|     federation_hint_html: With an account on %{instance} you'll be able to follow people on any Mastodon server and beyond. |     federation_hint_html: With an account on %{instance} you'll be able to follow people on any Mastodon server and beyond. | ||||||
|     generic_description: "%{domain} is one server in the network" |  | ||||||
|     get_apps: Try a mobile app |     get_apps: Try a mobile app | ||||||
|     hosted_on: Mastodon hosted on %{domain} |     hosted_on: Mastodon hosted on %{domain} | ||||||
|     instance_actor_flash: | |     instance_actor_flash: | | ||||||
|  | @ -486,8 +485,8 @@ en: | ||||||
|           open: Anyone can sign up |           open: Anyone can sign up | ||||||
|         title: Registrations mode |         title: Registrations mode | ||||||
|       show_known_fediverse_at_about_page: |       show_known_fediverse_at_about_page: | ||||||
|         desc_html: When toggled, it will show toots from all the known fediverse on preview. Otherwise it will only show local toots. |         desc_html: When disabled, restricts the public timeline linked from the landing page to showing only local content | ||||||
|         title: Show known fediverse on timeline preview |         title: Include federated content on unauthenticated public timeline page | ||||||
|       show_reblogs_in_public_timelines: |       show_reblogs_in_public_timelines: | ||||||
|         desc_html: Show public boosts of public toots in local and public timelines. |         desc_html: Show public boosts of public toots in local and public timelines. | ||||||
|         title: Show boosts in public timelines |         title: Show boosts in public timelines | ||||||
|  | @ -511,15 +510,18 @@ en: | ||||||
|         title: Custom terms of service |         title: Custom terms of service | ||||||
|       site_title: Server name |       site_title: Server name | ||||||
|       spam_check_enabled: |       spam_check_enabled: | ||||||
|         desc_html: Mastodon can auto-silence and auto-report accounts that send repeated unsolicited messages. There may be false positives. |         desc_html: Mastodon can auto-report accounts that send repeated unsolicited messages. There may be false positives. | ||||||
|         title: Anti-spam automation |         title: Anti-spam automation | ||||||
|       thumbnail: |       thumbnail: | ||||||
|         desc_html: Used for previews via OpenGraph and API. 1200x630px recommended |         desc_html: Used for previews via OpenGraph and API. 1200x630px recommended | ||||||
|         title: Server thumbnail |         title: Server thumbnail | ||||||
|       timeline_preview: |       timeline_preview: | ||||||
|         desc_html: Display public timeline on landing page |         desc_html: Display link to public timeline on landing page and allow API access to the public timeline without authentication | ||||||
|         title: Timeline preview |         title: Allow unauthenticated access to public timeline | ||||||
|       title: Site settings |       title: Site settings | ||||||
|  |       trendable_by_default: | ||||||
|  |         desc_html: Affects hashtags that have not been previously disallowed | ||||||
|  |         title: Allow hashtags to trend without prior review | ||||||
|       trends: |       trends: | ||||||
|         desc_html: Publicly display previously reviewed hashtags that are currently trending |         desc_html: Publicly display previously reviewed hashtags that are currently trending | ||||||
|         title: Trending hashtags |         title: Trending hashtags | ||||||
|  |  | ||||||
|  | @ -40,6 +40,7 @@ defaults: &defaults | ||||||
|   use_blurhash: true |   use_blurhash: true | ||||||
|   use_pending_items: false |   use_pending_items: false | ||||||
|   trends: true |   trends: true | ||||||
|  |   trendable_by_default: false | ||||||
|   notification_emails: |   notification_emails: | ||||||
|     follow: false |     follow: false | ||||||
|     reblog: false |     reblog: false | ||||||
|  |  | ||||||
|  | @ -52,6 +52,6 @@ class MigrateAccountConversations < ActiveRecord::Migration[5.2] | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def notifications_about_direct_statuses |   def notifications_about_direct_statuses | ||||||
|     Notification.joins(mention: :status).where(activity_type: 'Mention', statuses: { visibility: :direct }) |     Notification.joins('INNER JOIN mentions ON mentions.id = notifications.activity_id INNER JOIN statuses ON statuses.id = mentions.status_id').where(activity_type: 'Mention', statuses: { visibility: :direct }) | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
							
								
								
									
										11
									
								
								db/migrate/20191007013357_update_pt_locales.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								db/migrate/20191007013357_update_pt_locales.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,11 @@ | ||||||
|  | class UpdatePtLocales < ActiveRecord::Migration[5.2] | ||||||
|  |   disable_ddl_transaction! | ||||||
|  | 
 | ||||||
|  |   def up | ||||||
|  |     User.where(locale: 'pt').in_batches.update_all(locale: 'pt-PT') | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def down | ||||||
|  |     User.where(locale: 'pt-PT').in_batches.update_all(locale: 'pt') | ||||||
|  |   end | ||||||
|  | end | ||||||
							
								
								
									
										26
									
								
								db/schema.rb
									
									
									
									
									
								
							
							
						
						
									
										26
									
								
								db/schema.rb
									
									
									
									
									
								
							|  | @ -10,7 +10,7 @@ | ||||||
| # | # | ||||||
| # It's strongly recommended that you check this file into your version control system. | # It's strongly recommended that you check this file into your version control system. | ||||||
| 
 | 
 | ||||||
| ActiveRecord::Schema.define(version: 2019_10_01_213028) do | ActiveRecord::Schema.define(version: 2019_10_07_013357) do | ||||||
| 
 | 
 | ||||||
|   # These are extensions that must be enabled in order to support this database |   # These are extensions that must be enabled in order to support this database | ||||||
|   enable_extension "plpgsql" |   enable_extension "plpgsql" | ||||||
|  | @ -706,6 +706,30 @@ ActiveRecord::Schema.define(version: 2019_10_01_213028) do | ||||||
|     t.index ["tag_id", "status_id"], name: "index_statuses_tags_on_tag_id_and_status_id", unique: true |     t.index ["tag_id", "status_id"], name: "index_statuses_tags_on_tag_id_and_status_id", unique: true | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |   create_table "stream_entries", force: :cascade do |t| | ||||||
|  |     t.bigint "activity_id" | ||||||
|  |     t.string "activity_type" | ||||||
|  |     t.datetime "created_at", null: false | ||||||
|  |     t.datetime "updated_at", null: false | ||||||
|  |     t.boolean "hidden", default: false, null: false | ||||||
|  |     t.bigint "account_id" | ||||||
|  |     t.index ["account_id", "activity_type", "id"], name: "index_stream_entries_on_account_id_and_activity_type_and_id" | ||||||
|  |     t.index ["activity_id", "activity_type"], name: "index_stream_entries_on_activity_id_and_activity_type" | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   create_table "subscriptions", force: :cascade do |t| | ||||||
|  |     t.string "callback_url", default: "", null: false | ||||||
|  |     t.string "secret" | ||||||
|  |     t.datetime "expires_at" | ||||||
|  |     t.boolean "confirmed", default: false, null: false | ||||||
|  |     t.datetime "created_at", null: false | ||||||
|  |     t.datetime "updated_at", null: false | ||||||
|  |     t.datetime "last_successful_delivery_at" | ||||||
|  |     t.string "domain" | ||||||
|  |     t.bigint "account_id", null: false | ||||||
|  |     t.index ["account_id", "callback_url"], name: "index_subscriptions_on_account_id_and_callback_url", unique: true | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|   create_table "tags", force: :cascade do |t| |   create_table "tags", force: :cascade do |t| | ||||||
|     t.string "name", default: "", null: false |     t.string "name", default: "", null: false | ||||||
|     t.datetime "created_at", null: false |     t.datetime "created_at", null: false | ||||||
|  |  | ||||||
							
								
								
									
										87
									
								
								lib/json_ld/identity.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								lib/json_ld/identity.rb
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,87 @@ | ||||||
|  | # -*- encoding: utf-8 -*- | ||||||
|  | # frozen_string_literal: true | ||||||
|  | # This file generated automatically from http://w3id.org/identity/v1 | ||||||
|  | require 'json/ld' | ||||||
|  | class JSON::LD::Context | ||||||
|  |   add_preloaded("http://w3id.org/identity/v1") do | ||||||
|  |     new(term_definitions: { | ||||||
|  |       "Credential" => TermDefinition.new("Credential", id: "https://w3id.org/credentials#Credential", simple: true), | ||||||
|  |       "CryptographicKey" => TermDefinition.new("CryptographicKey", id: "https://w3id.org/security#Key", simple: true), | ||||||
|  |       "CryptographicKeyCredential" => TermDefinition.new("CryptographicKeyCredential", id: "https://w3id.org/credentials#CryptographicKeyCredential", simple: true), | ||||||
|  |       "EncryptedMessage" => TermDefinition.new("EncryptedMessage", id: "https://w3id.org/security#EncryptedMessage", simple: true), | ||||||
|  |       "GraphSignature2012" => TermDefinition.new("GraphSignature2012", id: "https://w3id.org/security#GraphSignature2012", simple: true), | ||||||
|  |       "Group" => TermDefinition.new("Group", id: "https://www.w3.org/ns/activitystreams#Group", simple: true), | ||||||
|  |       "Identity" => TermDefinition.new("Identity", id: "https://w3id.org/identity#Identity", simple: true), | ||||||
|  |       "LinkedDataSignature2015" => TermDefinition.new("LinkedDataSignature2015", id: "https://w3id.org/security#LinkedDataSignature2015", simple: true), | ||||||
|  |       "Organization" => TermDefinition.new("Organization", id: "http://schema.org/Organization", simple: true), | ||||||
|  |       "Person" => TermDefinition.new("Person", id: "http://schema.org/Person", simple: true), | ||||||
|  |       "PostalAddress" => TermDefinition.new("PostalAddress", id: "http://schema.org/PostalAddress", simple: true), | ||||||
|  |       "about" => TermDefinition.new("about", id: "http://schema.org/about", type_mapping: "@id"), | ||||||
|  |       "accessControl" => TermDefinition.new("accessControl", id: "https://w3id.org/permissions#accessControl", type_mapping: "@id"), | ||||||
|  |       "address" => TermDefinition.new("address", id: "http://schema.org/address", type_mapping: "@id"), | ||||||
|  |       "addressCountry" => TermDefinition.new("addressCountry", id: "http://schema.org/addressCountry", simple: true), | ||||||
|  |       "addressLocality" => TermDefinition.new("addressLocality", id: "http://schema.org/addressLocality", simple: true), | ||||||
|  |       "addressRegion" => TermDefinition.new("addressRegion", id: "http://schema.org/addressRegion", simple: true), | ||||||
|  |       "cipherAlgorithm" => TermDefinition.new("cipherAlgorithm", id: "https://w3id.org/security#cipherAlgorithm", simple: true), | ||||||
|  |       "cipherData" => TermDefinition.new("cipherData", id: "https://w3id.org/security#cipherData", simple: true), | ||||||
|  |       "cipherKey" => TermDefinition.new("cipherKey", id: "https://w3id.org/security#cipherKey", simple: true), | ||||||
|  |       "claim" => TermDefinition.new("claim", id: "https://w3id.org/credentials#claim", type_mapping: "@id"), | ||||||
|  |       "comment" => TermDefinition.new("comment", id: "http://www.w3.org/2000/01/rdf-schema#comment", simple: true), | ||||||
|  |       "created" => TermDefinition.new("created", id: "http://purl.org/dc/terms/created", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), | ||||||
|  |       "creator" => TermDefinition.new("creator", id: "http://purl.org/dc/terms/creator", type_mapping: "@id"), | ||||||
|  |       "cred" => TermDefinition.new("cred", id: "https://w3id.org/credentials#", simple: true, prefix: true), | ||||||
|  |       "credential" => TermDefinition.new("credential", id: "https://w3id.org/credentials#credential", type_mapping: "@id"), | ||||||
|  |       "dc" => TermDefinition.new("dc", id: "http://purl.org/dc/terms/", simple: true, prefix: true), | ||||||
|  |       "description" => TermDefinition.new("description", id: "http://schema.org/description", simple: true), | ||||||
|  |       "digestAlgorithm" => TermDefinition.new("digestAlgorithm", id: "https://w3id.org/security#digestAlgorithm", simple: true), | ||||||
|  |       "digestValue" => TermDefinition.new("digestValue", id: "https://w3id.org/security#digestValue", simple: true), | ||||||
|  |       "domain" => TermDefinition.new("domain", id: "https://w3id.org/security#domain", simple: true), | ||||||
|  |       "email" => TermDefinition.new("email", id: "http://schema.org/email", simple: true), | ||||||
|  |       "expires" => TermDefinition.new("expires", id: "https://w3id.org/security#expiration", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), | ||||||
|  |       "familyName" => TermDefinition.new("familyName", id: "http://schema.org/familyName", simple: true), | ||||||
|  |       "givenName" => TermDefinition.new("givenName", id: "http://schema.org/givenName", simple: true), | ||||||
|  |       "id" => TermDefinition.new("id", id: "@id", simple: true), | ||||||
|  |       "identity" => TermDefinition.new("identity", id: "https://w3id.org/identity#", simple: true, prefix: true), | ||||||
|  |       "identityService" => TermDefinition.new("identityService", id: "https://w3id.org/identity#identityService", type_mapping: "@id"), | ||||||
|  |       "idp" => TermDefinition.new("idp", id: "https://w3id.org/identity#idp", type_mapping: "@id"), | ||||||
|  |       "image" => TermDefinition.new("image", id: "http://schema.org/image", type_mapping: "@id"), | ||||||
|  |       "initializationVector" => TermDefinition.new("initializationVector", id: "https://w3id.org/security#initializationVector", simple: true), | ||||||
|  |       "issued" => TermDefinition.new("issued", id: "https://w3id.org/credentials#issued", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), | ||||||
|  |       "issuer" => TermDefinition.new("issuer", id: "https://w3id.org/credentials#issuer", type_mapping: "@id"), | ||||||
|  |       "label" => TermDefinition.new("label", id: "http://www.w3.org/2000/01/rdf-schema#label", simple: true), | ||||||
|  |       "member" => TermDefinition.new("member", id: "http://schema.org/member", type_mapping: "@id"), | ||||||
|  |       "memberOf" => TermDefinition.new("memberOf", id: "http://schema.org/memberOf", type_mapping: "@id"), | ||||||
|  |       "name" => TermDefinition.new("name", id: "http://schema.org/name", simple: true), | ||||||
|  |       "nonce" => TermDefinition.new("nonce", id: "https://w3id.org/security#nonce", simple: true), | ||||||
|  |       "normalizationAlgorithm" => TermDefinition.new("normalizationAlgorithm", id: "https://w3id.org/security#normalizationAlgorithm", simple: true), | ||||||
|  |       "owner" => TermDefinition.new("owner", id: "https://w3id.org/security#owner", type_mapping: "@id"), | ||||||
|  |       "password" => TermDefinition.new("password", id: "https://w3id.org/security#password", simple: true), | ||||||
|  |       "paymentProcessor" => TermDefinition.new("paymentProcessor", id: "https://w3id.org/payswarm#processor", simple: true), | ||||||
|  |       "perm" => TermDefinition.new("perm", id: "https://w3id.org/permissions#", simple: true, prefix: true), | ||||||
|  |       "postalCode" => TermDefinition.new("postalCode", id: "http://schema.org/postalCode", simple: true), | ||||||
|  |       "preferences" => TermDefinition.new("preferences", id: "https://w3id.org/payswarm#preferences", type_mapping: "@vocab"), | ||||||
|  |       "privateKey" => TermDefinition.new("privateKey", id: "https://w3id.org/security#privateKey", type_mapping: "@id"), | ||||||
|  |       "privateKeyPem" => TermDefinition.new("privateKeyPem", id: "https://w3id.org/security#privateKeyPem", simple: true), | ||||||
|  |       "ps" => TermDefinition.new("ps", id: "https://w3id.org/payswarm#", simple: true, prefix: true), | ||||||
|  |       "publicKey" => TermDefinition.new("publicKey", id: "https://w3id.org/security#publicKey", type_mapping: "@id"), | ||||||
|  |       "publicKeyPem" => TermDefinition.new("publicKeyPem", id: "https://w3id.org/security#publicKeyPem", simple: true), | ||||||
|  |       "publicKeyService" => TermDefinition.new("publicKeyService", id: "https://w3id.org/security#publicKeyService", type_mapping: "@id"), | ||||||
|  |       "rdf" => TermDefinition.new("rdf", id: "http://www.w3.org/1999/02/22-rdf-syntax-ns#", simple: true, prefix: true), | ||||||
|  |       "rdfs" => TermDefinition.new("rdfs", id: "http://www.w3.org/2000/01/rdf-schema#", simple: true, prefix: true), | ||||||
|  |       "recipient" => TermDefinition.new("recipient", id: "https://w3id.org/credentials#recipient", type_mapping: "@id"), | ||||||
|  |       "revoked" => TermDefinition.new("revoked", id: "https://w3id.org/security#revoked", type_mapping: "http://www.w3.org/2001/XMLSchema#dateTime"), | ||||||
|  |       "schema" => TermDefinition.new("schema", id: "http://schema.org/", simple: true, prefix: true), | ||||||
|  |       "sec" => TermDefinition.new("sec", id: "https://w3id.org/security#", simple: true, prefix: true), | ||||||
|  |       "signature" => TermDefinition.new("signature", id: "https://w3id.org/security#signature", simple: true), | ||||||
|  |       "signatureAlgorithm" => TermDefinition.new("signatureAlgorithm", id: "https://w3id.org/security#signatureAlgorithm", simple: true), | ||||||
|  |       "signatureValue" => TermDefinition.new("signatureValue", id: "https://w3id.org/security#signatureValue", simple: true), | ||||||
|  |       "streetAddress" => TermDefinition.new("streetAddress", id: "http://schema.org/streetAddress", simple: true), | ||||||
|  |       "title" => TermDefinition.new("title", id: "http://purl.org/dc/terms/title", simple: true), | ||||||
|  |       "type" => TermDefinition.new("type", id: "@type", simple: true), | ||||||
|  |       "url" => TermDefinition.new("url", id: "http://schema.org/url", type_mapping: "@id"), | ||||||
|  |       "writePermission" => TermDefinition.new("writePermission", id: "https://w3id.org/permissions#writePermission", type_mapping: "@id"), | ||||||
|  |       "xsd" => TermDefinition.new("xsd", id: "http://www.w3.org/2001/XMLSchema#", simple: true, prefix: true) | ||||||
|  |     }) | ||||||
|  |   end | ||||||
|  |   alias_preloaded("https://w3id.org/identity/v1", "http://w3id.org/identity/v1") | ||||||
|  | end | ||||||
|  | @ -211,7 +211,6 @@ module Mastodon | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     option :concurrency, type: :numeric, default: 5, aliases: [:c] |     option :concurrency, type: :numeric, default: 5, aliases: [:c] | ||||||
|     option :verbose, type: :boolean, aliases: [:v] |  | ||||||
|     option :dry_run, type: :boolean |     option :dry_run, type: :boolean | ||||||
|     desc 'cull', 'Remove remote accounts that no longer exist' |     desc 'cull', 'Remove remote accounts that no longer exist' | ||||||
|     long_desc <<-LONG_DESC |     long_desc <<-LONG_DESC | ||||||
|  |  | ||||||
|  | @ -15,7 +15,12 @@ module Mastodon | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def parallelize_with_progress(scope) |     def parallelize_with_progress(scope) | ||||||
|       ActiveRecord::Base.configurations[Rails.env]['pool'] = options[:concurrency] |       if options[:concurrency] < 1 | ||||||
|  |         say('Cannot run with this concurrency setting, must be at least 1', :red) | ||||||
|  |         exit(1) | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       ActiveRecord::Base.configurations[Rails.env]['pool'] = options[:concurrency] + 1 | ||||||
| 
 | 
 | ||||||
|       progress  = create_progress_bar(scope.count) |       progress  = create_progress_bar(scope.count) | ||||||
|       pool      = Concurrent::FixedThreadPool.new(options[:concurrency]) |       pool      = Concurrent::FixedThreadPool.new(options[:concurrency]) | ||||||
|  | @ -27,17 +32,26 @@ module Mastodon | ||||||
| 
 | 
 | ||||||
|         items.each do |item| |         items.each do |item| | ||||||
|           futures << Concurrent::Future.execute(executor: pool) do |           futures << Concurrent::Future.execute(executor: pool) do | ||||||
|             ActiveRecord::Base.connection_pool.with_connection do |             begin | ||||||
|               begin |               if !progress.total.nil? && progress.progress + 1 > progress.total | ||||||
|                 progress.log("Processing #{item.id}") if options[:verbose] |                 # The number of items has changed between start and now, | ||||||
|  |                 # since there is no good way to predict the final count from | ||||||
|  |                 # here, just change the progress bar to an indeterminate one | ||||||
| 
 | 
 | ||||||
|                 result = yield(item) |                 progress.total = nil | ||||||
|                 aggregate.increment(result) if result.is_a?(Integer) |  | ||||||
|               rescue => e |  | ||||||
|                 progress.log pastel.red("Error processing #{item.id}: #{e}") |  | ||||||
|               ensure |  | ||||||
|                 progress.increment |  | ||||||
|               end |               end | ||||||
|  | 
 | ||||||
|  |               progress.log("Processing #{item.id}") if options[:verbose] | ||||||
|  | 
 | ||||||
|  |               result = ActiveRecord::Base.connection_pool.with_connection do | ||||||
|  |                 yield(item) | ||||||
|  |               end | ||||||
|  | 
 | ||||||
|  |               aggregate.increment(result) if result.is_a?(Integer) | ||||||
|  |             rescue => e | ||||||
|  |               progress.log pastel.red("Error processing #{item.id}: #{e}") | ||||||
|  |             ensure | ||||||
|  |               progress.increment | ||||||
|             end |             end | ||||||
|           end |           end | ||||||
|         end |         end | ||||||
|  | @ -46,7 +60,7 @@ module Mastodon | ||||||
|         futures.map(&:value) |         futures.map(&:value) | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|       progress.finish |       progress.stop | ||||||
| 
 | 
 | ||||||
|       [total.value, aggregate.value] |       [total.value, aggregate.value] | ||||||
|     end |     end | ||||||
|  |  | ||||||
|  | @ -27,7 +27,6 @@ module Mastodon | ||||||
|       dry_run = options[:dry_run] ? '(DRY RUN)' : '' |       dry_run = options[:dry_run] ? '(DRY RUN)' : '' | ||||||
| 
 | 
 | ||||||
|       if options[:all] || username.nil? |       if options[:all] || username.nil? | ||||||
| 
 |  | ||||||
|         processed, = parallelize_with_progress(Account.joins(:user).merge(User.active)) do |account| |         processed, = parallelize_with_progress(Account.joins(:user).merge(User.active)) do |account| | ||||||
|           PrecomputeFeedService.new.call(account) unless options[:dry_run] |           PrecomputeFeedService.new.call(account) unless options[:dry_run] | ||||||
|         end |         end | ||||||
|  |  | ||||||
|  | @ -50,6 +50,7 @@ module Mastodon | ||||||
|     option :concurrency, type: :numeric, default: 5, aliases: [:c] |     option :concurrency, type: :numeric, default: 5, aliases: [:c] | ||||||
|     option :verbose, type: :boolean, default: false, aliases: [:v] |     option :verbose, type: :boolean, default: false, aliases: [:v] | ||||||
|     option :dry_run, type: :boolean, default: false |     option :dry_run, type: :boolean, default: false | ||||||
|  |     option :force, type: :boolean, default: false | ||||||
|     desc 'refresh', 'Fetch remote media files' |     desc 'refresh', 'Fetch remote media files' | ||||||
|     long_desc <<-DESC |     long_desc <<-DESC | ||||||
|       Re-downloads media attachments from other servers. You must specify the |       Re-downloads media attachments from other servers. You must specify the | ||||||
|  | @ -62,6 +63,9 @@ module Mastodon | ||||||
|       using username@domain handle of the account. |       using username@domain handle of the account. | ||||||
| 
 | 
 | ||||||
|       Use the --domain option to download attachments from a specific domain. |       Use the --domain option to download attachments from a specific domain. | ||||||
|  | 
 | ||||||
|  |       By default, attachments that are believed to be already downloaded will | ||||||
|  |       not be re-downloaded. To force re-download of every URL, use --force. | ||||||
|     DESC |     DESC | ||||||
|     def refresh |     def refresh | ||||||
|       dry_run = options[:dry_run] ? ' (DRY RUN)' : '' |       dry_run = options[:dry_run] ? ' (DRY RUN)' : '' | ||||||
|  | @ -85,7 +89,7 @@ module Mastodon | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|       processed, aggregate = parallelize_with_progress(scope) do |media_attachment| |       processed, aggregate = parallelize_with_progress(scope) do |media_attachment| | ||||||
|         next if media_attachment.remote_url.blank? |         next if media_attachment.remote_url.blank? || (!options[:force] && media_attachment.file_file_name.present?) | ||||||
| 
 | 
 | ||||||
|         unless options[:dry_run] |         unless options[:dry_run] | ||||||
|           media_attachment.reset_file! |           media_attachment.reset_file! | ||||||
|  | @ -97,5 +101,17 @@ module Mastodon | ||||||
| 
 | 
 | ||||||
|       say("Downloaded #{processed} media attachments (approx. #{number_to_human_size(aggregate)})#{dry_run}", :green, true) |       say("Downloaded #{processed} media attachments (approx. #{number_to_human_size(aggregate)})#{dry_run}", :green, true) | ||||||
|     end |     end | ||||||
|  | 
 | ||||||
|  |     desc 'usage', 'Calculate disk space consumed by Mastodon' | ||||||
|  |     def usage | ||||||
|  |       say("Attachments:\t#{number_to_human_size(MediaAttachment.sum(:file_file_size))} (#{number_to_human_size(MediaAttachment.where(account: Account.local).sum(:file_file_size))} local)") | ||||||
|  |       say("Custom emoji:\t#{number_to_human_size(CustomEmoji.sum(:image_file_size))} (#{number_to_human_size(CustomEmoji.local.sum(:image_file_size))} local)") | ||||||
|  |       say("Preview cards:\t#{number_to_human_size(PreviewCard.sum(:image_file_size))}") | ||||||
|  |       say("Avatars:\t#{number_to_human_size(Account.sum(:avatar_file_size))} (#{number_to_human_size(Account.local.sum(:avatar_file_size))} local)") | ||||||
|  |       say("Headers:\t#{number_to_human_size(Account.sum(:header_file_size))} (#{number_to_human_size(Account.local.sum(:header_file_size))} local)") | ||||||
|  |       say("Backups:\t#{number_to_human_size(Backup.sum(:dump_file_size))}") | ||||||
|  |       say("Imports:\t#{number_to_human_size(Import.sum(:data_file_size))}") | ||||||
|  |       say("Settings:\t#{number_to_human_size(SiteUpload.sum(:file_file_size))}") | ||||||
|  |     end | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -13,7 +13,7 @@ module Mastodon | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def patch |     def patch | ||||||
|       0 |       1 | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def flags |     def flags | ||||||
|  |  | ||||||
|  | @ -160,7 +160,7 @@ | ||||||
|     "stringz": "^2.0.0", |     "stringz": "^2.0.0", | ||||||
|     "substring-trie": "^1.0.2", |     "substring-trie": "^1.0.2", | ||||||
|     "terser-webpack-plugin": "^1.4.1", |     "terser-webpack-plugin": "^1.4.1", | ||||||
|     "tesseract.js": "^2.0.0-alpha.15", |     "tesseract.js": "^2.0.0-alpha.16", | ||||||
|     "throng": "^4.0.0", |     "throng": "^4.0.0", | ||||||
|     "tiny-queue": "^0.2.1", |     "tiny-queue": "^0.2.1", | ||||||
|     "uuid": "^3.1.0", |     "uuid": "^3.1.0", | ||||||
|  | @ -177,7 +177,7 @@ | ||||||
|     "babel-jest": "^24.9.0", |     "babel-jest": "^24.9.0", | ||||||
|     "enzyme": "^3.10.0", |     "enzyme": "^3.10.0", | ||||||
|     "enzyme-adapter-react-16": "^1.14.0", |     "enzyme-adapter-react-16": "^1.14.0", | ||||||
|     "eslint": "^6.4.0", |     "eslint": "^6.5.0", | ||||||
|     "eslint-plugin-import": "~2.18.2", |     "eslint-plugin-import": "~2.18.2", | ||||||
|     "eslint-plugin-jsx-a11y": "~6.2.3", |     "eslint-plugin-jsx-a11y": "~6.2.3", | ||||||
|     "eslint-plugin-promise": "~4.2.1", |     "eslint-plugin-promise": "~4.2.1", | ||||||
|  |  | ||||||
|  | @ -181,10 +181,6 @@ RSpec.describe SpamCheck do | ||||||
|       described_class.new(status2).flag! |       described_class.new(status2).flag! | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     it 'silences the account' do |  | ||||||
|       expect(sender.silenced?).to be true |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     it 'creates a report about the account' do |     it 'creates a report about the account' do | ||||||
|       expect(sender.targeted_reports.unresolved.count).to eq 1 |       expect(sender.targeted_reports.unresolved.count).to eq 1 | ||||||
|     end |     end | ||||||
|  |  | ||||||
|  | @ -126,8 +126,8 @@ RSpec.describe Account, type: :model do | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|       it 'sets default avatar, header, avatar_remote_url, and header_remote_url' do |       it 'sets default avatar, header, avatar_remote_url, and header_remote_url' do | ||||||
|         expect(account.avatar_remote_url).to eq '' |         expect(account.avatar_remote_url).to eq 'https://remote.test/invalid_avatar' | ||||||
|         expect(account.header_remote_url).to eq '' |         expect(account.header_remote_url).to eq expectation.header_remote_url | ||||||
|         expect(account.avatar_file_name).to  eq nil |         expect(account.avatar_file_name).to  eq nil | ||||||
|         expect(account.header_file_name).to  eq nil |         expect(account.header_file_name).to  eq nil | ||||||
|       end |       end | ||||||
|  |  | ||||||
|  | @ -18,6 +18,8 @@ RSpec.describe Remotable do | ||||||
| 
 | 
 | ||||||
|     def hoge=(arg); end |     def hoge=(arg); end | ||||||
| 
 | 
 | ||||||
|  |     def hoge_file_name; end | ||||||
|  | 
 | ||||||
|     def hoge_file_name=(arg); end |     def hoge_file_name=(arg); end | ||||||
| 
 | 
 | ||||||
|     def has_attribute?(arg); end |     def has_attribute?(arg); end | ||||||
|  | @ -109,12 +111,21 @@ RSpec.describe Remotable do | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|       context 'foo[attribute_name] == url' do |       context 'foo[attribute_name] == url' do | ||||||
|         it 'makes no request' do |         it 'makes no request if file is saved' do | ||||||
|           allow(foo).to receive(:[]).with(attribute_name).and_return(url) |           allow(foo).to receive(:[]).with(attribute_name).and_return(url) | ||||||
|  |           allow(foo).to receive(:hoge_file_name).and_return('foo.jpg') | ||||||
| 
 | 
 | ||||||
|           foo.hoge_remote_url = url |           foo.hoge_remote_url = url | ||||||
|           expect(request).not_to have_been_requested |           expect(request).not_to have_been_requested | ||||||
|         end |         end | ||||||
|  | 
 | ||||||
|  |         it 'makes request if file is not saved' do | ||||||
|  |           allow(foo).to receive(:[]).with(attribute_name).and_return(url) | ||||||
|  |           allow(foo).to receive(:hoge_file_name).and_return(nil) | ||||||
|  | 
 | ||||||
|  |           foo.hoge_remote_url = url | ||||||
|  |           expect(request).to have_been_requested | ||||||
|  |         end | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|       context "scheme is https, parsed_url.host isn't empty, and foo[attribute_name] != url" do |       context "scheme is https, parsed_url.host isn't empty, and foo[attribute_name] != url" do | ||||||
|  |  | ||||||
|  | @ -34,11 +34,10 @@ RSpec.describe HomeFeed, type: :model do | ||||||
|         Redis.current.set("account:#{account.id}:regeneration", true) |         Redis.current.set("account:#{account.id}:regeneration", true) | ||||||
|       end |       end | ||||||
| 
 | 
 | ||||||
|       it 'gets statuses with ids in the range from database' do |       it 'returns nothing' do | ||||||
|         results = subject.get(3) |         results = subject.get(3) | ||||||
| 
 | 
 | ||||||
|         expect(results.map(&:id)).to eq [10, 3, 2] |         expect(results.map(&:id)).to eq [] | ||||||
|         expect(results.first.attributes.keys).to include('id', 'updated_at') |  | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  | @ -333,49 +333,6 @@ RSpec.describe Status, type: :model do | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   describe '.as_home_timeline' do |  | ||||||
|     let(:account) { Fabricate(:account) } |  | ||||||
|     let(:followed) { Fabricate(:account) } |  | ||||||
|     let(:not_followed) { Fabricate(:account) } |  | ||||||
| 
 |  | ||||||
|     before do |  | ||||||
|       Fabricate(:follow, account: account, target_account: followed) |  | ||||||
| 
 |  | ||||||
|       @self_status = Fabricate(:status, account: account, visibility: :public) |  | ||||||
|       @self_direct_status = Fabricate(:status, account: account, visibility: :direct) |  | ||||||
|       @followed_status = Fabricate(:status, account: followed, visibility: :public) |  | ||||||
|       @followed_direct_status = Fabricate(:status, account: followed, visibility: :direct) |  | ||||||
|       @not_followed_status = Fabricate(:status, account: not_followed, visibility: :public) |  | ||||||
| 
 |  | ||||||
|       @results = Status.as_home_timeline(account) |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     it 'includes statuses from self' do |  | ||||||
|       expect(@results).to include(@self_status) |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     it 'does not include direct statuses from self' do |  | ||||||
|       expect(@results).to_not include(@self_direct_status) |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     it 'includes statuses from followed' do |  | ||||||
|       expect(@results).to include(@followed_status) |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     it 'does not include direct statuses mentioning recipient from followed' do |  | ||||||
|       Fabricate(:mention, account: account, status: @followed_direct_status) |  | ||||||
|       expect(@results).to_not include(@followed_direct_status) |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     it 'does not include direct statuses not mentioning recipient from followed' do |  | ||||||
|       expect(@results).not_to include(@followed_direct_status) |  | ||||||
|     end |  | ||||||
| 
 |  | ||||||
|     it 'does not include statuses from non-followed' do |  | ||||||
|       expect(@results).not_to include(@not_followed_status) |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   describe '.as_direct_timeline' do |   describe '.as_direct_timeline' do | ||||||
|     let(:account) { Fabricate(:account) } |     let(:account) { Fabricate(:account) } | ||||||
|     let(:followed) { Fabricate(:account) } |     let(:followed) { Fabricate(:account) } | ||||||
|  |  | ||||||
							
								
								
									
										52
									
								
								yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										52
									
								
								yarn.lock
									
									
									
									
									
								
							|  | @ -3921,10 +3921,10 @@ eslint@^2.7.0: | ||||||
|     text-table "~0.2.0" |     text-table "~0.2.0" | ||||||
|     user-home "^2.0.0" |     user-home "^2.0.0" | ||||||
| 
 | 
 | ||||||
| eslint@^6.4.0: | eslint@^6.5.0: | ||||||
|   version "6.4.0" |   version "6.5.0" | ||||||
|   resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.4.0.tgz#5aa9227c3fbe921982b2eda94ba0d7fae858611a" |   resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.5.0.tgz#304623eec903969dd5c9f2d61c6ce3d6ecec8750" | ||||||
|   integrity sha512-WTVEzK3lSFoXUovDHEbkJqCVPEPwbhCq4trDktNI6ygs7aO41d4cDT0JFAT5MivzZeVLWlg7vHL+bgrQv/t3vA== |   integrity sha512-IIbSW+vKOqMatPmS9ayyku4tvWxHY2iricSRtOz6+ZA5IPRlgXzEL0u/j6dr4eha0ugmhMwDTqxtmNu3kj9O4w== | ||||||
|   dependencies: |   dependencies: | ||||||
|     "@babel/code-frame" "^7.0.0" |     "@babel/code-frame" "^7.0.0" | ||||||
|     ajv "^6.10.0" |     ajv "^6.10.0" | ||||||
|  | @ -4005,12 +4005,7 @@ esrecurse@^4.1.0: | ||||||
|   dependencies: |   dependencies: | ||||||
|     estraverse "^4.1.0" |     estraverse "^4.1.0" | ||||||
| 
 | 
 | ||||||
| estraverse@^4.0.0, estraverse@^4.2.0: | estraverse@^4.0.0, estraverse@^4.1.0, estraverse@^4.1.1, estraverse@^4.2.0: | ||||||
|   version "4.2.0" |  | ||||||
|   resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.2.0.tgz#0dee3fed31fcd469618ce7342099fc1afa0bdb13" |  | ||||||
|   integrity sha1-De4/7TH81GlhjOc0IJn8GvoL2xM= |  | ||||||
| 
 |  | ||||||
| estraverse@^4.1.0, estraverse@^4.1.1: |  | ||||||
|   version "4.3.0" |   version "4.3.0" | ||||||
|   resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" |   resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" | ||||||
|   integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== |   integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== | ||||||
|  | @ -7009,11 +7004,6 @@ node-fetch@^1.0.1: | ||||||
|     encoding "^0.1.11" |     encoding "^0.1.11" | ||||||
|     is-stream "^1.0.1" |     is-stream "^1.0.1" | ||||||
| 
 | 
 | ||||||
| node-fetch@^2.3.0: |  | ||||||
|   version "2.6.0" |  | ||||||
|   resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd" |  | ||||||
|   integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA== |  | ||||||
| 
 |  | ||||||
| node-forge@0.8.2: | node-forge@0.8.2: | ||||||
|   version "0.8.2" |   version "0.8.2" | ||||||
|   resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.8.2.tgz#b4bcc59fb12ce77a8825fc6a783dfe3182499c5a" |   resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.8.2.tgz#b4bcc59fb12ce77a8825fc6a783dfe3182499c5a" | ||||||
|  | @ -9398,21 +9388,16 @@ selfsigned@^1.10.6: | ||||||
|   dependencies: |   dependencies: | ||||||
|     node-forge "0.8.2" |     node-forge "0.8.2" | ||||||
| 
 | 
 | ||||||
| "semver@2 || 3 || 4 || 5", semver@^5.4.1, semver@^5.5.1, semver@^5.7.0: | "semver@2 || 3 || 4 || 5", semver@^5.3.0, semver@^5.4.1, semver@^5.5.0, semver@^5.5.1, semver@^5.6.0, semver@^5.7.0: | ||||||
|   version "5.7.0" |   version "5.7.1" | ||||||
|   resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.0.tgz#790a7cf6fea5459bac96110b29b60412dc8ff96b" |   resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" | ||||||
|   integrity sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA== |   integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== | ||||||
| 
 | 
 | ||||||
| semver@4.3.2: | semver@4.3.2: | ||||||
|   version "4.3.2" |   version "4.3.2" | ||||||
|   resolved "https://registry.yarnpkg.com/semver/-/semver-4.3.2.tgz#c7a07158a80bedd052355b770d82d6640f803be7" |   resolved "https://registry.yarnpkg.com/semver/-/semver-4.3.2.tgz#c7a07158a80bedd052355b770d82d6640f803be7" | ||||||
|   integrity sha1-x6BxWKgL7dBSNVt3DYLWZA+AO+c= |   integrity sha1-x6BxWKgL7dBSNVt3DYLWZA+AO+c= | ||||||
| 
 | 
 | ||||||
| semver@^5.3.0, semver@^5.5.0, semver@^5.6.0: |  | ||||||
|   version "5.7.1" |  | ||||||
|   resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" |  | ||||||
|   integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ== |  | ||||||
| 
 |  | ||||||
| semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0: | semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0: | ||||||
|   version "6.3.0" |   version "6.3.0" | ||||||
|   resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" |   resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" | ||||||
|  | @ -10103,10 +10088,10 @@ terser@^4.1.2: | ||||||
|     source-map "~0.6.1" |     source-map "~0.6.1" | ||||||
|     source-map-support "~0.5.12" |     source-map-support "~0.5.12" | ||||||
| 
 | 
 | ||||||
| tesseract.js-core@^2.0.0-beta.11: | tesseract.js-core@^2.0.0-beta.12: | ||||||
|   version "2.0.0-beta.11" |   version "2.0.0-beta.13" | ||||||
|   resolved "https://registry.yarnpkg.com/tesseract.js-core/-/tesseract.js-core-2.0.0-beta.11.tgz#c35e3e689efad30138603977ad7eaaac44c7fd37" |   resolved "https://registry.yarnpkg.com/tesseract.js-core/-/tesseract.js-core-2.0.0-beta.13.tgz#a21d798e88098898a9bdd935d0553215e03274f8" | ||||||
|   integrity sha512-07haKH2JYYo0OfIJoioMS9dDiI5Hrl7+r1MqjeNAAT5WpKO0ATe4cpncC8s1kz0e3s1kaC5WOwL3YJcjbJE+hg== |   integrity sha512-GboWV/aV5h+Whito6L6Q3WCFZ2+lgxZGgjY84wSpWbTLEkkZgHsU+dz1or+3rWSABH/nuzHDco1bZRk5+f94mw== | ||||||
| 
 | 
 | ||||||
| tesseract.js-utils@^1.0.0-beta.8: | tesseract.js-utils@^1.0.0-beta.8: | ||||||
|   version "1.0.0-beta.8" |   version "1.0.0-beta.8" | ||||||
|  | @ -10120,18 +10105,17 @@ tesseract.js-utils@^1.0.0-beta.8: | ||||||
|     is-url "^1.2.4" |     is-url "^1.2.4" | ||||||
|     zlibjs "^0.3.1" |     zlibjs "^0.3.1" | ||||||
| 
 | 
 | ||||||
| tesseract.js@^2.0.0-alpha.15: | tesseract.js@^2.0.0-alpha.16: | ||||||
|   version "2.0.0-alpha.15" |   version "2.0.0-alpha.16" | ||||||
|   resolved "https://registry.yarnpkg.com/tesseract.js/-/tesseract.js-2.0.0-alpha.15.tgz#9887f4d1c10e25bb098fde7a10580c865c362fad" |   resolved "https://registry.yarnpkg.com/tesseract.js/-/tesseract.js-2.0.0-alpha.16.tgz#1e17717234a1464481abe12283f2c3ac79603d2e" | ||||||
|   integrity sha512-qM1XUFVlTO+tx6oVRpd9QQ8PwQLxo3qhbfIHByUlUVIqWx6y/U9xlHIaG033/Tjfs2EQ0NAehPTOJ+eNElsXEg== |   integrity sha512-8g3je2Kl8rkAFtpmwilGGj+8rCiPClNQaCjW6IafOPNn7hzFnVdL6fU6rG1Xsrc4Twv0HOa75kbpx5u70/WbTA== | ||||||
|   dependencies: |   dependencies: | ||||||
|     axios "^0.18.0" |     axios "^0.18.0" | ||||||
|     check-types "^7.4.0" |     check-types "^7.4.0" | ||||||
|     is-url "1.2.2" |     is-url "1.2.2" | ||||||
|     node-fetch "^2.3.0" |  | ||||||
|     opencollective-postinstall "^2.0.2" |     opencollective-postinstall "^2.0.2" | ||||||
|     resolve-url "^0.2.1" |     resolve-url "^0.2.1" | ||||||
|     tesseract.js-core "^2.0.0-beta.11" |     tesseract.js-core "^2.0.0-beta.12" | ||||||
|     tesseract.js-utils "^1.0.0-beta.8" |     tesseract.js-utils "^1.0.0-beta.8" | ||||||
| 
 | 
 | ||||||
| test-exclude@^5.0.0: | test-exclude@^5.0.0: | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue