Browse Source

Merge branch 'master' into live

master
Zac 2 weeks ago
parent
commit
631e5eeb3e
100 changed files with 1485 additions and 367 deletions
  1. 0
    28
      .dependabot/config.yml
  2. 1
    0
      .env.vagrant
  3. 14
    14
      Gemfile
  4. 116
    115
      Gemfile.lock
  5. 12
    0
      SECURITY.md
  6. 1
    1
      app/chewy/statuses_index.rb
  7. 5
    3
      app/controllers/accounts_controller.rb
  8. 21
    0
      app/controllers/activitypub/claims_controller.rb
  9. 31
    17
      app/controllers/activitypub/collections_controller.rb
  10. 2
    0
      app/controllers/admin/custom_emojis_controller.rb
  11. 30
    0
      app/controllers/api/v1/crypto/deliveries_controller.rb
  12. 59
    0
      app/controllers/api/v1/crypto/encrypted_messages_controller.rb
  13. 25
    0
      app/controllers/api/v1/crypto/keys/claims_controller.rb
  14. 17
    0
      app/controllers/api/v1/crypto/keys/counts_controller.rb
  15. 26
    0
      app/controllers/api/v1/crypto/keys/queries_controller.rb
  16. 29
    0
      app/controllers/api/v1/crypto/keys/uploads_controller.rb
  17. 6
    46
      app/controllers/auth/sessions_controller.rb
  18. 50
    0
      app/controllers/concerns/sign_in_token_authentication_concern.rb
  19. 48
    0
      app/controllers/concerns/two_factor_authentication_concern.rb
  20. 1
    1
      app/controllers/settings/migration/redirects_controller.rb
  21. 1
    1
      app/controllers/statuses_controller.rb
  22. 3
    1
      app/controllers/tags_controller.rb
  23. 5
    0
      app/helpers/application_helper.rb
  24. 19
    0
      app/helpers/webfinger_helper.rb
  25. 1
    1
      app/javascript/flavours/glitch/actions/importer/normalizer.js
  26. 80
    11
      app/javascript/flavours/glitch/actions/markers.js
  27. 4
    0
      app/javascript/flavours/glitch/actions/notifications.js
  28. 1
    1
      app/javascript/flavours/glitch/actions/streaming.js
  29. 11
    1
      app/javascript/flavours/glitch/actions/timelines.js
  30. 1
    1
      app/javascript/flavours/glitch/components/autosuggest_textarea.js
  31. 2
    2
      app/javascript/flavours/glitch/components/media_gallery.js
  32. 4
    1
      app/javascript/flavours/glitch/components/scrollable_list.js
  33. 1
    0
      app/javascript/flavours/glitch/components/status.js
  34. 18
    0
      app/javascript/flavours/glitch/components/timeline_hint.js
  35. 26
    2
      app/javascript/flavours/glitch/features/account_timeline/index.js
  36. 1
    0
      app/javascript/flavours/glitch/features/audio/index.js
  37. 4
    3
      app/javascript/flavours/glitch/features/emoji_picker/index.js
  38. 24
    2
      app/javascript/flavours/glitch/features/followers/index.js
  39. 24
    2
      app/javascript/flavours/glitch/features/following/index.js
  40. 1
    1
      app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js
  41. 8
    8
      app/javascript/flavours/glitch/features/hashtag_timeline/index.js
  42. 4
    0
      app/javascript/flavours/glitch/features/keyboard_shortcuts/index.js
  43. 76
    9
      app/javascript/flavours/glitch/features/status/components/card.js
  44. 1
    1
      app/javascript/flavours/glitch/features/status/components/detailed_status.js
  45. 11
    4
      app/javascript/flavours/glitch/features/ui/index.js
  46. 2
    0
      app/javascript/flavours/glitch/reducers/index.js
  47. 25
    0
      app/javascript/flavours/glitch/reducers/markers.js
  48. 5
    5
      app/javascript/flavours/glitch/styles/accessibility.scss
  49. 19
    2
      app/javascript/flavours/glitch/styles/components/columns.scss
  50. 25
    0
      app/javascript/flavours/glitch/styles/components/index.scss
  51. 25
    1
      app/javascript/flavours/glitch/styles/components/status.scss
  52. 1
    1
      app/javascript/flavours/glitch/styles/forms.scss
  53. 1
    1
      app/javascript/flavours/glitch/styles/mastodon-light/variables.scss
  54. 15
    1
      app/javascript/flavours/glitch/util/emoji/index.js
  55. 1
    1
      app/javascript/mastodon/actions/importer/normalizer.js
  56. 83
    11
      app/javascript/mastodon/actions/markers.js
  57. 4
    0
      app/javascript/mastodon/actions/notifications.js
  58. 1
    1
      app/javascript/mastodon/actions/streaming.js
  59. 10
    1
      app/javascript/mastodon/actions/timelines.js
  60. 1
    1
      app/javascript/mastodon/components/autosuggest_textarea.js
  61. 3
    2
      app/javascript/mastodon/components/media_gallery.js
  62. 1
    1
      app/javascript/mastodon/components/modal_root.js
  63. 4
    1
      app/javascript/mastodon/components/scrollable_list.js
  64. 1
    0
      app/javascript/mastodon/components/status.js
  65. 18
    0
      app/javascript/mastodon/components/timeline_hint.js
  66. 26
    2
      app/javascript/mastodon/features/account_timeline/index.js
  67. 1
    0
      app/javascript/mastodon/features/audio/index.js
  68. 4
    3
      app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
  69. 11
    1
      app/javascript/mastodon/features/emoji/__tests__/emoji-test.js
  70. 15
    1
      app/javascript/mastodon/features/emoji/emoji.js
  71. 26
    2
      app/javascript/mastodon/features/followers/index.js
  72. 26
    2
      app/javascript/mastodon/features/following/index.js
  73. 8
    8
      app/javascript/mastodon/features/hashtag_timeline/index.js
  74. 4
    0
      app/javascript/mastodon/features/keyboard_shortcuts/index.js
  75. 76
    9
      app/javascript/mastodon/features/status/components/card.js
  76. 1
    1
      app/javascript/mastodon/features/status/components/detailed_status.js
  77. 29
    9
      app/javascript/mastodon/features/ui/components/compose_panel.js
  78. 12
    4
      app/javascript/mastodon/features/ui/index.js
  79. 6
    0
      app/javascript/mastodon/locales/ar.json
  80. 6
    0
      app/javascript/mastodon/locales/ast.json
  81. 7
    1
      app/javascript/mastodon/locales/bg.json
  82. 6
    0
      app/javascript/mastodon/locales/bn.json
  83. 7
    1
      app/javascript/mastodon/locales/br.json
  84. 6
    0
      app/javascript/mastodon/locales/ca.json
  85. 6
    0
      app/javascript/mastodon/locales/co.json
  86. 6
    0
      app/javascript/mastodon/locales/cs.json
  87. 6
    0
      app/javascript/mastodon/locales/cy.json
  88. 6
    0
      app/javascript/mastodon/locales/da.json
  89. 6
    0
      app/javascript/mastodon/locales/de.json
  90. 39
    1
      app/javascript/mastodon/locales/defaultMessages.json
  91. 6
    0
      app/javascript/mastodon/locales/el.json
  92. 21
    15
      app/javascript/mastodon/locales/en.json
  93. 6
    0
      app/javascript/mastodon/locales/eo.json
  94. 6
    0
      app/javascript/mastodon/locales/es-AR.json
  95. 6
    0
      app/javascript/mastodon/locales/es.json
  96. 6
    0
      app/javascript/mastodon/locales/et.json
  97. 6
    0
      app/javascript/mastodon/locales/eu.json
  98. 6
    0
      app/javascript/mastodon/locales/fa.json
  99. 6
    0
      app/javascript/mastodon/locales/fi.json
  100. 0
    0
      app/javascript/mastodon/locales/fr.json

+ 0
- 28
.dependabot/config.yml View File

@@ -1,28 +0,0 @@
version: 1

update_configs:
- package_manager: "ruby:bundler"
directory: "/"
update_schedule: "weekly"
# Supported update schedule: live daily weekly monthly
version_requirement_updates: "auto"
# Supported version requirements: auto widen_ranges increase_versions increase_versions_if_necessary
allowed_updates:
- match:
dependency_type: "all"
# Supported dependency types: all indirect direct production development
update_type: "all"
# Supported update types: all security

- package_manager: "javascript"
directory: "/"
update_schedule: "weekly"
# Supported update schedule: live daily weekly monthly
version_requirement_updates: "auto"
# Supported version requirements: auto widen_ranges increase_versions increase_versions_if_necessary
allowed_updates:
- match:
dependency_type: "all"
# Supported dependency types: all indirect direct production development
update_type: "all"
# Supported update types: all security

+ 1
- 0
.env.vagrant View File

@@ -1,3 +1,4 @@
VAGRANT=true
LOCAL_DOMAIN=mastodon.local
BIND=0.0.0.0
DB_HOST=/var/run/postgresql/

+ 14
- 14
Gemfile View File

@@ -6,10 +6,10 @@ ruby '>= 2.5.0', '< 3.0.0'
gem 'pkg-config', '~> 1.4'

