Browse Source

Merge branch 'master' into glitch-soc/merge-upstream

Conflicts:
- app/models/media_attachment.rb
master
Thibaut Girka 1 month ago
parent
commit
33c80e0783
67 changed files with 898 additions and 290 deletions
  1. 39
    0
      CHANGELOG.md
  2. 5
    4
      Gemfile
  3. 20
    19
      Gemfile.lock
  4. 17
    5
      app/controllers/admin/domain_blocks_controller.rb
  5. 6
    0
      app/controllers/api/base_controller.rb
  6. 2
    0
      app/controllers/api/v1/custom_emojis_controller.rb
  7. 1
    0
      app/controllers/api/v1/instances/activity_controller.rb
  8. 1
    0
      app/controllers/api/v1/instances/peers_controller.rb
  9. 1
    0
      app/controllers/api/v1/instances_controller.rb
  10. 2
    1
      app/controllers/auth/registrations_controller.rb
  11. 1
    1
      app/controllers/settings/notifications_controller.rb
  12. 3
    1
      app/javascript/mastodon/actions/compose.js
  13. 1
    1
      app/javascript/mastodon/actions/timelines.js
  14. 69
    27
      app/javascript/mastodon/components/media_gallery.js
  15. 2
    1
      app/javascript/mastodon/components/status.js
  16. 10
    4
      app/javascript/mastodon/components/status_list.js
  17. 117
    37
      app/javascript/mastodon/features/account_gallery/components/media_item.js
  18. 45
    28
      app/javascript/mastodon/features/account_gallery/index.js
  19. 0
    2
      app/javascript/mastodon/features/compose/components/compose_form.js
  20. 3
    0
      app/javascript/mastodon/features/compose/components/upload_form.js
  21. 9
    31
      app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
  22. 10
    4
      app/javascript/mastodon/features/direct_timeline/components/conversations_list.js
  23. 10
    4
      app/javascript/mastodon/features/notifications/index.js
  24. 1
    0
      app/javascript/mastodon/features/report/components/status_check_box.js
  25. 2
    4
      app/javascript/mastodon/features/status/components/detailed_status.js
  26. 14
    8
      app/javascript/mastodon/features/status/index.js
  27. 27
    5
      app/javascript/mastodon/features/ui/components/media_modal.js
  28. 43
    2
      app/javascript/mastodon/features/ui/components/video_modal.js
  29. 7
    2
      app/javascript/mastodon/features/ui/index.js
  30. 49
    12
      app/javascript/mastodon/features/video/index.js
  31. 2
    2
      app/javascript/mastodon/locales/hy.json
  32. 120
    61
      app/javascript/styles/mastodon/components.scss
  33. 11
    0
      app/javascript/styles/mastodon/forms.scss
  34. 6
    1
      app/lib/activitypub/activity/create.rb
  35. 1
    0
      app/lib/activitypub/adapter.rb
  36. 1
    0
      app/models/concerns/ldap_authenticable.rb
  37. 1
    0
      app/models/concerns/omniauthable.rb
  38. 1
    0
      app/models/concerns/pam_authenticable.rb
  39. 7
    0
      app/models/domain_block.rb
  40. 12
    3
      app/models/media_attachment.rb
  41. 8
    3
      app/models/user.rb
  42. 2
    2
      app/serializers/activitypub/note_serializer.rb
  43. 1
    1
      app/serializers/rest/media_attachment_serializer.rb
  44. 1
    0
      app/services/block_service.rb
  45. 4
    1
      app/validators/blacklisted_email_validator.rb
  46. 1
    1
      app/views/stream_entries/_detailed_status.html.haml
  47. 1
    1
      app/views/stream_entries/_simple_status.html.haml
  48. 2
    0
      app/workers/activitypub/processing_worker.rb
  49. 3
    1
      config/initializers/rack_attack_logging.rb
  50. 1
    0
      config/locales/co.yml
  51. 1
    0
      config/locales/en.yml
  52. 3
    3
      config/locales/fr.yml
  53. 4
    3
      config/locales/sk.yml
  54. 5
    0
      db/migrate/20190420025523_add_blurhash_to_media_attachments.rb
  55. 2
    1
      db/schema.rb
  56. 4
    0
      lib/cli.rb
  57. 6
    1
      lib/mastodon/accounts_cli.rb
  58. 19
    0
      lib/mastodon/cache_cli.rb
  59. 1
    1
      lib/mastodon/version.rb
  60. 16
    0
      lib/paperclip/blurhash_transcoder.rb
  61. 1
    0
      package.json
  62. 1
    0
      public/robots.txt
  63. 12
    1
      spec/controllers/admin/domain_blocks_controller_spec.rb
  64. 83
    0
      spec/controllers/auth/registrations_controller_spec.rb
  65. 31
    0
      spec/models/domain_block_spec.rb
  66. 1
    0
      spec/validators/blacklisted_email_validator_spec.rb
  67. 5
    0
      yarn.lock

+ 39
- 0
CHANGELOG.md View File

@@ -3,6 +3,45 @@ Changelog
3 3
 
4 4
 All notable changes to this project will be documented in this file.
5 5
 
