Browse Source

Merge v3.3.0 into closed-social-v3

closed-social-v3
欧醚 3 years ago
parent
commit
fbf63eef29
728 changed files with 30003 additions and 8418 deletions
  1. +1
    -1
      .codeclimate.yml
  2. +1
    -1
      .github/ISSUE_TEMPLATE/bug_report.md
  3. +0
    -1
      .github/ISSUE_TEMPLATE/feature_request.md
  4. +2
    -2
      .github/dependabot.yml
  5. +2
    -1
      .rubocop.yml
  6. +1
    -1
      .ruby-version
  7. +374
    -257
      AUTHORS.md
  8. +234
    -3
      CHANGELOG.md
  9. +3
    -3
      Dockerfile
  10. +28
    -27
      Gemfile
  11. +133
    -122
      Gemfile.lock
  12. +2
    -2
      Procfile.dev
  13. +4
    -1
      app/controllers/about_controller.rb
  14. +5
    -1
      app/controllers/accounts_controller.rb
  15. +4
    -0
      app/controllers/activitypub/base_controller.rb
  16. +36
    -0
      app/controllers/activitypub/followers_synchronizations_controller.rb
  17. +23
    -5
      app/controllers/activitypub/inboxes_controller.rb
  18. +1
    -1
      app/controllers/activitypub/replies_controller.rb
  19. +7
    -0
      app/controllers/admin/accounts_controller.rb
  20. +1
    -1
      app/controllers/admin/announcements_controller.rb
  21. +5
    -4
      app/controllers/admin/domain_blocks_controller.rb
  22. +5
    -39
      app/controllers/admin/instances_controller.rb
  23. +56
    -0
      app/controllers/admin/ip_blocks_controller.rb
  24. +1
    -1
      app/controllers/admin/statuses_controller.rb
  25. +2
    -2
      app/controllers/api/base_controller.rb
  26. +2
    -2
      app/controllers/api/v1/accounts/featured_tags_controller.rb
  27. +2
    -2
      app/controllers/api/v1/accounts_controller.rb
  28. +8
    -0
      app/controllers/api/v1/admin/accounts_controller.rb
  29. +1
    -1
      app/controllers/api/v1/crypto/keys/claims_controller.rb
  30. +1
    -1
      app/controllers/api/v1/crypto/keys/queries_controller.rb
  31. +1
    -1
      app/controllers/api/v1/instances/peers_controller.rb
  32. +1
    -1
      app/controllers/api/v1/markers_controller.rb
  33. +1
    -1
      app/controllers/api/v1/mutes_controller.rb
  34. +13
    -2
      app/controllers/api/v1/statuses/favourites_controller.rb
  35. +1
    -1
      app/controllers/application_controller.rb
  36. +7
    -4
      app/controllers/auth/registrations_controller.rb
  37. +21
    -1
      app/controllers/auth/sessions_controller.rb
  38. +19
    -1
      app/controllers/concerns/account_owned_concern.rb
  39. +2
    -2
      app/controllers/concerns/cache_concern.rb
  40. +9
    -0
      app/controllers/concerns/registration_spam_concern.rb
  41. +9
    -7
      app/controllers/concerns/sign_in_token_authentication_concern.rb
  42. +12
    -4
      app/controllers/concerns/signature_verification.rb
  43. +18
    -14
      app/controllers/concerns/two_factor_authentication_concern.rb
  44. +3
    -4
      app/controllers/concerns/user_tracking_concern.rb
  45. +1
    -1
      app/controllers/filters_controller.rb
  46. +10
    -2
      app/controllers/follower_accounts_controller.rb
  47. +10
    -2
      app/controllers/following_accounts_controller.rb
  48. +8
    -1
      app/controllers/relationships_controller.rb
  49. +1
    -1
      app/controllers/settings/deletes_controller.rb
  50. +19
    -0
      app/controllers/settings/exports/bookmarks_controller.rb
  51. +6
    -2
      app/controllers/settings/pictures_controller.rb
  52. +1
    -0
      app/controllers/settings/preferences_controller.rb
  53. +1
    -1
      app/controllers/well_known/webfinger_controller.rb
  54. +4
    -0
      app/helpers/admin/action_logs_helper.rb
  55. +18
    -1
      app/helpers/application_helper.rb
  56. +4
    -0
      app/helpers/settings_helper.rb
  57. +14
    -22
      app/helpers/statuses_helper.rb
  58. +1
    -32
      app/helpers/webfinger_helper.rb
  59. +2
    -2
      app/javascript/mastodon/actions/accounts.js
  60. +7
    -0
      app/javascript/mastodon/actions/app.js
  61. +1
    -3
      app/javascript/mastodon/actions/compose.js
  62. +54
    -8
      app/javascript/mastodon/actions/markers.js
  63. +10
    -0
      app/javascript/mastodon/actions/mutes.js
  64. +51
    -0
      app/javascript/mastodon/actions/notifications.js
  65. +13
    -0
      app/javascript/mastodon/actions/onboarding.js
  66. +38
    -0
      app/javascript/mastodon/actions/picture_in_picture.js
  67. +112
    -0
      app/javascript/mastodon/blurhash.js
  68. +0
    -49
      app/javascript/mastodon/components/__tests__/__snapshots__/button-test.js.snap
  69. +7
    -0
      app/javascript/mastodon/components/account.js
  70. +14
    -3
      app/javascript/mastodon/components/animated_number.js
  71. +1
    -2
      app/javascript/mastodon/components/autosuggest_emoji.js
  72. +1
    -7
      app/javascript/mastodon/components/autosuggest_input.js
  73. +1
    -7
      app/javascript/mastodon/components/autosuggest_textarea.js
  74. +0
    -14
      app/javascript/mastodon/components/button.js
  75. +3
    -3
      app/javascript/mastodon/components/column.js
  76. +16
    -2
      app/javascript/mastodon/components/column_header.js
  77. +2
    -2
      app/javascript/mastodon/components/dropdown_menu.js
  78. +11
    -1
      app/javascript/mastodon/components/icon_button.js
  79. +3
    -1
      app/javascript/mastodon/components/icon_with_badge.js
  80. +6
    -6
      app/javascript/mastodon/components/intersection_observer_article.js
  81. +16
    -18
      app/javascript/mastodon/components/modal_root.js
  82. +69
    -0
      app/javascript/mastodon/components/picture_in_picture_placeholder.js
  83. +40
    -15
      app/javascript/mastodon/components/status.js
  84. +4
    -13
      app/javascript/mastodon/components/status_action_bar.js
  85. +6
    -12
      app/javascript/mastodon/components/status_content.js
  86. +25
    -7
      app/javascript/mastodon/containers/media_container.js
  87. +13
    -6
      app/javascript/mastodon/containers/status_container.js
  88. +6
    -6
      app/javascript/mastodon/features/account/components/header.js
  89. +1
    -1
      app/javascript/mastodon/features/account_gallery/components/media_item.js
  90. +15
    -4
      app/javascript/mastodon/features/account_gallery/index.js
  91. +3
    -1
      app/javascript/mastodon/features/account_timeline/index.js
  92. +101
    -11
      app/javascript/mastodon/features/audio/index.js
  93. +16
    -10
      app/javascript/mastodon/features/compose/components/compose_form.js
  94. +3
    -3
      app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
  95. +2
    -2
      app/javascript/mastodon/features/compose/components/privacy_dropdown.js
  96. +1
    -5
      app/javascript/mastodon/features/compose/components/reply_indicator.js
  97. +17
    -5
      app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
  98. +1
    -2
      app/javascript/mastodon/features/emoji/emoji.js
  99. +5
    -5
      app/javascript/mastodon/features/getting_started/components/announcements.js
  100. +2
    -2
      app/javascript/mastodon/features/getting_started/index.js

+ 1
- 1
.codeclimate.yml View File

@ -30,7 +30,7 @@ plugins:
channel: eslint-7 channel: eslint-7
rubocop: rubocop:
enabled: true enabled: true
channel: rubocop-0-88
channel: rubocop-1-70
sass-lint: sass-lint:
enabled: true enabled: true
exclude_patterns: exclude_patterns:

+ 1
- 1
.github/ISSUE_TEMPLATE/bug_report.md View File

@ -1,7 +1,7 @@
--- ---
name: Bug Report name: Bug Report
about: If something isn't working as expected about: If something isn't working as expected
labels: bug
--- ---
<!-- Make sure that you are submitting a new bug that was not previously reported or already fixed --> <!-- Make sure that you are submitting a new bug that was not previously reported or already fixed -->

+ 0
- 1
.github/ISSUE_TEMPLATE/feature_request.md View File

@ -1,7 +1,6 @@
--- ---
name: Feature Request name: Feature Request
about: I have a suggestion about: I have a suggestion
--- ---
<!-- Please use a concise and distinct title for the issue --> <!-- Please use a concise and distinct title for the issue -->

+ 2
- 2
.github/dependabot.yml View File

@ -11,7 +11,7 @@ updates:
interval: weekly interval: weekly
open-pull-requests-limit: 99 open-pull-requests-limit: 99
allow: allow:
- dependency-type: all
- dependency-type: direct
- package-ecosystem: bundler - package-ecosystem: bundler
directory: "/" directory: "/"
@ -19,4 +19,4 @@ updates:
interval: weekly interval: weekly
open-pull-requests-limit: 99 open-pull-requests-limit: 99
allow: allow:
- dependency-type: all
- dependency-type: direct

+ 2
- 1
.rubocop.yml View File

@ -2,7 +2,8 @@ require:
- rubocop-rails - rubocop-rails
AllCops: AllCops:
TargetRubyVersion: 2.4
TargetRubyVersion: 2.5
NewCops: disable
Exclude: Exclude:
- 'spec/**/*' - 'spec/**/*'
- 'db/**/*' - 'db/**/*'

+ 1
- 1
.ruby-version View File

@ -1 +1 @@
2.6.6
2.7.2

+ 374
- 257
AUTHORS.md View File