gem 'puma', '~> 4.3'
gem 'rails', '~> 5.2.4.2'
gem 'rails', '~> 5.2.4.3'
gem 'sprockets', '~> 3.7.2'
gem 'thor', '~> 0.20'
gem 'rack', '~> 2.2.2'
gem 'rack', '~> 2.2.3'

gem 'thwait', '~> 0.1.0'
gem 'e2mmap', '~> 0.1.0'
@@ -17,10 +17,10 @@ gem 'e2mmap', '~> 0.1.0'
gem 'hamlit-rails', '~> 0.2'
gem 'pg', '~> 1.2'
gem 'makara', '~> 0.4'
gem 'pghero', '~> 2.4'
gem 'pghero', '~> 2.5'
gem 'dotenv-rails', '~> 2.7'

gem 'aws-sdk-s3', '~> 1.64', require: false
gem 'aws-sdk-s3', '~> 1.68', require: false
gem 'fog-core', '<= 2.1.0'
gem 'fog-openstack', '~> 0.3', require: false
gem 'paperclip', '~> 6.0'
@@ -50,6 +50,7 @@ gem 'omniauth', '~> 1.9'

gem 'discard', '~> 1.2'
gem 'doorkeeper', '~> 5.4'
gem 'ed25519', '~> 1.2'
gem 'fast_blank', '~> 1.0'
gem 'fastimage'
gem 'goldfinger', '~> 2.1'
@@ -60,7 +61,7 @@ gem 'htmlentities', '~> 4.3'
gem 'http', '~> 4.4'
gem 'http_accept_language', '~> 2.1'
gem 'http_parser.rb', '~> 0.6', git: 'https://github.com/tmm1/http_parser.rb', ref: '54b17ba8c7d8d20a16dfc65d1775241833219cf2', submodules: true
gem 'httplog', '~> 1.4.2'
gem 'httplog', '~> 1.4.3'
gem 'idn-ruby', require: 'idn'
gem 'kaminari', '~> 1.2'
gem 'link_header', '~> 0.0'
@@ -79,11 +80,11 @@ gem 'rack-attack', '~> 6.3'
gem 'rack-cors', '~> 1.1', require: 'rack/cors'
gem 'rails-i18n', '~> 5.1'
gem 'rails-settings-cached', '~> 0.6'
gem 'redis', '~> 4.1', require: ['redis', 'redis/connection/hiredis']
gem 'redis', '~> 4.2', require: ['redis', 'redis/connection/hiredis']
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
gem 'rqrcode', '~> 1.1'
gem 'ruby-progressbar', '~> 1.10'
gem 'sanitize', '~> 5.1'
gem 'sanitize', '~> 5.2'
gem 'sidekiq', '~> 6.0'
gem 'sidekiq-scheduler', '~> 3.0'
gem 'sidekiq-unique-jobs', '~> 6.0'
@@ -93,7 +94,6 @@ gem 'simple_form', '~> 5.0'
gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie'
gem 'stoplight', '~> 2.2.0'
gem 'strong_migrations', '~> 0.6'
gem 'tty-command', '~> 0.9', require: false
gem 'tty-prompt', '~> 0.21', require: false
gem 'twitter-text', '~> 1.14'
gem 'tzinfo-data', '~> 1.2020'
@@ -122,13 +122,13 @@ end
group :test do
gem 'capybara', '~> 3.32'
gem 'climate_control', '~> 0.2'
gem 'faker', '~> 2.11'
gem 'faker', '~> 2.12'
gem 'microformats', '~> 4.2'
gem 'rails-controller-testing', '~> 1.0'
gem 'rspec-sidekiq', '~> 3.0'
gem 'simplecov', '~> 0.18', require: false
gem 'webmock', '~> 3.8'
gem 'parallel_tests', '~> 2.32'
gem 'parallel_tests', '~> 3.0'
gem 'rspec_junit_formatter', '~> 0.4'
end

@@ -141,13 +141,13 @@ group :development do
gem 'letter_opener', '~> 1.7'
gem 'letter_opener_web', '~> 1.4'
gem 'memory_profiler'
gem 'rubocop', '~> 0.82', require: false
gem 'rubocop-rails', '~> 2.5', require: false
gem 'rubocop', '~> 0.85', require: false
gem 'rubocop-rails', '~> 2.6', require: false
gem 'brakeman', '~> 4.8', require: false
gem 'bundler-audit', '~> 0.6', require: false
gem 'bundler-audit', '~> 0.7', require: false

gem 'capistrano', '~> 3.14'
gem 'capistrano-rails', '~> 1.4'
gem 'capistrano-rails', '~> 1.5'
gem 'capistrano-rbenv', '~> 2.1'
gem 'capistrano-yarn', '~> 2.0'


+ 116
- 115
Gemfile.lock View File