6
+## [2.8.1] - 2019-05-04
7
+### Added
8
+
9
+- Add link to existing domain block when trying to block an already-blocked domain ([ThibG](https://github.com/tootsuite/mastodon/pull/10663))
10
+- Add button to view context to media modal when opened from account gallery in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10676))
11
+- Add ability to create multiple-choice polls in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10603))
12
+- Add `GITHUB_REPOSITORY` and `SOURCE_BASE_URL` environment variables ([rosylilly](https://github.com/tootsuite/mastodon/pull/10600))
13
+- Add `/interact/` paths to `robots.txt` ([ThibG](https://github.com/tootsuite/mastodon/pull/10666))
14
+- Add `blurhash` to the Attachment entity in the REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/10630))
15
+
16
+### Changed
17
+
18
+- Change hidden media to be shown as a blurhash-based colorful gradient instead of a black box in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10630))
19
+- Change rejected media to be shown as a blurhash-based gradient instead of a list of filenames in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10630))
20
+- Change e-mail whitelist/blacklist to not be checked when invited ([Gargron](https://github.com/tootsuite/mastodon/pull/10683))
21
+- Change cache header of REST API results to no-cache ([ThibG](https://github.com/tootsuite/mastodon/pull/10655))
22
+- Change the "mark media as sensitive" button to be more obvious in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10673), [Gargron](https://github.com/tootsuite/mastodon/pull/10682))
23
+- Change account gallery in web UI to display 3 columns, open media modal ([Gargron](https://github.com/tootsuite/mastodon/pull/10667), [Gargron](https://github.com/tootsuite/mastodon/pull/10674))
24
+
25
+### Fixed
26
+
27
+- Fix LDAP/PAM/SAML/CAS users not being pre-approved ([Gargron](https://github.com/tootsuite/mastodon/pull/10621))
28
+- Fix accounts created through tootctl not being always pre-approved ([Gargron](https://github.com/tootsuite/mastodon/pull/10684))
29
+- Fix Sidekiq retrying ActivityPub processing jobs that fail validation ([ThibG](https://github.com/tootsuite/mastodon/pull/10614))
30
+- Fix toots not being scrolled into view sometimes through keyboard selection ([ThibG](https://github.com/tootsuite/mastodon/pull/10593))
31
+- Fix expired invite links being usable to bypass approval mode ([ThibG](https://github.com/tootsuite/mastodon/pull/10657))
32
+- Fix not being able to save e-mail preference for new pending accounts ([Gargron](https://github.com/tootsuite/mastodon/pull/10622))
33
+- Fix upload progressbar when image resizing is involved ([ThibG](https://github.com/tootsuite/mastodon/pull/10632))
34
+- Fix block action not automatically cancelling pending follow request ([ThibG](https://github.com/tootsuite/mastodon/pull/10633))
35
+- Fix stoplight logging to stderr separate from Rails logger ([Gargron](https://github.com/tootsuite/mastodon/pull/10624))
36
+- Fix sign up button not saying sign up when invite is used ([Gargron](https://github.com/tootsuite/mastodon/pull/10623))
37
+- Fix health checks in Docker Compose configuration ([fabianonline](https://github.com/tootsuite/mastodon/pull/10553))
38
+- Fix modal items not being scrollable on touch devices ([kedamaDQ](https://github.com/tootsuite/mastodon/pull/10605))
39
+- Fix Keybase configuration using wrong domain when a web domain is used ([BenLubar](https://github.com/tootsuite/mastodon/pull/10565))
40
+- Fix avatar GIFs not being animated on-hover on public profiles ([hyenagirl64](https://github.com/tootsuite/mastodon/pull/10549))
41
+- Fix OpenGraph parser not understanding some valid property meta tags ([da2x](https://github.com/tootsuite/mastodon/pull/10604))
42
+- Fix wrong fonts being displayed when Roboto is installed on user's machine ([ThibG](https://github.com/tootsuite/mastodon/pull/10594))
43
+- Fix confirmation modals being too narrow for a secondary action button ([ThibG](https://github.com/tootsuite/mastodon/pull/10586))
44
+
6 45
 ## [2.8.0] - 2019-04-10
7 46
 ### Added
8 47
 

+ 5
- 4
Gemfile View File

@@ -21,6 +21,7 @@ gem 'fog-openstack', '~> 0.3', require: false
21 21
 gem 'paperclip', '~> 6.0'
22 22
 gem 'paperclip-av-transcoder', '~> 0.6'
23 23
 gem 'streamio-ffmpeg', '~> 3.0'
24
+gem 'blurhash', '~> 0.1'
24 25
 
25 26
 gem 'active_model_serializers', '~> 0.10'
26 27
 gem 'addressable', '~> 2.6'
@@ -66,7 +67,7 @@ gem 'ox', '~> 2.10'
66 67
 gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c'
67 68
 gem 'pundit', '~> 2.0'
68 69
 gem 'premailer-rails'
69
-gem 'rack-attack', '~> 5.4'
70
+gem 'rack-attack', '~> 6.0'
70 71
 gem 'rack-cors', '~> 1.0', require: 'rack/cors'
71 72
 gem 'rails-i18n', '~> 5.1'
72 73
 gem 'rails-settings-cached', '~> 0.6'
@@ -124,14 +125,14 @@ group :development do
124 125
   gem 'annotate', '~> 2.7'
125 126
   gem 'better_errors', '~> 2.5'
126 127
   gem 'binding_of_caller', '~> 0.7'
127
-  gem 'bullet', '~> 5.9'
128
+  gem 'bullet', '~> 6.0'
128 129
   gem 'letter_opener', '~> 1.7'
129 130
   gem 'letter_opener_web', '~> 1.3'
130 131
   gem 'memory_profiler'
131
-  gem 'rubocop', '~> 0.67', require: false
132
+  gem 'rubocop', '~> 0.68', require: false
132 133
   gem 'brakeman', '~> 4.5', require: false
133 134
   gem 'bundler-audit', '~> 0.6', require: false
134
-  gem 'scss_lint', '~> 0.57', require: false
135
+  gem 'scss_lint', '~> 0.58', require: false
135 136
 
136 137
   gem 'capistrano', '~> 3.11'
137 138
   gem 'capistrano-rails', '~> 1.4'

+ 20
- 19
Gemfile.lock View File

@@ -66,8 +66,8 @@ GEM
66 66
       public_suffix (>= 2.0.2, < 4.0)
67 67
     airbrussh (1.3.0)
68 68
       sshkit (>= 1.6.1, != 1.7.0)
69
-    annotate (2.7.4)
70
-      activerecord (>= 3.2, < 6.0)
69
+    annotate (2.7.5)
70
+      activerecord (>= 3.2, < 7.0)
71 71
       rake (>= 10.4, < 13.0)
72 72
     arel (9.0.0)
73 73
     ast (2.4.0)
@@ -99,12 +99,14 @@ GEM
99 99
       rack (>= 0.9.0)
100 100
     binding_of_caller (0.8.0)
101 101
       debug_inspector (>= 0.0.1)
102
-    bootsnap (1.4.3)
102
+    blurhash (0.1.2)
103
+      ffi (~> 1.10.0)
104
+    bootsnap (1.4.4)
103 105
       msgpack (~> 1.0)
104 106
     brakeman (4.5.0)
105 107
     browser (2.5.3)
106 108
     builder (3.2.3)
107
-    bullet (5.9.0)
109
+    bullet (6.0.0)
108 110
       activesupport (>= 3.0.0)
109 111
       uniform_notifier (~> 1.11)
110 112
     bundler-audit (0.6.1)
@@ -205,7 +207,7 @@ GEM
205 207
     et-orbi (1.1.6)
206 208
       tzinfo
207 209
     excon (0.62.0)
208
-    fabrication (2.20.1)
210
+    fabrication (2.20.2)
209 211
     faker (1.9.3)
210 212
       i18n (>= 0.7)
211 213
     faraday (0.15.0)
@@ -348,7 +350,7 @@ GEM
348 350
     mini_mime (1.0.1)
349 351
     mini_portile2 (2.4.0)
350 352
     minitest (5.11.3)
351
-    msgpack (1.2.9)
353
+    msgpack (1.2.10)
352 354
     multi_json (1.13.1)
353 355
     multipart-post (2.0.0)
354 356
     necromancer (0.4.0)
@@ -395,7 +397,7 @@ GEM
395 397
     parallel (1.17.0)
396 398
     parallel_tests (2.28.0)
397 399
       parallel
398
-    parser (2.6.2.1)
400
+    parser (2.6.3.0)
399 401
       ast (~> 2.4.0)
400 402
     pastel (0.7.2)
401 403
       equatable (~> 0.5.0)
@@ -420,14 +422,13 @@ GEM
420 422
       pry (~> 0.10)
421 423
     pry-rails (0.3.9)
422 424
       pry (>= 0.10.4)
423
-    psych (3.1.0)
424 425
     public_suffix (3.0.3)
425 426
     puma (3.12.1)
426 427
     pundit (2.0.1)
427 428
       activesupport (>= 3.0.0)
428 429
     raabro (1.1.6)
429 430
     rack (2.0.7)
430
-    rack-attack (5.4.2)
431
+    rack-attack (6.0.0)
431 432
       rack (>= 1.0, < 3)
432 433
     rack-cors (1.0.3)
433 434
     rack-protection (2.0.5)
@@ -472,8 +473,8 @@ GEM
472 473
     rainbow (3.0.0)
473 474
     rake (12.3.2)
474 475
     rb-fsevent (0.10.3)
475
-    rb-inotify (0.9.10)
476
-      ffi (>= 0.5.0, < 2)
476
+    rb-inotify (0.10.0)
477
+      ffi (~> 1.0)
477 478
     rdf (3.0.9)
478 479
       hamster (~> 3.0)
479 480
       link_header (~> 0.0, >= 0.0.8)
@@ -528,11 +529,10 @@ GEM
528 529
       rspec-core (~> 3.0, >= 3.0.0)
529 530
       sidekiq (>= 2.4.0)
530 531
     rspec-support (3.8.0)
531
-    rubocop (0.67.2)
532
+    rubocop (0.68.1)
532 533
       jaro_winkler (~> 1.5.1)
533 534
       parallel (~> 1.10)
534 535
       parser (>= 2.5, != 2.5.1.1)
535
-      psych (>= 3.1.0)
536 536
       rainbow (>= 2.2.2, < 4.0)
537 537
       ruby-progressbar (~> 1.7)
538 538
       unicode-display_width (>= 1.4.0, < 1.6)
@@ -546,12 +546,12 @@ GEM
546 546
       crass (~> 1.0.2)
547 547
       nokogiri (>= 1.8.0)
548 548
       nokogumbo (~> 2.0)
549
-    sass (3.6.0)
549
+    sass (3.7.4)
550 550
       sass-listen (~> 4.0.0)
551 551
     sass-listen (4.0.0)
552 552
       rb-fsevent (~> 0.9, >= 0.9.4)
553 553
       rb-inotify (~> 0.9, >= 0.9.7)
554
-    scss_lint (0.57.1)
554
+    scss_lint (0.58.0)
555 555
       rake (>= 0.9, < 13)
556 556
       sass (~> 3.5, >= 3.5.5)
557 557
     sidekiq (5.2.7)
@@ -663,10 +663,11 @@ DEPENDENCIES
663 663
   aws-sdk-s3 (~> 1.36)
664 664
   better_errors (~> 2.5)
665 665
   binding_of_caller (~> 0.7)
666
+  blurhash (~> 0.1)
666 667
   bootsnap (~> 1.4)
667 668
   brakeman (~> 4.5)
668 669
   browser
669
-  bullet (~> 5.9)
670
+  bullet (~> 6.0)
670 671
   bundler-audit (~> 0.6)
671 672
   capistrano (~> 3.11)
672 673
   capistrano-rails (~> 1.4)
@@ -737,7 +738,7 @@ DEPENDENCIES
737 738
   pry-rails (~> 0.3)
738 739
   puma (~> 3.12)
739 740
   pundit (~> 2.0)
740
-  rack-attack (~> 5.4)
741
+  rack-attack (~> 6.0)
741 742
   rack-cors (~> 1.0)
742 743
   rails (~> 5.2.3)
743 744
   rails-controller-testing (~> 1.0)
@@ -750,9 +751,9 @@ DEPENDENCIES
750 751
   rqrcode (~> 0.10)
751 752
   rspec-rails (~> 3.8)
752 753
   rspec-sidekiq (~> 3.0)
753
-  rubocop (~> 0.67)
754
+  rubocop (~> 0.68)
754 755
   sanitize (~> 5.0)
755
-  scss_lint (~> 0.57)
756
+  scss_lint (~> 0.58)
756 757
   sidekiq (~> 5.2)
757 758
   sidekiq-bulk (~> 0.2.0)
758 759
   sidekiq-scheduler (~> 3.0)

+ 17
- 5
app/controllers/admin/domain_blocks_controller.rb View File

@@ -13,13 +13,25 @@ module Admin
13 13
       authorize :domain_block, :create?
14 14
 
15 15
       @domain_block = DomainBlock.new(resource_params)
16
+      existing_domain_block = resource_params[:domain].present? ? DomainBlock.find_by(domain: resource_params[:domain]) : nil
16 17
 
17
-      if @domain_block.save
18
-        DomainBlockWorker.perform_async(@domain_block.id)
19
-        log_action :create, @domain_block
20
-        redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
21
-      else
18
+      if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block)
19
+        @domain_block.save
20
+        flash[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe # rubocop:disable Rails/OutputSafety
21
+        @domain_block.errors[:domain].clear
22 22
         render :new
23
+      else
24
+        if existing_domain_block.present?
25
+          @domain_block = existing_domain_block
26
+          @domain_block.update(resource_params)
27
+        end
28
+        if @domain_block.save
29
+          DomainBlockWorker.perform_async(@domain_block.id)
30
+          log_action :create, @domain_block
31
+          redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
32
+        else
33
+          render :new
34
+        end
23 35
       end
24 36
     end
25 37
 

+ 6
- 0
app/controllers/api/base_controller.rb View File

@@ -9,6 +9,8 @@ class Api::BaseController < ApplicationController
9 9
   skip_before_action :store_current_location
10 10
   skip_before_action :check_user_permissions
11 11
 
12
+  before_action :set_cache_headers
13
+
12 14
   protect_from_forgery with: :null_session
13 15
 
14 16
   rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
@@ -88,4 +90,8 @@ class Api::BaseController < ApplicationController
88 90
   def authorize_if_got_token!(*scopes)
89 91
     doorkeeper_authorize!(*scopes) if doorkeeper_token
90 92
   end
93
+
94
+  def set_cache_headers
95
+    response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate'
96
+  end
91 97
 end

+ 2
- 0
app/controllers/api/v1/custom_emojis_controller.rb View File

@@ -3,6 +3,8 @@
3 3
 class Api::V1::CustomEmojisController < Api::BaseController
4 4
   respond_to :json
5 5
 
6
+  skip_before_action :set_cache_headers
7
+
6 8
   def index
7 9
     render_cached_json('api:v1:custom_emojis', expires_in: 1.minute) do
8 10
       ActiveModelSerializers::SerializableResource.new(CustomEmoji.local.where(disabled: false), each_serializer: REST::CustomEmojiSerializer)

+ 1
- 0
app/controllers/api/v1/instances/activity_controller.rb View File

@@ -2,6 +2,7 @@
2 2
 
3 3
 class Api::V1::Instances::ActivityController < Api::BaseController
4 4
   before_action :require_enabled_api!
5
+  skip_before_action :set_cache_headers
5 6
 
6 7
   respond_to :json
7 8
 

+ 1
- 0
app/controllers/api/v1/instances/peers_controller.rb View File

@@ -2,6 +2,7 @@
2 2
 
3 3
 class Api::V1::Instances::PeersController < Api::BaseController
4 4
   before_action :require_enabled_api!
5
+  skip_before_action :set_cache_headers
5 6
 
6 7
   respond_to :json
7 8
 

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

@@ -2,6 +2,7 @@
2 2
 
3 3
 class Api::V1::InstancesController < Api::BaseController
4 4
   respond_to :json
5
+  skip_before_action :set_cache_headers
5 6
 
6 7
   def show
7 8
     render_cached_json('api:v1:instances', expires_in: 5.minutes) do

+ 2
- 1
app/controllers/auth/registrations_controller.rb View File

@@ -96,7 +96,8 @@ class Auth::RegistrationsController < Devise::RegistrationsController
96 96
   end
97 97
 
98 98
   def set_invite
99
-    @invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil
99
+    invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil
100
+    @invite = invite&.valid_for_use? ? invite : nil
100 101
   end
101 102
 
102 103
   def determine_layout

+ 1
- 1
app/controllers/settings/notifications_controller.rb View File

@@ -21,7 +21,7 @@ class Settings::NotificationsController < Settings::BaseController
21 21
 
22 22
   def user_settings_params
23 23
     params.require(:user).permit(
24
-      notification_emails: %i(follow follow_request reblog favourite mention digest report),
24
+      notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account),
25 25
       interactions: %i(must_be_follower must_be_following must_be_following_dm)
26 26
     )
27 27
   end

+ 3
- 1
app/javascript/mastodon/actions/compose.js View File

@@ -203,8 +203,8 @@ export function uploadCompose(files) {
203 203
   return function (dispatch, getState) {
204 204
     const uploadLimit = 4;
205 205
     const media  = getState().getIn(['compose', 'media_attachments']);
206
-    const total = Array.from(files).reduce((a, v) => a + v.size, 0);
207 206
     const progress = new Array(files.length).fill(0);
207
+    let total = Array.from(files).reduce((a, v) => a + v.size, 0);
208 208
 
209 209
     if (files.length + media.size > uploadLimit) {
210 210
       dispatch(showAlert(undefined, messages.uploadErrorLimit));
@@ -224,6 +224,8 @@ export function uploadCompose(files) {
224 224
       resizeImage(f).then(file => {
225 225
         const data = new FormData();
226 226
         data.append('file', file);
227
+        // Account for disparity in size of original image and resized data
228
+        total += file.size - f.size;
227 229
 
228 230
         return api(getState).post('/api/v1/media', data, {
229 231
           onUploadProgress: function({ loaded }){

+ 1
- 1
app/javascript/mastodon/actions/timelines.js View File

@@ -96,7 +96,7 @@ export const expandPublicTimeline          = ({ maxId, onlyMedia } = {}, done =
96 96
 export const expandCommunityTimeline       = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
97 97
 export const expandAccountTimeline         = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
98 98
 export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
99
-export const expandAccountMediaTimeline    = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true });
99
+export const expandAccountMediaTimeline    = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
100 100
 export const expandListTimeline            = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
101 101
 export const expandHashtagTimeline         = (hashtag, { maxId, tags } = {}, done = noOp) => {
102 102
   return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {

+ 69
- 27
app/javascript/mastodon/components/media_gallery.js View File

@@ -7,6 +7,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
7 7
 import { isIOS } from '../is_mobile';
8 8
 import classNames from 'classnames';
9 9
 import { autoPlayGif, displayMedia } from '../initial_state';
10
+import { decode } from 'blurhash';
10 11
 
11 12
 const messages = defineMessages({
12 13
   toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
@@ -21,6 +22,7 @@ class Item extends React.PureComponent {
21 22
     size: PropTypes.number.isRequired,
22 23
     onClick: PropTypes.func.isRequired,
23 24
     displayWidth: PropTypes.number,
25
+    visible: PropTypes.bool.isRequired,
24 26
   };
25 27
 
26 28
   static defaultProps = {
@@ -29,6 +31,10 @@ class Item extends React.PureComponent {
29 31
     size: 1,
30 32
   };
31 33
 
34
+  state = {
35
+    loaded: false,
36
+  };
37
+
32 38
   handleMouseEnter = (e) => {
33 39
     if (this.hoverToPlay()) {
34 40
       e.target.play();
@@ -62,8 +68,40 @@ class Item extends React.PureComponent {
62 68
     e.stopPropagation();
63 69
   }
64 70
 
71
+  componentDidMount () {
72
+    if (this.props.attachment.get('blurhash')) {
73
+      this._decode();
74
+    }
75
+  }
76
+
77
+  componentDidUpdate (prevProps) {
78
+    if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
79
+      this._decode();
80
+    }
81
+  }
82
+
83
+  _decode () {
84
+    const hash   = this.props.attachment.get('blurhash');
85
+    const pixels = decode(hash, 32, 32);
86
+
87
+    if (pixels) {
88
+      const ctx       = this.canvas.getContext('2d');
89
+      const imageData = new ImageData(pixels, 32, 32);
90
+
91
+      ctx.putImageData(imageData, 0, 0);
92
+    }
93
+  }
94
+
95
+  setCanvasRef = c => {
96
+    this.canvas = c;
97
+  }
98
+
99
+  handleImageLoad = () => {
100
+    this.setState({ loaded: true });
101
+  }
102
+
65 103
   render () {
66
-    const { attachment, index, size, standalone, displayWidth } = this.props;
104
+    const { attachment, index, size, standalone, displayWidth, visible } = this.props;
67 105
 
68 106
     let width  = 50;
69 107
     let height = 100;
@@ -116,12 +154,20 @@ class Item extends React.PureComponent {
116 154
 
117 155
     let thumbnail = '';
118 156
 
119
-    if (attachment.get('type') === 'image') {
157
+    if (attachment.get('type') === 'unknown') {
158
+      return (
159
+        <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
160
+          <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}>
161
+            <canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
162
+          </a>
163
+        </div>
164
+      );
165
+    } else if (attachment.get('type') === 'image') {
120 166
       const previewUrl   = attachment.get('preview_url');
121 167
       const previewWidth = attachment.getIn(['meta', 'small', 'width']);
122 168
 
123
-      const originalUrl    = attachment.get('url');
124
-      const originalWidth  = attachment.getIn(['meta', 'original', 'width']);
169
+      const originalUrl   = attachment.get('url');
170
+      const originalWidth = attachment.getIn(['meta', 'original', 'width']);
125 171
 
126 172
       const hasSize = typeof originalWidth === 'number' && typeof previewWidth === 'number';
127 173
 
@@ -147,6 +193,7 @@ class Item extends React.PureComponent {
147 193
             alt={attachment.get('description')}
148 194
             title={attachment.get('description')}
149 195
             style={{ objectPosition: `${x}% ${y}%` }}
196
+            onLoad={this.handleImageLoad}
150 197
           />
151 198
         </a>
152 199
       );
@@ -176,7 +223,8 @@ class Item extends React.PureComponent {
176 223
 
177 224
     return (
178 225
       <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
179
-        {thumbnail}
226
+        <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && this.state.loaded })} />
227
+        {visible && thumbnail}
180 228
       </div>
181 229
     );
182 230
   }
@@ -225,6 +273,7 @@ class MediaGallery extends React.PureComponent {
225 273
     if (node /*&& this.isStandaloneEligible()*/) {
226 274
       // offsetWidth triggers a layout, so only calculate when we need to
227 275
       if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
276
+
228 277
       this.setState({
229 278
         width: node.offsetWidth,
230 279
       });
@@ -242,7 +291,7 @@ class MediaGallery extends React.PureComponent {
242 291
 
243 292
     const width = this.state.width || defaultWidth;
244 293
 
245
-    let children;
294
+    let children, spoilerButton;
246 295
 
247 296
     const style = {};
248 297
 
@@ -256,35 +305,28 @@ class MediaGallery extends React.PureComponent {
256 305
       style.height = height;
257 306
     }
258 307
 
259
-    if (!visible) {
260
-      let warning;
308
+    const size = media.take(4).size;
261 309
 
262
-      if (sensitive) {
263
-        warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
264
-      } else {
265
-        warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
266
-      }
310
+    if (this.isStandaloneEligible()) {
311
+      children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
312
+    } else {
313
+      children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} visible={visible} />);
314
+    }
267 315
 
268
-      children = (
269
-        <button type='button' className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}>
270
-          <span className='media-spoiler__warning'>{warning}</span>
271
-          <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
316
+    if (visible) {
317
+      spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />;
318
+    } else {
319
+      spoilerButton = (
320
+        <button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
321
+          <span className='spoiler-button__overlay__label'>{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}</span>
272 322
         </button>
273 323
       );
274
-    } else {
275
-      const size = media.take(4).size;
276
-
277
-      if (this.isStandaloneEligible()) {
278
-        children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} />;
279
-      } else {
280
-        children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} />);
281
-      }
282 324
     }
283 325
 
284 326
     return (
285 327
       <div className='media-gallery' style={style} ref={this.handleRef}>
286
-        <div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
287
-          <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
328
+        <div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}>
329
+          {spoilerButton}
288 330
         </div>
289 331
 
290 332
         {children}

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

@@ -274,7 +274,7 @@ class Status extends ImmutablePureComponent {
274 274
     if (status.get('poll')) {
275 275
       media = <PollContainer pollId={status.get('poll')} />;
276 276
     } else if (status.get('media_attachments').size > 0) {
277
-      if (this.props.muted || status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
277
+      if (this.props.muted) {
278 278
         media = (
279 279
           <AttachmentList
280 280
             compact
@@ -289,6 +289,7 @@ class Status extends ImmutablePureComponent {
289 289
             {Component => (
290 290
               <Component
291 291
                 preview={video.get('preview_url')}
292
+                blurhash={video.get('blurhash')}
292 293
                 src={video.get('url')}
293 294
                 alt={video.get('description')}
294 295
                 width={this.props.cachedMediaWidth}

+ 10
- 4
app/javascript/mastodon/components/status_list.js View File

@@ -46,22 +46,28 @@ export default class StatusList extends ImmutablePureComponent {
46 46
 
47 47
   handleMoveUp = (id, featured) => {
48 48
     const elementIndex = this.getCurrentStatusIndex(id, featured) - 1;
49
-    this._selectChild(elementIndex);
49
+    this._selectChild(elementIndex, true);
50 50
   }
51 51
 
52 52
   handleMoveDown = (id, featured) => {
53 53
     const elementIndex = this.getCurrentStatusIndex(id, featured) + 1;
54
-    this._selectChild(elementIndex);
54
+    this._selectChild(elementIndex, false);
55 55
   }
56 56
 
57 57
   handleLoadOlder = debounce(() => {
58 58
     this.props.onLoadMore(this.props.statusIds.size > 0 ? this.props.statusIds.last() : undefined);
59 59
   }, 300, { leading: true })
60 60
 
61
-  _selectChild (index) {
62
-    const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
61
+  _selectChild (index, align_top) {
62
+    const container = this.node.node;
63
+    const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
63 64
 
64 65
     if (element) {
66
+      if (align_top && container.scrollTop > element.offsetTop) {
67
+        element.scrollIntoView(true);
68
+      } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
69
+        element.scrollIntoView(false);
70
+      }
65 71
       element.focus();
66 72
     }
67 73
   }

+ 117
- 37
app/javascript/mastodon/features/account_gallery/components/media_item.js View File

@@ -1,62 +1,142 @@
1 1
 import React from 'react';
2
+import PropTypes from 'prop-types';
2 3
 import ImmutablePropTypes from 'react-immutable-proptypes';
3 4
 import ImmutablePureComponent from 'react-immutable-pure-component';
4
-import Permalink from '../../../components/permalink';
5
-import { displayMedia } from '../../../initial_state';
6
-import Icon from 'mastodon/components/icon';
5
+import { autoPlayGif, displayMedia } from 'mastodon/initial_state';
6
+import classNames from 'classnames';
7
+import { decode } from 'blurhash';
8
+import { isIOS } from 'mastodon/is_mobile';
7 9
 
8 10
 export default class MediaItem extends ImmutablePureComponent {
9 11
 
10 12
   static propTypes = {
11
-    media: ImmutablePropTypes.map.isRequired,
13
+    attachment: ImmutablePropTypes.map.isRequired,
14
+    displayWidth: PropTypes.number.isRequired,
15
+    onOpenMedia: PropTypes.func.isRequired,
12 16
   };
13 17
 
14 18
   state = {
15
-    visible: displayMedia !== 'hide_all' && !this.props.media.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
19
+    visible: displayMedia !== 'hide_all' && !this.props.attachment.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
20
+    loaded: false,
16 21
   };
17 22
 
18
-  handleClick = () => {
19
-    if (!this.state.visible) {
20
-      this.setState({ visible: true });
21
-      return true;
23
+  componentDidMount () {
24
+    if (this.props.attachment.get('blurhash')) {
25
+      this._decode();
22 26
     }
27
+  }
23 28
 
24
-    return false;
29
+  componentDidUpdate (prevProps) {
30
+    if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
31
+      this._decode();
32
+    }
25 33
   }
26 34
 
27
-  render () {
28
-    const { media } = this.props;
29
-    const { visible } = this.state;
30
-    const status = media.get('status');
31
-    const focusX = media.getIn(['meta', 'focus', 'x']);
32
-    const focusY = media.getIn(['meta', 'focus', 'y']);
33
-    const x = ((focusX /  2) + .5) * 100;
34
-    const y = ((focusY / -2) + .5) * 100;
35
-    const style = {};
36
-
37
-    let label, icon;
38
-
39
-    if (media.get('type') === 'gifv') {
40
-      label = <span className='media-gallery__gifv__label'>GIF</span>;
35
+  _decode () {
36
+    const hash   = this.props.attachment.get('blurhash');
37
+    const pixels = decode(hash, 32, 32);
38
+
39
+    if (pixels) {
40
+      const ctx       = this.canvas.getContext('2d');
41
+      const imageData = new ImageData(pixels, 32, 32);
42
+
43
+      ctx.putImageData(imageData, 0, 0);
44
+    }
45
+  }
46
+
47
+  setCanvasRef = c => {
48
+    this.canvas = c;
49
+  }
50
+
51
+  handleImageLoad = () => {
52
+    this.setState({ loaded: true });
53
+  }
54
+
55
+  handleMouseEnter = e => {
56
+    if (this.hoverToPlay()) {
57
+      e.target.play();
58
+    }
59
+  }
60
+
61
+  handleMouseLeave = e => {
62
+    if (this.hoverToPlay()) {
63
+      e.target.pause();
64
+      e.target.currentTime = 0;
41 65
     }
66
+  }
67
+
68
+  hoverToPlay () {
69
+    return !autoPlayGif && ['gifv', 'video'].indexOf(this.props.attachment.get('type')) !== -1;
70
+  }
71
+
72
+  handleClick = e => {
73
+    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
74
+      e.preventDefault();
75
+
76
+      if (this.state.visible) {
77
+        this.props.onOpenMedia(this.props.attachment);
78
+      } else {
79
+        this.setState({ visible: true });
80
+      }
81
+    }
82
+  }
83
+
84
+  render () {
85
+    const { attachment, displayWidth } = this.props;
86
+    const { visible, loaded } = this.state;
87
+
88
+    const width  = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
89
+    const height = width;
90
+    const status = attachment.get('status');
91
+
92
+    let thumbnail = '';
93
+
94
+    if (attachment.get('type') === 'unknown') {
95
+      // Skip
96
+    } else if (attachment.get('type') === 'image') {
97
+      const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
98
+      const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
99
+      const x      = ((focusX /  2) + .5) * 100;
100
+      const y      = ((focusY / -2) + .5) * 100;
101
+
102
+      thumbnail = (
103
+        <img
104
+          src={attachment.get('preview_url')}
105
+          alt={attachment.get('description')}
106
+          title={attachment.get('description')}
107
+          style={{ objectPosition: `${x}% ${y}%` }}
108
+          onLoad={this.handleImageLoad}
109
+        />
110
+      );
111
+    } else if (['gifv', 'video'].indexOf(attachment.get('type')) !== -1) {
112
+      const autoPlay = !isIOS() && autoPlayGif;
113
+
114
+      thumbnail = (
115
+        <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
116
+          <video
117
+            className='media-gallery__item-gifv-thumbnail'
118
+            aria-label={attachment.get('description')}
119
+            title={attachment.get('description')}
120
+            role='application'
121
+            src={attachment.get('url')}
122
+            onMouseEnter={this.handleMouseEnter}
123
+            onMouseLeave={this.handleMouseLeave}
124
+            autoPlay={autoPlay}
125
+            loop
126
+            muted
127
+          />
42 128
 
43
-    if (visible) {
44
-      style.backgroundImage    = `url(${media.get('preview_url')})`;
45
-      style.backgroundPosition = `${x}% ${y}%`;
46
-    } else {
47
-      icon = (
48
-        <span className='account-gallery__item__icons'>
49
-          <Icon id='eye-slash' />
50
-        </span>
129
+          <span className='media-gallery__gifv__label'>GIF</span>
130
+        </div>
51 131
       );
52 132
     }
53 133
 
54 134
     return (
55
-      <div className='account-gallery__item'>
56
-        <Permalink to={`/statuses/${status.get('id')}`} href={status.get('url')} style={style} onInterceptClick={this.handleClick}>
57
-          {icon}
58
-          {label}
59
-        </Permalink>
135
+      <div className='account-gallery__item' style={{ width, height }}>
136
+        <a className='media-gallery__item-thumbnail' href={status.get('url')} target='_blank' style={{ cursor: 'pointer' }} onClick={this.handleClick}>
137
+          <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} />
138
+          {visible && thumbnail}
139
+        </a>
60 140
       </div>
61 141
     );
62 142
   }

+ 45
- 28
app/javascript/mastodon/features/account_gallery/index.js View File

@@ -2,24 +2,25 @@ import React from 'react';
2 2
 import { connect } from 'react-redux';
3 3
 import ImmutablePropTypes from 'react-immutable-proptypes';
4 4
 import PropTypes from 'prop-types';
5
-import { fetchAccount } from '../../actions/accounts';
5
+import { fetchAccount } from 'mastodon/actions/accounts';
6 6
 import { expandAccountMediaTimeline } from '../../actions/timelines';
7
-import LoadingIndicator from '../../components/loading_indicator';
7
+import LoadingIndicator from 'mastodon/components/loading_indicator';
8 8
 import Column from '../ui/components/column';
9
-import ColumnBackButton from '../../components/column_back_button';
9
+import ColumnBackButton from 'mastodon/components/column_back_button';
10 10
 import ImmutablePureComponent from 'react-immutable-pure-component';
11
-import { getAccountGallery } from '../../selectors';
11
+import { getAccountGallery } from 'mastodon/selectors';
12 12
 import MediaItem from './components/media_item';
13 13
 import HeaderContainer from '../account_timeline/containers/header_container';
14 14
 import { ScrollContainer } from 'react-router-scroll-4';
15
-import LoadMore from '../../components/load_more';
15
+import LoadMore from 'mastodon/components/load_more';
16 16
 import MissingIndicator from 'mastodon/components/missing_indicator';
17
+import { openModal } from 'mastodon/actions/modal';
17 18
 
18 19
 const mapStateToProps = (state, props) => ({
19 20
   isAccount: !!state.getIn(['accounts', props.params.accountId]),
20
-  medias: getAccountGallery(state, props.params.accountId),
21
+  attachments: getAccountGallery(state, props.params.accountId),
21 22
   isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
22
-  hasMore:   state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
23
+  hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
23 24
 });
24 25
 
25 26
 class LoadMoreMedia extends ImmutablePureComponent {
@@ -51,12 +52,16 @@ class AccountGallery extends ImmutablePureComponent {
51 52
   static propTypes = {
52 53
     params: PropTypes.object.isRequired,
53 54
     dispatch: PropTypes.func.isRequired,
54
-    medias: ImmutablePropTypes.list.isRequired,
55
+    attachments: ImmutablePropTypes.list.isRequired,
55 56
     isLoading: PropTypes.bool,
56 57
     hasMore: PropTypes.bool,
57 58
     isAccount: PropTypes.bool,
58 59
   };
59 60
 
61
+  state = {
62
+    width: 323,
63
+  };
64
+
60 65
   componentDidMount () {
61 66
     this.props.dispatch(fetchAccount(this.props.params.accountId));
62 67
     this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
@@ -71,11 +76,11 @@ class AccountGallery extends ImmutablePureComponent {
71 76
 
72 77
   handleScrollToBottom = () => {
73 78
     if (this.props.hasMore) {
74
-      this.handleLoadMore(this.props.medias.size > 0 ? this.props.medias.last().getIn(['status', 'id']) : undefined);
79
+      this.handleLoadMore(this.props.attachments.size > 0 ? this.props.attachments.last().getIn(['status', 'id']) : undefined);
75 80
     }
76 81
   }
77 82
 
78
-  handleScroll = (e) => {
83
+  handleScroll = e => {
79 84
     const { scrollTop, scrollHeight, clientHeight } = e.target;
80 85
     const offset = scrollHeight - scrollTop - clientHeight;
81 86
 
@@ -88,13 +93,31 @@ class AccountGallery extends ImmutablePureComponent {
88 93
     this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId }));
89 94
   };
90 95
 
91
-  handleLoadOlder = (e) => {
96
+  handleLoadOlder = e => {
92 97
     e.preventDefault();
93 98
     this.handleScrollToBottom();
94 99
   }
95 100
 
101
+  handleOpenMedia = attachment => {
102
+    if (attachment.get('type') === 'video') {
103
+      this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status') }));
104
+    } else {
105
+      const media = attachment.getIn(['status', 'media_attachments']);
106
+      const index = media.findIndex(x => x.get('id') === attachment.get('id'));
107
+
108
+      this.props.dispatch(openModal('MEDIA', { media, index, status: attachment.get('status') }));
109
+    }
110
+  }
111
+
112
+  handleRef = c => {
113
+    if (c) {
114
+      this.setState({ width: c.offsetWidth });
115
+    }
116
+  }
117
+
96 118
   render () {
97
-    const { medias, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props;
119
+    const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props;
120
+    const { width } = this.state;
98 121
 
99 122
     if (!isAccount) {
100 123
       return (
@@ -104,9 +127,7 @@ class AccountGallery extends ImmutablePureComponent {
104 127
       );
105 128
     }
106 129
 
107
-    let loadOlder = null;
108
-
109
-    if (!medias && isLoading) {
130
+    if (!attachments && isLoading) {
110 131
       return (
111 132
         <Column>
112 133
           <LoadingIndicator />
@@ -114,7 +135,9 @@ class AccountGallery extends ImmutablePureComponent {
114 135
       );
115 136
     }
116 137
 
117
-    if (hasMore && !(isLoading && medias.size === 0)) {
138
+    let loadOlder = null;
139
+
140
+    if (hasMore && !(isLoading && attachments.size === 0)) {
118 141
       loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />;
119 142
     }
120 143
 
@@ -126,23 +149,17 @@ class AccountGallery extends ImmutablePureComponent {
126 149
           <div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
127 150
             <HeaderContainer accountId={this.props.params.accountId} />
128 151
 
129
-            <div role='feed' className='account-gallery__container'>
130
-              {medias.map((media, index) => media === null ? (
131
-                <LoadMoreMedia
132
-                  key={'more:' + medias.getIn(index + 1, 'id')}
133
-                  maxId={index > 0 ? medias.getIn(index - 1, 'id') : null}
134
-                  onLoadMore={this.handleLoadMore}
135
-                />
152
+            <div role='feed' className='account-gallery__container' ref={this.handleRef}>
153
+              {attachments.map((attachment, index) => attachment === null ? (
154
+                <LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
136 155
               ) : (
137
-                <MediaItem
138
-                  key={media.get('id')}
139
-                  media={media}
140
-                />
156
+                <MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} />
141 157
               ))}
158
+
142 159
               {loadOlder}
143 160
             </div>
144 161
 
145
-            {isLoading && medias.size === 0 && (
162
+            {isLoading && attachments.size === 0 && (
146 163
               <div className='scrollable__append'>
147 164
                 <LoadingIndicator />
148 165
               </div>

+ 0
- 2
app/javascript/mastodon/features/compose/components/compose_form.js View File

@@ -10,7 +10,6 @@ import UploadButtonContainer from '../containers/upload_button_container';
10 10
 import { defineMessages, injectIntl } from 'react-intl';
11 11
 import SpoilerButtonContainer from '../containers/spoiler_button_container';
12 12
 import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
13
-import SensitiveButtonContainer from '../containers/sensitive_button_container';
14 13
 import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
15 14
 import PollFormContainer from '../containers/poll_form_container';
16 15
 import UploadFormContainer from '../containers/upload_form_container';
@@ -215,7 +214,6 @@ class ComposeForm extends ImmutablePureComponent {
215 214
             <UploadButtonContainer />
216 215
             <PollButtonContainer />
217 216
             <PrivacyDropdownContainer />
218
-            <SensitiveButtonContainer />
219 217
             <SpoilerButtonContainer />
220 218
           </div>
221 219
           <div className='character-counter__wrapper'><CharacterCounter max={maxChars} text={text} /></div>

+ 3
- 0
app/javascript/mastodon/features/compose/components/upload_form.js View File

@@ -3,6 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
3 3
 import UploadProgressContainer from '../containers/upload_progress_container';
4 4
 import ImmutablePureComponent from 'react-immutable-pure-component';
5 5
 import UploadContainer from '../containers/upload_container';
6
+import SensitiveButtonContainer from '../containers/sensitive_button_container';
6 7
 
7 8
 export default class UploadForm extends ImmutablePureComponent {
8 9
 
@@ -22,6 +23,8 @@ export default class UploadForm extends ImmutablePureComponent {
22 23
             <UploadContainer id={id} key={id} />
23 24
           ))}
24 25
         </div>
26
+
27
+        {!mediaIds.isEmpty() && <SensitiveButtonContainer />}
25 28
       </div>
26 29
     );
27 30
   }

+ 9
- 31
app/javascript/mastodon/features/compose/containers/sensitive_button_container.js View File

@@ -2,11 +2,9 @@ import React from 'react';
2 2
 import { connect } from 'react-redux';
3 3
 import PropTypes from 'prop-types';
4 4
 import classNames from 'classnames';
5
-import IconButton from '../../../components/icon_button';
6
-import { changeComposeSensitivity } from '../../../actions/compose';
7
-import Motion from '../../ui/util/optional_motion';
8
-import spring from 'react-motion/lib/spring';
9
-import { injectIntl, defineMessages } from 'react-intl';
5
+import { changeComposeSensitivity } from 'mastodon/actions/compose';
6
+import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
7
+import Icon from 'mastodon/components/icon';
10 8
 
11 9
 const messages = defineMessages({
12 10
   marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' },
@@ -14,7 +12,6 @@ const messages = defineMessages({
14 12
 });
15 13
 
16 14
 const mapStateToProps = state => ({
17
-  visible: state.getIn(['compose', 'media_attachments']).size > 0,
18 15
   active: state.getIn(['compose', 'sensitive']),
19 16
   disabled: state.getIn(['compose', 'spoiler']),
20 17
 });
@@ -30,7 +27,6 @@ const mapDispatchToProps = dispatch => ({
30 27
 class SensitiveButton extends React.PureComponent {
31 28
 
32 29
   static propTypes = {
33
-    visible: PropTypes.bool,
34 30
     active: PropTypes.bool,
35 31
     disabled: PropTypes.bool,
36 32
     onClick: PropTypes.func.isRequired,
@@ -38,32 +34,14 @@ class SensitiveButton extends React.PureComponent {
38 34
   };
39 35
 
40 36
   render () {
41
-    const { visible, active, disabled, onClick, intl } = this.props;
37
+    const { active, disabled, onClick, intl } = this.props;
42 38
 
43 39
     return (
44
-      <Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}>
45
-        {({ scale }) => {
46
-          const icon = active ? 'eye-slash' : 'eye';
47
-          const className = classNames('compose-form__sensitive-button', {
48
-            'compose-form__sensitive-button--visible': visible,
49
-          });
50
-          return (
51
-            <div className={className} style={{ transform: `scale(${scale})` }}>
52
-              <IconButton
53
-                className='compose-form__sensitive-button__icon'
54
-                title={intl.formatMessage(active ? messages.marked : messages.unmarked)}
55
-                icon={icon}
56
-                onClick={onClick}
57
-                size={18}
58
-                active={active}
59
-                disabled={disabled}
60
-                style={{ lineHeight: null, height: null }}
61
-                inverted
62
-              />
63
-            </div>
64
-          );
65
-        }}
66
-      </Motion>
40
+      <div className='compose-form__sensitive-button'>
41
+        <button className={classNames('icon-button', { active })} onClick={onClick} disabled={disabled} title={intl.formatMessage(active ? messages.marked : messages.unmarked)}>
42
+          <Icon id='eye-slash' /> <FormattedMessage id='compose_form.sensitive.hide' defaultMessage='Mark media as sensitive' />
43
+        </button>
44
+      </div>
67 45
     );
68 46
   }
69 47
 

+ 10
- 4
app/javascript/mastodon/features/direct_timeline/components/conversations_list.js View File

@@ -20,18 +20,24 @@ export default class ConversationsList extends ImmutablePureComponent {
20 20
 
21 21
   handleMoveUp = id => {
22 22
     const elementIndex = this.getCurrentIndex(id) - 1;
23
-    this._selectChild(elementIndex);
23
+    this._selectChild(elementIndex, true);
24 24
   }
25 25
 
26 26
   handleMoveDown = id => {
27 27
     const elementIndex = this.getCurrentIndex(id) + 1;
28
-    this._selectChild(elementIndex);
28
+    this._selectChild(elementIndex, false);
29 29
   }
30 30
 
31
-  _selectChild (index) {
32
-    const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
31
+  _selectChild (index, align_top) {
32
+    const container = this.node.node;
33
+    const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
33 34
 
34 35
     if (element) {
36
+      if (align_top && container.scrollTop > element.offsetTop) {
37
+        element.scrollIntoView(true);
38
+      } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
39
+        element.scrollIntoView(false);
40
+      }
35 41
       element.focus();
36 42
     }
37 43
   }

+ 10
- 4
app/javascript/mastodon/features/notifications/index.js View File

@@ -113,18 +113,24 @@ class Notifications extends React.PureComponent {
113 113
 
114 114
   handleMoveUp = id => {
115 115
     const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
116
-    this._selectChild(elementIndex);
116
+    this._selectChild(elementIndex, true);
117 117
   }
118 118
 
119 119
   handleMoveDown = id => {
120 120
     const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
121
-    this._selectChild(elementIndex);
121
+    this._selectChild(elementIndex, false);
122 122
   }
123 123
 
124
-  _selectChild (index) {
125
-    const element = this.column.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
124
+  _selectChild (index, align_top) {
125
+    const container = this.column.node;
126
+    const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
126 127
 
127 128
     if (element) {
129
+      if (align_top && container.scrollTop > element.offsetTop) {
130
+        element.scrollIntoView(true);
131
+      } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
132
+        element.scrollIntoView(false);
133
+      }
128 134
       element.focus();
129 135
     }
130 136
   }

+ 1
- 0
app/javascript/mastodon/features/report/components/status_check_box.js View File

@@ -35,6 +35,7 @@ export default class StatusCheckBox extends React.PureComponent {
35 35
             {Component => (
36 36
               <Component
37 37
                 preview={video.get('preview_url')}
38
+                blurhash={video.get('blurhash')}
38 39
                 src={video.get('url')}
39 40
                 alt={video.get('description')}
40 41
                 width={239}

+ 2
- 4
app/javascript/mastodon/features/status/components/detailed_status.js View File

@@ -5,7 +5,6 @@ import Avatar from '../../../components/avatar';
5 5
 import DisplayName from '../../../components/display_name';
6 6
 import StatusContent from '../../../components/status_content';
7 7
 import MediaGallery from '../../../components/media_gallery';
8
-import AttachmentList from '../../../components/attachment_list';
9 8
 import { Link } from 'react-router-dom';
10 9
 import { FormattedDate, FormattedNumber } from 'react-intl';
11 10
 import Card from './card';
@@ -109,14 +108,13 @@ export default class DetailedStatus extends ImmutablePureComponent {
109 108
     if (status.get('poll')) {
110 109
       media = <PollContainer pollId={status.get('poll')} />;
111 110
     } else if (status.get('media_attachments').size > 0) {
112
-      if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
113
-        media = <AttachmentList media={status.get('media_attachments')} />;
114
-      } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
111
+      if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
115 112
         const video = status.getIn(['media_attachments', 0]);
116 113
 
117 114
         media = (
118 115
           <Video
119 116
             preview={video.get('preview_url')}
117
+            blurhash={video.get('blurhash')}
120 118
             src={video.get('url')}
121 119
             alt={video.get('description')}
122 120
             width={300}

+ 14
- 8
app/javascript/mastodon/features/status/index.js View File

@@ -316,15 +316,15 @@ class Status extends ImmutablePureComponent {
316 316
     const { status, ancestorsIds, descendantsIds } = this.props;
317 317
 
318 318
     if (id === status.get('id')) {
319
-      this._selectChild(ancestorsIds.size - 1);
319
+      this._selectChild(ancestorsIds.size - 1, true);
320 320
     } else {
321 321
       let index = ancestorsIds.indexOf(id);
322 322
 
323 323
       if (index === -1) {
324 324
         index = descendantsIds.indexOf(id);
325
-        this._selectChild(ancestorsIds.size + index);
325
+        this._selectChild(ancestorsIds.size + index, true);
326 326
       } else {
327
-        this._selectChild(index - 1);
327
+        this._selectChild(index - 1, true);
328 328
       }
329 329
     }
330 330
   }
@@ -333,23 +333,29 @@ class Status extends ImmutablePureComponent {
333 333
     const { status, ancestorsIds, descendantsIds } = this.props;
334 334
 
335 335
     if (id === status.get('id')) {
336
-      this._selectChild(ancestorsIds.size + 1);
336
+      this._selectChild(ancestorsIds.size + 1, false);
337 337
     } else {
338 338
       let index = ancestorsIds.indexOf(id);
339 339
 
340 340
       if (index === -1) {
341 341
         index = descendantsIds.indexOf(id);
342
-        this._selectChild(ancestorsIds.size + index + 2);
342
+        this._selectChild(ancestorsIds.size + index + 2, false);
343 343
       } else {
344
-        this._selectChild(index + 1);
344
+        this._selectChild(index + 1, false);
345 345
       }
346 346
     }
347 347
   }
348 348
 
349
-  _selectChild (index) {
350
-    const element = this.node.querySelectorAll('.focusable')[index];
349
+  _selectChild (index, align_top) {
350
+    const container = this.node;
351
+    const element = container.querySelectorAll('.focusable')[index];
351 352
 
352 353
     if (element) {
354
+      if (align_top && container.scrollTop > element.offsetTop) {
355
+        element.scrollIntoView(true);
356
+      } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
357
+        element.scrollIntoView(false);
358
+      }
353 359
       element.focus();
354 360
     }
355 361
   }

+ 27
- 5
app/javascript/mastodon/features/ui/components/media_modal.js View File

@@ -2,11 +2,11 @@ import React from 'react';
2 2
 import ReactSwipeableViews from 'react-swipeable-views';
3 3
 import ImmutablePropTypes from 'react-immutable-proptypes';
4 4
 import PropTypes from 'prop-types';
5
-import Video from '../../video';
6
-import ExtendedVideoPlayer from '../../../components/extended_video_player';
5
+import Video from 'mastodon/features/video';
6
+import ExtendedVideoPlayer from 'mastodon/components/extended_video_player';
7 7
 import classNames from 'classnames';
8
-import { defineMessages, injectIntl } from 'react-intl';
9
-import IconButton from '../../../components/icon_button';
8
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
9
+import IconButton from 'mastodon/components/icon_button';
10 10
 import ImmutablePureComponent from 'react-immutable-pure-component';
11 11
 import ImageLoader from './image_loader';
12 12
 import Icon from 'mastodon/components/icon';
@@ -24,6 +24,7 @@ class MediaModal extends ImmutablePureComponent {
24 24
 
25 25
   static propTypes = {
26 26
     media: ImmutablePropTypes.list.isRequired,
27
+    status: ImmutablePropTypes.map,
27 28
     index: PropTypes.number.isRequired,
28 29
     onClose: PropTypes.func.isRequired,
29 30
     intl: PropTypes.object.isRequired,
@@ -72,9 +73,12 @@ class MediaModal extends ImmutablePureComponent {
72 73
 
73 74
   componentDidMount () {
74 75
     window.addEventListener('keydown', this.handleKeyDown, false);
76
+
75 77
     if (this.context.router) {
76 78
       const history = this.context.router.history;
79
+
77 80
       history.push(history.location.pathname, previewState);
81
+
78 82
       this.unlistenHistory = history.listen(() => {
79 83
         this.props.onClose();
80 84
       });
@@ -83,6 +87,7 @@ class MediaModal extends ImmutablePureComponent {
83 87
 
84 88
   componentWillUnmount () {
85 89
     window.removeEventListener('keydown', this.handleKeyDown);
90
+
86 91
     if (this.context.router) {
87 92
       this.unlistenHistory();
88 93
 
@@ -102,8 +107,15 @@ class MediaModal extends ImmutablePureComponent {
102 107
     }));
103 108
   };
104 109
 
110
+  handleStatusClick = e => {
111
+    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
112
+      e.preventDefault();
113
+      this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
114
+    }
115
+  }
116
+
105 117
   render () {
106
-    const { media, intl, onClose } = this.props;
118
+    const { media, status, intl, onClose } = this.props;
107 119
     const { navigationHidden } = this.state;
108 120
 
109 121
     const index = this.getIndex();
@@ -144,6 +156,7 @@ class MediaModal extends ImmutablePureComponent {
144 156
         return (
145 157
           <Video
146 158
             preview={image.get('preview_url')}
159
+            blurhash={image.get('blurhash')}
147 160
             src={image.get('url')}
148 161
             width={image.get('width')}
149 162
             height={image.get('height')}
@@ -206,10 +219,19 @@ class MediaModal extends ImmutablePureComponent {
206 219
             {content}
207 220
           </ReactSwipeableViews>
208 221
         </div>
222
+
209 223
         <div className={navigationClassName}>
210 224
           <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} />
225
+
211 226
           {leftNav}
212 227
           {rightNav}
228
+
229
+          {status && (
230
+            <div className={classNames('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}>
231
+              <a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>
232
+            </div>
233
+          )}
234
+
213 235
           <ul className='media-modal__pagination'>
214 236
             {pagination}
215 237
           </ul>

+ 43
- 2
app/javascript/mastodon/features/ui/components/video_modal.js View File

@@ -1,28 +1,69 @@
1 1
 import React from 'react';
2 2
 import ImmutablePropTypes from 'react-immutable-proptypes';
3 3
 import PropTypes from 'prop-types';
4
-import Video from '../../video';
4
+import Video from 'mastodon/features/video';
5 5
 import ImmutablePureComponent from 'react-immutable-pure-component';
6
+import { FormattedMessage } from 'react-intl';
7
+
8
+export const previewState = 'previewVideoModal';
6 9
 
7 10
 export default class VideoModal extends ImmutablePureComponent {
8 11
 
9 12
   static propTypes = {
10 13
     media: ImmutablePropTypes.map.isRequired,
14
+    status: ImmutablePropTypes.map,
11 15
     time: PropTypes.number,
12 16
     onClose: PropTypes.func.isRequired,
13 17
   };
14 18
 
19
+  static contextTypes = {
20
+    router: PropTypes.object,
21
+  };
22
+
23
+  componentDidMount () {
24
+    if (this.context.router) {
25
+      const history = this.context.router.history;
26
+
27
+      history.push(history.location.pathname, previewState);
28
+
29
+      this.unlistenHistory = history.listen(() => {
30
+        this.props.onClose();
31
+      });
32
+    }
33
+  }
34
+
35
+  componentWillUnmount () {
36
+    if (this.context.router) {
37
+      this.unlistenHistory();
38
+
39
+      if (this.context.router.history.location.state === previewState) {
40
+        this.context.router.history.goBack();
41
+      }
42
+    }
43
+  }
44
+
45
+  handleStatusClick = e => {
46
+    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
47
+      e.preventDefault();
48
+      this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
49
+    }
50
+  }
51
+
15 52
   render () {
16
-    const { media, time, onClose } = this.props;
53
+    const { media, status, time, onClose } = this.props;
54
+
55
+    const link = status && <a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>;
17 56
 
18 57
     return (
19 58
       <div className='modal-root__modal video-modal'>
20 59
         <div>
21 60
           <Video
22 61
             preview={media.get('preview_url')}
62
+            blurhash={media.get('blurhash')}
23 63
             src={media.get('url')}
24 64
             startTime={time}
25 65
             onCloseVideo={onClose}
66
+            link={link}
26 67
             detailed
27 68
             alt={media.get('description')}
28 69
           />

+ 7
- 2
app/javascript/mastodon/features/ui/index.js View File

@@ -367,11 +367,16 @@ class UI extends React.PureComponent {
367 367
   handleHotkeyFocusColumn = e => {
368 368
     const index  = (e.key * 1) + 1; // First child is drawer, skip that
369 369
     const column = this.node.querySelector(`.column:nth-child(${index})`);
370
+    if (!column) return;
371
+    const container = column.querySelector('.scrollable');
370 372
 
371
-    if (column) {
372
-      const status = column.querySelector('.focusable');
373
+    if (container) {
374
+      const status = container.querySelector('.focusable');
373 375
 
374 376
       if (status) {
377
+        if (container.scrollTop > status.offsetTop) {
378
+          status.scrollIntoView(true);
379
+        }
375 380
         status.focus();
376 381
       }
377 382
     }

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

@@ -7,6 +7,7 @@ import classNames from 'classnames';
7 7
 import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
8 8
 import { displayMedia } from '../../initial_state';
9 9
 import Icon from 'mastodon/components/icon';
10
+import { decode } from 'blurhash';
10 11
 
11 12
 const messages = defineMessages({
12 13
   play: { id: 'video.play', defaultMessage: 'Play' },
@@ -102,6 +103,8 @@ class Video extends React.PureComponent {
102 103
     inline: PropTypes.bool,
103 104
     cacheWidth: PropTypes.func,
104 105
     intl: PropTypes.object.isRequired,
106
+    blurhash: PropTypes.string,
107
+    link: PropTypes.node,
105 108
   };
106 109
 
107 110
   state = {
@@ -139,6 +142,7 @@ class Video extends React.PureComponent {
139 142
 
140 143
   setVideoRef = c => {
141 144
     this.video = c;
145
+
142 146
     if (this.video) {
143 147
       this.setState({ volume: this.video.volume, muted: this.video.muted });
144 148
     }
@@ -152,6 +156,10 @@ class Video extends React.PureComponent {
152 156
     this.volume = c;
153 157
   }
154 158
 
159
+  setCanvasRef = c => {
160
+    this.canvas = c;
161
+  }
162
+
155 163
   handleClickRoot = e => e.stopPropagation();
156 164
 
157 165
   handlePlay = () => {
@@ -170,7 +178,6 @@ class Video extends React.PureComponent {
170 178
   }
171 179
 
172 180
   handleVolumeMouseDown = e => {
173
-
174 181
     document.addEventListener('mousemove', this.handleMouseVolSlide, true);
175 182
     document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
176 183
     document.addEventListener('touchmove', this.handleMouseVolSlide, true);
@@ -190,7 +197,6 @@ class Video extends React.PureComponent {
190 197
   }
191 198
 
192 199
   handleMouseVolSlide = throttle(e => {
193
-
194 200
     const rect = this.volume.getBoundingClientRect();
195 201
     const x = (e.clientX - rect.left) / this.volWidth; //x position within the element.
196 202
 
@@ -261,6 +267,10 @@ class Video extends React.PureComponent {
261 267
     document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
262 268
     document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
263 269
     document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
270
+
271
+    if (this.props.blurhash) {
272
+      this._decode();
273
+    }
264 274
   }
265 275
 
266 276
   componentWillUnmount () {
@@ -270,6 +280,24 @@ class Video extends React.PureComponent {
270 280
     document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
271 281
   }
272 282
 
283
+  componentDidUpdate (prevProps) {
284
+    if (prevProps.blurhash !== this.props.blurhash && this.props.blurhash) {
285
+      this._decode();
286
+    }
287
+  }
288
+
289
+  _decode () {
290
+    const hash   = this.props.blurhash;
291
+    const pixels = decode(hash, 32, 32);
292
+
293
+    if (pixels) {
294
+      const ctx       = this.canvas.getContext('2d');
295
+      const imageData = new ImageData(pixels, 32, 32);
296
+
297
+      ctx.putImageData(imageData, 0, 0);
298
+    }
299
+  }
300
+
273 301
   handleFullscreenChange = () => {
274 302
     this.setState({ fullscreen: isFullscreen() });
275 303
   }
@@ -314,6 +342,7 @@ class Video extends React.PureComponent {
314 342
 
315 343
   handleOpenVideo = () => {
316 344
     const { src, preview, width, height, alt } = this.props;
345
+
317 346
     const media = fromJS({
318 347
       type: 'video',
319 348
       url: src,
@@ -333,7 +362,7 @@ class Video extends React.PureComponent {
333 362
   }
334 363
 
335 364
   render () {
336
-    const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive } = this.props;
365
+    const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link } = this.props;
337 366
     const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
338 367
     const progress = (currentTime / duration) * 100;
339 368
 
@@ -351,6 +380,7 @@ class Video extends React.PureComponent {
351 380
     }
352 381
 
353 382
     let preload;
383
+
354 384
     if (startTime || fullscreen || dragging) {
355 385
       preload = 'auto';
356 386
     } else if (detailed) {
@@ -360,6 +390,7 @@ class Video extends React.PureComponent {
360 390
     }
361 391
 
362 392
     let warning;
393
+
363 394
     if (sensitive) {
364 395
       warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
365 396
     } else {
@@ -377,7 +408,9 @@ class Video extends React.PureComponent {
377 408
         onClick={this.handleClickRoot}
378 409
         tabIndex={0}
379 410
       >
380
-        <video
411
+        <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': revealed })} />
412
+
413
+        {revealed && <video
381 414
           ref={this.setVideoRef}
382 415
           src={src}
383 416
           poster={preview}
@@ -397,12 +430,13 @@ class Video extends React.PureComponent {
397 430
           onLoadedData={this.handleLoadedData}
398 431
           onProgress={this.handleProgress}
399 432
           onVolumeChange={this.handleVolumeChange}
400
-        />
433
+        />}
401 434
 
402
-        <button type='button' className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}>
403
-          <span className='video-player__spoiler__title'>{warning}</span>
404
-          <span className='video-player__spoiler__subtitle'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
405
-        </button>
435
+        <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed })}>
436
+          <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
437
+            <span className='spoiler-button__overlay__label'>{warning}</span>
438
+          </button>
439
+        </div>
406 440
 
407 441
         <div className={classNames('video-player__controls', { active: paused || hovered })}>
408 442
           <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
@@ -420,6 +454,7 @@ class Video extends React.PureComponent {
420 454
             <div className='video-player__buttons left'>
421 455
               <button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
422 456
               <button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
457
+
423 458
               <div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
424 459
                 <div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
425 460
                 <span
@@ -429,17 +464,19 @@ class Video extends React.PureComponent {
429 464
                 />
430 465
               </div>
431 466
 
432
-              {(detailed || fullscreen) &&
467
+              {(detailed || fullscreen) && (
433 468
                 <span>
434 469
                   <span className='video-player__time-current'>{formatTime(currentTime)}</span>
435 470
                   <span className='video-player__time-sep'>/</span>
436 471
                   <span className='video-player__time-total'>{formatTime(duration)}</span>
437 472
                 </span>
438
-              }
473
+              )}
474
+
475
+              {link && <span className='video-player__link'>{link}</span>}
439 476
             </div>
440 477
 
441 478
             <div className='video-player__buttons right'>
442
-              {!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye' fixedWidth /></button>}
479
+              {!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>}
443 480
               {(!fullscreen && onOpenVideo) && <button type='button' aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>}
444 481
               {onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>}
445 482
               <button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button>

+ 2
- 2
app/javascript/mastodon/locales/hy.json View File

@@ -243,7 +243,7 @@
243 243
   "navigation_bar.pins": "Ամրացված թթեր",
244 244
   "navigation_bar.preferences": "Նախապատվություններ",
245 245
   "navigation_bar.public_timeline": "Դաշնային հոսք",
246
-  "navigation_bar.security": "Security",
246
+  "navigation_bar.security": "Անվտանգություն",
247 247
   "notification.favourite": "{name} հավանեց թութդ",
248 248
   "notification.follow": "{name} սկսեց հետեւել քեզ",
249 249
   "notification.mention": "{name} նշեց քեզ",
@@ -309,7 +309,7 @@
309 309
   "search_results.accounts": "People",
310 310
   "search_results.hashtags": "Hashtags",
311 311
   "search_results.statuses": "Toots",
312
-  "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
312
+  "search_results.total": "{count, number} {count, plural, one {արդյունք} other {արդյունք}}",
313 313
   "status.admin_account": "Open moderation interface for @{name}",
314 314
   "status.admin_status": "Open this status in the moderation interface",
315 315
   "status.block": "Արգելափակել @{name}֊ին",

+ 120
- 61
app/javascript/styles/mastodon/components.scss View File

@@ -264,6 +264,16 @@
264 264
 .compose-form {
265 265
   padding: 10px;
266 266
 
267
+  &__sensitive-button {
268
+    padding: 10px;
269
+    padding-top: 0;
270
+
271
+    .icon-button {
272
+      font-size: 14px;
273
+      font-weight: 500;
274
+    }
275
+  }
276
+
267 277
   .compose-form__warning {
268 278
     color: $inverted-text-color;
269 279
     margin-bottom: 10px;
@@ -2412,7 +2422,7 @@ a.account__display-name {
2412 2422
 
2413 2423
     & > div {
2414 2424
       background: rgba($base-shadow-color, 0.6);
2415
-      border-radius: 4px;
2425
+      border-radius: 8px;
2416 2426
       padding: 12px 9px;
2417 2427
       flex: 0 0 auto;
2418 2428
       display: flex;
@@ -2423,19 +2433,18 @@ a.account__display-name {
2423 2433
     button,
2424 2434
     a {
2425 2435
       display: inline;
2426
-      color: $primary-text-color;
2436
+      color: $secondary-text-color;
2427 2437
       background: transparent;
2428 2438
       border: 0;
2429
-      padding: 0 5px;
2439
+      padding: 0 8px;
2430 2440
       text-decoration: none;
2431
-      opacity: 0.6;
2432 2441
       font-size: 18px;
2433 2442
       line-height: 18px;
2434 2443
 
2435 2444
       &:hover,
2436 2445
       &:active,
2437 2446
       &:focus {
2438
-        opacity: 1;
2447
+        color: $primary-text-color;
2439 2448
       }
2440 2449
     }
2441 2450
 
@@ -2932,15 +2941,49 @@ a.status-card.compact:hover {
2932 2941
 }
2933 2942
 
2934 2943
 .spoiler-button {
2935
-  display: none;
2936
-  left: 4px;
2944
+  top: 0;
2945
+  left: 0;
2946
+  width: 100%;
2947
+  height: 100%;
2937 2948
   position: absolute;
2938
-  text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
2939
-  top: 4px;
2940 2949
   z-index: 100;
2941 2950
 
2942
-  &.spoiler-button--visible {
2951
+  &--minified {
2943 2952
     display: block;
2953
+    left: 4px;
2954
+    top: 4px;
2955
+    width: auto;
2956
+    height: auto;
2957
+  }
2958
+
2959
+  &--hidden {
2960
+    display: none;
2961
+  }
2962
+
2963
+  &__overlay {
2964
+    display: block;
2965
+    background: transparent;
2966
+    width: 100%;
2967
+    height: 100%;
2968
+    border: 0;
2969
+
2970
+    &__label {
2971
+      display: inline-block;
2972
+      background: rgba($base-overlay-background, 0.5);
2973
+      border-radius: 8px;
2974
+      padding: 8px 12px;
2975
+      color: $primary-text-color;
2976
+      font-weight: 500;
2977
+      font-size: 14px;
2978
+    }
2979
+
2980
+    &:hover,
2981
+    &:focus,
2982
+    &:active {
2983
+      .spoiler-button__overlay__label {
2984
+        background: rgba($base-overlay-background, 0.8);
2985
+      }
2986
+    }
2944 2987
   }
2945 2988
 }
2946 2989
 
@@ -3728,6 +3771,31 @@ a.status-card.compact:hover {
3728 3771
   pointer-events: none;
3729 3772
 }
3730 3773
 
3774
+.media-modal__meta {
3775
+  text-align: center;
3776
+  position: absolute;
3777
+  left: 0;
3778
+  bottom: 20px;
3779
+  width: 100%;
3780
+  pointer-events: none;
3781
+
3782
+  &--shifted {
3783
+    bottom: 62px;
3784
+  }
3785
+
3786
+  a {
3787
+    text-decoration: none;
3788
+    font-weight: 500;
3789
+    color: $ui-secondary-color;
3790
+
3791
+    &:hover,
3792
+    &:focus,
3793
+    &:active {
3794
+      text-decoration: underline;
3795
+    }
3796
+  }
3797
+}
3798
+
3731 3799
 .media-modal__page-dot {
3732 3800
   display: inline-block;
3733 3801
 }
@@ -4200,6 +4268,7 @@ a.status-card.compact:hover {
4200 4268
   pointer-events: none;
4201 4269
   opacity: 0.9;
4202 4270
   transition: opacity 0.1s ease;
4271
+  line-height: 18px;
4203 4272
 }
4204 4273
 
4205 4274
 .media-gallery__gifv {
@@ -4313,6 +4382,8 @@ a.status-card.compact:hover {
4313 4382
   text-decoration: none;
4314 4383
   color: $secondary-text-color;
4315 4384
   line-height: 0;
4385
+  position: relative;
4386
+  z-index: 1;
4316 4387
 
4317 4388
   &,
4318 4389
   img {
@@ -4325,6 +4396,21 @@ a.status-card.compact:hover {
4325 4396
   }
4326 4397
 }
4327 4398
 
4399
+.media-gallery__preview {
4400
+  width: 100%;
4401
+  height: 100%;
4402
+  object-fit: cover;
4403
+  position: absolute;
4404
+  top: 0;
4405
+  left: 0;
4406
+  z-index: 0;
4407
+  background: $base-overlay-background;
4408
+
4409
+  &--hidden {
4410
+    display: none;
4411
+  }
4412
+}
4413
+
4328 4414
 .media-gallery__gifv {
4329 4415
   height: 100%;
4330 4416
   overflow: hidden;
@@ -4620,6 +4706,23 @@ a.status-card.compact:hover {
4620 4706
     }
4621 4707
   }
4622 4708
 
4709
+  &__link {
4710
+    padding: 2px 10px;
4711
+
4712
+    a {
4713
+      text-decoration: none;
4714
+      font-size: 14px;
4715
+      font-weight: 500;
4716
+      color: $white;
4717
+
4718
+      &:hover,
4719
+      &:active,
4720
+      &:focus {
4721
+        text-decoration: underline;
4722
+      }
4723
+    }
4724
+  }
4725
+
4623 4726
   &__seek {
4624 4727
     cursor: pointer;
4625 4728
     height: 24px;
@@ -4712,62 +4815,18 @@ a.status-card.compact:hover {
4712 4815
 
4713 4816
 .account-gallery__container {
4714 4817
   display: flex;
4715
-  justify-content: center;
4716 4818
   flex-wrap: wrap;
4717
-  padding: 2px;
4819
+  padding: 4px 2px;
4718 4820
 }
4719 4821
 
4720 4822
 .account-gallery__item {
4721
-  flex-grow: 1;
4722
-  width: 50%;
4723
-  overflow: hidden;
4823
+  border: none;
4824
+  box-sizing: border-box;
4825
+  display: block;
4724 4826
   position: relative;
4725
-
4726
-  &::before {
4727
-    content: "";
4728
-    display: block;
4729
-    padding-top: 100%;
4730
-  }
4731
-
4732
-  a {
4733
-    display: block;
4734
-    width: calc(100% - 4px);
4735
-    height: calc(100% - 4px);
4736
-    margin: 2px;
4737
-    top: 0;
4738
-    left: 0;
4739
-    background-color: $base-overlay-background;
4740
-    background-size: cover;
4741
-    background-position: center;
4742
-    position: absolute;
4743
-    color: $darker-text-color;
4744
-    text-decoration: none;
4745
-    border-radius: 4px;
4746
-
4747
-    &:hover,
4748
-    &:active,
4749
-    &:focus {
4750
-      outline: 0;
4751
-      color: $secondary-text-color;
4752
-
4753
-      &::before {
4754
-        content: "";
4755
-        display: block;
4756
-        width: 100%;
4757
-        height: 100%;
4758
-        background: rgba($base-overlay-background, 0.3);
4759
-        border-radius: 4px;
4760
-      }
4761
-    }
4762
-  }
4763
-
4764
-  &__icons {
4765
-    position: absolute;
4766
-    top: 50%;
4767
-    left: 50%;
4768
-    transform: translate(-50%, -50%);
4769
-    font-size: 24px;
4770
-  }
4827
+  border-radius: 4px;
4828
+  overflow: hidden;
4829
+  margin: 2px;
4771 4830
 }
4772 4831
 
4773 4832
 .notification__filter-bar,

+ 11
- 0
app/javascript/styles/mastodon/forms.scss View File

@@ -533,6 +533,17 @@ code {
533 533
     color: $error-value-color;
534 534
   }
535 535
 
536
+  a {
537
+    display: inline-block;
538
+    color: $darker-text-color;
539
+    text-decoration: none;
540
+
541
+    &:hover {
542
+      color: $primary-text-color;
543
+      text-decoration: underline;
544
+    }
545
+  }
546
+
536 547
   p {
537 548
     margin-bottom: 15px;
538 549
   }

+ 6
- 1
app/lib/activitypub/activity/create.rb View File

@@ -194,7 +194,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
194 194
       next if attachment['url'].blank?
195 195
 
196 196
       href             = Addressable::URI.parse(attachment['url']).normalize.to_s
197
-      media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint'])
197
+      media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil)
198 198
       media_attachments << media_attachment
199 199
 
200 200
       next if unsupported_media_type?(attachment['mediaType']) || skip_download?
@@ -369,6 +369,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
369 369
     mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type)
370 370
   end
371 371
 
372
+  def supported_blurhash?(blurhash)
373
+    components = blurhash.blank? ? nil : Blurhash.components(blurhash)
374
+    components.present? && components.none? { |comp| comp > 5 }
375
+  end
376
+
372 377
   def skip_download?
373 378
     return @skip_download if defined?(@skip_download)
374 379
     @skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?

+ 1
- 0
app/lib/activitypub/adapter.rb View File

@@ -19,6 +19,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
19 19
     conversation: { 'ostatus' => 'http://ostatus.org#', 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri', 'conversation' => 'ostatus:conversation' },
20 20
     focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } },
21 21
     identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
22
+    blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
22 23
   }.freeze
23 24
 
24 25
   def self.default_key_transform

+ 1
- 0
app/models/concerns/ldap_authenticable.rb View File

@@ -6,6 +6,7 @@ module LdapAuthenticable
6 6
   def ldap_setup(_attributes)
7 7
     self.confirmed_at = Time.now.utc
8 8
     self.admin        = false
9
+    self.external     = true
9 10
 
10 11
     save!
11 12
   end

+ 1
- 0
app/models/concerns/omniauthable.rb View File

@@ -66,6 +66,7 @@ module Omniauthable
66 66
         email: email || "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com",
67 67
         password: Devise.friendly_token[0, 20],
68 68
         agreement: true,
69
+        external: true,
69 70
         account_attributes: {
70 71
           username: ensure_unique_username(auth.uid),
71 72
           display_name: display_name,

+ 1
- 0
app/models/concerns/pam_authenticable.rb View File

@@ -34,6 +34,7 @@ module PamAuthenticable
34 34
       self.confirmed_at = Time.now.utc
35 35
       self.admin        = false
36 36
       self.account      = account
37
+      self.external     = true
37 38
 
38 39
       account.destroy! unless save
39 40
     end

+ 7
- 0
app/models/domain_block.rb View File

@@ -29,4 +29,11 @@ class DomainBlock < ApplicationRecord
29 29
   def self.blocked?(domain)
30 30
     where(domain: domain, severity: :suspend).exists?
31 31
   end
32
+
33
+  def stricter_than?(other_block)
34
+    return true if suspend?
35
+    return false if other_block.suspend? && (silence? || noop?)
36
+    return false if other_block.silence? && noop?
37
+    (reject_media || !other_block.reject_media) && (reject_reports || !other_block.reject_reports)
38
+  end
32 39
 end

+ 12
- 3
app/models/media_attachment.rb View File

@@ -18,6 +18,7 @@
18 18
 #  account_id          :bigint(8)
19 19
 #  description         :text
20 20
 #  scheduled_status_id :bigint(8)
21
+#  blurhash            :string
21 22
 #
22 23
 
23 24
 class MediaAttachment < ApplicationRecord
@@ -34,6 +35,11 @@ class MediaAttachment < ApplicationRecord
34 35
   VIDEO_CONVERTIBLE_MIME_TYPES = ['video/webm', 'video/quicktime'].freeze
35 36
   AUDIO_MIME_TYPES             = ['audio/mpeg', 'audio/mp4', 'audio/vnd.wav', 'audio/wav', 'audio/x-wav', 'audio/x-wave', 'audio/ogg',].freeze
36 37
 
38
+  BLURHASH_OPTIONS = {
39
+    x_comp: 4,
40
+    y_comp: 4,
41
+  }.freeze
42
+
37 43
   IMAGE_STYLES = {
38 44
     original: {
39 45
       pixels: 1_638_400, # 1280x1280px
@@ -43,6 +49,7 @@ class MediaAttachment < ApplicationRecord
43 49
     small: {
44 50
       pixels: 160_000, # 400x400px
45 51
       file_geometry_parser: FastGeometryParser,
52
+      blurhash: BLURHASH_OPTIONS,
46 53
     },
47 54
   }.freeze
48 55
 
@@ -71,6 +78,8 @@ class MediaAttachment < ApplicationRecord
71 78
       },
72 79
       format: 'png',
73 80
       time: 0,
81
+      file_geometry_parser: FastGeometryParser,
82
+      blurhash: BLURHASH_OPTIONS,
74 83
     },
75 84
   }.freeze
76 85
 
@@ -186,13 +195,13 @@ class MediaAttachment < ApplicationRecord
186 195
 
187 196
     def file_processors(f)
188 197
       if f.file_content_type == 'image/gif'
189
-        [:gif_transcoder]
198
+        [:gif_transcoder, :blurhash_transcoder]
190 199
       elsif VIDEO_MIME_TYPES.include? f.file_content_type
191
-        [:video_transcoder]
200
+        [:video_transcoder, :blurhash_transcoder]
192 201
       elsif AUDIO_MIME_TYPES.include? f.file_content_type
193 202
         [:audio_transcoder]
194 203
       else
195
-        [:lazy_thumbnail]
204
+        [:lazy_thumbnail, :blurhash_transcoder]
196 205
       end
197 206
     end
198 207
   end

+ 8
- 3
app/models/user.rb View File

@@ -78,7 +78,7 @@ class User < ApplicationRecord
78 78
   accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? }
79 79
 
80 80
   validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
81
-  validates_with BlacklistedEmailValidator, if: :email_changed?
81
+  validates_with BlacklistedEmailValidator, on: :create
82 82
   validates_with EmailMxValidator, if: :validate_email_dns?
83 83
   validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create
84 84
 
@@ -107,13 +107,14 @@ class User < ApplicationRecord
107 107
            :expand_spoilers, :default_language, :aggregate_reblogs, :show_application, to: :settings, prefix: :setting, allow_nil: false
108 108
 
109 109
   attr_reader :invite_code
110
+  attr_writer :external
110 111
 
111 112
   def confirmed?
112 113
     confirmed_at.present?
113 114
   end
114 115
 
115 116
   def invited?
116
-    invite_id.present?
117
+    invite_id.present? && invite.valid_for_use?
117 118
   end
118 119
 
119 120
   def disable!
@@ -273,13 +274,17 @@ class User < ApplicationRecord
273 274
   private
274 275
 
275 276
   def set_approved
276
-    self.approved = open_registrations? || invited?
277
+    self.approved = open_registrations? || invited? || external?
277 278
   end
278 279
 
279 280
   def open_registrations?
280 281
     Setting.registrations_mode == 'open'
281 282
   end
282 283
 
284
+  def external?
285
+    !!@external
286
+  end
287
+
283 288
   def sanitize_languages
284 289
     return if chosen_languages.nil?
285 290
     chosen_languages.reject!(&:blank?)

+ 2
- 2
app/serializers/activitypub/note_serializer.rb View File

@@ -2,7 +2,7 @@
2 2
 
3 3
 class ActivityPub::NoteSerializer < ActivityPub::Serializer
4 4
   context_extensions :atom_uri, :conversation, :sensitive,
5
-                     :hashtag, :emoji, :focal_point
5
+                     :hashtag, :emoji, :focal_point, :blurhash
6 6
 
7 7
   attributes :id, :type, :summary,
8 8
              :in_reply_to, :published, :url,
@@ -153,7 +153,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
153 153
   class MediaAttachmentSerializer < ActivityPub::Serializer
154 154
     include RoutingHelper
155 155
 
156
-    attributes :type, :media_type, :url, :name
156
+    attributes :type, :media_type, :url, :name, :blurhash
157 157
     attribute :focal_point, if: :focal_point?
158 158
 
159 159
     def type

+ 1
- 1
app/serializers/rest/media_attachment_serializer.rb View File

@@ -5,7 +5,7 @@ class REST::MediaAttachmentSerializer < ActiveModel::Serializer
5 5
 
6 6
   attributes :id, :type, :url, :preview_url,
7 7
              :remote_url, :text_url, :meta,
8
-             :description
8
+             :description, :blurhash
9 9
 
10 10
   def id
11 11
     object.id.to_s

+ 1
- 0
app/services/block_service.rb View File

@@ -6,6 +6,7 @@ class BlockService < BaseService
6 6
 
7 7
     UnfollowService.new.call(account, target_account) if account.following?(target_account)
8 8
     UnfollowService.new.call(target_account, account) if target_account.following?(account)
9
+    RejectFollowService.new.call(account, target_account) if target_account.requested?(account)
9 10
 
10 11
     block = account.block!(target_account)
11 12
 

+ 4
- 1
app/validators/blacklisted_email_validator.rb View File

@@ -2,7 +2,10 @@
2 2
 
3 3
 class BlacklistedEmailValidator < ActiveModel::Validator
4 4
   def validate(user)
5
+    return if user.invited?
6
+
5 7
     @email = user.email
8
+
6 9
     user.errors.add(:email, I18n.t('users.invalid_email')) if blocked_email?
7 10
   end
8 11
 
@@ -13,7 +16,7 @@ class BlacklistedEmailValidator < ActiveModel::Validator
13 16
   end
14 17
 
15 18
   def on_blacklist?
16
-    return true if EmailDomainBlock.block?(@email)
19
+    return true  if EmailDomainBlock.block?(@email)
17 20
     return false if Rails.configuration.x.email_domains_blacklist.blank?
18 21
 
19 22
     domains = Rails.configuration.x.email_domains_blacklist.gsub('.', '\.')

+ 1
- 1
app/views/stream_entries/_detailed_status.html.haml View File

@@ -28,7 +28,7 @@
28 28
   - elsif !status.media_attachments.empty?
29 29
     - if status.media_attachments.first.video?
30 30
       - video = status.media_attachments.first
31
-      = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do
31
+      = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do
32 32
         = render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments }
33 33
     - else
34 34
       = react_component :media_gallery, height: 380, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do

+ 1
- 1
app/views/stream_entries/_simple_status.html.haml View File

@@ -33,7 +33,7 @@
33 33
   - elsif !status.media_attachments.empty?
34 34
     - if status.media_attachments.first.video?
35 35
       - video = status.media_attachments.first
36
-      = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description do
36
+      = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description do
37 37
         = render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments }
38 38
     - else
39 39
       = react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do

+ 2
- 0
app/workers/activitypub/processing_worker.rb View File

@@ -7,5 +7,7 @@ class ActivityPub::ProcessingWorker
7 7
 
8 8
   def perform(account_id, body, delivered_to_account_id = nil)
9 9
     ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id), override_timestamps: true, delivered_to_account_id: delivered_to_account_id, delivery: true)
10
+  rescue ActiveRecord::RecordInvalid => e
11
+    Rails.logger.debug "Error processing incoming ActivityPub object: #{e}"
10 12
   end
11 13
 end

+ 3
- 1
config/initializers/rack_attack_logging.rb View File

@@ -1,4 +1,6 @@
1
-ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, req|
1
+ActiveSupport::Notifications.subscribe(/rack_attack/) do |_name, _start, _finish, _request_id, payload|
2
+  req = payload[:request]
3
+
2 4
   next unless [:throttle, :blacklist].include? req.env['rack.attack.match_type']
3 5
   Rails.logger.info("Rate limit hit (#{req.env['rack.attack.match_type']}): #{req.ip} #{req.request_method} #{req.fullpath}")
4 6
 end

+ 1
- 0
config/locales/co.yml View File

@@ -269,6 +269,7 @@ co:
269 269
       created_msg: U blucchime di u duminiu hè attivu
270 270
       destroyed_msg: U blucchime di u duminiu ùn hè più attivu
271 271
       domain: Duminiu
272
+      existing_domain_block_html: Avete digià impostu limite più strette nant'à %{name}, duvete <a href="%{unblock_url}">sbluccallu</a> primu.
272 273
       new:
273 274
         create: Creà un blucchime
274 275
         hint: U blucchime di duminiu ùn impedirà micca a creazione di conti indè a database, mà metudi di muderazione specifiche saranu applicati.

+ 1
- 0
config/locales/en.yml View File

@@ -270,6 +270,7 @@ en:
270 270
       created_msg: Domain block is now being processed
271 271
       destroyed_msg: Domain block has been undone
272 272
       domain: Domain
273
+      existing_domain_block_html: You have already imposed stricter limits on %{name}, you need to <a href="%{unblock_url}">unblock it</a> first.
273 274
       new:
274 275
         create: Create block
275 276
         hint: The domain block will not prevent creation of account entries in the database, but will retroactively and automatically apply specific moderation methods on those accounts.

+ 3
- 3
config/locales/fr.yml View File

@@ -260,10 +260,10 @@ fr:
260 260
         title: Nouveau blocage de domaine
261 261
       reject_media: Fichiers média rejetés
262 262
       reject_media_hint: Supprime localement les fichiers média stockés et refuse d’en télécharger ultérieurement. Ne concerne pas les suspensions
263
-      reject_reports: Rapports de rejet
264
-      reject_reports_hint: Ignorez tous les rapports provenant de ce domaine. Sans objet pour les suspensions
263
+      reject_reports: Rejeter les signalements
264
+      reject_reports_hint: Ignorez tous les signalements provenant de ce domaine. Ne concerne pas les suspensions
265 265
       rejecting_media: rejet des fichiers multimédia
266
-      rejecting_reports: rejet de rapports
266
+      rejecting_reports: rejet des signalements
267 267
       severity:
268 268
         silence: silencié
269 269
         suspend: suspendu

+ 4
- 3
config/locales/sk.yml View File

@@ -527,16 +527,17 @@ sk:
527 527
     login: Prihlás sa
528 528
     logout: Odhlás sa
529 529
     migrate_account: Presúvam sa na iný účet
530
-    migrate_account_html: Pokiaľ si želáš presmerovať tento účet na nejaký iný, môžeš si to <a href="%{path}">nastaviť tu</a>.
531
-    or_log_in_with: Alebo prihlásiť z
530
+    migrate_account_html: Ak si želáš presmerovať tento účet na nejaký iný, môžeš si to <a href="%{path}">nastaviť tu</a>.
531
+    or_log_in_with: Alebo prihlás s
532 532
     providers:
533 533
       cas: CAS
534 534
       saml: SAML
535 535
     register: Zaregistruj sa
536
-    resend_confirmation: Poslať potvrdzujúce pokyny znovu
536
+    resend_confirmation: Zašli potvrdzujúce pokyny znovu
537 537
     reset_password: Obnov heslo
538 538
     security: Zabezpečenie
539 539
     set_new_password: Nastav nové heslo
540
+    trouble_logging_in: Problém s prihlásením?
540 541
   authorize_follow:
541 542
     already_following: Tento účet už následuješ
542 543
     error: Naneštastie nastala chyba pri hľadaní vzdialeného účtu

+ 5
- 0
db/migrate/20190420025523_add_blurhash_to_media_attachments.rb View File

@@ -0,0 +1,5 @@
1
+class AddBlurhashToMediaAttachments < ActiveRecord::Migration[5.2]
2
+  def change
3
+    add_column :media_attachments, :blurhash, :string
4
+  end
5
+end

+ 2
- 1
db/schema.rb View File

@@ -10,7 +10,7 @@
10 10
 #
11 11
 # It's strongly recommended that you check this file into your version control system.
12 12
 
13
-ActiveRecord::Schema.define(version: 2019_04_09_054914) do
13
+ActiveRecord::Schema.define(version: 2019_04_20_025523) do
14 14
 
15 15
   # These are extensions that must be enabled in order to support this database
16 16
   enable_extension "plpgsql"
@@ -373,6 +373,7 @@ ActiveRecord::Schema.define(version: 2019_04_09_054914) do
373 373
     t.bigint "account_id"
374 374
     t.text "description"
375 375
     t.bigint "scheduled_status_id"
376
+    t.string "blurhash"
376 377
     t.index ["account_id"], name: "index_media_attachments_on_account_id"
377 378
     t.index ["scheduled_status_id"], name: "index_media_attachments_on_scheduled_status_id"
378 379
     t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true

+ 4
- 0
lib/cli.rb View File

@@ -9,6 +9,7 @@ require_relative 'mastodon/search_cli'
9 9
 require_relative 'mastodon/settings_cli'
10 10
 require_relative 'mastodon/statuses_cli'
11 11
 require_relative 'mastodon/domains_cli'
12
+require_relative 'mastodon/cache_cli'
12 13
 require_relative 'mastodon/version'
13 14
 
14 15
 module Mastodon
@@ -41,6 +42,9 @@ module Mastodon
41 42
     desc 'domains SUBCOMMAND ...ARGS', 'Manage account domains'
42 43
     subcommand 'domains', Mastodon::DomainsCLI
43 44
 
45
+    desc 'cache SUBCOMMAND ...ARGS', 'Manage cache'
46
+    subcommand 'cache', Mastodon::CacheCLI
47
+
44 48
     option :dry_run, type: :boolean
45 49
     desc 'self-destruct', 'Erase the server from the federation'
46 50
     long_desc <<~LONG_DESC

+ 6
- 1
lib/mastodon/accounts_cli.rb View File

@@ -73,7 +73,7 @@ module Mastodon
73 73
     def create(username)
74 74
       account  = Account.new(username: username)
75 75
       password = SecureRandom.hex
76
-      user     = User.new(email: options[:email], password: password, agreement: true, admin: options[:role] == 'admin', moderator: options[:role] == 'moderator', confirmed_at: options[:confirmed] ? Time.now.utc : nil)
76
+      user     = User.new(email: options[:email], password: password, agreement: true, approved: true, admin: options[:role] == 'admin', moderator: options[:role] == 'moderator', confirmed_at: options[:confirmed] ? Time.now.utc : nil)
77 77
 
78 78
       if options[:reattach]
79 79
         account = Account.find_local(username) || Account.new(username: username)
@@ -115,6 +115,7 @@ module Mastodon
115 115
     option :enable, type: :boolean
116 116
     option :disable, type: :boolean
117 117
     option :disable_2fa, type: :boolean
118
+    option :approve, type: :boolean
118 119
     desc 'modify USERNAME', 'Modify a user'
119 120
     long_desc <<-LONG_DESC
120 121
       Modify a user account.
@@ -128,6 +129,9 @@ module Mastodon
128 129
       With the --disable option, lock the user out of their account. The
129 130
       --enable option is the opposite.
130 131
 
132
+      With the --approve option, the account will be approved, if it was
133
+      previously not due to not having open registrations.
134
+
131 135
       With the --disable-2fa option, the two-factor authentication
132 136
       requirement for the user can be removed.
133 137
     LONG_DESC
@@ -147,6 +151,7 @@ module Mastodon
147 151
       user.email = options[:email] if options[:email]
148 152
       user.disabled = false if options[:enable]
149 153
       user.disabled = true if options[:disable]
154
+      user.approved = true if options[:approve]
150 155
       user.otp_required_for_login = false if options[:disable_2fa]
151 156
       user.confirm if options[:confirm]
152 157
 

+ 19
- 0
lib/mastodon/cache_cli.rb View File

@@ -0,0 +1,19 @@
1
+# frozen_string_literal: true
2
+
3
+require_relative '../../config/boot'
4
+require_relative '../../config/environment'
5
+require_relative 'cli_helper'
6
+
7
+module Mastodon
8
+  class CacheCLI < Thor
9
+    def self.exit_on_failure?
10
+      true
11
+    end
12
+
13
+    desc 'clear', 'Clear out the cache storage'
14
+    def clear
15
+      Rails.cache.clear
16
+      say('OK', :green)
17
+    end
18
+  end
19
+end

+ 1
- 1
lib/mastodon/version.rb View File

@@ -13,7 +13,7 @@ module Mastodon
13 13
     end
14 14
 
15 15
     def patch
16
-      0
16
+      1
17 17
     end
18 18
 
19 19
     def pre

+ 16
- 0
lib/paperclip/blurhash_transcoder.rb View File

@@ -0,0 +1,16 @@
1
+# frozen_string_literal: true
2
+
3
+module Paperclip
4
+  class BlurhashTranscoder < Paperclip::Processor
5
+    def make
6
+      return @file unless options[:style] == :small
7
+
8
+      pixels   = convert(':source RGB:-', source: File.expand_path(@file.path)).unpack('C*')
9
+      geometry = options.fetch(:file_geometry_parser).from_file(@file)
10
+
11
+      attachment.instance.blurhash = Blurhash.encode(geometry.width, geometry.height, pixels, options[:blurhash] || {})
12
+
13
+      @file
14
+    end
15
+  end
16
+end

+ 1
- 0
package.json View File

@@ -80,6 +80,7 @@
80 80
     "babel-plugin-react-intl": "^3.0.1",
81 81
     "babel-plugin-transform-react-remove-prop-types": "^0.4.24",
82 82
     "babel-runtime": "^6.26.0",
83
+    "blurhash": "^1.0.0",
83 84
     "classnames": "^2.2.5",
84 85
     "compression-webpack-plugin": "^2.0.0",
85 86
     "cross-env": "^5.1.4",

+ 1
- 0
public/robots.txt View File

@@ -2,3 +2,4 @@
2 2
 
3 3
 User-agent: *
4 4
 Disallow: /media_proxy/
5
+Disallow: /interact/

+ 12
- 1
spec/controllers/admin/domain_blocks_controller_spec.rb View File

@@ -37,7 +37,7 @@ RSpec.describe Admin::DomainBlocksController, type: :controller do
37 37
     end
38 38