@ -5,38 +5,39 @@ Mastodon is available on [GitHub](https://github.com/tootsuite/mastodon)
and provided thanks to the work of the following contributors: and provided thanks to the work of the following contributors:
* [Gargron](https://github.com/Gargron) * [Gargron](https://github.com/Gargron)
* [dependabot-preview[bot]](https://github.com/apps/dependabot-preview)
* [ThibG](https://github.com/ThibG) * [ThibG](https://github.com/ThibG)
* [ykzts](https://github.com/ykzts)
* [dependabot-preview[bot]](https://github.com/apps/dependabot-preview)
* [dependabot[bot]](https://github.com/apps/dependabot) * [dependabot[bot]](https://github.com/apps/dependabot)
* [ykzts](https://github.com/ykzts)
* [akihikodaki](https://github.com/akihikodaki) * [akihikodaki](https://github.com/akihikodaki)
* [mjankowski](https://github.com/mjankowski) * [mjankowski](https://github.com/mjankowski)
* [unarist](https://github.com/unarist) * [unarist](https://github.com/unarist)
* [yiskah](https://github.com/yiskah) * [yiskah](https://github.com/yiskah)
* [nolanlawson](https://github.com/nolanlawson) * [nolanlawson](https://github.com/nolanlawson)
* [abcang](https://github.com/abcang) * [abcang](https://github.com/abcang)
* [ysksn](https://github.com/ysksn)
* [mayaeh](https://github.com/mayaeh) * [mayaeh](https://github.com/mayaeh)
* [ysksn](https://github.com/ysksn)
* [sorin-davidoi](https://github.com/sorin-davidoi) * [sorin-davidoi](https://github.com/sorin-davidoi)
* [noellabo](https://github.com/noellabo)
* [lynlynlynx](https://github.com/lynlynlynx) * [lynlynlynx](https://github.com/lynlynlynx)
* [m4sk1n](mailto:me@m4sk.in) * [m4sk1n](mailto:me@m4sk.in)
* [Marcin Mikołajczak](mailto:me@m4sk.in) * [Marcin Mikołajczak](mailto:me@m4sk.in)
* [Kjwon15](https://github.com/Kjwon15) * [Kjwon15](https://github.com/Kjwon15)
* [noellabo](https://github.com/noellabo)
* [renatolond](https://github.com/renatolond) * [renatolond](https://github.com/renatolond)
* [alpaca-tc](https://github.com/alpaca-tc) * [alpaca-tc](https://github.com/alpaca-tc)
* [jeroenpraat](https://github.com/jeroenpraat) * [jeroenpraat](https://github.com/jeroenpraat)
* [nclm](https://github.com/nclm) * [nclm](https://github.com/nclm)
* [ineffyble](https://github.com/ineffyble) * [ineffyble](https://github.com/ineffyble)
* [shleeable](https://github.com/shleeable)
* [zunda](https://github.com/zunda) * [zunda](https://github.com/zunda)
* [shleeable](https://github.com/shleeable)
* [Masoud Abkenar](mailto:ampbox@gmail.com) * [Masoud Abkenar](mailto:ampbox@gmail.com)
* [blackle](https://github.com/blackle) * [blackle](https://github.com/blackle)
* [Quent-in](https://github.com/Quent-in) * [Quent-in](https://github.com/Quent-in)
* [JantsoP](https://github.com/JantsoP) * [JantsoP](https://github.com/JantsoP)
* [nullkal](https://github.com/nullkal) * [nullkal](https://github.com/nullkal)
* [yookoala](https://github.com/yookoala) * [yookoala](https://github.com/yookoala)
* [Sasha-Sorokin](https://github.com/Sasha-Sorokin)
* [Brawaru](https://github.com/Brawaru)
* [ariasuni](https://github.com/ariasuni)
* [Aditoo17](https://github.com/Aditoo17) * [Aditoo17](https://github.com/Aditoo17)
* [Quenty31](https://github.com/Quenty31) * [Quenty31](https://github.com/Quenty31)
* [marek-lach](https://github.com/marek-lach) * [marek-lach](https://github.com/marek-lach)
@ -45,9 +46,9 @@ and provided thanks to the work of the following contributors:
* [danhunsaker](https://github.com/danhunsaker) * [danhunsaker](https://github.com/danhunsaker)
* [eramdam](https://github.com/eramdam) * [eramdam](https://github.com/eramdam)
* [takayamaki](https://github.com/takayamaki) * [takayamaki](https://github.com/takayamaki)
* [ariasuni](https://github.com/ariasuni)
* [masarakki](https://github.com/masarakki) * [masarakki](https://github.com/masarakki)
* [ticky](https://github.com/ticky) * [ticky](https://github.com/ticky)
* [trwnh](https://github.com/trwnh)
* [ThisIsMissEm](https://github.com/ThisIsMissEm) * [ThisIsMissEm](https://github.com/ThisIsMissEm)
* [hinaloe](https://github.com/hinaloe) * [hinaloe](https://github.com/hinaloe)
* [hcmiya](https://github.com/hcmiya) * [hcmiya](https://github.com/hcmiya)
@ -57,10 +58,10 @@ and provided thanks to the work of the following contributors:
* [yukimochi](https://github.com/yukimochi) * [yukimochi](https://github.com/yukimochi)
* [palindromordnilap](https://github.com/palindromordnilap) * [palindromordnilap](https://github.com/palindromordnilap)
* [rkarabut](https://github.com/rkarabut) * [rkarabut](https://github.com/rkarabut)
* [trwnh](https://github.com/trwnh)
* [nightpool](https://github.com/nightpool) * [nightpool](https://github.com/nightpool)
* [Artoria2e5](https://github.com/Artoria2e5) * [Artoria2e5](https://github.com/Artoria2e5)
* [marrus-sh](https://github.com/marrus-sh) * [marrus-sh](https://github.com/marrus-sh)
* [dunn](https://github.com/dunn)
* [krainboltgreene](https://github.com/krainboltgreene) * [krainboltgreene](https://github.com/krainboltgreene)
* [pfigel](https://github.com/pfigel) * [pfigel](https://github.com/pfigel)
* [BoFFire](https://github.com/BoFFire) * [BoFFire](https://github.com/BoFFire)
@ -84,25 +85,25 @@ and provided thanks to the work of the following contributors:
* [ashleyhull-versent](https://github.com/ashleyhull-versent) * [ashleyhull-versent](https://github.com/ashleyhull-versent)
* [yhirano55](https://github.com/yhirano55) * [yhirano55](https://github.com/yhirano55)
* [rinsuki](https://github.com/rinsuki) * [rinsuki](https://github.com/rinsuki)
* [dunn](https://github.com/dunn)
* [devkral](https://github.com/devkral) * [devkral](https://github.com/devkral)
* [camponez](https://github.com/camponez) * [camponez](https://github.com/camponez)
* [hugogameiro](https://github.com/hugogameiro) * [hugogameiro](https://github.com/hugogameiro)
* [SerCom_KC](mailto:szescxz@gmail.com) * [SerCom_KC](mailto:szescxz@gmail.com)
* [aschmitz](https://github.com/aschmitz) * [aschmitz](https://github.com/aschmitz)
* [mfmfuyu](https://github.com/mfmfuyu)
* [kedamaDQ](https://github.com/kedamaDQ)
* [fpiesche](https://github.com/fpiesche) * [fpiesche](https://github.com/fpiesche)
* [gandaro](https://github.com/gandaro) * [gandaro](https://github.com/gandaro)
* [johnsudaar](https://github.com/johnsudaar) * [johnsudaar](https://github.com/johnsudaar)
* [trebmuh](https://github.com/trebmuh) * [trebmuh](https://github.com/trebmuh)
* [rmhasan](https://github.com/rmhasan) * [rmhasan](https://github.com/rmhasan)
* [kedamaDQ](https://github.com/kedamaDQ)
* [lindwurm](https://github.com/lindwurm) * [lindwurm](https://github.com/lindwurm)
* [victorhck](mailto:victorhck@geeko.site) * [victorhck](mailto:victorhck@geeko.site)
* [voidsatisfaction](https://github.com/voidsatisfaction) * [voidsatisfaction](https://github.com/voidsatisfaction)
* [mkljczk](https://github.com/mkljczk)
* [hikari-no-yume](https://github.com/hikari-no-yume) * [hikari-no-yume](https://github.com/hikari-no-yume)
* [seefood](https://github.com/seefood) * [seefood](https://github.com/seefood)
* [jackjennings](https://github.com/jackjennings) * [jackjennings](https://github.com/jackjennings)
* [mfmfuyu](https://github.com/mfmfuyu)
* [puckipedia](https://github.com/puckipedia) * [puckipedia](https://github.com/puckipedia)
* [spla](mailto:spla@mastodont.cat) * [spla](mailto:spla@mastodont.cat)
* [walf443](https://github.com/walf443) * [walf443](https://github.com/walf443)
@ -111,14 +112,15 @@ and provided thanks to the work of the following contributors:
* [Ashley](mailto:expenses@airmail.cc) * [Ashley](mailto:expenses@airmail.cc)
* [xqus](https://github.com/xqus) * [xqus](https://github.com/xqus)
* [pfm-eyesightjp](https://github.com/pfm-eyesightjp) * [pfm-eyesightjp](https://github.com/pfm-eyesightjp)
* [Samy KACIMI](mailto:samy.kacimi@gmail.com)
* [fakenine](https://github.com/fakenine)
* [tsuwatch](https://github.com/tsuwatch) * [tsuwatch](https://github.com/tsuwatch)
* [victorhck](https://github.com/victorhck) * [victorhck](https://github.com/victorhck)
* [mkljczk](https://github.com/mkljczk)
* [manuelviens](https://github.com/manuelviens) * [manuelviens](https://github.com/manuelviens)
* [tateisu](https://github.com/tateisu)
* [fvh-P](https://github.com/fvh-P) * [fvh-P](https://github.com/fvh-P)
* [rtucker](https://github.com/rtucker) * [rtucker](https://github.com/rtucker)
* [Anna e só](mailto:contraexemplos@gmail.com) * [Anna e só](mailto:contraexemplos@gmail.com)
* [dariusk](https://github.com/dariusk)
* [kazu9su](https://github.com/kazu9su) * [kazu9su](https://github.com/kazu9su)
* [Komic](https://github.com/Komic) * [Komic](https://github.com/Komic)
* [lmorchard](https://github.com/lmorchard) * [lmorchard](https://github.com/lmorchard)
@ -145,9 +147,9 @@ and provided thanks to the work of the following contributors:
* [fhemberger](https://github.com/fhemberger) * [fhemberger](https://github.com/fhemberger)
* [Gomasy](https://github.com/Gomasy) * [Gomasy](https://github.com/Gomasy)
* [greysteil](https://github.com/greysteil) * [greysteil](https://github.com/greysteil)
* [hencatsmith](https://github.com/hencatsmith)
* [hendotcat](https://github.com/hendotcat)
* [d6rkaiz](https://github.com/d6rkaiz) * [d6rkaiz](https://github.com/d6rkaiz)
* [Reverite](https://github.com/Reverite)
* [ladyisatis](https://github.com/ladyisatis)
* [JohnD28](https://github.com/JohnD28) * [JohnD28](https://github.com/JohnD28)
* [znz](https://github.com/znz) * [znz](https://github.com/znz)
* [saper](https://github.com/saper) * [saper](https://github.com/saper)
@ -160,14 +162,14 @@ and provided thanks to the work of the following contributors:
* [leopku](https://github.com/leopku) * [leopku](https://github.com/leopku)
* [SansPseudoFix](https://github.com/SansPseudoFix) * [SansPseudoFix](https://github.com/SansPseudoFix)
* [spla](mailto:sp@mastodont.cat) * [spla](mailto:sp@mastodont.cat)
* [tateisu](https://github.com/tateisu)
* [tomfhowe](https://github.com/tomfhowe) * [tomfhowe](https://github.com/tomfhowe)
* [noraworld](https://github.com/noraworld) * [noraworld](https://github.com/noraworld)
* [lfuelling](https://github.com/lfuelling) * [lfuelling](https://github.com/lfuelling)
* [theboss](https://github.com/theboss)
* [aji-su](https://github.com/aji-su)
* [nzws](https://github.com/nzws) * [nzws](https://github.com/nzws)
* [duxovni](https://github.com/duxovni) * [duxovni](https://github.com/duxovni)
* [smorimoto](https://github.com/smorimoto) * [smorimoto](https://github.com/smorimoto)
* [mashirozx](https://github.com/mashirozx)
* [178inaba](https://github.com/178inaba) * [178inaba](https://github.com/178inaba)
* [acid-chicken](https://github.com/acid-chicken) * [acid-chicken](https://github.com/acid-chicken)
* [xgess](https://github.com/xgess) * [xgess](https://github.com/xgess)
@ -175,7 +177,6 @@ and provided thanks to the work of the following contributors:
* [aablinov](https://github.com/aablinov) * [aablinov](https://github.com/aablinov)
* [stalker314314](https://github.com/stalker314314) * [stalker314314](https://github.com/stalker314314)
* [cutls](https://github.com/cutls) * [cutls](https://github.com/cutls)
* [dariusk](https://github.com/dariusk)
* [huertanix](https://github.com/huertanix) * [huertanix](https://github.com/huertanix)
* [eleboucher](https://github.com/eleboucher) * [eleboucher](https://github.com/eleboucher)
* [halkeye](https://github.com/halkeye) * [halkeye](https://github.com/halkeye)
@ -183,7 +184,7 @@ and provided thanks to the work of the following contributors:
* [treby](https://github.com/treby) * [treby](https://github.com/treby)
* [jpdevries](https://github.com/jpdevries) * [jpdevries](https://github.com/jpdevries)
* [gdpelican](https://github.com/gdpelican) * [gdpelican](https://github.com/gdpelican)
* [kmichl](https://github.com/kmichl)
* [Korbinian](mailto:kontakt@korbinian-michl.de)
* [Kurtis Rainbolt-Greene](mailto:me@kurtisrainboltgreene.name) * [Kurtis Rainbolt-Greene](mailto:me@kurtisrainboltgreene.name)
* [panarom](https://github.com/panarom) * [panarom](https://github.com/panarom)
* [Dar13](https://github.com/Dar13) * [Dar13](https://github.com/Dar13)
@ -225,6 +226,7 @@ and provided thanks to the work of the following contributors:
* [aaribaud](https://github.com/aaribaud) * [aaribaud](https://github.com/aaribaud)
* [pointlessone](https://github.com/pointlessone) * [pointlessone](https://github.com/pointlessone)
* [Andrew](mailto:andrewlchronister@gmail.com) * [Andrew](mailto:andrewlchronister@gmail.com)
* [arielrodrigues](https://github.com/arielrodrigues)
* [aurelien-reeves](https://github.com/aurelien-reeves) * [aurelien-reeves](https://github.com/aurelien-reeves)
* [elegaanz](https://github.com/elegaanz) * [elegaanz](https://github.com/elegaanz)
* [estuans](https://github.com/estuans) * [estuans](https://github.com/estuans)
@ -238,6 +240,7 @@ and provided thanks to the work of the following contributors:
* [muffinista](https://github.com/muffinista) * [muffinista](https://github.com/muffinista)
* [cdutson](https://github.com/cdutson) * [cdutson](https://github.com/cdutson)
* [farlistener](https://github.com/farlistener) * [farlistener](https://github.com/farlistener)
* [divergentdave](https://github.com/divergentdave)
* [DavidLibeau](https://github.com/DavidLibeau) * [DavidLibeau](https://github.com/DavidLibeau)
* [dmerejkowsky](https://github.com/dmerejkowsky) * [dmerejkowsky](https://github.com/dmerejkowsky)
* [ddevault](https://github.com/ddevault) * [ddevault](https://github.com/ddevault)
@ -276,7 +279,7 @@ and provided thanks to the work of the following contributors:
* [xPaw](https://github.com/xPaw) * [xPaw](https://github.com/xPaw)
* [petzah](https://github.com/petzah) * [petzah](https://github.com/petzah)
* [ignisf](https://github.com/ignisf) * [ignisf](https://github.com/ignisf)
* [raymestalez](https://github.com/raymestalez)
* [lumenwrites](https://github.com/lumenwrites)
* [remram44](https://github.com/remram44) * [remram44](https://github.com/remram44)
* [sts10](https://github.com/sts10) * [sts10](https://github.com/sts10)
* [SuperSandro2000](https://github.com/SuperSandro2000) * [SuperSandro2000](https://github.com/SuperSandro2000)
@ -286,8 +289,9 @@ and provided thanks to the work of the following contributors:
* [Sir-Boops](https://github.com/Sir-Boops) * [Sir-Boops](https://github.com/Sir-Boops)
* [stemid](https://github.com/stemid) * [stemid](https://github.com/stemid)
* [sumdog](https://github.com/sumdog) * [sumdog](https://github.com/sumdog)
* [OmmyZhang](https://github.com/OmmyZhang)
* [ThomasLeister](https://github.com/ThomasLeister) * [ThomasLeister](https://github.com/ThomasLeister)
* [mcat-ee](https://github.com/mcat-ee)
* [Tom McAtee](mailto:a1608768@student.adelaide.edu.au)
* [tototoshi](https://github.com/tototoshi) * [tototoshi](https://github.com/tototoshi)
* [TrashMacNugget](https://github.com/TrashMacNugget) * [TrashMacNugget](https://github.com/TrashMacNugget)
* [VirtuBox](https://github.com/VirtuBox) * [VirtuBox](https://github.com/VirtuBox)
@ -314,11 +318,13 @@ and provided thanks to the work of the following contributors:
* [matsurai25](https://github.com/matsurai25) * [matsurai25](https://github.com/matsurai25)
* [mecab](https://github.com/mecab) * [mecab](https://github.com/mecab)
* [nicobz25](https://github.com/nicobz25) * [nicobz25](https://github.com/nicobz25)
* [niwatori24](https://github.com/niwatori24)
* [oliverkeeble](https://github.com/oliverkeeble) * [oliverkeeble](https://github.com/oliverkeeble)
* [partev](https://github.com/partev) * [partev](https://github.com/partev)
* [pinfort](https://github.com/pinfort) * [pinfort](https://github.com/pinfort)
* [rbaumert](https://github.com/rbaumert) * [rbaumert](https://github.com/rbaumert)
* [rhoio](https://github.com/rhoio) * [rhoio](https://github.com/rhoio)
* [santiagorodriguez96](https://github.com/santiagorodriguez96)
* [sclaire-1](https://github.com/sclaire-1) * [sclaire-1](https://github.com/sclaire-1)
* [umonaca](https://github.com/umonaca) * [umonaca](https://github.com/umonaca)
* [usagi-f](https://github.com/usagi-f) * [usagi-f](https://github.com/usagi-f)
@ -327,7 +333,7 @@ and provided thanks to the work of the following contributors:
* [wxcafe](https://github.com/wxcafe) * [wxcafe](https://github.com/wxcafe)
* [Grawl](https://github.com/Grawl) * [Grawl](https://github.com/Grawl)
* [新都心(Neet Shin)](mailto:nucx@dio-vox.com) * [新都心(Neet Shin)](mailto:nucx@dio-vox.com)
* [clarfon](https://github.com/clarfon)
* [clarfonthey](https://github.com/clarfonthey)
* [cygnan](https://github.com/cygnan) * [cygnan](https://github.com/cygnan)
* [Awea](https://github.com/Awea) * [Awea](https://github.com/Awea)
* [eai04191](https://github.com/eai04191) * [eai04191](https://github.com/eai04191)
@ -358,11 +364,11 @@ and provided thanks to the work of the following contributors:
* [schas002](https://github.com/schas002) * [schas002](https://github.com/schas002)
* [contraexemplo](https://github.com/contraexemplo) * [contraexemplo](https://github.com/contraexemplo)
* [abackstrom](https://github.com/abackstrom) * [abackstrom](https://github.com/abackstrom)
* [arielrodrigues](https://github.com/arielrodrigues)
* [orlea](https://github.com/orlea) * [orlea](https://github.com/orlea)
* [armandfardeau](https://github.com/armandfardeau) * [armandfardeau](https://github.com/armandfardeau)
* [raboof](https://github.com/raboof) * [raboof](https://github.com/raboof)
* [jumbosushi](https://github.com/jumbosushi) * [jumbosushi](https://github.com/jumbosushi)
* [acuteaura](https://github.com/acuteaura)
* [ayumin](https://github.com/ayumin) * [ayumin](https://github.com/ayumin)
* [bzg](https://github.com/bzg) * [bzg](https://github.com/bzg)
* [BastienDurel](https://github.com/BastienDurel) * [BastienDurel](https://github.com/BastienDurel)
@ -389,7 +395,7 @@ and provided thanks to the work of the following contributors:
* [colindean](https://github.com/colindean) * [colindean](https://github.com/colindean)
* [DeeUnderscore](https://github.com/DeeUnderscore) * [DeeUnderscore](https://github.com/DeeUnderscore)
* [dachinat](https://github.com/dachinat) * [dachinat](https://github.com/dachinat)
* [shapeshifter-system](https://github.com/shapeshifter-system)
* [monsterpit-firedemon](https://github.com/monsterpit-firedemon)
* [watilde](https://github.com/watilde) * [watilde](https://github.com/watilde)
* [daprice](https://github.com/daprice) * [daprice](https://github.com/daprice)
* [da2x](https://github.com/da2x) * [da2x](https://github.com/da2x)
@ -400,14 +406,13 @@ and provided thanks to the work of the following contributors:
* [singingwolfboy](https://github.com/singingwolfboy) * [singingwolfboy](https://github.com/singingwolfboy)
* [caldwell](https://github.com/caldwell) * [caldwell](https://github.com/caldwell)
* [davidcelis](https://github.com/davidcelis) * [davidcelis](https://github.com/davidcelis)
* [divergentdave](https://github.com/divergentdave)
* [davefp](https://github.com/davefp) * [davefp](https://github.com/davefp)
* [yipdw](https://github.com/yipdw) * [yipdw](https://github.com/yipdw)
* [debanshuk](https://github.com/debanshuk) * [debanshuk](https://github.com/debanshuk)
* [mascali33](https://github.com/mascali33) * [mascali33](https://github.com/mascali33)
* [DerekNonGeneric](https://github.com/DerekNonGeneric) * [DerekNonGeneric](https://github.com/DerekNonGeneric)
* [dblandin](https://github.com/dblandin) * [dblandin](https://github.com/dblandin)
* [Drew Gates](mailto:aranaur@users.noreply.github.com)
* [Aranaur](https://github.com/Aranaur)
* [dtschust](https://github.com/dtschust) * [dtschust](https://github.com/dtschust)
* [Dryusdan](https://github.com/Dryusdan) * [Dryusdan](https://github.com/Dryusdan)
* [d3vgru](https://github.com/d3vgru) * [d3vgru](https://github.com/d3vgru)
@ -451,22 +456,25 @@ and provided thanks to the work of the following contributors:
* [J Yeary](mailto:usbsnowcrash@users.noreply.github.com) * [J Yeary](mailto:usbsnowcrash@users.noreply.github.com)
* [jack-michaud](https://github.com/jack-michaud) * [jack-michaud](https://github.com/jack-michaud)
* [Floppy](https://github.com/Floppy) * [Floppy](https://github.com/Floppy)
* [loomchild](https://github.com/loomchild)
* [jglauche](https://github.com/jglauche)
* [jenkr55](https://github.com/jenkr55)
* [hyenagirl64](https://github.com/hyenagirl64)
* [press5](https://github.com/press5)
* [TrollDecker](https://github.com/TrollDecker)
* [jmontane](https://github.com/jmontane)
* [Jarek Lipski](mailto:pub@loomchild.net)
* [Jennifer Glauche](mailto:=^.^=@github19.jglauche.de)
* [Jennifer Kruse](mailto:jenkr55@gmail.com)
* [Jeremy Rose](mailto:nornagon@nornagon.net)
* [Jessica](mailto:46502909+hyenagirl64@users.noreply.github.com)
* [Jessica K. Litwin](mailto:jessica@litw.in)
* [Jo Decker](mailto:trolldecker@users.noreply.github.com)
* [Joan Montané](mailto:jmontane@users.noreply.github.com)
* [Jonathan Klee](mailto:klee.jonathan@gmail.com) * [Jonathan Klee](mailto:klee.jonathan@gmail.com)
* [Jordan Guerder](mailto:jguerder@fr.pulseheberg.net) * [Jordan Guerder](mailto:jguerder@fr.pulseheberg.net)
* [Joseph Mingrone](mailto:jehops@users.noreply.github.com) * [Joseph Mingrone](mailto:jehops@users.noreply.github.com)
* [Josh Leeb-du Toit](mailto:mail@joshleeb.com)
* [Joshua Wood](mailto:josh@joshuawood.net) * [Joshua Wood](mailto:josh@joshuawood.net)
* [Julien](mailto:tiwy57@users.noreply.github.com) * [Julien](mailto:tiwy57@users.noreply.github.com)
* [Julien Deswaef](mailto:juego@requiem4tv.com) * [Julien Deswaef](mailto:juego@requiem4tv.com)
* [June Sallou](mailto:jnsll@users.noreply.github.com) * [June Sallou](mailto:jnsll@users.noreply.github.com)
* [Jérémy Benoist](mailto:j0k3r@users.noreply.github.com) * [Jérémy Benoist](mailto:j0k3r@users.noreply.github.com)
* [KEINOS](mailto:github@keinos.com) * [KEINOS](mailto:github@keinos.com)
* [Kairui Song | 宋恺睿](mailto:ryncsn@gmail.com)
* [Keiji Matsuzaki](mailto:futoase@gmail.com) * [Keiji Matsuzaki](mailto:futoase@gmail.com)
* [Kevin Liu](mailto:kevin@potatofrom.space) * [Kevin Liu](mailto:kevin@potatofrom.space)
* [Kit Redgrave](mailto:qwertyitis@gmail.com) * [Kit Redgrave](mailto:qwertyitis@gmail.com)
@ -482,7 +490,6 @@ and provided thanks to the work of the following contributors:
* [Lukas Burk](mailto:jemus42@users.noreply.github.com) * [Lukas Burk](mailto:jemus42@users.noreply.github.com)
* [Manato Kameya](mailto:grabacr07+github@gmail.com) * [Manato Kameya](mailto:grabacr07+github@gmail.com)
* [Mantas](mailto:mistermantas@users.noreply.github.com) * [Mantas](mailto:mistermantas@users.noreply.github.com)
* [Marcin Mikołajczak](mailto:me@mkljczk.pl)
* [Mareena Kunjachan](mailto:mareenakunjachan@gmail.com) * [Mareena Kunjachan](mailto:mareenakunjachan@gmail.com)
* [Marek Lach](mailto:marek.brohatwack.lach@gmail.com) * [Marek Lach](mailto:marek.brohatwack.lach@gmail.com)
* [Markus R](mailto:wirehack7@users.noreply.github.com) * [Markus R](mailto:wirehack7@users.noreply.github.com)
@ -529,10 +536,12 @@ and provided thanks to the work of the following contributors:
* [Norayr Chilingarian](mailto:norayr@arnet.am) * [Norayr Chilingarian](mailto:norayr@arnet.am)
* [Noëlle Anthony](mailto:noelle.d.anthony@gmail.com) * [Noëlle Anthony](mailto:noelle.d.anthony@gmail.com)
* [N氏](mailto:uenok.htc@gmail.com) * [N氏](mailto:uenok.htc@gmail.com)
* [OSAMU SATO](mailto:satosamu@gmail.com)
* [Olivier Nicole](mailto:olivierthnicole@gmail.com) * [Olivier Nicole](mailto:olivierthnicole@gmail.com)
* [Oskari Noppa](mailto:noppa@users.noreply.github.com) * [Oskari Noppa](mailto:noppa@users.noreply.github.com)
* [Otakan](mailto:otakan951@gmail.com) * [Otakan](mailto:otakan951@gmail.com)
* [Padraig Fahy](mailto:tech@padraigfahy.com) * [Padraig Fahy](mailto:tech@padraigfahy.com)
* [Patrice Ferlet](mailto:metal3d@gmail.com)
* [PatrickRWells](mailto:32802366+patrickrwells@users.noreply.github.com) * [PatrickRWells](mailto:32802366+patrickrwells@users.noreply.github.com)
* [Paul](mailto:naydex.mc+github@gmail.com) * [Paul](mailto:naydex.mc+github@gmail.com)
* [Pete Keen](mailto:pete@petekeen.net) * [Pete Keen](mailto:pete@petekeen.net)
@ -574,7 +583,6 @@ and provided thanks to the work of the following contributors:
* [TakesxiSximada](mailto:takesxi.sximada@gmail.com) * [TakesxiSximada](mailto:takesxi.sximada@gmail.com)
* [Tao Bror Bojlén](mailto:brortao@users.noreply.github.com) * [Tao Bror Bojlén](mailto:brortao@users.noreply.github.com)
* [Taras Gogol](mailto:taras2358@gmail.com) * [Taras Gogol](mailto:taras2358@gmail.com)
* [Tdxdxoz](mailto:tdxdxoz@gmail.com)
* [TheInventrix](mailto:theinventrix@users.noreply.github.com) * [TheInventrix](mailto:theinventrix@users.noreply.github.com)
* [TheMainOne](mailto:50847364+theevilskeleton@users.noreply.github.com) * [TheMainOne](mailto:50847364+theevilskeleton@users.noreply.github.com)
* [Thomas Alberola](mailto:thomas@needacoffee.fr) * [Thomas Alberola](mailto:thomas@needacoffee.fr)
@ -594,6 +602,7 @@ and provided thanks to the work of the following contributors:
* [Wesley Ellis](mailto:tahnok@gmail.com) * [Wesley Ellis](mailto:tahnok@gmail.com)
* [Wiktor](mailto:wiktor@metacode.biz) * [Wiktor](mailto:wiktor@metacode.biz)
* [Wonderfall](mailto:wonderfall@schrodinger.io) * [Wonderfall](mailto:wonderfall@schrodinger.io)
* [Y.Yamashiro](mailto:shukukei@mojizuri.jp)
* [YDrogen](mailto:ydrogen45@gmail.com) * [YDrogen](mailto:ydrogen45@gmail.com)
* [YMHuang](mailto:ymhuang@fmbase.tw) * [YMHuang](mailto:ymhuang@fmbase.tw)
* [YOSHIOKA Eiichiro](mailto:yoshioka.eiichiro@gmail.com) * [YOSHIOKA Eiichiro](mailto:yoshioka.eiichiro@gmail.com)
@ -638,6 +647,7 @@ and provided thanks to the work of the following contributors:
* [jumoru](mailto:jumoru@mailbox.org) * [jumoru](mailto:jumoru@mailbox.org)
* [kaiyou](mailto:pierre@jaury.eu) * [kaiyou](mailto:pierre@jaury.eu)
* [karlyeurl](mailto:karl.yeurl@gmail.com) * [karlyeurl](mailto:karl.yeurl@gmail.com)
* [kawaguchi](mailto:jiikko@users.noreply.github.com)
* [kedama](mailto:32974885+kedamadq@users.noreply.github.com) * [kedama](mailto:32974885+kedamadq@users.noreply.github.com)
* [kuro5hin](mailto:rusty@kuro5hin.org) * [kuro5hin](mailto:rusty@kuro5hin.org)
* [leo60228](mailto:leo@60228.dev) * [leo60228](mailto:leo@60228.dev)
@ -655,6 +665,7 @@ and provided thanks to the work of the following contributors:
* [notozeki](mailto:notozeki@users.noreply.github.com) * [notozeki](mailto:notozeki@users.noreply.github.com)
* [ntl-purism](mailto:57806346+ntl-purism@users.noreply.github.com) * [ntl-purism](mailto:57806346+ntl-purism@users.noreply.github.com)
* [nzws](mailto:git-yuzu@svk.jp) * [nzws](mailto:git-yuzu@svk.jp)
* [proxy](mailto:51172302+3n-k1@users.noreply.github.com)
* [rch850](mailto:rich850@gmail.com) * [rch850](mailto:rich850@gmail.com)
* [roikale](mailto:roikale@users.noreply.github.com) * [roikale](mailto:roikale@users.noreply.github.com)
* [rysiekpl](mailto:rysiek@hackerspace.pl) * [rysiekpl](mailto:rysiek@hackerspace.pl)
@ -694,308 +705,414 @@ This document is provided for informational purposes only. Since it is only upda
Following people have contributed to translation of Mastodon: Following people have contributed to translation of Mastodon:
- ᏦᏁᎢᎵᏫ 😷 (*Spanish, Argentina*)
- Sveinn í Felli (*Icelandic*)
- ᏦᏁᎢᎵᏫ 😷 (KNTRO) (*Spanish, Argentina*)
- Sveinn í Felli (sveinki) (*Icelandic*)
- qezwan (*Persian, Sorani (Kurdish)*)
- Hồ Nhất Duy (kantcer) (*Vietnamese*)
- taicv (*Vietnamese*) - taicv (*Vietnamese*)
- ButterflyOfFire (*Arabic; French; Kabyle*)
- Duy (*Vietnamese*)
- Evert Prants (*Estonian*)
- Zoltán Gera (*Hungarian*)
- Daniele Lira Mereb (*Portuguese, Brazilian*)
- Kristijan Tkalec (*Slovenian*)
- stan ionut (*Romanian*)
- Ramdziana F Y (*Indonesian*)
- Michal Stanke (*Czech*)
- Xosé M. (*Galician; Spanish*)
- 奈卜拉 (*Chinese Simplified*)
- borys_sh (*Ukrainian*)
- Miguel Mayol (*Spanish; Catalan*)
- Zoltán Gera (gerazo) (*Hungarian*)
- ButterflyOfFire (BoFFire) (*French, Arabic, Kabyle*)
- adrmzz (*Sardinian*)
- Ramdziana F Y (rafeyu) (*Indonesian*)
- Evert Prants (IcyDiamond) (*Estonian*)
- Daniele Lira Mereb (danilmereb) (*Portuguese, Brazilian*)
- Xosé M. (XoseM) (*Spanish, Galician*)
- Kristijan Tkalec (lapor) (*Slovenian*)
- stan ionut (stanionut12) (*Romanian*)
- Besnik_b (*Albanian*) - Besnik_b (*Albanian*)
- Thai Localization (*Thai*)
- Emanuel Pina (*Portuguese*)
- Jeong Arm (*Korean; Esperanto; Japanese*)
- Imre Kristoffer Eilertsen (*Norwegian*)
- Danial Behzadi (*Persian*)
- Osoitz (*Basque*)
- Peterandre (*Norwegian Nynorsk; Norwegian*)
- Jeroen (*Dutch*)
- spla (*Catalan; Spanish*)
- Iváns (*Galician*)
- Emanuel Pina (emanuelpina) (*Portuguese*)
- Thai Localization (thl10n) (*Thai*)
- 奈卜拉 (nebula_moe) (*Chinese Simplified*)
- Jeong Arm (Kjwon15) (*Japanese, Korean, Esperanto*)
- Michal Stanke (mstanke) (*Czech*)
- Alix Rossi (palindromordnilap) (*French, Corsican*)
- spla (*Spanish, Catalan*)
- Imre Kristoffer Eilertsen (DandelionSprout) (*Norwegian*)
- Jeroen (jeroenpraat) (*Dutch*)
- borys_sh (*Ukrainian*)
- Miguel Mayol (mitcoes) (*Spanish, Catalan*)
- Danial Behzadi (danialbehzadi) (*Persian*)
- yeft (*Chinese Traditional, Chinese Traditional, Hong Kong*)
- koyu (*German*) - koyu (*German*)
- Sasha Sorokin (*Russian; Vietnamese; Swedish; Catalan; Greek; Hungarian; Armenian; Albanian; Galician; French; Danish; German; Korean; Ukrainian*)
- Koala Yeung (yookoala) (*Chinese Traditional, Hong Kong*)
- Osoitz (*Basque*)
- Peterandre (*Norwegian, Norwegian Nynorsk*)
- tzium (*Sardinian*)
- Iváns (Ivans_translator) (*Galician*)
- Sasha Sorokin (Sasha-Sorokin) (*French, Catalan, Danish, German, Greek, Hungarian, Armenian, Korean, Russian, Albanian, Swedish, Ukrainian, Vietnamese, Galician*)
- kamee (*Armenian*)
- tolstoevsky (*Russian*)
- enolp (*Asturian*) - enolp (*Asturian*)
- Masoud Abkenar (*Persian*)
- FédiQuébec (manuelviens) (*French*)
- lamnatos (*Greek*) - lamnatos (*Greek*)
- Alix Rossi (*Corsican; French*)
- Maya Minatsuki (mayaeh) (*Japanese*)
- Masoud Abkenar (mabkenar) (*Persian*)
- Alessandro Levati (Oct326) (*Italian*)
- arshat (*Kazakh*) - arshat (*Kazakh*)
- FédiQuébec (*French*)
- Marek Ľach (*Slovak; Polish*)
- Muha Aliss (*Turkish*)
- tolstoevsky (*Russian*)
- Emyn-Russell Nt Nefydd (*Welsh*)
- Aditoo17 (*Czech*)
- Maya Minatsuki (*Japanese*)
- ariasuni (*French; Esperanto*)
- Roboron (*Spanish*) - Roboron (*Spanish*)
- Alessandro Levati (*Italian*)
- ariasuni (*French, Arabic, Czech, German, Greek, Hungarian, Slovenian, Ukrainian, Chinese Simplified, Portuguese, Brazilian, Persian, Norwegian Nynorsk, Esperanto, Breton, Corsican, Sardinian, Kabyle*)
- Ali Demirtaş (alidemirtas) (*Turkish*)
- Em St Cenydd (cancennau) (*Welsh*)
- Marek Ľach (mareklach) (*Polish, Slovak*)
- Muha Aliss (muhaaliss) (*Turkish*)
- Jurica (ahjk) (*Croatian*)
- Aditoo17 (*Czech*)
- Diluns (*Occitan*) - Diluns (*Occitan*)
- regulartranslator (*Portuguese, Brazilian*)
- gagik_ (*Armenian*)
- vishnuvaratharajan (*Tamil*) - vishnuvaratharajan (*Tamil*)
- Marcin Mikołajczak (*Polish*)
- Yi-Jyun Pan (*Chinese Traditional*)
- adrmzz (*Sardinian*)
- Marcin Mikołajczak (mkljczkk) (*Czech, Polish, Russian*)
- regulartranslator (*Portuguese, Brazilian*)
- Akarshan Biswas (biswasab) (*Bengali, Sanskrit*)
- Yi-Jyun Pan (pan93412) (*Chinese Traditional*)
- d5Ziif3K (*Ukrainian*) - d5Ziif3K (*Ukrainian*)
- GiorgioHerbie (*Italian*) - GiorgioHerbie (*Italian*)
- Rafael H L Moretti (Moretti) (*Portuguese, Brazilian*)
- Saederup92 (*Danish*)
- christalleras (*Norwegian Nynorsk*) - christalleras (*Norwegian Nynorsk*)
- cybergene (cyber-gene) (*Japanese*)
- Taloran (*Norwegian Nynorsk*) - Taloran (*Norwegian Nynorsk*)
- ThibG (*French; Icelandic*)
- Akarshan Biswas (*Bengali*)
- ThibG (*French, Icelandic*)
- xatier (*Chinese Traditional*)
- otrapersona (*Spanish, Spanish, Mexico*)
- atarashiako (*Chinese Simplified*) - atarashiako (*Chinese Simplified*)
- 101010 (*Polish*)
- 101010 (101010pl) (*Polish*)
- silkevicious (*Italian*) - silkevicious (*Italian*)
- Bertil Hedkvist (*Swedish*)
- cybergene (*Japanese*)
- Floxu (fredrikdim1) (*Norwegian Nynorsk*)
- Bertil Hedkvist (Berrahed) (*Swedish*)
- William(ѕ)ⁿ (wmlgr) (*Spanish*)
- norayr (*Armenian*) - norayr (*Armenian*)
- William(ѕ)ⁿ (*Spanish*)
- Tiago Epifânio (*Portuguese*)
- Mentor Gashi (*Albanian*)
- Jaz-Michael King (*Welsh*)
- Tiago Epifânio (tfve) (*Portuguese*)
- Ryo (DrRyo) (*Korean*)
- Mentor Gashi (mentorgashi.com) (*Albanian*)
- Jaz-Michael King (jazmichaelking) (*Welsh*)
- carolinagiorno (*Portuguese, Brazilian*) - carolinagiorno (*Portuguese, Brazilian*)
- Roby Thomas (*Malayalam*)
- Bharat Kumar (*Hindi*)
- Roby Thomas (roby.thomas) (*Malayalam*)
- Bharat Kumar (Marwari) (*Hindi*)
- ThonyVezbe (*Breton*)
- dkdarshan760 (*Sanskrit*)
- Tagomago (tagomago) (*French, Spanish*)
- tykayn (*French*) - tykayn (*French*)
- axi (*Finnish*) - axi (*Finnish*)
- Selyan Slimane AMIRI (*Kabyle*)
- Selyan Slimane AMIRI (slimane_AMIRI) (*Kabyle*)
- Balázs Meskó (mesko.balazs) (*Hungarian*)
- taoxvx (*Danish*) - taoxvx (*Danish*)
- Hrach Mkrtchyan (*Armenian*)
- sabri (*Spanish; Spanish, Argentina*)
- Dewi (*Breton; French*)
- Hrach Mkrtchyan (mhrach87) (*Armenian*)
- sabri (thetomatoisavegetable) (*Spanish, Spanish, Argentina*)
- Dewi (Unkorneg) (*French, Breton*)
- Coelacanthus (*Chinese Simplified*)
- syncopams (*Chinese Simplified, Chinese Traditional, Chinese Traditional, Hong Kong*)
- SteinarK (*Norwegian Nynorsk*) - SteinarK (*Norwegian Nynorsk*)
- Mathias B. Vagnes (*Norwegian*)
- dashersyed (*Urdu*)
- ThonyVezbe (*Breton*)
- Acolyte (*Ukrainian*)
- Conight Wang (*Chinese Simplified*)
- Damjan Dimitrioski (*Macedonian*)
- Sokratis Alichanidis (alichani) (*Greek*)
- Mathias B. Vagnes (vagnes) (*Norwegian*)
- dashersyed (*Urdu (Pakistan)*)
- Acolyte (666noob404) (*Ukrainian*)
- Conight Wang (xfddwhh) (*Chinese Simplified*)
- liffon (*Swedish*)
- Damjan Dimitrioski (gnud) (*Macedonian*)
- PPNplus (*Thai*) - PPNplus (*Thai*)
- Tagomago (*Spanish; French*)
- shioko (*Chinese Simplified*) - shioko (*Chinese Simplified*)
- Balázs Meskó (*Hungarian*)
- Evgeny Petrov (*Russian*)
- Gwenn (*Breton*)
- Ryo (*Korean*)
- Rafael H L Moretti (*Portuguese, Brazilian*)
- v4vachan (*Malayalam*)
- Hakim Oubouali (zenata1) (*Standard Moroccan Tamazight*)
- Evgeny Petrov (kondra007) (*Russian*)
- Gwenn (Belvar) (*Breton*)
- StanleyFrew (*French*)
- Hayk Khachatryan (brutusromanus123) (*Armenian*)
- jaranta (*Finnish*) - jaranta (*Finnish*)
- gagik_ (*Armenian*)
- Felicia (*Swedish*)
- Jess Rafn (*Danish*)
- Stasiek Michalski (*Polish*)
- liffon (*Swedish*)
- dxwc (*Bengali*)
- Saederup92 (*Danish*)
- Felicia (midsommar) (*Swedish*)
- Denys (dector) (*Ukrainian*)
- Pukima (pukimaaa) (*German*)
- Vanege (*Esperanto*) - Vanege (*Esperanto*)
- Jess Rafn (therealyez) (*Danish*)
- strubbl (*German*)
- Stasiek Michalski (hellcp) (*Polish*)
- dxwc (*Bengali*)
- jmontane (*Catalan*) - jmontane (*Catalan*)
- Johan Schiff (*Swedish*)
- Arunmozhi (*Tamil*)
- kat (*Ukrainian; Russian*)
- Laura (*Polish*)
- oti4500 (*Hungarian; Ukrainian*)
- diazepan (*Spanish; Spanish, Argentina*)
- Sokratis Alichanidis (*Greek*)
- Rikard Linde (*Swedish*)
- Juan José Salvador Piedra (*Spanish*)
- Liboide (*Spanish*)
- Johan Schiff (schyffel) (*Swedish*)
- Arunmozhi (tecoholic) (*Tamil*)
- kat (katktv) (*Russian, Ukrainian*)
- Rikard Linde (rikardlinde) (*Swedish*)
- oti4500 (*Hungarian, Ukrainian*)
- Laura (selfisekai) (*Polish*)
- Rachida S. (ZiriSut) (*Kabyle*)
- diazepan (*Spanish, Spanish, Argentina*)
- marzuquccen (*Kabyle*) - marzuquccen (*Kabyle*)
- BurekzFinezt (*Serbian*)
- Juan José Salvador Piedra (JuanjoSalvador) (*Spanish*)
- Tigran (tigransimonyan) (*Armenian*)
- BurekzFinezt (*Serbian (Cyrillic)*)
- SHeija (*Finnish*) - SHeija (*Finnish*)
- Jack R (*Spanish*)
- andruhov (*Ukrainian; Russian*)
- 森の子リスのミーコの大冒険 (*Japanese*)
- るいーね (*Japanese*)
- Sam Tux (*Bengali*)
- atriix (*Swedish*)
- Jack R (isaac.97_WT) (*Spanish*)
- antonyho (*Chinese Traditional, Hong Kong*)
- andruhov (*Russian, Ukrainian*)
- Aryamik Sharma (Aryamik) (*Swedish, Hindi*)
- phena109 (*Chinese Traditional, Hong Kong*)
- 森の子リスのミーコの大冒険 (Phroneris) (*Japanese*)
- るいーね (ruine) (*Japanese*)
- ahangarha (*Persian*)
- Sam Tux (imahbub) (*Bengali*)
- igordrozniak (*Polish*)
- Unmual (*Spanish*) - Unmual (*Spanish*)
- AW Unad (*Indonesian*)
- Cutls (*Japanese*)
- Ray (*Spanish*)
- Falling Snowdin (*Vietnamese*)
- Andrea Lo Iacono (*Italian*)
- EPEMA (*German*)
- Kinshuk Sunil (*Hindi*)
- Ullas Joseph (*Malayalam*)
- Yu-Pai Liu (*Chinese Traditional*)
- Amarin Cemthong (*Thai*)
- juanda097 (*Spanish*)
- Isaac Huang (caasih) (*Chinese Traditional*)
- AW Unad (awcodify) (*Indonesian*)
- Allen Zhong (AstroProfundis) (*Chinese Simplified*)
- Cutls (cutls) (*Japanese*)
- Ray (Ipsumry) (*Spanish*)
- Falling Snowdin (tghgg) (*Vietnamese*)
- coxde (*Chinese Simplified*)
- Rasmus Lindroth (RasmusLindroth) (*Swedish*)
- Andrea Lo Iacono (niels0n) (*Italian*)
- Kinshuk Sunil (kinshuksunil) (*Hindi*)
- Ullas Joseph (ullasjoseph) (*Malayalam*)
- Goudarz Jafari (Goudarz) (*Persian*)
- Yu-Pai Liu (tedliou) (*Chinese Traditional*)
- Amarin Cemthong (acitmaster) (*Thai*)
- juanda097 (juanda-097) (*Spanish*)
- Anunnakey (*Macedonian*) - Anunnakey (*Macedonian*)
- StanleyFrew (*French*)
- fragola (*Italian*)
- erikstl (*Esperanto*) - erikstl (*Esperanto*)
- twpenguin (*Chinese Traditional*)
- bobchao (*Chinese Traditional*)
- Esther (esthermations) (*Portuguese*)
- MadeInSteak (*Finnish*) - MadeInSteak (*Finnish*)
- Heimen Stoffels (*Dutch*)
- Rajarshi Guha (*Bengali*)
- Andrew (*Romanian*)
- Goudarz Jafari (*Persian*)
- Heimen Stoffels (vistausss) (*Dutch*)
- Rajarshi Guha (rajarshiguha) (*Bengali*)
- Andrew (iAndrew3) (*Romanian*)
- Gopal Sharma (gopalvirat) (*Hindi*)
- arethsu (*Swedish*) - arethsu (*Swedish*)
- Carlos Solís (*Esperanto*)
- Parthan S Ramanujam (*Tamil*)
- Ali Demirtaş (*Turkish*)
- Kasper Nymand (*Danish*)
- TS (*Finnish*)
- Tofiq Abdula (Xwla) (*Sorani (Kurdish)*)
- Carlos Solís (csolisr) (*Esperanto*)
- Parthan S Ramanujam (parthan) (*Tamil*)
- Kasper Nymand (KasperNymand) (*Danish*)
- TS (morte) (*Finnish*)
- subram (*Turkish*)
- SensDeViata (*Ukrainian*) - SensDeViata (*Ukrainian*)
- Ptrcmd (ptrcmd) (*Chinese Traditional*)
- SergioFMiranda (*Portuguese, Brazilian*) - SergioFMiranda (*Portuguese, Brazilian*)
- OctolinGamer (*Portuguese, Brazilian*)
- Scvoet (scvoet) (*Chinese Simplified*)
- hiroTS (*Chinese Traditional*)
- johne32rus23 (*Russian*)
- AzureNya (*Chinese Simplified*) - AzureNya (*Chinese Simplified*)
- Ram varma (*Tamil*)
- 北䑓如法 (*Japanese*)
- OctolinGamer (octolingamer) (*Portuguese, Brazilian*)
- Ram varma (ram4varma) (*Tamil*)
- Hexandcube (hexandcube) (*Polish*)
- 北䑓如法 (Nyoho) (*Japanese*)
- frumble (*German*) - frumble (*German*)
- kekkepikkuni (*Tamil*) - kekkepikkuni (*Tamil*)
- Neo_Chen (NeoChen1024) (*Chinese Traditional*)
- oorsutri (*Tamil*) - oorsutri (*Tamil*)
- Nithin V (*Tamil*)
- Miro Rauhala (*Finnish*)
- Rhys Harrison (rhedders) (*Esperanto*)
- Nithin V (Nithin896) (*Tamil*)
- Miro Rauhala (mirorauhala) (*Finnish*)
- diorama (*Italian*) - diorama (*Italian*)
- Rhys Harrison (*Esperanto*)
- Guillaume Turchini (*French*)
- Ganesh D (*Marathi*)
- AlexKoala (alexkoala) (*Korean*)
- Aswin C (officialcjunior) (*Malayalam*)
- Guillaume Turchini (orion78fr) (*French*)
- Ganesh D (auntgd) (*Marathi*)
- dragnucs2 (*Arabic*) - dragnucs2 (*Arabic*)
- Pedro Henrique (*Portuguese, Brazilian*)
- Tejas Harad (*Marathi*)
- Vasanthan (*Tamil*)
- 硫酸鶏 (*Japanese*)
- Ryan Ho (koungho) (*Chinese Traditional*)
- Pedro Henrique (exploronauta) (*Portuguese, Brazilian*)
- Tejas Harad (h_tejas) (*Marathi*)
- Vasanthan (vasanthan) (*Tamil*)
- 硫酸鶏 (acid_chicken) (*Japanese*)
- clarmin b8 (clarminb8) (*Sorani (Kurdish)*)
- manukp (*Malayalam*) - manukp (*Malayalam*)
- psymyn (*Hebrew*) - psymyn (*Hebrew*)
- earth dweller (*Marathi*)
- meijerivoi (*Finnish*)
- earth dweller (sanethoughtyt) (*Marathi*)
- meijerivoi (toilet) (*Finnish*)
- essaar (*Tamil*) - essaar (*Tamil*)
- serubeena (*Swedish*) - serubeena (*Swedish*)
- Karol Kosek (krkkPL) (*Polish*)
- Rintan (*Japanese*) - Rintan (*Japanese*)
- Karol Kosek (*Polish*)
- valarivan (*Tamil*) - valarivan (*Tamil*)
- Sebastián Andil (*Slovak*)
- v4vachan (*Malayalam*)
- KEINOS (*Japanese*)
- Ivan T. (*Chinese Traditional, Hong Kong*)
- Hernik (hernik27) (*Czech*)
- Sebastián Andil (Selrond) (*Slovak*)
- Hinaloe (hinaloe) (*Japanese*)
- filippodb (*Italian*) - filippodb (*Italian*)
- Balázs Meskó (*Hungarian*)
- KEINOS (*Japanese*)
- Balázs Meskó (meskobalazs) (*Hungarian*)
- Bottle (suryasalem2010) (*Tamil*)
- JzshAC (*Chinese Simplified*) - JzshAC (*Chinese Simplified*)
- Bottle (*Tamil*)
- Khóo (*Chinese Traditional*)
- Steven Tappert (*German*)
- Antillion (*Spanish*)
- ZiriSut (*Kabyle*)
- gowthamanb (*Tamil*)
- Wrya ali (John12) (*Sorani (Kurdish)*)
- Khóo (khootiatling) (*Chinese Traditional*)
- Steven Tappert (sammy8806) (*German*)
- Antillion (antillion99) (*Spanish*)
- Pukima (Pukimaa) (*German*)
- Reg3xp (*Persian*)
- hiphipvargas (*Portuguese*) - hiphipvargas (*Portuguese*)
- Arttu Ylhävuori (*Finnish*)
- Ch. (*Korean*)
- gowthamanb (*Tamil*)
- Ch. (sftblw) (*Korean*)
- Jeff Huang (s8321414) (*Chinese Traditional*)
- Arttu Ylhävuori (arttu.ylhavuori) (*Finnish*)
- tctovsli (*Norwegian Nynorsk*) - tctovsli (*Norwegian Nynorsk*)
- Hinaloe (*Japanese*)
- strubbl (*German*)
- Timo Tijhof (Krinkle) (*Dutch*)
- Yamagishi Kazutoshi (ykzts) (*Japanese, Icelandic, Sorani (Kurdish)*)
- vjasiegd (*Polish*) - vjasiegd (*Polish*)
- SamitiMed (*Thai*)
- Reg3xp (*Persian*)
- AlexKoala (*Korean*)
- SamitiMed (samiti3d) (*Thai*)
- Rekan Adl (rekan-adl1) (*Sorani (Kurdish)*)
- umelard (*Hebrew*) - umelard (*Hebrew*)
- Antara2Cinta (Se7enTime) (*Indonesian*)
- VSx86 (*Russian*) - VSx86 (*Russian*)
- Daniel Dimitrov (*Bulgarian*)
- mynameismonkey (*Welsh*)
- Daniel Dimitrov (danny-dimitrov) (*Bulgarian*)
- parnikkapore (*Thai*) - parnikkapore (*Thai*)
- Mo_der Steven (*Chinese Simplified*)
- mynameismonkey (*Welsh*)
- Sherwan Othman (sherwanothman11) (*Sorani (Kurdish)*)
- Yassine Aït-El-Mouden (yaitelmouden) (*Standard Moroccan Tamazight*)
- SKELET (*Danish*) - SKELET (*Danish*)
- Renato "Lond" Cerqueira (*Portuguese, Brazilian*)
- Mo_der Steven (SakuraPuare) (*Chinese Simplified*)
- Fei Yang (Fei1Yang) (*Chinese Traditional*)
- ALEM FARID (faridatcemlulaqbayli) (*Kabyle*)
- enipra (*Armenian*) - enipra (*Armenian*)
- musix (*Persian*) - musix (*Persian*)
- ギャラ (*Chinese Simplified; Japanese*)
- ALEM FARID (*Kabyle*)
- Renato "Lond" Cerqueira (renatolond) (*Portuguese, Brazilian*)
- ギャラ (gyara) (*Japanese, Chinese Simplified*)
- Hougo (hougo) (*French*)
- ybardapurkar (*Marathi*) - ybardapurkar (*Marathi*)
- Adrián Lattes (*Spanish*)
- Adrián Lattes (haztecaso) (*Spanish*)
- TracyJacks (*Chinese Simplified*)
- rasheedgm (*Kannada*) - rasheedgm (*Kannada*)
- GatoOscuro (*Spanish*)
- mecqor labi (mecqorlabi) (*Persian*)
- Belkacem Mohammed (belkacem77) (*Kabyle*)
- Navjot Singh (nspeaks) (*Hindi*)
- omquylzu (*Latvian*) - omquylzu (*Latvian*)
- Belkacem Mohammed (*Kabyle*)
- Navjot Singh (*Hindi*)
- Ozai (*German*) - Ozai (*German*)
- Sahak Petrosyan (*Armenian*)
- siamano (*Thai; Esperanto*)
- se7entime (*Indonesian*)
- Viorel-Cătălin Răpițeanu (*Romanian*)
- Siddhartha Sarathi Basu (*Bengali*)
- Pachara Chantawong (*Thai*)
- Skew (*French*)
- Zijian Zhao (*Chinese Simplified*)
- Guru Prasath Anandapadmanaban (*Tamil*)
- Sahak Petrosyan (petrosyan) (*Armenian*)
- siamano (*Thai, Esperanto*)
- Viorel-Cătălin Răpițeanu (rapiteanu) (*Romanian*)
- Siddhartha Sarathi Basu (quinoa_biryani) (*Bengali*)
- Pachara Chantawong (pachara2202) (*Thai*)
- mkljczk (*Polish*)
- Skew (noan.perrot) (*French*)
- Zijian Zhao (jobs2512821228) (*Chinese Simplified*)
- turtle836 (*German*) - turtle836 (*German*)
- GatoOscuro (*Spanish*)
- Lamin (*Japanese*)
- Marcepanek_ (*Polish*)
- Yann Aguettaz (*French*)
- Feruz Oripov (*Russian*)
- Mick Onio (*Asturian*)
- hg6 (*Hindi*)
- Malik Mann (*German*)
- padulafacundo (*Spanish*)
- Guru Prasath Anandapadmanaban (guruprasath) (*Tamil*)
- Lamin (laminne) (*Japanese*)
- Marcepanek_ (thekingmarcepan) (*Polish*)
- Feruz Oripov (FeruzOripov) (*Russian*)
- Yann Aguettaz (yann-a) (*French*)
- Mick Onio (xgc.redes) (*Asturian*)
- Tianqi Zhang (tina.zhang040609) (*Chinese Simplified*)
- Malik Mann (dermalikmann) (*German*)
- dadosch (*German*)
- r3dsp1 (*Chinese Traditional, Hong Kong*) - r3dsp1 (*Chinese Traditional, Hong Kong*)
- Tianqi Zhang (*Chinese Simplified*)
- Padraic Calpin (*Slovenian*)
- cenegd (*Chinese Simplified*)
- padulafacundo (*Spanish*)
- hg6 (*Hindi*)
- Orlando Murcio (Atos20) (*Spanish, Mexico*)
- piupiupiudiu (*Chinese Simplified*) - piupiupiudiu (*Chinese Simplified*)
- Hugh Liu (*Chinese Simplified*)
- Rakino (*Chinese Simplified*)
- Jothipazhani Nagarajan (*Tamil*)
- Miquel Sabaté Solà (*Catalan*)
- shdy (*German*)
- Padraic Calpin (padraic-padraic) (*Slovenian*)
- Ильзира Рахматуллина (rahmatullinailzira53) (*Tatar*)
- cenegd (*Chinese Simplified*)
- Hugh Liu (youloveonlymeh) (*Chinese Simplified*)
- Pixelcode (realpixelcode) (*German*)
- Yogesh K S (yogi) (*Kannada*)
- Rakino (rakino) (*Chinese Simplified*)
- Miquel Sabaté Solà (mssola) (*Catalan*)
- AmazighNM (*Kabyle*) - AmazighNM (*Kabyle*)
- Solid Rhino (*Dutch*)
- Jothipazhani Nagarajan (jothipazhani.n) (*Tamil*)
- Clash Clans (KURD12345) (*Sorani (Kurdish)*)
- hallomaurits (*Dutch*) - hallomaurits (*Dutch*)
- alnd hezh (alndhezh) (*Sorani (Kurdish)*)
- Solid Rhino (SolidRhino) (*Dutch*)
- k_taka (peaceroad) (*Japanese*)
- Hallo Abdullah (hallo_hamza12) (*Sorani (Kurdish)*)
- hussama (*Portuguese, Brazilian*) - hussama (*Portuguese, Brazilian*)
- shafouz (*Portuguese, Brazilian*)
- Tagada (*French*)
- Tom_ (*Czech*)
- SnDer (*Dutch*)
- Sébastien Feugère (smonff) (*French*)
- 林水溶 (shuiRong) (*Chinese Simplified*)
- eichkat3r (*German*) - eichkat3r (*German*)
- PifyZ (*French*)
- OminousCry (*Russian*) - OminousCry (*Russian*)
- Shrinivasan T (*Tamil*)
- Nathaël Noguès (*French*)
- Daniel M. (*Catalan*)
- Swati Sani (*Urdu*)
- Kk (*Kannada*)
- SnDer (*Dutch*)
- PifyZ (*French*)
- Tom_ (*Czech*)
- Tagada (Tagadda) (*French*)
- shafouz (*Portuguese, Brazilian*)
- Kahina Mess (K_hina) (*Kabyle*)
- Nathaël Noguès (NatNgs) (*French*)
- Kk (kishorkumara3) (*Kannada*)
- Swati Sani (swatisani) (*Urdu (Pakistan)*)
- Shrinivasan T (tshrinivasan) (*Tamil*)
- さっかりんにーさん (saccharin23) (*Japanese*)
- 夜楓Yoka (Yoka2627) (*Chinese Simplified*)
- Daniel M. (daniconil) (*Catalan*)
- Vikatakavi (*Kannada*)
- SusVersiva (*Catalan*) - SusVersiva (*Catalan*)
- Robin van der Vliet (*Esperanto*)
- Tradjincal (tradjincal) (*French*)
- pullopen (*Chinese Simplified*)
- Robin van der Vliet (RobinvanderVliet) (*Esperanto*)
- Zinkokooo (*Basque*) - Zinkokooo (*Basque*)
- Tradjincal (*French*)
- Vikatakavi (*Kannada*)
- prabhjot (*Hindi*)
- twpenguin (*Chinese Traditional*)
- mmokhi (*Persian*) - mmokhi (*Persian*)
- Livingston Samuel (livingston) (*Tamil*)
- prabhjot (*Hindi*)
- sergioaraujo1 (*Portuguese, Brazilian*) - sergioaraujo1 (*Portuguese, Brazilian*)
- Livingston Samuel (*Tamil*)
- CyberAmoeba (pseudoobscura) (*Chinese Simplified*)
- tsundoker (*Malayalam*) - tsundoker (*Malayalam*)
- skaaarrr (*German*) - skaaarrr (*German*)
- 夜楓Yoka (*Chinese Simplified*)
- kiwi0 (*Italian*)
- Ricardo Colin (rysard) (*Spanish*)
- mkljczk (mykylyjczyk) (*Polish*)
- Philipp Fischbeck (PFischbeck) (*German*)
- fedot (*Russian*) - fedot (*Russian*)
- mkljczk (*Polish*)
- igordrozniak (*Polish*)
- Ricardo Colin (*Spanish*)
- Esther (*Portuguese*)
- Paz Galindo (*Spanish*)
- Philipp Fischbeck (*German*)
- Paz Galindo (paz.almendra.g) (*Spanish*)
- GaggiX (*Italian*)
- ralozkolya (*Georgian*) - ralozkolya (*Georgian*)
- JackXu (*Chinese Simplified*)
- Allen Zhong (*Chinese Simplified*)
- Zoé Bőle (*German*)
- Lukas Fülling (*German*)
- Albatroz Jeremias (*Portuguese*)
- Samir Tighzert (*Kabyle*)
- Nocta (*French*)
- Anoop (*Malayalam*)
- Zoé Bőle (zoe1337) (*German*)
- Lukas Fülling (lfuelling) (*German*)
- JackXu (Merman-Jack) (*Chinese Simplified*)
- Aymeric (AymBroussier) (*French*)
- Anoop (anoopp) (*Malayalam*)
- pezcurrel (*Italian*) - pezcurrel (*Italian*)
- Dremski (*Bulgarian*) - Dremski (*Bulgarian*)
- Aymeric (*French*)
- tamaina (*Japanese*)
- Doug (*Portuguese, Brazilian*)
- Matias Lavik (*Norwegian Nynorsk*)
- Fleva (*Sardinian*)
- Xurxo Guerra (xguerrap) (*Galician*)
- mashirozx (*Chinese Simplified*)
- Albatroz Jeremias (albjeremias) (*Portuguese*)
- Samir Tighzert (samir_t7) (*Kabyle*)
- Apple (blackteaovo) (*Chinese Simplified*)
- Nocta (*French*)
- OpenAlgeria (*Arabic*) - OpenAlgeria (*Arabic*)
- koppe-pan (*Japanese*)
- Amith Raj Shetty (*Kannada*)
- tamaina (*Japanese*)
- abidin toumi (Zet24) (*Arabic*)
- xpac1985 (xpac) (*German*)
- Kaede (kaedech) (*Japanese*)
- ÀŘǾŚ PÀŚĦÀÍ (arospashai) (*Sorani (Kurdish)*)
- Matias Lavik (matiaslavik) (*Norwegian Nynorsk*)
- smedvedev (*Russian*) - smedvedev (*Russian*)
- Trond Boksasp (*Norwegian*)
- mikel (mikelalas) (*Spanish*)
- Doug (douglasalvespe) (*Portuguese, Brazilian*)
- Trond Boksasp (boksasp) (*Norwegian*)
- Fleva (*Sardinian*)
- Mohammad Adnan Mahmood (adnanmig) (*Arabic*)
- Sais Lakshmanan (Saislakshmanan) (*Tamil*)
- Amith Raj Shetty (amithraj1989) (*Kannada*)
- random_person (*Spanish*) - random_person (*Spanish*)
- Sais Lakshmanan (*Tamil*)
- mikel (*Spanish*)
- Mohammad Adnan Mahmood (*Arabic*)
- djoerd (*Dutch*)
- Baban Abdulrahman (baban.abdulrehman) (*Sorani (Kurdish)*)
- ebrezhoneg (*Breton*)
- dashty (*Sorani (Kurdish)*)
- Salh_haji6 (*Sorani (Kurdish)*)
- Amir Kurdo (kuraking202) (*Sorani (Kurdish)*)
- おさ (osapon) (*Japanese*)
- Ranj A Abdulqadir (RanjAhmed) (*Sorani (Kurdish)*)
- umonaca (*Chinese Simplified*)
- Bartek Fijałkowski (brateq) (*Polish*)
- tateisu (*Japanese*)
- centumix (*Japanese*)
- Jari Ronkainen (ronchaine) (*Finnish*)
- Savarín Electrográfico Marmota Intergalactica (herrero.maty) (*Spanish*)
- Torsten Högel (torstenhoegel) (*German*)
- Abijeet Patro (Abijeet) (*Basque*)
- Ács Zoltán (acszoltan111) (*Hungarian*)
- Benjamin Cobb (benjamincobb) (*German*)
- waweic (*German*)
- Aries (orlea) (*Japanese*)
- silverscat_3 (SilversCat) (*Japanese*)
- kavitha129 (*Tamil*)
- dcapillae (*Spanish*)
- SamOak (*Portuguese, Brazilian*)
- capiscuas (*Spanish*)
- NeverMine17 (*Russian*)
- Nithya Mary (nithyamary25) (*Tamil*)
- t_aus_m (*German*)
- dobrado (*Portuguese, Brazilian*)
- Hannah (Aniqueper1) (*Chinese Simplified*)
- Jiniux (*Italian*)
- 于晚霞 (xissshawww) (*Chinese Simplified*)

+ 234
- 3
CHANGELOG.md View File

@ -3,6 +3,237 @@ Changelog
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [3.3.0] - 2020-12-27
### Added
- **Add hotkeys for audio/video control in web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/15158), [Gargron](https://github.com/tootsuite/mastodon/pull/15198))
- `Space` and `k` to toggle playback
- `m` to toggle mute
- `f` to toggle fullscreen
- `j` and `l` to go back and forward by 10 seconds
- `.` and `,` to go back and forward by a frame (video only)
- Add expand/compress button on media modal in web UI ([mashirozx](https://github.com/tootsuite/mastodon/pull/15068), [mashirozx](https://github.com/tootsuite/mastodon/pull/15088), [mashirozx](https://github.com/tootsuite/mastodon/pull/15094))
- Add border around 🕺 emoji in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14769))
- Add border around 🐞 emoji in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14712))
- Add home link to the getting started column when home isn't mounted ([ThibG](https://github.com/tootsuite/mastodon/pull/14707))
- Add option to disable swiping motions across the web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/13885))
- **Add pop-out player for audio/video in web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/14870), [Gargron](https://github.com/tootsuite/mastodon/pull/15157), [Gargron](https://github.com/tootsuite/mastodon/pull/14915), [noellabo](https://github.com/tootsuite/mastodon/pull/15309))
- Continue watching/listening when you scroll away
- Action bar to interact with/open toot from the pop-out player
- Add unread notification markers in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14818), [ThibG](https://github.com/tootsuite/mastodon/pull/14960), [ThibG](https://github.com/tootsuite/mastodon/pull/14954), [noellabo](https://github.com/tootsuite/mastodon/pull/14897), [noellabo](https://github.com/tootsuite/mastodon/pull/14907))
- Add paragraph about browser add-ons when encountering errors in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14801))
- Add import and export for bookmarks ([ThibG](https://github.com/tootsuite/mastodon/pull/14956))
- Add cache buster feature for media files ([Gargron](https://github.com/tootsuite/mastodon/pull/15155))
- If you have a proxy cache in front of object storage, deleted files will persist until the cache expires
- If enabled, cache buster will make a special request to the proxy to signal a cache reset
- Add duration option to the mute function ([aquarla](https://github.com/tootsuite/mastodon/pull/13831))
- Add replies policy option to the list function ([ThibG](https://github.com/tootsuite/mastodon/pull/9205), [trwnh](https://github.com/tootsuite/mastodon/pull/15304))
- Add `og:published_time` OpenGraph tags on toots ([nornagon](https://github.com/tootsuite/mastodon/pull/14865))
- **Add option to be notified when a followed user posts** ([Gargron](https://github.com/tootsuite/mastodon/pull/13546), [ThibG](https://github.com/tootsuite/mastodon/pull/14896), [Gargron](https://github.com/tootsuite/mastodon/pull/14822))
- If you don't want to miss a toot, click the bell button!
- Add client-side validation in password change forms ([ThibG](https://github.com/tootsuite/mastodon/pull/14564))
- Add client-side validation in the registration form ([ThibG](https://github.com/tootsuite/mastodon/pull/14560), [ThibG](https://github.com/tootsuite/mastodon/pull/14599))
- Add support for Gemini URLs ([joshleeb](https://github.com/tootsuite/mastodon/pull/15013))
- Add app shortcuts to web app manifest ([mkljczk](https://github.com/tootsuite/mastodon/pull/15234))
- Add WebAuthn as an alternative 2FA method ([santiagorodriguez96](https://github.com/tootsuite/mastodon/pull/14466), [jiikko](https://github.com/tootsuite/mastodon/pull/14806))
- Add honeypot fields and minimum fill-out time for sign-up form ([ThibG](https://github.com/tootsuite/mastodon/pull/15276))
- Add icon for mutual relationships in relationship manager ([noellabo](https://github.com/tootsuite/mastodon/pull/15149))
- Add follow selected followers button in relationship manager ([noellabo](https://github.com/tootsuite/mastodon/pull/15148))
- **Add subresource integrity for JS and CSS assets** ([Gargron](https://github.com/tootsuite/mastodon/pull/15096))
- If you use a CDN for static assets (JavaScript, CSS, and so on), you have to trust that the CDN does not modify the assets maliciously
- Subresource integrity compares server-generated asset digests with what's actually served from the CDN and prevents such attacks
- Add `ku`, `sa`, `sc`, `zgh` to available locales ([ykzts](https://github.com/tootsuite/mastodon/pull/15138))
- Add ability to force an account to mark media as sensitive ([noellabo](https://github.com/tootsuite/mastodon/pull/14361))
- **Add ability to block access or limit sign-ups from chosen IPs** ([Gargron](https://github.com/tootsuite/mastodon/pull/14963), [ThibG](https://github.com/tootsuite/mastodon/pull/15263))
- Add rules for IPs or CIDR ranges that automatically expire after a configurable amount of time
- Choose the severity of the rule, either blocking all access or merely limiting sign-ups
- **Add support for reversible suspensions through ActivityPub** ([Gargron](https://github.com/tootsuite/mastodon/pull/14989))
- Servers can signal that one of their accounts has been suspended
- During suspension, the account can only delete its own content
- A reversal of the suspension can be signalled the same way
- A local suspension always overrides a remote one
- Add indication to admin UI of whether a report has been forwarded ([ThibG](https://github.com/tootsuite/mastodon/pull/13237))
- Add display of reasons for joining of an account in admin UI ([mashirozx](https://github.com/tootsuite/mastodon/pull/15265))
- Add option to obfuscate domain name in public list of domain blocks ([Gargron](https://github.com/tootsuite/mastodon/pull/15355))
- Add option to make reasons for joining required on sign-up ([ThibG](https://github.com/tootsuite/mastodon/pull/15326), [ThibG](https://github.com/tootsuite/mastodon/pull/15358), [ThibG](https://github.com/tootsuite/mastodon/pull/15385), [ThibG](https://github.com/tootsuite/mastodon/pull/15405))
- Add ActivityPub follower synchronization mechanism ([ThibG](https://github.com/tootsuite/mastodon/pull/14510), [ThibG](https://github.com/tootsuite/mastodon/pull/15026))
- Add outbox attribute to instance actor ([ThibG](https://github.com/tootsuite/mastodon/pull/14721))
- Add featured hashtags as an ActivityPub collection ([Gargron](https://github.com/tootsuite/mastodon/pull/11595), [noellabo](https://github.com/tootsuite/mastodon/pull/15277))
- Add support for dereferencing objects through bearcaps ([Gargron](https://github.com/tootsuite/mastodon/pull/14683), [noellabo](https://github.com/tootsuite/mastodon/pull/14981))
- Add `S3_READ_TIMEOUT` environment variable ([tateisu](https://github.com/tootsuite/mastodon/pull/14952))
- Add `ALLOWED_PRIVATE_ADDRESSES` environment variable ([ThibG](https://github.com/tootsuite/mastodon/pull/14722))
- Add `--fix-permissions` option to `tootctl media remove-orphans` ([Gargron](https://github.com/tootsuite/mastodon/pull/14383), [uist1idrju3i](https://github.com/tootsuite/mastodon/pull/14715))
- Add `tootctl accounts merge` ([Gargron](https://github.com/tootsuite/mastodon/pull/15201), [ThibG](https://github.com/tootsuite/mastodon/pull/15264), [ThibG](https://github.com/tootsuite/mastodon/pull/15256))
- Has someone changed their domain or subdomain thereby creating two accounts where there should be one?
- This command will fix it on your end
- Add `tootctl maintenance fix-duplicates` ([ThibG](https://github.com/tootsuite/mastodon/pull/14860), [Gargron](https://github.com/tootsuite/mastodon/pull/15223), [ThibG](https://github.com/tootsuite/mastodon/pull/15373))
- Index corruption in the database?
- This command is for you
- **Add support for managing multiple stream subscriptions in a single connection** ([Gargron](https://github.com/tootsuite/mastodon/pull/14524), [Gargron](https://github.com/tootsuite/mastodon/pull/14566), [mfmfuyu](https://github.com/tootsuite/mastodon/pull/14859), [zunda](https://github.com/tootsuite/mastodon/pull/14608))
- Previously, getting live updates for multiple timelines required opening a HTTP or WebSocket connection for each
- More connections means more resource consumption on both ends, not to mention the (ever so slight) delay when establishing a new connection
- Now, with just a single WebSocket connection you can subscribe and unsubscribe to and from multiple streams
- Add support for limiting results by both `min_id` and `max_id` at the same time in REST API ([tateisu](https://github.com/tootsuite/mastodon/pull/14776))
- Add `GET /api/v1/accounts/:id/featured_tags` to REST API ([noellabo](https://github.com/tootsuite/mastodon/pull/11817), [noellabo](https://github.com/tootsuite/mastodon/pull/15270))
- Add stoplight for object storage failures, return HTTP 503 in REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/13043))
- Add optional `tootctl remove media` cronjob in Helm chart ([dunn](https://github.com/tootsuite/mastodon/pull/14396))
- Add clean error message when `RAILS_ENV` is unset ([ThibG](https://github.com/tootsuite/mastodon/pull/15381))
### Changed
- **Change media modals look in web UI** ([Gargron](https://github.com/tootsuite/mastodon/pull/15217), [Gargron](https://github.com/tootsuite/mastodon/pull/15221), [Gargron](https://github.com/tootsuite/mastodon/pull/15284), [Gargron](https://github.com/tootsuite/mastodon/pull/15283), [Kjwon15](https://github.com/tootsuite/mastodon/pull/15308), [noellabo](https://github.com/tootsuite/mastodon/pull/15305), [ThibG](https://github.com/tootsuite/mastodon/pull/15417))
- Background of the overlay matches the color of the image
- Action bar to interact with or open the toot from the modal
- Change order of announcements in admin UI to be newest-first ([ThibG](https://github.com/tootsuite/mastodon/pull/15091))
- **Change account suspensions to be reversible by default** ([Gargron](https://github.com/tootsuite/mastodon/pull/14726), [ThibG](https://github.com/tootsuite/mastodon/pull/15152), [ThibG](https://github.com/tootsuite/mastodon/pull/15106), [ThibG](https://github.com/tootsuite/mastodon/pull/15100), [ThibG](https://github.com/tootsuite/mastodon/pull/15099), [noellabo](https://github.com/tootsuite/mastodon/pull/14855), [ThibG](https://github.com/tootsuite/mastodon/pull/15380), [Gargron](https://github.com/tootsuite/mastodon/pull/15420), [Gargron](https://github.com/tootsuite/mastodon/pull/15414))
- Suspensions no longer equal deletions
- A suspended account can be unsuspended with minimal consequences for 30 days
- Immediate deletion of data is still available as an explicit option
- Suspended accounts can request an archive of their data through the UI
- Change REST API to return empty data for suspended accounts (14765)
- Change web UI to show empty profile for suspended accounts ([Gargron](https://github.com/tootsuite/mastodon/pull/14766), [Gargron](https://github.com/tootsuite/mastodon/pull/15345))
- Change featured hashtag suggestions to be recently used instead of most used ([abcang](https://github.com/tootsuite/mastodon/pull/14760))
- Change direct toots to appear in the home feed again ([Gargron](https://github.com/tootsuite/mastodon/pull/14711), [ThibG](https://github.com/tootsuite/mastodon/pull/15182), [noellabo](https://github.com/tootsuite/mastodon/pull/14727))
- Return to treating all toots the same instead of trying to retrofit direct visibility into an instant messaging model
- Change email address validation to return more specific errors ([ThibG](https://github.com/tootsuite/mastodon/pull/14565))
- Change HTTP signature requirements to include `Digest` header on `POST` requests ([ThibG](https://github.com/tootsuite/mastodon/pull/15069))
- Change click area of video/audio player buttons to be bigger in web UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/15049))
- Change order of filters by alphabetic by "keyword or phrase" ([ariasuni](https://github.com/tootsuite/mastodon/pull/15050))
- Change suspension of remote accounts to also undo outgoing follows ([ThibG](https://github.com/tootsuite/mastodon/pull/15188))
- Change string "Home" to "Home and lists" in the filter creation screen ([ariasuni](https://github.com/tootsuite/mastodon/pull/15139))
- Change string "Boost to original audience" to "Boost with original visibility" in web UI ([3n-k1](https://github.com/tootsuite/mastodon/pull/14598))
- Change string "Show more" to "Show newer" and "Show older" on public pages ([ariasuni](https://github.com/tootsuite/mastodon/pull/15052))
- Change order of announcements to be reverse chronological in web UI ([dariusk](https://github.com/tootsuite/mastodon/pull/15065), [dariusk](https://github.com/tootsuite/mastodon/pull/15070))
- Change RTL detection to rely on unicode-bidi paragraph by paragraph in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/14573))
- Change visibility icon next to timestamp to be clickable in web UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/15053), [mayaeh](https://github.com/tootsuite/mastodon/pull/15055))
- Change public thread view to hide "Show thread" link ([ThibG](https://github.com/tootsuite/mastodon/pull/15266))
- Change number format on about page from full to shortened ([Gargron](https://github.com/tootsuite/mastodon/pull/15327))
- Change how scheduled tasks run in multi-process environments ([noellabo](https://github.com/tootsuite/mastodon/pull/15314))
- New dedicated queue `scheduler`
- Runs by default when Sidekiq is executed with no options
- Has to be added manually in a multi-process environment
### Removed
- Remove fade-in animation from modals in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/15199))
- Remove auto-redirect to direct messages in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/15142))
- Remove obsolete IndexedDB operations from web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/14730))
- Remove dependency on unused and unmaintained http_parser.rb gem ([ThibG](https://github.com/tootsuite/mastodon/pull/14574))
### Fixed
- Fix layout on about page when contact account has a long username ([ThibG](https://github.com/tootsuite/mastodon/pull/15357))
- Fix follow limit preventing re-following of a moved account ([Gargron](https://github.com/tootsuite/mastodon/pull/14207), [ThibG](https://github.com/tootsuite/mastodon/pull/15384))
- **Fix deletes not reaching every server that interacted with toot** ([Gargron](https://github.com/tootsuite/mastodon/pull/15200))
- Previously, delete of a toot would be primarily sent to the followers of its author, people mentioned in the toot, and people who reblogged the toot
- Now, additionally, it is ensured that it is sent to people who replied to it, favourited it, and to the person it replies to even if that person is not mentioned
- Fix resolving an account through its non-canonical form (i.e. alternate domain) ([ThibG](https://github.com/tootsuite/mastodon/pull/15187))
- Fix sending redundant ActivityPub events when processing remote account deletion ([ThibG](https://github.com/tootsuite/mastodon/pull/15104))
- Fix Move handler not being triggered when failing to fetch target account ([ThibG](https://github.com/tootsuite/mastodon/pull/15107))
- Fix downloading remote media files when server returns empty filename ([ThibG](https://github.com/tootsuite/mastodon/pull/14867))
- Fix account processing failing because of large collections ([ThibG](https://github.com/tootsuite/mastodon/pull/15027))
- Fix not being able to unfavorite toots one has lost access to ([ThibG](https://github.com/tootsuite/mastodon/pull/15192))
- Fix not being able to unbookmark toots one has lost access to ([ThibG](https://github.com/tootsuite/mastodon/pull/14604))
- Fix possible casing inconsistencies in hashtag search ([ThibG](https://github.com/tootsuite/mastodon/pull/14906))
- Fix updating account counters when association is not yet created ([Gargron](https://github.com/tootsuite/mastodon/pull/15108))
- Fix cookies not having a SameSite attribute ([Gargron](https://github.com/tootsuite/mastodon/pull/15098))
- Fix poll ending notifications being created for each vote ([ThibG](https://github.com/tootsuite/mastodon/pull/15071))
- Fix multiple boosts of a same toot erroneously appearing in TL ([ThibG](https://github.com/tootsuite/mastodon/pull/14759))
- Fix asset builds not picking up `CDN_HOST` change ([ThibG](https://github.com/tootsuite/mastodon/pull/14381))
- Fix desktop notifications permission prompt in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/14985), [Gargron](https://github.com/tootsuite/mastodon/pull/15141), [ThibG](https://github.com/tootsuite/mastodon/pull/13543), [ThibG](https://github.com/tootsuite/mastodon/pull/15176))
- Some time ago, browsers added a requirement that desktop notification prompts could only be displayed in response to a user-generated event (such as a click)
- This means that for some time, users who haven't already given the permission before were not getting a prompt and as such were not receiving desktop notifications
- Fix "Mark media as sensitive" string not supporting pluralizations in other languages in web UI ([ariasuni](https://github.com/tootsuite/mastodon/pull/15051))
- Fix glitched image uploads when canvas read access is blocked in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/15180))
- Fix some account gallery items having empty labels in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/15073))
- Fix alt-key hotkeys activating while typing in a text field in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14942))
- Fix wrong seek bar width on media player in web UI ([mfmfuyu](https://github.com/tootsuite/mastodon/pull/15060))
- Fix logging out on mobile in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14901))
- Fix wrong click area for GIFVs in media modal in web UI ([noellabo](https://github.com/tootsuite/mastodon/pull/14615))
- Fix unreadable placeholder text color in high contrast theme in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/14803))
- Fix scrolling issues when closing some dropdown menus in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14606))
- Fix notification filter bar incorrectly filtering gaps in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14808))
- Fix disabled boost icon being replaced by private boost icon on hover in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14456))
- Fix hashtag detection in compose form being different to server-side in web UI ([kedamaDQ](https://github.com/tootsuite/mastodon/pull/14484), [ThibG](https://github.com/tootsuite/mastodon/pull/14513))
- Fix home last read marker mishandling gaps in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14809))
- Fix unnecessary re-rendering of various components when typing in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/15286))
- Fix notifications being unnecessarily re-rendered in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/15312))
- Fix column swiping animation logic in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/15301))
- Fix inefficiency when fetching hashtag timeline ([noellabo](https://github.com/tootsuite/mastodon/pull/14861), [akihikodaki](https://github.com/tootsuite/mastodon/pull/14662))
- Fix inefficiency when fetching bookmarks ([akihikodaki](https://github.com/tootsuite/mastodon/pull/14674))
- Fix inefficiency when fetching favourites ([akihikodaki](https://github.com/tootsuite/mastodon/pull/14673))
- Fix inefficiency when fetching media-only account timeline ([akihikodaki](https://github.com/tootsuite/mastodon/pull/14675))
- Fix inefficieny when deleting accounts ([Gargron](https://github.com/tootsuite/mastodon/pull/15387), [ThibG](https://github.com/tootsuite/mastodon/pull/15409), [ThibG](https://github.com/tootsuite/mastodon/pull/15407), [ThibG](https://github.com/tootsuite/mastodon/pull/15408), [ThibG](https://github.com/tootsuite/mastodon/pull/15402), [ThibG](https://github.com/tootsuite/mastodon/pull/15416), [Gargron](https://github.com/tootsuite/mastodon/pull/15421))
- Fix redundant query when processing batch actions on custom emojis ([niwatori24](https://github.com/tootsuite/mastodon/pull/14534))
- Fix slow distinct queries where grouped queries are faster ([Gargron](https://github.com/tootsuite/mastodon/pull/15287))
- Fix performance on instances list in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/15282))
- Fix server actor appearing in list of accounts in admin UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14567))
- Fix "bootstrap timeline accounts" toggle in site settings in admin UI ([ThibG](https://github.com/tootsuite/mastodon/pull/15325))
- Fix PostgreSQL secret name for cronjob in Helm chart ([metal3d](https://github.com/tootsuite/mastodon/pull/15072))
- Fix Procfile not being compatible with herokuish ([acuteaura](https://github.com/tootsuite/mastodon/pull/12685))
- Fix installation of tini being split into multiple steps in Dockerfile ([ryncsn](https://github.com/tootsuite/mastodon/pull/14686))
### Security
- Fix streaming API allowing connections to persist after access token invalidation ([Gargron](https://github.com/tootsuite/mastodon/pull/15111))
- Fix 2FA/sign-in token sessions being valid after password change ([Gargron](https://github.com/tootsuite/mastodon/pull/14802))
- Fix resolving accounts sometimes creating duplicate records for a given ActivityPub identifier ([ThibG](https://github.com/tootsuite/mastodon/pull/15364))
## [3.2.2] - 2020-12-19
### Added
- Add `tootctl maintenance fix-duplicates` ([ThibG](https://github.com/tootsuite/mastodon/pull/14860), [Gargron](https://github.com/tootsuite/mastodon/pull/15223))
- Index corruption in the database?
- This command is for you
### Removed
- Remove dependency on unused and unmaintained http_parser.rb gem ([ThibG](https://github.com/tootsuite/mastodon/pull/14574))
### Fixed
- Fix Move handler not being triggered when failing to fetch target account ([ThibG](https://github.com/tootsuite/mastodon/pull/15107))
- Fix downloading remote media files when server returns empty filename ([ThibG](https://github.com/tootsuite/mastodon/pull/14867))
- Fix possible casing inconsistencies in hashtag search ([ThibG](https://github.com/tootsuite/mastodon/pull/14906))
- Fix updating account counters when association is not yet created ([Gargron](https://github.com/tootsuite/mastodon/pull/15108))
- Fix account processing failing because of large collections ([ThibG](https://github.com/tootsuite/mastodon/pull/15027))
- Fix resolving an account through its non-canonical form (i.e. alternate domain) ([ThibG](https://github.com/tootsuite/mastodon/pull/15187))
- Fix slow distinct queries where grouped queries are faster ([Gargron](https://github.com/tootsuite/mastodon/pull/15287))
### Security
- Fix 2FA/sign-in token sessions being valid after password change ([Gargron](https://github.com/tootsuite/mastodon/pull/14802))
- Fix resolving accounts sometimes creating duplicate records for a given ActivityPub identifier ([ThibG](https://github.com/tootsuite/mastodon/pull/15364))
## [3.2.1] - 2020-10-19
### Added
- Add support for latest HTTP Signatures spec draft ([ThibG](https://github.com/tootsuite/mastodon/pull/14556))
- Add support for inlined objects in ActivityPub `to`/`cc` ([ThibG](https://github.com/tootsuite/mastodon/pull/14514))
### Changed
- Change actors to not be served at all without authentication in limited federation mode ([ThibG](https://github.com/tootsuite/mastodon/pull/14800))
- Previously, a bare version of an actor was served when not authenticated, i.e. username and public key
- Because all actor fetch requests are signed using a separate system actor, that is no longer required
### Fixed
- Fix `tootctl media` commands not recognizing very large IDs ([ThibG](https://github.com/tootsuite/mastodon/pull/14536))
- Fix crash when failing to load emoji picker in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14525))
- Fix contrast requirements in thumbnail color extraction ([ThibG](https://github.com/tootsuite/mastodon/pull/14464))
- Fix audio/video player not using `CDN_HOST` on public pages ([ThibG](https://github.com/tootsuite/mastodon/pull/14486))
- Fix private boost icon not being used on public pages ([OmmyZhang](https://github.com/tootsuite/mastodon/pull/14471))
- Fix audio player on Safari in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/14485), [ThibG](https://github.com/tootsuite/mastodon/pull/14465))
- Fix dereferencing remote statuses not using the correct account for signature when receiving a targeted inbox delivery ([ThibG](https://github.com/tootsuite/mastodon/pull/14656))
- Fix nil error in `tootctl media remove` ([noellabo](https://github.com/tootsuite/mastodon/pull/14657))
- Fix videos with near-60 fps being rejected ([Gargron](https://github.com/tootsuite/mastodon/pull/14684))
- Fix reported statuses not being included in warning e-mail ([Gargron](https://github.com/tootsuite/mastodon/pull/14778))
- Fix `Reject` activities of `Follow` objects not correctly destroying a follow relationship ([ThibG](https://github.com/tootsuite/mastodon/pull/14479))
- Fix inefficiencies in fan-out-on-write service ([Gargron](https://github.com/tootsuite/mastodon/pull/14682), [noellabo](https://github.com/tootsuite/mastodon/pull/14709))
- Fix timeout errors when trying to webfinger some IPv6 configurations ([Gargron](https://github.com/tootsuite/mastodon/pull/14919))
- Fix files served as `application/octet-stream` being rejected without attempting mime type detection ([ThibG](https://github.com/tootsuite/mastodon/pull/14452))
## [3.2.0] - 2020-07-27 ## [3.2.0] - 2020-07-27
### Added ### Added
@ -156,14 +387,14 @@ All notable changes to this project will be documented in this file.
- Only then proceed to start removing their data (slow) - Only then proceed to start removing their data (slow)
- Clear out media attachments in a separate worker (slow) - Clear out media attachments in a separate worker (slow)
## [v3.1.5] - 2020-07-07
## [3.1.5] - 2020-07-07
### Security ### Security
- Fix media attachment enumeration ([ThibG](https://github.com/tootsuite/mastodon/pull/14254)) - Fix media attachment enumeration ([ThibG](https://github.com/tootsuite/mastodon/pull/14254))
- Change rate limits for various paths ([Gargron](https://github.com/tootsuite/mastodon/pull/14253)) - Change rate limits for various paths ([Gargron](https://github.com/tootsuite/mastodon/pull/14253))
- Fix other sessions not being logged out on password change ([Gargron](https://github.com/tootsuite/mastodon/pull/14252)) - Fix other sessions not being logged out on password change ([Gargron](https://github.com/tootsuite/mastodon/pull/14252))
## [v3.1.4] - 2020-05-14
## [3.1.4] - 2020-05-14
### Added ### Added
- Add `vi` to available locales ([taicv](https://github.com/tootsuite/mastodon/pull/13542)) - Add `vi` to available locales ([taicv](https://github.com/tootsuite/mastodon/pull/13542))
@ -230,7 +461,7 @@ All notable changes to this project will be documented in this file.
- For apps that self-register on behalf of every individual user (such as most mobile apps), this is a non-issue - For apps that self-register on behalf of every individual user (such as most mobile apps), this is a non-issue
- The issue only affects developers of apps who are shared between multiple users, such as server-side apps like cross-posters - The issue only affects developers of apps who are shared between multiple users, such as server-side apps like cross-posters
## [v3.1.3] - 2020-04-05
## [3.1.3] - 2020-04-05
### Added ### Added
- Add ability to filter audit log in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/13381)) - Add ability to filter audit log in admin UI ([Gargron](https://github.com/tootsuite/mastodon/pull/13381))

+ 3
- 3
Dockerfile View File

@ -1,10 +1,10 @@
FROM ubuntu:20.04 as build-dep FROM ubuntu:20.04 as build-dep
# Use bash for the shell # Use bash for the shell
SHELL ["bash", "-c"]
SHELL ["/usr/bin/bash", "-c"]
# Install Node v12 (LTS) # Install Node v12 (LTS)
ENV NODE_VER="12.16.3"
ENV NODE_VER="12.20.0"
RUN ARCH= && \ RUN ARCH= && \
dpkgArch="$(dpkg --print-architecture)" && \ dpkgArch="$(dpkg --print-architecture)" && \
case "${dpkgArch##*-}" in \ case "${dpkgArch##*-}" in \
@ -40,7 +40,7 @@ RUN apt update && \
cd .. && rm -rf jemalloc-$JE_VER $JE_VER.tar.gz cd .. && rm -rf jemalloc-$JE_VER $JE_VER.tar.gz
# Install Ruby # Install Ruby
ENV RUBY_VER="2.6.6"
ENV RUBY_VER="2.7.2"
ENV CPPFLAGS="-I/opt/jemalloc/include" ENV CPPFLAGS="-I/opt/jemalloc/include"
ENV LDFLAGS="-L/opt/jemalloc/lib/" ENV LDFLAGS="-L/opt/jemalloc/lib/"
RUN apt update && \ RUN apt update && \

+ 28
- 27
Gemfile View File

@ -5,22 +5,19 @@ ruby '>= 2.5.0', '< 3.0.0'
gem 'pkg-config', '~> 1.4' gem 'pkg-config', '~> 1.4'
gem 'puma', '~> 4.3'
gem 'puma', '~> 5.1'
gem 'rails', '~> 5.2.4.4' gem 'rails', '~> 5.2.4.4'
gem 'sprockets', '~> 3.7.2' gem 'sprockets', '~> 3.7.2'
gem 'thor', '~> 1.0' gem 'thor', '~> 1.0'
gem 'rack', '~> 2.2.3' gem 'rack', '~> 2.2.3'
gem 'thwait', '~> 0.2.0'
gem 'e2mmap', '~> 0.1.0'
gem 'hamlit-rails', '~> 0.2' gem 'hamlit-rails', '~> 0.2'
gem 'pg', '~> 1.2' gem 'pg', '~> 1.2'
gem 'makara', '~> 0.4' gem 'makara', '~> 0.4'
gem 'pghero', '~> 2.7' gem 'pghero', '~> 2.7'
gem 'dotenv-rails', '~> 2.7' gem 'dotenv-rails', '~> 2.7'
gem 'aws-sdk-s3', '~> 1.81', require: false
gem 'aws-sdk-s3', '~> 1.87', require: false
gem 'fog-core', '<= 2.1.0' gem 'fog-core', '<= 2.1.0'
gem 'fog-openstack', '~> 0.3', require: false gem 'fog-openstack', '~> 0.3', require: false
gem 'paperclip', '~> 6.0' gem 'paperclip', '~> 6.0'
@ -30,12 +27,12 @@ gem 'blurhash', '~> 0.1'
gem 'active_model_serializers', '~> 0.10' gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.7' gem 'addressable', '~> 2.7'
gem 'bootsnap', '~> 1.4', require: false
gem 'bootsnap', '~> 1.5', require: false
gem 'browser' gem 'browser'
gem 'charlock_holmes', '~> 0.7.7' gem 'charlock_holmes', '~> 0.7.7'
gem 'iso-639' gem 'iso-639'
gem 'chewy', '~> 5.1' gem 'chewy', '~> 5.1'
gem 'cld3', '~> 3.3.0'
gem 'cld3', '~> 3.4.1'
gem 'devise', '~> 4.7' gem 'devise', '~> 4.7'
gem 'devise-two-factor', '~> 3.1' gem 'devise-two-factor', '~> 3.1'
@ -43,10 +40,11 @@ group :pam_authentication, optional: true do
gem 'devise_pam_authenticatable2', '~> 9.2' gem 'devise_pam_authenticatable2', '~> 9.2'
end end
gem 'net-ldap', '~> 0.16'
gem 'omniauth-cas', '~> 1.1'
gem 'net-ldap', '~> 0.17'
gem 'omniauth-cas', '~> 2.0'
gem 'omniauth-saml', '~> 1.10' gem 'omniauth-saml', '~> 1.10'
gem 'omniauth', '~> 1.9' gem 'omniauth', '~> 1.9'
gem 'omniauth-rails_csrf_protection', '~> 0.1'
gem 'color_diff', '~> 0.1' gem 'color_diff', '~> 0.1'
gem 'discard', '~> 1.2' gem 'discard', '~> 1.2'
@ -54,7 +52,6 @@ gem 'doorkeeper', '~> 5.4'
gem 'ed25519', '~> 1.2' gem 'ed25519', '~> 1.2'
gem 'fast_blank', '~> 1.0' gem 'fast_blank', '~> 1.0'
gem 'fastimage' gem 'fastimage'
gem 'goldfinger', '~> 2.1'
gem 'hiredis', '~> 0.6' gem 'hiredis', '~> 0.6'
gem 'redis-namespace', '~> 1.8' gem 'redis-namespace', '~> 1.8'
gem 'health_check', git: 'https://github.com/ianheggie/health_check', ref: '0b799ead604f900ed50685e9b2d469cd2befba5b' gem 'health_check', git: 'https://github.com/ianheggie/health_check', ref: '0b799ead604f900ed50685e9b2d469cd2befba5b'
@ -67,12 +64,12 @@ gem 'kaminari', '~> 1.2'
gem 'link_header', '~> 0.0' gem 'link_header', '~> 0.0'
gem 'mime-types', '~> 3.3.1', require: 'mime/types/columnar' gem 'mime-types', '~> 3.3.1', require: 'mime/types/columnar'
gem 'nilsimsa', git: 'https://github.com/witgo/nilsimsa', ref: 'fd184883048b922b176939f851338d0a4971a532' gem 'nilsimsa', git: 'https://github.com/witgo/nilsimsa', ref: 'fd184883048b922b176939f851338d0a4971a532'
gem 'nokogiri', '~> 1.10'
gem 'nokogiri', '~> 1.11'
gem 'nsa', '~> 0.2' gem 'nsa', '~> 0.2'
gem 'oj', '~> 3.10' gem 'oj', '~> 3.10'
gem 'ox', '~> 2.13'
gem 'ox', '~> 2.14'
gem 'parslet' gem 'parslet'
gem 'parallel', '~> 1.19'
gem 'parallel', '~> 1.20'
gem 'posix-spawn' gem 'posix-spawn'
gem 'pundit', '~> 2.1' gem 'pundit', '~> 2.1'
gem 'premailer-rails' gem 'premailer-rails'
@ -82,9 +79,10 @@ gem 'rails-i18n', '~> 5.1'
gem 'rails-settings-cached', '~> 0.6' gem 'rails-settings-cached', '~> 0.6'
gem 'redis', '~> 4.2', require: ['redis', 'redis/connection/hiredis'] gem 'redis', '~> 4.2', require: ['redis', 'redis/connection/hiredis']
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock' gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
gem 'rqrcode', '~> 1.1'
gem 'ruby-progressbar', '~> 1.10'
gem 'rqrcode', '~> 1.2'
gem 'ruby-progressbar', '~> 1.11'
gem 'sanitize', '~> 5.2' gem 'sanitize', '~> 5.2'
gem 'scenic', '~> 1.5'
gem 'sidekiq', '~> 6.1' gem 'sidekiq', '~> 6.1'
gem 'sidekiq-scheduler', '~> 3.0' gem 'sidekiq-scheduler', '~> 3.0'
gem 'sidekiq-unique-jobs', '~> 6.0' gem 'sidekiq-unique-jobs', '~> 6.0'
@ -94,7 +92,7 @@ gem 'simple_form', '~> 5.0'
gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie' gem 'sprockets-rails', '~> 3.2', require: 'sprockets/railtie'
gem 'stoplight', '~> 2.2.1' gem 'stoplight', '~> 2.2.1'
gem 'strong_migrations', '~> 0.7' gem 'strong_migrations', '~> 0.7'
gem 'tty-prompt', '~> 0.22', require: false
gem 'tty-prompt', '~> 0.23', require: false
gem 'twitter-text', '~> 1.14' gem 'twitter-text', '~> 1.14'
gem 'tzinfo-data', '~> 1.2020' gem 'tzinfo-data', '~> 1.2020'
gem 'webpacker', '~> 5.2' gem 'webpacker', '~> 5.2'
@ -119,30 +117,30 @@ group :production, :test do
end end
group :test do group :test do
gem 'capybara', '~> 3.33'
gem 'capybara', '~> 3.34'
gem 'climate_control', '~> 0.2' gem 'climate_control', '~> 0.2'
gem 'faker', '~> 2.14'
gem 'faker', '~> 2.15'
gem 'microformats', '~> 4.2' gem 'microformats', '~> 4.2'
gem 'rails-controller-testing', '~> 1.0' gem 'rails-controller-testing', '~> 1.0'
gem 'rspec-sidekiq', '~> 3.1' gem 'rspec-sidekiq', '~> 3.1'
gem 'simplecov', '~> 0.19', require: false
gem 'webmock', '~> 3.9'
gem 'parallel_tests', '~> 3.2'
gem 'simplecov', '~> 0.21', require: false
gem 'webmock', '~> 3.11'
gem 'parallel_tests', '~> 3.4'
gem 'rspec_junit_formatter', '~> 0.4' gem 'rspec_junit_formatter', '~> 0.4'
end end
group :development do group :development do
gem 'active_record_query_trace', '~> 1.7'
gem 'active_record_query_trace', '~> 1.8'
gem 'annotate', '~> 3.1' gem 'annotate', '~> 3.1'
gem 'better_errors', '~> 2.8'
gem 'binding_of_caller', '~> 0.7'
gem 'better_errors', '~> 2.9'
gem 'binding_of_caller', '~> 1.0'
gem 'bullet', '~> 6.1' gem 'bullet', '~> 6.1'
gem 'letter_opener', '~> 1.7' gem 'letter_opener', '~> 1.7'
gem 'letter_opener_web', '~> 1.4' gem 'letter_opener_web', '~> 1.4'
gem 'memory_profiler' gem 'memory_profiler'
gem 'rubocop', '~> 0.91', require: false
gem 'rubocop-rails', '~> 2.8', require: false
gem 'brakeman', '~> 4.9', require: false
gem 'rubocop', '~> 1.7', require: false
gem 'rubocop-rails', '~> 2.9', require: false
gem 'brakeman', '~> 4.10', require: false
gem 'bundler-audit', '~> 0.7', require: false gem 'bundler-audit', '~> 0.7', require: false
gem 'capistrano', '~> 3.14' gem 'capistrano', '~> 3.14'
@ -160,3 +158,6 @@ end
gem 'concurrent-ruby', require: false gem 'concurrent-ruby', require: false
gem 'connection_pool', require: false gem 'connection_pool', require: false
gem 'xorcist', '~> 1.1'
gem 'pluck_each', '~> 0.1.3'

+ 133
- 122
Gemfile.lock View File

@ -39,12 +39,12 @@ GEM
erubi (~> 1.4) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.3) rails-html-sanitizer (~> 1.0, >= 1.0.3)
active_model_serializers (0.10.10)
actionpack (>= 4.1, < 6.1)
activemodel (>= 4.1, < 6.1)
active_model_serializers (0.10.12)
actionpack (>= 4.1, < 6.2)
activemodel (>= 4.1, < 6.2)
case_transform (>= 0.2) case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3) jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
active_record_query_trace (1.7)
active_record_query_trace (1.8)
activejob (5.2.4.4) activejob (5.2.4.4)
activesupport (= 5.2.4.4) activesupport (= 5.2.4.4)
globalid (>= 0.3.6) globalid (>= 0.3.6)
@ -79,37 +79,37 @@ GEM
cocaine (~> 0.5.3) cocaine (~> 0.5.3)
awrence (1.1.1) awrence (1.1.1)
aws-eventstream (1.1.0) aws-eventstream (1.1.0)
aws-partitions (1.373.0)
aws-sdk-core (3.107.0)
aws-partitions (1.413.0)
aws-sdk-core (3.110.0)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0) aws-partitions (~> 1, >= 1.239.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
jmespath (~> 1.0) jmespath (~> 1.0)
aws-sdk-kms (1.38.0)
aws-sdk-core (~> 3, >= 3.99.0)
aws-sdk-kms (1.40.0)
aws-sdk-core (~> 3, >= 3.109.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.81.0)
aws-sdk-core (~> 3, >= 3.104.3)
aws-sdk-s3 (1.87.0)
aws-sdk-core (~> 3, >= 3.109.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sigv4 (1.2.2) aws-sigv4 (1.2.2)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
bcrypt (3.1.16) bcrypt (3.1.16)
better_errors (2.8.1)
better_errors (2.9.1)
coderay (>= 1.0.0) coderay (>= 1.0.0)
erubi (>= 1.0.0) erubi (>= 1.0.0)
rack (>= 0.9.0) rack (>= 0.9.0)
bindata (2.4.8) bindata (2.4.8)
binding_of_caller (0.8.0)
binding_of_caller (1.0.0)
debug_inspector (>= 0.0.1) debug_inspector (>= 0.0.1)
blurhash (0.1.4) blurhash (0.1.4)
ffi (~> 1.10.0) ffi (~> 1.10.0)
bootsnap (1.4.8)
bootsnap (1.5.1)
msgpack (~> 1.0) msgpack (~> 1.0)
brakeman (4.9.1)
brakeman (4.10.1)
browser (4.2.0) browser (4.2.0)
builder (3.2.4) builder (3.2.4)
bullet (6.1.0)
bullet (6.1.2)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
uniform_notifier (~> 1.11) uniform_notifier (~> 1.11)
bundler-audit (0.7.0.1) bundler-audit (0.7.0.1)
@ -131,7 +131,7 @@ GEM
sshkit (~> 1.3) sshkit (~> 1.3)
capistrano-yarn (2.0.2) capistrano-yarn (2.0.2)
capistrano (~> 3.0) capistrano (~> 3.0)
capybara (3.33.0)
capybara (3.34.0)
addressable addressable
mini_mime (>= 0.1.3) mini_mime (>= 0.1.3)
nokogiri (~> 1.8) nokogiri (~> 1.8)
@ -147,9 +147,9 @@ GEM
activesupport (>= 4.0) activesupport (>= 4.0)
elasticsearch (>= 2.0.0) elasticsearch (>= 2.0.0)
elasticsearch-dsl elasticsearch-dsl
chunky_png (1.3.12)
cld3 (3.3.0)
ffi (>= 1.1.0, < 1.12.0)
chunky_png (1.3.15)
cld3 (3.4.1)
ffi (>= 1.1.0, < 1.15.0)
climate_control (0.2.0) climate_control (0.2.0)
cocaine (0.5.8) cocaine (0.5.8)
climate_control (>= 0.0.3, < 1.0) climate_control (>= 0.0.3, < 1.0)
@ -160,11 +160,12 @@ GEM
cose (1.0.0) cose (1.0.0)
cbor (~> 0.5.9) cbor (~> 0.5.9)
openssl-signature_algorithm (~> 0.4.0) openssl-signature_algorithm (~> 0.4.0)
crack (0.4.4)
crack (0.4.5)
rexml
crass (1.0.6) crass (1.0.6)
css_parser (1.7.1) css_parser (1.7.1)
addressable addressable
debug_inspector (0.0.3)
debug_inspector (1.0.0)
devise (4.7.3) devise (4.7.3)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
@ -183,7 +184,7 @@ GEM
diff-lcs (1.4.4) diff-lcs (1.4.4)
discard (1.2.0) discard (1.2.0)
activerecord (>= 4.2, < 7) activerecord (>= 4.2, < 7)
docile (1.3.2)
docile (1.3.4)
domain_name (0.5.20190701) domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0) unf (>= 0.0.5, < 1.0.0)
doorkeeper (5.4.0) doorkeeper (5.4.0)
@ -204,17 +205,17 @@ GEM
faraday (~> 1) faraday (~> 1)
multi_json multi_json
encryptor (3.0.0) encryptor (3.0.0)
erubi (1.9.0)
erubi (1.10.0)
et-orbi (1.2.4) et-orbi (1.2.4)
tzinfo tzinfo
excon (0.76.0) excon (0.76.0)
fabrication (2.21.1) fabrication (2.21.1)
faker (2.14.0)
faker (2.15.1)
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
faraday (1.0.1) faraday (1.0.1)
multipart-post (>= 1.2, < 3) multipart-post (>= 1.2, < 3)
fast_blank (1.0.0) fast_blank (1.0.0)
fastimage (2.2.0)
fastimage (2.2.1)
ffi (1.10.0) ffi (1.10.0)
ffi-compiler (1.0.1) ffi-compiler (1.0.1)
ffi (>= 1.0.0) ffi (>= 1.0.0)
@ -235,17 +236,12 @@ GEM
fugit (1.3.9) fugit (1.3.9)
et-orbi (~> 1.1, >= 1.1.8) et-orbi (~> 1.1, >= 1.1.8)
raabro (~> 1.3) raabro (~> 1.3)
fuubar (2.5.0)
fuubar (2.5.1)
rspec-core (~> 3.0) rspec-core (~> 3.0)
ruby-progressbar (~> 1.4) ruby-progressbar (~> 1.4)
globalid (0.4.2) globalid (0.4.2)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
goldfinger (2.1.1)
addressable (~> 2.5)
http (~> 4.0)
nokogiri (~> 1.8)
oj (~> 3.0)
hamlit (2.11.1)
hamlit (2.13.0)
temple (>= 0.8.2) temple (>= 0.8.2)
thor thor
tilt tilt
@ -278,7 +274,7 @@ GEM
rainbow (>= 2.0.0) rainbow (>= 2.0.0)
i18n (1.8.5) i18n (1.8.5)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
i18n-tasks (0.9.31)
i18n-tasks (0.9.33)
activesupport (>= 4.0.2) activesupport (>= 4.0.2)
ast (>= 2.1.0) ast (>= 2.1.0)
erubi erubi
@ -294,14 +290,14 @@ GEM
jmespath (1.4.0) jmespath (1.4.0)
json (2.3.1) json (2.3.1)
json-canonicalization (0.2.0) json-canonicalization (0.2.0)
json-ld (3.1.4)
json-ld (3.1.7)
htmlentities (~> 4.3) htmlentities (~> 4.3)
json-canonicalization (~> 0.2) json-canonicalization (~> 0.2)
link_header (~> 0.0, >= 0.0.8) link_header (~> 0.0, >= 0.0.8)
multi_json (~> 1.14) multi_json (~> 1.14)
rack (~> 2.0) rack (~> 2.0)
rdf (~> 3.1) rdf (~> 3.1)
json-ld-preloaded (3.1.3)
json-ld-preloaded (3.1.4)
json-ld (~> 3.1) json-ld (~> 3.1)
rdf (~> 3.1) rdf (~> 3.1)
jsonapi-renderer (0.2.2) jsonapi-renderer (0.2.2)
@ -332,7 +328,7 @@ GEM
activesupport (>= 4) activesupport (>= 4)
railties (>= 4) railties (>= 4)
request_store (~> 1.0) request_store (~> 1.0)
loofah (2.7.0)
loofah (2.8.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.5.9) nokogiri (>= 1.5.9)
mail (2.7.1) mail (2.7.1)
@ -343,7 +339,7 @@ GEM
mimemagic (~> 0.3.2) mimemagic (~> 0.3.2)
mario-redis-lock (1.2.1) mario-redis-lock (1.2.1)
redis (>= 3.0.5) redis (>= 3.0.5)
memory_profiler (0.9.14)
memory_profiler (1.0.0)
method_source (1.0.0) method_source (1.0.0)
microformats (4.2.1) microformats (4.2.1)
json (~> 2.2) json (~> 2.2)
@ -353,40 +349,44 @@ GEM
mime-types-data (3.2020.0512) mime-types-data (3.2020.0512)
mimemagic (0.3.5) mimemagic (0.3.5)
mini_mime (1.0.2) mini_mime (1.0.2)
mini_portile2 (2.4.0)
mini_portile2 (2.5.0)
minitest (5.14.2) minitest (5.14.2)
msgpack (1.3.3) msgpack (1.3.3)
multi_json (1.15.0) multi_json (1.15.0)
multipart-post (2.1.1) multipart-post (2.1.1)
net-ldap (0.16.3)
net-ldap (0.17.0)
net-scp (3.0.0) net-scp (3.0.0)
net-ssh (>= 2.6.5, < 7.0.0) net-ssh (>= 2.6.5, < 7.0.0)
net-ssh (6.1.0) net-ssh (6.1.0)
nio4r (2.5.4) nio4r (2.5.4)
nokogiri (1.10.10)
mini_portile2 (~> 2.4.0)
nokogumbo (2.0.2)
nokogiri (1.11.1)
mini_portile2 (~> 2.5.0)
racc (~> 1.4)
nokogumbo (2.0.4)
nokogiri (~> 1.8, >= 1.8.4) nokogiri (~> 1.8, >= 1.8.4)
nsa (0.2.7) nsa (0.2.7)
activesupport (>= 4.2, < 6) activesupport (>= 4.2, < 6)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
sidekiq (>= 3.5) sidekiq (>= 3.5)
statsd-ruby (~> 1.4, >= 1.4.0) statsd-ruby (~> 1.4, >= 1.4.0)
oj (3.10.14)
oj (3.10.18)
omniauth (1.9.1) omniauth (1.9.1)
hashie (>= 3.4.6) hashie (>= 3.4.6)
rack (>= 1.6.2, < 3) rack (>= 1.6.2, < 3)
omniauth-cas (1.1.1)
omniauth-cas (2.0.0)
addressable (~> 2.3) addressable (~> 2.3)
nokogiri (~> 1.5) nokogiri (~> 1.5)
omniauth (~> 1.2) omniauth (~> 1.2)
omniauth-saml (1.10.2)
omniauth-rails_csrf_protection (0.1.2)
actionpack (>= 4.2)
omniauth (>= 1.3.1)
omniauth-saml (1.10.3)
omniauth (~> 1.3, >= 1.3.2) omniauth (~> 1.3, >= 1.3.2)
ruby-saml (~> 1.9) ruby-saml (~> 1.9)
openssl (2.2.0) openssl (2.2.0)
openssl-signature_algorithm (0.4.0) openssl-signature_algorithm (0.4.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
ox (2.13.4)
ox (2.14.0)
paperclip (6.0.0) paperclip (6.0.0)
activemodel (>= 4.2.0) activemodel (>= 4.2.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
@ -396,20 +396,23 @@ GEM
paperclip-av-transcoder (0.6.4) paperclip-av-transcoder (0.6.4)
av (~> 0.9.0) av (~> 0.9.0)
paperclip (>= 2.5.2) paperclip (>= 2.5.2)
parallel (1.19.2)
parallel_tests (3.2.0)
parallel (1.20.1)
parallel_tests (3.4.0)
parallel parallel
parser (2.7.1.4)
parser (3.0.0.0)
ast (~> 2.4.1) ast (~> 2.4.1)
parslet (2.0.0) parslet (2.0.0)
pastel (0.8.0) pastel (0.8.0)
tty-color (~> 0.5) tty-color (~> 0.5)
pg (1.2.3) pg (1.2.3)
pghero (2.7.2)
pghero (2.7.3)
activerecord (>= 5) activerecord (>= 5)
pkg-config (1.4.3)
pkg-config (1.4.4)
pluck_each (0.1.3)
activerecord (> 3.2.0)
activesupport (> 3.0.0)
posix-spawn (0.3.15) posix-spawn (0.3.15)
premailer (1.13.1)
premailer (1.14.2)
addressable addressable
css_parser (>= 1.6.0) css_parser (>= 1.6.0)
htmlentities (>= 4.0.0) htmlentities (>= 4.0.0)
@ -426,11 +429,12 @@ GEM
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (4.0.6) public_suffix (4.0.6)
puma (4.3.6)
puma (5.1.1)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.1.0) pundit (2.1.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.3.1)
raabro (1.3.3)
racc (1.5.2)
rack (2.2.3) rack (2.2.3)
rack-attack (6.3.1) rack-attack (6.3.1)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
@ -474,13 +478,13 @@ GEM
rake (>= 0.8.7) rake (>= 0.8.7)
thor (>= 0.19.0, < 2.0) thor (>= 0.19.0, < 2.0)
rainbow (3.0.0) rainbow (3.0.0)
rake (13.0.1)
rdf (3.1.6)
rake (13.0.3)
rdf (3.1.8)
hamster (~> 3.0) hamster (~> 3.0)
link_header (~> 0.0, >= 0.0.8) link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.4.0) rdf-normalize (0.4.0)
rdf (~> 3.1) rdf (~> 3.1)
redis (4.2.2)
redis (4.2.5)
redis-actionpack (5.2.0) redis-actionpack (5.2.0)
actionpack (>= 5, < 7) actionpack (>= 5, < 7)
redis-rack (>= 2.1.0, < 3) redis-rack (>= 2.1.0, < 3)
@ -499,7 +503,7 @@ GEM
redis-store (>= 1.2, < 2) redis-store (>= 1.2, < 2)
redis-store (1.9.0) redis-store (1.9.0)
redis (>= 4, < 5) redis (>= 4, < 5)
regexp_parser (1.8.0)
regexp_parser (1.8.2)
request_store (1.5.0) request_store (1.5.0)
rack (>= 1.4) rack (>= 1.4)
responders (3.0.1) responders (3.0.1)
@ -508,58 +512,61 @@ GEM
rexml (3.2.4) rexml (3.2.4)
rotp (2.1.2) rotp (2.1.2)
rpam2 (4.0.2) rpam2 (4.0.2)
rqrcode (1.1.2)
rqrcode (1.2.0)
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 0.1)
rqrcode_core (0.1.2)
rspec-core (3.9.2)
rspec-support (~> 3.9.3)
rspec-expectations (3.9.2)
rqrcode_core (~> 0.2)
rqrcode_core (0.2.0)
rspec-core (3.10.1)
rspec-support (~> 3.10.0)
rspec-expectations (3.10.1)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.9.0)
rspec-mocks (3.9.1)
rspec-support (~> 3.10.0)
rspec-mocks (3.10.1)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.9.0)
rspec-rails (4.0.1)
rspec-support (~> 3.10.0)
rspec-rails (4.0.2)
actionpack (>= 4.2) actionpack (>= 4.2)
activesupport (>= 4.2) activesupport (>= 4.2)
railties (>= 4.2) railties (>= 4.2)
rspec-core (~> 3.9)
rspec-expectations (~> 3.9)
rspec-mocks (~> 3.9)
rspec-support (~> 3.9)
rspec-core (~> 3.10)
rspec-expectations (~> 3.10)
rspec-mocks (~> 3.10)
rspec-support (~> 3.10)
rspec-sidekiq (3.1.0) rspec-sidekiq (3.1.0)
rspec-core (~> 3.0, >= 3.0.0) rspec-core (~> 3.0, >= 3.0.0)
sidekiq (>= 2.4.0) sidekiq (>= 2.4.0)
rspec-support (3.9.3)
rspec-support (3.10.1)
rspec_junit_formatter (0.4.1) rspec_junit_formatter (0.4.1)
rspec-core (>= 2, < 4, != 2.12.0) rspec-core (>= 2, < 4, != 2.12.0)
rubocop (0.91.0)
rubocop (1.7.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 2.7.1.1)
parser (>= 2.7.1.5)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.7)
regexp_parser (>= 1.8, < 3.0)
rexml rexml
rubocop-ast (>= 0.4.0, < 1.0)
rubocop-ast (>= 1.2.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 2.0) unicode-display_width (>= 1.4.0, < 2.0)
rubocop-ast (0.4.2)
parser (>= 2.7.1.4)
rubocop-rails (2.8.1)
rubocop-ast (1.3.0)
parser (>= 2.7.1.5)
rubocop-rails (2.9.1)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 0.87.0)
ruby-progressbar (1.10.1)
rubocop (>= 0.90.0, < 2.0)
ruby-progressbar (1.11.0)
ruby-saml (1.11.0) ruby-saml (1.11.0)
nokogiri (>= 1.5.10) nokogiri (>= 1.5.10)
rufus-scheduler (3.6.0) rufus-scheduler (3.6.0)
fugit (~> 1.1, >= 1.1.6) fugit (~> 1.1, >= 1.1.6)
safety_net_attestation (0.4.0) safety_net_attestation (0.4.0)
jwt (~> 2.0) jwt (~> 2.0)
sanitize (5.2.1)
sanitize (5.2.2)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.8.0) nokogiri (>= 1.8.0)
nokogumbo (~> 2.0) nokogumbo (~> 2.0)
scenic (1.5.4)
activerecord (>= 4.0.0)
railties (>= 4.0.0)
securecompare (1.0.0) securecompare (1.0.0)
semantic_range (2.3.0) semantic_range (2.3.0)
sidekiq (6.1.2) sidekiq (6.1.2)
@ -575,19 +582,21 @@ GEM
sidekiq (>= 3) sidekiq (>= 3)
thwait thwait
tilt (>= 1.4.0) tilt (>= 1.4.0)
sidekiq-unique-jobs (6.0.23)
sidekiq-unique-jobs (6.0.25)
concurrent-ruby (~> 1.0, >= 1.0.5) concurrent-ruby (~> 1.0, >= 1.0.5)
sidekiq (>= 4.0, < 7.0) sidekiq (>= 4.0, < 7.0)
thor (>= 0.20, < 2.0) thor (>= 0.20, < 2.0)
simple-navigation (4.1.0) simple-navigation (4.1.0)
activesupport (>= 2.3.2) activesupport (>= 2.3.2)
simple_form (5.0.2)
simple_form (5.0.3)
actionpack (>= 5.0) actionpack (>= 5.0)
activemodel (>= 5.0) activemodel (>= 5.0)
simplecov (0.19.0)
simplecov (0.21.2)
docile (~> 1.1) docile (~> 1.1)
simplecov-html (~> 0.11) simplecov-html (~> 0.11)
simplecov-html (0.12.2)
simplecov_json_formatter (~> 0.1)
simplecov-html (0.12.3)
simplecov_json_formatter (0.1.2)
sprockets (3.7.2) sprockets (3.7.2)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
rack (> 1, < 3) rack (> 1, < 3)
@ -598,15 +607,15 @@ GEM
sshkit (1.21.0) sshkit (1.21.0)
net-scp (>= 1.1.2) net-scp (>= 1.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
stackprof (0.2.15)
stackprof (0.2.16)
statsd-ruby (1.4.0) statsd-ruby (1.4.0)
stoplight (2.2.1) stoplight (2.2.1)
streamio-ffmpeg (3.0.2) streamio-ffmpeg (3.0.2)
multi_json (~> 1.8) multi_json (~> 1.8)
strong_migrations (0.7.1)
strong_migrations (0.7.4)
activerecord (>= 5) activerecord (>= 5)
temple (0.8.2) temple (0.8.2)
terminal-table (1.8.0)
terminal-table (2.0.0)
unicode-display_width (~> 1.1, >= 1.1.1) unicode-display_width (~> 1.1, >= 1.1.1)
terrapin (0.6.0) terrapin (0.6.0)
climate_control (>= 0.0.3, < 1.0) climate_control (>= 0.0.3, < 1.0)
@ -618,21 +627,21 @@ GEM
tpm-key_attestation (0.9.0) tpm-key_attestation (0.9.0)
bindata (~> 2.4) bindata (~> 2.4)
openssl-signature_algorithm (~> 0.4.0) openssl-signature_algorithm (~> 0.4.0)
tty-color (0.5.2)
tty-color (0.6.0)
tty-cursor (0.7.1) tty-cursor (0.7.1)
tty-prompt (0.22.0)
tty-prompt (0.23.0)
pastel (~> 0.8) pastel (~> 0.8)
tty-reader (~> 0.8) tty-reader (~> 0.8)
tty-reader (0.8.0)
tty-reader (0.9.0)
tty-cursor (~> 0.7) tty-cursor (~> 0.7)
tty-screen (~> 0.8) tty-screen (~> 0.8)
wisper (~> 2.0) wisper (~> 2.0)
tty-screen (0.8.1) tty-screen (0.8.1)
twitter-text (1.14.7) twitter-text (1.14.7)
unf (~> 0.1.0) unf (~> 0.1.0)
tzinfo (1.2.7)
tzinfo (1.2.9)
thread_safe (~> 0.1) thread_safe (~> 0.1)
tzinfo-data (1.2020.1)
tzinfo-data (1.2020.6)
tzinfo (>= 1.0.0) tzinfo (>= 1.0.0)
unf (0.1.4) unf (0.1.4)
unf_ext unf_ext
@ -651,7 +660,7 @@ GEM
safety_net_attestation (~> 0.4.0) safety_net_attestation (~> 0.4.0)
securecompare (~> 1.0) securecompare (~> 1.0)
tpm-key_attestation (~> 0.9.0) tpm-key_attestation (~> 0.9.0)
webmock (3.9.1)
webmock (3.11.0)
addressable (>= 2.3.6) addressable (>= 2.3.6)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
@ -667,6 +676,7 @@ GEM
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
wisper (2.0.1) wisper (2.0.1)
xorcist (1.1.2)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
@ -675,15 +685,15 @@ PLATFORMS
DEPENDENCIES DEPENDENCIES
active_model_serializers (~> 0.10) active_model_serializers (~> 0.10)
active_record_query_trace (~> 1.7)
active_record_query_trace (~> 1.8)
addressable (~> 2.7) addressable (~> 2.7)
annotate (~> 3.1) annotate (~> 3.1)
aws-sdk-s3 (~> 1.81)
better_errors (~> 2.8)
binding_of_caller (~> 0.7)
aws-sdk-s3 (~> 1.87)
better_errors (~> 2.9)
binding_of_caller (~> 1.0)
blurhash (~> 0.1) blurhash (~> 0.1)
bootsnap (~> 1.4)
brakeman (~> 4.9)
bootsnap (~> 1.5)
brakeman (~> 4.10)
browser browser
bullet (~> 6.1) bullet (~> 6.1)
bundler-audit (~> 0.7) bundler-audit (~> 0.7)
@ -691,10 +701,10 @@ DEPENDENCIES
capistrano-rails (~> 1.6) capistrano-rails (~> 1.6)
capistrano-rbenv (~> 2.2) capistrano-rbenv (~> 2.2)
capistrano-yarn (~> 2.0) capistrano-yarn (~> 2.0)
capybara (~> 3.33)
capybara (~> 3.34)
charlock_holmes (~> 0.7.7) charlock_holmes (~> 0.7.7)
chewy (~> 5.1) chewy (~> 5.1)
cld3 (~> 3.3.0)
cld3 (~> 3.4.1)
climate_control (~> 0.2) climate_control (~> 0.2)
color_diff (~> 0.1) color_diff (~> 0.1)
concurrent-ruby concurrent-ruby
@ -705,16 +715,14 @@ DEPENDENCIES
discard (~> 1.2) discard (~> 1.2)
doorkeeper (~> 5.4) doorkeeper (~> 5.4)
dotenv-rails (~> 2.7) dotenv-rails (~> 2.7)
e2mmap (~> 0.1.0)
ed25519 (~> 1.2) ed25519 (~> 1.2)
fabrication (~> 2.21) fabrication (~> 2.21)
faker (~> 2.14)
faker (~> 2.15)
fast_blank (~> 1.0) fast_blank (~> 1.0)
fastimage fastimage
fog-core (<= 2.1.0) fog-core (<= 2.1.0)
fog-openstack (~> 0.3) fog-openstack (~> 0.3)
fuubar (~> 2.5) fuubar (~> 2.5)
goldfinger (~> 2.1)
hamlit-rails (~> 0.2) hamlit-rails (~> 0.2)
health_check! health_check!
hiredis (~> 0.6) hiredis (~> 0.6)
@ -737,29 +745,31 @@ DEPENDENCIES
memory_profiler memory_profiler
microformats (~> 4.2) microformats (~> 4.2)
mime-types (~> 3.3.1) mime-types (~> 3.3.1)
net-ldap (~> 0.16)
net-ldap (~> 0.17)
nilsimsa! nilsimsa!
nokogiri (~> 1.10)
nokogiri (~> 1.11)
nsa (~> 0.2) nsa (~> 0.2)
oj (~> 3.10) oj (~> 3.10)
omniauth (~> 1.9) omniauth (~> 1.9)
omniauth-cas (~> 1.1)
omniauth-cas (~> 2.0)
omniauth-rails_csrf_protection (~> 0.1)
omniauth-saml (~> 1.10) omniauth-saml (~> 1.10)
ox (~> 2.13)
ox (~> 2.14)
paperclip (~> 6.0) paperclip (~> 6.0)
paperclip-av-transcoder (~> 0.6) paperclip-av-transcoder (~> 0.6)
parallel (~> 1.19)
parallel_tests (~> 3.2)
parallel (~> 1.20)
parallel_tests (~> 3.4)
parslet parslet
pg (~> 1.2) pg (~> 1.2)
pghero (~> 2.7) pghero (~> 2.7)
pkg-config (~> 1.4) pkg-config (~> 1.4)
pluck_each (~> 0.1.3)
posix-spawn posix-spawn
premailer-rails premailer-rails
private_address_check (~> 0.5) private_address_check (~> 0.5)
pry-byebug (~> 3.9) pry-byebug (~> 3.9)
pry-rails (~> 0.3) pry-rails (~> 0.3)
puma (~> 4.3)
puma (~> 5.1)
pundit (~> 2.1) pundit (~> 2.1)
rack (~> 2.2.3) rack (~> 2.2.3)
rack-attack (~> 6.3) rack-attack (~> 6.3)
@ -772,21 +782,22 @@ DEPENDENCIES
redis (~> 4.2) redis (~> 4.2)
redis-namespace (~> 1.8) redis-namespace (~> 1.8)
redis-rails (~> 5.0) redis-rails (~> 5.0)
rqrcode (~> 1.1)
rqrcode (~> 1.2)
rspec-rails (~> 4.0) rspec-rails (~> 4.0)
rspec-sidekiq (~> 3.1) rspec-sidekiq (~> 3.1)
rspec_junit_formatter (~> 0.4) rspec_junit_formatter (~> 0.4)
rubocop (~> 0.91)
rubocop-rails (~> 2.8)
ruby-progressbar (~> 1.10)
rubocop (~> 1.7)
rubocop-rails (~> 2.9)
ruby-progressbar (~> 1.11)
sanitize (~> 5.2) sanitize (~> 5.2)
scenic (~> 1.5)
sidekiq (~> 6.1) sidekiq (~> 6.1)
sidekiq-bulk (~> 0.2.0) sidekiq-bulk (~> 0.2.0)
sidekiq-scheduler (~> 3.0) sidekiq-scheduler (~> 3.0)
sidekiq-unique-jobs (~> 6.0) sidekiq-unique-jobs (~> 6.0)
simple-navigation (~> 4.1) simple-navigation (~> 4.1)
simple_form (~> 5.0) simple_form (~> 5.0)
simplecov (~> 0.19)
simplecov (~> 0.21)
sprockets (~> 3.7.2) sprockets (~> 3.7.2)
sprockets-rails (~> 3.2) sprockets-rails (~> 3.2)
stackprof stackprof
@ -794,11 +805,11 @@ DEPENDENCIES
streamio-ffmpeg (~> 3.0) streamio-ffmpeg (~> 3.0)
strong_migrations (~> 0.7) strong_migrations (~> 0.7)
thor (~> 1.0) thor (~> 1.0)
thwait (~> 0.2.0)
tty-prompt (~> 0.22)
tty-prompt (~> 0.23)
twitter-text (~> 1.14) twitter-text (~> 1.14)
tzinfo-data (~> 1.2020) tzinfo-data (~> 1.2020)
webauthn (~> 3.0.0.alpha1) webauthn (~> 3.0.0.alpha1)
webmock (~> 3.9)
webmock (~> 3.11)
webpacker (~> 5.2) webpacker (~> 5.2)
webpush webpush
xorcist (~> 1.1)

+ 2
- 2
Procfile.dev View File

@ -1,4 +1,4 @@
web: env PORT=3000 bundle exec puma -C config/puma.rb
sidekiq: env PORT=3000 bundle exec sidekiq
web: env PORT=3000 RAILS_ENV=development bundle exec puma -C config/puma.rb
sidekiq: env PORT=3000 RAILS_ENV=development bundle exec sidekiq
stream: env PORT=4000 yarn run start stream: env PORT=4000 yarn run start
webpack: ./bin/webpack-dev-server --listen-host 0.0.0.0 webpack: ./bin/webpack-dev-server --listen-host 0.0.0.0

+ 4
- 1
app/controllers/about_controller.rb View File

@ -1,12 +1,15 @@
# frozen_string_literal: true # frozen_string_literal: true
class AboutController < ApplicationController class AboutController < ApplicationController
include RegistrationSpamConcern
layout 'public' layout 'public'
# before_action :require_open_federation!, only: [:show, :more] # before_action :require_open_federation!, only: [:show, :more]
before_action :set_body_classes, only: :show before_action :set_body_classes, only: :show
before_action :set_instance_presenter before_action :set_instance_presenter
before_action :set_expires_in, only: [:show, :more, :terms]
before_action :set_expires_in, only: [:more, :terms]
before_action :set_registration_form_time, only: :show
before_action :authenticate_user!, only: [:jump, :my_data] before_action :authenticate_user!, only: [:jump, :my_data]
skip_before_action :require_functional!, only: [:more, :terms] skip_before_action :require_functional!, only: [:more, :terms]

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

@ -81,7 +81,7 @@ class AccountsController < ApplicationController
end end
def account_media_status_ids def account_media_status_ids
@account.media_attachments.attached.reorder(nil).select(:status_id).distinct
@account.media_attachments.attached.reorder(nil).select(:status_id).group(:status_id)
end end
def no_replies_scope def no_replies_scope
@ -102,6 +102,10 @@ class AccountsController < ApplicationController
params[:username] params[:username]
end end
def skip_temporary_suspension_response?
request.format == :json
end
def rss_url def rss_url
if tag_requested? if tag_requested?
short_account_tag_url(@account, params[:tag], format: 'rss') short_account_tag_url(@account, params[:tag], format: 'rss')

+ 4
- 0
app/controllers/activitypub/base_controller.rb View File

@ -8,4 +8,8 @@ class ActivityPub::BaseController < Api::BaseController
def set_cache_headers def set_cache_headers
response.headers['Vary'] = 'Signature' if authorized_fetch_mode? response.headers['Vary'] = 'Signature' if authorized_fetch_mode?
end end
def skip_temporary_suspension_response?
false
end
end end

+ 36
- 0
app/controllers/activitypub/followers_synchronizations_controller.rb View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
class ActivityPub::FollowersSynchronizationsController < ActivityPub::BaseController
include SignatureVerification
include AccountOwnedConcern
before_action :require_signature!
before_action :set_items
before_action :set_cache_headers
def show
expires_in 0, public: false
render json: collection_presenter,
serializer: ActivityPub::CollectionSerializer,
adapter: ActivityPub::Adapter,
content_type: 'application/activity+json'
end
private
def uri_prefix
signed_request_account.uri[/http(s?):\/\/[^\/]+\//]
end
def set_items
@items = @account.followers.where(Account.arel_table[:uri].matches(uri_prefix + '%', false, true)).pluck(:uri)
end
def collection_presenter
ActivityPub::CollectionPresenter.new(
id: account_followers_synchronization_url(@account),
type: :ordered,
items: @items
)
end
end

+ 23
- 5
app/controllers/activitypub/inboxes_controller.rb View File

@ -5,25 +5,26 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
include JsonLdHelper include JsonLdHelper
include AccountOwnedConcern include AccountOwnedConcern
before_action :skip_unknown_actor_delete
before_action :skip_unknown_actor_activity
before_action :require_signature! before_action :require_signature!
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
def create def create
upgrade_account upgrade_account
process_collection_synchronization
process_payload process_payload
head 202 head 202
end end
private private
def skip_unknown_actor_delete
head 202 if unknown_deleted_account?
def skip_unknown_actor_activity
head 202 if unknown_affected_account?
end end
def unknown_deleted_account?
def unknown_affected_account?
json = Oj.load(body, mode: :strict) json = Oj.load(body, mode: :strict)
json.is_a?(Hash) && json['type'] == 'Delete' && json['actor'].present? && json['actor'] == value_or_id(json['object']) && !Account.where(uri: json['actor']).exists?
json.is_a?(Hash) && %w(Delete Update).include?(json['type']) && json['actor'].present? && json['actor'] == value_or_id(json['object']) && !Account.where(uri: json['actor']).exists?
rescue Oj::ParseError rescue Oj::ParseError
false false
end end
@ -32,6 +33,10 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
params[:account_username].present? params[:account_username].present?
end end
def skip_temporary_suspension_response?
true
end
def body def body
return @body if defined?(@body) return @body if defined?(@body)
@ -52,6 +57,19 @@ class ActivityPub::InboxesController < ActivityPub::BaseController
DeliveryFailureTracker.reset!(signed_request_account.inbox_url) DeliveryFailureTracker.reset!(signed_request_account.inbox_url)
end end
def process_collection_synchronization
raw_params = request.headers['Collection-Synchronization']
return if raw_params.blank? || ENV['DISABLE_FOLLOWERS_SYNCHRONIZATION'] == 'true'
# Re-using the syntax for signature parameters
tree = SignatureParamsParser.new.parse(raw_params)
params = SignatureParamsTransformer.new.apply(tree)
ActivityPub::PrepareFollowersSynchronizationService.new.call(signed_request_account, params)
rescue Parslet::ParseFailed
Rails.logger.warn 'Error parsing Collection-Synchronization header'
end
def process_payload def process_payload
ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body, @account&.id) ActivityPub::ProcessingWorker.perform_async(signed_request_account.id, body, @account&.id)
end end

+ 1
- 1
app/controllers/activitypub/replies_controller.rb View File

@ -31,7 +31,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
end end
def set_replies def set_replies
@replies = only_other_accounts? ? Status.where.not(account_id: @account.id) : @account.statuses
@replies = only_other_accounts? ? Status.where.not(account_id: @account.id).joins(:account).merge(Account.without_suspended) : @account.statuses
@replies = @replies.where(in_reply_to_id: @status.id, visibility: [:public, :unlisted]) @replies = @replies.where(in_reply_to_id: @status.id, visibility: [:public, :unlisted])
@replies = @replies.paginate_by_min_id(DESCENDANTS_LIMIT, params[:min_id]) @replies = @replies.paginate_by_min_id(DESCENDANTS_LIMIT, params[:min_id])
end end

+ 7
- 0
app/controllers/admin/accounts_controller.rb View File

@ -53,6 +53,13 @@ module Admin
redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.destroyed_msg', username: @account.acct) redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.destroyed_msg', username: @account.acct)
end end
def unsensitive
authorize @account, :unsensitive?
@account.unsensitize!
log_action :unsensitive, @account
redirect_to admin_account_path(@account.id)
end
def unsilence def unsilence
authorize @account, :unsilence? authorize @account, :unsilence?
@account.unsilence! @account.unsilence!

+ 1
- 1
app/controllers/admin/announcements_controller.rb View File

@ -71,7 +71,7 @@ class Admin::AnnouncementsController < Admin::BaseController
private private
def set_announcements def set_announcements
@announcements = AnnouncementFilter.new(filter_params).results.page(params[:page])
@announcements = AnnouncementFilter.new(filter_params).results.reverse_chronological.page(params[:page])
end end
def set_announcement def set_announcement

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

@ -29,6 +29,7 @@ module Admin
@domain_block = existing_domain_block @domain_block = existing_domain_block
@domain_block.update(resource_params) @domain_block.update(resource_params)
end end
if @domain_block.save if @domain_block.save
DomainBlockWorker.perform_async(@domain_block.id) DomainBlockWorker.perform_async(@domain_block.id)
log_action :create, @domain_block log_action :create, @domain_block
@ -40,7 +41,7 @@ module Admin
end end
def update def update
authorize :domain_block, :create?
authorize :domain_block, :update?
@domain_block.update(update_params) @domain_block.update(update_params)
@ -48,7 +49,7 @@ module Admin
if @domain_block.save if @domain_block.save
DomainBlockWorker.perform_async(@domain_block.id, severity_changed) DomainBlockWorker.perform_async(@domain_block.id, severity_changed)
log_action :create, @domain_block
log_action :update, @domain_block
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg') redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
else else
render :edit render :edit
@ -73,11 +74,11 @@ module Admin
end end
def update_params def update_params
params.require(:domain_block).permit(:severity, :reject_media, :reject_reports, :private_comment, :public_comment)
params.require(:domain_block).permit(:severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate)
end end
def resource_params def resource_params
params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_reports, :private_comment, :public_comment)
params.require(:domain_block).permit(:domain, :severity, :reject_media, :reject_reports, :private_comment, :public_comment, :obfuscate)
end end
end end
end end

+ 5
- 39
app/controllers/admin/instances_controller.rb View File

@ -2,65 +2,31 @@
module Admin module Admin
class InstancesController < BaseController class InstancesController < BaseController
before_action :set_domain_block, only: :show
before_action :set_domain_allow, only: :show
before_action :set_instances, only: :index
before_action :set_instance, only: :show before_action :set_instance, only: :show
def index def index
authorize :instance, :index? authorize :instance, :index?
@instances = ordered_instances
end end
def show def show
authorize :instance, :show? authorize :instance, :show?
@following_count = Follow.where(account: Account.where(domain: params[:id])).count
@followers_count = Follow.where(target_account: Account.where(domain: params[:id])).count
@reports_count = Report.where(target_account: Account.where(domain: params[:id])).count
@blocks_count = Block.where(target_account: Account.where(domain: params[:id])).count
@available = DeliveryFailureTracker.available?(params[:id])
@media_storage = MediaAttachment.where(account: Account.where(domain: params[:id])).sum(:file_file_size)
@private_comment = @domain_block&.private_comment
@public_comment = @domain_block&.public_comment
end end
private private
def set_domain_block
@domain_block = DomainBlock.rule_for(params[:id])
end
def set_domain_allow
@domain_allow = DomainAllow.rule_for(params[:id])
end
def set_instance def set_instance
resource = Account.by_domain_accounts.find_by(domain: params[:id])
resource ||= @domain_block
resource ||= @domain_allow
@instance = Instance.find(params[:id])
end
if resource
@instance = Instance.new(resource)
else
not_found
end
def set_instances
@instances = filtered_instances.page(params[:page])
end end
def filtered_instances def filtered_instances
InstanceFilter.new(whitelist_mode? ? { allowed: true } : filter_params).results InstanceFilter.new(whitelist_mode? ? { allowed: true } : filter_params).results
end end
def paginated_instances
filtered_instances.page(params[:page])
end
helper_method :paginated_instances
def ordered_instances
paginated_instances.map { |resource| Instance.new(resource) }
end
def filter_params def filter_params
params.slice(*InstanceFilter::KEYS).permit(*InstanceFilter::KEYS) params.slice(*InstanceFilter::KEYS).permit(*InstanceFilter::KEYS)
end end

+ 56
- 0
app/controllers/admin/ip_blocks_controller.rb View File

@ -0,0 +1,56 @@
# frozen_string_literal: true
module Admin
class IpBlocksController < BaseController
def index
authorize :ip_block, :index?
@ip_blocks = IpBlock.page(params[:page])
@form = Form::IpBlockBatch.new
end
def new
authorize :ip_block, :create?
@ip_block = IpBlock.new(ip: '', severity: :no_access, expires_in: 1.year)
end
def create
authorize :ip_block, :create?
@ip_block = IpBlock.new(resource_params)
if @ip_block.save
log_action :create, @ip_block
redirect_to admin_ip_blocks_path, notice: I18n.t('admin.ip_blocks.created_msg')
else
render :new
end
end
def batch
@form = Form::IpBlockBatch.new(form_ip_block_batch_params.merge(current_account: current_account, action: action_from_button))
@form.save
rescue ActionController::ParameterMissing
flash[:alert] = I18n.t('admin.ip_blocks.no_ip_block_selected')
rescue Mastodon::NotPermittedError
flash[:alert] = I18n.t('admin.custom_emojis.not_permitted')
ensure
redirect_to admin_ip_blocks_path
end
private
def resource_params
params.require(:ip_block).permit(:ip, :severity, :comment, :expires_in)
end
def action_from_button
'delete' if params[:delete]
end
def form_ip_block_batch_params
params.require(:form_ip_block_batch).permit(ip_block_ids: [])
end
end
end

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

@ -14,7 +14,7 @@ module Admin
@statuses = @account.statuses.where(visibility: [:public, :unlisted]) @statuses = @account.statuses.where(visibility: [:public, :unlisted])
if params[:media] if params[:media]
account_media_status_ids = @account.media_attachments.attached.reorder(nil).select(:status_id).distinct
account_media_status_ids = @account.media_attachments.attached.reorder(nil).select(:status_id).group(:status_id)
@statuses.merge!(Status.where(id: account_media_status_ids)) @statuses.merge!(Status.where(id: account_media_status_ids))
end end

+ 2
- 2
app/controllers/api/base_controller.rb View File

@ -40,7 +40,7 @@ class Api::BaseController < ApplicationController
render json: { error: 'This action is not allowed' }, status: 403 render json: { error: 'This action is not allowed' }, status: 403
end end
rescue_from Mastodon::RaceConditionError do
rescue_from Mastodon::RaceConditionError, Seahorse::Client::NetworkingError, Stoplight::Error::RedLight do
render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503 render json: { error: 'There was a temporary problem serving your request, please try again' }, status: 503
end end
@ -103,7 +103,7 @@ class Api::BaseController < ApplicationController
elsif !current_user.functional? elsif !current_user.functional?
render json: { error: 'Your login is currently disabled' }, status: 403 render json: { error: 'Your login is currently disabled' }, status: 403
else else
set_user_activity
update_user_sign_in
end end
end end

+ 2
- 2
app/controllers/api/v1/accounts/featured_tags_controller.rb View File

@ -7,7 +7,7 @@ class Api::V1::Accounts::FeaturedTagsController < Api::BaseController
respond_to :json respond_to :json
def index def index
render json: @featured_tags, each_serializer: REST::AccountFeaturedTagSerializer
render json: @featured_tags, each_serializer: REST::FeaturedTagSerializer
end end
private private
@ -17,6 +17,6 @@ class Api::V1::Accounts::FeaturedTagsController < Api::BaseController
end end
def set_featured_tags def set_featured_tags
@featured_tags = @account.suspended? ? @account.featured_tags : []
@featured_tags = @account.suspended? ? [] : @account.featured_tags
end end
end end

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

@ -20,7 +20,7 @@ class Api::V1::AccountsController < Api::BaseController
end end
def create def create
token = AppSignUpService.new.call(doorkeeper_token.application, account_params)
token = AppSignUpService.new.call(doorkeeper_token.application, request.remote_ip, account_params)
response = Doorkeeper::OAuth::TokenResponse.new(token) response = Doorkeeper::OAuth::TokenResponse.new(token)
headers.merge!(response.headers) headers.merge!(response.headers)
@ -42,7 +42,7 @@ class Api::V1::AccountsController < Api::BaseController
end end
def mute def mute
MuteService.new.call(current_user.account, @account, notifications: truthy_param?(:notifications))
MuteService.new.call(current_user.account, @account, notifications: truthy_param?(:notifications), duration: (params[:duration]&.to_i || 0))
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships
end end

+ 8
- 0
app/controllers/api/v1/admin/accounts_controller.rb View File

@ -22,6 +22,7 @@ class Api::V1::Admin::AccountsController < Api::BaseController
active active
pending pending
disabled disabled
sensitized
silenced silenced
suspended suspended
username username
@ -68,6 +69,13 @@ class Api::V1::Admin::AccountsController < Api::BaseController
render json: @account, serializer: REST::Admin::AccountSerializer render json: @account, serializer: REST::Admin::AccountSerializer
end end
def unsensitive
authorize @account, :unsensitive?
@account.unsensitize!
log_action :unsensitive, @account
render json: @account, serializer: REST::Admin::AccountSerializer
end
def unsilence def unsilence
authorize @account, :unsilence? authorize @account, :unsilence?
@account.unsilence! @account.unsilence!

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

@ -12,7 +12,7 @@ class Api::V1::Crypto::Keys::ClaimsController < Api::BaseController
private private
def set_claim_results 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
@claim_results = devices.filter_map { |device_params| ::Keys::ClaimService.new.call(current_account, device_params[:account_id], device_params[:device_id]) }
end end
def resource_params def resource_params

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

@ -17,7 +17,7 @@ class Api::V1::Crypto::Keys::QueriesController < Api::BaseController
end end
def set_query_results def set_query_results
@query_results = @accounts.map { |account| ::Keys::QueryService.new.call(account) }.compact
@query_results = @accounts.filter_map { |account| ::Keys::QueryService.new.call(account) }
end end
def account_ids def account_ids

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

@ -8,7 +8,7 @@ class Api::V1::Instances::PeersController < Api::BaseController
def index def index
expires_in 1.day, public: true expires_in 1.day, public: true
render_with_cache(expires_in: 1.day) { Account.remote.domains }
render_with_cache(expires_in: 1.day) { Instance.where.not(domain: DomainBlock.select(:domain)).pluck(:domain) }
end end
private private

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

@ -7,7 +7,7 @@ class Api::V1::MarkersController < Api::BaseController
before_action :require_user! before_action :require_user!
def index def index
@markers = current_user.markers.where(timeline: Array(params[:timeline])).each_with_object({}) { |marker, h| h[marker.timeline] = marker }
@markers = current_user.markers.where(timeline: Array(params[:timeline])).index_by(&:timeline)
render json: serialize_map(@markers) render json: serialize_map(@markers)
end end

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

@ -7,7 +7,7 @@ class Api::V1::MutesController < Api::BaseController
def index def index
@accounts = load_accounts @accounts = load_accounts
render json: @accounts, each_serializer: REST::AccountSerializer
render json: @accounts, each_serializer: REST::MutedAccountSerializer
end end
private private

+ 13
- 2
app/controllers/api/v1/statuses/favourites_controller.rb View File

@ -5,7 +5,7 @@ class Api::V1::Statuses::FavouritesController < Api::BaseController
before_action -> { doorkeeper_authorize! :write, :'write:favourites' } before_action -> { doorkeeper_authorize! :write, :'write:favourites' }
before_action :require_user! before_action :require_user!
before_action :set_status
before_action :set_status, only: [:create]
def create def create
FavouriteService.new.call(current_account, @status) FavouriteService.new.call(current_account, @status)
@ -13,8 +13,19 @@ class Api::V1::Statuses::FavouritesController < Api::BaseController
end end
def destroy def destroy
UnfavouriteWorker.perform_async(current_account.id, @status.id)
fav = current_account.favourites.find_by(status_id: params[:status_id])
if fav
@status = fav.status
UnfavouriteWorker.perform_async(current_account.id, @status.id)
else
@status = Status.find(params[:status_id])
authorize @status, :show?
end
render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, favourites_map: { @status.id => false }) render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_account.id, favourites_map: { @status.id => false })
rescue Mastodon::NotPermittedError
not_found
end end
private private

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

@ -28,7 +28,7 @@ class ApplicationController < ActionController::Base
rescue_from ActiveRecord::RecordNotFound, with: :not_found rescue_from ActiveRecord::RecordNotFound, with: :not_found
rescue_from Mastodon::NotPermittedError, with: :forbidden rescue_from Mastodon::NotPermittedError, with: :forbidden
rescue_from HTTP::Error, OpenSSL::SSL::SSLError, with: :internal_server_error rescue_from HTTP::Error, OpenSSL::SSL::SSLError, with: :internal_server_error
rescue_from Mastodon::RaceConditionError, with: :service_unavailable
rescue_from Mastodon::RaceConditionError, Seahorse::Client::NetworkingError, Stoplight::Error::RedLight, with: :service_unavailable
rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests rescue_from Mastodon::RateLimitExceededError, with: :too_many_requests
before_action :store_current_location, except: :raise_not_found, unless: :devise_controller? before_action :store_current_location, except: :raise_not_found, unless: :devise_controller?

+ 7
- 4
app/controllers/auth/registrations_controller.rb View File

@ -2,6 +2,7 @@
class Auth::RegistrationsController < Devise::RegistrationsController class Auth::RegistrationsController < Devise::RegistrationsController
include Devise::Controllers::Rememberable include Devise::Controllers::Rememberable
include RegistrationSpamConcern
layout :determine_layout layout :determine_layout
@ -13,6 +14,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
before_action :set_body_classes, only: [:new, :create, :edit, :update] before_action :set_body_classes, only: [:new, :create, :edit, :update]
before_action :require_not_suspended!, only: [:update] before_action :require_not_suspended!, only: [:update]
before_action :set_cache_headers, only: [:edit, :update] before_action :set_cache_headers, only: [:edit, :update]
before_action :set_registration_form_time, only: :new
skip_before_action :require_functional!, only: [:edit, :update] skip_before_action :require_functional!, only: [:edit, :update]
@ -45,16 +47,17 @@ class Auth::RegistrationsController < Devise::RegistrationsController
def build_resource(hash = nil) def build_resource(hash = nil)
super(hash) super(hash)
resource.locale = I18n.locale
resource.invite_code = params[:invite_code] if resource.invite_code.blank?
resource.current_sign_in_ip = request.remote_ip
resource.locale = I18n.locale
resource.invite_code = params[:invite_code] if resource.invite_code.blank?
resource.registration_form_time = session[:registration_form_time]
resource.sign_up_ip = request.remote_ip
resource.build_account if resource.account.nil? resource.build_account if resource.account.nil?
end end
def configure_sign_up_params def configure_sign_up_params
devise_parameter_sanitizer.permit(:sign_up) do |u| devise_parameter_sanitizer.permit(:sign_up) do |u|
u.permit({ account_attributes: [:username], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement)
u.permit({ account_attributes: [:username], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password)
end end
end end

+ 21
- 1
app/controllers/auth/sessions_controller.rb View File

@ -7,6 +7,7 @@ class Auth::SessionsController < Devise::SessionsController
skip_before_action :require_no_authentication, only: [:create] skip_before_action :require_no_authentication, only: [:create]
skip_before_action :require_functional! skip_before_action :require_functional!
skip_before_action :update_user_sign_in
include TwoFactorAuthenticationConcern include TwoFactorAuthenticationConcern
include SignInTokenAuthenticationConcern include SignInTokenAuthenticationConcern
@ -24,6 +25,7 @@ class Auth::SessionsController < Devise::SessionsController
def create def create
super do |resource| super do |resource|
resource.update_sign_in!(request, new_sign_in: true)
remember_me(resource) remember_me(resource)
flash.delete(:notice) flash.delete(:notice)
end end
@ -57,7 +59,7 @@ class Auth::SessionsController < Devise::SessionsController
def find_user def find_user
if session[:attempt_user_id] if session[:attempt_user_id]
User.find(session[:attempt_user_id])
User.find_by(id: session[:attempt_user_id])
else else
user = User.authenticate_with_ldap(user_params) if Devise.ldap_authentication user = User.authenticate_with_ldap(user_params) if Devise.ldap_authentication
user ||= User.authenticate_with_pam(user_params) if Devise.pam_authentication user ||= User.authenticate_with_pam(user_params) if Devise.pam_authentication
@ -90,6 +92,7 @@ class Auth::SessionsController < Devise::SessionsController
def require_no_authentication def require_no_authentication
super super
# Delete flash message that isn't entirely useful and may be confusing in # Delete flash message that isn't entirely useful and may be confusing in
# most cases because /web doesn't display/clear flash messages. # most cases because /web doesn't display/clear flash messages.
flash.delete(:alert) if flash[:alert] == I18n.t('devise.failure.already_authenticated') flash.delete(:alert) if flash[:alert] == I18n.t('devise.failure.already_authenticated')
@ -107,13 +110,30 @@ class Auth::SessionsController < Devise::SessionsController
def home_paths(resource) def home_paths(resource)
paths = [about_path] paths = [about_path]
if single_user_mode? && resource.is_a?(User) if single_user_mode? && resource.is_a?(User)
paths << short_account_path(username: resource.account) paths << short_account_path(username: resource.account)
end end
paths paths
end end
def continue_after? def continue_after?
truthy_param?(:continue) truthy_param?(:continue)
end end
def restart_session
clear_attempt_from_session
redirect_to new_user_session_path, alert: I18n.t('devise.failure.timeout')
end
def set_attempt_session(user)
session[:attempt_user_id] = user.id
session[:attempt_user_updated_at] = user.updated_at.to_s
end
def clear_attempt_from_session
session.delete(:attempt_user_id)
session.delete(:attempt_user_updated_at)
end
end end

+ 19
- 1
app/controllers/concerns/account_owned_concern.rb View File

@ -29,6 +29,24 @@ module AccountOwnedConcern
end end
def check_account_suspension def check_account_suspension
expires_in(3.minutes, public: true) && gone if @account.suspended?
if @account.suspended_permanently?
permanent_suspension_response
elsif @account.suspended? && !skip_temporary_suspension_response?
temporary_suspension_response
end
end
def skip_temporary_suspension_response?
false
end
def permanent_suspension_response
expires_in(3.minutes, public: true)
gone
end
def temporary_suspension_response
expires_in(3.minutes, public: true)
forbidden
end end
end end

+ 2
- 2
app/controllers/concerns/cache_concern.rb View File

@ -38,14 +38,14 @@ module CacheConcern
klass.reload_stale_associations!(cached_keys_with_value.values) if klass.respond_to?(:reload_stale_associations!) klass.reload_stale_associations!(cached_keys_with_value.values) if klass.respond_to?(:reload_stale_associations!)
unless uncached_ids.empty? unless uncached_ids.empty?
uncached = klass.where(id: uncached_ids).with_includes.each_with_object({}) { |item, h| h[item.id] = item }
uncached = klass.where(id: uncached_ids).with_includes.index_by(&:id)
uncached.each_value do |item| uncached.each_value do |item|
Rails.cache.write(item, item) Rails.cache.write(item, item)
end end
end end
raw.map { |item| cached_keys_with_value[item.id] || uncached[item.id] }.compact
raw.filter_map { |item| cached_keys_with_value[item.id] || uncached[item.id] }
end end
def cache_collection_paginated_by_id(raw, klass, limit, options) def cache_collection_paginated_by_id(raw, klass, limit, options)

+ 9
- 0
app/controllers/concerns/registration_spam_concern.rb View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
module RegistrationSpamConcern
extend ActiveSupport::Concern
def set_registration_form_time
session[:registration_form_time] = Time.now.utc
end
end

+ 9
- 7
app/controllers/concerns/sign_in_token_authentication_concern.rb View File

@ -18,7 +18,9 @@ module SignInTokenAuthenticationConcern
def authenticate_with_sign_in_token def authenticate_with_sign_in_token
user = self.resource = find_user user = self.resource = find_user
if user_params[:sign_in_token_attempt].present? && session[:attempt_user_id]
if user.present? && session[:attempt_user_id].present? && session[:attempt_user_updated_at] != user.updated_at.to_s
restart_session
elsif user_params.key?(:sign_in_token_attempt) && session[:attempt_user_id]
authenticate_with_sign_in_token_attempt(user) authenticate_with_sign_in_token_attempt(user)
elsif user.present? && user.external_or_valid_password?(user_params[:password]) elsif user.present? && user.external_or_valid_password?(user_params[:password])
prompt_for_sign_in_token(user) prompt_for_sign_in_token(user)
@ -27,7 +29,7 @@ module SignInTokenAuthenticationConcern
def authenticate_with_sign_in_token_attempt(user) def authenticate_with_sign_in_token_attempt(user)
if valid_sign_in_token_attempt?(user) if valid_sign_in_token_attempt?(user)
session.delete(:attempt_user_id)
clear_attempt_from_session
remember_me(user) remember_me(user)
sign_in(user) sign_in(user)
else else
@ -42,10 +44,10 @@ module SignInTokenAuthenticationConcern
UserMailer.sign_in_token(user, request.remote_ip, request.user_agent, Time.now.utc.to_s).deliver_later! UserMailer.sign_in_token(user, request.remote_ip, request.user_agent, Time.now.utc.to_s).deliver_later!
end end
set_locale do
session[:attempt_user_id] = user.id
@body_classes = 'lighter'
render :sign_in_token
end
set_attempt_session(user)
@body_classes = 'lighter'
set_locale { render :sign_in_token }
end end
end end

+ 12
- 4
app/controllers/concerns/signature_verification.rb View File

@ -76,6 +76,7 @@ module SignatureVerification
raise SignatureVerificationError, 'Signed request date outside acceptable time window' unless matches_time_window? raise SignatureVerificationError, 'Signed request date outside acceptable time window' unless matches_time_window?
verify_signature_strength! verify_signature_strength!
verify_body_digest!
account = account_from_key_id(signature_params['keyId']) account = account_from_key_id(signature_params['keyId'])
@ -126,10 +127,19 @@ module SignatureVerification
def verify_signature_strength! def verify_signature_strength!
raise SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)') raise SignatureVerificationError, 'Mastodon requires the Date header or (created) pseudo-header to be signed' unless signed_headers.include?('date') || signed_headers.include?('(created)')
raise SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(Request::REQUEST_TARGET) || signed_headers.include?('digest') raise SignatureVerificationError, 'Mastodon requires the Digest header or (request-target) pseudo-header to be signed' unless signed_headers.include?(Request::REQUEST_TARGET) || signed_headers.include?('digest')
raise SignatureVerificationError, 'Mastodon requires the Host header to be signed' unless signed_headers.include?('host')
raise SignatureVerificationError, 'Mastodon requires the Host header to be signed when doing a GET request' if requestn>.get?span> && !signed_headers.include?('host')
raise SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest') raise SignatureVerificationError, 'Mastodon requires the Digest header to be signed when doing a POST request' if request.post? && !signed_headers.include?('digest')
end end
def verify_body_digest!
return unless signed_headers.include?('digest')
digests = request.headers['Digest'].split(',').map { |digest| digest.split('=', 2) }.map { |key, value| [key.downcase, value] }
sha256 = digests.assoc('sha-256')
raise SignatureVerificationError, "Mastodon only supports SHA-256 in Digest header. Offered algorithms: #{digests.map(&:first).join(', ')}" if sha256.nil?
raise SignatureVerificationError, "Invalid Digest value. Computed SHA-256 digest: #{body_digest}; given: #{sha256[1]}" if body_digest != sha256[1]
end
def verify_signature(account, signature, compare_signed_string) def verify_signature(account, signature, compare_signed_string)
if account.keypair.public_key.verify(OpenSSL::Digest.new('SHA256'), signature, compare_signed_string) if account.keypair.public_key.verify(OpenSSL::Digest.new('SHA256'), signature, compare_signed_string)
@signed_request_account = account @signed_request_account = account
@ -153,8 +163,6 @@ module SignatureVerification
raise SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank? raise SignatureVerificationError, 'Pseudo-header (expires) used but corresponding argument missing' if signature_params['expires'].blank?
"(expires): #{signature_params['expires']}" "(expires): #{signature_params['expires']}"
elsif signed_header == 'digest'
"digest: #{body_digest}"
else else
"#{signed_header}: #{request.headers[to_header_name(signed_header)]}" "#{signed_header}: #{request.headers[to_header_name(signed_header)]}"
end end
@ -187,7 +195,7 @@ module SignatureVerification
end end
def body_digest def body_digest
"SHA-256=#{Digest::SHA256.base64digest(request_body)}"
@body_digest ||= Digest::SHA256.base64digest(request_body)
end end
def to_header_name(name) def to_header_name(name)

+ 18
- 14
app/controllers/concerns/two_factor_authentication_concern.rb View File

@ -37,9 +37,11 @@ module TwoFactorAuthenticationConcern
def authenticate_with_two_factor def authenticate_with_two_factor
user = self.resource = find_user user = self.resource = find_user
if user.webauthn_enabled? && user_params[:credential].present? && session[:attempt_user_id]
if user.present? && session[:attempt_user_id].present? && session[:attempt_user_updated_at] != user.updated_at.to_s
restart_session
elsif user.webauthn_enabled? && user_params.key?(:credential) && session[:attempt_user_id]
authenticate_with_two_factor_via_webauthn(user) authenticate_with_two_factor_via_webauthn(user)
elsif user_params[:otp_attempt].present? && session[:attempt_user_id]
elsif user_params.key?(:otp_attempt) && session[:attempt_user_id]
authenticate_with_two_factor_via_otp(user) authenticate_with_two_factor_via_otp(user)
elsif user.present? && user.external_or_valid_password?(user_params[:password]) elsif user.present? && user.external_or_valid_password?(user_params[:password])
prompt_for_two_factor(user) prompt_for_two_factor(user)
@ -50,7 +52,7 @@ module TwoFactorAuthenticationConcern
webauthn_credential = WebAuthn::Credential.from_get(user_params[:credential]) webauthn_credential = WebAuthn::Credential.from_get(user_params[:credential])
if valid_webauthn_credential?(user, webauthn_credential) if valid_webauthn_credential?(user, webauthn_credential)
session.delete(:attempt_user_id)
clear_attempt_from_session
remember_me(user) remember_me(user)
sign_in(user) sign_in(user)
render json: { redirect_path: root_path }, status: :ok render json: { redirect_path: root_path }, status: :ok
@ -61,7 +63,7 @@ module TwoFactorAuthenticationConcern
def authenticate_with_two_factor_via_otp(user) def authenticate_with_two_factor_via_otp(user)
if valid_otp_attempt?(user) if valid_otp_attempt?(user)
session.delete(:attempt_user_id)
clear_attempt_from_session
remember_me(user) remember_me(user)
sign_in(user) sign_in(user)
else else
@ -71,16 +73,18 @@ module TwoFactorAuthenticationConcern
end end
def prompt_for_two_factor(user) def prompt_for_two_factor(user)
set_locale do
session[:attempt_user_id] = user.id
@body_classes = 'lighter'
@webauthn_enabled = user.webauthn_enabled?
@scheme_type = if user.webauthn_enabled? && user_params[:otp_attempt].blank?
'webauthn'
else
'totp'
end
render :two_factor
set_attempt_session(user)
@body_classes = 'lighter'
@webauthn_enabled = user.webauthn_enabled?
@scheme_type = begin
if user.webauthn_enabled? && user_params[:otp_attempt].blank?
'webauthn'
else
'totp'
end
end end
set_locale { render :two_factor }
end end
end end

+ 3
- 4
app/controllers/concerns/user_tracking_concern.rb View File

@ -6,14 +6,13 @@ module UserTrackingConcern
UPDATE_SIGN_IN_HOURS = 24 UPDATE_SIGN_IN_HOURS = 24
included do included do
before_action :set_user_activity
before_action :update_user_sign_in
end end
private private
def set_user_activity
return unless user_needs_sign_in_update?
current_user.update_tracked_fields!(request)
def update_user_sign_in
current_user.update_sign_in!(request) if user_needs_sign_in_update?
end end
def user_needs_sign_in_update? def user_needs_sign_in_update?

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

@ -9,7 +9,7 @@ class FiltersController < ApplicationController
before_action :set_body_classes before_action :set_body_classes
def index def index
@filters = current_account.custom_filters
@filters = current_account.custom_filters.order(:phrase)
end end
def new def new

+ 10
- 2
app/controllers/follower_accounts_controller.rb View File

@ -52,6 +52,14 @@ class FollowerAccountsController < ApplicationController
account_followers_url(@account, page: page) unless page.nil? account_followers_url(@account, page: page) unless page.nil?
end end
def next_page_url
page_url(follows.next_page) if follows.respond_to?(:next_page)
end
def prev_page_url
page_url(follows.prev_page) if follows.respond_to?(:prev_page)
end
def collection_presenter def collection_presenter
if page_requested? if page_requested?
ActivityPub::CollectionPresenter.new( ActivityPub::CollectionPresenter.new(
@ -60,8 +68,8 @@ class FollowerAccountsController < ApplicationController
size: @account.followers_count, size: @account.followers_count,
items: follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) }, items: follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) },
part_of: account_followers_url(@account), part_of: account_followers_url(@account),
next: page_url(follows.next_page),
prev: page_url(follows.prev_page)
next: next_page_url,
prev: prev_page_url
) )
else else
ActivityPub::CollectionPresenter.new( ActivityPub::CollectionPresenter.new(

+ 10
- 2
app/controllers/following_accounts_controller.rb View File

@ -52,6 +52,14 @@ class FollowingAccountsController < ApplicationController
account_following_index_url(@account, page: page) unless page.nil? account_following_index_url(@account, page: page) unless page.nil?
end end
def next_page_url
page_url(follows.next_page) if follows.respond_to?(:next_page)
end
def prev_page_url
page_url(follows.prev_page) if follows.respond_to?(:prev_page)
end
def collection_presenter def collection_presenter
if page_requested? if page_requested?
ActivityPub::CollectionPresenter.new( ActivityPub::CollectionPresenter.new(
@ -60,8 +68,8 @@ class FollowingAccountsController < ApplicationController
size: @account.following_count, size: @account.following_count,
items: follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) }, items: follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) },
part_of: account_following_index_url(@account), part_of: account_following_index_url(@account),
next: page_url(follows.next_page),
prev: page_url(follows.prev_page)
next: next_page_url,
prev: prev_page_url
) )
else else
ActivityPub::CollectionPresenter.new( ActivityPub::CollectionPresenter.new(

+ 8
- 1
app/controllers/relationships_controller.rb View File

@ -5,6 +5,7 @@ class RelationshipsController < ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
before_action :set_accounts, only: :show before_action :set_accounts, only: :show
before_action :set_relationships, only: :show
before_action :set_body_classes before_action :set_body_classes
helper_method :following_relationship?, :followed_by_relationship?, :mutual_relationship? helper_method :following_relationship?, :followed_by_relationship?, :mutual_relationship?
@ -28,6 +29,10 @@ class RelationshipsController < ApplicationController
@accounts = RelationshipFilter.new(current_account, filter_params).results.page(params[:page]).per(40) @accounts = RelationshipFilter.new(current_account, filter_params).results.page(params[:page]).per(40)
end end
def set_relationships
@relationships = AccountRelationshipsPresenter.new(@accounts.pluck(:id), current_user.account_id)
end
def form_account_batch_params def form_account_batch_params
params.require(:form_account_batch).permit(:action, account_ids: []) params.require(:form_account_batch).permit(:action, account_ids: [])
end end
@ -49,7 +54,9 @@ class RelationshipsController < ApplicationController
end end
def action_from_button def action_from_button
if params[:unfollow]
if params[:follow]
'follow'
elsif params[:unfollow]
'unfollow' 'unfollow'
elsif params[:remove_from_followers] elsif params[:remove_from_followers]
'remove_from_followers' 'remove_from_followers'

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

@ -42,7 +42,7 @@ class Settings::DeletesController < Settings::BaseController
end end
def destroy_account! def destroy_account!
current_account.suspend!
current_account.suspend!(origin: :local)
AccountDeletionWorker.perform_async(current_user.account_id) AccountDeletionWorker.perform_async(current_user.account_id)
sign_out sign_out
end end

+ 19
- 0
app/controllers/settings/exports/bookmarks_controller.rb View File

@ -0,0 +1,19 @@
# frozen_string_literal: true
module Settings
module Exports
class BookmarksController < BaseController
include ExportControllerConcern
def index
send_export_file
end
private
def export_data
@export.to_bookmarks_csv
end
end
end
end

+ 6
- 2
app/controllers/settings/pictures_controller.rb View File

@ -7,8 +7,12 @@ module Settings
def destroy def destroy
if valid_picture? if valid_picture?
msg = I18n.t('generic.changes_saved_msg') if UpdateAccountService.new.call(@account, { @picture => nil, "#{@picture}_remote_url" => '' })
redirect_to settings_profile_path, notice: msg, status: 303
if UpdateAccountService.new.call(@account, { @picture => nil, "#{@picture}_remote_url" => '' })
ActivityPub::UpdateDistributionWorker.perform_async(@account.id)
redirect_to settings_profile_path, notice: I18n.t('generic.changes_saved_msg'), status: 303
else
redirect_to settings_profile_path
end
else else
bad_request bad_request
end end

+ 1
- 0
app/controllers/settings/preferences_controller.rb View File

@ -43,6 +43,7 @@ class Settings::PreferencesController < Settings::BaseController
:setting_display_media, :setting_display_media,
:setting_expand_spoilers, :setting_expand_spoilers,
:setting_reduce_motion, :setting_reduce_motion,
:setting_disable_swiping,
:setting_system_font_ui, :setting_system_font_ui,
:setting_noindex, :setting_noindex,
:setting_theme, :setting_theme,

+ 1
- 1
app/controllers/well_known/webfinger_controller.rb View File

@ -35,7 +35,7 @@ module WellKnown
end end
def check_account_suspension def check_account_suspension
expires_in(3.minutes, public: true) && gone if @account.suspended?
expires_in(3.minutes, public: true) && gone if @account.suspended_permanently?
end end
def bad_request def bad_request

+ 4
- 0
app/helpers/admin/action_logs_helper.rb View File

@ -29,6 +29,8 @@ module Admin::ActionLogsHelper
link_to record.target_account.acct, admin_account_path(record.target_account_id) link_to record.target_account.acct, admin_account_path(record.target_account_id)
when 'Announcement' when 'Announcement'
link_to truncate(record.text), edit_admin_announcement_path(record.id) link_to truncate(record.text), edit_admin_announcement_path(record.id)
when 'IpBlock'
"#{record.ip}/#{record.ip.prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{record.severity}")})"
end end
end end
@ -48,6 +50,8 @@ module Admin::ActionLogsHelper
end end
when 'Announcement' when 'Announcement'
truncate(attributes['text'].is_a?(Array) ? attributes['text'].last : attributes['text']) truncate(attributes['text'].is_a?(Array) ? attributes['text'].last : attributes['text'])
when 'IpBlock'
"#{attributes['ip']}/#{attributes['ip'].prefix} (#{I18n.t("simple_form.labels.ip_block.severities.#{attributes['severity']}")})"
end end
end end
end end

+ 18
- 1
app/helpers/application_helper.rb View File

@ -7,6 +7,13 @@ module ApplicationHelper
follow follow
).freeze ).freeze
RTL_LOCALES = %i(
ar
fa
he
ku
).freeze
def active_nav_class(*paths) def active_nav_class(*paths)
paths.any? { |path| current_page?(path) } ? 'active' : '' paths.any? { |path| current_page?(path) } ? 'active' : ''
end end
@ -45,7 +52,7 @@ module ApplicationHelper
end end
def locale_direction def locale_direction
if [:ar, :fa, :he].include?(I18n.locale)
if RTL_LOCALES.include?(I18n.locale)
'rtl' 'rtl'
else else
'ltr' 'ltr'
@ -90,6 +97,16 @@ module ApplicationHelper
end end
end end
def interrelationships_icon(relationships, account_id)
if relationships.following[account_id] && relationships.followed_by[account_id]
fa_icon('exchange', title: I18n.t('relationships.mutual'), class: 'fa-fw active passive')
elsif relationships.following[account_id]
fa_icon(locale_direction == 'ltr' ? 'arrow-right' : 'arrow-left', title: I18n.t('relationships.following'), class: 'fa-fw active')
elsif relationships.followed_by[account_id]
fa_icon(locale_direction == 'ltr' ? 'arrow-left' : 'arrow-right', title: I18n.t('relationships.followers'), class: 'fa-fw passive')
end
end
def custom_emoji_tag(custom_emoji, animate = true) def custom_emoji_tag(custom_emoji, animate = true)
if animate if animate
image_tag(custom_emoji.image.url, class: 'emojione', alt: ":#{custom_emoji.shortcode}:") image_tag(custom_emoji.image.url, class: 'emojione', alt: ":#{custom_emoji.shortcode}:")

+ 4
- 0
app/helpers/settings_helper.rb View File

@ -40,6 +40,7 @@ module SettingsHelper
kk: 'Қазақша', kk: 'Қазақша',
kn: 'ಕನ್ನಡ', kn: 'ಕನ್ನಡ',
ko: '한국어', ko: '한국어',
ku: 'سۆرانی',
lt: 'Lietuvių', lt: 'Lietuvių',
lv: 'Latviešu', lv: 'Latviešu',
mk: 'Македонски', mk: 'Македонски',
@ -56,6 +57,8 @@ module SettingsHelper
pt: 'Português', pt: 'Português',
ro: 'Română', ro: 'Română',
ru: 'Русский', ru: 'Русский',
sa: 'संस्कृतम्',
sc: 'Sardu',
sk: 'Slovenčina', sk: 'Slovenčina',
sl: 'Slovenščina', sl: 'Slovenščina',
sq: 'Shqip', sq: 'Shqip',
@ -69,6 +72,7 @@ module SettingsHelper
uk: 'Українська', uk: 'Українська',
ur: 'اُردُو', ur: 'اُردُو',
vi: 'Tiếng Việt', vi: 'Tiếng Việt',
zgh: 'ⵜⴰⵎⴰⵣⵉⵖⵜ',
'zh-CN': '简体中文', 'zh-CN': '简体中文',
'zh-HK': '繁體中文(香港)', 'zh-HK': '繁體中文(香港)',
'zh-TW': '繁體中文(臺灣)', 'zh-TW': '繁體中文(臺灣)',

+ 14
- 22
app/helpers/statuses_helper.rb View File

@ -4,8 +4,12 @@ module StatusesHelper
EMBEDDED_CONTROLLER = 'statuses' EMBEDDED_CONTROLLER = 'statuses'
EMBEDDED_ACTION = 'embed' EMBEDDED_ACTION = 'embed'
def link_to_more(url)
link_to t('statuses.show_more'), url, class: 'load-more load-gap'
def link_to_newer(url)
link_to t('statuses.show_newer'), url, class: 'load-more load-gap'
end
def link_to_older(url)
link_to t('statuses.show_older'), url, class: 'load-more load-gap'
end end
def nothing_here(extra_classes = '') def nothing_here(extra_classes = '')
@ -88,22 +92,6 @@ module StatusesHelper
end end
end end
def rtl_status?(status)
status.local? ? rtl?(status.text) : rtl?(strip_tags(status.text))
end
def rtl?(text)
text = simplified_text(text)
rtl_words = text.scan(/[\p{Hebrew}\p{Arabic}\p{Syriac}\p{Thaana}\p{Nko}]+/m)
if rtl_words.present?
total_size = text.size.to_f
rtl_size(rtl_words) / total_size > 0.3
else
false
end
end
def fa_visibility_icon(status) def fa_visibility_icon(status)
case status.visibility case status.visibility
when 'public' when 'public'
@ -117,6 +105,14 @@ module StatusesHelper
end end
end end
def sensitized?(status, account)
if !account.nil? && account.id == status.account_id
status.sensitive
else
status.account.sensitized? || status.sensitive
end
end
private private
def simplified_text(text) def simplified_text(text)
@ -131,10 +127,6 @@ module StatusesHelper
end end
end end
def rtl_size(words)
words.reduce(0) { |acc, elem| acc + elem.size }.to_f
end
def embedded_view? def embedded_view?
params[:controller] == EMBEDDED_CONTROLLER && params[:action] == EMBEDDED_ACTION params[:controller] == EMBEDDED_CONTROLLER && params[:action] == EMBEDDED_ACTION
end end

+ 1
- 32
app/helpers/webfinger_helper.rb View File

@ -1,38 +1,7 @@
# frozen_string_literal: true # 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 module WebfingerHelper
def webfinger!(uri) def webfinger!(uri)
hidden_service_uri = /\.(onion|i2p)(:\d+)?$/.match(uri)
raise Mastodon::HostValidationError, 'Instance does not support hidden service connections' if !Rails.configuration.x.access_to_hidden_service && hidden_service_uri
opts = {
ssl: !hidden_service_uri,
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
Webfinger.new(uri).perform
end end
end end

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

@ -257,11 +257,11 @@ export function unblockAccountFail(error) {
}; };
export function muteAccount(id, notifications) {
export function muteAccount(id, notifications, duration=0) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(muteAccountRequest(id)); dispatch(muteAccountRequest(id));
api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications }).then(response => {
api(getState).post(`/api/v1/accounts/${id}/mute`, { notifications, duration }).then(response => {
// Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers // Pass in entire statuses map so we can use it to filter stuff in different parts of the reducers
dispatch(muteAccountSuccess(response.data, getState().get('statuses'))); dispatch(muteAccountSuccess(response.data, getState().get('statuses')));
}).catch(error => { }).catch(error => {

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

@ -8,3 +8,10 @@ export const focusApp = () => ({
export const unfocusApp = () => ({ export const unfocusApp = () => ({
type: APP_UNFOCUS, type: APP_UNFOCUS,
}); });
export const APP_LAYOUT_CHANGE = 'APP_LAYOUT_CHANGE';
export const changeLayout = layout => ({
type: APP_LAYOUT_CHANGE,
layout,
});

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

@ -152,9 +152,7 @@ export function submitCompose(routerHistory) {
'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']), 'Idempotency-Key': getState().getIn(['compose', 'idempotencyKey']),
}, },
}).then(function (response) { }).then(function (response) {
if (response.data.visibility === 'direct' && getState().getIn(['conversations', 'mounted']) <= 0 && routerHistory) {
routerHistory.push('/timelines/direct');
} else if (routerHistory && routerHistory.location.pathname === '/statuses/new' && window.history.state) {
if (routerHistory && routerHistory.location.pathname === '/statuses/new' && window.history.state) {
routerHistory.goBack(); routerHistory.goBack();
} }

+ 54
- 8
app/javascript/mastodon/actions/markers.js View File

@ -1,8 +1,10 @@
import api from '../api'; import api from '../api';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import compareId from '../compare_id'; import compareId from '../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 MARKERS_SUBMIT_SUCCESS = 'MARKERS_SUBMIT_SUCCESS';
export const synchronouslySubmitMarkers = () => (dispatch, getState) => { export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
@ -26,15 +28,19 @@ export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
}, },
body: JSON.stringify(params), body: JSON.stringify(params),
}); });
return; return;
} else if (navigator && navigator.sendBeacon) { } else if (navigator && navigator.sendBeacon) {
// Failing that, we can use sendBeacon, but we have to encode the data as // Failing that, we can use sendBeacon, but we have to encode the data as
// FormData for DoorKeeper to recognize the token. // FormData for DoorKeeper to recognize the token.
const formData = new FormData(); const formData = new FormData();
formData.append('bearer_token', accessToken); formData.append('bearer_token', accessToken);
for (const [id, value] of Object.entries(params)) { for (const [id, value] of Object.entries(params)) {
formData.append(`${id}[last_read_id]`, value.last_read_id); formData.append(`${id}[last_read_id]`, value.last_read_id);
} }
if (navigator.sendBeacon('/api/v1/markers', formData)) { if (navigator.sendBeacon('/api/v1/markers', formData)) {
return; return;
} }
@ -58,7 +64,7 @@ const _buildParams = (state) => {
const params = {}; const params = {};
const lastHomeId = state.getIn(['timelines', 'home', 'items']).find(item => item !== null); const lastHomeId = state.getIn(['timelines', 'home', 'items']).find(item => item !== null);
const lastNotificationId = state.getIn(['notifications', 'items', 0, 'id']);
const lastNotificationId = state.getIn(['notifications', 'lastReadId']);
if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) { if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) {
params.home = { params.home = {
@ -82,11 +88,9 @@ const debouncedSubmitMarkers = debounce((dispatch, getState) => {
return; return;
} }
api().post('/api/v1/markers', params).then(() => {
api(getState).post('/api/v1/markers', params).then(() => {
dispatch(submitMarkersSuccess(params)); dispatch(submitMarkersSuccess(params));
}).catch(error => {
dispatch(showAlertForError(error));
});
}).catch(() => {});
}, 300000, { leading: true, trailing: true }); }, 300000, { leading: true, trailing: true });
export function submitMarkersSuccess({ home, notifications }) { export function submitMarkersSuccess({ home, notifications }) {
@ -97,6 +101,48 @@ export function submitMarkersSuccess({ home, notifications }) {
}; };
}; };
export function submitMarkers() {
return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
export function submitMarkers(params = {}) {
const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
if (params.immediate === true) {
debouncedSubmitMarkers.flush();
}
return result;
};
export const fetchMarkers = () => (dispatch, getState) => {
const params = { timeline: ['notifications'] };
dispatch(fetchMarkersRequest());
api(getState).get('/api/v1/markers', { params }).then(response => {
dispatch(fetchMarkersSuccess(response.data));
}).catch(error => {
dispatch(fetchMarkersFail(error));
});
};
export function fetchMarkersRequest() {
return {
type: MARKERS_FETCH_REQUEST,
skipLoading: true,
};
};
export function fetchMarkersSuccess(markers) {
return {
type: MARKERS_FETCH_SUCCESS,
markers,
skipLoading: true,
};
};
export function fetchMarkersFail(error) {
return {
type: MARKERS_FETCH_FAIL,
error,
skipLoading: true,
skipAlert: true,
};
}; };

+ 10
- 0
app/javascript/mastodon/actions/mutes.js View File

@ -13,6 +13,7 @@ export const MUTES_EXPAND_FAIL = 'MUTES_EXPAND_FAIL';
export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL'; export const MUTES_INIT_MODAL = 'MUTES_INIT_MODAL';
export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS'; export const MUTES_TOGGLE_HIDE_NOTIFICATIONS = 'MUTES_TOGGLE_HIDE_NOTIFICATIONS';
export const MUTES_CHANGE_DURATION = 'MUTES_CHANGE_DURATION';
export function fetchMutes() { export function fetchMutes() {
return (dispatch, getState) => { return (dispatch, getState) => {
@ -104,3 +105,12 @@ export function toggleHideNotifications() {
dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS }); dispatch({ type: MUTES_TOGGLE_HIDE_NOTIFICATIONS });
}; };
} }
export function changeMuteDuration(duration) {
return dispatch => {
dispatch({
type: MUTES_CHANGE_DURATION,
duration,
});
};
}

+ 51
- 0
app/javascript/mastodon/actions/notifications.js View File

@ -16,6 +16,7 @@ import { getFiltersRegex } from '../selectors';
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
import compareId from 'mastodon/compare_id'; import compareId from 'mastodon/compare_id';
import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer'; import { searchTextFromRawStatus } from 'mastodon/actions/importer/normalizer';
import { requestNotificationPermission } from '../utils/notifications';
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
@ -33,6 +34,12 @@ export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT'; export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT';
export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT'; export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
export const NOTIFICATIONS_SET_BROWSER_SUPPORT = 'NOTIFICATIONS_SET_BROWSER_SUPPORT';
export const NOTIFICATIONS_SET_BROWSER_PERMISSION = 'NOTIFICATIONS_SET_BROWSER_PERMISSION';
defineMessages({ defineMessages({
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
group: { id: 'notifications.group', defaultMessage: '{count} notifications' }, group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
@ -232,3 +239,47 @@ export const mountNotifications = () => ({
export const unmountNotifications = () => ({ export const unmountNotifications = () => ({
type: NOTIFICATIONS_UNMOUNT, type: NOTIFICATIONS_UNMOUNT,
}); });
export const markNotificationsAsRead = () => ({
type: NOTIFICATIONS_MARK_AS_READ,
});
// Browser support
export function setupBrowserNotifications() {
return dispatch => {
dispatch(setBrowserSupport('Notification' in window));
if ('Notification' in window) {
dispatch(setBrowserPermission(Notification.permission));
}
if ('Notification' in window && 'permissions' in navigator) {
navigator.permissions.query({ name: 'notifications' }).then((status) => {
status.onchange = () => dispatch(setBrowserPermission(Notification.permission));
}).catch(console.warn);
}
};
}
export function requestBrowserPermission(callback = noOp) {
return dispatch => {
requestNotificationPermission((permission) => {
dispatch(setBrowserPermission(permission));
callback(permission);
});
};
};
export function setBrowserSupport (value) {
return {
type: NOTIFICATIONS_SET_BROWSER_SUPPORT,
value,
};
}
export function setBrowserPermission (value) {
return {
type: NOTIFICATIONS_SET_BROWSER_PERMISSION,
value,
};
}

+ 13
- 0
app/javascript/mastodon/actions/onboarding.js View File

@ -1,8 +1,21 @@
import { changeSetting, saveSettings } from './settings'; import { changeSetting, saveSettings } from './settings';
import { requestBrowserPermission } from './notifications';
export const INTRODUCTION_VERSION = 20181216044202; export const INTRODUCTION_VERSION = 20181216044202;
export const closeOnboarding = () => dispatch => { export const closeOnboarding = () => dispatch => {
dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION)); dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION));
dispatch(saveSettings()); dispatch(saveSettings());
dispatch(requestBrowserPermission((permission) => {
if (permission === 'granted') {
dispatch(changeSetting(['notifications', 'alerts', 'follow'], true));
dispatch(changeSetting(['notifications', 'alerts', 'favourite'], true));
dispatch(changeSetting(['notifications', 'alerts', 'reblog'], true));
dispatch(changeSetting(['notifications', 'alerts', 'mention'], true));
dispatch(changeSetting(['notifications', 'alerts', 'poll'], true));
dispatch(changeSetting(['notifications', 'alerts', 'status'], true));
dispatch(saveSettings());
}
}));
}; };

+ 38
- 0
app/javascript/mastodon/actions/picture_in_picture.js View File

@ -0,0 +1,38 @@
// @ts-check
export const PICTURE_IN_PICTURE_DEPLOY = 'PICTURE_IN_PICTURE_DEPLOY';
export const PICTURE_IN_PICTURE_REMOVE = 'PICTURE_IN_PICTURE_REMOVE';
/**
* @typedef MediaProps
* @property {string} src
* @property {boolean} muted
* @property {number} volume
* @property {number} currentTime
* @property {string} poster
* @property {string} backgroundColor
* @property {string} foregroundColor
* @property {string} accentColor
*/
/**
* @param {string} statusId
* @param {string} accountId
* @param {string} playerType
* @param {MediaProps} props
* @return {object}
*/
export const deployPictureInPicture = (statusId, accountId, playerType, props) => ({
type: PICTURE_IN_PICTURE_DEPLOY,
statusId,
accountId,
playerType,
props,
});
/*
* @return {object}
*/
export const removePictureInPicture = () => ({
type: PICTURE_IN_PICTURE_REMOVE,
});

+ 112
- 0
app/javascript/mastodon/blurhash.js View File

@ -0,0 +1,112 @@
const DIGIT_CHARACTERS = [
'0',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'A',
'B',
'C',
'D',
'E',
'F',
'G',
'H',
'I',
'J',
'K',
'L',
'M',
'N',
'O',
'P',
'Q',
'R',
'S',
'T',
'U',
'V',
'W',
'X',
'Y',
'Z',
'a',
'b',
'c',
'd',
'e',
'f',
'g',
'h',
'i',
'j',
'k',
'l',
'm',
'n',
'o',
'p',
'q',
'r',
's',
't',
'u',
'v',
'w',
'x',
'y',
'z',
'#',
'$',
'%',
'*',
'+',
',',
'-',
'.',
':',
';',
'=',
'?',
'@',
'[',
']',
'^',
'_',
'{',
'|',
'}',
'~',
];
export const decode83 = (str) => {
let value = 0;
let c, digit;
for (let i = 0; i < str.length; i++) {
c = str[i];
digit = DIGIT_CHARACTERS.indexOf(c);
value = value * 83 + digit;
}
return value;
};
export const intToRGB = int => ({
r: Math.max(0, (int >> 16)),
g: Math.max(0, (int >> 8) & 255),
b: Math.max(0, (int & 255)),
});
export const getAverageFromBlurhash = blurhash => {
if (!blurhash) {
return null;
}
return intToRGB(decode83(blurhash.slice(2, 6)));
};

+ 0
- 49
app/javascript/mastodon/components/__tests__/__snapshots__/button-test.js.snap View File

@ -4,13 +4,6 @@ exports[`
<button <button
className="button button-secondary" className="button button-secondary"
onClick={[Function]} onClick={[Function]}
style={
Object {
"height": "36px",
"lineHeight": "36px",
"padding": "0 16px",
}
}
/> />
`; `;
@ -18,13 +11,6 @@ exports[`
<button <button
className="button" className="button"
onClick={[Function]} onClick={[Function]}
style={
Object {
"height": "36px",
"lineHeight": "36px",
"padding": "0 16px",
}
}
/> />
`; `;
@ -33,13 +19,6 @@ exports[`
className="button" className="button"
disabled={true} disabled={true}
onClick={[Function]} onClick={[Function]}
style={
Object {
"height": "36px",
"lineHeight": "36px",
"padding": "0 16px",
}
}
/> />
`; `;
@ -47,13 +26,6 @@ exports[`
<button <button
className="button button--block" className="button button--block"
onClick={[Function]} onClick={[Function]}
style={
Object {
"height": "36px",
"lineHeight": "36px",
"padding": "0 16px",
}
}
/> />
`; `;
@ -61,13 +33,6 @@ exports[`
<button <button
className="button" className="button"
onClick={[Function]} onClick={[Function]}
style={
Object {
"height": "36px",
"lineHeight": "36px",
"padding": "0 16px",
}
}
> >
<p> <p>
children children
@ -79,13 +44,6 @@ exports[`
<button <button
className="button" className="button"
onClick={[Function]} onClick={[Function]}
style={
Object {
"height": "36px",
"lineHeight": "36px",
"padding": "0 16px",
}
}
> >
foo foo
</button> </button>
@ -95,13 +53,6 @@ exports[`
<button <button
className="button" className="button"
onClick={[Function]} onClick={[Function]}
style={
Object {
"height": "36px",
"lineHeight": "36px",
"padding": "0 16px",
}
}
> >
foo foo
</button> </button>

+ 7
- 0
app/javascript/mastodon/components/account.js View File

@ -8,6 +8,7 @@ import IconButton from './icon_button';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { me } from '../initial_state'; import { me } from '../initial_state';
import RelativeTimestamp from './relative_timestamp';
const messages = defineMessages({ const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' },
@ -107,11 +108,17 @@ class Account extends ImmutablePureComponent {
} }
} }
let mute_expires_at;
if (account.get('mute_expires_at')) {
mute_expires_at = <div><RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></div>;
}
return ( return (
<div className='account'> <div className='account'>
<div className='account__wrapper'> <div className='account__wrapper'>
<Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}> <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}>
<div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div> <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
{mute_expires_at}
<DisplayName account={account} /> <DisplayName account={account} />
</Permalink> </Permalink>

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

@ -5,10 +5,21 @@ import TransitionMotion from 'react-motion/lib/TransitionMotion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import { reduceMotion } from 'mastodon/initial_state'; import { reduceMotion } from 'mastodon/initial_state';
const obfuscatedCount = count => {
if (count < 0) {
return 0;
} else if (count <= 1) {
return count;
} else {
return '1+';
}
};
export default class AnimatedNumber extends React.PureComponent { export default class AnimatedNumber extends React.PureComponent {
static propTypes = { static propTypes = {
value: PropTypes.number.isRequired, value: PropTypes.number.isRequired,
obfuscate: PropTypes.bool,
}; };
state = { state = {
@ -36,11 +47,11 @@ export default class AnimatedNumber extends React.PureComponent {
} }
render () { render () {
const { value } = this.props;
const { value, obfuscate } = this.props;
const { direction } = this.state; const { direction } = this.state;
if (reduceMotion) { if (reduceMotion) {
return <FormattedNumber value={value} />;
return obfuscate ? obfuscatedCount(value) : <FormattedNumber value={value} />;
} }
const styles = [{ const styles = [{
@ -54,7 +65,7 @@ export default class AnimatedNumber extends React.PureComponent {
{items => ( {items => (
<span className='animated-number'> <span className='animated-number'>
{items.map(({ key, data, style }) => ( {items.map(({ key, data, style }) => (
<span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}><FormattedNumber value={data} /></span>
<span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <FormattedNumber value={data} />}</span>
))} ))}
</span> </span>
)} )}

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

@ -1,8 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light'; import unicodeMapping from '../features/emoji/emoji_unicode_mapping_light';
const assetHost = process.env.CDN_HOST || '';
import { assetHost } from 'mastodon/utils/config';
export default class AutosuggestEmoji extends React.PureComponent { export default class AutosuggestEmoji extends React.PureComponent {

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

@ -4,7 +4,6 @@ import AutosuggestEmoji from './autosuggest_emoji';
import AutosuggestHashtag from './autosuggest_hashtag'; import AutosuggestHashtag from './autosuggest_hashtag';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { isRtl } from '../rtl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import classNames from 'classnames'; import classNames from 'classnames';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
@ -189,11 +188,6 @@ export default class AutosuggestInput extends ImmutablePureComponent {
render () { render () {
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength } = this.props; const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, className, id, maxLength } = this.props;
const { suggestionsHidden } = this.state; const { suggestionsHidden } = this.state;
const style = { direction: 'ltr' };
if (isRtl(value)) {
style.direction = 'rtl';
}
return ( return (
<div className='autosuggest-input'> <div className='autosuggest-input'>
@ -212,7 +206,7 @@ export default class AutosuggestInput extends ImmutablePureComponent {
onKeyUp={onKeyUp} onKeyUp={onKeyUp}
onFocus={this.onFocus} onFocus={this.onFocus}
onBlur={this.onBlur} onBlur={this.onBlur}
style={style}
dir='auto'
aria-autocomplete='list' aria-autocomplete='list'
id={id} id={id}
className={className} className={className}

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

@ -4,7 +4,6 @@ import AutosuggestEmoji from './autosuggest_emoji';
import AutosuggestHashtag from './autosuggest_hashtag'; import AutosuggestHashtag from './autosuggest_hashtag';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { isRtl } from '../rtl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize'; import Textarea from 'react-textarea-autosize';
import classNames from 'classnames'; import classNames from 'classnames';
@ -195,11 +194,6 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
render () { render () {
const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children } = this.props; const { value, suggestions, disabled, placeholder, onKeyUp, autoFocus, children } = this.props;
const { suggestionsHidden } = this.state; const { suggestionsHidden } = this.state;
const style = { direction: 'ltr' };
if (isRtl(value)) {
style.direction = 'rtl';
}
return [ return [
<div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'> <div className='compose-form__autosuggest-wrapper' key='autosuggest-wrapper'>
@ -220,7 +214,7 @@ export default class AutosuggestTextarea extends ImmutablePureComponent {
onFocus={this.onFocus} onFocus={this.onFocus}
onBlur={this.onBlur} onBlur={this.onBlur}
onPaste={this.onPaste} onPaste={this.onPaste}
style={style}
dir='auto'
aria-autocomplete='list' aria-autocomplete='list'
/> />
</label> </label>

+ 0
- 14
app/javascript/mastodon/components/button.js View File

@ -10,17 +10,11 @@ export default class Button extends React.PureComponent {
disabled: PropTypes.bool, disabled: PropTypes.bool,
block: PropTypes.bool, block: PropTypes.bool,
secondary: PropTypes.bool, secondary: PropTypes.bool,
size: PropTypes.number,
className: PropTypes.string, className: PropTypes.string,
title: PropTypes.string, title: PropTypes.string,
style: PropTypes.object,
children: PropTypes.node, children: PropTypes.node,
}; };
static defaultProps = {
size: 36,
};
handleClick = (e) => { handleClick = (e) => {
if (!this.props.disabled) { if (!this.props.disabled) {
this.props.onClick(e); this.props.onClick(e);
@ -36,13 +30,6 @@ export default class Button extends React.PureComponent {
} }
render () { render () {
const style = {
padding: `0 ${this.props.size / 2.25}px`,
height: `${this.props.size}px`,
lineHeight: `${this.props.size}px`,
...this.props.style,
};
const className = classNames('button', this.props.className, { const className = classNames('button', this.props.className, {
'button-secondary': this.props.secondary, 'button-secondary': this.props.secondary,
'button--block': this.props.block, 'button--block': this.props.block,
@ -54,7 +41,6 @@ export default class Button extends React.PureComponent {
disabled={this.props.disabled} disabled={this.props.disabled}
onClick={this.handleClick} onClick={this.handleClick}
ref={this.setRef} ref={this.setRef}
style={style}
title={this.props.title} title={this.props.title}
> >
{this.props.text || this.props.children} {this.props.text || this.props.children}

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

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import detectPassiveEvents from 'detect-passive-events';
import { supportsPassiveEvents } from 'detect-passive-events';
import { scrollTop } from '../scroll'; import { scrollTop } from '../scroll';
export default class Column extends React.PureComponent { export default class Column extends React.PureComponent {
@ -35,9 +35,9 @@ export default class Column extends React.PureComponent {
componentDidMount () { componentDidMount () {
if (this.props.bindToDocument) { if (this.props.bindToDocument) {
document.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false);
document.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
} else { } else {
this.node.addEventListener('wheel', this.handleWheel, detectPassiveEvents.hasSupport ? { passive: true } : false);
this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
} }
} }

+ 16
- 2
app/javascript/mastodon/components/column_header.js View File

@ -34,6 +34,7 @@ class ColumnHeader extends React.PureComponent {
onMove: PropTypes.func, onMove: PropTypes.func,
onClick: PropTypes.func, onClick: PropTypes.func,
appendContent: PropTypes.node, appendContent: PropTypes.node,
collapseIssues: PropTypes.bool,
}; };
state = { state = {
@ -83,7 +84,7 @@ class ColumnHeader extends React.PureComponent {
} }
render () { render () {
const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent } = this.props;
const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props;
const { collapsed, animating } = this.state; const { collapsed, animating } = this.state;
const wrapperClassName = classNames('column-header__wrapper', { const wrapperClassName = classNames('column-header__wrapper', {
@ -145,7 +146,20 @@ class ColumnHeader extends React.PureComponent {
} }
if (children || (multiColumn && this.props.onPin)) { if (children || (multiColumn && this.props.onPin)) {
collapseButton = <button className={collapsibleButtonClassName} title={formatMessage(collapsed ? messages.show : messages.hide)} aria-label={formatMessage(collapsed ? messages.show : messages.hide)} aria-pressed={collapsed ? 'false' : 'true'} onClick={this.handleToggleClick}><Icon id='sliders' /></button>;
collapseButton = (
<button
className={collapsibleButtonClassName}
title={formatMessage(collapsed ? messages.show : messages.hide)}
aria-label={formatMessage(collapsed ? messages.show : messages.hide)}
aria-pressed={collapsed ? 'false' : 'true'}
onClick={this.handleToggleClick}
>
<i className='icon-with-badge'>
<Icon id='sliders' />
{collapseIssues && <i className='icon-with-badge__issue-badge' />}
</i>
</button>
);
} }
const hasTitle = icon && title; const hasTitle = icon && title;

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

@ -5,9 +5,9 @@ import IconButton from './icon_button';
import Overlay from 'react-overlays/lib/Overlay'; import Overlay from 'react-overlays/lib/Overlay';
import Motion from '../features/ui/util/optional_motion'; import Motion from '../features/ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import detectPassiveEvents from 'detect-passive-events';
import { supportsPassiveEvents } from 'detect-passive-events';
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
let id = 0; let id = 0;
class DropdownMenu extends React.PureComponent { class DropdownMenu extends React.PureComponent {

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

@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import AnimatedNumber from 'mastodon/components/animated_number';
export default class IconButton extends React.PureComponent { export default class IconButton extends React.PureComponent {
@ -24,6 +25,8 @@ export default class IconButton extends React.PureComponent {
animate: PropTypes.bool, animate: PropTypes.bool,
overlay: PropTypes.bool, overlay: PropTypes.bool,
tabIndex: PropTypes.string, tabIndex: PropTypes.string,
counter: PropTypes.number,
obfuscateCount: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
@ -97,6 +100,8 @@ export default class IconButton extends React.PureComponent {
pressed, pressed,
tabIndex, tabIndex,
title, title,
counter,
obfuscateCount,
} = this.props; } = this.props;
const { const {
@ -111,8 +116,13 @@ export default class IconButton extends React.PureComponent {
activate, activate,
deactivate, deactivate,
overlayed: overlay, overlayed: overlay,
'icon-button--with-counter': typeof counter !== 'undefined',
}); });
if (typeof counter !== 'undefined') {
style.width = 'auto';
}
return ( return (
<button <button
aria-label={title} aria-label={title}
@ -128,7 +138,7 @@ export default class IconButton extends React.PureComponent {
tabIndex={tabIndex} tabIndex={tabIndex}
disabled={disabled} disabled={disabled}
> >
<Icon id={icon} fixedWidth aria-hidden='true' />
<Icon id={icon} fixedWidth aria-hidden='true' /> {typeof counter !== 'undefined' && <span className='icon-button__counter'><AnimatedNumber value={counter} obfuscate={obfuscateCount} /></span>}
</button> </button>
); );
} }

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

@ -4,16 +4,18 @@ import Icon from 'mastodon/components/icon';
const formatNumber = num => num > 40 ? '40+' : num; const formatNumber = num => num > 40 ? '40+' : num;
const IconWithBadge = ({ id, count, className }) => (
const IconWithBadge = ({ id, count, issueBadge, className }) => (
<i className='icon-with-badge'> <i className='icon-with-badge'>
<Icon id={id} fixedWidth className={className} /> <Icon id={id} fixedWidth className={className} />
{count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>} {count > 0 && <i className='icon-with-badge__badge'>{formatNumber(count)}</i>}
{issueBadge && <i className='icon-with-badge__issue-badge' />}
</i> </i>
); );
IconWithBadge.propTypes = { IconWithBadge.propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
count: PropTypes.number.isRequired, count: PropTypes.number.isRequired,
issueBadge: PropTypes.bool,
className: PropTypes.string, className: PropTypes.string,
}; };

+ 6
- 6
app/javascript/mastodon/components/intersection_observer_article.js View File

@ -2,10 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
import getRectFromEntry from '../features/ui/util/get_rect_from_entry'; import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
import { is } from 'immutable';
// Diff these props in the "rendered" state
const updateOnPropsForRendered = ['id', 'index', 'listLength'];
// Diff these props in the "unrendered" state // Diff these props in the "unrendered" state
const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight']; const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
@ -33,9 +30,12 @@ export default class IntersectionObserverArticle extends React.Component {
// If we're going from rendered to unrendered (or vice versa) then update // If we're going from rendered to unrendered (or vice versa) then update
return true; return true;
} }
// Otherwise, diff based on props
const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered;
return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop]));
// If we are and remain hidden, diff based on props
if (isUnrendered) {
return !updateOnPropsForUnrendered.every(prop => nextProps[prop] === this.props[prop]);
}
// Else, assume the children have changed
return true;
} }
componentDidMount () { componentDidMount () {

+ 16
- 18
app/javascript/mastodon/components/modal_root.js View File

@ -1,19 +1,21 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import 'wicg-inert'; import 'wicg-inert';
import { multiply } from 'color-blend';
export default class ModalRoot extends React.PureComponent { export default class ModalRoot extends React.PureComponent {
static propTypes = { static propTypes = {
children: PropTypes.node, children: PropTypes.node,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
backgroundColor: PropTypes.shape({
r: PropTypes.number,
g: PropTypes.number,
b: PropTypes.number,
}),
}; };
state = {
revealed: !!this.props.children,
};
activeElement = this.state.revealed ? document.activeElement : null;
activeElement = this.props.children ? document.activeElement : null;
handleKeyUp = (e) => { handleKeyUp = (e) => {
if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27) if ((e.key === 'Escape' || e.key === 'Esc' || e.keyCode === 27)
@ -53,8 +55,6 @@ export default class ModalRoot extends React.PureComponent {
this.activeElement = document.activeElement; this.activeElement = document.activeElement;
this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true)); this.getSiblings().forEach(sibling => sibling.setAttribute('inert', true));
} else if (!nextProps.children) {
this.setState({ revealed: false });
} }
} }
@ -68,14 +68,7 @@ export default class ModalRoot extends React.PureComponent {
Promise.resolve().then(() => { Promise.resolve().then(() => {
this.activeElement.focus({ preventScroll: true }); this.activeElement.focus({ preventScroll: true });
this.activeElement = null; this.activeElement = null;
}).catch((error) => {
console.error(error);
});
}
if (this.props.children) {
requestAnimationFrame(() => {
this.setState({ revealed: true });
});
}).catch(console.error);
} }
} }
@ -94,7 +87,6 @@ export default class ModalRoot extends React.PureComponent {
render () { render () {
const { children, onClose } = this.props; const { children, onClose } = this.props;
const { revealed } = this.state;
const visible = !!children; const visible = !!children;
if (!visible) { if (!visible) {
@ -103,10 +95,16 @@ export default class ModalRoot extends React.PureComponent {
); );
} }
let backgroundColor = null;
if (this.props.backgroundColor) {
backgroundColor = multiply({ ...this.props.backgroundColor, a: 1 }, { r: 0, g: 0, b: 0, a: 0.7 });
}
return ( return (
<div className='modal-root' ref={this.setRef} style={{ opacity: revealed ? 1 : 0 }}>
<div className='modal-root' ref={this.setRef}>
<div style={{ pointerEvents: visible ? 'auto' : 'none' }}> <div style={{ pointerEvents: visible ? 'auto' : 'none' }}>
<div role='presentation' className='modal-root__overlay' onClick={onClose} />
<div role='presentation' className='modal-root__overlay' onClick={onClose} style={{ backgroundColor: backgroundColor ? `rgba(${backgroundColor.r}, ${backgroundColor.g}, ${backgroundColor.b}, 0.7)` : null }} />
<div role='dialog' className='modal-root__container'>{children}</div> <div role='dialog' className='modal-root__container'>{children}</div>
</div> </div>
</div> </div>

+ 69
- 0
app/javascript/mastodon/components/picture_in_picture_placeholder.js View File

@ -0,0 +1,69 @@
import React from 'react';
import PropTypes from 'prop-types';
import Icon from 'mastodon/components/icon';
import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { FormattedMessage } from 'react-intl';
export default @connect()
class PictureInPicturePlaceholder extends React.PureComponent {
static propTypes = {
width: PropTypes.number,
dispatch: PropTypes.func.isRequired,
};
state = {
width: this.props.width,
height: this.props.width && (this.props.width / (16/9)),
};
handleClick = () => {
const { dispatch } = this.props;
dispatch(removePictureInPicture());
}
setRef = c => {
this.node = c;
if (this.node) {
this._setDimensions();
}
}
_setDimensions () {
const width = this.node.offsetWidth;
const height = width / (16/9);
this.setState({ width, height });
}
componentDidMount () {
window.addEventListener('resize', this.handleResize, { passive: true });
}
componentWillUnmount () {
window.removeEventListener('resize', this.handleResize);
}
handleResize = debounce(() => {
if (this.node) {
this._setDimensions();
}
}, 250, {
trailing: true,
});
render () {
const { height } = this.state;
return (
<div ref={this.setRef} className='picture-in-picture-placeholder' style={{ height }} role='button' tabIndex='0' onClick={this.handleClick}>
<Icon id='window-restore' />
<FormattedMessage id='picture_in_picture.restore' defaultMessage='Put it back' />
</div>
);
}
}

+ 40
- 15
app/javascript/mastodon/components/status.js View File

@ -18,6 +18,7 @@ import { HotKeys } from 'react-hotkeys';
import classNames from 'classnames'; import classNames from 'classnames';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import { displayMedia } from '../initial_state'; import { displayMedia } from '../initial_state';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
import StatusContainer from '../containers/status_container2'; import StatusContainer from '../containers/status_container2';
@ -98,6 +99,11 @@ class Status extends ImmutablePureComponent {
cacheMediaWidth: PropTypes.func, cacheMediaWidth: PropTypes.func,
cachedMediaWidth: PropTypes.number, cachedMediaWidth: PropTypes.number,
scrollKey: PropTypes.string, scrollKey: PropTypes.string,
deployPictureInPicture: PropTypes.func,
pictureInPicture: ImmutablePropTypes.contains({
inUse: PropTypes.bool,
available: PropTypes.bool,
}),
sonsIds: ImmutablePropTypes.list, sonsIds: ImmutablePropTypes.list,
}; };
@ -108,6 +114,8 @@ class Status extends ImmutablePureComponent {
'account', 'account',
'muted', 'muted',
'hidden', 'hidden',
'unread',
'pictureInPicture',
'sonsIds', 'sonsIds',
'ancestorsText', 'ancestorsText',
]; ];
@ -190,8 +198,13 @@ class Status extends ImmutablePureComponent {
return <div className='audio-player' style={{ height: '110px' }} />; return <div className='audio-player' style={{ height: '110px' }} />;
} }
handleOpenVideo = (media, options) => {
this.props.onOpenVideo(media, options);
handleOpenVideo = (options) => {
const status = this._properStatus();
this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), options);
}
handleOpenMedia = (media, index) => {
this.props.onOpenMedia(this._properStatus().get('id'), media, index);
} }
handleHotkeyOpenMedia = e => { handleHotkeyOpenMedia = e => {
@ -201,16 +214,21 @@ class Status extends ImmutablePureComponent {
e.preventDefault(); e.preventDefault();
if (status.get('media_attachments').size > 0) { if (status.get('media_attachments').size > 0) {
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
// TODO: toggle play/paused?
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
onOpenVideo(status.getIn(['media_attachments', 0]), { startTime: 0 });
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), { startTime: 0 });
} else { } else {
onOpenMedia(status.get('media_attachments'), 0);
onOpenMedia(status.get('id'), status.get('media_attachments'), 0);
} }
} }
} }
handleDeployPictureInPicture = (type, mediaProps) => {
const { deployPictureInPicture } = this.props;
const status = this._properStatus();
deployPictureInPicture(status, type, mediaProps);
}
handleHotkeyReply = e => { handleHotkeyReply = e => {
e.preventDefault(); e.preventDefault();
this.props.onReply(this._properStatus(), this.context.router.history); this.props.onReply(this._properStatus(), this.context.router.history);
@ -300,7 +318,7 @@ class Status extends ImmutablePureComponent {
let statusAvatar, prepend, rebloggedByText; let statusAvatar, prepend, rebloggedByText;
let sons, quote; let sons, quote;
const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, deep, tree_type, ancestorsText, sonsIds } = this.props;
const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, pictureInPicture, deep, tree_type, ancestorsText, sonsIds } = this.props;
let { status, account, ...other } = this.props; let { status, account, ...other } = this.props;
@ -371,7 +389,9 @@ class Status extends ImmutablePureComponent {
status = status.get('reblog'); status = status.get('reblog');
} }
if (status.get('media_attachments').size > 0) {
if (pictureInPicture.get('inUse')) {
media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />;
} else if (status.get('media_attachments').size > 0) {
if (this.props.muted) { if (this.props.muted) {
media = ( media = (
<AttachmentList <AttachmentList
@ -396,6 +416,7 @@ class Status extends ImmutablePureComponent {
width={this.props.cachedMediaWidth} width={this.props.cachedMediaWidth}
height={110} height={110}
cacheWidth={this.props.cacheMediaWidth} cacheWidth={this.props.cacheMediaWidth}
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
/> />
)} )}
</Bundle> </Bundle>
@ -408,6 +429,7 @@ class Status extends ImmutablePureComponent {
{Component => ( {Component => (
<Component <Component
preview={attachment.get('preview_url')} preview={attachment.get('preview_url')}
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
blurhash={attachment.get('blurhash')} blurhash={attachment.get('blurhash')}
src={attachment.get('url')} src={attachment.get('url')}
alt={attachment.get('description')} alt={attachment.get('description')}
@ -417,6 +439,7 @@ class Status extends ImmutablePureComponent {
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}
onOpenVideo={this.handleOpenVideo} onOpenVideo={this.handleOpenVideo}
cacheWidth={this.props.cacheMediaWidth} cacheWidth={this.props.cacheMediaWidth}
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
visible={this.state.showMedia} visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility} onToggleVisibility={this.handleToggleMediaVisibility}
/> />
@ -431,7 +454,7 @@ class Status extends ImmutablePureComponent {
media={status.get('media_attachments')} media={status.get('media_attachments')}
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}
height={110} height={110}
onOpenMedia={this.props.onOpenMedia}
onOpenMedia={this.handleOpenMedia}
cacheWidth={this.props.cacheMediaWidth} cacheWidth={this.props.cacheMediaWidth}
defaultWidth={this.props.cachedMediaWidth} defaultWidth={this.props.cachedMediaWidth}
visible={this.state.showMedia} visible={this.state.showMedia}
@ -444,7 +467,7 @@ class Status extends ImmutablePureComponent {
} else if (status.get('spoiler_text').length === 0 && status.get('card')) { } else if (status.get('spoiler_text').length === 0 && status.get('card')) {
media = ( media = (
<Card <Card
onOpenMedia={this.props.onOpenMedia}
onOpenMedia={this.handleOpenMedia}
card={status.get('card')} card={status.get('card')}
compact compact
cacheWidth={this.props.cacheMediaWidth} cacheWidth={this.props.cacheMediaWidth}
@ -508,14 +531,16 @@ class Status extends ImmutablePureComponent {
return ( return (
<HotKeys handlers={handlers}> <HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted }, (deep!=null) && 'tree-'+tree_type)} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted }, (deep!=null) && 'tree-'+tree_type)} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
{prepend} {prepend}
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, read: unread === false })} data-id={status.get('id')}>
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted })} data-id={status.get('id')}>
<div className='status__expand' onClick={this.handleExpandClick} role='presentation' /> <div className='status__expand' onClick={this.handleExpandClick} role='presentation' />
<div className='status__info'> <div className='status__info'>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'><RelativeTimestamp timestamp={status.get('created_at')} /></a>
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'>
<span className='status__visibility-icon'><Icon id={visibilityIcon.icon} title={visibilityIcon.text} /></span>
<RelativeTimestamp timestamp={status.get('created_at')} />
</a>
<a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'> <a onClick={this.handleAccountClick} data-id={status.getIn(['account', 'id'])} href={status.getIn(['account', 'url'])} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
<div className='status__avatar'> <div className='status__avatar'>

+ 4
- 13
app/javascript/mastodon/components/status_action_bar.js View File

@ -44,16 +44,6 @@ const messages = defineMessages({
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
}); });
const obfuscatedCount = count => {
if (count < 0) {
return 0;
} else if (count <= 10) {
return count;
} else {
return '10+';
}
};
const mapStateToProps = (state, { status }) => ({ const mapStateToProps = (state, { status }) => ({
relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]), relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
}); });
@ -330,9 +320,10 @@ class StatusActionBar extends ImmutablePureComponent {
return ( return (
<div className='status__action-bar'> <div className='status__action-bar'>
<div className='status__action-bar__counter'><IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'comment-o' : replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div>
<div className='status__action-bar__counter'><IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />{publicStatus && <span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('reblogs_count'))}</span>}</div>
<div className='status__action-bar__counter'><IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='heart' onClick={this.handleFavouriteClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('favourites_count'))}</span></div>
<IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'comment-o' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} />
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='heart' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
{shareButton} {shareButton}
<div className='status__action-bar-dropdown'> <div className='status__action-bar-dropdown'>

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

@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { isRtl } from '../rtl';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import Permalink from './permalink'; import Permalink from './permalink';
import classnames from 'classnames'; import classnames from 'classnames';
@ -186,17 +185,12 @@ export default class StatusContent extends React.PureComponent {
const content = { __html: status.get('contentHtml') }; const content = { __html: status.get('contentHtml') };
const spoilerContent = { __html: status.get('spoilerHtml') }; const spoilerContent = { __html: status.get('spoilerHtml') };
const directionStyle = { direction: 'ltr' };
const classNames = classnames('status__content', { const classNames = classnames('status__content', {
'status__content--with-action': this.props.onClick && this.context.router, 'status__content--with-action': this.props.onClick && this.context.router,
'status__content--with-spoiler': status.get('spoiler_text').length > 0, 'status__content--with-spoiler': status.get('spoiler_text').length > 0,
'status__content--collapsed': renderReadMore, 'status__content--collapsed': renderReadMore,
}); });
if (isRtl(status.get('search_index'))) {
directionStyle.direction = 'rtl';
}
const showThreadButton = ( const showThreadButton = (
<button className='status__content__read-more-button' onClick={this.props.onClick}> <button className='status__content__read-more-button' onClick={this.props.onClick}>
<FormattedMessage id='status.show_thread' defaultMessage='Show thread' /> <FormattedMessage id='status.show_thread' defaultMessage='Show thread' />
@ -225,7 +219,7 @@ export default class StatusContent extends React.PureComponent {
} }
return ( return (
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
<div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp}>
<p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}> <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}>
<span dangerouslySetInnerHTML={spoilerContent} /> <span dangerouslySetInnerHTML={spoilerContent} />
{' '} {' '}
@ -234,7 +228,7 @@ export default class StatusContent extends React.PureComponent {
{mentionsPlaceholder} {mentionsPlaceholder}
<div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} style={directionStyle} dangerouslySetInnerHTML={content} />
<div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''}`} dangerouslySetInnerHTML={content} />
{!hidden && !!status.get('poll') && <PollContainer pollId={status.get('poll')} />} {!hidden && !!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
@ -243,8 +237,8 @@ export default class StatusContent extends React.PureComponent {
); );
} else if (this.props.onClick) { } else if (this.props.onClick) {
const output = [ const output = [
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content'>
<div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} />
<div className={classNames} ref={this.setRef} tabIndex='0' onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content'>
<div className='status__content__text status__content__text--visible' dangerouslySetInnerHTML={content} />
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />} {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}
@ -259,8 +253,8 @@ export default class StatusContent extends React.PureComponent {
return output; return output;
} else { } else {
return ( return (
<div className={classNames} ref={this.setRef} tabIndex='0' style={directionStyle}>
<div className='status__content__text status__content__text--visible' style={directionStyle} dangerouslySetInnerHTML={content} />
<div className={classNames} ref={this.setRef} tabIndex='0'>
<div className='status__content__text status__content__text--visible' dangerouslySetInnerHTML={content} />
{!!status.get('poll') && <PollContainer pollId={status.get('poll')} />} {!!status.get('poll') && <PollContainer pollId={status.get('poll')} />}

+ 25
- 7
app/javascript/mastodon/containers/media_container.js View File

@ -2,7 +2,7 @@ import React, { PureComponent, Fragment } from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { IntlProvider, addLocaleData } from 'react-intl'; import { IntlProvider, addLocaleData } from 'react-intl';
import { List as ImmutableList, fromJS } from 'immutable';
import { fromJS } from 'immutable';
import { getLocale } from 'mastodon/locales'; import { getLocale } from 'mastodon/locales';
import { getScrollbarWidth } from 'mastodon/utils/scrollbar'; import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
import MediaGallery from 'mastodon/components/media_gallery'; import MediaGallery from 'mastodon/components/media_gallery';
@ -30,6 +30,8 @@ export default class MediaContainer extends PureComponent {
media: null, media: null,
index: null, index: null,
time: null, time: null,
backgroundColor: null,
options: null,
}; };
handleOpenMedia = (media, index) => { handleOpenMedia = (media, index) => {
@ -39,20 +41,32 @@ export default class MediaContainer extends PureComponent {
this.setState({ media, index }); this.setState({ media, index });
} }
handleOpenVideo = (video, time) => {
const media = ImmutableList([video]);
handleOpenVideo = (options) => {
const { components } = this.props;
const { media } = JSON.parse(components[options.componetIndex].getAttribute('data-props'));
const mediaList = fromJS(media);
document.body.classList.add('with-modals--active'); document.body.classList.add('with-modals--active');
document.documentElement.style.marginRight = `${getScrollbarWidth()}px`; document.documentElement.style.marginRight = `${getScrollbarWidth()}px`;
this.setState({ media, time });
this.setState({ media: mediaList, options });
} }
handleCloseMedia = () => { handleCloseMedia = () => {
document.body.classList.remove('with-modals--active'); document.body.classList.remove('with-modals--active');
document.documentElement.style.marginRight = 0; document.documentElement.style.marginRight = 0;
this.setState({ media: null, index: null, time: null });
this.setState({
media: null,
index: null,
time: null,
backgroundColor: null,
options: null,
});
}
setBackgroundColor = color => {
this.setState({ backgroundColor: color });
} }
render () { render () {
@ -73,6 +87,7 @@ export default class MediaContainer extends PureComponent {
...(hashtag ? { hashtag: fromJS(hashtag) } : {}), ...(hashtag ? { hashtag: fromJS(hashtag) } : {}),
...(componentName === 'Video' ? { ...(componentName === 'Video' ? {
componetIndex: i,
onOpenVideo: this.handleOpenVideo, onOpenVideo: this.handleOpenVideo,
} : { } : {
onOpenMedia: this.handleOpenMedia, onOpenMedia: this.handleOpenMedia,
@ -85,13 +100,16 @@ export default class MediaContainer extends PureComponent {
); );
})} })}
<ModalRoot onClose={this.handleCloseMedia}>
<ModalRoot backgroundColor={this.state.backgroundColor} onClose={this.handleCloseMedia}>
{this.state.media && ( {this.state.media && (
<MediaModal <MediaModal
media={this.state.media} media={this.state.media}
index={this.state.index || 0} index={this.state.index || 0}
time={this.state.time}
currentTime={this.state.options?.startTime}
autoPlay={this.state.options?.autoPlay}
volume={this.state.options?.defaultVolume}
onClose={this.handleCloseMedia} onClose={this.handleCloseMedia}
onChangeBackgroundColor={this.setBackgroundColor}
/> />
)} )}
</ModalRoot> </ModalRoot>

+ 13
- 6
app/javascript/mastodon/containers/status_container.js View File

@ -3,7 +3,7 @@ import { connect } from 'react-redux';
import Immutable from 'immutable'; import Immutable from 'immutable';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import Status from '../components/status'; import Status from '../components/status';
import { makeGetStatus } from '../selectors';
import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
import { import {
replyCompose, replyCompose,
mentionCompose, mentionCompose,
@ -39,6 +39,7 @@ import { initMuteModal } from '../actions/mutes';
import { initBlockModal } from '../actions/blocks'; import { initBlockModal } from '../actions/blocks';
import { initReport } from '../actions/reports'; import { initReport } from '../actions/reports';
import { openModal } from '../actions/modal'; import { openModal } from '../actions/modal';
import { deployPictureInPicture } from '../actions/picture_in_picture';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { boostModal, deleteModal, treeRoot } from '../initial_state'; import { boostModal, deleteModal, treeRoot } from '../initial_state';
import { showAlertForError } from '../actions/alerts'; import { showAlertForError } from '../actions/alerts';
@ -55,7 +56,8 @@ const messages = defineMessages({
const makeMapStateToProps = () => { const makeMapStateToProps = () => {
const getStatus = makeGetStatus(); const getStatus = makeGetStatus();
const getPictureInPicture = makeGetPictureInPicture();
const getAncestorsIds = createSelector([ const getAncestorsIds = createSelector([
(_, { id }) => id, (_, { id }) => id,
state => state.getIn(['contexts', 'inReplyTos']), state => state.getIn(['contexts', 'inReplyTos']),
@ -115,6 +117,7 @@ const makeMapStateToProps = () => {
status, status,
ancestorsText, ancestorsText,
sonsIds, sonsIds,
pictureInPicture: getPictureInPicture(state, props),
}; };
}; };
@ -206,12 +209,12 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(mentionCompose(account, router)); dispatch(mentionCompose(account, router));
}, },
onOpenMedia (media, index) {
dispatch(openModal('MEDIA', { media, index }));
onOpenMedia (statusId, media, index) {
dispatch(openModal('MEDIA', { statusId, media, index }));
}, },
onOpenVideo (media, options) {
dispatch(openModal('VIDEO', { media, options }));
onOpenVideo (statusId, media, options) {
dispatch(openModal('VIDEO', { statusId, media, options }));
}, },
onBlock (status) { onBlock (status) {
@ -267,6 +270,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(unblockDomain(domain)); dispatch(unblockDomain(domain));
}, },
deployPictureInPicture (status, type, mediaProps) {
dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps));
},
}); });
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));

+ 6
- 6
app/javascript/mastodon/features/account/components/header.js View File

@ -164,13 +164,17 @@ class Header extends ImmutablePureComponent {
info.push(<span key='domain_blocked' className='relationship-tag'><FormattedMessage id='account.domain_blocked' defaultMessage='Domain blocked' /></span>); info.push(<span key='domain_blocked' className='relationship-tag'><FormattedMessage id='account.domain_blocked' defaultMessage='Domain blocked' /></span>);
} }
if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) {
bellBtn = <IconButton icon='bell-o' size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
}
if (me !== account.get('id')) { if (me !== account.get('id')) {
if (!account.get('relationship')) { // Wait until the relationship is loaded if (!account.get('relationship')) { // Wait until the relationship is loaded
actionBtn = ''; actionBtn = '';
} else if (account.getIn(['relationship', 'requested'])) { } else if (account.getIn(['relationship', 'requested'])) {
actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />;
actionBtn = <Button className={classNames('logo-button', { 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />;
} else if (!account.getIn(['relationship', 'blocking'])) { } else if (!account.getIn(['relationship', 'blocking'])) {
actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />;
actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames('logo-button', { 'button--destructive': account.getIn(['relationship', 'following']), 'button--with-bell': bellBtn !== '' })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.props.onFollow} />;
} else if (account.getIn(['relationship', 'blocking'])) { } else if (account.getIn(['relationship', 'blocking'])) {
actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />; actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />;
} }
@ -178,10 +182,6 @@ class Header extends ImmutablePureComponent {
actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />; actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />;
} }
if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) {
bellBtn = <IconButton icon='bell-o' size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
}
if (account.get('moved') && !account.getIn(['relationship', 'following'])) { if (account.get('moved') && !account.getIn(['relationship', 'following'])) {
actionBtn = ''; actionBtn = '';
} }

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

@ -122,7 +122,7 @@ export default class MediaItem extends ImmutablePureComponent {
<div className='media-gallery__gifv'> <div className='media-gallery__gifv'>
{content} {content}
<span className='media-gallery__gifv__label'>{label}</span>
{label && <span className='media-gallery__gifv__label'>{label}</span>}
</div> </div>
); );
} }

+ 15
- 4
app/javascript/mastodon/features/account_gallery/index.js View File

@ -105,15 +105,18 @@ class AccountGallery extends ImmutablePureComponent {
} }
handleOpenMedia = attachment => { handleOpenMedia = attachment => {
const { dispatch } = this.props;
const statusId = attachment.getIn(['status', 'id']);
if (attachment.get('type') === 'video') { if (attachment.get('type') === 'video') {
this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status'), options: { autoPlay: true } }));
dispatch(openModal('VIDEO', { media: attachment, statusId, options: { autoPlay: true } }));
} else if (attachment.get('type') === 'audio') { } else if (attachment.get('type') === 'audio') {
this.props.dispatch(openModal('AUDIO', { media: attachment, status: attachment.get('status'), options: { autoPlay: true } }));
dispatch(openModal('AUDIO', { media: attachment, statusId, options: { autoPlay: true } }));
} else { } else {
const media = attachment.getIn(['status', 'media_attachments']); const media = attachment.getIn(['status', 'media_attachments']);
const index = media.findIndex(x => x.get('id') === attachment.get('id')); const index = media.findIndex(x => x.get('id') === attachment.get('id'));
this.props.dispatch(openModal('MEDIA', { media, index, status: attachment.get('status') }));
dispatch(openModal('MEDIA', { media, index, statusId }));
} }
} }
@ -149,6 +152,14 @@ class AccountGallery extends ImmutablePureComponent {
loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />; loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />;
} }
let emptyMessage;
if (suspended) {
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
} else if (blockedBy) {
emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
}
return ( return (
<Column> <Column>
<ColumnBackButton multiColumn={multiColumn} /> <ColumnBackButton multiColumn={multiColumn} />
@ -159,7 +170,7 @@ class AccountGallery extends ImmutablePureComponent {
{(suspended || blockedBy) ? ( {(suspended || blockedBy) ? (
<div className='empty-column-indicator'> <div className='empty-column-indicator'>
<FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />
{emptyMessage}
</div> </div>
) : ( ) : (
<div role='feed' className='account-gallery__container' ref={this.handleRef}> <div role='feed' className='account-gallery__container' ref={this.handleRef}>

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

@ -136,7 +136,9 @@ class AccountTimeline extends ImmutablePureComponent {
let emptyMessage; let emptyMessage;
if (suspended || blockedBy) {
if (suspended) {
emptyMessage = <FormattedMessage id='empty_column.account_suspended' defaultMessage='Account suspended' />;
} else if (blockedBy) {
emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />; emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
} else if (remote && statusIds.isEmpty()) { } else if (remote && statusIds.isEmpty()) {
emptyMessage = <RemoteHint url={remoteUrl} />; emptyMessage = <RemoteHint url={remoteUrl} />;

+ 101
- 11
app/javascript/mastodon/features/audio/index.js View File

@ -37,7 +37,11 @@ class Audio extends React.PureComponent {
backgroundColor: PropTypes.string, backgroundColor: PropTypes.string,
foregroundColor: PropTypes.string, foregroundColor: PropTypes.string,
accentColor: PropTypes.string, accentColor: PropTypes.string,
currentTime: PropTypes.number,
autoPlay: PropTypes.bool, autoPlay: PropTypes.bool,
volume: PropTypes.number,
muted: PropTypes.bool,
deployPictureInPicture: PropTypes.func,
}; };
state = { state = {
@ -64,6 +68,19 @@ class Audio extends React.PureComponent {
} }
} }
_pack() {
return {
src: this.props.src,
volume: this.audio.volume,
muted: this.audio.muted,
currentTime: this.audio.currentTime,
poster: this.props.poster,
backgroundColor: this.props.backgroundColor,
foregroundColor: this.props.foregroundColor,
accentColor: this.props.accentColor,
};
}
_setDimensions () { _setDimensions () {
const width = this.player.offsetWidth; const width = this.player.offsetWidth;
const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9)); const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
@ -112,6 +129,10 @@ class Audio extends React.PureComponent {
componentWillUnmount () { componentWillUnmount () {
window.removeEventListener('scroll', this.handleScroll); window.removeEventListener('scroll', this.handleScroll);
window.removeEventListener('resize', this.handleResize); window.removeEventListener('resize', this.handleResize);
if (!this.state.paused && this.audio && this.props.deployPictureInPicture) {
this.props.deployPictureInPicture('audio', this._pack());
}
} }
togglePlay = () => { togglePlay = () => {
@ -225,7 +246,7 @@ class Audio extends React.PureComponent {
handleTimeUpdate = () => { handleTimeUpdate = () => {
this.setState({ this.setState({
currentTime: this.audio.currentTime, currentTime: this.audio.currentTime,
duration: Math.floor(this.audio.duration),
duration: this.audio.duration,
}); });
} }
@ -248,7 +269,13 @@ class Audio extends React.PureComponent {
const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0); const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
if (!this.state.paused && !inView) { if (!this.state.paused && !inView) {
this.setState({ paused: true }, () => this.audio.pause());
this.audio.pause();
if (this.props.deployPictureInPicture) {
this.props.deployPictureInPicture('audio', this._pack());
}
this.setState({ paused: true });
} }
}, 150, { trailing: true }); }, 150, { trailing: true });
@ -261,10 +288,22 @@ class Audio extends React.PureComponent {
} }
handleLoadedData = () => { handleLoadedData = () => {
const { autoPlay } = this.props;
const { autoPlay, currentTime, volume, muted } = this.props;
if (currentTime) {
this.audio.currentTime = currentTime;
}
if (volume !== undefined) {
this.audio.volume = volume;
}
if (muted !== undefined) {
this.audio.muted = muted;
}
if (autoPlay) { if (autoPlay) {
this.audio.play();
this.togglePlay();
} }
} }
@ -347,13 +386,59 @@ class Audio extends React.PureComponent {
return this.props.foregroundColor || '#ffffff'; return this.props.foregroundColor || '#ffffff';
} }
seekBy (time) {
const currentTime = this.audio.currentTime + time;
if (!isNaN(currentTime)) {
this.setState({ currentTime }, () => {
this.audio.currentTime = currentTime;
});
}
}
handleAudioKeyDown = e => {
// On the audio element or the seek bar, we can safely use the space bar
// for playback control because there are no buttons to press
if (e.key === ' ') {
e.preventDefault();
e.stopPropagation();
this.togglePlay();
}
}
handleKeyDown = e => {
switch(e.key) {
case 'k':
e.preventDefault();
e.stopPropagation();
this.togglePlay();
break;
case 'm':
e.preventDefault();
e.stopPropagation();
this.toggleMute();
break;
case 'j':
e.preventDefault();
e.stopPropagation();
this.seekBy(-10);
break;
case 'l':
e.preventDefault();
e.stopPropagation();
this.seekBy(10);
break;
}
}
render () { render () {
const { src, intl, alt, editable, autoPlay } = this.props; const { src, intl, alt, editable, autoPlay } = this.props;
const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state; const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state;
const progress = (currentTime / duration) * 100;
const progress = Math.min((currentTime / duration) * 100, 100);
return ( return (
<div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex='0' onKeyDown={this.handleKeyDown}>
<audio <audio
src={src} src={src}
ref={this.setAudioRef} ref={this.setAudioRef}
@ -367,12 +452,14 @@ class Audio extends React.PureComponent {
<canvas <canvas
role='button' role='button'
tabIndex='0'
className='audio-player__canvas' className='audio-player__canvas'
width={this.state.width} width={this.state.width}
height={this.state.height} height={this.state.height}
style={{ width: '100%', position: 'absolute', top: 0, left: 0 }} style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
ref={this.setCanvasRef} ref={this.setCanvasRef}
onClick={this.togglePlay} onClick={this.togglePlay}
onKeyDown={this.handleAudioKeyDown}
title={alt} title={alt}
aria-label={alt} aria-label={alt}
/> />
@ -393,20 +480,21 @@ class Audio extends React.PureComponent {
className={classNames('video-player__seek__handle', { active: dragging })} className={classNames('video-player__seek__handle', { active: dragging })}
tabIndex='0' tabIndex='0'
style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }} style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }}
onKeyDown={this.handleAudioKeyDown}
/> />
</div> </div>
<div className='video-player__controls active'> <div className='video-player__controls active'>
<div className='video-player__buttons-bar'> <div className='video-player__buttons-bar'>
<div className='video-player__buttons left'> <div className='video-player__buttons left'>
<button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
<button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
<button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} className='player-button' onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
<button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
<div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}> <div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}>
<div className='video-player__volume__current' style={{ width: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} /> <div className='video-player__volume__current' style={{ width: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} />
<span <span
className={classNames('video-player__volume__handle')}
className='video-player__volume__handle'
tabIndex='0' tabIndex='0'
style={{ left: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} style={{ left: `${volume * 100}%`, backgroundColor: this._getAccentColor() }}
/> />
@ -415,12 +503,14 @@ class Audio extends React.PureComponent {
<span className='video-player__time'> <span className='video-player__time'>
<span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span> <span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
<span className='video-player__time-sep'>/</span> <span className='video-player__time-sep'>/</span>
<span className='video-player__time-total'>{formatTime(this.state.duration || Math.floor(this.props.duration))}</span>
<span className='video-player__time-total'>{formatTime(Math.floor(this.state.duration || this.props.duration))}</span>
</span> </span>
</div> </div>
<div className='video-player__buttons right'> <div className='video-player__buttons right'>
<button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} onClick={this.handleDownload}><Icon id='download' fixedWidth /></button>
<a title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} className='video-player__download__icon player-button' href={this.props.src} download>
<Icon id={'download'} fixedWidth />
</a>
</div> </div>
</div> </div>
</div> </div>

+ 16
- 10
app/javascript/mastodon/features/compose/components/compose_form.js View File

@ -77,6 +77,18 @@ class ComposeForm extends ImmutablePureComponent {
} }
} }
getFulltextForCharacterCounting = () => {
return [this.props.spoiler? this.props.spoilerText: '', countableText(this.props.text)].join('');
}
canSubmit = () => {
const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props;
const fulltext = this.getFulltextForCharacterCounting();
const isOnlyWhitespace = fulltext.length !== 0 && fulltext.trim().length === 0;
return !(isSubmitting || isUploading || isChangingUpload || length(fulltext) > 5000 || (isOnlyWhitespace && !anyMedia));
}
handleSubmit = () => { handleSubmit = () => {
if (this.props.text !== this.autosuggestTextarea.textarea.value) { if (this.props.text !== this.autosuggestTextarea.textarea.value) {
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly) // Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
@ -84,11 +96,7 @@ class ComposeForm extends ImmutablePureComponent {
this.props.onChange(this.autosuggestTextarea.textarea.value); this.props.onChange(this.autosuggestTextarea.textarea.value);
} }
// Submit disabled:
const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props;
const fulltext = [this.props.spoilerText, countableText(this.props.text)].join('');
if (isSubmitting || isUploading || isChangingUpload || length(fulltext) > 5000 || (fulltext.length !== 0 && fulltext.trim().length === 0 && !anyMedia)) {
if (!this.canSubmit()) {
return; return;
} }
@ -178,10 +186,8 @@ class ComposeForm extends ImmutablePureComponent {
} }
render () { render () {
const { intl, onPaste, showSearch, anyMedia } = this.props;
const { intl, onPaste, showSearch } = this.props;
const disabled = this.props.isSubmitting; const disabled = this.props.isSubmitting;
const text = [this.props.spoilerText, countableText(this.props.text)].join('');
const disabledButton = disabled || this.props.isUploading || this.props.isChangingUpload || length(text) > 5000 || (text.length !== 0 && text.trim().length === 0 && !anyMedia);
let publishText = ''; let publishText = '';
if (this.props.privacy === 'private' || this.props.privacy === 'direct') { if (this.props.privacy === 'private' || this.props.privacy === 'direct') {
@ -243,11 +249,11 @@ class ComposeForm extends ImmutablePureComponent {
<PrivacyDropdownContainer /> <PrivacyDropdownContainer />
<SpoilerButtonContainer /> <SpoilerButtonContainer />
</div> </div>
<div className='character-counter__wrapper'><CharacterCounter max={5000} text={text} /></div>
<div className='character-counter__wrapper'><CharacterCounter max={5000} text={this.getFulltextForCharacterCounting()} /></div>
</div> </div>
<div className='compose-form__publish'> <div className='compose-form__publish'>
<div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={disabledButton} block /></div>
<div className='compose-form__publish-button-wrapper'><Button text={publishText} onClick={this.handleSubmit} disabled={!this.canSubmit()} block /></div>
</div> </div>
</div> </div>
); );

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

@ -5,8 +5,9 @@ import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components'
import Overlay from 'react-overlays/lib/Overlay'; import Overlay from 'react-overlays/lib/Overlay';
import classNames from 'classnames'; import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import detectPassiveEvents from 'detect-passive-events';
import { supportsPassiveEvents } from 'detect-passive-events';
import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji'; import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
import { assetHost } from 'mastodon/utils/config';
const messages = defineMessages({ const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' }, emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
@ -25,11 +26,10 @@ const messages = defineMessages({
flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' }, flags: { id: 'emoji_button.flags', defaultMessage: 'Flags' },
}); });
const assetHost = process.env.CDN_HOST || '';
let EmojiPicker, Emoji; // load asynchronously let EmojiPicker, Emoji; // load asynchronously
const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`; const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`;
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
class ModifierPickerMenu extends React.PureComponent { class ModifierPickerMenu extends React.PureComponent {

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

@ -5,7 +5,7 @@ import IconButton from '../../../components/icon_button';
import Overlay from 'react-overlays/lib/Overlay'; import Overlay from 'react-overlays/lib/Overlay';
import Motion from '../../ui/util/optional_motion'; import Motion from '../../ui/util/optional_motion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import detectPassiveEvents from 'detect-passive-events';
import { supportsPassiveEvents } from 'detect-passive-events';
import classNames from 'classnames'; import classNames from 'classnames';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
@ -21,7 +21,7 @@ const messages = defineMessages({
change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' }, change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
}); });
const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
class PrivacyDropdownMenu extends React.PureComponent { class PrivacyDropdownMenu extends React.PureComponent {

+ 1
- 5
app/javascript/mastodon/features/compose/components/reply_indicator.js View File

@ -6,7 +6,6 @@ import IconButton from '../../../components/icon_button';
import DisplayName from '../../../components/display_name'; import DisplayName from '../../../components/display_name';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { isRtl } from '../../../rtl';
import AttachmentList from 'mastodon/components/attachment_list'; import AttachmentList from 'mastodon/components/attachment_list';
const messages = defineMessages({ const messages = defineMessages({
@ -45,9 +44,6 @@ class ReplyIndicator extends ImmutablePureComponent {
} }
const content = { __html: status.get('contentHtml') }; const content = { __html: status.get('contentHtml') };
const style = {
direction: isRtl(status.get('search_index')) ? 'rtl' : 'ltr',
};
return ( return (
<div className='reply-indicator'> <div className='reply-indicator'>
@ -60,7 +56,7 @@ class ReplyIndicator extends ImmutablePureComponent {
</a> </a>
</div> </div>
<div className='reply-indicator__content' style={style} dangerouslySetInnerHTML={content} />
<div className='reply-indicator__content' dangerouslySetInnerHTML={content} />
{status.get('media_attachments').size > 0 && ( {status.get('media_attachments').size > 0 && (
<AttachmentList <AttachmentList

+ 17
- 5
app/javascript/mastodon/features/compose/containers/sensitive_button_container.js View File

@ -6,13 +6,20 @@ import { changeComposeSensitivity } from 'mastodon/actions/compose';
import { injectIntl, defineMessages, FormattedMessage } from 'react-intl'; import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages({
marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' },
unmarked: { id: 'compose_form.sensitive.unmarked', defaultMessage: 'Media is not marked as sensitive' },
marked: {
id: 'compose_form.sensitive.marked',
defaultMessage: '{count, plural, one {Media is marked as sensitive} other {Media is marked as sensitive}}',
},
unmarked: {
id: 'compose_form.sensitive.unmarked',
defaultMessage: '{count, plural, one {Media is not marked as sensitive} other {Media is not marked as sensitive}}',
},
}); });
const mapStateToProps = state => ({ const mapStateToProps = state => ({
active: state.getIn(['compose', 'sensitive']), active: state.getIn(['compose', 'sensitive']),
disabled: state.getIn(['compose', 'spoiler']), disabled: state.getIn(['compose', 'spoiler']),
mediaCount: state.getIn(['compose', 'media_attachments']).size,
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({
@ -28,16 +35,17 @@ class SensitiveButton extends React.PureComponent {
static propTypes = { static propTypes = {
active: PropTypes.bool, active: PropTypes.bool,
disabled: PropTypes.bool, disabled: PropTypes.bool,
mediaCount: PropTypes.number,
onClick: PropTypes.func.isRequired, onClick: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
}; };
render () { render () {
const { active, disabled, onClick, intl } = this.props;
const { active, disabled, mediaCount, onClick, intl } = this.props;
return ( return (
<div className='compose-form__sensitive-button'> <div className='compose-form__sensitive-button'>
<label className={classNames('icon-button', { active })} title={intl.formatMessage(active ? messages.marked : messages.unmarked)}>
<label className={classNames('icon-button', { active })} title={intl.formatMessage(active ? messages.marked : messages.unmarked, { count: mediaCount })}>
<input <input
name='mark-sensitive' name='mark-sensitive'
type='checkbox' type='checkbox'
@ -48,7 +56,11 @@ class SensitiveButton extends React.PureComponent {
<span className={classNames('checkbox', { active })} /> <span className={classNames('checkbox', { active })} />
<FormattedMessage id='compose_form.sensitive.hide' defaultMessage='Mark media as sensitive' />
<FormattedMessage
id='compose_form.sensitive.hide'
defaultMessage='{count, plural, one {Mark media as sensitive} other {Mark media as sensitive}}'
values={{ count: mediaCount }}
/>
</label> </label>
</div> </div>
); );

+ 1
- 2
app/javascript/mastodon/features/emoji/emoji.js View File

@ -1,11 +1,10 @@
import { autoPlayGif } from '../../initial_state'; import { autoPlayGif } from '../../initial_state';
import unicodeMapping from './emoji_unicode_mapping_light'; import unicodeMapping from './emoji_unicode_mapping_light';
import { assetHost } from 'mastodon/utils/config';
import Trie from 'substring-trie'; import Trie from 'substring-trie';
const trie = new Trie(Object.keys(unicodeMapping)); const trie = new Trie(Object.keys(unicodeMapping));
const assetHost = process.env.CDN_HOST || '';
// Convert to file names from emojis. (For different variation selector emojis) // Convert to file names from emojis. (For different variation selector emojis)
const emojiFilenames = (emojis) => { const emojiFilenames = (emojis) => {
return emojis.map(v => unicodeMapping[v].filename); return emojis.map(v => unicodeMapping[v].filename);

+ 5
- 5
app/javascript/mastodon/features/getting_started/components/announcements.js View File

@ -6,7 +6,7 @@ import PropTypes from 'prop-types';
import IconButton from 'mastodon/components/icon_button'; import IconButton from 'mastodon/components/icon_button';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage, FormattedDate } from 'react-intl';
import { autoPlayGif, reduceMotion } from 'mastodon/initial_state';
import { autoPlayGif, reduceMotion, disableSwiping } from 'mastodon/initial_state';
import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg'; import elephantUIPlane from 'mastodon/../images/elephant_ui_plane.svg';
import { mascot } from 'mastodon/initial_state'; import { mascot } from 'mastodon/initial_state';
import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light'; import unicodeMapping from 'mastodon/features/emoji/emoji_unicode_mapping_light';
@ -15,6 +15,7 @@ import EmojiPickerDropdown from 'mastodon/features/compose/containers/emoji_pick
import AnimatedNumber from 'mastodon/components/animated_number'; import AnimatedNumber from 'mastodon/components/animated_number';
import TransitionMotion from 'react-motion/lib/TransitionMotion'; import TransitionMotion from 'react-motion/lib/TransitionMotion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import { assetHost } from 'mastodon/utils/config';
const messages = defineMessages({ const messages = defineMessages({
close: { id: 'lightbox.close', defaultMessage: 'Close' }, close: { id: 'lightbox.close', defaultMessage: 'Close' },
@ -153,8 +154,6 @@ class Content extends ImmutablePureComponent {
} }
const assetHost = process.env.CDN_HOST || '';
class Emoji extends React.PureComponent { class Emoji extends React.PureComponent {
static propTypes = { static propTypes = {
@ -397,7 +396,7 @@ class Announcements extends ImmutablePureComponent {
_markAnnouncementAsRead () { _markAnnouncementAsRead () {
const { dismissAnnouncement, announcements } = this.props; const { dismissAnnouncement, announcements } = this.props;
const { index } = this.state; const { index } = this.state;
const announcement = announcements.get(index);
const announcement = announcements.get(announcements.size - 1 - index);
if (!announcement.get('read')) dismissAnnouncement(announcement.get('id')); if (!announcement.get('read')) dismissAnnouncement(announcement.get('id'));
} }
@ -436,8 +435,9 @@ class Announcements extends ImmutablePureComponent {
removeReaction={this.props.removeReaction} removeReaction={this.props.removeReaction}
intl={intl} intl={intl}
selected={index === idx} selected={index === idx}
disabled={disableSwiping}
/> />
))}
)).reverse()}
</ReactSwipeableViews> </ReactSwipeableViews>
{announcements.size > 1 && ( {announcements.size > 1 && (

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

@ -10,7 +10,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { me, profile_directory, showTrends } from '../../initial_state'; import { me, profile_directory, showTrends } from '../../initial_state';
import { fetchFollowRequests } from 'mastodon/actions/accounts'; import { fetchFollowRequests } from 'mastodon/actions/accounts';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import NavigationBar from '../compose/components/navigation_bar';
import NavigationContainer from '../compose/containers/navigation_container';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import LinkFooter from 'mastodon/features/ui/components/link_footer'; import LinkFooter from 'mastodon/features/ui/components/link_footer';
import TrendsContainer from './containers/trends_container'; import TrendsContainer from './containers/trends_container';
@ -168,7 +168,7 @@ class GettingStarted extends ImmutablePureComponent {
<div className='getting-started'> <div className='getting-started'>
<div className='getting-started__wrapper' style={{ height }}> <div className='getting-started__wrapper' style={{ height }}>
{!multiColumn && <NavigationBar account={myAccount} />}
{!multiColumn && <NavigationContainer />}
{navItems} {navItems}
</div> </div>

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save