@@ -31,25 +31,25 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (5.2.4.2)
actionpack (= 5.2.4.2)
actioncable (5.2.4.3)
actionpack (= 5.2.4.3)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailer (5.2.4.2)
actionpack (= 5.2.4.2)
actionview (= 5.2.4.2)
activejob (= 5.2.4.2)
actionmailer (5.2.4.3)
actionpack (= 5.2.4.3)
actionview (= 5.2.4.3)
activejob (= 5.2.4.3)
mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0)
actionpack (5.2.4.2)
actionview (= 5.2.4.2)
activesupport (= 5.2.4.2)
actionpack (5.2.4.3)
actionview (= 5.2.4.3)
activesupport (= 5.2.4.3)
rack (~> 2.0, >= 2.0.8)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.2)
actionview (5.2.4.2)
activesupport (= 5.2.4.2)
actionview (5.2.4.3)
activesupport (= 5.2.4.3)
builder (~> 3.1)
erubi (~> 1.4)
rails-dom-testing (~> 2.0)
@@ -60,20 +60,20 @@ GEM
case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
active_record_query_trace (1.7)
activejob (5.2.4.2)
activesupport (= 5.2.4.2)
activejob (5.2.4.3)
activesupport (= 5.2.4.3)
globalid (>= 0.3.6)
activemodel (5.2.4.2)
activesupport (= 5.2.4.2)
activerecord (5.2.4.2)
activemodel (= 5.2.4.2)
activesupport (= 5.2.4.2)
activemodel (5.2.4.3)
activesupport (= 5.2.4.3)
activerecord (5.2.4.3)
activemodel (= 5.2.4.3)
activesupport (= 5.2.4.3)
arel (>= 9.0)
activestorage (5.2.4.2)
actionpack (= 5.2.4.2)
activerecord (= 5.2.4.2)
activestorage (5.2.4.3)
actionpack (= 5.2.4.3)
activerecord (= 5.2.4.3)
marcel (~> 0.3.1)
activesupport (5.2.4.2)
activesupport (5.2.4.3)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 2)
minitest (~> 5.1)
@@ -86,29 +86,29 @@ GEM
activerecord (>= 3.2, < 7.0)
rake (>= 10.4, < 14.0)
arel (9.0.0)
ast (2.4.0)
ast (2.4.1)
attr_encrypted (3.1.0)
encryptor (~> 3.0.0)
av (0.9.0)
cocaine (~> 0.5.3)
aws-eventstream (1.1.0)
aws-partitions (1.312.0)
aws-sdk-core (3.95.0)
aws-partitions (1.329.0)
aws-sdk-core (3.99.2)
aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0)
aws-sigv4 (~> 1.1)
jmespath (~> 1.0)
aws-sdk-kms (1.31.0)
aws-sdk-core (~> 3, >= 3.71.0)
aws-sdk-kms (1.34.1)
aws-sdk-core (~> 3, >= 3.99.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.64.0)
aws-sdk-core (~> 3, >= 3.83.0)
aws-sdk-s3 (1.68.1)
aws-sdk-core (~> 3, >= 3.99.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1)
aws-sigv4 (1.1.3)
aws-sigv4 (1.1.4)
aws-eventstream (~> 1.0, >= 1.0.2)
bcrypt (3.1.13)
better_errors (2.7.0)
better_errors (2.7.1)
coderay (>= 1.0.0)
erubi (>= 1.0.0)
rack (>= 0.9.0)
@@ -118,24 +118,24 @@ GEM
ffi (~> 1.10.0)
bootsnap (1.4.6)
msgpack (~> 1.0)
brakeman (4.8.1)
browser (4.1.0)
brakeman (4.8.2)
browser (4.2.0)
builder (3.2.4)
bullet (6.1.0)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
bundler-audit (0.6.1)
bundler-audit (0.7.0.1)
bundler (>= 1.2.0, < 3)
thor (~> 0.18)
thor (>= 0.18, < 2)
byebug (11.1.3)
capistrano (3.14.0)
capistrano (3.14.1)
airbrussh (>= 1.0.0)
i18n
rake (>= 10.0.0)
sshkit (>= 1.9.0)
capistrano-bundler (1.6.0)
capistrano (~> 3.1)
capistrano-rails (1.4.0)
capistrano-rails (1.5.0)
capistrano (~> 3.1)
capistrano-bundler (~> 1.1)
capistrano-rbenv (2.1.6)
@@ -143,7 +143,7 @@ GEM
sshkit (~> 1.3)
capistrano-yarn (2.0.2)
capistrano (~> 3.0)
capybara (3.32.1)
capybara (3.32.2)
addressable
mini_mime (>= 0.1.3)
nokogiri (~> 1.8)
@@ -164,16 +164,16 @@ GEM
climate_control (0.2.0)
cocaine (0.5.8)
climate_control (>= 0.0.3, < 1.0)
coderay (1.1.2)
coderay (1.1.3)
concurrent-ruby (1.1.6)
connection_pool (2.2.2)
connection_pool (2.2.3)
crack (0.4.3)
safe_yaml (~> 1.0.0)
crass (1.0.6)
css_parser (1.7.1)
addressable
debug_inspector (0.0.3)
devise (4.7.1)
devise (4.7.2)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
@@ -201,6 +201,7 @@ GEM
dotenv (= 2.7.5)
railties (>= 3.2, < 6.1)
e2mmap (0.1.0)
ed25519 (1.2.4)
elasticsearch (7.7.0)
elasticsearch-api (= 7.7.0)
elasticsearch-transport (= 7.7.0)
@@ -215,9 +216,9 @@ GEM
erubi (1.9.0)
et-orbi (1.2.4)
tzinfo
excon (0.73.0)
excon (0.74.0)
fabrication (2.21.1)
faker (2.11.0)
faker (2.12.0)
i18n (>= 1.6, < 2)
faraday (1.0.1)
multipart-post (>= 1.2, < 3)
@@ -235,14 +236,14 @@ GEM
fog-json (1.2.0)
fog-core
multi_json (~> 1.10)
fog-openstack (0.3.7)
fog-openstack (0.3.10)
fog-core (>= 1.45, <= 2.1.0)
fog-json (>= 1.0)
ipaddress (>= 0.8)
formatador (0.2.5)
fugit (1.3.5)
fugit (1.3.6)
et-orbi (~> 1.1, >= 1.1.8)
raabro (~> 1.1)
raabro (~> 1.3)
fuubar (2.5.0)
rspec-core (~> 3.0)
ruby-progressbar (~> 1.4)
@@ -281,10 +282,10 @@ GEM
http-parser (1.2.1)
ffi-compiler (>= 1.0, < 2.0)
http_accept_language (2.1.1)
httplog (1.4.2)
httplog (1.4.3)
rack (>= 1.0)
rainbow (>= 2.0.0)
i18n (1.8.2)
i18n (1.8.3)
concurrent-ruby (~> 1.0)
i18n-tasks (0.9.31)
activesupport (>= 4.0.2)
@@ -299,7 +300,6 @@ GEM
idn-ruby (0.1.0)
ipaddress (0.8.3)
iso-639 (0.3.5)
jaro_winkler (1.5.4)
jmespath (1.4.0)
json (2.3.0)
json-canonicalization (0.2.0)
@@ -310,23 +310,23 @@ GEM
multi_json (~> 1.14)
rack (~> 2.0)
rdf (~> 3.1)
json-ld-preloaded (3.1.2)
json-ld-preloaded (3.1.3)
json-ld (~> 3.1)
rdf (~> 3.1)
jsonapi-renderer (0.2.2)
jwt (2.2.1)
kaminari (1.2.0)
kaminari (1.2.1)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.0)
kaminari-activerecord (= 1.2.0)
kaminari-core (= 1.2.0)
kaminari-actionview (1.2.0)
kaminari-actionview (= 1.2.1)
kaminari-activerecord (= 1.2.1)
kaminari-core (= 1.2.1)
kaminari-actionview (1.2.1)
actionview
kaminari-core (= 1.2.0)
kaminari-activerecord (1.2.0)
kaminari-core (= 1.2.1)
kaminari-activerecord (1.2.1)
activerecord
kaminari-core (= 1.2.0)
kaminari-core (1.2.0)
kaminari-core (= 1.2.1)
kaminari-core (1.2.1)
launchy (2.5.0)
addressable (~> 2.7)
letter_opener (1.7.0)
@@ -359,11 +359,11 @@ GEM
nokogiri (~> 1.10)
mime-types (3.3.1)
mime-types-data (~> 3.2015)
mime-types-data (3.2020.0425)
mime-types-data (3.2020.0512)
mimemagic (0.3.5)
mini_mime (1.0.2)
mini_portile2 (2.4.0)
minitest (5.14.0)
minitest (5.14.1)
msgpack (1.3.3)
multi_json (1.14.1)
multipart-post (2.1.1)
@@ -371,7 +371,7 @@ GEM
net-ldap (0.16.2)
net-scp (3.0.0)
net-ssh (>= 2.6.5, < 7.0.0)
net-ssh (6.0.2)
net-ssh (6.1.0)
nio4r (2.5.2)
nokogiri (1.10.9)
mini_portile2 (~> 2.4.0)
@@ -404,17 +404,17 @@ GEM
paperclip-av-transcoder (0.6.4)
av (~> 0.9.0)
paperclip (>= 2.5.2)
parallel (1.19.1)
parallel_tests (2.32.0)
parallel (1.19.2)
parallel_tests (3.0.0)
parallel
parser (2.7.1.2)
parser (2.7.1.3)
ast (~> 2.4.0)
parslet (2.0.0)
pastel (0.7.4)
equatable (~> 0.6)
tty-color (~> 0.5)
pg (1.2.3)
pghero (2.4.2)
pghero (2.5.0)
activerecord (>= 5)
pkg-config (1.4.1)
premailer (1.11.1)
@@ -434,13 +434,13 @@ GEM
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (4.0.5)
puma (4.3.3)
puma (4.3.5)
nio4r (~> 2.0)
pundit (2.1.0)
activesupport (>= 3.0.0)
raabro (1.3.1)
rack (2.2.2)
rack-attack (6.3.0)
rack (2.2.3)
rack-attack (6.3.1)
rack (>= 1.0, < 3)
rack-cors (1.1.1)
rack (>= 2.0.0)
@@ -450,18 +450,18 @@ GEM
rack
rack-test (1.1.0)
rack (>= 1.0, < 3)
rails (5.2.4.2)
actioncable (= 5.2.4.2)
actionmailer (= 5.2.4.2)
actionpack (= 5.2.4.2)
actionview (= 5.2.4.2)
activejob (= 5.2.4.2)
activemodel (= 5.2.4.2)
activerecord (= 5.2.4.2)
activestorage (= 5.2.4.2)
activesupport (= 5.2.4.2)
rails (5.2.4.3)
actioncable (= 5.2.4.3)
actionmailer (= 5.2.4.3)
actionpack (= 5.2.4.3)
actionview (= 5.2.4.3)
activejob (= 5.2.4.3)
activemodel (= 5.2.4.3)
activerecord (= 5.2.4.3)
activestorage (= 5.2.4.3)
activesupport (= 5.2.4.3)
bundler (>= 1.3.0)
railties (= 5.2.4.2)
railties (= 5.2.4.3)
sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.4)
actionpack (>= 5.0.1.x)
@@ -477,21 +477,21 @@ GEM
railties (>= 5.0, < 6)
rails-settings-cached (0.6.6)
rails (>= 4.2.0)
railties (5.2.4.2)
actionpack (= 5.2.4.2)
activesupport (= 5.2.4.2)
railties (5.2.4.3)
actionpack (= 5.2.4.3)
activesupport (= 5.2.4.3)
method_source
rake (>= 0.8.7)
thor (>= 0.19.0, < 2.0)
rainbow (3.0.0)
rake (13.0.1)
rdf (3.1.1)
rdf (3.1.2)
hamster (~> 3.0)
link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.4.0)
rdf (~> 3.1)
redcarpet (3.5.0)
redis (4.1.4)
redis (4.2.1)
redis-actionpack (5.2.0)
actionpack (>= 5, < 7)
redis-rack (>= 2.1.0, < 3)
@@ -510,10 +510,10 @@ GEM
redis-store (>= 1.2, < 2)
redis-store (1.8.2)
redis (>= 4, < 5)
regexp_parser (1.7.0)
regexp_parser (1.7.1)
request_store (1.5.0)
rack (>= 1.4)
responders (3.0.0)
responders (3.0.1)
actionpack (>= 5.0)
railties (>= 5.0)
rexml (3.2.4)
@@ -531,7 +531,7 @@ GEM
rspec-mocks (3.9.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.9.0)
rspec-rails (4.0.0)
rspec-rails (4.0.1)
actionpack (>= 4.2)
activesupport (>= 4.2)
railties (>= 4.2)
@@ -545,25 +545,28 @@ GEM
rspec-support (3.9.3)
rspec_junit_formatter (0.4.1)
rspec-core (>= 2, < 4, != 2.12.0)
rubocop (0.82.0)
jaro_winkler (~> 1.5.1)
rubocop (0.85.1)
parallel (~> 1.10)
parser (>= 2.7.0.1)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.7)
rexml
rubocop-ast (>= 0.0.3)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 2.0)
rubocop-rails (2.5.2)
activesupport
rubocop-ast (0.0.3)
parser (>= 2.7.0.1)
rubocop-rails (2.6.0)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 0.72.0)
rubocop (>= 0.82.0)
ruby-progressbar (1.10.1)
ruby-saml (1.11.0)
nokogiri (>= 1.5.10)
rufus-scheduler (3.6.0)
fugit (~> 1.1, >= 1.1.6)
safe_yaml (1.0.5)
sanitize (5.1.0)
sanitize (5.2.1)
crass (~> 1.0.2)
nokogiri (>= 1.8.0)
nokogumbo (~> 2.0)
@@ -582,7 +585,7 @@ GEM
sidekiq (>= 3)
thwait
tilt (>= 1.4.0)
sidekiq-unique-jobs (6.0.21)
sidekiq-unique-jobs (6.0.22)
concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 4.0, < 7.0)
thor (~> 0)
@@ -610,7 +613,7 @@ GEM
stoplight (2.2.0)
streamio-ffmpeg (3.0.2)
multi_json (~> 1.8)
strong_migrations (0.6.6)
strong_migrations (0.6.8)
activerecord (>= 5)
temple (0.8.2)
terminal-table (1.8.0)
@@ -622,8 +625,6 @@ GEM
thwait (0.1.0)
tilt (2.0.10)
tty-color (0.5.1)
tty-command (0.9.0)
pastel (~> 0.7.0)
tty-cursor (0.7.1)
tty-prompt (0.21.0)
necromancer (~> 0.5.0)
@@ -633,7 +634,7 @@ GEM
tty-cursor (~> 0.7)
tty-screen (~> 0.7)
wisper (~> 2.0.0)
tty-screen (0.7.1)
tty-screen (0.8.0)
twitter-text (1.14.7)
unf (~> 0.1.0)
tzinfo (1.2.7)
@@ -659,9 +660,9 @@ GEM
webpush (0.3.8)
hkdf (~> 0.2)
jwt (~> 2.0)
websocket-driver (0.7.1)
websocket-driver (0.7.2)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.4)
websocket-extensions (0.1.5)
wisper (2.0.1)
xpath (3.2.0)
nokogiri (~> 1.8)
@@ -674,7 +675,7 @@ DEPENDENCIES
active_record_query_trace (~> 1.7)
addressable (~> 2.7)
annotate (~> 3.1)
aws-sdk-s3 (~> 1.64)
aws-sdk-s3 (~> 1.68)
better_errors (~> 2.7)
binding_of_caller (~> 0.7)
blurhash (~> 0.1)
@@ -682,9 +683,9 @@ DEPENDENCIES
brakeman (~> 4.8)
browser
bullet (~> 6.1)
bundler-audit (~> 0.6)
bundler-audit (~> 0.7)
capistrano (~> 3.14)
capistrano-rails (~> 1.4)
capistrano-rails (~> 1.5)
capistrano-rbenv (~> 2.1)
capistrano-yarn (~> 2.0)
capybara (~> 3.32)
@@ -701,8 +702,9 @@ DEPENDENCIES
doorkeeper (~> 5.4)
dotenv-rails (~> 2.7)
e2mmap (~> 0.1.0)
ed25519 (~> 1.2)
fabrication (~> 2.21)
faker (~> 2.11)
faker (~> 2.12)
fast_blank (~> 1.0)
fastimage
fog-core (<= 2.1.0)
@@ -716,7 +718,7 @@ DEPENDENCIES
http (~> 4.4)
http_accept_language (~> 2.1)
http_parser.rb (~> 0.6)!
httplog (~> 1.4.2)
httplog (~> 1.4.3)
i18n-tasks (~> 0.9)
idn-ruby
iso-639
@@ -744,10 +746,10 @@ DEPENDENCIES
paperclip (~> 6.0)
paperclip-av-transcoder (~> 0.6)
parallel (~> 1.19)
parallel_tests (~> 2.32)
parallel_tests (~> 3.0)
parslet
pg (~> 1.2)
pghero (~> 2.4)
pghero (~> 2.5)
pkg-config (~> 1.4)
posix-spawn!
premailer-rails
@@ -756,26 +758,26 @@ DEPENDENCIES
pry-rails (~> 0.3)
puma (~> 4.3)
pundit (~> 2.1)
rack (~> 2.2.2)
rack (~> 2.2.3)
rack-attack (~> 6.3)
rack-cors (~> 1.1)
rails (~> 5.2.4.2)
rails (~> 5.2.4.3)
rails-controller-testing (~> 1.0)
rails-i18n (~> 5.1)
rails-settings-cached (~> 0.6)
rdf-normalize (~> 0.4)
redcarpet (~> 3.4)
redis (~> 4.1)
redis (~> 4.2)
redis-namespace (~> 1.7)
redis-rails (~> 5.0)
rqrcode (~> 1.1)
rspec-rails (~> 4.0)
rspec-sidekiq (~> 3.0)
rspec_junit_formatter (~> 0.4)
rubocop (~> 0.82)
rubocop-rails (~> 2.5)
rubocop (~> 0.85)
rubocop-rails (~> 2.6)
ruby-progressbar (~> 1.10)
sanitize (~> 5.1)
sanitize (~> 5.2)
sidekiq (~> 6.0)
sidekiq-bulk (~> 0.2.0)
sidekiq-scheduler (~> 3.0)
@@ -791,7 +793,6 @@ DEPENDENCIES
strong_migrations (~> 0.6)
thor (~> 0.20)
thwait (~> 0.1.0)
tty-command (~> 0.9)
tty-prompt (~> 0.21)
twitter-text (~> 1.14)
tzinfo-data (~> 1.2020)

+ 12
- 0
SECURITY.md View File

@@ -0,0 +1,12 @@
# Security Policy

## Supported Versions

| Version | Supported |
| ------- | ------------------ |
| 3.1.x | :white_check_mark: |
| < 3.1 | :x: |

## Reporting a Vulnerability

hello@joinmastodon.org

+ 1
- 1
app/chewy/statuses_index.rb View File

@@ -33,7 +33,7 @@ class StatusesIndex < Chewy::Index

define_type ::Status.unscoped.kept.without_reblogs.includes(:media_attachments), delete_if: ->(status) { status.searchable_by.empty? } do
crutch :mentions do |collection|
data = ::Mention.where(status_id: collection.map(&:id)).where(account: Account.local).pluck(:status_id, :account_id)
data = ::Mention.where(status_id: collection.map(&:id)).where(account: Account.local, silent: false).pluck(:status_id, :account_id)
data.each.with_object({}) { |(id, name), result| (result[id] ||= []).push(name) }
end


+ 5
- 3
app/controllers/accounts_controller.rb View File

@@ -1,7 +1,8 @@
# frozen_string_literal: true

class AccountsController < ApplicationController
PAGE_SIZE = 20
PAGE_SIZE = 20
PAGE_SIZE_MAX = 200

include AccountControllerConcern
include SignatureAuthentication
@@ -27,7 +28,7 @@ class AccountsController < ApplicationController
return
end

@pinned_statuses = cache_collection(@account.pinned_statuses, Status) if show_pinned_statuses?
@pinned_statuses = cache_collection(@account.pinned_statuses.not_local_only, Status) if show_pinned_statuses?
@statuses = filtered_status_page
@statuses = cache_collection(@statuses, Status)
@rss_url = rss_url
@@ -41,7 +42,8 @@ class AccountsController < ApplicationController
format.rss do
expires_in 1.minute, public: true

@statuses = filtered_statuses.without_reblogs.limit(PAGE_SIZE)
limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE
@statuses = filtered_statuses.without_reblogs.limit(limit)
@statuses = cache_collection(@statuses, Status)
render xml: RSS::AccountSerializer.render(@account, @statuses, params[:tag])
end

+ 21
- 0
app/controllers/activitypub/claims_controller.rb View File

@@ -0,0 +1,21 @@
# frozen_string_literal: true

class ActivityPub::ClaimsController < ActivityPub::BaseController
include SignatureVerification
include AccountOwnedConcern

skip_before_action :authenticate_user!

before_action :require_signature!
before_action :set_claim_result

def create
render json: @claim_result, serializer: ActivityPub::OneTimeKeySerializer
end

private

def set_claim_result
@claim_result = ::Keys::ClaimService.new.call(@account.id, params[:id])
end
end

+ 31
- 17
app/controllers/activitypub/collections_controller.rb View File

@@ -5,8 +5,9 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController
include AccountOwnedConcern

before_action :require_signature!, if: :authorized_fetch_mode?
before_action :set_items
before_action :set_size
before_action :set_statuses
before_action :set_type
before_action :set_cache_headers

def show
@@ -16,40 +17,53 @@ class ActivityPub::CollectionsController < ActivityPub::BaseController

private

def set_statuses
@statuses = scope_for_collection
@statuses = cache_collection(@statuses, Status)
def set_items
case params[:id]
when 'featured'
@items = begin
# Because in public fetch mode we cache the response, there would be no
# benefit from performing the check below, since a blocked account or domain
# would likely be served the cache from the reverse proxy anyway

if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain)))
[]
else
cache_collection(@account.pinned_statuses.not_local_only, Status)
end
end
when 'devices'
@items = @account.devices
else
not_found
end
end

def set_size
case params[:id]
when 'featured'
@size = @account.pinned_statuses.count
when 'featured', 'devices'
@size = @items.size
else
not_found
end
end

def scope_for_collection
def set_type
case params[:id]
when 'featured'
# Because in public fetch mode we cache the response, there would be no
# benefit from performing the check below, since a blocked account or domain
# would likely be served the cache from the reverse proxy anyway
if authorized_fetch_mode? && !signed_request_account.nil? && (@account.blocking?(signed_request_account) || (!signed_request_account.domain.nil? && @account.domain_blocking?(signed_request_account.domain)))
Status.none
else
@account.pinned_statuses
end
@type = :ordered
when 'devices'
@type = :unordered
else
not_found
end
end

def collection_presenter
ActivityPub::CollectionPresenter.new(
id: account_collection_url(@account, params[:id]),
type: :ordered,
type: @type,
size: @size,
items: @statuses
items: @items
)
end
end

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

@@ -33,6 +33,8 @@ module Admin
@form.save
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.accounts.no_account_selected')
rescue Mastodon::NotPermittedError
flash[:alert] = I18n.t('admin.custom_emojis.not_permitted')
ensure
redirect_to admin_custom_emojis_path(filter_params)
end

+ 30
- 0
app/controllers/api/v1/crypto/deliveries_controller.rb View File

@@ -0,0 +1,30 @@
# frozen_string_literal: true

class Api::V1::Crypto::DeliveriesController < Api::BaseController
before_action -> { doorkeeper_authorize! :crypto }
before_action :require_user!
before_action :set_current_device

def create
devices.each do |device_params|
DeliverToDeviceService.new.call(current_account, @current_device, device_params)
end

render_empty
end

private

def set_current_device
@current_device = Device.find_by!(access_token: doorkeeper_token)
end

def resource_params
params.require(:device)
params.permit(device: [:account_id, :device_id, :type, :body, :hmac])
end

def devices
Array(resource_params[:device])
end
end

+ 59
- 0
app/controllers/api/v1/crypto/encrypted_messages_controller.rb View File

@@ -0,0 +1,59 @@
# frozen_string_literal: true

class Api::V1::Crypto::EncryptedMessagesController < Api::BaseController
LIMIT = 80

before_action -> { doorkeeper_authorize! :crypto }
before_action :require_user!
before_action :set_current_device

before_action :set_encrypted_messages, only: :index
after_action :insert_pagination_headers, only: :index

def index
render json: @encrypted_messages, each_serializer: REST::EncryptedMessageSerializer
end

def clear
@current_device.encrypted_messages.up_to(params[:up_to_id]).delete_all
render_empty
end

private

def set_current_device
@current_device = Device.find_by!(access_token: doorkeeper_token)
end

def set_encrypted_messages
@encrypted_messages = @current_device.encrypted_messages.paginate_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
end

def insert_pagination_headers
set_pagination_headers(next_path, prev_path)
end

def next_path
api_v1_crypto_encrypted_messages_url pagination_params(max_id: pagination_max_id) if records_continue?
end

def prev_path
api_v1_crypto_encrypted_messages_url pagination_params(min_id: pagination_since_id) unless @encrypted_messages.empty?
end

def pagination_max_id
@encrypted_messages.last.id
end

def pagination_since_id
@encrypted_messages.first.id
end

def records_continue?
@encrypted_messages.size == limit_param(LIMIT)
end

def pagination_params(core_params)
params.slice(:limit).permit(:limit).merge(core_params)
end
end

+ 25
- 0
app/controllers/api/v1/crypto/keys/claims_controller.rb View File

@@ -0,0 +1,25 @@
# frozen_string_literal: true

class Api::V1::Crypto::Keys::ClaimsController < Api::BaseController
before_action -> { doorkeeper_authorize! :crypto }
before_action :require_user!
before_action :set_claim_results

def create
render json: @claim_results, each_serializer: REST::Keys::ClaimResultSerializer
end

private

def set_claim_results
@claim_results = devices.map { |device_params| ::Keys::ClaimService.new.call(current_account, device_params[:account_id], device_params[:device_id]) }.compact
end

def resource_params
params.permit(device: [:account_id, :device_id])
end

def devices
Array(resource_params[:device])
end
end

+ 17
- 0
app/controllers/api/v1/crypto/keys/counts_controller.rb View File

@@ -0,0 +1,17 @@
# frozen_string_literal: true

class Api::V1::Crypto::Keys::CountsController < Api::BaseController
before_action -> { doorkeeper_authorize! :crypto }
before_action :require_user!
before_action :set_current_device

def show
render json: { one_time_keys: @current_device.one_time_keys.count }
end

private

def set_current_device
@current_device = Device.find_by!(access_token: doorkeeper_token)
end
end

+ 26
- 0
app/controllers/api/v1/crypto/keys/queries_controller.rb View File

@@ -0,0 +1,26 @@
# frozen_string_literal: true

class Api::V1::Crypto::Keys::QueriesController < Api::BaseController
before_action -> { doorkeeper_authorize! :crypto }
before_action :require_user!
before_action :set_accounts
before_action :set_query_results

def create
render json: @query_results, each_serializer: REST::Keys::QueryResultSerializer
end

private

def set_accounts
@accounts = Account.where(id: account_ids).includes(:devices)
end

def set_query_results
@query_results = @accounts.map { |account| ::Keys::QueryService.new.call(account) }.compact
end

def account_ids
Array(params[:id]).map(&:to_i)
end
end

+ 29
- 0
app/controllers/api/v1/crypto/keys/uploads_controller.rb View File

@@ -0,0 +1,29 @@
# frozen_string_literal: true

class Api::V1::Crypto::Keys::UploadsController < Api::BaseController
before_action -> { doorkeeper_authorize! :crypto }
before_action :require_user!

def create
device = Device.find_or_initialize_by(access_token: doorkeeper_token)

device.transaction do
device.account = current_account
device.update!(resource_params[:device])

if resource_params[:one_time_keys].present? && resource_params[:one_time_keys].is_a?(Enumerable)
resource_params[:one_time_keys].each do |one_time_key_params|
device.one_time_keys.create!(one_time_key_params)
end
end
end

render json: device, serializer: REST::Keys::DeviceSerializer
end

private

def resource_params
params.permit(device: [:device_id, :name, :fingerprint_key, :identity_key], one_time_keys: [:key_id, :key, :signature])
end
end

+ 6
- 46
app/controllers/auth/sessions_controller.rb View File

@@ -9,7 +9,9 @@ class Auth::SessionsController < Devise::SessionsController
skip_before_action :require_functional!

prepend_before_action :set_pack
prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]

include TwoFactorAuthenticationConcern
include SignInTokenAuthenticationConcern

before_action :set_instance_presenter, only: [:new]
before_action :set_body_classes
@@ -40,8 +42,8 @@ class Auth::SessionsController < Devise::SessionsController
protected

def find_user
if session[:otp_user_id]
User.find(session[:otp_user_id])
if session[:attempt_user_id]
User.find(session[:attempt_user_id])
else
user = User.authenticate_with_ldap(user_params) if Devise.ldap_authentication
user ||= User.authenticate_with_pam(user_params) if Devise.pam_authentication
@@ -50,7 +52,7 @@ class Auth::SessionsController < Devise::SessionsController
end

def user_params
params.require(:user).permit(:email, :password, :otp_attempt)
params.require(:user).permit(:email, :password, :otp_attempt, :sign_in_token_attempt)
end

def after_sign_in_path_for(resource)
@@ -71,48 +73,6 @@ class Auth::SessionsController < Devise::SessionsController
super
end

def two_factor_enabled?
find_user&.otp_required_for_login?
end

def valid_otp_attempt?(user)
user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
user.invalidate_otp_backup_code!(user_params[:otp_attempt])
rescue OpenSSL::Cipher::CipherError
false
end

def authenticate_with_two_factor
user = self.resource = find_user

if user_params[:otp_attempt].present? && session[:otp_user_id]
authenticate_with_two_factor_via_otp(user)
elsif user.present? && (user.encrypted_password.blank? || user.valid_password?(user_params[:password]))
# If encrypted_password is blank, we got the user from LDAP or PAM,
# so credentials are already valid

prompt_for_two_factor(user)
end
end

def authenticate_with_two_factor_via_otp(user)
if valid_otp_attempt?(user)
session.delete(:otp_user_id)
remember_me(user)
sign_in(user)
else
flash.now[:alert] = I18n.t('users.invalid_otp_token')
prompt_for_two_factor(user)
end
end

def prompt_for_two_factor(user)
session[:otp_user_id] = user.id
use_pack 'auth'
@body_classes = 'lighter'
render :two_factor
end

def require_no_authentication
super
# Delete flash message that isn't entirely useful and may be confusing in

+ 50
- 0
app/controllers/concerns/sign_in_token_authentication_concern.rb View File

@@ -0,0 +1,50 @@
# frozen_string_literal: true

module SignInTokenAuthenticationConcern
extend ActiveSupport::Concern

included do
prepend_before_action :authenticate_with_sign_in_token, if: :sign_in_token_required?, only: [:create]
end

def sign_in_token_required?
find_user&.suspicious_sign_in?(request.remote_ip)
end

def valid_sign_in_token_attempt?(user)
Devise.secure_compare(user.sign_in_token, user_params[:sign_in_token_attempt])
end

def authenticate_with_sign_in_token
user = self.resource = find_user

if user_params[:sign_in_token_attempt].present? && session[:attempt_user_id]
authenticate_with_sign_in_token_attempt(user)
elsif user.present? && user.external_or_valid_password?(user_params[:password])
prompt_for_sign_in_token(user)
end
end

def authenticate_with_sign_in_token_attempt(user)
if valid_sign_in_token_attempt?(user)
session.delete(:attempt_user_id)
remember_me(user)
sign_in(user)
else
flash.now[:alert] = I18n.t('users.invalid_sign_in_token')
prompt_for_sign_in_token(user)
end
end

def prompt_for_sign_in_token(user)
if user.sign_in_token_expired?
user.generate_sign_in_token && user.save
UserMailer.sign_in_token(user, request.remote_ip, request.user_agent, Time.now.utc.to_s).deliver_later!
end

session[:attempt_user_id] = user.id
use_pack 'auth'
@body_classes = 'lighter'
render :sign_in_token
end
end

+ 48
- 0
app/controllers/concerns/two_factor_authentication_concern.rb View File

@@ -0,0 +1,48 @@
# frozen_string_literal: true

module TwoFactorAuthenticationConcern
extend ActiveSupport::Concern

included do
prepend_before_action :authenticate_with_two_factor, if: :two_factor_enabled?, only: [:create]
end

def two_factor_enabled?
find_user&.otp_required_for_login?
end

def valid_otp_attempt?(user)
user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
user.invalidate_otp_backup_code!(user_params[:otp_attempt])
rescue OpenSSL::Cipher::CipherError
false
end

def authenticate_with_two_factor
user = self.resource = find_user

if user_params[:otp_attempt].present? && session[:attempt_user_id]
authenticate_with_two_factor_attempt(user)
elsif user.present? && user.external_or_valid_password?(user_params[:password])
prompt_for_two_factor(user)
end
end

def authenticate_with_two_factor_attempt(user)
if valid_otp_attempt?(user)
session.delete(:attempt_user_id)
remember_me(user)
sign_in(user)
else
flash.now[:alert] = I18n.t('users.invalid_otp_token')
prompt_for_two_factor(user)
end
end

def prompt_for_two_factor(user)
session[:attempt_user_id] = user.id
use_pack 'auth'
@body_classes = 'lighter'
render :two_factor
end
end

+ 1
- 1
app/controllers/settings/migration/redirects_controller.rb View File

@@ -18,7 +18,7 @@ class Settings::Migration::RedirectsController < Settings::BaseController
if @redirect.valid_with_challenge?(current_user)
current_account.update!(moved_to_account: @redirect.target_account)
ActivityPub::UpdateDistributionWorker.perform_async(current_account.id)
redirect_to settings_migration_path, notice: I18n.t('migrations.moved_msg', acct: current_account.moved_to_account.acct)
redirect_to settings_migration_path, notice: I18n.t('migrations.redirected_msg', acct: current_account.moved_to_account.acct)
else
render :new
end

+ 1
- 1
app/controllers/statuses_controller.rb View File

@@ -44,7 +44,7 @@ class StatusesController < ApplicationController

def activity
expires_in 3.minutes, public: @status.distributable? && public_fetch_mode?
render_with_cache json: @status, content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter
render_with_cache json: ActivityPub::ActivityPresenter.from_status(@status), content_type: 'application/activity+json', serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter
end

def embed

+ 3
- 1
app/controllers/tags_controller.rb View File

@@ -3,7 +3,8 @@
class TagsController < ApplicationController
include SignatureVerification

PAGE_SIZE = 20
PAGE_SIZE = 20
PAGE_SIZE_MAX = 200

layout 'public'

@@ -26,6 +27,7 @@ class TagsController < ApplicationController
format.rss do
expires_in 0, public: true

limit = params[:limit].present? ? [params[:limit].to_i, PAGE_SIZE_MAX].min : PAGE_SIZE
@statuses = HashtagQueryService.new.call(@tag, filter_params, nil, @local).limit(PAGE_SIZE)
@statuses = cache_collection(@statuses, Status)


+ 5
- 0
app/helpers/application_helper.rb View File

@@ -137,6 +137,11 @@ module ApplicationHelper
text: [params[:title], params[:text], params[:url]].compact.join(' '),
}

permit_visibilities = %w(public unlisted private direct)
default_privacy = current_account&.user&.setting_default_privacy
permit_visibilities.shift(permit_visibilities.index(default_privacy) + 1) if default_privacy.present?
state_params[:visibility] = params[:visibility] if permit_visibilities.include? params[:visibility]

if user_signed_in?
state_params[:settings] = state_params[:settings].merge(Web::Setting.find_by(user: current_user)&.data || {})
state_params[:push_subscription] = current_account.user.web_push_subscription(current_session)

+ 19
- 0
app/helpers/webfinger_helper.rb View File

@@ -1,5 +1,16 @@
# frozen_string_literal: true

# Monkey-patch on monkey-patch.
# Because it conflicts with the request.rb patch.
class HTTP::Timeout::PerOperationOriginal < HTTP::Timeout::PerOperation
def connect(socket_class, host, port, nodelay = false)
::Timeout.timeout(@connect_timeout, HTTP::TimeoutError) do
@socket = socket_class.open(host, port)
@socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) if nodelay
end
end
end

module WebfingerHelper
def webfinger!(uri)
hidden_service_uri = /\.(onion|i2p)(:\d+)?$/.match(uri)
@@ -12,6 +23,14 @@ module WebfingerHelper
headers: {
'User-Agent': Mastodon::Version.user_agent,
},

timeout_class: HTTP::Timeout::PerOperationOriginal,

timeout_options: {
write_timeout: 10,
connect_timeout: 5,
read_timeout: 10,
},
}

Goldfinger::Client.new(uri, opts.merge(Rails.configuration.x.http_client_proxy)).finger

+ 1
- 1
app/javascript/flavours/glitch/actions/importer/normalizer.js View File

@@ -12,7 +12,7 @@ const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {

export function searchTextFromRawStatus (status) {
const spoilerText = status.spoiler_text || '';
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
return domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
}


+ 80
- 11
app/javascript/flavours/glitch/actions/markers.js View File

@@ -1,38 +1,107 @@
import api from 'flavours/glitch/util/api';
import { debounce } from 'lodash';
import compareId from 'flavours/glitch/util/compare_id';
import { showAlertForError } from './alerts';

export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST';
export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS';
export const MARKERS_FETCH_FAIL = 'MARKERS_FETCH_FAIL';
export const MARKERS_SUBMIT_SUCCESS = 'MARKERS_SUBMIT_SUCCESS';

export const submitMarkers = () => (dispatch, getState) => {
export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
const accessToken = getState().getIn(['meta', 'access_token'], '');
const params = {};
const params = _buildParams(getState());

const lastHomeId = getState().getIn(['timelines', 'home', 'items', 0]);
const lastNotificationId = getState().getIn(['notifications', 'lastReadId']);
if (Object.keys(params).length === 0) {
return;
}

// The Fetch API allows us to perform requests that will be carried out
// after the page closes. But that only works if the `keepalive` attribute
// is supported.
if (window.fetch && 'keepalive' in new Request('')) {
fetch('/api/v1/markers', {
keepalive: true,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`,
},
body: JSON.stringify(params),
});
return;
} else if (navigator && navigator.sendBeacon) {
// Failing that, we can use sendBeacon, but we have to encode the data as
// FormData for DoorKeeper to recognize the token.
const formData = new FormData();
formData.append('bearer_token', accessToken);
for (const [id, value] of Object.entries(params)) {
formData.append(`${id}[last_read_id]`, value.last_read_id);
}
if (navigator.sendBeacon('/api/v1/markers', formData)) {
return;
}
}

// If neither Fetch nor sendBeacon worked, try to perform a synchronous
// request.
try {
const client = new XMLHttpRequest();

client.open('POST', '/api/v1/markers', false);
client.setRequestHeader('Content-Type', 'application/json');
client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
client.SUBMIT(JSON.stringify(params));
} catch (e) {
// Do not make the BeforeUnload handler error out
}
};

const _buildParams = (state) => {
const params = {};

const lastHomeId = state.getIn(['timelines', 'home', 'items', 0]);
const lastNotificationId = state.getIn(['notifications', 'lastReadId']);

if (lastHomeId) {
if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) {
params.home = {
last_read_id: lastHomeId,
};
}

if (lastNotificationId && lastNotificationId !== '0') {
if (lastNotificationId && lastNotificationId !== '0' && compareId(lastNotificationId, state.getIn(['markers', 'notifications'])) > 0) {
params.notifications = {
last_read_id: lastNotificationId,
};
}

return params;
};

const debouncedSubmitMarkers = debounce((dispatch, getState) => {
const params = _buildParams(getState());

if (Object.keys(params).length === 0) {
return;
}

const client = new XMLHttpRequest();
api().post('/api/v1/markers', params).then(() => {
dispatch(submitMarkersSuccess(params));
}).catch(error => {
dispatch(showAlertForError(error));
});
}, 300000, { leading: true, trailing: true });

export function submitMarkersSuccess({ home, notifications }) {
return {
type: MARKERS_SUBMIT_SUCCESS,
home: (home || {}).last_read_id,
notifications: (notifications || {}).last_read_id,
};
};

client.open('POST', '/api/v1/markers', false);
client.setRequestHeader('Content-Type', 'application/json');
client.setRequestHeader('Authorization', `Bearer ${accessToken}`);
client.send(JSON.stringify(params));
export function submitMarkers() {
return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
};

export const fetchMarkers = () => (dispatch, getState) => {

+ 4
- 0
app/javascript/flavours/glitch/actions/notifications.js View File

@@ -7,6 +7,7 @@ import {
importFetchedStatus,
importFetchedStatuses,
} from './importer';
import { submitMarkers } from './markers';
import { saveSettings } from './settings';
import { defineMessages } from 'react-intl';
import { List as ImmutableList } from 'immutable';
@@ -81,6 +82,8 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
filtered = regex && regex.test(searchIndex);
}

dispatch(submitMarkers());

if (showInColumn) {
dispatch(importFetchedAccount(notification.account));

@@ -168,6 +171,7 @@ export function expandNotifications({ maxId } = {}, done = noOp) {

dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
fetchRelatedRelationships(dispatch, response.data);
dispatch(submitMarkers());
}).catch(error => {
dispatch(expandNotificationsFail(error, isLoadingMore));
}).finally(() => {

+ 1
- 1
app/javascript/flavours/glitch/actions/streaming.js View File

@@ -74,6 +74,6 @@ const refreshHomeTimelineAndNotification = (dispatch, done) => {
export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
export const connectCommunityStream = ({ onlyMedia } = {}) => connectTimelineStream(`community${onlyMedia ? ':media' : ''}`, `public:local${onlyMedia ? ':media' : ''}`);
export const connectPublicStream = ({ onlyMedia, onlyRemote } = {}) => connectTimelineStream(`public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`, `public${onlyRemote ? ':remote' : ''}${onlyMedia ? ':media' : ''}`);
export const connectHashtagStream = (id, tag, accept) => connectTimelineStream(`hashtag:${id}`, `hashtag&tag=${tag}`, null, accept);
export const connectHashtagStream = (id, tag, local, accept) => connectTimelineStream(`hashtag:${id}${local ? ':local' : ''}`, `hashtag${local ? ':local' : ''}&tag=${tag}`, null, accept);
export const connectDirectStream = () => connectTimelineStream('direct', 'direct');
export const connectListStream = id => connectTimelineStream(`list:${id}`, `list&list=${id}`);

+ 11
- 1
app/javascript/flavours/glitch/actions/timelines.js View File

@@ -1,4 +1,5 @@
import { importFetchedStatus, importFetchedStatuses } from './importer';
import { submitMarkers } from './markers';
import api, { getLinks } from 'flavours/glitch/util/api';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
import compareId from 'flavours/glitch/util/compare_id';
@@ -49,6 +50,10 @@ export function updateTimeline(timeline, status, accept) {
usePendingItems: preferPendingItems,
filtered
});

if (timeline === 'home') {
dispatch(submitMarkers());
}
};
};

@@ -112,6 +117,10 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) {

dispatch(importFetchedStatuses(response.data));
dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.status === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems));

if (timelineId === 'home') {
dispatch(submitMarkers());
}
}).catch(error => {
dispatch(expandTimelineFail(timelineId, error, isLoadingMore));
}).finally(() => {
@@ -129,11 +138,12 @@ export const expandAccountFeaturedTimeline = accountId => expandTimeline(`accoun
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => {
return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, {
max_id: maxId,
any: parseTags(tags, 'any'),
all: parseTags(tags, 'all'),
none: parseTags(tags, 'none'),
local: local,
}, done);
};


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

@@ -208,7 +208,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
<span style={{ display: 'none' }}>{placeholder}</span>

<Textarea
inputRef={this.setTextarea}
ref={this.setTextarea}
className='autosuggest-textarea__textarea'
disabled={disabled}
placeholder={placeholder}

+ 2
- 2
app/javascript/flavours/glitch/components/media_gallery.js View File

@@ -23,7 +23,7 @@ const messages = defineMessages({
id: 'status.sensitive_toggle',
},
toggle_visible: {
defaultMessage: 'Hide media',
defaultMessage: 'Hide {number, plural, one {image} other {images}}',
id: 'media_gallery.toggle_visible',
},
warning: {
@@ -368,7 +368,7 @@ class MediaGallery extends React.PureComponent {
</button>
);
} else if (visible) {
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />;
spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible, { number: size })} icon='eye-slash' overlay onClick={this.handleOpen} />;
} else {
spoilerButton = (
<button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>

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

@@ -32,6 +32,7 @@ export default class ScrollableList extends PureComponent {
hasMore: PropTypes.bool,
numPending: PropTypes.number,
prepend: PropTypes.node,
append: PropTypes.node,
alwaysPrepend: PropTypes.bool,
emptyMessage: PropTypes.node,
children: PropTypes.node,
@@ -272,7 +273,7 @@ export default class ScrollableList extends PureComponent {
}

render () {
const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props;
const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, append, emptyMessage, onLoadMore } = this.props;
const { fullscreen } = this.state;
const childrenCount = React.Children.count(children);

@@ -319,6 +320,8 @@ export default class ScrollableList extends PureComponent {
))}

{loadMore}

{!hasMore && append}
</div>
</div>
);

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

@@ -656,6 +656,7 @@ class Status extends ImmutablePureComponent {
compact
cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth}
sensitive={status.get('sensitive')}
/>
);
mediaIcon = 'link';

+ 18
- 0
app/javascript/flavours/glitch/components/timeline_hint.js View File

@@ -0,0 +1,18 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';

const TimelineHint = ({ resource, url }) => (
<div className='timeline-hint'>
<strong><FormattedMessage id='timeline_hint.remote_resource_not_displayed' defaultMessage='{resource} from other servers are not displayed.' values={{ resource }} /></strong>
<br />
<a href={url} target='_blank'><FormattedMessage id='account.browse_more_on_origin_server' defaultMessage='Browse more on the original profile' /></a>
</div>
);

TimelineHint.propTypes = {
resource: PropTypes.node.isRequired,
url: PropTypes.string.isRequired,
};

export default TimelineHint;

+ 26
- 2
app/javascript/flavours/glitch/features/account_timeline/index.js View File

@@ -15,11 +15,14 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { FormattedMessage } from 'react-intl';
import { fetchAccountIdentityProofs } from '../../actions/identity_proofs';
import MissingIndicator from 'flavours/glitch/components/missing_indicator';
import TimelineHint from 'flavours/glitch/components/timeline_hint';

const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => {
const path = withReplies ? `${accountId}:with_replies` : accountId;

return {
remote: !!state.getIn(['accounts', accountId, 'acct']) !== state.getIn(['accounts', accountId, 'username']),
remoteUrl: state.getIn(['accounts', accountId, 'url']),
isAccount: !!state.getIn(['accounts', accountId]),
statusIds: state.getIn(['timelines', `account:${path}`, 'items'], ImmutableList()),
featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()),
@@ -28,6 +31,14 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false })
};
};

const RemoteHint = ({ url }) => (
<TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.statuses' defaultMessage='Older toots' />} />
);

RemoteHint.propTypes = {
url: PropTypes.string.isRequired,
};

export default @connect(mapStateToProps)
class AccountTimeline extends ImmutablePureComponent {

@@ -40,6 +51,8 @@ class AccountTimeline extends ImmutablePureComponent {
hasMore: PropTypes.bool,
withReplies: PropTypes.bool,
isAccount: PropTypes.bool,
remote: PropTypes.bool,
remoteUrl: PropTypes.string,
multiColumn: PropTypes.bool,
};

@@ -78,7 +91,7 @@ class AccountTimeline extends ImmutablePureComponent {
}

render () {
const { statusIds, featuredStatusIds, isLoading, hasMore, isAccount, multiColumn } = this.props;
const { statusIds, featuredStatusIds, isLoading, hasMore, isAccount, multiColumn, remote, remoteUrl } = this.props;

if (!isAccount) {
return (
@@ -97,6 +110,16 @@ class AccountTimeline extends ImmutablePureComponent {
);
}

let emptyMessage;

if (remote && statusIds.isEmpty()) {
emptyMessage = <RemoteHint url={remoteUrl} />;
} else {
emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />;
}

const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;

return (
<Column ref={this.setRef} name='account'>
<ProfileColumnHeader onClick={this.handleHeaderClick} multiColumn={multiColumn} />
@@ -104,13 +127,14 @@ class AccountTimeline extends ImmutablePureComponent {
<StatusList
prepend={<HeaderContainer accountId={this.props.params.accountId} />}
alwaysPrepend
append={remoteMessage}
scrollKey='account_timeline'
statusIds={statusIds}
featuredStatusIds={featuredStatusIds}
isLoading={isLoading}
hasMore={hasMore}
onLoadMore={this.handleLoadMore}
emptyMessage={<FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
timelineId='account'
/>

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

@@ -125,6 +125,7 @@ class Audio extends React.PureComponent {
this.wavesurfer.createPeakCache();
this.wavesurfer.load(this.props.src);
this.wavesurfer.toggleInteraction();
this.wavesurfer.setVolume(this.state.volume);
this.loaded = true;
}


+ 4
- 3
app/javascript/flavours/glitch/features/emoji_picker/index.js View File

@@ -279,12 +279,13 @@ class EmojiPickerMenu extends React.PureComponent {
};
}

handleClick = emoji => {
handleClick = (emoji, event) => {
if (!emoji.native) {
emoji.native = emoji.colons;
}

this.props.onClose();
if (!event.ctrlKey) {
this.props.onClose();
}
this.props.onPick(emoji);
}


+ 24
- 2
app/javascript/flavours/glitch/features/followers/index.js View File

@@ -17,14 +17,25 @@ import HeaderContainer from 'flavours/glitch/features/account_timeline/container
import ImmutablePureComponent from 'react-immutable-pure-component';
import MissingIndicator from 'flavours/glitch/components/missing_indicator';
import ScrollableList from 'flavours/glitch/components/scrollable_list';
import TimelineHint from 'flavours/glitch/components/timeline_hint';

const mapStateToProps = (state, props) => ({
remote: !!state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username']),
remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']),
isAccount: !!state.getIn(['accounts', props.params.accountId]),
accountIds: state.getIn(['user_lists', 'followers', props.params.accountId, 'items']),
hasMore: !!state.getIn(['user_lists', 'followers', props.params.accountId, 'next']),
isLoading: state.getIn(['user_lists', 'followers', props.params.accountId, 'isLoading'], true),
});

const RemoteHint = ({ url }) => (
<TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.followers' defaultMessage='Followers' />} />
);

RemoteHint.propTypes = {
url: PropTypes.string.isRequired,
};

export default @connect(mapStateToProps)
class Followers extends ImmutablePureComponent {

@@ -35,6 +46,8 @@ class Followers extends ImmutablePureComponent {
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
isAccount: PropTypes.bool,
remote: PropTypes.bool,
remoteUrl: PropTypes.string,
multiColumn: PropTypes.bool,
};

@@ -65,7 +78,7 @@ class Followers extends ImmutablePureComponent {
}

render () {
const { accountIds, hasMore, isAccount, multiColumn, isLoading } = this.props;
const { accountIds, hasMore, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props;

if (!isAccount) {
return (
@@ -83,7 +96,15 @@ class Followers extends ImmutablePureComponent {
);
}

const emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />;
let emptyMessage;

if (remote && accountIds.isEmpty()) {
emptyMessage = <RemoteHint url={remoteUrl} />;
} else {
emptyMessage = <FormattedMessage id='account.followers.empty' defaultMessage='No one follows this user yet.' />;
}

const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;

return (
<Column ref={this.setRef}>
@@ -96,6 +117,7 @@ class Followers extends ImmutablePureComponent {
onLoadMore={this.handleLoadMore}
prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
alwaysPrepend
append={remoteMessage}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>

+ 24
- 2
app/javascript/flavours/glitch/features/following/index.js View File

@@ -17,14 +17,25 @@ import HeaderContainer from 'flavours/glitch/features/account_timeline/container
import ImmutablePureComponent from 'react-immutable-pure-component';
import MissingIndicator from 'flavours/glitch/components/missing_indicator';
import ScrollableList from 'flavours/glitch/components/scrollable_list';
import TimelineHint from 'flavours/glitch/components/timeline_hint';

const mapStateToProps = (state, props) => ({
remote: !!state.getIn(['accounts', props.params.accountId, 'acct']) !== state.getIn(['accounts', props.params.accountId, 'username']),
remoteUrl: state.getIn(['accounts', props.params.accountId, 'url']),
isAccount: !!state.getIn(['accounts', props.params.accountId]),
accountIds: state.getIn(['user_lists', 'following', props.params.accountId, 'items']),
hasMore: !!state.getIn(['user_lists', 'following', props.params.accountId, 'next']),
isLoading: state.getIn(['user_lists', 'following', props.params.accountId, 'isLoading'], true),
});

const RemoteHint = ({ url }) => (
<TimelineHint url={url} resource={<FormattedMessage id='timeline_hint.resources.follows' defaultMessage='Follows' />} />
);

RemoteHint.propTypes = {
url: PropTypes.string.isRequired,
};

export default @connect(mapStateToProps)
class Following extends ImmutablePureComponent {

@@ -35,6 +46,8 @@ class Following extends ImmutablePureComponent {
hasMore: PropTypes.bool,
isLoading: PropTypes.bool,
isAccount: PropTypes.bool,
remote: PropTypes.bool,
remoteUrl: PropTypes.string,
multiColumn: PropTypes.bool,
};

@@ -65,7 +78,7 @@ class Following extends ImmutablePureComponent {
}

render () {
const { accountIds, hasMore, isAccount, multiColumn, isLoading } = this.props;
const { accountIds, hasMore, isAccount, multiColumn, isLoading, remote, remoteUrl } = this.props;

if (!isAccount) {
return (
@@ -83,7 +96,15 @@ class Following extends ImmutablePureComponent {
);
}

const emptyMessage = <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />;
let emptyMessage;

if (remote && accountIds.isEmpty()) {
emptyMessage = <RemoteHint url={remoteUrl} />;
} else {
emptyMessage = <FormattedMessage id='account.follows.empty' defaultMessage="This user doesn't follow anyone yet." />;
}

const remoteMessage = remote ? <RemoteHint url={remoteUrl} /> : null;

return (
<Column ref={this.setRef}>
@@ -96,6 +117,7 @@ class Following extends ImmutablePureComponent {
onLoadMore={this.handleLoadMore}
prepend={<HeaderContainer accountId={this.props.params.accountId} hideTabs />}
alwaysPrepend
append={remoteMessage}
emptyMessage={emptyMessage}
bindToDocument={!multiColumn}
>

+ 1
- 1
app/javascript/flavours/glitch/features/getting_started/containers/trends_container.js View File

@@ -1,5 +1,5 @@
import { connect } from 'react-redux';
import { fetchTrends } from 'mastodon/actions/trends';
import { fetchTrends } from 'flavours/glitch/actions/trends';
import Trends from '../components/trends';

const mapStateToProps = state => ({

+ 8
- 8
app/javascript/flavours/glitch/features/hashtag_timeline/index.js View File

@@ -12,7 +12,7 @@ import { connectHashtagStream } from 'flavours/glitch/actions/streaming';
import { isEqual } from 'lodash';

const mapStateToProps = (state, props) => ({
hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}`, 'unread']) > 0,
hasUnread: state.getIn(['timelines', `hashtag:${props.params.id}${props.params.local ? ':local' : ''}`, 'unread']) > 0,
});

export default @connect(mapStateToProps)
@@ -75,13 +75,13 @@ class HashtagTimeline extends React.PureComponent {
this.column.scrollTop();
}

_subscribe (dispatch, id, tags = {}) {
_subscribe (dispatch, id, tags = {}, local) {
let any = (tags.any || []).map(tag => tag.value);
let all = (tags.all || []).map(tag => tag.value);
let none = (tags.none || []).map(tag => tag.value);

[id, ...any].map(tag => {
this.disconnects.push(dispatch(connectHashtagStream(id, tag, status => {
this.disconnects.push(dispatch(connectHashtagStream(id, tag, local, status => {
let tags = status.tags.map(tag => tag.name);

return all.filter(tag => tags.includes(tag)).length === all.length &&
@@ -99,7 +99,7 @@ class HashtagTimeline extends React.PureComponent {
const { dispatch } = this.props;
const { id, tags, local } = this.props.params;

this._subscribe(dispatch, id, tags);
this._subscribe(dispatch, id, tags, local);
dispatch(expandHashtagTimeline(id, { tags, local }));
}

@@ -109,8 +109,8 @@ class HashtagTimeline extends React.PureComponent {

if (id !== params.id || !isEqual(tags, params.tags) || !isEqual(local, params.local)) {
this._unsubscribe();
this._subscribe(dispatch, id, tags);
dispatch(clearTimeline(`hashtag:${id}`));
this._subscribe(dispatch, id, tags, local);
dispatch(clearTimeline(`hashtag:${id}${local ? ':local' : ''}`));
dispatch(expandHashtagTimeline(id, { tags, local }));
}
}
@@ -130,7 +130,7 @@ class HashtagTimeline extends React.PureComponent {

render () {
const { hasUnread, columnId, multiColumn } = this.props;
const { id } = this.props.params;
const { id, local } = this.props.params;
const pinned = !!columnId;

return (
@@ -153,7 +153,7 @@ class HashtagTimeline extends React.PureComponent {
<StatusListContainer
trackScroll={!pinned}
scrollKey={`hashtag_timeline-${columnId}`}
timelineId={`hashtag:${id}`}
timelineId={`hashtag:${id}${local ? ':local' : ''}`}